Compare commits
42
Commits
a31fc02ef6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b010762a0b | ||
|
|
2dab720e55 | ||
|
|
67f13dbc93 | ||
|
|
a7f0097a11 | ||
|
|
644aa21426 | ||
|
|
6ebd26b60e | ||
|
|
ac04c674a6 | ||
|
|
30918f33e7 | ||
|
|
8d72defb82 | ||
|
|
562ab5191f | ||
|
|
796a883d70 | ||
|
|
6e65d84a1c | ||
|
|
8e64c0c963 | ||
|
|
4005ce0631 | ||
|
|
cf4480743e | ||
|
|
3a34790487 | ||
|
|
2402e2e7fd | ||
|
|
a57f5f0951 | ||
|
|
c9e53193b9 | ||
|
|
e9695c0239 | ||
|
|
b5d17d8944 | ||
|
|
eff63e0024 | ||
|
|
76f1834712 | ||
|
|
2592c92f66 | ||
|
|
aad8e2600f | ||
|
|
7defbfcc2e | ||
|
|
22ffb31a66 | ||
|
|
0ee5a3521a | ||
|
|
0810ad4166 | ||
|
|
2974105e25 | ||
|
|
aaf9354b7f | ||
|
|
9dd8e772a9 | ||
|
|
ec0191f84a | ||
|
|
360f203ca2 | ||
|
|
c639f43891 | ||
|
|
3571c99416 | ||
|
|
0ca3e9d4ca | ||
|
|
0943cdf5f3 | ||
|
|
19b791493f | ||
|
|
b4cb62b490 | ||
|
|
59fc78332d | ||
|
|
5c616a9d03 |
@@ -11,6 +11,9 @@ APP_PASSWORD=change-me
|
|||||||
SESSION_TTL_HOURS=720
|
SESSION_TTL_HOURS=720
|
||||||
# Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares)
|
# Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares)
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
# Postgres fallback (typically a docker container). Empty = local SQLite.
|
||||||
|
# Example: POSTGRESQL_URL=postgres://kisassistant:secret@localhost:5432/kisassistant
|
||||||
|
POSTGRESQL_URL=
|
||||||
# Local file CDN (avatars). NixOS module points it under the state dir.
|
# Local file CDN (avatars). NixOS module points it under the state dir.
|
||||||
CDN_DIR=./cdn
|
CDN_DIR=./cdn
|
||||||
# Local file CDN (avatars today, more namespaces tomorrow).
|
# Local file CDN (avatars today, more namespaces tomorrow).
|
||||||
@@ -22,6 +25,21 @@ CDN_DIR=./cdn
|
|||||||
OCR_LANG=eng
|
OCR_LANG=eng
|
||||||
OCR_MAX_CHARS=100000
|
OCR_MAX_CHARS=100000
|
||||||
|
|
||||||
|
# --- platform tools: neural TTS (first configured wins, TTS_PROVIDER forces one) ---
|
||||||
|
# ElevenLabs (most natural): https://elevenlabs.io/app/settings/api-keys
|
||||||
|
#ELEVENLABS_API_KEY=
|
||||||
|
#ELEVENLABS_VOICE=
|
||||||
|
# OpenAI (reuses the LLM key when set, tts-1 + alloy/echo/fable/onyx/nova/shimmer)
|
||||||
|
#OPENAI_API_KEY=
|
||||||
|
#TTS_VOICE=alloy
|
||||||
|
# Local piper (offline, like tesseract): PIPER_BIN + onnx model
|
||||||
|
#PIPER_BIN=piper
|
||||||
|
#PIPER_MODEL=/var/lib/kisassistant/piper/en_US-lessac-medium.onnx
|
||||||
|
# Test/development overrides (base URLs, default = api.elevenlabs.io / api.openai.com)
|
||||||
|
#ELEVENLABS_BASE_URL=
|
||||||
|
#OPENAI_BASE_URL=
|
||||||
|
#TTS_PROVIDER=auto
|
||||||
|
|
||||||
# Per-driver debug logging to stdout ([driver:name] lines).
|
# Per-driver debug logging to stdout ([driver:name] lines).
|
||||||
DRIVER_DEBUG=true
|
DRIVER_DEBUG=true
|
||||||
|
|
||||||
@@ -60,6 +78,9 @@ ANTHROPIC_API_KEY=
|
|||||||
ANTHROPIC_ENABLED=false
|
ANTHROPIC_ENABLED=false
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_ENABLED=false
|
OPENAI_ENABLED=false
|
||||||
|
# Gemini (Google AI, OpenAI-compatible endpoint). Key from https://aistudio.google.com/apikey
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
GEMINI_ENABLED=false
|
||||||
# Arcaic backend (headless browser, throwaway profile per process).
|
# Arcaic backend (headless browser, throwaway profile per process).
|
||||||
# Every process launch creates a throwaway Firefox profile (deleted on exit).
|
# Every process launch creates a throwaway Firefox profile (deleted on exit).
|
||||||
ARCAIC_ENABLED=false
|
ARCAIC_ENABLED=false
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Read this before touching the code. Docs live in `docs/` (English).
|
|||||||
|
|
||||||
KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
|
KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
|
||||||
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
|
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
|
||||||
frontend, SQLite persistence via Bun's native `bun:sqlite` driver.
|
frontend, SQLite persistence by default via Bun's native `bun:sqlite` driver
|
||||||
|
(Postgres fallback via native `bun:sql` when `POSTGRESQL_URL` is set).
|
||||||
|
|
||||||
## Commands (run from repo root unless noted)
|
## Commands (run from repo root unless noted)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ flake.nix/module.nix NixOS packaging + service module
|
|||||||
- Frontend styling: Tailwind only, emerald accent, `rounded-2xl/3xl` everywhere, `dark:` variants on every surface. Inter Variable font (self-hosted, no CDN).
|
- 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).
|
- 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).
|
- 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).
|
- DB: async store fns through the `DbClient` interface (`src/db/client.ts`) — SQLite default (`bun:sqlite`, `PRAGMA journal_mode=WAL`), Postgres when `POSTGRESQL_URL` is set (Bun native `bun:sql`). Write queries once with `?` placeholders (auto-rewritten to `$n` for pg), keep SQL portable (no bare `GROUP BY pk` — pg rejects it; quote `"window"`). Schema is created idempotently at boot — keep it that way (no migration framework).
|
||||||
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels.
|
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels.
|
||||||
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
|
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded,
|
Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded,
|
||||||
Signal-style, Inter font, light/dark/auto themes), Bun backend with
|
Signal-style, Inter font, light/dark/auto themes), Bun backend with
|
||||||
class-oriented LLM **drivers** and a **SQLite** database (Bun native driver) for
|
class-oriented LLM **drivers** and a **SQLite/Postgres** database (Bun native drivers) for
|
||||||
accounts, server-side chats and public shares. No cookies — Bearer keys in
|
accounts, server-side chats and public shares. No cookies — Bearer keys in
|
||||||
`localStorage`.
|
`localStorage`.
|
||||||
|
|
||||||
@@ -58,15 +58,24 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
|||||||
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
|
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
|
||||||
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
|
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
|
||||||
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
|
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
|
||||||
|
| POST | `/api/admin/users/:id/reset-link` | admin | email a one-time password-reset link (needs user email + SMTP) |
|
||||||
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
||||||
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
||||||
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
||||||
| POST | `/api/admin/ollama/pull` | admin | pull a model from the Ollama library (NDJSON progress) |
|
| POST | `/api/admin/ollama/pull` | admin | pull a model (library tag or `hf.co/…`, `{name, nodeId?}`, NDJSON progress) |
|
||||||
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model |
|
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model (`?node=` for a pool node) |
|
||||||
|
| GET/POST | `/api/admin/ollama/nodes` | admin | Ollama pool: list (+effective order) / register a host |
|
||||||
|
| PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node |
|
||||||
|
| GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d |
|
||||||
|
| GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it |
|
||||||
|
| GET | `/api/admin/platform` | admin | this host facts: hostname, kernel, uptime, load, memory, IPs |
|
||||||
|
| GET | `/api/tools/tts/info` | Bearer | neural TTS provider + voices (null = browser fallback) |
|
||||||
|
| POST | `/api/tools/tts` | Bearer | synthesize `{text, voice?}` → audio bytes (cached, 30/hour) |
|
||||||
|
| GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models |
|
||||||
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
|
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
|
||||||
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
||||||
| WS | `/api/access/ws/:id` | ticket bearer | live ticket messages + status (ping/pong heartbeat) |
|
| WS | `/api/access/ws/:id` | ticket bearer | live ticket messages + status (ping/pong heartbeat) |
|
||||||
| WS | `/api/admin/ws?token=…` | admin | firehose of all access events (badge + triage list) |
|
| WS | `/api/admin/ws?token=…` | admin | firehose of all access + report events (badges + triage lists, full payloads, no polling) |
|
||||||
| GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply |
|
| GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply |
|
||||||
| POST | `/api/reports` | Bearer | flag an AI response (copyright/gore/falseinfo/bug) → `{report}` (201) |
|
| POST | `/api/reports` | Bearer | flag an AI response (copyright/gore/falseinfo/bug) → `{report}` (201) |
|
||||||
| GET/PATCH | `/api/admin/reports`, `/api/admin/reports/:id` | admin | report triage: list, reviewing/resolved/dismissed + note |
|
| GET/PATCH | `/api/admin/reports`, `/api/admin/reports/:id` | admin | report triage: list, reviewing/resolved/dismissed + note |
|
||||||
@@ -80,18 +89,22 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
|||||||
| GET | `/api/conversations/archived` | Bearer | archived chats (hidden from sidebar + search) |
|
| GET | `/api/conversations/archived` | Bearer | archived chats (hidden from sidebar + search) |
|
||||||
| POST | `/api/conversations/:id/archive`, `/api/conversations/:id/unarchive` | owner | archive (read-only) / restore to the list |
|
| POST | `/api/conversations/:id/archive`, `/api/conversations/:id/unarchive` | owner | archive (read-only) / restore to the list |
|
||||||
| GET | `/api/share/:publicId` | **public** | read-only shared chat JSON |
|
| GET | `/api/share/:publicId` | **public** | read-only shared chat JSON |
|
||||||
| GET | `/api/models` | Bearer | `[{id: "driver:model", …}]` across enabled drivers |
|
| GET | `/api/models` | Bearer | `[{id: "driver:model", …}]` across enabled drivers + your own-key providers |
|
||||||
|
| GET/PUT/DELETE | `/api/me/providers`, `/api/me/providers/:id` | Bearer | personal providers (BYOK: openai/anthropic/deepseek/gemini) — keys stored server-side, never returned |
|
||||||
|
| GET/PATCH | `/api/admin/model-policy` | admin | per-model kill-switch + hourly/daily limits (users filtered, enforced in chat) |
|
||||||
| POST | `/api/chat` | Bearer | `{model, messages, conversationId?, stream?}` → JSON or SSE |
|
| POST | `/api/chat` | Bearer | `{model, messages, conversationId?, stream?}` → JSON or SSE |
|
||||||
|
|
||||||
`POST /api/chat` with `conversationId` persists the user message + assistant
|
`POST /api/chat` with `conversationId` persists the user message + assistant
|
||||||
reply into that conversation (ownership enforced) and auto-titles new chats.
|
reply into that conversation (ownership enforced) and auto-titles new chats.
|
||||||
|
|
||||||
## Database (SQLite, Bun native)
|
## Database (SQLite default, Postgres fallback — both Bun native)
|
||||||
|
|
||||||
`$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username,
|
`$DATA_DIR/kisassistant.db` (WAL mode), or Postgres when `POSTGRESQL_URL` is
|
||||||
|
set (e.g. a docker container — see `docs/OPERATIONS.md`). Tables: `users` (id, username,
|
||||||
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
|
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
|
||||||
`conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`,
|
`conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`,
|
||||||
`resets` (recovery tokens), `settings` (feature flags)
|
`resets` (recovery tokens), `settings` (feature flags),
|
||||||
|
`user_provider_keys` (per-user BYOK API keys: user_id, provider, api_key)
|
||||||
(conv ↔ public id). No migration system — schema is created idempotently at boot.
|
(conv ↔ public id). No migration system — schema is created idempotently at boot.
|
||||||
|
|
||||||
## Drivers (backend layout)
|
## Drivers (backend layout)
|
||||||
@@ -100,11 +113,12 @@ display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tok
|
|||||||
src/drivers/types.ts LLMDriver interface + DriverModel + errors
|
src/drivers/types.ts LLMDriver interface + DriverModel + errors
|
||||||
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
|
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
|
||||||
src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class
|
src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class
|
||||||
src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI drivers
|
src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI / Gemini drivers (global key or per-user override)
|
||||||
src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini) — throwaway Firefox profile
|
src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini) — throwaway Firefox profile
|
||||||
src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
|
src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
|
||||||
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
|
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
|
||||||
src/db.ts SQLite store (users, sessions, convs, messages, shares)
|
src/userProviders.ts personal providers (BYOK): per-user keys, own-key driver factory
|
||||||
|
src/db.ts async store (users, sessions, convs, messages, shares) — SQLite/Postgres via DbClient
|
||||||
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
|
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,4 +158,5 @@ Nginx terminates TLS; the app listens on `127.0.0.1:3000` with
|
|||||||
Inter Variable (self-hosted, no external requests). `bun run --cwd frontend build`
|
Inter Variable (self-hosted, no external requests). `bun run --cwd frontend build`
|
||||||
outputs to `public/` (wiped first). Collapsible sidebar, right-click context menu
|
outputs to `public/` (wiped first). Collapsible sidebar, right-click context menu
|
||||||
(rename / topic / share / delete), share modals, confirm dialogs, admin accounts
|
(rename / topic / share / delete), share modals, confirm dialogs, admin accounts
|
||||||
panel, profile settings, public read-only `/share/:id` page. English only.
|
panel, profile settings, public read-only `/share/:id` page, per-conversation
|
||||||
|
URLs (`/chat/:id`, deep-linkable). English only.
|
||||||
|
|||||||
+40
-8
@@ -1,16 +1,27 @@
|
|||||||
# Architecture (backend)
|
# Architecture (backend)
|
||||||
|
|
||||||
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
|
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
|
frontend (same origin, no CORS). Persistence is SQLite by default via Bun's
|
||||||
`bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode).
|
native `bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode), or
|
||||||
|
Postgres when `POSTGRESQL_URL` is set (Bun's native `bun:sql` driver,
|
||||||
|
typically a docker container — see `docs/OPERATIONS.md`).
|
||||||
|
|
||||||
```
|
```
|
||||||
browser ──► Bun.serve (src/index.ts)
|
browser ──► Bun.serve (src/index.ts)
|
||||||
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► kisassistant.db
|
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► sqlite file | postgres
|
||||||
│ └─────► DriverRegistry ──► ollama / APIs
|
│ └─────► DriverRegistry ──► ollama / APIs
|
||||||
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id)
|
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id and /chat/:id)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All store functions (`db.ts`, `access.ts`, `reports.ts`, `totp.ts`,
|
||||||
|
`modelPolicy.ts`, `userProviders.ts`) are `async` and talk through the
|
||||||
|
`DbClient` interface (`src/db/client.ts`): `get` / `all` / `run` / `exec` /
|
||||||
|
`insertReturningId` / `transaction`. Queries are written once with `?`
|
||||||
|
placeholders and translated to `$1, $2, …` for Postgres (quote-aware);
|
||||||
|
Postgres `BIGINT` cells arrive as `bigint` and are normalized to `number`.
|
||||||
|
One portable-SQL rule this forces: no bare `GROUP BY pk` with unaggregated
|
||||||
|
columns (rejected by Postgres) — use correlated subqueries instead.
|
||||||
|
|
||||||
## Request flow: chat
|
## Request flow: chat
|
||||||
|
|
||||||
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
|
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
|
||||||
@@ -40,7 +51,10 @@ Streaming guarantees (ChatGPT-style, no buffering):
|
|||||||
|
|
||||||
## Database schema
|
## Database schema
|
||||||
|
|
||||||
Created idempotently at boot in `getDb()` (no migration framework; keep it so).
|
Created idempotently at boot in `initDb()` (no migration framework; keep it
|
||||||
|
so). On Postgres, timestamps are `BIGINT`, auto ids `BIGSERIAL`, later
|
||||||
|
columns arrive via `ADD COLUMN IF NOT EXISTS`, and `"window"` (model_usage)
|
||||||
|
is quoted — `WINDOW` is reserved on Postgres.
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -49,6 +63,7 @@ Created idempotently at boot in `getDb()` (no migration framework; keep it so).
|
|||||||
| `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). |
|
| `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). |
|
||||||
| `settings` | `key PK, value, updated_at`. Admin-controlled feature flags (`registration_enabled` default off, `tools_ocr_enabled` default on). |
|
| `settings` | `key PK, value, updated_at`. Admin-controlled feature flags (`registration_enabled` default off, `tools_ocr_enabled` default on). |
|
||||||
| `conversations` | `id, user_id, title, topic, model, archived_at, created_at, updated_at`. `archived_at = 0` means live; otherwise a unix-ms archive timestamp (added idempotently via `ALTER TABLE`, like `users.email`). |
|
| `conversations` | `id, user_id, title, topic, model, archived_at, created_at, updated_at`. `archived_at = 0` means live; otherwise a unix-ms archive timestamp (added idempotently via `ALTER TABLE`, like `users.email`). |
|
||||||
|
| `user_provider_keys` | `user_id, provider PK, api_key, updated_at`. Personal LLM keys (BYOK) — stored reversibly (needed upstream), never returned to clients (GET exposes presence + last4 only). Wiped with the account in `deleteUser()`. |
|
||||||
| `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. |
|
| `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`. |
|
| `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. |
|
||||||
|
|
||||||
@@ -93,17 +108,24 @@ exists: key = account id (`/cdn/avatar/<id>.png`), ownership enforced.
|
|||||||
tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes
|
tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes
|
||||||
raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns
|
raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns
|
||||||
`{ text, truncated, chars }` — transcription happens server-side with
|
`{ text, truncated, chars }` — transcription happens server-side with
|
||||||
per-driver-style stdout logging; a missing binary yields a clean 501. Text
|
per-driver-style stdout logging; a missing binary yields a clean 501.
|
||||||
|
`sanitizeOcrText` strips engine noise (control/format/private-use code
|
||||||
|
points, U+FFFD markers, form-feeds) while keeping legitimate text
|
||||||
|
(accents, CJK, emoji). Text
|
||||||
attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model
|
attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model
|
||||||
only ever sees reviewed text blocks, never raw files.
|
only ever sees reviewed text blocks, never raw files.
|
||||||
|
|
||||||
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
||||||
`DriverRegistry` with global priority
|
`DriverRegistry` with global priority
|
||||||
`ollama → mistral → glm → arcaic-openai → arcaic-gemini → arcaic-qwen → deepseek → anthropic → openai`.
|
`ollama → mistral → glm → gemini → arcaic-openai → arcaic-gemini → arcaic-qwen → deepseek → anthropic → openai`.
|
||||||
Models are addressed as `"driver:model"` (split on the first `:`).
|
Models are addressed as `"driver:model"` (split on the first `:`).
|
||||||
|
|
||||||
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||||
`POST /api/chat` with NDJSON streaming.
|
`POST /api/chat` with NDJSON streaming. Multi-node pool (`src/ollamaNodes.ts`:
|
||||||
|
`ollama_nodes` table + `effectiveOllamaNodes()`): each generation picks a
|
||||||
|
node by weighted random and fails over pre-stream on error/timeout
|
||||||
|
(`ollama_timeout_s` setting); per-attempt telemetry (ok, latency, token
|
||||||
|
counts) lands in `ollama_stats` for the admin charts.
|
||||||
- `GET /api/models` is served from an in-memory cache in `DriverRegistry`
|
- `GET /api/models` is served from an in-memory cache in `DriverRegistry`
|
||||||
(`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time.
|
(`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time.
|
||||||
Concurrent misses share one in-flight upstream fetch. Admins pass
|
Concurrent misses share one in-flight upstream fetch. Admins pass
|
||||||
@@ -113,6 +135,16 @@ Models are addressed as `"driver:model"` (split on the first `:`).
|
|||||||
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
(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**
|
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.
|
its key is non-empty; failing drivers are skipped (not fatal) in listings.
|
||||||
|
- Personal providers (BYOK, `src/userProviders.ts`): users attach their own
|
||||||
|
`openai` / `anthropic` / `deepseek` / `gemini` keys in settings
|
||||||
|
(`GET/PUT/DELETE /api/me/providers`, keys stored in `user_provider_keys`,
|
||||||
|
never returned — GET exposes presence + last4 only). `GET /api/models`
|
||||||
|
merges the caller's own-key models into their menu (a personal entry
|
||||||
|
replaces the platform entry with the same id, flagged `personal: true` so
|
||||||
|
the picker lists it under its own "driver · personal key" section), and
|
||||||
|
`POST /api/chat` prefers the personal key for those four providers (also
|
||||||
|
when the global driver is disabled). The admin model policy still applies. Driver
|
||||||
|
constructors accept an `apiKeyOverride` for these per-user instances.
|
||||||
- `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`,
|
- `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`,
|
||||||
one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a
|
one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a
|
||||||
headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp
|
headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp
|
||||||
|
|||||||
+32
-5
@@ -15,6 +15,10 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
|||||||
- `/register` — full-page self-registration (`RegisterPage.tsx`; key shown
|
- `/register` — full-page self-registration (`RegisterPage.tsx`; key shown
|
||||||
once, then hands username+key to `/login` via `sessionStorage`). No splash.
|
once, then hands username+key to `/login` via `sessionStorage`). No splash.
|
||||||
- `/chat` — the platform itself (auth required; boots behind the splash).
|
- `/chat` — the platform itself (auth required; boots behind the splash).
|
||||||
|
Bare `/chat` is the empty composer (new chat, created lazily on first send).
|
||||||
|
- `/chat/:id` — one conversation page (deep-linkable, back/forward supported).
|
||||||
|
Selecting a chat navigates there; unknown ids bounce back to `/chat`.
|
||||||
|
Archived chats are viewed read-only on bare `/chat` (no per-id URL).
|
||||||
- `/` — redirects to `/chat` when logged in, `/login` otherwise (placeholder
|
- `/` — redirects to `/chat` when logged in, `/login` otherwise (placeholder
|
||||||
for a future landing page).
|
for a future landing page).
|
||||||
- Public routes (unchanged): `/share/:publicId`, `/reset/:token`,
|
- Public routes (unchanged): `/share/:publicId`, `/reset/:token`,
|
||||||
@@ -56,11 +60,13 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
|||||||
share button), message list (`Markdown` for assistant, emerald bubble for
|
share button), message list (`Markdown` for assistant, emerald bubble for
|
||||||
user), rounded composer with paperclip attach menu, attachment chips
|
user), rounded composer with paperclip attach menu, attachment chips
|
||||||
(Enter to send, Shift+Enter for newline). With `readOnly` (archived chat)
|
(Enter to send, Shift+Enter for newline). With `readOnly` (archived chat)
|
||||||
the composer and share button are replaced by an unarchive banner.
|
the composer is replaced by an unarchive banner with a share button
|
||||||
|
(archived chats stay shareable, but an archived public chat cannot go
|
||||||
|
private again — `DELETE …/share` answers 403 until unarchived).
|
||||||
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
|
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
|
||||||
copy, unshare), `SettingsModal` (display name, avatar upload box with
|
copy, unshare), `SettingsModal` (display name, avatar upload box with
|
||||||
drag & drop, avatar URL, theme, plus an "Archived chats" category to
|
drag & drop, avatar URL, theme, plus an "Archived chats" category to
|
||||||
view / unarchive / delete archived conversations).
|
view / unarchive / delete / share archived conversations).
|
||||||
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
|
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
|
||||||
regenerate (revokes sessions), edit, delete with confirm, plus server-side
|
regenerate (revokes sessions), edit, delete with confirm, plus server-side
|
||||||
search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination.
|
search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination.
|
||||||
@@ -74,14 +80,35 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
|||||||
(`{ title, topic, model, authorName, authorAvatarUrl, archived, sharedAt, messages }`);
|
(`{ title, topic, model, authorName, authorAvatarUrl, archived, sharedAt, messages }`);
|
||||||
user messages render the author's avatar via `Avatar` (URL or initials);
|
user messages render the author's avatar via `Avatar` (URL or initials);
|
||||||
an "Archived" pill shows when the source conversation is archived.
|
an "Archived" pill shows when the source conversation is archived.
|
||||||
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
|
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
|
||||||
a copy button.
|
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||||
|
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
||||||
|
- Assistant bubbles show the producing model (`<model>` chip in the footer,
|
||||||
|
stored per message in `messages.model` — survives model switches; same
|
||||||
|
chip on the streaming bubble and shared pages).
|
||||||
|
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
||||||
|
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||||
|
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
||||||
|
status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx`
|
||||||
|
(live Hugging Face GGUF search + one-click `hf.co/…` pull to a node).
|
||||||
|
Stats tab: `StatsSection.tsx` (node/window pickers, bar/area/line dock, SVG
|
||||||
|
tooltips, platform host cards).
|
||||||
|
- Accent color (`lib/accent.ts`) derives the 50–950 ramp from one hex and
|
||||||
|
also repaints the SVG favicon (data URL) so the tab icon matches the dress.
|
||||||
|
- Voice (`lib/voice.ts`, no dependency): `Chat.tsx` composer mic with live
|
||||||
|
waveform + speech-recognition transcript (editable before send), per-bubble
|
||||||
|
playback with glow through a unified player (`playText`: neural server
|
||||||
|
audio via `POST /api/tools/tts` with browser-synthesis fallback),
|
||||||
|
`Settings → Voice` prefs (engine/lang/voice/speed/pitch/neural voice,
|
||||||
|
localStorage), admin-editable preview lines.
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
|
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
|
||||||
class from the account theme (`auto` follows `prefers-color-scheme` with a
|
class from the account theme (`auto` follows `prefers-color-scheme` with a
|
||||||
live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl`
|
live media-query listener; `sunset` follows the browser's local time —
|
||||||
|
light 07:00–19:00, dark otherwise — re-applied at each boundary via
|
||||||
|
`lib/solarTheme.ts`). Accent: emerald; shapes: `rounded-2xl/3xl`
|
||||||
everywhere; every surface needs a `dark:` variant. UI text is English only.
|
everywhere; every surface needs a `dark:` variant. UI text is English only.
|
||||||
|
|
||||||
## Mobile
|
## Mobile
|
||||||
|
|||||||
+180
-10
@@ -10,7 +10,8 @@ Copy `.env.example` to `.env`. Full list:
|
|||||||
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
|
| `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` |
|
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
|
||||||
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
|
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
|
||||||
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) |
|
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite — unused when `POSTGRESQL_URL` is set) |
|
||||||
|
| `POSTGRESQL_URL` | *(empty)* | Postgres fallback, e.g. `postgres://kisassistant:secret@localhost:5432/kisassistant` (typically a docker container). Empty = local SQLite. Boot fails fast when unreachable. |
|
||||||
| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) |
|
| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) |
|
||||||
| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool |
|
| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool |
|
||||||
| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) |
|
| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) |
|
||||||
@@ -24,7 +25,7 @@ Copy `.env.example` to `.env`. Full list:
|
|||||||
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
|
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
|
||||||
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
|
| `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 |
|
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
|
||||||
| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
|
| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*`, `GEMINI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
|
||||||
| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) |
|
| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) |
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
@@ -42,6 +43,34 @@ Smoke tests: start the backend on a **free non-3000 port** with a throwaway
|
|||||||
the API with curl/python: login → models → conversations → chat → share →
|
the API with curl/python: login → models → conversations → chat → share →
|
||||||
unshare. See `AGENTS.md`.
|
unshare. See `AGENTS.md`.
|
||||||
|
|
||||||
|
## Postgres fallback (docker)
|
||||||
|
|
||||||
|
SQLite is the default. For Postgres, run a container and point the app at it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name kisassistant-pg --restart unless-stopped \
|
||||||
|
-e POSTGRES_USER=kisassistant -e POSTGRES_PASSWORD=secret \
|
||||||
|
-e POSTGRES_DB=kisassistant -p 127.0.0.1:5432:5432 \
|
||||||
|
-v kisassistant-pgdata:/var/lib/postgresql/data \
|
||||||
|
postgres:17-alpine
|
||||||
|
|
||||||
|
POSTGRESQL_URL=postgres://kisassistant:[email protected]:5432/kisassistant \
|
||||||
|
DATA_DIR=/tmp/ka-pg-throwaway \
|
||||||
|
bun run src/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The app uses Bun's native `bun:sql` driver — no extra dependency.
|
||||||
|
- Schema is created idempotently at boot (same tables as SQLite; timestamps
|
||||||
|
are `BIGINT`, auto ids `BIGSERIAL`). A fresh database starts empty except
|
||||||
|
the bootstrapped `admin` row — **there is no SQLite→Postgres data
|
||||||
|
migration** (re-create accounts or keep SQLite).
|
||||||
|
- Switching back is just unsetting `POSTGRESQL_URL` (the SQLite file is
|
||||||
|
untouched while Postgres is active).
|
||||||
|
- Back up with `pg_dump` (SQLite: copy `kisassistant.db`, or
|
||||||
|
`.backup` while running under WAL).
|
||||||
|
|
||||||
## Managing accounts
|
## Managing accounts
|
||||||
|
|
||||||
1. Log in as `admin` with the `APP_PASSWORD` key.
|
1. Log in as `admin` with the `APP_PASSWORD` key.
|
||||||
@@ -55,11 +84,16 @@ unshare. See `AGENTS.md`.
|
|||||||
## Admin Center (`/admin`) & self-registration
|
## Admin Center (`/admin`) & self-registration
|
||||||
|
|
||||||
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
||||||
four tabs: **Accounts** (same manager as before, incl. the Ollama model
|
seven deep-linkable tabs (`/admin` = accounts, `/admin/models`,
|
||||||
catalog), **Access** (access-request wishlist triage, see below),
|
`/admin/stats`, `/admin/access`, `/admin/reports`, `/admin/features`,
|
||||||
**Features** (server-side switches), **Mail** (SMTP credentials viewer +
|
`/admin/mail`): **Accounts** (same manager as before), **Access**
|
||||||
|
(access-request wishlist triage, see below), **Reports**, **Models** (node
|
||||||
|
pool, per-model policy, Ollama library + HF catalog), **Stats** (pool load
|
||||||
|
charts + platform host facts, see below), **Features** (server-side
|
||||||
|
switches + voice-preview lines), **Mail** (SMTP credentials viewer +
|
||||||
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
||||||
`POST /api/admin/mail/test`, admin Bearer only).
|
`POST /api/admin/mail/test`, admin Bearer only — plus the password-recovery
|
||||||
|
rate limits below).
|
||||||
|
|
||||||
- `registration_enabled` (default off): when on, the login page shows a
|
- `registration_enabled` (default off): when on, the login page shows a
|
||||||
working **Register** button — anyone can create an account (key shown once)
|
working **Register** button — anyone can create an account (key shown once)
|
||||||
@@ -71,8 +105,140 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
|||||||
login page (wishlist). Only switchable on while public registration is off —
|
login page (wishlist). Only switchable on while public registration is off —
|
||||||
the two are mutually exclusive (API answers 409 otherwise).
|
the two are mutually exclusive (API answers 409 otherwise).
|
||||||
- `tools_ocr_enabled` (default on): admin kill-switch for the OCR tool.
|
- `tools_ocr_enabled` (default on): admin kill-switch for the OCR tool.
|
||||||
|
- `reports_enabled` (default on): admin kill-switch for message reports —
|
||||||
|
the chat flag button turns grey with a "Report disabled" tooltip and
|
||||||
|
`POST /api/reports` answers 403. Surfaced to clients via `GET /api/tools`.
|
||||||
|
- `username_change_enabled` (default on): lets users rename themselves
|
||||||
|
(`PATCH /api/me` `username`); off answers 403 and locks the Settings field.
|
||||||
|
Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to
|
||||||
|
clients via `GET /api/tools`.
|
||||||
- Flags live in the `settings` SQLite table, edited via
|
- Flags live in the `settings` SQLite table, edited via
|
||||||
`GET/PATCH /api/admin/settings` (admin Bearer only).
|
`GET/PATCH /api/admin/settings` (admin Bearer only). The same endpoint
|
||||||
|
carries the Ollama pool tuning (`ollama_timeout_s` 5–3600, default 300;
|
||||||
|
`ollama_default_weight` 0–1000, default 100) and the recovery envelope
|
||||||
|
(`recovery_limit_max` 1–100, default 5; `recovery_limit_window_min`
|
||||||
|
1–1440, default 60).
|
||||||
|
|
||||||
|
## Password recovery rate limits
|
||||||
|
|
||||||
|
`POST /api/auth/recover` and the `/api/auth/reset/:token` endpoints share a
|
||||||
|
dedicated per-IP envelope (separate from login brute-force protection):
|
||||||
|
every hit counts, successes included (recovery answers are always-ok by
|
||||||
|
design, so enumeration is impossible). Over-limit answers 429 with
|
||||||
|
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
|
||||||
|
Admin Center → Mail → Password recovery limits, no restart. Admins can also
|
||||||
|
email a one-time reset link to any user with an address from the Accounts
|
||||||
|
tab (`POST /api/admin/users/:id/reset-link` — 409 without email, 501
|
||||||
|
without SMTP).
|
||||||
|
|
||||||
|
## Model access policy (kill-switch + rate limits)
|
||||||
|
|
||||||
|
The Admin Center **Models** tab lists every model across enabled drivers
|
||||||
|
(grouped, Ollama library moved there too). Per model id (`"driver:model"`):
|
||||||
|
an **Enabled** switch and **hourly / daily** per-user request caps
|
||||||
|
(0 = unlimited, admins always bypass). Policy lives in the `settings` table
|
||||||
|
(`model_policy` JSON blob, default entries pruned); usage counters in
|
||||||
|
`model_usage` (fixed UTC hour/day windows, old buckets pruned on write).
|
||||||
|
|
||||||
|
- Disabled models vanish from `GET /api/models` for users (admins still see
|
||||||
|
everything) and `POST /api/chat` rejects them (403) — attempts count
|
||||||
|
toward limits even when generation later fails; over-limit answers 429.
|
||||||
|
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
|
||||||
|
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
|
||||||
|
Ollama library: `POST /api/admin/ollama/pull` (`{name, nodeId?}`, NDJSON
|
||||||
|
progress — library tags AND `hf.co/user/repo:quant` references),
|
||||||
|
`DELETE /api/admin/ollama/models/:name[?node=]`, and the connectivity probe
|
||||||
|
`GET /api/admin/ollama/status` (default node — surfaced in the node pool).
|
||||||
|
|
||||||
|
## Ollama node pool (multi-host, weighted)
|
||||||
|
|
||||||
|
Admin Center → Models → **Ollama nodes**: register extra Ollama hosts
|
||||||
|
(modal with name, host, weight + a Test probe before saving). The
|
||||||
|
env-configured `OLLAMA_HOST` stays as the implicit `default` node (read-only
|
||||||
|
row, `env` badge). Each generation picks a node by **weighted random**
|
||||||
|
among enabled nodes with weight > 0 (share shown as % — firewall-like
|
||||||
|
priority, heaviest first) and fails over to the next node on error or
|
||||||
|
timeout, but only before any token was produced (a started reply never
|
||||||
|
restarts elsewhere). Weight 0 or disabled = no traffic. Pool changes
|
||||||
|
invalidate the model list cache; `listModels` merges tags across nodes.
|
||||||
|
|
||||||
|
- `ollama_timeout_s` (default 300 s, Admin Center → Models): max time for
|
||||||
|
one generation per node attempt (caller aborts propagate instantly and
|
||||||
|
never fail over).
|
||||||
|
- Node detail view: live probe (version, latency, on-disk models, running
|
||||||
|
models with VRAM via `/api/ps`) + load charts over 1h / 6h / 24h / 7d /
|
||||||
|
30d (requests, errors, avg latency, tokens/s from app-side generation
|
||||||
|
telemetry in `ollama_stats`, 35-day retention — Ollama exposes no CPU/RAM
|
||||||
|
API, so host CPU/RAM meters are intentionally absent).
|
||||||
|
- HF catalog (same tab): live Hugging Face search (`filter=gguf`, sorted by
|
||||||
|
downloads), per-repo quantization picker, one-click pull to the selected
|
||||||
|
node. Needs Ollama with `hf.co/…` support + internet on that host.
|
||||||
|
- Routes: `GET/POST /api/admin/ollama/nodes`, `PATCH/DELETE
|
||||||
|
/api/admin/ollama/nodes/:id`, `GET …/nodes/:id/status`,
|
||||||
|
`GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`,
|
||||||
|
`GET /api/admin/ollama/probe?host=…`.
|
||||||
|
|
||||||
|
## Telemetry sub-page (`/admin/stats`) + platform facts
|
||||||
|
|
||||||
|
The **Stats** tab is the pool observatory: node picker, window picker
|
||||||
|
(1h / 6h / 24h / 7d / 30d), chart-type dock (bar / area / line SVG charts
|
||||||
|
with hover tooltips: requests, errors, avg latency, tokens), totals tiles,
|
||||||
|
and a latency underlay. Below, **platform host facts** for this server —
|
||||||
|
hostname, kernel, arch/CPU, app + system uptime, load average, memory bar,
|
||||||
|
runtime, network interfaces/IPs (`GET /api/admin/platform`, admin only).
|
||||||
|
Disk meters are intentionally absent (models live on the Ollama hosts).
|
||||||
|
|
||||||
|
## Voice: dictation + playback
|
||||||
|
|
||||||
|
- **Composer mic** (browser speech recognition — Chrome/Edge): records with
|
||||||
|
a live gradient waveform, timer and live transcript; **Stop** drops the
|
||||||
|
transcript into the composer, editable before sending; **Discard** throws
|
||||||
|
it away. Unsupported browsers get a disabled mic with an explanatory
|
||||||
|
tooltip. No audio leaves the browser (no server STT endpoint yet —
|
||||||
|
whisper.cpp via `WHISPER_BIN` is the documented next step).
|
||||||
|
- **Message playback**: every assistant bubble has a Listen button with a
|
||||||
|
Siri-style breathing glow while speaking (click again to stop; playback
|
||||||
|
stops on send / new message / conversation switch). Engine per user
|
||||||
|
(Settings → Voice): **auto** (neural server voice when available, browser
|
||||||
|
fallback), **browser**, or **server**.
|
||||||
|
- **Neural server TTS** (`POST /api/tools/tts` `{text, voice?}` → audio
|
||||||
|
bytes, `GET /api/tools/tts/info` → provider + voices): ElevenLabs
|
||||||
|
(any voice_id, most natural) → OpenAI `tts-1` (6 stock voices, reuses the
|
||||||
|
LLM key) → local piper (`PIPER_BIN` + `PIPER_MODEL`, offline WAV).
|
||||||
|
`TTS_PROVIDER` forces one; `TTS_VOICE` / `ELEVENLABS_VOICE` set defaults.
|
||||||
|
Output is cached on disk (sha of provider|voice|text, 200 files cap),
|
||||||
|
rate-limited to 30 jobs/hour/account (429), kill-switched via
|
||||||
|
`tools_tts_enabled` (Admin Center → Features). Preview lines are
|
||||||
|
pre-generated once at boot and right after each preview save, so serving
|
||||||
|
users always hits the cache (`X-TTS-Cache: HIT/MISS` proves it). Test
|
||||||
|
overrides: `ELEVENLABS_BASE_URL` / `OPENAI_BASE_URL`.
|
||||||
|
- **Settings → Voice** (per browser, localStorage): engine, language
|
||||||
|
(auto-detect per message or forced), browser voice/rate/pitch, neural
|
||||||
|
voice id, and one-tap previews of the admin-set lines.
|
||||||
|
- **Admin Center → Features → Voice previews**: the default playback lines
|
||||||
|
(`voice_previews` setting, 1–6 lines `{text, lang, rate, pitch, timbre,
|
||||||
|
voice}`, each with its OWN voice character — masculine/feminine/any
|
||||||
|
timbre hint, speed, pitch, optional server voice id picked from a select
|
||||||
|
menu fed by the provider catalog), also served to users via
|
||||||
|
`GET /api/tools`. Users can adopt any preview's character as their own
|
||||||
|
playback voice from Settings → Voice.
|
||||||
|
|
||||||
|
## Personal providers (BYOK)
|
||||||
|
|
||||||
|
Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini`
|
||||||
|
API keys in **Settings → Providers** (per account, no admin involved).
|
||||||
|
Connected providers add their models to that user's model menu at the top
|
||||||
|
(no page refresh needed); usage is billed to the user's own provider
|
||||||
|
account, and the admin model policy (kill-switch + rate limits) still
|
||||||
|
applies.
|
||||||
|
|
||||||
|
- Keys are stored server-side in `user_provider_keys` and **never returned**
|
||||||
|
to any client (`GET /api/me/providers` exposes presence + last 4 chars
|
||||||
|
only; never logged). A stored key greys out its field (Replace to rotate,
|
||||||
|
Remove to disconnect). Deleting the account wipes its keys.
|
||||||
|
- Routes: `GET /api/me/providers`, `PUT /api/me/providers/:id`
|
||||||
|
(`{apiKey}`, 8–256 chars), `DELETE /api/me/providers/:id` (all Bearer,
|
||||||
|
owner-scoped).
|
||||||
|
|
||||||
## Access-request wishlist (registration approval queue)
|
## Access-request wishlist (registration approval queue)
|
||||||
|
|
||||||
@@ -99,9 +265,13 @@ UUID, same pattern as share links).
|
|||||||
(ticket bearer — pushes `access_message` + `access_status` for that ticket)
|
(ticket bearer — pushes `access_message` + `access_status` for that ticket)
|
||||||
and `GET /api/admin/ws?token=…` (admin Bearer in query string, browsers
|
and `GET /api/admin/ws?token=…` (admin Bearer in query string, browsers
|
||||||
can't set WS headers — pushes `access_message` + `access_status` +
|
can't set WS headers — pushes `access_message` + `access_status` +
|
||||||
`access_created` for every ticket, driving the AdminCenter badge and the
|
`access_created` for every ticket plus `report_created` + `report_status`,
|
||||||
triage list). Clients ping every 25 s (`{"type":"ping"}` → `{"type":"pong"}`)
|
driving the AdminCenter badges and the triage lists). Every event carries
|
||||||
and resync over HTTP on every (re)connect.
|
its full payload (`access_created` bundles the ticket, its first message
|
||||||
|
and the message count), so clients apply it straight to state — HTTP
|
||||||
|
resync only happens on (re)connect to cover the offline gap. Clients ping
|
||||||
|
every 25 s (`{"type":"ping"}` → `{"type":"pong"}`) with a pong watchdog
|
||||||
|
that redials half-dead sockets.
|
||||||
|
|
||||||
## Content reports (flagged AI responses)
|
## Content reports (flagged AI responses)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>KisAssistant — private AI chat</title>
|
<title>KisAssistant — private AI chat</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
<g>
|
<defs>
|
||||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981"/>
|
<linearGradient id="ka-petal" x1="0" y1="1" x2="0" y2="0">
|
||||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(60 50 50)"/>
|
<stop offset="0" stop-color="#047857"/>
|
||||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(120 50 50)"/>
|
<stop offset="0.6" stop-color="#10b981"/>
|
||||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(180 50 50)"/>
|
<stop offset="1" stop-color="#6ee7b7"/>
|
||||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(240 50 50)"/>
|
</linearGradient>
|
||||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(300 50 50)"/>
|
<linearGradient id="ka-petals" x1="0" y1="1" x2="0" y2="0">
|
||||||
<circle cx="50" cy="50" r="10" fill="#fbbf24"/>
|
<stop offset="0" stop-color="#10b981"/>
|
||||||
</g>
|
<stop offset="1" stop-color="#a7f3d0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="ka-core">
|
||||||
|
<stop offset="0" stop-color="#fef3c7"/>
|
||||||
|
<stop offset="0.5" stop-color="#fbbf24"/>
|
||||||
|
<stop offset="1" stop-color="#d97706"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="20" fill="url(#ka-petal)" transform="rotate(8 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="19" fill="url(#ka-petal)" transform="rotate(80 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="20.5" fill="url(#ka-petal)" transform="rotate(151 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="19.5" fill="url(#ka-petal)" transform="rotate(223 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="20" fill="url(#ka-petal)" transform="rotate(294 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(44 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="11.5" fill="url(#ka-petals)" transform="rotate(116 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(188 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="11.5" fill="url(#ka-petals)" transform="rotate(259 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(331 50 50)"/>
|
||||||
|
<circle cx="50" cy="50" r="10.5" fill="#f59e0b" opacity="0.22"/>
|
||||||
|
<circle cx="50" cy="50" r="8" fill="url(#ka-core)"/>
|
||||||
|
<circle cx="47.5" cy="47" r="2.2" fill="#fffbeb" opacity="0.95"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 1.8 KiB |
+328
-65
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api, clearToken, getToken } from "./lib/api.ts";
|
import { api, clearToken, getToken } from "./lib/api.ts";
|
||||||
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
|
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
FilePreview,
|
FilePreview,
|
||||||
User,
|
User,
|
||||||
} from "./lib/types.ts";
|
} from "./lib/types.ts";
|
||||||
import { navigate, normalizePath, useRoute } from "./lib/route.ts";
|
import { adminTabFromPath, chatIdFromPath, isChatPath, navigate, normalizePath, useRoute } from "./lib/route.ts";
|
||||||
import { Login } from "./components/Login.tsx";
|
import { Login } from "./components/Login.tsx";
|
||||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||||
import { Sidebar } from "./components/Sidebar.tsx";
|
import { Sidebar } from "./components/Sidebar.tsx";
|
||||||
@@ -25,6 +25,10 @@ import { SharePage } from "./components/SharePage.tsx";
|
|||||||
import { ResetPage } from "./components/ResetPage.tsx";
|
import { ResetPage } from "./components/ResetPage.tsx";
|
||||||
import { ReviewPage } from "./components/ReviewPage.tsx";
|
import { ReviewPage } from "./components/ReviewPage.tsx";
|
||||||
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
|
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
|
||||||
|
import { Toasts } from "./components/Toasts.tsx";
|
||||||
|
import { pushToast } from "./lib/toasts.ts";
|
||||||
|
import { clearFailedEntry, getFailedEntry, getSavedModel, pickModel, setFailedEntry, setSavedModel } from "./lib/localPrefs.ts";
|
||||||
|
import { msUntilNextSolarSwitch, resolveThemeDark } from "./lib/solarTheme.ts";
|
||||||
import { useT } from "./lib/i18n.ts";
|
import { useT } from "./lib/i18n.ts";
|
||||||
|
|
||||||
function shareIdFromPath(): string | null {
|
function shareIdFromPath(): string | null {
|
||||||
@@ -40,7 +44,7 @@ const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null;
|
|||||||
const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
|
const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
|
||||||
const IS_ADMIN_PAGE =
|
const IS_ADMIN_PAGE =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
/^\/admin\/?$/.test(window.location.pathname);
|
(/^\/admin\/?$/.test(window.location.pathname) || adminTabFromPath(window.location.pathname) !== null);
|
||||||
|
|
||||||
function resetTokenFromPath(): string | null {
|
function resetTokenFromPath(): string | null {
|
||||||
const m = window.location.pathname.match(
|
const m = window.location.pathname.match(
|
||||||
@@ -58,12 +62,12 @@ function reviewIdFromPath(): string | null {
|
|||||||
* actually plays even when the session restores instantly. */
|
* actually plays even when the session restores instantly. */
|
||||||
const MIN_SPLASH_MS = 1100;
|
const MIN_SPLASH_MS = 1100;
|
||||||
|
|
||||||
|
/** Max footer-arrow retries for the same failed model prompt. */
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
function applyTheme(theme: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const dark =
|
const dark = resolveThemeDark(theme);
|
||||||
theme === "dark" ||
|
|
||||||
(theme !== "light" &&
|
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
root.classList.toggle("dark", dark);
|
root.classList.toggle("dark", dark);
|
||||||
root.style.colorScheme = dark ? "dark" : "light";
|
root.style.colorScheme = dark ? "dark" : "light";
|
||||||
}
|
}
|
||||||
@@ -86,6 +90,21 @@ export function App() {
|
|||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [convs, setConvs] = useState<Conversation[]>([]);
|
const [convs, setConvs] = useState<Conversation[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
// Async callbacks (refreshConvs) need the current selection without
|
||||||
|
// re-creating on every render.
|
||||||
|
const activeIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
activeIdRef.current = activeId;
|
||||||
|
}, [activeId]);
|
||||||
|
// Id of the conversation currently receiving a streamed reply (null when
|
||||||
|
// idle). Set synchronously before any state update in send()/retryFailed
|
||||||
|
// so the route/message effects below never clobber a conversation that is
|
||||||
|
// being created or written to: the URL stays bare /chat until we navigate
|
||||||
|
// explicitly, and the server message list is still empty while streaming.
|
||||||
|
const sendingForRef = useRef<string | null>(null);
|
||||||
|
// True once the conversation list was fetched at least once (so an
|
||||||
|
// unknown /chat/<id> can be bounced instead of flashing empty).
|
||||||
|
const [convsReady, setConvsReady] = useState(false);
|
||||||
// Conversation opened from "Archived chats" (read-only, not in the sidebar list).
|
// Conversation opened from "Archived chats" (read-only, not in the sidebar list).
|
||||||
const [archivedConv, setArchivedConv] = useState<Conversation | null>(null);
|
const [archivedConv, setArchivedConv] = useState<Conversation | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
@@ -102,11 +121,25 @@ export function App() {
|
|||||||
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
|
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
|
||||||
const [previewBusy, setPreviewBusy] = useState(false);
|
const [previewBusy, setPreviewBusy] = useState(false);
|
||||||
const [ocrAvailable, setOcrAvailable] = useState(false);
|
const [ocrAvailable, setOcrAvailable] = useState(false);
|
||||||
|
const [reportsEnabled, setReportsEnabled] = useState(true);
|
||||||
|
const [usernameChangeEnabled, setUsernameChangeEnabled] = useState(true);
|
||||||
|
// Model-error retry: which trailing message is the error bubble (if any),
|
||||||
|
// its conversation, and how many times this prompt was already retried.
|
||||||
|
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
// Conversation currently receiving a streamed reply (null when idle).
|
||||||
|
// Streaming / thinking / sending UI is rendered ONLY there — every other
|
||||||
|
// conversation stays clean and selectable while a reply lands.
|
||||||
|
const [sendingFor, setSendingFor] = useState<string | null>(null);
|
||||||
|
function setFlight(id: string | null): void {
|
||||||
|
sendingForRef.current = id;
|
||||||
|
setSendingFor(id);
|
||||||
|
}
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
|
||||||
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
||||||
if (IS_ADMIN_PAGE) {
|
if (IS_ADMIN_PAGE) {
|
||||||
return <AdminCenter />;
|
return <AdminCenter initialTab={adminTabFromPath(window.location.pathname)} />;
|
||||||
}
|
}
|
||||||
if (RESET_TOKEN) {
|
if (RESET_TOKEN) {
|
||||||
return <ResetPage token={RESET_TOKEN} />;
|
return <ResetPage token={RESET_TOKEN} />;
|
||||||
@@ -118,20 +151,28 @@ export function App() {
|
|||||||
return <SharePage publicId={SHARE_ID} />;
|
return <SharePage publicId={SHARE_ID} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshConvs = useCallback(async (select?: string) => {
|
// Reload the sidebar list; resolves the id that ends up selected so
|
||||||
|
// callers can sync the URL (/chat/<id>) with it.
|
||||||
|
const refreshConvs = useCallback(async (select?: string): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const res = await api.convs();
|
const res = await api.convs();
|
||||||
setConvs(res.conversations);
|
setConvs(res.conversations);
|
||||||
if (select) setActiveId(select);
|
setConvsReady(true);
|
||||||
else if (res.conversations.length > 0) {
|
let next: string | null = null;
|
||||||
setActiveId((cur) =>
|
if (select) {
|
||||||
|
next = res.conversations.some((c) => c.id === select) ? select : null;
|
||||||
|
} else {
|
||||||
|
const cur = activeIdRef.current;
|
||||||
|
next =
|
||||||
cur && res.conversations.some((c) => c.id === cur)
|
cur && res.conversations.some((c) => c.id === cur)
|
||||||
? cur
|
? cur
|
||||||
: res.conversations[0]!.id,
|
: (res.conversations[0]?.id ?? null);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setActiveId(next);
|
||||||
|
return next;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore (session errors reload the page via api layer)
|
// ignore (session errors reload the page via api layer)
|
||||||
|
return activeIdRef.current;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -159,13 +200,28 @@ export function App() {
|
|||||||
.tools()
|
.tools()
|
||||||
.catch(() => ({
|
.catch(() => ({
|
||||||
tools: [] as { name: string; available: boolean }[],
|
tools: [] as { name: string; available: boolean }[],
|
||||||
|
reportsEnabled: true,
|
||||||
|
usernameChangeEnabled: true,
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
||||||
|
setReportsEnabled(t.reportsEnabled ?? true);
|
||||||
|
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||||
setModels(m.models);
|
setModels(m.models);
|
||||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(res.user.username)));
|
||||||
setConvs(c.conversations);
|
setConvs(c.conversations);
|
||||||
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
|
setConvsReady(true);
|
||||||
|
// Deep link: /chat/<id> opens that conversation directly.
|
||||||
|
const urlId = chatIdFromPath(window.location.pathname);
|
||||||
|
if (urlId && c.conversations.some((x) => x.id === urlId)) {
|
||||||
|
setActiveId(urlId);
|
||||||
|
} else if (urlId) {
|
||||||
|
navigate("/chat", true); // unknown or deleted id
|
||||||
|
} else if (c.conversations.length > 0) {
|
||||||
|
const first = c.conversations[0]!.id;
|
||||||
|
setActiveId(first);
|
||||||
|
navigate(`/chat/${first}`, true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -188,7 +244,7 @@ export function App() {
|
|||||||
if (user) navigate("/chat", true);
|
if (user) navigate("/chat", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path === "/chat") {
|
if (isChatPath(path)) {
|
||||||
if (noToken || (authReady && !user)) navigate("/login", true);
|
if (noToken || (authReady && !user)) navigate("/login", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -197,20 +253,87 @@ export function App() {
|
|||||||
else if (authReady) navigate(user ? "/chat" : "/login", true);
|
else if (authReady) navigate(user ? "/chat" : "/login", true);
|
||||||
}, [path, user, authReady]);
|
}, [path, user, authReady]);
|
||||||
|
|
||||||
|
// Browser back/forward (or manual URL edit): the URL is the source of
|
||||||
|
// truth for the selected conversation. Own navigations are no-ops here
|
||||||
|
// (state already matches), so this never fights the handlers below.
|
||||||
|
// Exception: while the first reply of a new chat is being created/sent,
|
||||||
|
// the URL is still bare /chat but the selection already points at the new
|
||||||
|
// id — adopting the bare URL would drop the selection and the next
|
||||||
|
// follow-up would fork a brand new conversation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || checking || !isChatPath(path)) return;
|
||||||
|
const id = chatIdFromPath(path);
|
||||||
|
if (id === activeId) return;
|
||||||
|
if (id === null && sending && sendingForRef.current !== null && activeId === sendingForRef.current) return;
|
||||||
|
// Back to bare /chat (id null): empty composer, unless an archived
|
||||||
|
// chat is being viewed — archived views always live on bare /chat.
|
||||||
|
if (id !== null || !archivedConv) {
|
||||||
|
setArchivedConv(null);
|
||||||
|
setActiveId(id);
|
||||||
|
setMobileNav(false);
|
||||||
|
}
|
||||||
|
}, [path, user, checking, activeId, archivedConv, sending]);
|
||||||
|
|
||||||
|
// Unknown /chat/<id> (deleted elsewhere, typo): bounce to /chat once
|
||||||
|
// the list is loaded. Archived views always live on bare /chat.
|
||||||
|
// Exception: a freshly created id may reach the URL before the sidebar
|
||||||
|
// list refresh caught up — never bounce the conversation being sent to.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || checking || !convsReady || !isChatPath(path)) return;
|
||||||
|
const id = chatIdFromPath(path);
|
||||||
|
if (id && !convs.some((c) => c.id === id)) {
|
||||||
|
if (sendingForRef.current === id) return;
|
||||||
|
navigate("/chat", true);
|
||||||
|
}
|
||||||
|
}, [path, user, checking, convsReady, convs]);
|
||||||
|
|
||||||
// Load messages for active conversation
|
// Load messages for active conversation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The conversation being sent to owns its optimistic messages until the
|
||||||
|
// stream lands — the server list is still empty mid-flight, so a refetch
|
||||||
|
// here would blank the fresh exchange (displayed as a recreated chat).
|
||||||
|
if (sendingForRef.current === activeId) return;
|
||||||
|
const id = activeId;
|
||||||
api
|
api
|
||||||
.getConv(activeId)
|
.getConv(id)
|
||||||
.then((res) => setMessages(res.messages))
|
.then((res) => {
|
||||||
|
if (activeIdRef.current !== id || sendingForRef.current === id) return;
|
||||||
|
setMessages(res.messages);
|
||||||
|
// A model error is persisted browser-side (never server-side, so
|
||||||
|
// history stays clean): re-create the trailing error bubble + retry
|
||||||
|
// state after a refresh. Stale entries (conversation moved on) drop.
|
||||||
|
const entry = getFailedEntry(id);
|
||||||
|
if (entry && entry.index === res.messages.length) {
|
||||||
|
setMessages([...res.messages, { role: "assistant", content: t("attach.chatError") }]);
|
||||||
|
setFailed({ index: entry.index, convId: id });
|
||||||
|
setRetryCount(entry.retryCount);
|
||||||
|
} else if (entry) {
|
||||||
|
clearFailedEntry(id);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => setMessages([]));
|
.catch(() => setMessages([]));
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
// Follow OS theme when account theme is auto
|
// Follow OS theme when account theme is auto; follow local time (07:00–19:00)
|
||||||
|
// when account theme is sunset — re-applied at each day/night boundary.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (user?.theme === "sunset") {
|
||||||
|
let timer: number | undefined;
|
||||||
|
const schedule = () => {
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
applyTheme("sunset");
|
||||||
|
schedule();
|
||||||
|
}, msUntilNextSolarSwitch());
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
if (user?.theme !== "auto") return;
|
if (user?.theme !== "auto") return;
|
||||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const h = () => applyTheme("auto");
|
const h = () => applyTheme("auto");
|
||||||
@@ -228,16 +351,21 @@ export function App() {
|
|||||||
.models()
|
.models()
|
||||||
.then((m) => {
|
.then((m) => {
|
||||||
setModels(m.models);
|
setModels(m.models);
|
||||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(u.username)));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
api
|
api
|
||||||
.tools()
|
.tools()
|
||||||
.then((t) =>
|
.then((t) => {
|
||||||
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)),
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
||||||
)
|
setReportsEnabled(t.reportsEnabled ?? true);
|
||||||
|
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||||
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
refreshConvs();
|
// Land directly on the latest conversation's own URL.
|
||||||
|
refreshConvs().then((id) => {
|
||||||
|
if (id) navigate(`/chat/${id}`, true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -248,6 +376,8 @@ export function App() {
|
|||||||
setChecking(false);
|
setChecking(false);
|
||||||
setConvs([]);
|
setConvs([]);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
@@ -255,29 +385,54 @@ export function App() {
|
|||||||
navigate("/login", true);
|
navigate("/login", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newChat() {
|
/** Stream one assistant reply for an exact history; resolves its text. */
|
||||||
try {
|
async function streamReply(history: ChatMessage[], convId: string): Promise<string> {
|
||||||
setArchivedConv(null);
|
// "Thinking" feature flag: decorate the outgoing payload only — the
|
||||||
const res = await api.createConv({ model });
|
// system message is never shown nor persisted client-side.
|
||||||
await refreshConvs(res.conversation.id);
|
const outMessages = featureEnabled("thinking")
|
||||||
} catch {
|
? [
|
||||||
// fall back to local-only pending state
|
{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage,
|
||||||
}
|
...history,
|
||||||
|
]
|
||||||
|
: history;
|
||||||
|
let full = "";
|
||||||
|
await api.chatStream({ model, messages: outMessages, conversationId: convId }, (t) => {
|
||||||
|
full += t;
|
||||||
|
setStreaming(full);
|
||||||
|
});
|
||||||
|
return full;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(text: string) {
|
async function send(text: string) {
|
||||||
if (sending || !model || archivedConv) return;
|
if (sending || !model || archivedConv) {
|
||||||
|
// Single flight: another conversation is streaming — say so instead
|
||||||
|
// of silently swallowing the submit.
|
||||||
|
if (sending && sendingForRef.current && sendingForRef.current !== activeIdRef.current) {
|
||||||
|
pushToast(t("chat.busyOther"), { icon: "model" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let convId = activeId;
|
let convId = activeId;
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
try {
|
try {
|
||||||
const res = await api.createConv({ model });
|
const res = await api.createConv({ model });
|
||||||
convId = res.conversation.id;
|
convId = res.conversation.id;
|
||||||
|
// Claim the new conversation synchronously (before any awaited
|
||||||
|
// render): the URL is still bare /chat while the selection already
|
||||||
|
// points at the new id, and sendingForRef shields both until the
|
||||||
|
// stream lands. Then refresh the list and move the URL to /chat/<id>.
|
||||||
|
setFlight(convId);
|
||||||
|
setActiveId(convId);
|
||||||
await refreshConvs(convId);
|
await refreshConvs(convId);
|
||||||
|
navigate(`/chat/${convId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setFlight(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setFlight(convId);
|
||||||
}
|
}
|
||||||
const userMsg: ChatMessage = { role: "user", content: text };
|
const userMsg: ChatMessage = { role: "user", content: text, model };
|
||||||
// Attachments travel as reviewed text blocks appended to the message.
|
// Attachments travel as reviewed text blocks appended to the message.
|
||||||
const blocks = attachments.map(
|
const blocks = attachments.map(
|
||||||
(a) =>
|
(a) =>
|
||||||
@@ -288,38 +443,86 @@ export function App() {
|
|||||||
);
|
);
|
||||||
if (blocks.length > 0)
|
if (blocks.length > 0)
|
||||||
userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
|
userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
|
||||||
const history = [...messages, userMsg];
|
// A trailing error bubble is display-only: never send the fake error
|
||||||
|
// text to the model, whether it was just created or restored.
|
||||||
|
const cleanBase =
|
||||||
|
failed && failed.convId === convId ? messages.filter((_, i) => i !== failed.index) : messages;
|
||||||
|
const history = [...cleanBase, userMsg];
|
||||||
setMessages(history);
|
setMessages(history);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
let full = "";
|
|
||||||
try {
|
try {
|
||||||
// "Thinking" feature flag: decorate the outgoing payload only — the
|
const full = await streamReply(history, convId!);
|
||||||
// system message is never shown nor persisted client-side.
|
// Only apply to the conversation still on screen: if the user
|
||||||
const outMessages = featureEnabled("thinking")
|
// switched chats mid-stream, the reply is already persisted
|
||||||
? [
|
// server-side and the other view reloads it on selection.
|
||||||
{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage,
|
if (activeIdRef.current === convId) {
|
||||||
...history,
|
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||||
]
|
}
|
||||||
: history;
|
|
||||||
await api.chatStream(
|
|
||||||
{ model, messages: outMessages, conversationId: convId! },
|
|
||||||
(t) => {
|
|
||||||
full += t;
|
|
||||||
setStreaming(full);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setMessages([...history, { role: "assistant", content: full }]);
|
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
await refreshConvs();
|
clearFailedEntry(convId!);
|
||||||
|
// Explicit select: never let the post-stream refresh drift the
|
||||||
|
// selection (or the URL) away from this conversation.
|
||||||
|
await refreshConvs(convId!);
|
||||||
|
if (activeIdRef.current === convId) navigate(`/chat/${convId}`);
|
||||||
} catch {
|
} catch {
|
||||||
setMessages([
|
if (activeIdRef.current === convId) {
|
||||||
...history,
|
setMessages([
|
||||||
{ role: "assistant", content: t("attach.chatError") },
|
...history,
|
||||||
]);
|
{ role: "assistant", content: t("attach.chatError") },
|
||||||
|
]);
|
||||||
|
setFailed({ index: history.length, convId: convId! });
|
||||||
|
}
|
||||||
|
// Persist the failure browser-side so the bubble + retry survive a
|
||||||
|
// refresh (nothing fake is written server-side).
|
||||||
|
setFailedEntry(convId!, { index: history.length, retryCount: 0, at: Date.now() });
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
} finally {
|
} finally {
|
||||||
|
if (sendingForRef.current === convId) setFlight(null);
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run the same prompt after a model error (footer retry arrow on the
|
||||||
|
* error bubble). The trailing error message is dropped and the untouched
|
||||||
|
* history is streamed again — at most MAX_RETRIES times per prompt.
|
||||||
|
*/
|
||||||
|
async function retryFailed() {
|
||||||
|
if (sending || !failed || retryCount >= MAX_RETRIES || !model || archivedConv) return;
|
||||||
|
const history = messages.slice(0, failed.index);
|
||||||
|
const convId = failed.convId;
|
||||||
|
const nextCount = retryCount + 1;
|
||||||
|
setFlight(convId);
|
||||||
|
setMessages(history);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(nextCount);
|
||||||
|
setSending(true);
|
||||||
|
setStreaming("");
|
||||||
|
try {
|
||||||
|
const full = await streamReply(history, convId);
|
||||||
|
if (activeIdRef.current === convId) {
|
||||||
|
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||||
|
}
|
||||||
|
setRetryCount(0);
|
||||||
|
clearFailedEntry(convId);
|
||||||
|
await refreshConvs(convId);
|
||||||
|
if (activeIdRef.current === convId) navigate(`/chat/${convId}`);
|
||||||
|
} catch {
|
||||||
|
if (activeIdRef.current === convId) {
|
||||||
|
setMessages([
|
||||||
|
...history,
|
||||||
|
{ role: "assistant", content: t("attach.chatError") },
|
||||||
|
]);
|
||||||
|
setFailed({ index: history.length, convId });
|
||||||
|
}
|
||||||
|
setFailedEntry(convId, { index: history.length, retryCount: nextCount, at: Date.now() });
|
||||||
|
setStreaming("");
|
||||||
|
} finally {
|
||||||
|
if (sendingForRef.current === convId) setFlight(null);
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,6 +594,7 @@ export function App() {
|
|||||||
async function removeConv(c: Conversation) {
|
async function removeConv(c: Conversation) {
|
||||||
try {
|
try {
|
||||||
await api.deleteConv(c.id);
|
await api.deleteConv(c.id);
|
||||||
|
clearFailedEntry(c.id);
|
||||||
if (activeId === c.id) {
|
if (activeId === c.id) {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -399,7 +603,9 @@ export function App() {
|
|||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
await refreshConvs();
|
const next = await refreshConvs();
|
||||||
|
navigate(next ? `/chat/${next}` : "/chat");
|
||||||
|
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -413,7 +619,9 @@ export function App() {
|
|||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
await refreshConvs();
|
const next = await refreshConvs();
|
||||||
|
navigate(next ? `/chat/${next}` : "/chat");
|
||||||
|
pushToast(t("toast.chatArchived", { title: c.title }), { icon: "archive" });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -426,6 +634,8 @@ export function App() {
|
|||||||
setArchivedConv(c);
|
setArchivedConv(c);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
// Archived views always live on bare /chat (no per-conversation URL).
|
||||||
|
navigate("/chat");
|
||||||
api
|
api
|
||||||
.getConv(c.id)
|
.getConv(c.id)
|
||||||
.then((res) => setMessages(res.messages))
|
.then((res) => setMessages(res.messages))
|
||||||
@@ -438,8 +648,10 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
await api.unarchiveConv(archivedConv.id);
|
await api.unarchiveConv(archivedConv.id);
|
||||||
const id = archivedConv.id;
|
const id = archivedConv.id;
|
||||||
|
pushToast(t("toast.chatUnarchived", { title: archivedConv.title }), { icon: "unarchive" });
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
await refreshConvs(id);
|
await refreshConvs(id);
|
||||||
|
navigate(`/chat/${id}`);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -453,10 +665,30 @@ export function App() {
|
|||||||
if (deletedId && archivedConv?.id === deletedId) {
|
if (deletedId && archivedConv?.id === deletedId) {
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
navigate("/chat");
|
||||||
}
|
}
|
||||||
await refreshConvs();
|
await refreshConvs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings "Providers" changed (key saved/removed): reload the model menu
|
||||||
|
* so connected providers appear (or disappear) at the top without a
|
||||||
|
* page refresh. The current pick survives when still available.
|
||||||
|
*/
|
||||||
|
async function handleProvidersChanged() {
|
||||||
|
try {
|
||||||
|
const m = await api.models();
|
||||||
|
setModels(m.models);
|
||||||
|
setModel((cur) =>
|
||||||
|
cur && m.models.some((x) => x.id === cur)
|
||||||
|
? cur
|
||||||
|
: pickModel(m.models, user ? getSavedModel(user.username) : null),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore (session errors reload the page via api layer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auth pages render instantly (no splash); the platform boots behind one.
|
// Auth pages render instantly (no splash); the platform boots behind one.
|
||||||
if (path === "/login") {
|
if (path === "/login") {
|
||||||
if (user) return null; // bounce to /chat imminent
|
if (user) return null; // bounce to /chat imminent
|
||||||
@@ -472,7 +704,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// / (or unknown paths) while the guard above redirects.
|
// / (or unknown paths) while the guard above redirects.
|
||||||
if (path !== "/chat") {
|
if (!isChatPath(path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,12 +724,20 @@ export function App() {
|
|||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggle={() => setSidebarCollapsed(true)}
|
onToggle={() => setSidebarCollapsed(true)}
|
||||||
onNew={() => {
|
onNew={() => {
|
||||||
newChat();
|
// Bare /chat with an empty composer; the conversation is
|
||||||
|
// created lazily on send (then the URL becomes /chat/<id>).
|
||||||
|
setArchivedConv(null);
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
|
navigate("/chat");
|
||||||
setMobileNav(false);
|
setMobileNav(false);
|
||||||
}}
|
}}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
|
navigate(`/chat/${id}`);
|
||||||
setMobileNav(false);
|
setMobileNav(false);
|
||||||
}}
|
}}
|
||||||
onRename={setEditConv}
|
onRename={setEditConv}
|
||||||
@@ -520,11 +760,20 @@ export function App() {
|
|||||||
user={user}
|
user={user}
|
||||||
conv={active}
|
conv={active}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
streaming={streaming}
|
// Streaming / thinking / sending UI lives only in the conversation
|
||||||
sending={sending}
|
// being generated for — anywhere else renders clean and stays usable.
|
||||||
|
streaming={sendingFor === activeId ? streaming : ""}
|
||||||
|
sending={sending && sendingFor === activeId}
|
||||||
models={models}
|
models={models}
|
||||||
model={model}
|
model={model}
|
||||||
onModelChange={setModel}
|
onModelChange={(id) => {
|
||||||
|
setModel(id);
|
||||||
|
setSavedModel(user.username, id);
|
||||||
|
pushToast(t("toast.modelChanged", { label: models.find((m) => m.id === id)?.label ?? id }), {
|
||||||
|
icon: "model",
|
||||||
|
tag: "model",
|
||||||
|
});
|
||||||
|
}}
|
||||||
onSend={send}
|
onSend={send}
|
||||||
onShare={() => active && setShareConv(active)}
|
onShare={() => active && setShareConv(active)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
@@ -535,10 +784,15 @@ export function App() {
|
|||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
attachError={attachError}
|
attachError={attachError}
|
||||||
ocrAvailable={ocrAvailable}
|
ocrAvailable={ocrAvailable}
|
||||||
|
reportsEnabled={reportsEnabled}
|
||||||
onRemoveAttachment={(id) =>
|
onRemoveAttachment={(id) =>
|
||||||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||||
}
|
}
|
||||||
onPickFile={handlePickFile}
|
onPickFile={handlePickFile}
|
||||||
|
failedIndex={failed && failed.convId === activeId ? failed.index : null}
|
||||||
|
retryCount={retryCount}
|
||||||
|
maxRetries={MAX_RETRIES}
|
||||||
|
onRetry={readOnly ? undefined : retryFailed}
|
||||||
/>
|
/>
|
||||||
<FilePreviewModal
|
<FilePreviewModal
|
||||||
preview={filePreview}
|
preview={filePreview}
|
||||||
@@ -554,6 +808,7 @@ export function App() {
|
|||||||
onClose={() => setEditConv(null)}
|
onClose={() => setEditConv(null)}
|
||||||
onSaved={(c) => {
|
onSaved={(c) => {
|
||||||
setConvs((prev) => prev.map((x) => (x.id === c.id ? c : x)));
|
setConvs((prev) => prev.map((x) => (x.id === c.id ? c : x)));
|
||||||
|
pushToast(t("toast.convUpdated", { title: c.title }), { icon: "edit" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ShareModal conv={shareConv} onClose={() => setShareConv(null)} />
|
<ShareModal conv={shareConv} onClose={() => setShareConv(null)} />
|
||||||
@@ -566,8 +821,14 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
user={settingsOpen ? user : null}
|
user={settingsOpen ? user : null}
|
||||||
|
usernameChangeEnabled={usernameChangeEnabled}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
onSaved={(u) => {
|
onSaved={(u) => {
|
||||||
|
if (u.theme !== user?.theme) {
|
||||||
|
pushToast(t("toast.themeChanged"), { icon: "theme", tag: "theme" });
|
||||||
|
} else {
|
||||||
|
pushToast(t("toast.settingsSaved"), { icon: "check" });
|
||||||
|
}
|
||||||
setUser(u);
|
setUser(u);
|
||||||
applyTheme(u.theme);
|
applyTheme(u.theme);
|
||||||
}}
|
}}
|
||||||
@@ -578,7 +839,9 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
onViewArchived={openArchived}
|
onViewArchived={openArchived}
|
||||||
onArchivedChanged={handleArchivedChanged}
|
onArchivedChanged={handleArchivedChanged}
|
||||||
|
onProvidersChanged={handleProvidersChanged}
|
||||||
/>
|
/>
|
||||||
|
<Toasts />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } fr
|
|||||||
import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
||||||
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
@@ -16,6 +17,22 @@ const STATUS_STYLE: Record<AccessStatus, { cls: string }> = {
|
|||||||
type Filter = "all" | AccessStatus;
|
type Filter = "all" | AccessStatus;
|
||||||
const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"];
|
const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message ids already applied (WS echo + HTTP response race). Shared by the
|
||||||
|
* list (count bumps) and the open thread (appends) so each message lands
|
||||||
|
* exactly once no matter which arrives first.
|
||||||
|
*/
|
||||||
|
const seenAccessMsgIds = new Set<number>();
|
||||||
|
function markAccessMsgSeen(msg: AccessMessage): boolean {
|
||||||
|
if (seenAccessMsgIds.has(msg.id)) return false;
|
||||||
|
seenAccessMsgIds.add(msg.id);
|
||||||
|
if (seenAccessMsgIds.size > 1000) {
|
||||||
|
const oldest = seenAccessMsgIds.values().next().value;
|
||||||
|
if (oldest !== undefined) seenAccessMsgIds.delete(oldest);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Admin triage for the access-request wishlist: accept / refuse / review. */
|
/** Admin triage for the access-request wishlist: accept / refuse / review. */
|
||||||
export function AccessRequestsPanel() {
|
export function AccessRequestsPanel() {
|
||||||
const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]);
|
const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]);
|
||||||
@@ -24,11 +41,23 @@ export function AccessRequestsPanel() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
// State mirrors for the WS handler (no side effects inside updaters).
|
||||||
|
const rowsRef = useRef(requests);
|
||||||
|
const selectedRef = useRef(selectedId);
|
||||||
|
rowsRef.current = requests;
|
||||||
|
selectedRef.current = selectedId;
|
||||||
|
function setRows(fn: (prev: (AccessRequest & { message_count: number })[]) => (AccessRequest & { message_count: number })[]): void {
|
||||||
|
setRequests((prev) => {
|
||||||
|
const next = fn(prev);
|
||||||
|
rowsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(select?: string) {
|
async function refresh(select?: string) {
|
||||||
try {
|
try {
|
||||||
const res = await api.adminAccessList();
|
const res = await api.adminAccessList();
|
||||||
setRequests(res.requests);
|
setRows(() => res.requests);
|
||||||
if (select) setSelectedId(select);
|
if (select) setSelectedId(select);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||||
@@ -39,14 +68,40 @@ export function AccessRequestsPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
// Live list: the admin firehose pushes every access event (shares its
|
// Live list: the admin firehose pushes full payloads, so rows update
|
||||||
// socket with the AdminCenter badge via the pool). Resync on
|
// incrementally with zero HTTP (shares its socket with the AdminCenter
|
||||||
// (re)connect covers events missed while offline.
|
// badge via the pool). Full resync only on (re)connect (offline gap).
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||||
onEvent: (evt) => {
|
onEvent: (evt) => {
|
||||||
if (evt.type !== "pong") refresh();
|
if (evt.type === "pong") return;
|
||||||
|
if (evt.type === "access_created") {
|
||||||
|
markAccessMsgSeen(evt.message);
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.some((r) => r.id === evt.request_id)
|
||||||
|
? prev
|
||||||
|
: [{ ...evt.request, message_count: evt.message_count }, ...prev],
|
||||||
|
);
|
||||||
|
} else if (evt.type === "access_message") {
|
||||||
|
if (!markAccessMsgSeen(evt.message)) return;
|
||||||
|
const row = rowsRef.current.find((r) => r.id === evt.request_id);
|
||||||
|
if (row && evt.message.author === "user" && selectedRef.current !== evt.request_id) {
|
||||||
|
pushToast(t("live.newMessage", { user: row.username }), { icon: "mail" });
|
||||||
|
}
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === evt.request_id ? { ...r, message_count: r.message_count + 1 } : r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (evt.type === "access_status") {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === evt.request_id ? { ...evt.request, message_count: r.message_count } : r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Report events: the ReportsPanel owns them; badges own the counts.
|
||||||
},
|
},
|
||||||
onSync: () => {
|
onSync: () => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -121,7 +176,7 @@ export function AccessRequestsPanel() {
|
|||||||
{t(`access.status.${r.status}` as StringKey)}
|
{t(`access.status.${r.status}` as StringKey)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
|
{selectedId === r.id && <TicketDetail request={r} onUpdated={(updated) => setRows((prev) => prev.map((x) => x.id === updated.id ? { ...updated, message_count: x.message_count } : x))} />}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -129,7 +184,7 @@ export function AccessRequestsPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) {
|
function TicketDetail({ request, onUpdated }: { request: AccessRequest; onUpdated: (r: AccessRequest) => void }) {
|
||||||
const [messages, setMessages] = useState<AccessMessage[]>([]);
|
const [messages, setMessages] = useState<AccessMessage[]>([]);
|
||||||
const [reason, setReason] = useState(request.reason);
|
const [reason, setReason] = useState(request.reason);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@@ -137,29 +192,19 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||||
// Latest callbacks / server state without restarting the poll loop.
|
|
||||||
const onChangedRef = useRef(onChanged);
|
|
||||||
onChangedRef.current = onChanged;
|
|
||||||
const known = useRef({ status: request.status, reason: request.reason });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReason(request.reason);
|
setReason(request.reason);
|
||||||
setFreshKey(null);
|
setFreshKey(null);
|
||||||
known.current = { status: request.status, reason: request.reason };
|
// Live thread: requester replies arrive instantly with their full
|
||||||
// Live thread: requester replies arrive instantly, and a status change
|
// payload (no refetch); status changes made elsewhere arrive on the
|
||||||
// made elsewhere syncs the list (badges, filters). Resync on
|
// parent's firehose and flow back down as props. Full reload only on
|
||||||
// (re)connect covers events missed while offline.
|
// (re)connect (offline gap).
|
||||||
async function fetchOnce() {
|
async function fetchOnce() {
|
||||||
try {
|
try {
|
||||||
const res = await api.adminAccessGet(request.id);
|
const res = await api.adminAccessGet(request.id);
|
||||||
|
for (const m of res.messages) markAccessMsgSeen(m);
|
||||||
setMessages(res.messages);
|
setMessages(res.messages);
|
||||||
if (
|
|
||||||
res.request.status !== known.current.status ||
|
|
||||||
res.request.reason !== known.current.reason
|
|
||||||
) {
|
|
||||||
known.current = { status: res.request.status, reason: res.request.reason };
|
|
||||||
onChangedRef.current();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Transient failure: keep last state.
|
// Transient failure: keep last state.
|
||||||
}
|
}
|
||||||
@@ -167,15 +212,14 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
fetchOnce();
|
fetchOnce();
|
||||||
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
|
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
|
||||||
onEvent: (evt) => {
|
onEvent: (evt) => {
|
||||||
if (evt.type === "pong" || evt.request_id !== request.id) return;
|
// Report events only travel the admin firehose — never ticket sockets.
|
||||||
|
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
|
||||||
|
if (evt.request_id !== request.id) return;
|
||||||
if (evt.type === "access_message") {
|
if (evt.type === "access_message") {
|
||||||
const msg = evt.message;
|
if (!markAccessMsgSeen(evt.message)) return;
|
||||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
|
setMessages((prev) => [...prev, evt.message]);
|
||||||
onChangedRef.current();
|
|
||||||
} else if (evt.type === "access_status") {
|
|
||||||
known.current = { status: evt.request.status, reason: evt.request.reason };
|
|
||||||
onChangedRef.current();
|
|
||||||
}
|
}
|
||||||
|
// access_status flows via the parent firehose -> onUpdated -> props.
|
||||||
},
|
},
|
||||||
onSync: () => {
|
onSync: () => {
|
||||||
fetchOnce();
|
fetchOnce();
|
||||||
@@ -192,8 +236,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
try {
|
try {
|
||||||
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
|
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
|
||||||
if (res.key) setFreshKey(res.key);
|
if (res.key) setFreshKey(res.key);
|
||||||
known.current = { status: res.request.status, reason: res.request.reason };
|
onUpdated(res.request);
|
||||||
onChanged();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("access.updateFailed"));
|
setError(err instanceof Error ? err.message : t("access.updateFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -209,9 +252,10 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await api.adminAccessReply(request.id, body);
|
const res = await api.adminAccessReply(request.id, body);
|
||||||
setMessages((prev) => [...prev, res.message]);
|
// The broadcast echo of our own message may already be applied —
|
||||||
|
// the shared seen-set keeps exactly one copy either way.
|
||||||
|
if (markAccessMsgSeen(res.message)) setMessages((prev) => [...prev, res.message]);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
onChanged();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("access.sendFailed"));
|
setError(err instanceof Error ? err.message : t("access.sendFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
|
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Plus, Send, ShieldAlert, SlidersHorizontal, Trash2, TrendingUp, Users } from "lucide-react";
|
||||||
import { api, getToken } from "../lib/api.ts";
|
import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus, type VoicePreview } from "../lib/api.ts";
|
||||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
import { AdminPanel } from "./AdminPanel.tsx";
|
import { AdminPanel } from "./AdminPanel.tsx";
|
||||||
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||||
import { ReportsPanel } from "./ReportsPanel.tsx";
|
import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||||
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
import { ModelsPanel } from "./ModelsPanel.tsx";
|
||||||
|
import { StatsSection } from "./StatsSection.tsx";
|
||||||
|
import { Toasts } from "./Toasts.tsx";
|
||||||
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
|
import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Picker, Spinner, Switch } from "./ui.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { useT } from "../lib/i18n.ts";
|
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||||
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
|
import { OPENAI_STATIC_VOICES } from "../lib/voice.ts";
|
||||||
|
|
||||||
type Tab = "accounts" | "access" | "reports" | "features" | "mail";
|
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
|
||||||
|
|
||||||
|
const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "stats", "features", "mail"];
|
||||||
|
|
||||||
|
function validTab(v: string | null | undefined): Tab {
|
||||||
|
return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts";
|
||||||
|
}
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
function applyTheme(theme: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
const dark = resolveThemeDark(theme);
|
||||||
root.classList.toggle("dark", dark);
|
root.classList.toggle("dark", dark);
|
||||||
root.style.colorScheme = dark ? "dark" : "light";
|
root.style.colorScheme = dark ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminCenter() {
|
export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [allowed, setAllowed] = useState<boolean | null>(null);
|
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||||
const [tab, setTab] = useState<Tab>("accounts");
|
const [tab, setTab] = useState<Tab>(() => validTab(initialTab));
|
||||||
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [openAccessCount, setOpenAccessCount] = useState(0);
|
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||||
const [openReportCount, setOpenReportCount] = useState(0);
|
const [openReportCount, setOpenReportCount] = useState(0);
|
||||||
|
// Deep links (/admin, /admin/models, …): unknown sections bounce to /admin.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTab !== null && initialTab !== undefined && !VALID_TABS.includes(initialTab)) {
|
||||||
|
navigate("/admin", true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function selectTab(id: Tab): void {
|
||||||
|
setTab(id);
|
||||||
|
navigate(id === "accounts" ? "/admin" : `/admin/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full badge resync (HTTP) — only for mount, reconnect gaps and rare
|
||||||
|
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
|
||||||
|
const resyncBadgesRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
||||||
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
||||||
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
|
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
|
||||||
{ id: "reports", label: t("center.tabReports"), icon: Flag },
|
{ id: "reports", label: t("center.tabReports"), icon: Flag },
|
||||||
|
{ id: "models", label: t("center.tabModels"), icon: Boxes },
|
||||||
|
{ id: "stats", label: t("center.tabStats"), icon: TrendingUp },
|
||||||
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
||||||
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let timer: number | undefined;
|
||||||
api.verify()
|
api.verify()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.user.isAdmin) {
|
if (!r.user.isAdmin) {
|
||||||
@@ -45,48 +77,92 @@ export function AdminCenter() {
|
|||||||
}
|
}
|
||||||
setAllowed(true);
|
setAllowed(true);
|
||||||
applyTheme(r.user.theme);
|
applyTheme(r.user.theme);
|
||||||
|
if (r.user.theme === "sunset") {
|
||||||
|
const schedule = (): void => {
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
applyTheme("sunset");
|
||||||
|
schedule();
|
||||||
|
}, msUntilNextSolarSwitch());
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
||||||
})
|
})
|
||||||
.catch(() => setAllowed(false));
|
.catch(() => setAllowed(false));
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Live badges on the Access/Reports tabs: the admin firehose pushes every access
|
// Live badges on the Access/Reports tabs: the admin firehose pushes full
|
||||||
// event, so the open counts stay fresh with zero polling (reports re-fetch
|
// payloads, so the open counts update incrementally with zero HTTP.
|
||||||
// alongside). Resync on (re)connect covers events missed while offline.
|
// Resync on (re)connect covers events missed while offline.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allowed) return;
|
if (!allowed) return;
|
||||||
async function fetchCount() {
|
const accessById = new Map<string, AccessStatus>();
|
||||||
|
const reportById = new Map<string, { status: ReportStatus; shadowbanned: number }>();
|
||||||
|
function recount() {
|
||||||
|
let access = 0;
|
||||||
|
for (const s of accessById.values()) if (s === "pending" || s === "reviewing") access++;
|
||||||
|
let reports = 0;
|
||||||
|
for (const r of reportById.values()) {
|
||||||
|
if ((r.status === "open" || r.status === "reviewing") && r.shadowbanned !== 1) reports++;
|
||||||
|
}
|
||||||
|
setOpenAccessCount(access);
|
||||||
|
setOpenReportCount(reports);
|
||||||
|
}
|
||||||
|
async function resync() {
|
||||||
try {
|
try {
|
||||||
const res = await api.adminAccessList();
|
const res = await api.adminAccessList();
|
||||||
setOpenAccessCount(
|
accessById.clear();
|
||||||
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
|
for (const r of res.requests) accessById.set(r.id, r.status);
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Transient failure: keep last count.
|
// Transient failure: keep last counts.
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.adminReportList();
|
const res = await api.adminReportList();
|
||||||
setOpenReportCount(
|
reportById.clear();
|
||||||
res.reports.filter((r) => (r.status === "open" || r.status === "reviewing") && r.reporter_shadowbanned !== 1).length,
|
for (const r of res.reports) {
|
||||||
);
|
reportById.set(r.id, { status: r.status, shadowbanned: r.reporter_shadowbanned });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Transient failure: keep last count.
|
// Transient failure: keep last counts.
|
||||||
}
|
}
|
||||||
|
recount();
|
||||||
}
|
}
|
||||||
fetchCount();
|
resyncBadgesRef.current = resync;
|
||||||
|
resync();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||||
onEvent: (evt) => {
|
onEvent: (evt) => {
|
||||||
if (evt.type !== "pong") fetchCount();
|
if (evt.type === "pong" || evt.type === "access_message") return;
|
||||||
|
if (evt.type === "access_created") {
|
||||||
|
accessById.set(evt.request_id, evt.request.status);
|
||||||
|
recount();
|
||||||
|
pushToast(t("live.newRequest", { user: evt.request.username }), { icon: "inbox" });
|
||||||
|
} else if (evt.type === "access_status") {
|
||||||
|
accessById.set(evt.request_id, evt.request.status);
|
||||||
|
recount();
|
||||||
|
} else if (evt.type === "report_created") {
|
||||||
|
reportById.set(evt.report.id, { status: evt.report.status, shadowbanned: evt.report.reporter_shadowbanned });
|
||||||
|
recount();
|
||||||
|
pushToast(t("live.newReport", { reason: evt.report.reason }), { icon: "mail" });
|
||||||
|
} else if (evt.type === "report_status") {
|
||||||
|
const prev = reportById.get(evt.report.id);
|
||||||
|
reportById.set(evt.report.id, {
|
||||||
|
status: evt.report.status,
|
||||||
|
shadowbanned: prev?.shadowbanned ?? evt.report.reporter_shadowbanned,
|
||||||
|
});
|
||||||
|
recount();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSync: () => {
|
onSync: () => {
|
||||||
fetchCount();
|
resync();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [allowed]);
|
}, [allowed]);
|
||||||
|
|
||||||
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
|
async function toggle(patch: AdminSettingsPatch) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -131,33 +207,39 @@ export function AdminCenter() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mx-auto w-full max-w-4xl px-4 pb-3">
|
<div className="mx-auto w-full max-w-4xl px-4 pb-3">
|
||||||
<div className="flex gap-1 overflow-x-auto rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="flex gap-1 overflow-x-auto rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
{TABS.map((t) => (
|
{TABS.map((tb) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={tb.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => selectTab(tb.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||||
tab === t.id
|
tab === tb.id
|
||||||
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
: "opacity-60 hover:opacity-100",
|
: "opacity-60 hover:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<t.icon size={15} />
|
<tb.icon size={15} />
|
||||||
{t.label}
|
{tb.label}
|
||||||
{t.id === "access" && openAccessCount > 0 && (
|
{tb.id === "access" && (
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={`${t.label}: ${openAccessCount}`}
|
aria-label={`${tb.label}: ${openAccessCount}`}
|
||||||
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
|
className={cn(
|
||||||
|
"absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold leading-4 text-white shadow",
|
||||||
|
openAccessCount > 0 ? "bg-red-500" : "bg-stone-400 dark:bg-zinc-600",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{openAccessCount > 99 ? "99+" : openAccessCount}
|
{openAccessCount > 99 ? "99+" : openAccessCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{t.id === "reports" && openReportCount > 0 && (
|
{tb.id === "reports" && (
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={`${t.label}: ${openReportCount}`}
|
aria-label={`${tb.label}: ${openReportCount}`}
|
||||||
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
|
className={cn(
|
||||||
|
"absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold leading-4 text-white shadow",
|
||||||
|
openReportCount > 0 ? "bg-red-500" : "bg-stone-400 dark:bg-zinc-600",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{openReportCount > 99 ? "99+" : openReportCount}
|
{openReportCount > 99 ? "99+" : openReportCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -185,10 +267,18 @@ export function AdminCenter() {
|
|||||||
|
|
||||||
{tab === "reports" && (
|
{tab === "reports" && (
|
||||||
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
<ReportsPanel />
|
<ReportsPanel onShadowbanChanged={() => resyncBadgesRef.current()} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "models" && (
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<ModelsPanel />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "stats" && <StatsSection />}
|
||||||
|
|
||||||
{tab === "features" && (
|
{tab === "features" && (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
@@ -224,17 +314,251 @@ export function AdminCenter() {
|
|||||||
</div>
|
</div>
|
||||||
<Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
|
<Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("center.reportsTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.reportsDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.reportsTitle")} checked={settings?.reportsEnabled ?? false} onChange={(v) => toggle({ reportsEnabled: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("center.usernameChangeTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.usernameChangeDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.usernameChangeTitle")} checked={settings?.usernameChangeEnabled ?? false} onChange={(v) => toggle({ usernameChangeEnabled: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("center.ttsTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.ttsDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.ttsTitle")} checked={settings?.ttsEnabled ?? false} onChange={(v) => toggle({ ttsEnabled: v })} />
|
||||||
|
</div>
|
||||||
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
||||||
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
<VoicePreviewsCard />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "mail" && <MailPanel />}
|
{tab === "mail" && <MailPanel />}
|
||||||
</main>
|
</main>
|
||||||
|
<Toasts />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin voice-preview lines (Settings → Voice for users). Empty = reset. */
|
||||||
|
function VoicePreviewsCard() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [lines, setLines] = useState<VoicePreview[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [ttsProvider, setTtsProvider] = useState<string | null>(null);
|
||||||
|
const [ttsAvailable, setTtsAvailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminGetSettings()
|
||||||
|
.then((r) => {
|
||||||
|
setLines(r.settings.voicePreviews);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setLoaded(true));
|
||||||
|
api.ttsInfo()
|
||||||
|
.then((r) => {
|
||||||
|
setServerVoices(r.voices);
|
||||||
|
setTtsProvider(r.provider);
|
||||||
|
setTtsAvailable(r.available);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(next: VoicePreview[]) {
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.adminPatchSettings({ voicePreviews: next });
|
||||||
|
setLines(res.settings.voicePreviews);
|
||||||
|
setMsg({ ok: true, text: t("vfeat.saved") });
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok: false, text: e instanceof Error ? e.message : t("vfeat.invalid") });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit one line after editing (empty text drops the row). */
|
||||||
|
function commit(i: number) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line) return;
|
||||||
|
const text = line.text.trim();
|
||||||
|
if (text === "") {
|
||||||
|
const next = lines.filter((_, j) => j !== i);
|
||||||
|
if (next.length === 0) {
|
||||||
|
setMsg({ ok: false, text: t("vfeat.invalid") });
|
||||||
|
void refreshLines();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void save(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void save(lines.map((l, j) => (j === i ? { ...l, text } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(i: number, p: Partial<VoicePreview>) {
|
||||||
|
setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...p } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLines() {
|
||||||
|
try {
|
||||||
|
const r = await api.adminGetSettings();
|
||||||
|
setLines(r.settings.voicePreviews);
|
||||||
|
} catch {
|
||||||
|
// keep current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="text-sm font-medium">{t("vfeat.title")}</p>
|
||||||
|
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("vfeat.desc")}</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<li key={i} className="rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
value={line.text}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={500}
|
||||||
|
aria-label={t("vfeat.line", { n: i + 1 })}
|
||||||
|
onChange={(e) => patch(i, { text: e.target.value })}
|
||||||
|
onBlur={() => commit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || lines.length <= 1}
|
||||||
|
onClick={() => void save(lines.filter((_, j) => j !== i))}
|
||||||
|
aria-label={t("vfeat.remove", { n: i + 1 })}
|
||||||
|
className="shrink-0 rounded-full p-2 text-red-600 transition hover:bg-red-50 disabled:opacity-30 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-end gap-x-4 gap-y-2">
|
||||||
|
<span className="w-36">
|
||||||
|
<Field label={t("vfeat.timbre")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("vfeat.timbre")}
|
||||||
|
value={line.timbre}
|
||||||
|
onChange={(v) => {
|
||||||
|
patch(i, { timbre: v as VoicePreview["timbre"] });
|
||||||
|
// Persist timbre/rate/pitch tweaks live (text commits on blur).
|
||||||
|
const next = lines.map((l, j) => (j === i ? { ...l, timbre: v as VoicePreview["timbre"] } : l));
|
||||||
|
if (next[i]!.text.trim() !== "") void save(next);
|
||||||
|
}}
|
||||||
|
align="left"
|
||||||
|
options={(["masculine", "feminine", "any"] as const).map((v) => ({ value: v, label: t(`voice.timbre.${v}` as StringKey) }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</span>
|
||||||
|
<span className="w-40">
|
||||||
|
<Field label={t("vfeat.voice")} hint={ttsAvailable ? (ttsProvider ? `${ttsProvider} · ${t("vfeat.voiceHint")}` : t("vfeat.voiceHint")) : t("vfeat.noProvider")}>
|
||||||
|
{serverVoices.length > 0 || ttsProvider !== "elevenlabs" ? (
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("vfeat.voice")}
|
||||||
|
value={line.voice}
|
||||||
|
onChange={(v) => {
|
||||||
|
patch(i, { voice: v });
|
||||||
|
const next = lines.map((l, j) => (j === i ? { ...l, voice: v } : l));
|
||||||
|
if (next[i]!.text.trim() !== "") void save(next);
|
||||||
|
}}
|
||||||
|
align="left"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("vfeat.voicePh") },
|
||||||
|
...(serverVoices.length > 0
|
||||||
|
? serverVoices.map((v) => ({ value: v.id, label: v.name }))
|
||||||
|
: OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={line.voice}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder={t("vfeat.voicePh")}
|
||||||
|
aria-label={t("vfeat.voice")}
|
||||||
|
onChange={(e) => patch(i, { voice: e.target.value })}
|
||||||
|
onBlur={() => commit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</span>
|
||||||
|
<label className="min-w-32 flex-1 text-xs opacity-80">
|
||||||
|
<span className="mb-1 block font-medium">{t("voice.rate")} · {line.rate.toFixed(2)}×</span>
|
||||||
|
<input
|
||||||
|
type="range" min={0.5} max={1.5} step={0.05} value={line.rate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => patch(i, { rate: Number(e.target.value) })}
|
||||||
|
onMouseUp={() => commitSliders(i)}
|
||||||
|
onTouchEnd={() => commitSliders(i)}
|
||||||
|
className="w-full accent-[rgb(var(--ka-accent-600))]"
|
||||||
|
aria-label={t("voice.rate")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-32 flex-1 text-xs opacity-80">
|
||||||
|
<span className="mb-1 block font-medium">{t("voice.pitch")} · {line.pitch.toFixed(2)}×</span>
|
||||||
|
<input
|
||||||
|
type="range" min={0.5} max={2} step={0.05} value={line.pitch}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => patch(i, { pitch: Number(e.target.value) })}
|
||||||
|
onMouseUp={() => commitSliders(i)}
|
||||||
|
onTouchEnd={() => commitSliders(i)}
|
||||||
|
className="w-full accent-[rgb(var(--ka-accent-600))]"
|
||||||
|
aria-label={t("voice.pitch")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{lines.length < 6 && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setLines((prev) => [...prev, { text: "", lang: "auto", rate: 1, pitch: 1, timbre: "any" as const, voice: "" }])}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> {t("vfeat.add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void save([])}>
|
||||||
|
{t("vfeat.reset")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<p role={msg.ok ? "status" : "alert"} className={cn("mt-3 rounded-2xl px-4 py-2.5 text-sm", msg.ok ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")}>
|
||||||
|
{msg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function commitSliders(i: number) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line || line.text.trim() === "") return;
|
||||||
|
void save(lines.map((l, j) => (j === i ? { ...l } : l)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type SmtpInfo = {
|
type SmtpInfo = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -256,9 +580,17 @@ function MailPanel() {
|
|||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [recMax, setRecMax] = useState("5");
|
||||||
|
const [recWindow, setRecWindow] = useState("60");
|
||||||
|
const [recSaving, setRecSaving] = useState(false);
|
||||||
|
const [recMsg, setRecMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
||||||
|
api.adminGetSettings().then((r) => {
|
||||||
|
setRecMax(String(r.settings.recoveryLimitMax));
|
||||||
|
setRecWindow(String(r.settings.recoveryLimitWindowMin));
|
||||||
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function verify() {
|
async function verify() {
|
||||||
@@ -296,6 +628,27 @@ function MailPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveRecoveryLimits() {
|
||||||
|
const max = Number(recMax);
|
||||||
|
const win = Number(recWindow);
|
||||||
|
if (!Number.isInteger(max) || max < 1 || max > 100 || !Number.isInteger(win) || win < 1 || win > 1440) {
|
||||||
|
setRecMsg({ ok: false, text: t("rec.invalid") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecSaving(true);
|
||||||
|
setRecMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.adminPatchSettings({ recoveryLimitMax: max, recoveryLimitWindowMin: win });
|
||||||
|
setRecMax(String(res.settings.recoveryLimitMax));
|
||||||
|
setRecWindow(String(res.settings.recoveryLimitWindowMin));
|
||||||
|
setRecMsg({ ok: true, text: t("rec.saved") });
|
||||||
|
} catch (e) {
|
||||||
|
setRecMsg({ ok: false, text: e instanceof Error ? e.message : t("common.saveFailed") });
|
||||||
|
} finally {
|
||||||
|
setRecSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
@@ -393,6 +746,31 @@ function MailPanel() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="text-sm font-medium">{t("rec.title")}</p>
|
||||||
|
<p className="mt-0.5 text-sm opacity-60">{t("rec.desc")}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||||
|
<div className="w-32">
|
||||||
|
<Field label={t("rec.max")} hint={t("rec.maxHint")}>
|
||||||
|
<Input type="number" min={1} max={100} value={recMax} onChange={(e) => setRecMax(e.target.value)} disabled={recSaving} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Field label={t("rec.window")} hint={t("rec.windowHint")}>
|
||||||
|
<Input type="number" min={1} max={1440} value={recWindow} onChange={(e) => setRecWindow(e.target.value)} disabled={recSaving} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={saveRecoveryLimits} disabled={recSaving}>
|
||||||
|
{recSaving ? <Spinner size={14} /> : null} {t("rec.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{recMsg && (
|
||||||
|
<p role={recMsg.ok ? "status" : "alert"} className={cn("mt-3 rounded-2xl px-4 py-2.5 text-sm", recMsg.ok ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")}>
|
||||||
|
{recMsg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type FC, type ReactNode } from "react";
|
import { useEffect, useState, type FC, type ReactNode } from "react";
|
||||||
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
|
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Mail, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
import type { DriverModel, User } from "../lib/types.ts";
|
import type { DriverModel, User } from "../lib/types.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
@@ -190,7 +190,9 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||||
const [editTarget, setEditTarget] = useState<User | null>(null);
|
const [editTarget, setEditTarget] = useState<User | null>(null);
|
||||||
const [modelsOpen, setModelsOpen] = useState(false);
|
const [resetTarget, setResetTarget] = useState<User | null>(null);
|
||||||
|
const [resetSent, setResetSent] = useState<{ username: string; email: string } | null>(null);
|
||||||
|
const [regenTarget, setRegenTarget] = useState<User | null>(null);
|
||||||
// Listing: search + sort + filter + pagination (server-side).
|
// Listing: search + sort + filter + pagination (server-side).
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [debouncedQ, setDebouncedQ] = useState("");
|
const [debouncedQ, setDebouncedQ] = useState("");
|
||||||
@@ -263,6 +265,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
|
|
||||||
async function regenerate(u: User) {
|
async function regenerate(u: User) {
|
||||||
setError("");
|
setError("");
|
||||||
|
setFreshKey(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.adminRegenerate(u.id);
|
const res = await api.adminRegenerate(u.id);
|
||||||
setFreshKey({ username: u.username, key: res.key });
|
setFreshKey({ username: u.username, key: res.key });
|
||||||
@@ -271,6 +274,17 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendResetLink(u: User) {
|
||||||
|
setError("");
|
||||||
|
setResetSent(null);
|
||||||
|
try {
|
||||||
|
const res = await api.adminResetLink(u.id);
|
||||||
|
setResetSent({ username: u.username, email: res.email });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.resetFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(u: User) {
|
async function remove(u: User) {
|
||||||
try {
|
try {
|
||||||
await api.adminDelete(u.id);
|
await api.adminDelete(u.id);
|
||||||
@@ -304,14 +318,17 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{resetSent && (
|
||||||
|
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium"><Mail size={15} /> {t("admin.resetSent", { user: resetSent.username, email: resetSent.email })}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!showCreate ? (
|
{!showCreate ? (
|
||||||
<div className="mb-5 flex flex-wrap gap-2">
|
<div className="mb-5 flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
|
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
|
||||||
<UserPlus size={15} /> {t("admin.newAccount")}
|
<UserPlus size={15} /> {t("admin.newAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}>
|
|
||||||
<Bot size={15} /> {t("ollama.models")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
|
<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">
|
||||||
@@ -375,7 +392,8 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
{!u.isAdmin && (
|
{!u.isAdmin && (
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
|
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
|
||||||
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
<button title={t("admin.regenKey")} onClick={() => setRegenTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||||
|
<button title={t("admin.resetLink")} onClick={() => setResetTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Mail size={15} /></button>
|
||||||
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
|
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -383,7 +401,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 flex items-center justify-between gap-2 text-sm">
|
<div className="mt-4 flex items-center justify-between gap-2 pt-1 text-sm">
|
||||||
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
|
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
@@ -414,14 +432,40 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
onConfirm={() => deleteTarget && remove(deleteTarget)}
|
onConfirm={() => deleteTarget && remove(deleteTarget)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetTarget !== null}
|
||||||
|
onClose={() => setResetTarget(null)}
|
||||||
|
title={t("admin.resetTitle", { user: resetTarget?.username ?? "" })}
|
||||||
|
message={t("admin.resetMsg", { email: resetTarget?.email || t("admin.noEmail") })}
|
||||||
|
confirmLabel={t("admin.resetConfirm")}
|
||||||
|
onConfirm={() => {
|
||||||
|
const u = resetTarget;
|
||||||
|
setResetTarget(null);
|
||||||
|
if (u) void sendResetLink(u);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={regenTarget !== null}
|
||||||
|
onClose={() => setRegenTarget(null)}
|
||||||
|
title={t("admin.regenTitle", { user: regenTarget?.username ?? "" })}
|
||||||
|
message={t("admin.regenMsg")}
|
||||||
|
confirmLabel={t("admin.regenConfirm")}
|
||||||
|
onConfirm={() => {
|
||||||
|
const u = regenTarget;
|
||||||
|
setRegenTarget(null);
|
||||||
|
if (u) void regenerate(u);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
||||||
<OllamaModelsModal open={modelsOpen} onClose={() => setModelsOpen(false)} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -430,6 +474,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
setUsername(user.username);
|
||||||
setDisplayName(user.displayName);
|
setDisplayName(user.displayName);
|
||||||
setAvatarUrl(user.avatarUrl);
|
setAvatarUrl(user.avatarUrl);
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
@@ -442,7 +487,18 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
|
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
if (normalizedUsername !== (user.username ?? "").toLowerCase()) patch.username = normalizedUsername;
|
||||||
|
if (displayName.trim() !== (user.displayName ?? "")) patch.displayName = displayName.trim();
|
||||||
|
if (avatarUrl.trim() !== (user.avatarUrl ?? "")) patch.avatarUrl = avatarUrl.trim();
|
||||||
|
if (email.trim() !== (user.email ?? "")) patch.email = email.trim();
|
||||||
|
if (theme !== (user.theme ?? "auto")) patch.theme = theme;
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.adminPatch(user.id, patch);
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -453,6 +509,9 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
return (
|
return (
|
||||||
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
|
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
|
||||||
<form onSubmit={save} className="space-y-4">
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
<Field label={t("settings.username")} hint={t("settings.usernameHint")}>
|
||||||
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} />
|
||||||
|
</Field>
|
||||||
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
||||||
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
||||||
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
||||||
@@ -464,6 +523,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
align="left"
|
align="left"
|
||||||
options={[
|
options={[
|
||||||
{ value: "auto", label: t("common.themeAuto") },
|
{ value: "auto", label: t("common.themeAuto") },
|
||||||
|
{ value: "sunset", label: t("common.themeSunset") },
|
||||||
{ value: "light", label: t("common.light") },
|
{ value: "light", label: t("common.light") },
|
||||||
{ value: "dark", label: t("common.dark") },
|
{ value: "dark", label: t("common.dark") },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, Mic, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, Square, User as UserIcon, Volume2, VolumeX, X } from "lucide-react";
|
||||||
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||||
import { api, ApiError, type ReportReason } from "../lib/api.ts";
|
import { api, ApiError, type ReportReason } from "../lib/api.ts";
|
||||||
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
import { AssistantAvatar, Avatar, Button, copyText, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
import { UserMessageContent } from "./MessageContent.tsx";
|
import { UserMessageContent } from "./MessageContent.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { useFeatures } from "../lib/features.ts";
|
import { useFeatures } from "../lib/features.ts";
|
||||||
|
import { fmtDuration, getVoicePrefs, listTtsVoices, playText, recognizerCtor, stopPlayback, sttSupported, type Recognizer } from "../lib/voice.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
|
|
||||||
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
||||||
|
|
||||||
function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void }) {
|
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry, speaking, onToggleSpeak }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void; speaking?: boolean; onToggleSpeak?: () => void }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const isUser = msg.role === "user";
|
const isUser = msg.role === "user";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
function copy() {
|
function copy() {
|
||||||
navigator.clipboard.writeText(msg.content).then(() => {
|
copyText(msg.content).then((ok) => {
|
||||||
|
if (!ok) return;
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
function searchWeb() {
|
function searchWeb() {
|
||||||
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
|
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
<div className={cn("flex gap-2 sm:gap-3", isUser && "flex-row-reverse")}>
|
||||||
{isUser
|
{isUser
|
||||||
? <Avatar name={authorName} url={authorAvatar} size={32} />
|
? <Avatar name={authorName} url={authorAvatar} size={32} />
|
||||||
: <AssistantAvatar size={32} />}
|
: <AssistantAvatar size={32} />}
|
||||||
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
<div className={cn("min-w-0 max-w-[88%] sm:max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
|
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||||
{isUser ? t("chat.you") : "KisAssistant"}
|
{isUser ? t("chat.you") : "KisAssistant"}
|
||||||
@@ -39,21 +41,51 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg
|
|||||||
<UserMessageContent content={msg.content} />
|
<UserMessageContent content={msg.content} />
|
||||||
</div>
|
</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={cn("rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900", speaking && "speaking-glow")}>
|
||||||
<Markdown text={msg.content} />
|
<Markdown text={msg.content} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isUser && onReport && (
|
{!isUser && (msg.model || onReport || onToggleSpeak || (isFailed && onRetry)) && (
|
||||||
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
||||||
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
|
{msg.model ? (
|
||||||
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
|
<span
|
||||||
</IconButton>
|
title={msg.model}
|
||||||
<IconButton title={t("msg.search")} onClick={searchWeb}>
|
className="mr-1 max-w-40 truncate rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-[11px] opacity-80 dark:bg-zinc-800"
|
||||||
<Search size={14} />
|
>
|
||||||
</IconButton>
|
{msg.model}
|
||||||
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
|
</span>
|
||||||
<Flag size={14} />
|
) : null}
|
||||||
</IconButton>
|
{onToggleSpeak && (
|
||||||
|
<IconButton title={speaking ? t("voice.stopSpeak") : t("voice.speak")} onClick={onToggleSpeak}>
|
||||||
|
{speaking ? <VolumeX size={14} className="speaking-orb text-accent-500" /> : <Volume2 size={14} />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{onReport && (
|
||||||
|
<>
|
||||||
|
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
|
||||||
|
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton title={t("msg.search")} onClick={searchWeb}>
|
||||||
|
<Search size={14} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
title={reportsEnabled ? t("msg.report") : t("msg.reportDisabled")}
|
||||||
|
onClick={() => onReport(index)}
|
||||||
|
disabled={!reportsEnabled}
|
||||||
|
>
|
||||||
|
<Flag size={14} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isFailed && onRetry && (
|
||||||
|
<IconButton
|
||||||
|
title={`${t("msg.retry")} (${Math.min(retryCount ?? 0, maxRetries ?? 0)}/${maxRetries ?? 0})`}
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retryDisabled}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +93,33 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Live voice waveform: mirrored gradient bars glowing with the input level. */
|
||||||
|
function Waveform({ levels }: { levels: number[] }) {
|
||||||
|
const BARS = 48;
|
||||||
|
const padded = [...Array(Math.max(0, BARS - levels.length)).fill(0), ...levels].slice(-BARS) as number[];
|
||||||
|
return (
|
||||||
|
<div className="flex h-12 items-center gap-[3px] px-2 pt-1" aria-hidden>
|
||||||
|
{padded.map((lv, i) => {
|
||||||
|
const mid = (BARS - 1) / 2;
|
||||||
|
const edge = 1 - Math.abs(i - mid) / mid; // taller in the middle
|
||||||
|
const h = Math.max(6, Math.min(100, (8 + lv * 92) * (0.35 + edge * 0.65)));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="wave-bar w-full rounded-full"
|
||||||
|
style={{
|
||||||
|
height: `${h}%`,
|
||||||
|
opacity: 0.35 + lv * 0.65,
|
||||||
|
background: "linear-gradient(to top, rgb(var(--ka-accent-600)), rgb(var(--ka-accent-300)))",
|
||||||
|
boxShadow: lv > 0.12 ? "0 0 8px rgb(var(--ka-accent-500) / 0.55)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
|
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -181,8 +240,13 @@ export function Chat({
|
|||||||
attachments,
|
attachments,
|
||||||
attachError,
|
attachError,
|
||||||
ocrAvailable,
|
ocrAvailable,
|
||||||
|
reportsEnabled = true,
|
||||||
onRemoveAttachment,
|
onRemoveAttachment,
|
||||||
onPickFile,
|
onPickFile,
|
||||||
|
failedIndex,
|
||||||
|
retryCount,
|
||||||
|
maxRetries,
|
||||||
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
conv: Conversation | null;
|
conv: Conversation | null;
|
||||||
@@ -203,8 +267,14 @@ export function Chat({
|
|||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
attachError: string | null;
|
attachError: string | null;
|
||||||
ocrAvailable: boolean;
|
ocrAvailable: boolean;
|
||||||
|
reportsEnabled?: boolean;
|
||||||
onRemoveAttachment: (id: string) => void;
|
onRemoveAttachment: (id: string) => void;
|
||||||
onPickFile: (kind: "ocr" | "text", file: File) => void;
|
onPickFile: (kind: "ocr" | "text", file: File) => void;
|
||||||
|
/** Trailing model-error bubble index (retry arrow), if any. */
|
||||||
|
failedIndex: number | null;
|
||||||
|
retryCount: number;
|
||||||
|
maxRetries: number;
|
||||||
|
onRetry?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [attachOpen, setAttachOpen] = useState(false);
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
@@ -215,13 +285,172 @@ export function Chat({
|
|||||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const imgRef = useRef<HTMLInputElement>(null);
|
const imgRef = useRef<HTMLInputElement>(null);
|
||||||
const txtRef = useRef<HTMLInputElement>(null);
|
const txtRef = useRef<HTMLInputElement>(null);
|
||||||
|
// TTS playback: which assistant bubble is speaking (null = silent).
|
||||||
|
const [speakingKey, setSpeakingKey] = useState<number | null>(null);
|
||||||
|
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
|
||||||
|
const ttsServerRef = useRef(false);
|
||||||
|
// Voice input: live recording session (null = composer idle).
|
||||||
|
const [rec, setRec] = useState<{
|
||||||
|
stream: MediaStream;
|
||||||
|
audioCtx: AudioContext;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
recog: Recognizer | null;
|
||||||
|
startTs: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [recLevels, setRecLevels] = useState<number[]>([]);
|
||||||
|
const [recElapsed, setRecElapsed] = useState(0);
|
||||||
|
const [recText, setRecText] = useState("");
|
||||||
|
const recRaf = useRef(0);
|
||||||
|
const recTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const micOn = sttSupported();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listTtsVoices().then((v) => { voicesRef.current = v; });
|
||||||
|
api.ttsInfo().then((r) => { ttsServerRef.current = r.available; }).catch(() => {});
|
||||||
|
return () => stopPlayback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// New message / conversation: stop any playback (never leak speech).
|
||||||
|
useEffect(() => {
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [conv?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelRec(false);
|
||||||
|
stopPlayback();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleSpeak(index: number, text: string) {
|
||||||
|
if (speakingKey === index) {
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefs = getVoicePrefs();
|
||||||
|
playText({
|
||||||
|
text,
|
||||||
|
engine: prefs.engine,
|
||||||
|
browser: { voices: voicesRef.current, lang: prefs.lang, rate: prefs.rate, pitch: prefs.pitch, voiceURI: prefs.voiceURI },
|
||||||
|
serverVoice: prefs.serverVoice ?? undefined,
|
||||||
|
serverAvailable: ttsServerRef.current,
|
||||||
|
fetchAudio: (t, v) => api.ttsSpeak(t, v),
|
||||||
|
onend: () => setSpeakingKey(null),
|
||||||
|
onerror: () => setSpeakingKey(null),
|
||||||
|
});
|
||||||
|
setSpeakingKey(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRec(r: NonNullable<typeof rec>) {
|
||||||
|
cancelAnimationFrame(recRaf.current);
|
||||||
|
if (recTimer.current) clearInterval(recTimer.current);
|
||||||
|
recTimer.current = null;
|
||||||
|
try { r.recog?.abort(); } catch { /* ignore */ }
|
||||||
|
try { r.audioCtx.close(); } catch { /* ignore */ }
|
||||||
|
for (const tr of r.stream.getTracks()) {
|
||||||
|
try { tr.stop(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRec(reset = true) {
|
||||||
|
setRec((cur) => {
|
||||||
|
if (cur) cleanupRec(cur);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (reset) {
|
||||||
|
setRecLevels([]);
|
||||||
|
setRecElapsed(0);
|
||||||
|
setRecText("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRec() {
|
||||||
|
if (rec || sending || readOnly || !micOn) return;
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const audioCtx = new AudioContext();
|
||||||
|
const src = audioCtx.createMediaStreamSource(stream);
|
||||||
|
const analyser = audioCtx.createAnalyser();
|
||||||
|
analyser.fftSize = 512;
|
||||||
|
src.connect(analyser);
|
||||||
|
const data = new Uint8Array(analyser.fftSize);
|
||||||
|
const Ctor = recognizerCtor();
|
||||||
|
const recog: Recognizer | null = Ctor ? new Ctor() : null;
|
||||||
|
let finals = "";
|
||||||
|
if (recog) {
|
||||||
|
recog.lang = navigator.language || "fr-FR";
|
||||||
|
recog.interimResults = true;
|
||||||
|
recog.continuous = true;
|
||||||
|
recog.onresult = (ev) => {
|
||||||
|
let interim = "";
|
||||||
|
for (const res of Array.from(ev.results)) {
|
||||||
|
if (res.isFinal) finals += res[0]!.transcript;
|
||||||
|
else interim += res[0]!.transcript;
|
||||||
|
}
|
||||||
|
setRecText((finals + " " + interim).trim());
|
||||||
|
};
|
||||||
|
recog.onerror = () => {};
|
||||||
|
try { recog.start(); } catch { /* already started */ }
|
||||||
|
}
|
||||||
|
const startTs = Date.now();
|
||||||
|
const session = { stream, audioCtx, analyser, recog, startTs };
|
||||||
|
setRec(session);
|
||||||
|
setRecLevels([]);
|
||||||
|
setRecText("");
|
||||||
|
setRecElapsed(0);
|
||||||
|
let lastPush = 0;
|
||||||
|
const loop = () => {
|
||||||
|
analyser.getByteTimeDomainData(data);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const v = (data[i]! - 128) / 128;
|
||||||
|
sum += v * v;
|
||||||
|
}
|
||||||
|
const level = Math.min(1, Math.sqrt(sum / data.length) * 2.4);
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastPush > 90) {
|
||||||
|
lastPush = now;
|
||||||
|
setRecLevels((prev) => [...prev.slice(-47), level]);
|
||||||
|
}
|
||||||
|
recRaf.current = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
recRaf.current = requestAnimationFrame(loop);
|
||||||
|
recTimer.current = setInterval(() => setRecElapsed((Date.now() - startTs) / 1000), 500);
|
||||||
|
} catch {
|
||||||
|
// Mic denied / unavailable: stay on the text composer.
|
||||||
|
cancelRec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRec() {
|
||||||
|
const clean = recText.trim();
|
||||||
|
setRec((cur) => {
|
||||||
|
if (cur) cleanupRec(cur);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
// Transcript lands in the composer, editable before sending.
|
||||||
|
if (clean) {
|
||||||
|
setDraft((d) => (d ? `${d.trimEnd()}\n${clean}` : clean));
|
||||||
|
requestAnimationFrame(autoGrow);
|
||||||
|
}
|
||||||
|
setRecText("");
|
||||||
|
setRecLevels([]);
|
||||||
|
setRecElapsed(0);
|
||||||
|
}
|
||||||
|
|
||||||
const modelGroups: PickerGroup[] = (() => {
|
const modelGroups: PickerGroup[] = (() => {
|
||||||
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
const group = m.group ?? m.driver;
|
// Own-key (BYOK) models get their own section so they are never
|
||||||
|
// confused with platform models: "openai · personal key".
|
||||||
|
const base = m.group ?? m.driver;
|
||||||
|
const group = m.personal ? `${base} · ${t("chat.personalTag")}` : base;
|
||||||
const list = byDriver.get(group) ?? [];
|
const list = byDriver.get(group) ?? [];
|
||||||
list.push({ value: m.id, label: m.label, hint: m.id });
|
list.push({ value: m.id, label: m.label, hint: m.personal ? `${m.id} · ${t("chat.personalTag")}` : m.id });
|
||||||
byDriver.set(group, list);
|
byDriver.set(group, list);
|
||||||
}
|
}
|
||||||
return [...byDriver.entries()].map(([group, options]) => ({ group, options }));
|
return [...byDriver.entries()].map(([group, options]) => ({ group, options }));
|
||||||
@@ -232,6 +461,14 @@ export function Chat({
|
|||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}, [messages, streaming]);
|
}, [messages, streaming]);
|
||||||
|
|
||||||
|
// A fresh message ends any playback (the glow never sticks to a stale row).
|
||||||
|
const msgCount = messages.length;
|
||||||
|
useEffect(() => {
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [msgCount]);
|
||||||
|
|
||||||
function autoGrow() {
|
function autoGrow() {
|
||||||
const ta = taRef.current;
|
const ta = taRef.current;
|
||||||
if (ta) {
|
if (ta) {
|
||||||
@@ -245,6 +482,8 @@ export function Chat({
|
|||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
requestAnimationFrame(autoGrow);
|
requestAnimationFrame(autoGrow);
|
||||||
onSend(text);
|
onSend(text);
|
||||||
@@ -264,7 +503,7 @@ export function Chat({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="hidden items-center gap-2 font-semibold sm:flex">
|
<span className="hidden items-center gap-2 font-semibold sm:flex">
|
||||||
<FlowerMark size={24} dynamic={false} />
|
<FlowerMark size={24} dynamic={sending} />
|
||||||
{conv?.title || t("sidebar.newChat")}
|
{conv?.title || t("sidebar.newChat")}
|
||||||
</span>
|
</span>
|
||||||
{conv?.topic && (
|
{conv?.topic && (
|
||||||
@@ -288,8 +527,8 @@ export function Chat({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref={boxRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-6 sm:px-8">
|
<div ref={boxRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-4 sm:space-y-5 sm:px-8 sm:py-6">
|
||||||
<div className="mx-auto w-full max-w-3xl space-y-5">
|
<div className="mx-auto w-full max-w-3xl space-y-4 sm:space-y-5">
|
||||||
{messages.length === 0 && !streaming && (
|
{messages.length === 0 && !streaming && (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
|
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
|
||||||
@@ -298,21 +537,29 @@ export function Chat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
||||||
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} />
|
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} reportsEnabled={reportsEnabled} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} speaking={speakingKey === i} onToggleSpeak={m.role === "assistant" ? () => toggleSpeak(i, m.content) : undefined} />
|
||||||
))}
|
))}
|
||||||
{streaming !== "" && (
|
{streaming !== "" && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-2 sm:gap-3">
|
||||||
<AssistantAvatar size={32} />
|
<AssistantAvatar size={32} dynamic />
|
||||||
<div className="min-w-0 max-w-[85%]">
|
<div className="min-w-0 max-w-[88%] sm:max-w-[85%]">
|
||||||
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
<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">
|
<span>KisAssistant</span>
|
||||||
|
{model ? (
|
||||||
|
<span title={model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{model}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
<div className="typing-cursor"><Markdown text={streaming} /></div>
|
<div className="typing-cursor"><Markdown text={streaming} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sending && streaming === "" && (
|
{sending && streaming === "" && (
|
||||||
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">{t("chat.thinking")}</span></div>
|
<div className="flex items-center gap-2.5 opacity-80">
|
||||||
|
<AssistantAvatar size={32} dynamic />
|
||||||
|
<span className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} />{t("chat.thinking")}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,6 +626,15 @@ export function Chat({
|
|||||||
<Archive size={17} />
|
<Archive size={17} />
|
||||||
</span>
|
</span>
|
||||||
<p className="min-w-0 flex-1 text-sm opacity-70">{t("archived.readonly")}</p>
|
<p className="min-w-0 flex-1 text-sm opacity-70">{t("archived.readonly")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShare}
|
||||||
|
aria-label={t("sidebar.share")}
|
||||||
|
title={t("sidebar.share")}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition hover:bg-stone-200/70 active:scale-95 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<Share2 size={16} className="opacity-70" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onUnarchive}
|
onClick={onUnarchive}
|
||||||
@@ -388,6 +644,35 @@ export function Chat({
|
|||||||
{t("archived.unarchive")}
|
{t("archived.unarchive")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : rec ? (
|
||||||
|
<div className="voice-recorder mx-auto w-full max-w-3xl rounded-[1.75rem] border border-accent-500/40 bg-white p-3 shadow-lg dark:border-accent-500/30 dark:bg-zinc-900">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<span className="rec-dot" aria-hidden />
|
||||||
|
<span className="font-mono text-xs opacity-70">{fmtDuration(recElapsed)}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm opacity-80" aria-live="polite">
|
||||||
|
{recText || t("voice.listening")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cancelRec()}
|
||||||
|
aria-label={t("voice.cancel")}
|
||||||
|
title={t("voice.cancel")}
|
||||||
|
className="grid h-9 w-9 shrink-0 place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={16} className="opacity-70" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopRec}
|
||||||
|
aria-label={t("voice.stop")}
|
||||||
|
title={t("voice.stop")}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
<Square size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Waveform levels={recLevels} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className={`mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${features.attachments ? "pl-2" : "pl-5"}`}>
|
<form onSubmit={submit} className={`mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${features.attachments ? "pl-2" : "pl-5"}`}>
|
||||||
{features.attachments && (
|
{features.attachments && (
|
||||||
@@ -403,6 +688,16 @@ export function Chat({
|
|||||||
)}
|
)}
|
||||||
<input ref={imgRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} />
|
<input ref={imgRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} />
|
||||||
<input ref={txtRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />
|
<input ref={txtRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startRec}
|
||||||
|
disabled={!micOn || sending}
|
||||||
|
aria-label={t("voice.mic")}
|
||||||
|
title={micOn ? t("voice.mic") : t("voice.micOff")}
|
||||||
|
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 disabled:opacity-40 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Mic size={17} className="opacity-70" />
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
ref={taRef}
|
ref={taRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { AccessRequestModal } from "./ReviewPage.tsx";
|
|||||||
|
|
||||||
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [username, setUsername] = useState("admin");
|
const [username, setUsername] = useState("");
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { CopyButton } from "./ui.tsx";
|
import { CopyButton } from "./ui.tsx";
|
||||||
|
import { highlightCode } from "../lib/highlight.tsx";
|
||||||
|
|
||||||
|
/** Raw source text of a react-markdown <code> element (children may be split). */
|
||||||
|
export function extractCodeText(children: unknown): string {
|
||||||
|
if (children === null || children === undefined) return "";
|
||||||
|
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||||
|
if (Array.isArray(children)) return children.map(extractCodeText).join("");
|
||||||
|
const props = (children as { props?: { children?: unknown } })?.props;
|
||||||
|
if (props && typeof props === "object" && "children" in props) return extractCodeText(props.children);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||||
|
const nodes = useMemo(() => highlightCode(code, lang), [code, lang]);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{lang && (
|
{lang && (
|
||||||
@@ -12,7 +25,7 @@ function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
|||||||
<CopyButton text={code} />
|
<CopyButton text={code} />
|
||||||
</div>
|
</div>
|
||||||
<pre className={lang ? "pt-9" : ""}>
|
<pre className={lang ? "pt-9" : ""}>
|
||||||
<code>{code}</code>
|
<code>{nodes}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -28,14 +41,24 @@ export function Markdown({ text }: { text: string }) {
|
|||||||
// react-markdown nests <code> inside <pre>; render our block instead.
|
// react-markdown nests <code> inside <pre>; render our block instead.
|
||||||
const child = Array.isArray(children) ? children[0] : children;
|
const child = Array.isArray(children) ? children[0] : children;
|
||||||
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
|
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
|
||||||
const className = props?.className || "";
|
if (!props || typeof props !== "object") return <pre>{children}</pre>;
|
||||||
|
const className = typeof props.className === "string" ? props.className : "";
|
||||||
const lang = className.replace("language-", "");
|
const lang = className.replace("language-", "");
|
||||||
const code = String(props?.children ?? "").replace(/\n$/, "");
|
const code = extractCodeText(props.children).replace(/\n$/, "");
|
||||||
return <CodeBlock code={code} lang={lang} />;
|
return <CodeBlock code={code} lang={lang} />;
|
||||||
},
|
},
|
||||||
code({ children }) {
|
code({ children }) {
|
||||||
return <code>{children}</code>;
|
return <code>{children}</code>;
|
||||||
},
|
},
|
||||||
|
table({ children }) {
|
||||||
|
// Wide tables would blow the bubble off-screen on mobile —
|
||||||
|
// scroll them inside their own pane instead.
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto pb-1">
|
||||||
|
<table>{children}</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function AttachmentBlock({ header, body, tone }: { header: string; body: string;
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 block overflow-hidden rounded-2xl text-left",
|
"mt-2 block max-w-full overflow-hidden rounded-2xl text-left",
|
||||||
tone === "accent" ? "bg-black/25 dark:bg-black/30" : "bg-stone-200/70 dark:bg-zinc-800",
|
tone === "accent" ? "bg-black/25 dark:bg-black/30" : "bg-stone-200/70 dark:bg-zinc-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -42,7 +42,7 @@ function AttachmentBlock({ header, body, tone }: { header: string; body: string;
|
|||||||
{open && (
|
{open && (
|
||||||
<pre
|
<pre
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-72 overflow-auto whitespace-pre-wrap break-words border-t px-3 py-2 font-mono text-[12.5px] leading-relaxed",
|
"max-h-72 max-w-full overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] border-t px-3 py-2 font-mono text-[12.5px] leading-relaxed",
|
||||||
tone === "accent" ? "border-white/10" : "border-stone-300/70 dark:border-zinc-700",
|
tone === "accent" ? "border-white/10" : "border-stone-300/70 dark:border-zinc-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Bot, Undo2 } from "lucide-react";
|
||||||
|
import { api, type PolicyModel } from "../lib/api.ts";
|
||||||
|
import { OllamaModelsModal } from "./AdminPanel.tsx";
|
||||||
|
import { NodesSection } from "./OllamaNodes.tsx";
|
||||||
|
import { CatalogSection } from "./OllamaCatalog.tsx";
|
||||||
|
import { Button, Input, Spinner, Switch } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
/** Admin models section: node pool, per-model policy, Ollama library + HF catalog. */
|
||||||
|
export function ModelsPanel() {
|
||||||
|
const [models, setModels] = useState<PolicyModel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [ollamaOpen, setOllamaOpen] = useState(false);
|
||||||
|
const { t } = useT();
|
||||||
|
|
||||||
|
async function refresh(force = false) {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await api.adminModelPolicy(force);
|
||||||
|
setModels(res.models);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function apply(updated: PolicyModel) {
|
||||||
|
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = (() => {
|
||||||
|
const byGroup = new Map<string, PolicyModel[]>();
|
||||||
|
for (const m of models) {
|
||||||
|
const g = m.group ?? m.driver;
|
||||||
|
const list = byGroup.get(g) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
byGroup.set(g, list);
|
||||||
|
}
|
||||||
|
return [...byGroup.entries()];
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<NodesSection />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{t("models.title")}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refresh(true)}
|
||||||
|
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
|
||||||
|
title={t("models.refresh")}
|
||||||
|
aria-label={t("models.refreshAria")}
|
||||||
|
>
|
||||||
|
<Undo2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-sm opacity-60">{t("models.desc")}</p>
|
||||||
|
{error && <p className="mb-3 rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||||
|
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
{!loading && models.length === 0 && !error && (
|
||||||
|
<p className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("models.empty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{groups.map(([group, list]) => (
|
||||||
|
<div key={group} className="mb-4">
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{group}</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{list.map((m) => (
|
||||||
|
<ModelRow key={m.id} model={m} onChanged={apply} onError={setError} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="text-sm font-medium">{t("models.ollamaTitle")}</p>
|
||||||
|
<p className="mt-0.5 text-sm opacity-60">{t("models.ollamaDesc")}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOllamaOpen(true)}
|
||||||
|
>
|
||||||
|
<Bot size={14} /> {t("ollama.models")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<OllamaModelsModal
|
||||||
|
open={ollamaOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setOllamaOpen(false);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CatalogSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- per-model policy rows ---------- */
|
||||||
|
|
||||||
|
function ModelRow({ model, onChanged, onError }: {
|
||||||
|
model: PolicyModel;
|
||||||
|
onChanged: (m: PolicyModel) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [hourly, setHourly] = useState(model.hourly === 0 ? "" : String(model.hourly));
|
||||||
|
const [daily, setDaily] = useState(model.daily === 0 ? "" : String(model.daily));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHourly(model.hourly === 0 ? "" : String(model.hourly));
|
||||||
|
setDaily(model.daily === 0 ? "" : String(model.daily));
|
||||||
|
}, [model.hourly, model.daily]);
|
||||||
|
|
||||||
|
async function patch(p: { enabled?: boolean; hourly?: number; daily?: number }) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.adminModelPolicyPatch(model.id, p);
|
||||||
|
onChanged(res.model);
|
||||||
|
onError("");
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : t("models.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitNumber(kind: "hourly" | "daily", raw: string) {
|
||||||
|
const v = raw.trim() === "" ? 0 : Number(raw);
|
||||||
|
if (!Number.isInteger(v) || v < 0 || v > 1000000) {
|
||||||
|
if (kind === "hourly") setHourly(model.hourly === 0 ? "" : String(model.hourly));
|
||||||
|
else setDaily(model.daily === 0 ? "" : String(model.daily));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v === model[kind]) return;
|
||||||
|
void patch({ [kind]: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 dark:border-zinc-800 dark:bg-zinc-900",
|
||||||
|
!model.enabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 basis-44">
|
||||||
|
<span className="block truncate text-sm font-medium">{model.label || model.id}</span>
|
||||||
|
<span className="block truncate font-mono text-xs opacity-50">{model.id}</span>
|
||||||
|
</span>
|
||||||
|
<label className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs opacity-80">
|
||||||
|
<span className="w-24 shrink-0">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000000}
|
||||||
|
value={hourly}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder={t("models.unlimited")}
|
||||||
|
aria-label={t("models.hourly")}
|
||||||
|
onChange={(e) => setHourly(e.target.value)}
|
||||||
|
onBlur={(e) => commitNumber("hourly", e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{t("models.hourly")}
|
||||||
|
</label>
|
||||||
|
<label className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs opacity-80">
|
||||||
|
<span className="w-24 shrink-0">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000000}
|
||||||
|
value={daily}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder={t("models.unlimited")}
|
||||||
|
aria-label={t("models.daily")}
|
||||||
|
onChange={(e) => setDaily(e.target.value)}
|
||||||
|
onBlur={(e) => commitNumber("daily", e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{t("models.daily")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
label={t("models.enabled")}
|
||||||
|
checked={model.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(v) => void patch({ enabled: v })}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Download, Globe, Server } from "lucide-react";
|
||||||
|
import { api, type EffectiveOllamaNode } from "../lib/api.ts";
|
||||||
|
import { Field, Input, Picker, Spinner } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
interface HfHit {
|
||||||
|
id: string;
|
||||||
|
downloads: number;
|
||||||
|
likes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HfFile {
|
||||||
|
name: string;
|
||||||
|
quant: string;
|
||||||
|
size: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the quantization tag from a GGUF filename (…-Q4_K_M.gguf). */
|
||||||
|
function quantOf(filename: string): string | null {
|
||||||
|
const base = filename.split("/").pop() ?? filename;
|
||||||
|
const m = base.match(/[-_.](Q\d(?:_\w+)?|IQ\d_\w+|BF16|F16|F32)(?:\.gguf)$/i);
|
||||||
|
return m ? m[1]!.toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hugging Face GGUF catalog: search, pick a quantization, pull hf.co/… */
|
||||||
|
export function CatalogSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [hits, setHits] = useState<HfHit[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [files, setFiles] = useState<Record<string, HfFile[] | null>>({});
|
||||||
|
const [nodes, setNodes] = useState<EffectiveOllamaNode[]>([{ id: "default", name: "default", host: "", weight: 0 }]);
|
||||||
|
const [nodeId, setNodeId] = useState("default");
|
||||||
|
const [pulling, setPulling] = useState<string | null>(null);
|
||||||
|
const [pullStatus, setPullStatus] = useState("");
|
||||||
|
const [pullPct, setPullPct] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.ollamaNodes().then((r) => {
|
||||||
|
setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
setHits([]);
|
||||||
|
setSearched(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
timer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://huggingface.co/api/models?search=${encodeURIComponent(query)}&filter=gguf&sort=downloads&direction=-1&limit=15`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Hugging Face: ${res.status}`);
|
||||||
|
const data = (await res.json()) as { id?: string; downloads?: number; likes?: number }[];
|
||||||
|
setHits(
|
||||||
|
(Array.isArray(data) ? data : [])
|
||||||
|
.filter((m) => typeof m.id === "string")
|
||||||
|
.map((m) => ({ id: m.id as string, downloads: m.downloads ?? 0, likes: m.likes ?? 0 })),
|
||||||
|
);
|
||||||
|
setSearched(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "search failed");
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
async function loadFiles(id: string) {
|
||||||
|
if (files[id] !== undefined) return;
|
||||||
|
setFiles((prev) => ({ ...prev, [id]: null }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://huggingface.co/api/models/${encodeURIComponent(id)}`);
|
||||||
|
if (!res.ok) throw new Error(`Hugging Face: ${res.status}`);
|
||||||
|
const data = (await res.json()) as { siblings?: { rfilename?: string }[] };
|
||||||
|
const list: HfFile[] = [];
|
||||||
|
for (const s of data.siblings ?? []) {
|
||||||
|
const name = s.rfilename ?? "";
|
||||||
|
if (!name.toLowerCase().endsWith(".gguf")) continue;
|
||||||
|
const quant = quantOf(name);
|
||||||
|
if (quant) list.push({ name, quant, size: null });
|
||||||
|
}
|
||||||
|
// Biggest quants last (heuristic on the tag, not the file size).
|
||||||
|
setFiles((prev) => ({ ...prev, [id]: list }));
|
||||||
|
} catch {
|
||||||
|
setFiles((prev) => ({ ...prev, [id]: [] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pull(id: string, quant: string) {
|
||||||
|
const ref = `hf.co/${id}:${quant}`;
|
||||||
|
if (pulling) return;
|
||||||
|
setPulling(ref);
|
||||||
|
setError("");
|
||||||
|
setPullStatus(t("catalog.pulling", { name: ref }));
|
||||||
|
setPullPct(null);
|
||||||
|
try {
|
||||||
|
await api.ollamaPull(ref, (p) => {
|
||||||
|
if (p.error) {
|
||||||
|
setError(p.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPullStatus(p.status || ref);
|
||||||
|
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
|
||||||
|
else setPullPct(null);
|
||||||
|
}, { nodeId });
|
||||||
|
setPullStatus(ref);
|
||||||
|
setPullPct(100);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "pull failed");
|
||||||
|
} finally {
|
||||||
|
setPulling(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Globe size={14} className="opacity-60" /> {t("catalog.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("catalog.desc")}</p>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-48 flex-1">
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t("catalog.searchPh")}
|
||||||
|
aria-label={t("catalog.searchPh")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-44">
|
||||||
|
<Field label={t("catalog.node")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("catalog.node")}
|
||||||
|
icon={Server}
|
||||||
|
value={nodeId}
|
||||||
|
onChange={setNodeId}
|
||||||
|
align="right"
|
||||||
|
options={nodes.map((n) => ({ value: n.id, label: n.id === "default" ? "default" : `${n.name} (w:${n.weight})` }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||||
|
{pulling && (
|
||||||
|
<div className="mb-3 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
|
||||||
|
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Download size={15} className="animate-bounce" /> {t("catalog.pulling", { name: pulling })}
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p>
|
||||||
|
{pullPct !== null && (
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800">
|
||||||
|
<div className="h-full rounded-full bg-accent-600 transition-all dark:bg-accent-500" style={{ width: `${pullPct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searching && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
{!searching && searched && hits.length === 0 && (
|
||||||
|
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-4 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("catalog.noRes")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{hits.map((h) => (
|
||||||
|
<li key={h.id} className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<button
|
||||||
|
onClick={() => loadFiles(h.id)}
|
||||||
|
className="flex w-full items-center gap-3 text-left"
|
||||||
|
aria-expanded={files[h.id] !== undefined}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-mono text-sm font-medium">{h.id}</span>
|
||||||
|
<span className="block text-xs opacity-50">{fmtNum(h.downloads)} {t("catalog.downloads")} · ♥ {fmtNum(h.likes)}</span>
|
||||||
|
</span>
|
||||||
|
<span className={cn("text-xs opacity-60", files[h.id] === undefined && "underline underline-offset-2")}>
|
||||||
|
{t("catalog.files")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{files[h.id] !== undefined && (
|
||||||
|
<div className="mt-2 border-t border-stone-200/70 pt-2 dark:border-zinc-800">
|
||||||
|
{files[h.id] === null ? (
|
||||||
|
<p className="flex items-center gap-2 py-1 text-xs opacity-60"><Spinner size={13} /> {t("common.loading")}</p>
|
||||||
|
) : files[h.id]!.length === 0 ? (
|
||||||
|
<p className="py-1 text-xs opacity-50">{t("catalog.noRes")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{files[h.id]!.map((f) => {
|
||||||
|
const ref = `hf.co/${h.id}:${f.quant}`;
|
||||||
|
return (
|
||||||
|
<li key={f.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => pull(h.id, f.quant)}
|
||||||
|
disabled={pulling !== null}
|
||||||
|
title={ref}
|
||||||
|
className="rounded-full border border-stone-200/70 px-3 py-1.5 font-mono text-xs transition hover:border-accent-500/60 hover:bg-accent-500/5 disabled:opacity-40 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
{pulling === ref ? <Spinner size={12} /> : <Download size={12} className="mr-1 inline" />}
|
||||||
|
{f.quant}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-xs opacity-50">{t("catalog.hint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Activity, Pencil, Plus, Server, Trash2, Undo2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type EffectiveOllamaNode,
|
||||||
|
type OllamaNode,
|
||||||
|
type OllamaNodeStats,
|
||||||
|
type OllamaNodeStatus,
|
||||||
|
} from "../lib/api.ts";
|
||||||
|
import { Button, ConfirmDialog, Field, Input, Modal, Picker, Spinner, Switch } from "./ui.tsx";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
export function fmtSize(bytes: number): string {
|
||||||
|
if (!bytes || bytes < 0) return "—";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let v = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
/** Admin Ollama pool: nodes with firewall-like weights + generation timeout. */
|
||||||
|
export function NodesSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [nodes, setNodes] = useState<OllamaNode[]>([]);
|
||||||
|
const [effective, setEffective] = useState<EffectiveOllamaNode[]>([]);
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, OllamaNodeStatus | null>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [formNode, setFormNode] = useState<OllamaNode | null | "new">(null);
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [timeoutS, setTimeoutS] = useState("300");
|
||||||
|
const [defWeight, setDefWeight] = useState("100");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [list, settings] = await Promise.all([api.ollamaNodes(), api.adminGetSettings()]);
|
||||||
|
setNodes(list.nodes);
|
||||||
|
setEffective(list.effective);
|
||||||
|
setTimeoutS(String(settings.settings.ollamaTimeoutS));
|
||||||
|
setDefWeight(String(settings.settings.ollamaDefaultWeight));
|
||||||
|
// Live probes in parallel; a dead node never blocks the list.
|
||||||
|
const ids = ["default", ...list.nodes.map((n) => n.id)];
|
||||||
|
const results = await Promise.all(
|
||||||
|
ids.map(async (id) => {
|
||||||
|
try {
|
||||||
|
return [id, await api.ollamaNodeStatus(id)] as const;
|
||||||
|
} catch {
|
||||||
|
return [id, null] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const map: Record<string, OllamaNodeStatus | null> = {};
|
||||||
|
for (const [id, st] of results) map[id] = st;
|
||||||
|
setStatuses(map);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalWeight = effective.reduce((s, n) => s + n.weight, 0);
|
||||||
|
|
||||||
|
async function savePool(patch: { ollamaTimeoutS?: number; ollamaDefaultWeight?: number }) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await api.adminPatchSettings(patch);
|
||||||
|
setTimeoutS(String(res.settings.ollamaTimeoutS));
|
||||||
|
setDefWeight(String(res.settings.ollamaDefaultWeight));
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitTimeout(raw: string) {
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isInteger(v) || v < 5 || v > 3600) {
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void savePool({ ollamaTimeoutS: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDefWeight(raw: string) {
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isInteger(v) || v < 0 || v > 1000) {
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void savePool({ ollamaDefaultWeight: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(node: OllamaNode, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
const res = await api.ollamaNodePatch(node.id, { enabled });
|
||||||
|
setNodes((prev) => prev.map((n) => (n.id === res.node.id ? res.node : n)));
|
||||||
|
const list = await api.ollamaNodes();
|
||||||
|
setEffective(list.effective);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try {
|
||||||
|
await api.ollamaNodeDelete(id);
|
||||||
|
setDeleteId(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: { id: string; name: string; host: string; weight: number; enabled: boolean; builtin: boolean }[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
name: "default",
|
||||||
|
host: statuses["default"]?.host ?? "…",
|
||||||
|
weight: effective.find((n) => n.id === "default")?.weight ?? 0,
|
||||||
|
enabled: effective.some((n) => n.id === "default"),
|
||||||
|
builtin: true,
|
||||||
|
},
|
||||||
|
...nodes.map((n) => ({ id: n.id, name: n.name, host: n.host, weight: n.weight, enabled: n.enabled, builtin: false })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Server size={14} className="opacity-60" /> {t("nodes.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 mt-0.5 text-sm opacity-60">{t("nodes.desc")}</p>
|
||||||
|
|
||||||
|
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<Field label={t("nodes.timeout")} hint={t("nodes.timeoutUnit")}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={3600}
|
||||||
|
value={timeoutS}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(e) => setTimeoutS(e.target.value)}
|
||||||
|
onBlur={(e) => commitTimeout(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="mt-1 text-xs opacity-60">{t("nodes.timeoutDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<Field label={t("nodes.defWeight")} hint="0–1000">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
value={defWeight}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(e) => setDefWeight(e.target.value)}
|
||||||
|
onBlur={(e) => commitDefWeight(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="mt-1 text-xs opacity-60">{t("nodes.defWeightDesc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||||
|
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const st = statuses[r.id] ?? null;
|
||||||
|
const online = st?.reachable === true;
|
||||||
|
const share = totalWeight > 0 && r.enabled && r.weight > 0 ? Math.round((r.weight / totalWeight) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800",
|
||||||
|
!r.enabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full", st === undefined ? "bg-stone-300 dark:bg-zinc-600" : online ? "bg-emerald-500" : "bg-red-500")} role="status" />
|
||||||
|
<span className="min-w-0 flex-1 basis-40">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{r.name}
|
||||||
|
{r.builtin && <span className="ml-2 rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">env</span>}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate font-mono text-xs opacity-50">{r.host}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-xs dark:bg-zinc-800" title={t("nodes.weightHint")}>
|
||||||
|
w:{r.weight} · {r.enabled ? t("nodes.share", { pct: share }) : t("nodes.off")}
|
||||||
|
</span>
|
||||||
|
{!r.builtin && (
|
||||||
|
<Switch
|
||||||
|
label={t("nodes.enabled")}
|
||||||
|
checked={r.enabled}
|
||||||
|
onChange={(v) => {
|
||||||
|
const full = nodes.find((n) => n.id === r.id);
|
||||||
|
if (full) void toggleEnabled(full, v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="flex shrink-0 gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailId(r.id)}
|
||||||
|
className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"
|
||||||
|
title={t("nodes.detail")}
|
||||||
|
aria-label={t("nodes.detail")}
|
||||||
|
>
|
||||||
|
<Activity size={15} />
|
||||||
|
</button>
|
||||||
|
{!r.builtin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const full = nodes.find((n) => n.id === r.id);
|
||||||
|
if (full) setFormNode(full);
|
||||||
|
}}
|
||||||
|
className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"
|
||||||
|
title={t("nodes.edit")}
|
||||||
|
aria-label={t("nodes.edit")}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteId(r.id)}
|
||||||
|
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||||
|
title={t("common.delete")}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setFormNode("new")}>
|
||||||
|
<Plus size={14} /> {t("nodes.add")}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={() => refresh()}
|
||||||
|
className="rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
|
||||||
|
title={t("models.refresh")}
|
||||||
|
aria-label={t("models.refreshAria")}
|
||||||
|
>
|
||||||
|
<Undo2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NodeFormModal
|
||||||
|
node={formNode === "new" ? null : formNode}
|
||||||
|
open={formNode !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setFormNode(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detailId && <NodeDetailModal nodeId={detailId} onClose={() => setDetailId(null)} />}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
title={t("nodes.deleteTitle")}
|
||||||
|
message={t("nodes.deleteMsg")}
|
||||||
|
onConfirm={() => deleteId && remove(deleteId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- add / edit modal ---------- */
|
||||||
|
|
||||||
|
function NodeFormModal({ node, open, onClose }: { node: OllamaNode | null; open: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [weight, setWeight] = useState("100");
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
const [probe, setProbe] = useState<OllamaNodeStatus | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(node?.name ?? "");
|
||||||
|
setHost(node?.host ?? "");
|
||||||
|
setWeight(String(node?.weight ?? 100));
|
||||||
|
setEnabled(node?.enabled ?? true);
|
||||||
|
setProbe(null);
|
||||||
|
setError("");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [open, node]);
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
setProbing(true);
|
||||||
|
setProbe(null);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setProbe(await api.ollamaNodeProbe(host.trim()));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("nodes.testFail"));
|
||||||
|
} finally {
|
||||||
|
setProbing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const w = Number(weight);
|
||||||
|
const payload = { name: name.trim(), host: host.trim(), weight: w, enabled };
|
||||||
|
if (node) await api.ollamaNodePatch(node.id, payload);
|
||||||
|
else await api.ollamaNodeCreate(payload);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : t("nodes.invalid");
|
||||||
|
setError(/already registered/i.test(msg) ? t("nodes.dupHost") : msg);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={node ? t("nodes.edit") : t("nodes.add")} icon={Server}>
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
<Field label={t("nodes.name")}>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("nodes.namePh")} required maxLength={60} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("nodes.host")} hint={t("nodes.hostHint")}>
|
||||||
|
<Input value={host} onChange={(e) => setHost(e.target.value)} placeholder={t("nodes.hostPh")} required inputMode="url" />
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="w-32">
|
||||||
|
<Field label={t("nodes.weight")} hint="0–1000">
|
||||||
|
<Input type="number" min={0} max={1000} value={weight} onChange={(e) => setWeight(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="pb-2.5">
|
||||||
|
<Switch label={t("nodes.enabled")} checked={enabled} onChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto pb-1">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={test} disabled={probing || !host.trim()}>
|
||||||
|
{probing ? <Spinner size={14} /> : <Activity size={14} />} {probing ? t("nodes.probing") : t("nodes.test")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{probe && (
|
||||||
|
<p className={cn("rounded-2xl px-4 py-2.5 text-sm", probe.reachable ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")} role="status">
|
||||||
|
{probe.reachable
|
||||||
|
? `${t("nodes.testOk")}${probe.version ? ` · v${probe.version}` : ""} · ${probe.models.length} models`
|
||||||
|
: `${t("nodes.testFail")}${probe.error ? ` — ${probe.error}` : ""}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs opacity-60">{t("nodes.weightHint")}</p>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={busy}>{busy ? <Spinner size={14} /> : t("common.save")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- node detail: live status + load charts ---------- */
|
||||||
|
|
||||||
|
function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [status, setStatus] = useState<OllamaNodeStatus | null>(null);
|
||||||
|
const [stats, setStats] = useState<OllamaNodeStats | null>(null);
|
||||||
|
const [window, setWindow] = useState<StatsWindowId>("24h");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.ollamaNodeStatus(nodeId).then((s) => { if (alive) setStatus(s); }).catch(() => { if (alive) setStatus(null); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [nodeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.ollamaNodeStats(nodeId, window).then((r) => { if (alive) setStats(r.stats); }).catch(() => {});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [nodeId, window]);
|
||||||
|
|
||||||
|
const maxReq = Math.max(1, ...((stats?.buckets ?? []).map((b) => b.req)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={t("nodes.detail")} icon={Activity} wide>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => { onClose(); navigate("/admin/stats"); }}>
|
||||||
|
<Activity size={14} /> {t("stats.openFull")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!status ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("models.connHost")}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm" title={status.host}>{status.host}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.version")}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">{status.version ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.latency")}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.diskModels", { n: status.models.length })}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">{status.running.length} running</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{status.running.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.running")}</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{status.running.map((m) => (
|
||||||
|
<li key={m.name} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono">{m.name}</span>
|
||||||
|
<span className="shrink-0 text-xs opacity-60">{t("nodes.vram")}: {fmtSize(m.sizeVram)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.stats")}</p>
|
||||||
|
<span className="ml-auto w-28">
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("nodes.stats")}
|
||||||
|
value={window}
|
||||||
|
onChange={(v) => setWindow(v as StatsWindowId)}
|
||||||
|
align="right"
|
||||||
|
options={WINDOWS.map((w) => ({ value: w, label: w }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs opacity-80">
|
||||||
|
<span>{stats.totals.req} {t("nodes.req")}</span>
|
||||||
|
<span className={stats.totals.err > 0 ? "text-red-500" : undefined}>{stats.totals.err} {t("nodes.err")}</span>
|
||||||
|
<span>{t("nodes.avg")}: {stats.totals.avgMs === null ? "—" : `${Math.round(stats.totals.avgMs)} ms`}</span>
|
||||||
|
<span>{t("nodes.tps")}: {stats.totals.tokPerSec === null ? "—" : stats.totals.tokPerSec.toFixed(1)}</span>
|
||||||
|
<span>{stats.totals.promptTok + stats.totals.evalTok} {t("nodes.tok")}</span>
|
||||||
|
</div>
|
||||||
|
{stats.totals.req === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-4 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("nodes.emptyStats")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-20 items-end gap-[3px]" role="img" aria-label={`${stats.totals.req} ${t("nodes.req")}`}>
|
||||||
|
{stats.buckets.map((b, i) => {
|
||||||
|
const h = Math.max(2, Math.round((b.req / maxReq) * 100));
|
||||||
|
const errH = b.req > 0 ? Math.round(h * (b.err / b.req)) : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex min-w-0 flex-1 flex-col justify-end overflow-hidden rounded-sm bg-accent-500/70"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
title={`${new Date(b.t).toLocaleString()} — ${b.req} ${t("nodes.req")}, ${b.err} ${t("nodes.err")}`}
|
||||||
|
>
|
||||||
|
{errH > 0 && <div className="w-full bg-red-500" style={{ height: `${(errH / h) * 100}%` }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.models.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.diskModels", { n: status.models.length })}</p>
|
||||||
|
<ul className="max-h-48 space-y-1.5 overflow-y-auto">
|
||||||
|
{status.models.map((m) => (
|
||||||
|
<li key={m.name} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono">{m.name}</span>
|
||||||
|
<span className="shrink-0 text-xs opacity-60">{fmtSize(m.size)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs opacity-50">{t("nodes.cpuNote")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,27 +76,29 @@ export function RegisterPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className="mt-4 space-y-2.5">
|
<form onSubmit={submit} autoComplete="off" className="mt-4 space-y-2.5">
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
<Field label={t("common.username")}>
|
<Field label={t("common.username")}>
|
||||||
<Input
|
<Input
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
name="ka-register-username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="alice"
|
placeholder="alice"
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
autoComplete="username"
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t("common.displayName")}>
|
<Field label={t("common.displayName")}>
|
||||||
<Input
|
<Input
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
name="ka-register-displayname"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="Alice"
|
placeholder="Alice"
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
autoComplete="nickname"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
|
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
|
||||||
import { api, type Report, type ReportStatus } from "../lib/api.ts";
|
import { api, getToken, type Report, type ReportStatus } from "../lib/api.ts";
|
||||||
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx";
|
import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
@@ -16,7 +17,7 @@ type Filter = "all" | ReportStatus;
|
|||||||
const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"];
|
const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"];
|
||||||
|
|
||||||
/** Admin triage for user-flagged AI responses: review / resolve / dismiss. */
|
/** Admin triage for user-flagged AI responses: review / resolve / dismiss. */
|
||||||
export function ReportsPanel() {
|
export function ReportsPanel({ onShadowbanChanged }: { onShadowbanChanged?: () => void }) {
|
||||||
const [reports, setReports] = useState<Report[]>([]);
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
const [filter, setFilter] = useState<Filter>("open");
|
const [filter, setFilter] = useState<Filter>("open");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -42,11 +43,24 @@ export function ReportsPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
// Live list: new reports pop without manual refresh.
|
// Live list: the admin firehose pushes full report payloads, so rows
|
||||||
const id = setInterval(() => {
|
// update incrementally with zero HTTP (shares its socket with the
|
||||||
if (!document.hidden) refresh();
|
// AdminCenter badge via the pool). Full resync only on (re)connect.
|
||||||
}, 15000);
|
const token = getToken();
|
||||||
return () => clearInterval(id);
|
if (!token) return;
|
||||||
|
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === "report_created") {
|
||||||
|
setReports((prev) => (prev.some((r) => r.id === evt.report.id) ? prev : [evt.report, ...prev]));
|
||||||
|
} else if (evt.type === "report_status") {
|
||||||
|
setReports((prev) => prev.map((r) => (r.id === evt.report.id ? evt.report : r)));
|
||||||
|
}
|
||||||
|
// Access events: the AccessRequestsPanel owns them.
|
||||||
|
},
|
||||||
|
onSync: () => {
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -124,7 +138,18 @@ export function ReportsPanel() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
|
{selectedId === r.id && (
|
||||||
|
<ReportDetail
|
||||||
|
report={r}
|
||||||
|
onPatched={(updated) => setReports((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))}
|
||||||
|
onBanned={(reporterId, shadowbanned) => {
|
||||||
|
setReports((prev) =>
|
||||||
|
prev.map((x) => (x.reporter_id === reporterId ? { ...x, reporter_shadowbanned: shadowbanned ? 1 : 0 } : x)),
|
||||||
|
);
|
||||||
|
onShadowbanChanged?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -132,7 +157,7 @@ export function ReportsPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportDetail({ report, onChanged }: { report: Report; onChanged: () => void }) {
|
function ReportDetail({ report, onPatched, onBanned }: { report: Report; onPatched: (r: Report) => void; onBanned: (reporterId: string, shadowbanned: boolean) => void }) {
|
||||||
const [note, setNote] = useState(report.admin_note);
|
const [note, setNote] = useState(report.admin_note);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -149,8 +174,10 @@ function ReportDetail({ report, onChanged }: { report: Report; onChanged: () =>
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
|
// The report_status broadcast echoes the same payload to every admin —
|
||||||
onChanged();
|
// applying the response directly keeps this view instant and idempotent.
|
||||||
|
const res = await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
|
||||||
|
onPatched(res.report);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
|
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,7 +190,9 @@ function ReportDetail({ report, onChanged }: { report: Report; onChanged: () =>
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await api.adminShadowban(report.reporter_id, shadowbanned);
|
await api.adminShadowban(report.reporter_id, shadowbanned);
|
||||||
onChanged();
|
// Shadow-ban has no WS event (per-user, not per-report): patch every
|
||||||
|
// row of this reporter locally and resync the badges over HTTP.
|
||||||
|
onBanned(report.reporter_id, shadowbanned);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
|
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from "./ui.tsx";
|
} from "./ui.tsx";
|
||||||
|
import { Toasts } from "./Toasts.tsx";
|
||||||
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
@@ -70,10 +72,28 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Message ids already applied (WS echo + HTTP response race): each lands once.
|
||||||
|
const seenIds = useRef(new Set<number>());
|
||||||
|
function appendMessage(msg: AccessMessage): boolean {
|
||||||
|
if (seenIds.current.has(msg.id)) return false;
|
||||||
|
seenIds.current.add(msg.id);
|
||||||
|
setMessages((prev) => [...prev, msg]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow live traffic: stick to the bottom while the user already is.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = threadRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 96;
|
||||||
|
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await api.accessTicket(ticketId);
|
const res = await api.accessTicket(ticketId);
|
||||||
|
for (const m of res.messages) seenIds.current.add(m.id);
|
||||||
setRequest(res.request);
|
setRequest(res.request);
|
||||||
setMessages(res.messages);
|
setMessages(res.messages);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,6 +107,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
async function refreshSilent() {
|
async function refreshSilent() {
|
||||||
try {
|
try {
|
||||||
const res = await api.accessTicket(ticketId);
|
const res = await api.accessTicket(ticketId);
|
||||||
|
for (const m of res.messages) seenIds.current.add(m.id);
|
||||||
setRequest(res.request);
|
setRequest(res.request);
|
||||||
setMessages(res.messages);
|
setMessages(res.messages);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -96,16 +117,21 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
// Live updates over WebSocket: admin replies and the decision arrive
|
// Live updates over WebSocket with full payloads: admin replies and the
|
||||||
// instantly; resync on (re)connect covers events missed while offline.
|
// decision apply straight to state (no refetch); resync on (re)connect
|
||||||
|
// covers events missed while offline.
|
||||||
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
|
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
|
||||||
onEvent: (evt) => {
|
onEvent: (evt) => {
|
||||||
if (evt.type === "pong" || evt.request_id !== ticketId) return;
|
// Report events only travel the admin firehose — never ticket sockets.
|
||||||
|
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
|
||||||
|
if (evt.request_id !== ticketId) return;
|
||||||
if (evt.type === "access_message") {
|
if (evt.type === "access_message") {
|
||||||
const msg = evt.message;
|
if (appendMessage(evt.message) && evt.message.author === "admin") {
|
||||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
|
pushToast(t("live.adminReply"), { icon: "mail" });
|
||||||
|
}
|
||||||
} else if (evt.type === "access_status") {
|
} else if (evt.type === "access_status") {
|
||||||
setRequest(evt.request);
|
setRequest(evt.request);
|
||||||
|
pushToast(t("live.ticketStatus", { status: t(`review.status.${evt.request.status}` as StringKey) }), { icon: "inbox" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSync: () => {
|
onSync: () => {
|
||||||
@@ -124,7 +150,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await api.accessReply(ticketId, body);
|
const res = await api.accessReply(ticketId, body);
|
||||||
setMessages((prev) => [...prev, res.message]);
|
appendMessage(res.message);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("access.sendFailed"));
|
setError(err instanceof Error ? err.message : t("access.sendFailed"));
|
||||||
@@ -190,7 +216,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
<div ref={threadRef} className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -259,6 +285,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Toasts />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export function SharePage({ publicId }: { publicId: string }) {
|
|||||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
<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" ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||||
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
||||||
|
{m.role !== "user" && m.model ? (
|
||||||
|
<span title={m.model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{m.model}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{m.role === "user" ? (
|
{m.role === "user" ? (
|
||||||
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900"><UserMessageContent content={m.content} tone="plain" /></div>
|
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900"><UserMessageContent content={m.content} tone="plain" /></div>
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { BarChart3, Cpu, Globe, HardDrive, Network, Server, Spline } from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type EffectiveOllamaNode,
|
||||||
|
type OllamaNodeStats,
|
||||||
|
type PlatformInfo,
|
||||||
|
} from "../lib/api.ts";
|
||||||
|
import { fmtSize } from "./OllamaNodes.tsx";
|
||||||
|
import { Picker, Spinner } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
type ChartKind = "bar" | "area" | "line";
|
||||||
|
const CHARTS: { id: ChartKind; label: string; icon: typeof BarChart3 }[] = [
|
||||||
|
{ id: "bar", label: "Bar", icon: BarChart3 },
|
||||||
|
{ id: "area", label: "Area", icon: Spline },
|
||||||
|
{ id: "line", label: "Line", icon: Spline },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtUptime(totalS: number): string {
|
||||||
|
const d = Math.floor(totalS / 86400);
|
||||||
|
const h = Math.floor((totalS % 86400) / 3600);
|
||||||
|
const m = Math.floor((totalS % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtClock(t: number): string {
|
||||||
|
const d = new Date(t);
|
||||||
|
const hh = d.getHours().toString().padStart(2, "0");
|
||||||
|
const mm = d.getMinutes().toString().padStart(2, "0");
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1} ${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin telemetry sub-page: pool load charts + platform host facts. */
|
||||||
|
export function StatsSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [nodes, setNodes] = useState<EffectiveOllamaNode[]>([{ id: "default", name: "default", host: "", weight: 0 }]);
|
||||||
|
const [nodeId, setNodeId] = useState("default");
|
||||||
|
const [window, setWindow] = useState<StatsWindowId>("24h");
|
||||||
|
const [kind, setKind] = useState<ChartKind>("bar");
|
||||||
|
const [stats, setStats] = useState<OllamaNodeStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [platform, setPlatform] = useState<PlatformInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.ollamaNodes()
|
||||||
|
.then((r) => setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]))
|
||||||
|
.catch(() => {});
|
||||||
|
api.platform().then((r) => setPlatform(r.platform)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.ollamaNodeStats(nodeId, window)
|
||||||
|
.then((r) => setStats(r.stats))
|
||||||
|
.catch(() => setStats(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [nodeId, window]);
|
||||||
|
|
||||||
|
const totals = stats?.totals;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Server size={14} className="opacity-60" /> {t("stats.poolTitle")}
|
||||||
|
</p>
|
||||||
|
<span className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-40">
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("stats.node")}
|
||||||
|
value={nodeId}
|
||||||
|
onChange={setNodeId}
|
||||||
|
align="right"
|
||||||
|
options={nodes.map((n) => ({ value: n.id, label: n.id === "default" ? "default" : n.name }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-24">
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("stats.window")}
|
||||||
|
value={window}
|
||||||
|
onChange={(v) => setWindow(v as StatsWindowId)}
|
||||||
|
align="right"
|
||||||
|
options={WINDOWS.map((w) => ({ value: w, label: w }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-sm opacity-60">{t("stats.poolDesc")}</p>
|
||||||
|
|
||||||
|
{/* Chart-type dock */}
|
||||||
|
<div className="mb-3 inline-flex gap-1 rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900" role="tablist" aria-label={t("stats.chart")}>
|
||||||
|
{CHARTS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === c.id}
|
||||||
|
onClick={() => setKind(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-medium transition",
|
||||||
|
kind === c.id
|
||||||
|
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
|
: "opacity-60 hover:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<c.icon size={13} />
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
|
||||||
|
{totals && (
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||||
|
<StatTile label={t("nodes.req")} value={String(totals.req)} />
|
||||||
|
<StatTile label={t("nodes.err")} value={String(totals.err)} alert={totals.err > 0} />
|
||||||
|
<StatTile label={t("nodes.avg")} value={totals.avgMs === null ? "—" : `${Math.round(totals.avgMs)} ms`} />
|
||||||
|
<StatTile label={t("nodes.tps")} value={totals.tokPerSec === null ? "—" : totals.tokPerSec.toFixed(1)} />
|
||||||
|
<StatTile label={t("nodes.tok")} value={String(totals.promptTok + totals.evalTok)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && (stats.totals.req === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-8 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("nodes.emptyStats")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<LoadChart stats={stats} kind={kind} />
|
||||||
|
))}
|
||||||
|
<p className="mt-2 text-xs opacity-50">{t("nodes.cpuNote")}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Cpu size={14} className="opacity-60" /> {t("stats.hostTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm opacity-60">{t("stats.hostDesc")}</p>
|
||||||
|
{!platform ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<StatTile label={t("stats.hostname")} value={platform.hostname} mono />
|
||||||
|
<StatTile label={t("stats.kernel")} value={platform.kernel} mono />
|
||||||
|
<StatTile label={t("stats.cpu")} value={`${platform.cpus} × ${platform.cpuModel?.split("@")[0]?.trim() ?? "?"}`} mono />
|
||||||
|
<StatTile label={t("stats.runtime")} value={platform.runtime} mono />
|
||||||
|
<StatTile label={t("stats.uptime")} value={fmtUptime(platform.uptimeS)} />
|
||||||
|
<StatTile label={t("stats.appUptime")} value={fmtUptime(platform.appUptimeS)} />
|
||||||
|
<StatTile label={t("stats.load")} value={platform.loadavg.map((v) => v.toFixed(2)).join(" ")} mono />
|
||||||
|
<StatTile
|
||||||
|
label={t("stats.memory")}
|
||||||
|
value={`${fmtSize(platform.memTotal - platform.memFree)} / ${fmtSize(platform.memTotal)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800" title={t("stats.memory")}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-600 dark:bg-accent-500"
|
||||||
|
style={{ width: `${Math.min(100, Math.round(((platform.memTotal - platform.memFree) / Math.max(1, platform.memTotal)) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider opacity-50">
|
||||||
|
<Network size={12} /> {t("stats.network")}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{platform.nets
|
||||||
|
.filter((n) => !n.address.startsWith("fe80"))
|
||||||
|
.sort((a, b) => Number(a.internal) - Number(b.internal))
|
||||||
|
.map((n, i) => (
|
||||||
|
<li key={`${n.name}-${n.address}-${i}`} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
|
||||||
|
<Globe size={13} className="shrink-0 opacity-50" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono">{n.address}</span>
|
||||||
|
<span className="shrink-0 text-xs opacity-60">{n.name} · {n.family}{n.internal ? " · lo" : ""}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs opacity-50">
|
||||||
|
<HardDrive size={12} /> {t("stats.diskNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, value, mono, alert }: { label: string; value: string; mono?: boolean; alert?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<p className="truncate text-[11px] uppercase tracking-wider opacity-50">{label}</p>
|
||||||
|
<p className={cn("truncate text-sm font-medium", mono && "font-mono", alert && "text-red-500")} title={value}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SVG load chart with tooltip ---------- */
|
||||||
|
|
||||||
|
const W = 480;
|
||||||
|
const H = 180;
|
||||||
|
const PAD_L = 8;
|
||||||
|
const PAD_R = 8;
|
||||||
|
const PAD_T = 12;
|
||||||
|
const PAD_B = 22;
|
||||||
|
|
||||||
|
function LoadChart({ stats, kind }: { stats: OllamaNodeStats; kind: ChartKind }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
const buckets = stats.buckets;
|
||||||
|
const n = buckets.length;
|
||||||
|
const maxReq = Math.max(1, ...buckets.map((b) => b.req));
|
||||||
|
const maxAvg = Math.max(1, ...buckets.map((b) => b.avgMs ?? 0));
|
||||||
|
|
||||||
|
const iw = W - PAD_L - PAD_R;
|
||||||
|
const ih = H - PAD_T - PAD_B;
|
||||||
|
const step = iw / n;
|
||||||
|
const bw = Math.max(2, step - 2);
|
||||||
|
|
||||||
|
const { points, areaPath, errPoints } = useMemo(() => {
|
||||||
|
const pts = buckets.map((b, i) => ({
|
||||||
|
x: PAD_L + i * step + step / 2,
|
||||||
|
y: PAD_T + ih - (b.req / maxReq) * ih,
|
||||||
|
}));
|
||||||
|
const area = `M${PAD_L},${PAD_T + ih} ` + pts.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + ` L${(PAD_L + iw).toFixed(1)},${PAD_T + ih} Z`;
|
||||||
|
const err = buckets.map((b, i) => ({
|
||||||
|
x: PAD_L + i * step + step / 2,
|
||||||
|
y: PAD_T + ih - (b.err / maxReq) * ih,
|
||||||
|
}));
|
||||||
|
return { points: pts, areaPath: area, errPoints: err };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stats, kind]);
|
||||||
|
|
||||||
|
function onMove(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
const rect = wrapRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const sx = W / rect.width;
|
||||||
|
const x = (e.clientX - rect.left) * sx - PAD_L;
|
||||||
|
const i = Math.round(x / step - 0.5);
|
||||||
|
setHover(Math.max(0, Math.min(n - 1, i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hb = hover !== null ? buckets[hover]! : null;
|
||||||
|
// Latency sparkline (normalized, bottom-anchored, subtle).
|
||||||
|
const latPath = useMemo(() => {
|
||||||
|
const pts = buckets.map((b, i) => {
|
||||||
|
const x = PAD_L + i * step + step / 2;
|
||||||
|
const v = b.avgMs ?? 0;
|
||||||
|
return `L${x.toFixed(1)},${(PAD_T + ih - (v / maxAvg) * ih * 0.5).toFixed(1)}`;
|
||||||
|
});
|
||||||
|
return `M${PAD_L},${PAD_T + ih} ` + pts.join(" ");
|
||||||
|
}, [buckets, maxAvg, step, ih]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="relative">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
className="block w-full select-none"
|
||||||
|
onMouseMove={onMove}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${stats.totals.req} ${t("nodes.req")}`}
|
||||||
|
>
|
||||||
|
{/* gridlines */}
|
||||||
|
{[0.25, 0.5, 0.75].map((f) => (
|
||||||
|
<line
|
||||||
|
key={f}
|
||||||
|
x1={PAD_L}
|
||||||
|
x2={W - PAD_R}
|
||||||
|
y1={PAD_T + ih * f}
|
||||||
|
y2={PAD_T + ih * f}
|
||||||
|
className="stroke-stone-200 dark:stroke-zinc-800"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{kind === "bar" && buckets.map((b, i) => {
|
||||||
|
const h = Math.max(1.5, (b.req / maxReq) * ih);
|
||||||
|
const x = PAD_L + i * step + (step - bw) / 2;
|
||||||
|
const y = PAD_T + ih - h;
|
||||||
|
const errH = b.req > 0 ? h * (b.err / b.req) : 0;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={x} y={y} width={bw} height={h} rx={1.5} style={{ fill: "rgb(var(--ka-accent-500) / 0.75)" }} opacity={hover === i ? 1 : 0.85} />
|
||||||
|
{errH > 0 && <rect x={x} y={y} width={bw} height={errH} rx={1.5} className="fill-red-500" />}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{kind === "area" && (
|
||||||
|
<>
|
||||||
|
<path d={areaPath} style={{ fill: "rgb(var(--ka-accent-500) / 0.25)" }} />
|
||||||
|
<polyline
|
||||||
|
points={points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
style={{ stroke: "rgb(var(--ka-accent-500))" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline points={errPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" className="stroke-red-500" strokeWidth={1.5} strokeDasharray="3 2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === "line" && (
|
||||||
|
<>
|
||||||
|
<polyline points={points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" style={{ stroke: "rgb(var(--ka-accent-500))" }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
<polyline points={errPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" className="stroke-red-500" strokeWidth={1.5} strokeDasharray="3 2" />
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle key={i} cx={p.x} cy={p.y} r={hover === i ? 3.5 : 2} style={{ fill: "rgb(var(--ka-accent-500))" }} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* latency underlay */}
|
||||||
|
<path d={latPath} fill="none" className="stroke-sky-400" strokeWidth={1} opacity={0.5} />
|
||||||
|
{/* hover cursor */}
|
||||||
|
{hover !== null && (
|
||||||
|
<line x1={points[hover]!.x} x2={points[hover]!.x} y1={PAD_T} y2={PAD_T + ih} className="stroke-stone-400 dark:stroke-zinc-500" strokeWidth={1} strokeDasharray="2 2" />
|
||||||
|
)}
|
||||||
|
{/* x labels */}
|
||||||
|
{[0, Math.floor(n / 2), n - 1].map((i) => (
|
||||||
|
<text key={i} x={PAD_L + i * step + step / 2} y={H - 6} textAnchor="middle" fontSize={9} className="fill-stone-400 dark:fill-zinc-500">
|
||||||
|
{fmtClock(buckets[i]!.t)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{hb && hover !== null && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 min-w-36 rounded-2xl border border-stone-200 bg-white/95 px-3 py-2 text-xs shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95"
|
||||||
|
style={{
|
||||||
|
left: `clamp(4px, ${(points[hover]!.x / W) * 100}%, calc(100% - 150px))`,
|
||||||
|
top: 0,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1 font-medium opacity-70">{fmtClock(hb.t)}</p>
|
||||||
|
<p>{hb.req} {t("nodes.req")} · <span className="text-red-500">{hb.err} {t("nodes.err")}</span></p>
|
||||||
|
<p className="opacity-70">{t("nodes.avg")}: {hb.avgMs === null ? "—" : `${Math.round(hb.avgMs)} ms`}</p>
|
||||||
|
<p className="opacity-70">{hb.promptTok + hb.evalTok} {t("nodes.tok")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-4 text-[11px] opacity-60">
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full" style={{ background: "rgb(var(--ka-accent-500))" }} /> {t("nodes.req")}</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full bg-red-500" /> {t("nodes.err")}</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full bg-sky-400" /> {t("nodes.avg")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Archive, ArchiveRestore, Check, Cpu, Inbox, Mail, Palette, Pencil, SunMoon, Trash2, Unplug, type LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
import { removeToast, TOAST_DISPLAY_MS, useToasts, type Toast, type ToastIcon } from "../lib/toasts.ts";
|
||||||
|
|
||||||
|
const ICONS: Record<ToastIcon, LucideIcon> = {
|
||||||
|
trash: Trash2,
|
||||||
|
archive: Archive,
|
||||||
|
unarchive: ArchiveRestore,
|
||||||
|
model: Cpu,
|
||||||
|
theme: SunMoon,
|
||||||
|
accent: Palette,
|
||||||
|
unshare: Unplug,
|
||||||
|
edit: Pencil,
|
||||||
|
check: Check,
|
||||||
|
mail: Mail,
|
||||||
|
inbox: Inbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Must match the .toast-exit animation duration in index.css. */
|
||||||
|
const EXIT_MS = 260;
|
||||||
|
|
||||||
|
function ToastPill({ toast }: { toast: Toast }) {
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
|
// Full visibility window, then the exit animation. Restarted whenever a
|
||||||
|
// same-tag push replaces the text (nonce bump).
|
||||||
|
useEffect(() => {
|
||||||
|
setLeaving(false);
|
||||||
|
const t = window.setTimeout(() => setLeaving(true), TOAST_DISPLAY_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [toast.nonce]);
|
||||||
|
|
||||||
|
// Unmount once the exit animation has played out.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!leaving) return;
|
||||||
|
const t = window.setTimeout(() => removeToast(toast.id), EXIT_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [leaving, toast.id]);
|
||||||
|
|
||||||
|
const Icon = ICONS[toast.icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
onClick={() => setLeaving(true)}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto flex cursor-pointer items-center gap-2.5 rounded-full border border-stone-200 bg-white/95 py-2 pl-2.5 pr-4 shadow-xl backdrop-blur",
|
||||||
|
"dark:border-zinc-700 dark:bg-zinc-900/95",
|
||||||
|
leaving ? "toast-exit" : "toast-enter",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
|
||||||
|
<Icon size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="max-w-72 truncate text-sm font-medium">{toast.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-center pill stack. Fixed above the composer (bottom-24), over the
|
||||||
|
* modals (z-[70] > modal z-50) so settings actions toast visibly too.
|
||||||
|
*/
|
||||||
|
export function Toasts() {
|
||||||
|
const toasts = useToasts();
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="pointer-events-none fixed inset-x-0 bottom-24 z-[70] flex flex-col items-center gap-2 px-4"
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastPill key={t.id} toast={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
|
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Pipette, Play, Plug, ScanText, Search, Settings2, Share2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react";
|
||||||
import { api } from "../lib/api.ts";
|
import { api, type UserProvider, type VoicePreview } from "../lib/api.ts";
|
||||||
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
import { ACCENT_PRESETS, applyAccent, currentAccent, hexToHsv, hsvToHex, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||||
|
import { detectLang, getVoicePrefs, listTtsVoices, OPENAI_STATIC_VOICES, pickVoice, playText, setVoicePrefs, stopPlayback, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
|
||||||
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
||||||
import type { Conversation, FilePreview, User } from "../lib/types.ts";
|
import type { Conversation, FilePreview, User } from "../lib/types.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
@@ -227,10 +229,18 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
|||||||
|
|
||||||
async function unshare() {
|
async function unshare() {
|
||||||
if (!conv) return;
|
if (!conv) return;
|
||||||
await api.unshare(conv.id);
|
setError("");
|
||||||
onClose();
|
try {
|
||||||
|
await api.unshare(conv.id);
|
||||||
|
pushToast(t("toast.unshared"), { icon: "unshare" });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("dlg.reqFailed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const archived = (conv?.archived_at ?? 0) !== 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}>
|
<Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}>
|
||||||
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> {t("dlg.shareCreating")}</p>}
|
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> {t("dlg.shareCreating")}</p>}
|
||||||
@@ -243,8 +253,13 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
|||||||
<span className="min-w-0 flex-1 truncate">{url}</span>
|
<span className="min-w-0 flex-1 truncate">{url}</span>
|
||||||
<CopyButton text={url} />
|
<CopyButton text={url} />
|
||||||
</div>
|
</div>
|
||||||
|
{archived && <p className="text-xs opacity-60">{t("dlg.unshareBlocked")}</p>}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
|
{!archived ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<Button size="sm" onClick={onClose}>{t("common.done")}</Button>
|
<Button size="sm" onClick={onClose}>{t("common.done")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,20 +272,25 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
|||||||
|
|
||||||
export function SettingsModal({
|
export function SettingsModal({
|
||||||
user,
|
user,
|
||||||
|
usernameChangeEnabled = true,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onKeyRotated,
|
onKeyRotated,
|
||||||
onViewArchived,
|
onViewArchived,
|
||||||
onArchivedChanged,
|
onArchivedChanged,
|
||||||
|
onProvidersChanged,
|
||||||
}: {
|
}: {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
usernameChangeEnabled?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (u: User) => void;
|
onSaved: (u: User) => void;
|
||||||
onKeyRotated?: () => void;
|
onKeyRotated?: () => void;
|
||||||
onViewArchived: (c: Conversation) => void;
|
onViewArchived: (c: Conversation) => void;
|
||||||
onArchivedChanged: (deletedId?: string) => void;
|
onArchivedChanged: (deletedId?: string) => void;
|
||||||
|
onProvidersChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -297,10 +317,12 @@ export function SettingsModal({
|
|||||||
// Two-column settings: categories on the left, content on the right.
|
// Two-column settings: categories on the left, content on the right.
|
||||||
// Searching filters the category list and jumps to the first match.
|
// Searching filters the category list and jumps to the first match.
|
||||||
const CATS = [
|
const CATS = [
|
||||||
{ id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] },
|
{ id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "username", "login", "display", "email", "account"] },
|
||||||
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
|
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
|
||||||
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
|
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
|
||||||
|
{ id: "voice", title: t("voice.title"), icon: Mic, keywords: ["voice", "voix", "voz", "speech", "tts", "audio", "speak", "listen", "language", "langue", "accent", "parler"] },
|
||||||
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
|
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
|
||||||
|
{ id: "providers", title: t("settings.providers"), icon: Plug, keywords: ["providers", "provider", "api", "keys", "openai", "anthropic", "deepseek", "gemini", "byok", "model", "fournisseur", "proveedor", "провайдер"] },
|
||||||
{ id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] },
|
{ id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] },
|
||||||
] as const;
|
] as const;
|
||||||
type CatId = (typeof CATS)[number]["id"];
|
type CatId = (typeof CATS)[number]["id"];
|
||||||
@@ -315,6 +337,7 @@ export function SettingsModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
setUsername(user.username);
|
||||||
setDisplayName(user.displayName);
|
setDisplayName(user.displayName);
|
||||||
setAvatarUrl(user.avatarUrl);
|
setAvatarUrl(user.avatarUrl);
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
@@ -379,7 +402,21 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
discardStaged();
|
discardStaged();
|
||||||
}
|
}
|
||||||
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: url, email: email.trim(), theme });
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
// Only send fields that actually changed: this isolates a
|
||||||
|
// username error (e.g. admin rename guard) from the rest of the
|
||||||
|
// profile so one rejected field can't block the whole save.
|
||||||
|
const patch: { username?: string; displayName?: string; avatarUrl?: string; email?: string; theme?: string } = {};
|
||||||
|
if (normalizedUsername !== (user.username ?? "").toLowerCase()) patch.username = normalizedUsername;
|
||||||
|
if (displayName.trim() !== (user.displayName ?? "")) patch.displayName = displayName.trim();
|
||||||
|
if (url !== (user.avatarUrl ?? "")) patch.avatarUrl = url;
|
||||||
|
if (email.trim() !== (user.email ?? "")) patch.email = email.trim();
|
||||||
|
if (theme !== (user.theme ?? "auto")) patch.theme = theme;
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await api.updateMe(patch);
|
||||||
onSaved(res.user);
|
onSaved(res.user);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -391,14 +428,25 @@ export function SettingsModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={user !== null} onClose={onClose} title={t("settings.title")} icon={Settings2} wide>
|
<Modal open={user !== null} onClose={onClose} title={t("settings.title")} icon={Settings2} wide>
|
||||||
<form onSubmit={save} className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
{/* Filter UI lives OUTSIDE the data <form> on purpose: when the
|
||||||
|
Providers pane mounts its password fields, browsers and
|
||||||
|
password-manager extensions must not mistake this filter for
|
||||||
|
the login username field (autofilling e.g. "admin" into it and
|
||||||
|
popping their search menu over the modal). */}
|
||||||
|
<div className="relative" role="search">
|
||||||
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
|
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") e.preventDefault();
|
||||||
|
}}
|
||||||
placeholder={t("settings.searchPh")}
|
placeholder={t("settings.searchPh")}
|
||||||
aria-label={t("settings.searchAria")}
|
aria-label={t("settings.searchAria")}
|
||||||
|
name="ka-settings-search"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
|
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
@@ -413,8 +461,18 @@ export function SettingsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
{search.trim() && visibleCats.length === 0 ? (
|
{search.trim() && visibleCats.length === 0 ? (
|
||||||
<p className="px-2 py-4 text-center text-sm opacity-50">{t("settings.noMatch", { q: search.trim() })}</p>
|
<div className="space-y-2 px-2 py-4 text-center">
|
||||||
|
<p className="text-sm opacity-50">{t("settings.noMatch", { q: search.trim() })}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
className="rounded-full border border-stone-200 bg-white px-3.5 py-1.5 text-sm transition hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{t("settings.clearSearch")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 sm:flex-row">
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
<nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col">
|
<nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col">
|
||||||
@@ -422,7 +480,10 @@ export function SettingsModal({
|
|||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCat(c.id)}
|
// Picking a category exits search mode: keeping the query
|
||||||
|
// would leave the nav filtered (often down to this single
|
||||||
|
// button, or empty) and trap the user in the search view.
|
||||||
|
onClick={() => { setCat(c.id); setSearch(""); }}
|
||||||
aria-current={activeCat === c.id}
|
aria-current={activeCat === c.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-2.5 rounded-2xl px-3.5 py-2.5 text-sm transition",
|
"flex shrink-0 items-center gap-2.5 rounded-2xl px-3.5 py-2.5 text-sm transition",
|
||||||
@@ -502,11 +563,14 @@ export function SettingsModal({
|
|||||||
/>
|
/>
|
||||||
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
|
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
<Field label={t("settings.username")} hint={usernameChangeEnabled ? t("settings.usernameHint") : t("settings.usernameDisabled")}>
|
||||||
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin" || !usernameChangeEnabled} />
|
||||||
|
</Field>
|
||||||
<Field label={t("common.displayName")}>
|
<Field label={t("common.displayName")}>
|
||||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required />
|
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t("settings.email")} hint={t("settings.emailHint")}>
|
<Field label={t("settings.email")} hint={t("settings.emailHint")}>
|
||||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
|
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" autoComplete="email" />
|
||||||
</Field>
|
</Field>
|
||||||
</SettingsPane>
|
</SettingsPane>
|
||||||
)}
|
)}
|
||||||
@@ -521,6 +585,7 @@ export function SettingsModal({
|
|||||||
align="left"
|
align="left"
|
||||||
options={[
|
options={[
|
||||||
{ value: "auto", label: t("common.themeAutoFull") },
|
{ value: "auto", label: t("common.themeAutoFull") },
|
||||||
|
{ value: "sunset", label: t("common.themeSunsetFull") },
|
||||||
{ value: "light", label: t("common.light") },
|
{ value: "light", label: t("common.light") },
|
||||||
{ value: "dark", label: t("common.dark") },
|
{ value: "dark", label: t("common.dark") },
|
||||||
]}
|
]}
|
||||||
@@ -540,12 +605,24 @@ export function SettingsModal({
|
|||||||
</SettingsPane>
|
</SettingsPane>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeCat === "voice" && (
|
||||||
|
<SettingsPane>
|
||||||
|
<VoiceSection />
|
||||||
|
</SettingsPane>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeCat === "security" && (
|
{activeCat === "security" && (
|
||||||
<SettingsPane>
|
<SettingsPane>
|
||||||
<SecuritySection onKeyRotated={onKeyRotated} />
|
<SecuritySection onKeyRotated={onKeyRotated} />
|
||||||
</SettingsPane>
|
</SettingsPane>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeCat === "providers" && (
|
||||||
|
<SettingsPane>
|
||||||
|
<ProvidersSection onChanged={onProvidersChanged} />
|
||||||
|
</SettingsPane>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeCat === "archived" && (
|
{activeCat === "archived" && (
|
||||||
<SettingsPane>
|
<SettingsPane>
|
||||||
<ArchivedSection onView={onViewArchived} onChanged={onArchivedChanged} />
|
<ArchivedSection onView={onViewArchived} onChanged={onArchivedChanged} />
|
||||||
@@ -560,7 +637,8 @@ export function SettingsModal({
|
|||||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
|
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
|
||||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("common.save")}</Button>
|
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("common.save")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -585,6 +663,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Conversation | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Conversation | null>(null);
|
||||||
|
const [shareTarget, setShareTarget] = useState<Conversation | null>(null);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
@@ -607,6 +686,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
await api.unarchiveConv(c.id);
|
await api.unarchiveConv(c.id);
|
||||||
await refresh();
|
await refresh();
|
||||||
onChanged();
|
onChanged();
|
||||||
|
pushToast(t("toast.chatUnarchived", { title: c.title }), { icon: "unarchive" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -622,6 +702,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
await refresh();
|
await refresh();
|
||||||
onChanged(c.id);
|
onChanged(c.id);
|
||||||
|
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("common.deleteFailed"));
|
setError(err instanceof Error ? err.message : t("common.deleteFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -634,8 +715,8 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<p className="-mb-2 text-xs opacity-60">{t("archived.readonly")}</p>
|
<p className="text-xs leading-relaxed opacity-60">{t("archived.readonly")}</p>
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
{(list ?? []).length === 0 ? (
|
{(list ?? []).length === 0 ? (
|
||||||
<p className="py-4 text-center text-sm opacity-50">{t("archived.empty")}</p>
|
<p className="py-4 text-center text-sm opacity-50">{t("archived.empty")}</p>
|
||||||
@@ -670,6 +751,16 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
>
|
>
|
||||||
<ArchiveRestore size={15} />
|
<ArchiveRestore size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("sidebar.share")}
|
||||||
|
aria-label={`${t("sidebar.share")}: ${c.title}`}
|
||||||
|
disabled={busyId === c.id}
|
||||||
|
onClick={() => setShareTarget(c)}
|
||||||
|
className="rounded-full p-2 transition hover:bg-stone-100 disabled:opacity-50 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Share2 size={15} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
@@ -685,6 +776,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
<ShareModal conv={shareTarget} onClose={() => setShareTarget(null)} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
@@ -702,17 +794,13 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
function AccentSection() {
|
function AccentSection() {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [state, setState] = useState<AccentState>(() => currentAccent());
|
const [state, setState] = useState<AccentState>(() => currentAccent());
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
function pick(presetId: string) {
|
function pick(presetId: string) {
|
||||||
const next = { ...state, presetId };
|
const next = { ...state, presetId };
|
||||||
setState(next);
|
setState(next);
|
||||||
applyAccent(next);
|
applyAccent(next);
|
||||||
}
|
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
|
||||||
|
|
||||||
function pickCustom(hex: string) {
|
|
||||||
const next = { presetId: "custom", customHex: hex };
|
|
||||||
setState(next);
|
|
||||||
previewAccent(hex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -736,26 +824,213 @@ function AccentSection() {
|
|||||||
style={{ backgroundColor: p.base }}
|
style={{ backgroundColor: p.base }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<label
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen((o) => !o)}
|
||||||
|
aria-expanded={pickerOpen}
|
||||||
|
aria-label={t("settings.customAria")}
|
||||||
|
title={t("settings.customColor")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid h-8 w-8 cursor-pointer place-items-center overflow-hidden rounded-full border-2 transition active:scale-90",
|
"grid h-8 w-8 place-items-center rounded-full border-2 transition active:scale-90",
|
||||||
state.presetId === "custom"
|
state.presetId === "custom" || pickerOpen
|
||||||
? "border-stone-900 dark:border-white"
|
? "border-stone-900 dark:border-white"
|
||||||
: "border-transparent hover:scale-110",
|
: "border-transparent hover:scale-110",
|
||||||
)}
|
)}
|
||||||
style={{ background: state.presetId === "custom" ? state.customHex : undefined }}
|
style={{ background: "conic-gradient(#f43f5e,#f59e0b,#84cc16,#14b8a6,#3b82f6,#8b5cf6,#f43f5e)" }}
|
||||||
title={t("settings.customColor")}
|
|
||||||
>
|
>
|
||||||
<Sparkles size={14} className={cn("text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]", state.presetId === "custom" && "opacity-0")} />
|
<Pipette size={14} className="text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]" />
|
||||||
<input
|
</button>
|
||||||
type="color"
|
</div>
|
||||||
aria-label={t("settings.customAria")}
|
{pickerOpen && (
|
||||||
value={state.presetId === "custom" ? state.customHex : "#10b981"}
|
<AccentCustomPanel
|
||||||
onChange={(e) => pickCustom(e.target.value)}
|
initialHex={state.presetId === "custom" ? state.customHex : (ACCENT_PRESETS.find((p) => p.id === state.presetId)?.base ?? "#10b981")}
|
||||||
onBlur={() => applyAccent(state)} // persist the last previewed value
|
onPreview={(hex) => {
|
||||||
className="absolute inset-0 cursor-pointer opacity-0"
|
setState({ presetId: "custom", customHex: hex });
|
||||||
|
previewAccent(hex);
|
||||||
|
}}
|
||||||
|
onCommit={(hex) => {
|
||||||
|
const next = { presetId: "custom", customHex: hex };
|
||||||
|
setState(next);
|
||||||
|
applyAccent(next);
|
||||||
|
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Custom accent picker: SV square + hue slider + hex field ---------- */
|
||||||
|
|
||||||
|
function AccentCustomPanel({ initialHex, onPreview, onCommit }: {
|
||||||
|
initialHex: string;
|
||||||
|
onPreview: (hex: string) => void;
|
||||||
|
onCommit: (hex: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useT();
|
||||||
|
const start = hexToHsv(initialHex) ?? { h: 160, s: 84, v: 73 };
|
||||||
|
const [h, setH] = useState(start.h);
|
||||||
|
const [s, setS] = useState(start.s);
|
||||||
|
const [v, setV] = useState(start.v);
|
||||||
|
const [hexInput, setHexInput] = useState(() => hsvToHex(start.h, start.s, start.v));
|
||||||
|
const [hexError, setHexError] = useState(false);
|
||||||
|
const [drag, setDrag] = useState<null | "sv" | "hue">(null);
|
||||||
|
const svRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hueRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Synchronous mirror (pointerup fires before setState flushes).
|
||||||
|
const hsvRef = useRef({ h: start.h, s: start.s, v: start.v });
|
||||||
|
|
||||||
|
const currentHex = useMemo(() => hsvToHex(h, s, v), [h, s, v]);
|
||||||
|
|
||||||
|
function applyHsv(nh: number, ns: number, nv: number) {
|
||||||
|
const c = {
|
||||||
|
h: Math.min(359.99, Math.max(0, nh)),
|
||||||
|
s: Math.min(100, Math.max(0, ns)),
|
||||||
|
v: Math.min(100, Math.max(0, nv)),
|
||||||
|
};
|
||||||
|
hsvRef.current = c;
|
||||||
|
setH(c.h);
|
||||||
|
setS(c.s);
|
||||||
|
setV(c.v);
|
||||||
|
const hex = hsvToHex(c.h, c.s, c.v);
|
||||||
|
setHexInput(hex);
|
||||||
|
setHexError(false);
|
||||||
|
onPreview(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function posToSv(e: React.PointerEvent): void {
|
||||||
|
const el = svRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
applyHsv(hsvRef.current.h, ((e.clientX - r.left) / r.width) * 100, (1 - (e.clientY - r.top) / r.height) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function posToHue(e: React.PointerEvent): void {
|
||||||
|
const el = hueRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const nh = ((e.clientX - r.left) / r.width) * 360;
|
||||||
|
const c = hsvRef.current;
|
||||||
|
applyHsv(nh, c.s, c.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function begin(kind: "sv" | "hue", e: React.PointerEvent) {
|
||||||
|
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||||
|
setDrag(kind);
|
||||||
|
if (kind === "sv") posToSv(e);
|
||||||
|
else posToHue(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitHex(raw: string) {
|
||||||
|
const m = /^#?([0-9a-fA-F]{6})$/.exec(raw.trim());
|
||||||
|
if (!m) {
|
||||||
|
setHexError(true);
|
||||||
|
setHexInput(currentHex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hex = `#${m[1]!.toLowerCase()}`;
|
||||||
|
const c = hexToHsv(hex);
|
||||||
|
if (c) {
|
||||||
|
hsvRef.current = c;
|
||||||
|
setH(c.h);
|
||||||
|
setS(c.s);
|
||||||
|
setV(c.v);
|
||||||
|
setHexInput(hex);
|
||||||
|
setHexError(false);
|
||||||
|
onPreview(hex);
|
||||||
|
onCommit(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-3 rounded-2xl border border-stone-200/70 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<div
|
||||||
|
ref={svRef}
|
||||||
|
role="slider"
|
||||||
|
aria-label={t("settings.customAria")}
|
||||||
|
aria-valuetext={currentHex}
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={(e) => begin("sv", e)}
|
||||||
|
onPointerMove={(e) => { if (drag === "sv") posToSv(e); }}
|
||||||
|
onPointerUp={() => { if (drag === "sv") { setDrag(null); onCommit(hsvToHex(hsvRef.current.h, hsvRef.current.s, hsvRef.current.v)); } }}
|
||||||
|
onPointerCancel={() => setDrag(null)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
const step = e.shiftKey ? 10 : 2;
|
||||||
|
const c = hsvRef.current;
|
||||||
|
if (e.key === "ArrowLeft") { applyHsv(c.h, c.s - step, c.v); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), Math.min(100, Math.max(0, c.s - step)), c.v)); }
|
||||||
|
else if (e.key === "ArrowRight") { applyHsv(c.h, c.s + step, c.v); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), Math.min(100, Math.max(0, c.s + step)), c.v)); }
|
||||||
|
else if (e.key === "ArrowUp") { applyHsv(c.h, c.s, c.v + step); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), c.s, Math.min(100, Math.max(0, c.v + step)))); }
|
||||||
|
else if (e.key === "ArrowDown") { applyHsv(c.h, c.s, c.v - step); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), c.s, Math.min(100, Math.max(0, c.v - step)))); }
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
className="relative h-40 w-full cursor-crosshair touch-none select-none overflow-hidden rounded-2xl"
|
||||||
|
style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute h-4 w-4 rounded-full border-2 border-white shadow-md"
|
||||||
|
style={{ left: `${s}%`, top: `${100 - v}%`, transform: "translate(-50%, -50%)", backgroundColor: currentHex }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={hueRef}
|
||||||
|
role="slider"
|
||||||
|
aria-label={t("settings.accent")}
|
||||||
|
aria-valuetext={String(Math.round(h))}
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={(e) => begin("hue", e)}
|
||||||
|
onPointerMove={(e) => { if (drag === "hue") posToHue(e); }}
|
||||||
|
onPointerUp={() => { if (drag === "hue") { setDrag(null); onCommit(hsvToHex(hsvRef.current.h, hsvRef.current.s, hsvRef.current.v)); } }}
|
||||||
|
onPointerCancel={() => setDrag(null)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||||
|
const nh = hsvRef.current.h + (e.key === "ArrowRight" ? 4 : -4);
|
||||||
|
const c = hsvRef.current;
|
||||||
|
applyHsv(nh, c.s, c.v);
|
||||||
|
onCommit(hsvToHex(nh, c.s, c.v));
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
className="relative h-3 w-full cursor-pointer touch-none select-none rounded-full"
|
||||||
|
style={{ background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-1/2 h-5 w-5 rounded-full border-2 border-white bg-transparent shadow-md"
|
||||||
|
style={{ left: `calc(${(h / 360) * 100}% - 10px)`, transform: "translateY(-50%)", backgroundColor: currentHex }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-9 w-9 shrink-0 rounded-full border border-stone-200 dark:border-zinc-700" style={{ backgroundColor: currentHex }} aria-hidden />
|
||||||
|
<Field label={t("accent.hexLabel")}>
|
||||||
|
<Input
|
||||||
|
value={hexInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHexInput(e.target.value);
|
||||||
|
const m = /^#?([0-9a-fA-F]{6})$/.exec(e.target.value.trim());
|
||||||
|
if (m) {
|
||||||
|
const hex = `#${m[1]!.toLowerCase()}`;
|
||||||
|
const c = hexToHsv(hex);
|
||||||
|
if (c) {
|
||||||
|
hsvRef.current = c;
|
||||||
|
setH(c.h);
|
||||||
|
setS(c.s);
|
||||||
|
setV(c.v);
|
||||||
|
setHexError(false);
|
||||||
|
onPreview(hex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHexError(e.target.value.trim().length > 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => commitHex(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") commitHex((e.target as HTMLInputElement).value); }}
|
||||||
|
maxLength={7}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
className={cn("font-mono uppercase", hexError && "!border-red-500")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -797,6 +1072,225 @@ function FeaturesSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Voice (settings): playback voice, language, previews ---------- */
|
||||||
|
|
||||||
|
const VOICE_LANGS = ["auto", "fr", "en", "es", "de", "it", "pt", "ru"];
|
||||||
|
|
||||||
|
function VoiceSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [prefs, setPrefs] = useState<VoicePrefs>(() => getVoicePrefs());
|
||||||
|
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||||
|
const [previews, setPreviews] = useState<VoicePreview[]>([]);
|
||||||
|
const [playing, setPlaying] = useState<number | null>(null);
|
||||||
|
const [ttsServer, setTtsServer] = useState<{ available: boolean; provider: string | null; voices: { id: string; name: string }[] } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
listTtsVoices().then((v) => { if (alive) setVoices(v); });
|
||||||
|
api.tools().then((r) => { if (alive) setPreviews(r.voicePreviews); }).catch(() => {});
|
||||||
|
api.ttsInfo().then((r) => { if (alive) setTtsServer({ available: r.available, provider: r.provider, voices: r.voices }); }).catch(() => {});
|
||||||
|
return () => { alive = false; stopPlayback(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update(p: Partial<VoicePrefs>) {
|
||||||
|
const next = { ...prefs, ...p };
|
||||||
|
setPrefs(next);
|
||||||
|
setVoicePrefs(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(i: number, line: VoicePreview) {
|
||||||
|
if (playing === i) {
|
||||||
|
stopPlayback();
|
||||||
|
setPlaying(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Each line plays with ITS OWN character; the neural server engine is
|
||||||
|
// used when selected and available (line.server voice or user default).
|
||||||
|
playText({
|
||||||
|
text: line.text,
|
||||||
|
engine: prefs.engine,
|
||||||
|
browser: { voices, lang: line.lang, rate: line.rate, pitch: line.pitch, timbre: line.timbre },
|
||||||
|
serverVoice: line.voice || prefs.serverVoice || undefined,
|
||||||
|
serverAvailable: ttsServer?.available ?? false,
|
||||||
|
fetchAudio: (text, voice) => api.ttsSpeak(text, voice),
|
||||||
|
onend: () => setPlaying(null),
|
||||||
|
onerror: () => setPlaying(null),
|
||||||
|
});
|
||||||
|
setPlaying(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adopt a preview's character as the global playback voice. */
|
||||||
|
function adoptVoice(line: VoicePreview) {
|
||||||
|
const lang = line.lang === "auto" ? detectLang(line.text) : line.lang;
|
||||||
|
const pick = pickVoice(voices, { lang, timbre: line.timbre });
|
||||||
|
const next: VoicePrefs = {
|
||||||
|
...prefs,
|
||||||
|
voiceURI: pick?.voiceURI ?? prefs.voiceURI,
|
||||||
|
rate: line.rate,
|
||||||
|
pitch: line.pitch,
|
||||||
|
lang,
|
||||||
|
};
|
||||||
|
setPrefs(next);
|
||||||
|
setVoicePrefs(next);
|
||||||
|
pushToast(t("voice.adopted"), { icon: "check" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = ttsSupported();
|
||||||
|
const groups = (() => {
|
||||||
|
const byLang = new Map<string, SpeechSynthesisVoice[]>();
|
||||||
|
for (const v of voices) {
|
||||||
|
const lang = (v.lang || "?").split("-")[0]!.toLowerCase();
|
||||||
|
const list = byLang.get(lang) ?? [];
|
||||||
|
list.push(v);
|
||||||
|
byLang.set(lang, list);
|
||||||
|
}
|
||||||
|
return [...byLang.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([group, options]) => ({
|
||||||
|
group,
|
||||||
|
options: options.map((v) => ({ value: v.voiceURI, label: v.name + (v.default ? " ★" : "") })),
|
||||||
|
}));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="-mb-2 text-xs opacity-60">{t("voice.desc")}</p>
|
||||||
|
{!supported && (
|
||||||
|
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm text-amber-700 dark:text-amber-400" role="alert">
|
||||||
|
{t("voice.noVoices")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Field label={t("voice.language")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("voice.language")}
|
||||||
|
icon={Globe}
|
||||||
|
value={prefs.lang}
|
||||||
|
onChange={(v) => update({ lang: v, voiceURI: null })}
|
||||||
|
align="left"
|
||||||
|
options={VOICE_LANGS.map((l) => ({ value: l, label: l === "auto" ? t("voice.auto") : l }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("voice.engine")} hint={ttsServer?.available ? t("voice.engineHintOn", { provider: ttsServer.provider ?? "?" }) : t("voice.engineHintOff")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("voice.engine")}
|
||||||
|
icon={Volume2}
|
||||||
|
value={prefs.engine}
|
||||||
|
onChange={(v) => update({ engine: v as VoicePrefs["engine"] })}
|
||||||
|
align="left"
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: t("voice.engineAuto") },
|
||||||
|
{ value: "browser", label: t("voice.engineBrowser") },
|
||||||
|
{ value: "server", label: t("voice.engineServer") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{(prefs.engine !== "browser") && (
|
||||||
|
<>
|
||||||
|
{!ttsServer?.available && (
|
||||||
|
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm text-amber-700 dark:text-amber-400">{t("voice.noServer")}</p>
|
||||||
|
)}
|
||||||
|
{ttsServer && ttsServer.voices.length > 0 ? (
|
||||||
|
<Field label={t("voice.serverVoice")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("voice.serverVoice")}
|
||||||
|
icon={Volume2}
|
||||||
|
value={prefs.serverVoice ?? ""}
|
||||||
|
onChange={(v) => update({ serverVoice: v || null })}
|
||||||
|
align="left"
|
||||||
|
options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v.id, label: v.name }))]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : ttsServer?.provider === "elevenlabs" ? (
|
||||||
|
<Field label={t("voice.serverVoice")} hint={t("voice.serverVoiceHint")}>
|
||||||
|
<Input
|
||||||
|
value={prefs.serverVoice ?? ""}
|
||||||
|
onChange={(e) => update({ serverVoice: e.target.value.trim() || null })}
|
||||||
|
placeholder="pNInz6obpgDQGcFmaJgB"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : ttsServer?.provider === "piper" ? (
|
||||||
|
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-sm opacity-70 dark:bg-zinc-800">{t("voice.piperNote")}</p>
|
||||||
|
) : (
|
||||||
|
<Field label={t("voice.serverVoice")} hint={t("vfeat.noProvider")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("voice.serverVoice")}
|
||||||
|
icon={Volume2}
|
||||||
|
value={prefs.serverVoice ?? ""}
|
||||||
|
onChange={(v) => update({ serverVoice: v || null })}
|
||||||
|
align="left"
|
||||||
|
options={[{ value: "", label: t("voice.defaultVoice") }, ...OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Field label={t("voice.voice")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("voice.voice")}
|
||||||
|
icon={Volume2}
|
||||||
|
value={prefs.voiceURI ?? ""}
|
||||||
|
onChange={(v) => update({ voiceURI: v || null })}
|
||||||
|
align="left"
|
||||||
|
groups={[{ group: "—", options: [{ value: "", label: t("voice.defaultVoice") }] }, ...groups]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label={`${t("voice.rate")} · ${prefs.rate.toFixed(2)}×`}>
|
||||||
|
<input
|
||||||
|
type="range" min={0.5} max={1.5} step={0.05} value={prefs.rate}
|
||||||
|
onChange={(e) => update({ rate: Number(e.target.value) })}
|
||||||
|
className="w-full accent-[rgb(var(--ka-accent-600))]"
|
||||||
|
aria-label={t("voice.rate")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={`${t("voice.pitch")} · ${prefs.pitch.toFixed(2)}×`}>
|
||||||
|
<input
|
||||||
|
type="range" min={0.5} max={1.5} step={0.05} value={prefs.pitch}
|
||||||
|
onChange={(e) => update({ pitch: Number(e.target.value) })}
|
||||||
|
className="w-full accent-[rgb(var(--ka-accent-600))]"
|
||||||
|
aria-label={t("voice.pitch")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("voice.previews")}</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{previews.map((line, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 rounded-2xl border border-stone-200/70 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => preview(i, line)}
|
||||||
|
disabled={!supported}
|
||||||
|
aria-label={`${t("voice.preview")} ${i + 1}`}
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent-600 text-white transition hover:bg-accent-500 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950"
|
||||||
|
>
|
||||||
|
{playing === i ? <Square size={13} /> : <Play size={13} className="translate-x-[1px]" />}
|
||||||
|
</button>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm">{line.text}</span>
|
||||||
|
<span className="block text-xs opacity-50">
|
||||||
|
{t(`voice.timbre.${line.timbre}` as StringKey)} · {(line.lang === "auto" ? detectLang(line.text) : line.lang).toUpperCase()} · {line.rate.toFixed(2)}× / {line.pitch.toFixed(2)}×
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => adoptVoice(line)}
|
||||||
|
disabled={!supported}
|
||||||
|
title={t("voice.adopt")}
|
||||||
|
aria-label={`${t("voice.adopt")} ${i + 1}`}
|
||||||
|
className="shrink-0 rounded-full px-2.5 py-1.5 text-xs font-medium text-accent-700 transition hover:bg-accent-600/10 disabled:opacity-40 dark:text-accent-400"
|
||||||
|
>
|
||||||
|
{t("voice.adopt")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
|
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
|
||||||
|
|
||||||
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
||||||
@@ -996,3 +1490,216 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Providers (settings): personal API keys (BYOK) ---------- */
|
||||||
|
|
||||||
|
const PROVIDER_META: { id: string; label: string; docs: string }[] = [
|
||||||
|
{ id: "openai", label: "OpenAI", docs: "https://platform.openai.com/api-keys" },
|
||||||
|
{ id: "anthropic", label: "Anthropic", docs: "https://console.anthropic.com/settings/keys" },
|
||||||
|
{ id: "deepseek", label: "DeepSeek", docs: "https://platform.deepseek.com/api_keys" },
|
||||||
|
{ id: "gemini", label: "Gemini", docs: "https://aistudio.google.com/apikey" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [list, setList] = useState<UserProvider[] | null>(null);
|
||||||
|
const [keys, setKeys] = useState<Record<string, string>>({});
|
||||||
|
// Connected providers show a greyed-out (disabled) key field; "Replace"
|
||||||
|
// unlocks it for rotation.
|
||||||
|
const [editing, setEditing] = useState<Record<string, boolean>>({});
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const res = await api.providers();
|
||||||
|
setList(res.providers);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("providers.loadFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(id: string) {
|
||||||
|
const v = (keys[id] ?? "").trim();
|
||||||
|
if (v.length < 8 || v.length > 256 || /\s/.test(v)) {
|
||||||
|
setError(t("providers.invalidKey"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyId(id);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const res = await api.setProvider(id, v);
|
||||||
|
setList(res.providers);
|
||||||
|
setKeys((k) => ({ ...k, [id]: "" }));
|
||||||
|
setEditing((e) => ({ ...e, [id]: false }));
|
||||||
|
setNotice(t("providers.keySaved"));
|
||||||
|
onChanged?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
setBusyId(id);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const res = await api.deleteProvider(id);
|
||||||
|
setList(res.providers);
|
||||||
|
setEditing((e) => ({ ...e, [id]: false }));
|
||||||
|
setNotice(t("providers.keyRemoved"));
|
||||||
|
onChanged?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.deleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list === null && !error) {
|
||||||
|
return <p className="flex items-center gap-2 py-4 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map((list ?? []).map((p) => [p.provider, p]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs leading-relaxed opacity-60">{t("providers.intro")}</p>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
{notice && <p className="rounded-2xl bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{PROVIDER_META.map((m) => {
|
||||||
|
const st = byId.get(m.id);
|
||||||
|
const connected = !!st?.hasKey;
|
||||||
|
const busy = busyId === m.id;
|
||||||
|
// A stored key locks the field (greyed out) until "Replace".
|
||||||
|
const locked = connected && !editing[m.id];
|
||||||
|
return (
|
||||||
|
<li key={m.id} className="space-y-2.5 rounded-2xl border border-stone-200/70 p-3.5 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 shrink-0 rounded-full",
|
||||||
|
connected ? "bg-emerald-500" : "bg-stone-300 dark:bg-zinc-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{m.label}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-xs",
|
||||||
|
connected
|
||||||
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-stone-200/70 opacity-60 dark:bg-zinc-800",
|
||||||
|
)}>
|
||||||
|
{connected ? t("providers.connected") : t("providers.notConnected")}
|
||||||
|
</span>
|
||||||
|
{connected && st?.last4 && (
|
||||||
|
<code className="rounded-lg bg-stone-100 px-2 py-0.5 font-mono text-xs opacity-70 dark:bg-zinc-800">
|
||||||
|
••••{st.last4}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
{connected ? (
|
||||||
|
<span className="ml-auto shrink-0 cursor-not-allowed text-xs opacity-40">
|
||||||
|
{t("providers.getKey")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={m.docs}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ml-auto shrink-0 text-xs text-accent-700 underline-offset-2 hover:underline dark:text-accent-400"
|
||||||
|
>
|
||||||
|
{t("providers.getKey")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
// API keys are not login credentials: tell browsers and
|
||||||
|
// password-manager extensions to leave these fields alone
|
||||||
|
// (no username fill, no inline search menu, no save prompt).
|
||||||
|
// Note: plain "off" is deliberately ignored by browsers on
|
||||||
|
// password fields — "new-password" is the token they honor.
|
||||||
|
autoComplete="new-password"
|
||||||
|
name={`ka-provider-key-${m.id}`}
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
|
spellCheck={false}
|
||||||
|
value={locked ? "" : (keys[m.id] ?? "")}
|
||||||
|
onChange={(e) => setKeys((k) => ({ ...k, [m.id]: e.target.value }))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !locked) {
|
||||||
|
e.preventDefault();
|
||||||
|
save(m.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={locked && st?.last4 ? `••••${st.last4}` : t("providers.keyPh")}
|
||||||
|
aria-label={`${m.label} API key`}
|
||||||
|
disabled={busy || locked}
|
||||||
|
className="disabled:cursor-not-allowed disabled:bg-stone-100 disabled:opacity-60 dark:disabled:bg-zinc-800"
|
||||||
|
/>
|
||||||
|
{!locked && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || !(keys[m.id] ?? "").trim()}
|
||||||
|
onClick={() => save(m.id)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{busy ? <Spinner size={15} /> : t("providers.saveKey")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{connected && (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{locked ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditing((e) => ({ ...e, [m.id]: true }))}
|
||||||
|
>
|
||||||
|
<Pencil size={14} /> {t("providers.replaceKey")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing((e) => ({ ...e, [m.id]: false }));
|
||||||
|
setKeys((k) => ({ ...k, [m.id]: "" }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
className="!text-red-600 dark:!text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> {t("providers.removeKey")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs leading-relaxed opacity-60">{t("providers.hint")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
|
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
@@ -465,7 +465,7 @@ const GRADIENTS = [
|
|||||||
export function Avatar({ name, url, size = 36 }: { name: string; url?: string; size?: number }) {
|
export function Avatar({ name, url, size = 36 }: { name: string; url?: string; size?: number }) {
|
||||||
const cdnV = useCdnVersion();
|
const cdnV = useCdnVersion();
|
||||||
if (url) {
|
if (url) {
|
||||||
return <img src={withCdnVersion(url, cdnV)} alt={name} width={size} height={size} className="rounded-full object-cover" style={{ width: size, height: size }} />;
|
return <img src={withCdnVersion(url, cdnV)} alt={name} width={size} height={size} className="shrink-0 rounded-full object-cover" style={{ width: size, height: size }} />;
|
||||||
}
|
}
|
||||||
const initial = (name.trim()[0] || "?").toUpperCase();
|
const initial = (name.trim()[0] || "?").toUpperCase();
|
||||||
const g = GRADIENTS[(name.charCodeAt(0) || 0) % GRADIENTS.length];
|
const g = GRADIENTS[(name.charCodeAt(0) || 0) % GRADIENTS.length];
|
||||||
@@ -483,13 +483,33 @@ export function Avatar({ name, url, size = 36 }: { name: string; url?: string; s
|
|||||||
/* ---------- Brand logo (single declaration — import anywhere) ---------- */
|
/* ---------- Brand logo (single declaration — import anywhere) ---------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The KisAssistant flower mark. Petals follow the runtime accent color,
|
* The KisAssistant flower mark: a soft five-petal blossom with a lighter
|
||||||
* heart stays amber — same artwork as the loading screen and favicon.
|
* inner ring, petals following the runtime accent color (gradients read
|
||||||
|
* the --ka-accent-* triplets), heart in amber radial with a highlight —
|
||||||
|
* same artwork as the loading screen and favicon. Petals are deliberately
|
||||||
|
* slightly uneven (sizes/angles) so it reads organic, not geometric.
|
||||||
* `dynamic` controls the bloom animation: pass false for a static,
|
* `dynamic` controls the bloom animation: pass false for a static,
|
||||||
* full-bloom mark (e.g. thread bars and headers).
|
* full-bloom mark (e.g. thread bars and headers).
|
||||||
*/
|
*/
|
||||||
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
|
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
|
||||||
const petals = [0, 60, 120, 180, 240, 300];
|
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||||
|
const petalId = `ka-petal-${uid}`;
|
||||||
|
const petalInId = `ka-petalin-${uid}`;
|
||||||
|
const coreId = `ka-core-${uid}`;
|
||||||
|
const outer = [
|
||||||
|
{ r: 8, ry: 20 },
|
||||||
|
{ r: 80, ry: 19 },
|
||||||
|
{ r: 151, ry: 20.5 },
|
||||||
|
{ r: 223, ry: 19.5 },
|
||||||
|
{ r: 294, ry: 20 },
|
||||||
|
];
|
||||||
|
const inner = [
|
||||||
|
{ r: 44, ry: 12 },
|
||||||
|
{ r: 116, ry: 11.5 },
|
||||||
|
{ r: 188, ry: 12 },
|
||||||
|
{ r: 259, ry: 11.5 },
|
||||||
|
{ r: 331, ry: 12 },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -499,32 +519,68 @@ export function FlowerMark({ size, className, dynamic = true }: { size?: number;
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label="KisAssistant"
|
aria-label="KisAssistant"
|
||||||
>
|
>
|
||||||
{petals.map((r, i) => (
|
<defs>
|
||||||
<g key={r} transform={`rotate(${r} 50 50)`}>
|
<linearGradient id={petalId} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stopColor="rgb(var(--ka-accent-600))" />
|
||||||
|
<stop offset="0.6" stopColor="rgb(var(--ka-accent-500))" />
|
||||||
|
<stop offset="1" stopColor="rgb(var(--ka-accent-300))" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={petalInId} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stopColor="rgb(var(--ka-accent-400))" />
|
||||||
|
<stop offset="1" stopColor="rgb(var(--ka-accent-200))" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={coreId}>
|
||||||
|
<stop offset="0" stopColor="#fef3c7" />
|
||||||
|
<stop offset="0.5" stopColor="#fbbf24" />
|
||||||
|
<stop offset="1" stopColor="#d97706" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
{outer.map((p, i) => (
|
||||||
|
<g key={p.r} transform={`rotate(${p.r} 50 50)`}>
|
||||||
<ellipse
|
<ellipse
|
||||||
cx="50"
|
cx="50"
|
||||||
cy="25"
|
cy="27"
|
||||||
rx="13"
|
rx="13.5"
|
||||||
ry="21"
|
ry={p.ry}
|
||||||
className={dynamic ? "flower-petal fill-accent-500" : "fill-accent-500"}
|
fill={`url(#${petalId})`}
|
||||||
style={dynamic ? { animationDelay: `${i * 0.22}s` } : undefined}
|
className={dynamic ? "flower-petal" : undefined}
|
||||||
|
style={dynamic ? { animationDelay: `${i * 0.2}s` } : undefined}
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
<circle cx="50" cy="50" r="10" className={dynamic ? "flower-core fill-amber-400" : "fill-amber-400"} />
|
{inner.map((p, i) => (
|
||||||
|
<g key={p.r} transform={`rotate(${p.r} 50 50)`}>
|
||||||
|
<ellipse
|
||||||
|
cx="50"
|
||||||
|
cy="34"
|
||||||
|
rx="8"
|
||||||
|
ry={p.ry}
|
||||||
|
fill={`url(#${petalInId})`}
|
||||||
|
className={dynamic ? "flower-petal-inner" : undefined}
|
||||||
|
style={dynamic ? { animationDelay: `${i * 0.2 + 0.6}s` } : undefined}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<g className={dynamic ? "flower-core" : undefined}>
|
||||||
|
<circle cx="50" cy="50" r="10.5" fill="#f59e0b" opacity="0.22" />
|
||||||
|
<circle cx="50" cy="50" r="8" fill={`url(#${coreId})`} />
|
||||||
|
<circle cx="47.5" cy="47" r="2.2" fill="#fffbeb" opacity="0.95" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assistant identity: static flower mark on a soft circle (message avatars). */
|
/** Assistant identity: flower mark on a soft circle (message avatars).
|
||||||
export function AssistantAvatar({ size = 32 }: { size?: number }) {
|
* Pass `dynamic` while the assistant is thinking/streaming so the logo
|
||||||
|
* blooms, then back to static once the reply is done. */
|
||||||
|
export function AssistantAvatar({ size = 32, dynamic = false }: { size?: number; dynamic?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="grid shrink-0 place-items-center rounded-full bg-accent-600/10"
|
className="grid shrink-0 place-items-center rounded-full bg-accent-600/10"
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
aria-label="KisAssistant"
|
aria-label="KisAssistant"
|
||||||
>
|
>
|
||||||
<FlowerMark size={Math.round(size * 0.78)} dynamic={false} />
|
<FlowerMark size={Math.round(size * 0.78)} dynamic={dynamic} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -577,6 +633,28 @@ export function Toggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clipboard write with legacy fallback (non-secure contexts lack the async API). */
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
@@ -584,7 +662,8 @@ export function CopyButton({ text, label }: { text: string; label?: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
copyText(text).then((ok) => {
|
||||||
|
if (!ok) return;
|
||||||
setDone(true);
|
setDone(true);
|
||||||
setTimeout(() => setDone(false), 1500);
|
setTimeout(() => setDone(false), 1500);
|
||||||
});
|
});
|
||||||
@@ -606,7 +685,7 @@ export function LoadingScreen() {
|
|||||||
<div className="loading-enter grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant">
|
<div className="loading-enter grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant">
|
||||||
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
||||||
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
|
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
|
||||||
<p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
|
<p className="loader-title font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
|
||||||
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
|
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+105
-12
@@ -15,6 +15,7 @@
|
|||||||
--ka-accent-700: 4 120 87;
|
--ka-accent-700: 4 120 87;
|
||||||
--ka-accent-800: 6 95 70;
|
--ka-accent-800: 6 95 70;
|
||||||
--ka-accent-900: 6 78 59;
|
--ka-accent-900: 6 78 59;
|
||||||
|
--ka-accent-950: 2 44 34;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
@@ -30,8 +31,9 @@ html, body, #root {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
|
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
|
||||||
/* Kill the 300ms tap delay, keep gestures app-like. */
|
/* App-like viewport: pan + scroll only — no double-tap or pinch zoom.
|
||||||
touch-action: manipulation;
|
(Kills the 300ms tap delay as a side effect, keeps gestures app-like.) */
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,13 @@ body {
|
|||||||
/* markdown content */
|
/* markdown content */
|
||||||
.md p:first-child { margin-top: 0; }
|
.md p:first-child { margin-top: 0; }
|
||||||
.md p:last-child { margin-bottom: 0; }
|
.md p:last-child { margin-bottom: 0; }
|
||||||
|
/* Long unbroken strings (URLs, file paths, hashes, tokens) must wrap
|
||||||
|
anywhere — otherwise they overflow the bubble, which then grows past its
|
||||||
|
row and glues itself to the screen edge. `pre` keeps its own horizontal
|
||||||
|
scroll (white-space:pre ignores wrapping). */
|
||||||
|
.md {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.md pre {
|
.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;
|
@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;
|
||||||
}
|
}
|
||||||
@@ -90,24 +99,32 @@ body {
|
|||||||
50% { opacity: 0; }
|
50% { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading flower: petals bloom one after another, core breathes. */
|
/* Loading flower: outer petals bloom in turn, inner petals follow softer,
|
||||||
.flower-petal {
|
core breathes with an amber glow. */
|
||||||
|
.flower-petal, .flower-petal-inner, .flower-core {
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
animation: bloom 1.8s ease-in-out infinite;
|
}
|
||||||
|
.flower-petal {
|
||||||
|
animation: bloom 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.flower-petal-inner {
|
||||||
|
animation: bloom-inner 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes bloom {
|
@keyframes bloom {
|
||||||
0%, 100% { transform: scale(0.55); opacity: 0.3; }
|
0%, 100% { transform: scale(0.62); opacity: 0.35; }
|
||||||
50% { transform: scale(1); opacity: 1; }
|
50% { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
@keyframes bloom-inner {
|
||||||
|
0%, 100% { transform: scale(0.55); opacity: 0.2; }
|
||||||
|
50% { transform: scale(1); opacity: 0.95; }
|
||||||
|
}
|
||||||
.flower-core {
|
.flower-core {
|
||||||
transform-box: fill-box;
|
animation: core-breathe 2.4s ease-in-out infinite;
|
||||||
transform-origin: center;
|
|
||||||
animation: core-breathe 1.8s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
@keyframes core-breathe {
|
@keyframes core-breathe {
|
||||||
0%, 100% { transform: scale(0.85); }
|
0%, 100% { transform: scale(0.9); }
|
||||||
50% { transform: scale(1.15); }
|
50% { transform: scale(1.1); }
|
||||||
}
|
}
|
||||||
/* Smooth entrance for full-screen loaders. */
|
/* Smooth entrance for full-screen loaders. */
|
||||||
@keyframes loading-in {
|
@keyframes loading-in {
|
||||||
@@ -118,6 +135,15 @@ body {
|
|||||||
animation: loading-in 0.45s ease-out both;
|
animation: loading-in 0.45s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loader title: soft accent glow pulse. */
|
||||||
|
.loader-title {
|
||||||
|
animation: title-glow 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes title-glow {
|
||||||
|
0%, 100% { opacity: 0.92; }
|
||||||
|
50% { opacity: 1; text-shadow: 0 0 18px rgb(var(--ka-accent-500) / 0.35); }
|
||||||
|
}
|
||||||
|
|
||||||
.loading-dots::after {
|
.loading-dots::after {
|
||||||
content: "";
|
content: "";
|
||||||
animation: dots 1.4s steps(4) infinite;
|
animation: dots 1.4s steps(4) infinite;
|
||||||
@@ -129,12 +155,79 @@ body {
|
|||||||
75% { content: "..."; }
|
75% { content: "..."; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom pills: springy rise on appear, soft sink on dismiss. */
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(18px) scale(0.94); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.toast-enter {
|
||||||
|
animation: toast-in 0.38s cubic-bezier(0.21, 1.02, 0.73, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes toast-out {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||||
|
}
|
||||||
|
.toast-exit {
|
||||||
|
animation: toast-out 0.26s ease-in both;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after {
|
/* Brand flower intentionally exempt: it always animates, even when the
|
||||||
|
browser requests reduced motion (battery saver / eco mode). */
|
||||||
|
.typing-cursor::after,
|
||||||
|
.loading-dots::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Voice: Siri-style glow on the bubble being read aloud. */
|
||||||
|
.speaking-glow {
|
||||||
|
position: relative;
|
||||||
|
border-color: rgb(var(--ka-accent-500) / 0.55) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(var(--ka-accent-500) / 0.25),
|
||||||
|
0 0 22px rgb(var(--ka-accent-500) / 0.28),
|
||||||
|
0 8px 30px rgb(var(--ka-accent-500) / 0.12);
|
||||||
|
animation: speak-breathe 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes speak-breathe {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.25), 0 0 14px rgb(var(--ka-accent-500) / 0.2), 0 8px 30px rgb(var(--ka-accent-500) / 0.1); }
|
||||||
|
50% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.4), 0 0 30px rgb(var(--ka-accent-500) / 0.38), 0 8px 30px rgb(var(--ka-accent-500) / 0.16); }
|
||||||
|
}
|
||||||
|
.speaking-orb {
|
||||||
|
animation: orb-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes orb-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.55; transform: scale(0.86); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice recorder: pulsing REC dot, buttery bars, soft entrance. */
|
||||||
|
.rec-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 10px rgb(239 68 68 / 0.8);
|
||||||
|
animation: rec-blink 1.2s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes rec-blink {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.45; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
.wave-bar {
|
||||||
|
transition: height 90ms linear, opacity 90ms linear;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
.voice-recorder {
|
||||||
|
animation: recorder-in 0.25s cubic-bezier(0.21, 1.02, 0.73, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes recorder-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Kill Chrome's yellow autofill background (login / password fields) */
|
/* Kill Chrome's yellow autofill background (login / password fields) */
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
|
|||||||
+103
-7
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Runtime accent color (settings → accent color). Predefined palettes plus a
|
* Runtime accent color (settings → accent color). Predefined palettes plus a
|
||||||
* custom picker; shades 50–900 are derived from a single base hex via HSL and
|
* custom picker; shades 50–950 are derived from a single base hex via HSL and
|
||||||
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
|
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
|
||||||
* palette. Persisted per-browser (localStorage), like the feature flags.
|
* palette. Persisted per-browser (localStorage), like the feature flags.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900", string>;
|
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900" | "950", string>;
|
||||||
|
|
||||||
export interface AccentPreset {
|
export interface AccentPreset {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,11 +25,11 @@ export const ACCENT_PRESETS: AccentPreset[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const STORAGE_KEY = "ka:accent";
|
const STORAGE_KEY = "ka:accent";
|
||||||
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
|
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
|
||||||
// Lightness ramps for 50→900 around a mid 500 (HSL L values in %).
|
// Lightness ramps for 50→950 around a mid 500 (HSL L values in %).
|
||||||
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
|
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
|
||||||
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
|
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
|
||||||
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24,
|
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24, "950": 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||||
@@ -66,8 +66,42 @@ function hslToRgbTriplet(h: number, s: number, l: number): string {
|
|||||||
return `${to(r)} ${to(g)} ${to(b)}`;
|
return `${to(r)} ${to(g)} ${to(b)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the 50–900 ramp from a base hex (HSL ramp, emerald-style). */
|
/** HSV helpers for the custom color picker (no native input involved). */
|
||||||
export function shadesFor(baseHex: string): AccentShades {
|
export function hexToHsv(hex: string): { h: number; s: number; v: number } | null {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseInt(m[1]!, 16);
|
||||||
|
const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
const d = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (d !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) * 60;
|
||||||
|
else h = ((r - g) / d + 4) * 60;
|
||||||
|
}
|
||||||
|
return { h, s: max === 0 ? 0 : (d / max) * 100, v: max * 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hsvToHex(h: number, s: number, v: number): string {
|
||||||
|
const sn = Math.min(100, Math.max(0, s)) / 100;
|
||||||
|
const vn = Math.min(100, Math.max(0, v)) / 100;
|
||||||
|
const hh = ((h % 360) + 360) % 360;
|
||||||
|
const c = vn * sn;
|
||||||
|
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
|
||||||
|
const m = vn - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (hh < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (hh < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (hh < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (hh < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (hh < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const to = (zv: number) => Math.round((zv + m) * 255).toString(16).padStart(2, "0");
|
||||||
|
return `#${to(r)}${to(g)}${to(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 50–950 ramp from a base hex (HSL ramp, emerald-style). */export function shadesFor(baseHex: string): AccentShades {
|
||||||
const { h, s } = hexToHsl(baseHex);
|
const { h, s } = hexToHsl(baseHex);
|
||||||
const out = {} as AccentShades;
|
const out = {} as AccentShades;
|
||||||
for (const k of SHADE_KEYS) {
|
for (const k of SHADE_KEYS) {
|
||||||
@@ -99,6 +133,7 @@ export function applyAccent(state: AccentState): void {
|
|||||||
const shades = shadesFor(base);
|
const shades = shadesFor(base);
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||||
|
paintFavicon(base);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -109,6 +144,67 @@ export function previewAccent(baseHex: string): void {
|
|||||||
const shades = shadesFor(baseHex);
|
const shades = shadesFor(baseHex);
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||||
|
paintFavicon(baseHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixHex(hex: string, target: number, amount: number): string {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
const n = m ? parseInt(m[1]!, 16) : 0x10b981;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
const mix = (v: number) => Math.round(v + (target - v) * amount);
|
||||||
|
const to = (v: number) => mix(v).toString(16).padStart(2, "0");
|
||||||
|
return `#${to(r)}${to(g)}${to(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favicon follows the accent AND the theme: the tile background is
|
||||||
|
* theme-owned (white by day, near-black by night) while the flower itself
|
||||||
|
* wears the accent ramp — never an accent tile.
|
||||||
|
*/
|
||||||
|
export function paintFavicon(baseHex: string, dark?: boolean): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981";
|
||||||
|
const isDark = dark ?? document.documentElement.classList.contains("dark");
|
||||||
|
const tile = isDark ? "#09090b" : "#ffffff";
|
||||||
|
const ring = isDark ? "#27272a" : "#e7e5e4";
|
||||||
|
const light = mixHex(base, 255, 0.55);
|
||||||
|
const petals = [8, 80, 151, 223, 295].map(
|
||||||
|
(a) => `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${base}" transform="rotate(${a} 50 50)"/>`,
|
||||||
|
).join("");
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` +
|
||||||
|
`<rect width="100" height="100" rx="22" fill="${tile}" stroke="${ring}" stroke-width="3"/>${petals}` +
|
||||||
|
`<ellipse cx="50" cy="28" rx="13" ry="20" fill="${light}" opacity="0.45" transform="rotate(8 50 50)"/>` +
|
||||||
|
`<circle cx="50" cy="50" r="10" fill="#fbbf24"/></svg>`;
|
||||||
|
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
for (const rel of ["icon", "apple-touch-icon"]) {
|
||||||
|
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.rel = rel;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.type = rel === "icon" ? "image/svg+xml" : link.type;
|
||||||
|
link.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last accent base, so theme flips can repaint without knowing the color. */
|
||||||
|
let lastBase = "#10b981";
|
||||||
|
|
||||||
|
function repaint(): void {
|
||||||
|
paintFavicon(lastBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined" && typeof MutationObserver !== "undefined") {
|
||||||
|
new MutationObserver((muts) => {
|
||||||
|
for (const m of muts) {
|
||||||
|
if (m.attributeName === "class") {
|
||||||
|
repaint();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the saved accent on load.
|
// Restore the saved accent on load.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AccessMessage, AccessRequest } from "./api.ts";
|
import type { AccessMessage, AccessRequest, Report } from "./api.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared WebSocket pool for live access-request updates.
|
* Shared WebSocket pool for live access-request updates.
|
||||||
@@ -7,13 +7,21 @@ import type { AccessMessage, AccessRequest } from "./api.ts";
|
|||||||
* AdminCenter badge and the triage list share the admin firehose).
|
* AdminCenter badge and the triage list share the admin firehose).
|
||||||
* Auto-reconnects with backoff; every (re)connect triggers onSync so
|
* Auto-reconnects with backoff; every (re)connect triggers onSync so
|
||||||
* components resync over HTTP and never miss an event sent while offline.
|
* components resync over HTTP and never miss an event sent while offline.
|
||||||
* A 25s client ping keeps idle connections alive through proxies.
|
* A 25s client ping keeps idle connections alive through proxies, and a
|
||||||
|
* pong watchdog reconnects half-dead sockets (ping answered by nobody)
|
||||||
|
* that would otherwise silently stop notifying.
|
||||||
|
*
|
||||||
|
* Events carry their full payload — handlers apply them straight to state.
|
||||||
|
* HTTP resync (onSync) is only the offline-gap fallback, never the
|
||||||
|
* per-event path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AccessLiveEvent =
|
export type AccessLiveEvent =
|
||||||
| { type: "access_message"; request_id: string; message: AccessMessage }
|
| { type: "access_message"; request_id: string; message: AccessMessage }
|
||||||
| { type: "access_status"; request_id: string; request: AccessRequest }
|
| { type: "access_status"; request_id: string; request: AccessRequest }
|
||||||
| { type: "access_created"; request_id: string; request: AccessRequest }
|
| { type: "access_created"; request_id: string; request: AccessRequest; message: AccessMessage; message_count: number }
|
||||||
|
| { type: "report_created"; report: Report }
|
||||||
|
| { type: "report_status"; report: Report }
|
||||||
| { type: "pong" };
|
| { type: "pong" };
|
||||||
|
|
||||||
export interface AccessLiveListener {
|
export interface AccessLiveListener {
|
||||||
@@ -28,15 +36,51 @@ interface Pool {
|
|||||||
backoff: number;
|
backoff: number;
|
||||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
pingTimer: ReturnType<typeof setInterval> | null;
|
pingTimer: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Last time the server proved liveness (open or pong). 0 = unknown. */
|
||||||
|
lastLive: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let globalHooksInstalled = false;
|
||||||
|
|
||||||
const pools = new Map<string, Pool>();
|
const pools = new Map<string, Pool>();
|
||||||
|
|
||||||
|
/** Ping period; the watchdog tolerates one missed pong plus slack. */
|
||||||
|
const PING_MS = 25_000;
|
||||||
|
const WATCHDOG_MS = PING_MS + 10_000;
|
||||||
|
|
||||||
export function wsUrl(path: string): string {
|
export function wsUrl(path: string): string {
|
||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${proto}//${window.location.host}${path}`;
|
return `${proto}//${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reconnect every pooled socket now (tab visible again / back online). */
|
||||||
|
function reconnectAll(): void {
|
||||||
|
for (const [url, pool] of pools) {
|
||||||
|
if (pool.listeners.size === 0) continue;
|
||||||
|
if (pool.retryTimer) {
|
||||||
|
clearTimeout(pool.retryTimer);
|
||||||
|
pool.retryTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pool.ws?.close();
|
||||||
|
} catch {
|
||||||
|
// already gone — connect() below redials
|
||||||
|
}
|
||||||
|
pool.ws = null;
|
||||||
|
pool.backoff = 0;
|
||||||
|
connect(url, pool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installGlobalHooks(): void {
|
||||||
|
if (globalHooksInstalled || typeof document === "undefined") return;
|
||||||
|
globalHooksInstalled = true;
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") reconnectAll();
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => reconnectAll());
|
||||||
|
}
|
||||||
|
|
||||||
function connect(url: string, pool: Pool): void {
|
function connect(url: string, pool: Pool): void {
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
try {
|
try {
|
||||||
@@ -49,15 +93,27 @@ function connect(url: string, pool: Pool): void {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
pool.backoff = 0;
|
pool.backoff = 0;
|
||||||
|
pool.lastLive = Date.now();
|
||||||
pool.pingTimer = setInterval(() => {
|
pool.pingTimer = setInterval(() => {
|
||||||
|
// Half-dead socket: OPEN but the server hasn't proved liveness for
|
||||||
|
// over a ping period + grace (proxy killed it, server restarted…).
|
||||||
|
// Close it so onclose schedules a fresh dial.
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
if (Date.now() - pool.lastLive > WATCHDOG_MS) {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// close handler reconnects
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify({ type: "ping" }));
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
} catch {
|
} catch {
|
||||||
// send failed — the close handler will schedule a retry
|
// send failed — the close handler will schedule a retry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 25_000);
|
}, PING_MS);
|
||||||
for (const l of [...pool.listeners]) {
|
for (const l of [...pool.listeners]) {
|
||||||
try {
|
try {
|
||||||
l.onSync?.();
|
l.onSync?.();
|
||||||
@@ -75,6 +131,8 @@ function connect(url: string, pool: Pool): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed.type !== "string") return;
|
if (!parsed || typeof parsed.type !== "string") return;
|
||||||
|
// Any frame (event or pong) proves the socket is alive.
|
||||||
|
if (pool.ws === ws) pool.lastLive = Date.now();
|
||||||
for (const l of [...pool.listeners]) {
|
for (const l of [...pool.listeners]) {
|
||||||
try {
|
try {
|
||||||
l.onEvent(parsed);
|
l.onEvent(parsed);
|
||||||
@@ -110,10 +168,12 @@ function scheduleRetry(url: string, pool: Pool): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
|
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
|
||||||
let pool = pools.get(url);
|
let pool: Pool | undefined = pools.get(url);
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
|
const fresh: Pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null, lastLive: 0 };
|
||||||
pools.set(url, pool);
|
pools.set(url, fresh);
|
||||||
|
pool = fresh;
|
||||||
|
installGlobalHooks();
|
||||||
connect(url, pool);
|
connect(url, pool);
|
||||||
}
|
}
|
||||||
pool.listeners.add(listener);
|
pool.listeners.add(listener);
|
||||||
|
|||||||
+203
-8
@@ -42,6 +42,138 @@ export interface Report {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelPolicyEntry {
|
||||||
|
enabled: boolean;
|
||||||
|
hourly: number;
|
||||||
|
daily: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProvider {
|
||||||
|
provider: string;
|
||||||
|
label: string;
|
||||||
|
hasKey: boolean;
|
||||||
|
last4: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyModel extends DriverModel {
|
||||||
|
enabled: boolean;
|
||||||
|
hourly: number;
|
||||||
|
daily: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaHostModel {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoicePreview {
|
||||||
|
text: string;
|
||||||
|
lang: string;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
timbre: "masculine" | "feminine" | "any";
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformInfo {
|
||||||
|
hostname: string;
|
||||||
|
kernel: string;
|
||||||
|
arch: string;
|
||||||
|
uptimeS: number;
|
||||||
|
appUptimeS: number;
|
||||||
|
loadavg: number[];
|
||||||
|
cpus: number;
|
||||||
|
cpuModel: string | null;
|
||||||
|
memTotal: number;
|
||||||
|
memFree: number;
|
||||||
|
runtime: string;
|
||||||
|
nets: { name: string; address: string; family: string; internal: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettings {
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
accessRequestEnabled: boolean;
|
||||||
|
ocrEnabled: boolean;
|
||||||
|
reportsEnabled: boolean;
|
||||||
|
usernameChangeEnabled: boolean;
|
||||||
|
ollamaTimeoutS: number;
|
||||||
|
ollamaDefaultWeight: number;
|
||||||
|
recoveryLimitMax: number;
|
||||||
|
recoveryLimitWindowMin: number;
|
||||||
|
voicePreviews: VoicePreview[];
|
||||||
|
ttsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettingsPatch {
|
||||||
|
registrationEnabled?: boolean;
|
||||||
|
accessRequestEnabled?: boolean;
|
||||||
|
ocrEnabled?: boolean;
|
||||||
|
reportsEnabled?: boolean;
|
||||||
|
usernameChangeEnabled?: boolean;
|
||||||
|
ollamaTimeoutS?: number;
|
||||||
|
ollamaDefaultWeight?: number;
|
||||||
|
recoveryLimitMax?: number;
|
||||||
|
recoveryLimitWindowMin?: number;
|
||||||
|
voicePreviews?: VoicePreview[];
|
||||||
|
ttsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveOllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaNodeStatus {
|
||||||
|
reachable: boolean;
|
||||||
|
host: string;
|
||||||
|
version: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
models: OllamaHostModel[];
|
||||||
|
running: { name: string; sizeVram: number; size: number; expiresAt: string | null }[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaStatsBucket {
|
||||||
|
t: number;
|
||||||
|
req: number;
|
||||||
|
err: number;
|
||||||
|
avgMs: number | null;
|
||||||
|
promptTok: number;
|
||||||
|
evalTok: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaNodeStats {
|
||||||
|
window: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null };
|
||||||
|
buckets: OllamaStatsBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
reachable: boolean;
|
||||||
|
version: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
models: OllamaHostModel[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = "kisassistant_token";
|
const TOKEN_KEY = "kisassistant_token";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
@@ -102,7 +234,7 @@ export const api = {
|
|||||||
resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false),
|
resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false),
|
||||||
|
|
||||||
me: () => req<{ user: User }>("/api/me"),
|
me: () => req<{ user: User }>("/api/me"),
|
||||||
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
updateMe: (patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||||
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
/** Rotate your own access key (old sessions revoked, key shown once). */
|
/** Rotate your own access key (old sessions revoked, key shown once). */
|
||||||
@@ -135,17 +267,27 @@ export const api = {
|
|||||||
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
|
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" }),
|
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" }),
|
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
|
||||||
|
adminResetLink: (id: string) => req<{ ok: boolean; email: string }>(`/api/admin/users/${id}/reset-link`, { method: "POST" }),
|
||||||
adminShadowban: (id: string, shadowbanned: boolean) =>
|
adminShadowban: (id: string, shadowbanned: boolean) =>
|
||||||
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
|
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ shadowbanned }),
|
body: JSON.stringify({ shadowbanned }),
|
||||||
}),
|
}),
|
||||||
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
|
||||||
|
/** Admin: per-model access policy (kill-switch + rate limits). */
|
||||||
|
adminModelPolicy: (refresh = false) =>
|
||||||
|
req<{ models: PolicyModel[] }>(`/api/admin/model-policy${refresh ? "?refresh=1" : ""}`),
|
||||||
|
adminModelPolicyPatch: (model: string, patch: { enabled?: boolean; hourly?: number; daily?: number }) =>
|
||||||
|
req<{ model: PolicyModel }>(`/api/admin/model-policy`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ model, ...patch }),
|
||||||
|
}),
|
||||||
|
adminPatch: (id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||||
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings"),
|
adminGetSettings: () => req<{ settings: AdminSettings }>("/api/admin/settings"),
|
||||||
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) =>
|
adminPatchSettings: (patch: AdminSettingsPatch) =>
|
||||||
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ settings: AdminSettings }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
|
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
|
||||||
adminMailStatus: () =>
|
adminMailStatus: () =>
|
||||||
@@ -236,11 +378,12 @@ export const api = {
|
|||||||
ollamaPull: async (
|
ollamaPull: async (
|
||||||
name: string,
|
name: string,
|
||||||
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
|
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
|
||||||
|
opts: { nodeId?: string } = {},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const res = await fetch("/api/admin/ollama/pull", {
|
const res = await fetch("/api/admin/ollama/pull", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, ...(opts.nodeId ? { nodeId: opts.nodeId } : {}) }),
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
@@ -268,10 +411,62 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
|
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
|
||||||
ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
ollamaDelete: (name: string, nodeId?: string) =>
|
||||||
|
req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}${nodeId ? `?node=${encodeURIComponent(nodeId)}` : ""}`, { method: "DELETE" }),
|
||||||
|
|
||||||
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"),
|
/** Admin: Ollama node pool (extra hosts with firewall-like weights). */
|
||||||
|
ollamaNodes: () => req<{ nodes: OllamaNode[]; effective: EffectiveOllamaNode[] }>("/api/admin/ollama/nodes"),
|
||||||
|
ollamaNodeCreate: (n: { name: string; host: string; weight: number; enabled: boolean }) =>
|
||||||
|
req<{ node: OllamaNode }>("/api/admin/ollama/nodes", { method: "POST", body: JSON.stringify(n) }),
|
||||||
|
ollamaNodePatch: (id: string, patch: { name?: string; host?: string; weight?: number; enabled?: boolean }) =>
|
||||||
|
req<{ node: OllamaNode }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
ollamaNodeDelete: (id: string) =>
|
||||||
|
req<{ ok: boolean }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||||
|
ollamaNodeStatus: (id: string) =>
|
||||||
|
req<OllamaNodeStatus>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`),
|
||||||
|
ollamaNodeProbe: (host: string) =>
|
||||||
|
req<OllamaNodeStatus>(`/api/admin/ollama/probe?host=${encodeURIComponent(host)}`),
|
||||||
|
/** Admin: platform host facts (hostname, kernel, uptime, network…). */
|
||||||
|
platform: () => req<{ platform: PlatformInfo }>("/api/admin/platform"),
|
||||||
|
ollamaNodeStats: (id: string, window: string) =>
|
||||||
|
req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`),
|
||||||
|
|
||||||
|
/** Admin: Ollama connectivity probe (reachability, version, on-disk models). */
|
||||||
|
ollamaStatus: () => req<OllamaStatus>("/api/admin/ollama/status"),
|
||||||
|
|
||||||
|
/** Personal LLM providers (BYOK): presence + last4 only, keys never leave the server. */
|
||||||
|
providers: () => req<{ providers: UserProvider[] }>("/api/me/providers"),
|
||||||
|
setProvider: (provider: string, apiKey: string) =>
|
||||||
|
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ apiKey }),
|
||||||
|
}),
|
||||||
|
deleteProvider: (provider: string) =>
|
||||||
|
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"),
|
||||||
|
|
||||||
|
/** Neural TTS provider status (null = browser speech fallback). */
|
||||||
|
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: { id: string; name: string }[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
|
||||||
|
|
||||||
|
/** Synthesize text server-side (neural voice). Returns audio bytes. */
|
||||||
|
ttsSpeak: async (text: string, voice?: string): Promise<Blob> => {
|
||||||
|
const res = await fetch("/api/tools/tts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify({ text, ...(voice ? { voice } : {}) }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
window.location.reload();
|
||||||
|
throw new ApiError(401, "Session expired.");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new ApiError(res.status, data.error || `TTS failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return await res.blob();
|
||||||
|
},
|
||||||
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
||||||
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
|
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
|
||||||
const res = await fetch("/api/tools/ocr", {
|
const res = await fetch("/api/tools/ocr", {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency-free syntax highlighting for chat code blocks.
|
||||||
|
*
|
||||||
|
* No highlight.js / prism (keeps the bundle small and the Nix offline build
|
||||||
|
* lockfile-stable): a small regex tokenizer per language family that emits
|
||||||
|
* <span> tokens colored for the always-dark <pre> bubble (see index.css).
|
||||||
|
* Unknown languages fall back to generic strings/comments/numbers so every
|
||||||
|
* block still gets some coloration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CLS = {
|
||||||
|
comment: "text-stone-400 italic",
|
||||||
|
string: "text-amber-200",
|
||||||
|
keyword: "text-violet-300",
|
||||||
|
number: "text-sky-300",
|
||||||
|
function: "text-emerald-300",
|
||||||
|
type: "text-rose-300",
|
||||||
|
decorator: "text-orange-300",
|
||||||
|
diffAdd: "text-emerald-300",
|
||||||
|
diffDel: "text-red-300",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TokKind = keyof typeof CLS | "plain";
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
kind: TokKind;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
mjs: "javascript",
|
||||||
|
cjs: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
mts: "typescript",
|
||||||
|
py: "python",
|
||||||
|
py3: "python",
|
||||||
|
sh: "bash",
|
||||||
|
shell: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
console: "bash",
|
||||||
|
terminal: "bash",
|
||||||
|
yml: "yaml",
|
||||||
|
json5: "json",
|
||||||
|
jsonc: "json",
|
||||||
|
cxx: "cpp",
|
||||||
|
"c++": "cpp",
|
||||||
|
hpp: "cpp",
|
||||||
|
cs: "csharp",
|
||||||
|
rs: "rust",
|
||||||
|
golang: "go",
|
||||||
|
dockerfile: "docker",
|
||||||
|
makefile: "make",
|
||||||
|
md: "markdown",
|
||||||
|
html: "xml",
|
||||||
|
vue: "xml",
|
||||||
|
svelte: "xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEYWORDS: Record<string, string[]> = {
|
||||||
|
javascript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set".split(" "),
|
||||||
|
typescript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set interface type enum implements private public protected readonly abstract namespace declare".split(" "),
|
||||||
|
python: "and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case".split(" "),
|
||||||
|
bash: "if then else elif fi for while in do done case esac function select until echo exit return local export alias cd ls".split(" "),
|
||||||
|
rust: "as break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while async await dyn".split(" "),
|
||||||
|
go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var".split(" "),
|
||||||
|
java: "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while var".split(" "),
|
||||||
|
c: "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while".split(" "),
|
||||||
|
cpp: "auto break case char const continue default do double else enum extern float for goto if inline int long namespace new private protected public return short signed sizeof static struct switch template this throw try typedef typename union unsigned using virtual void volatile while class constexpr decltype noexcept nullptr static_assert".split(" "),
|
||||||
|
csharp: "abstract as base bool break byte case catch char checked class const continue decimal default do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var".split(" "),
|
||||||
|
php: "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile extends final finally fn for foreach function global if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public require require_once return static switch throw trait try unset use var while xor yield".split(" "),
|
||||||
|
ruby: "alias and begin BEGIN break case class def defined do else elsif end END ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield".split(" "),
|
||||||
|
swift: "associatedtype class deinit enum extension fileprivate func if import in init inout internal let open operator private protocol public static struct subscript typealias var break case continue default defer do else fallthrough for guard repeat return switch where while as catch throw throws try await async".split(" "),
|
||||||
|
kotlin: "as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field finally get import init out set value where".split(" "),
|
||||||
|
sql: "select from where join left right inner outer on group by order having limit offset insert into values update set delete create table alter drop index view as and or not null primary key foreign references distinct union all exists in between like is asc desc".split(" "),
|
||||||
|
json: "true false null".split(" "),
|
||||||
|
yaml: "true false null yes no on off".split(" "),
|
||||||
|
css: "import media supports keyframes font-face".split(" "),
|
||||||
|
xml: [],
|
||||||
|
docker: "from run cmd label maintainer expose env add copy entrypoint volume user workdir arg onbuild stopsignal healthcheck shell".split(" "),
|
||||||
|
make: "ifeq ifneq else endif include override export unexport define endef".split(" "),
|
||||||
|
markdown: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function langKey(lang: string): string {
|
||||||
|
const l = lang.trim().toLowerCase();
|
||||||
|
return ALIASES[l] ?? l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(words: string[]): string {
|
||||||
|
return words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Grammar {
|
||||||
|
lineComment?: string;
|
||||||
|
blockComment?: boolean;
|
||||||
|
strings: ("sq" | "dq" | "bq" | "py3")[];
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function grammarFor(lang: string): Grammar | null {
|
||||||
|
switch (lang) {
|
||||||
|
case "javascript":
|
||||||
|
case "typescript":
|
||||||
|
return { lineComment: "//", blockComment: true, strings: ["sq", "dq", "bq"], keywords: KEYWORDS[lang]! };
|
||||||
|
case "json":
|
||||||
|
return { strings: ["dq"], keywords: KEYWORDS.json! };
|
||||||
|
case "python":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq", "py3"], keywords: KEYWORDS.python! };
|
||||||
|
case "bash":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.bash! };
|
||||||
|
case "rust":
|
||||||
|
case "go":
|
||||||
|
case "java":
|
||||||
|
case "c":
|
||||||
|
case "cpp":
|
||||||
|
case "csharp":
|
||||||
|
case "php":
|
||||||
|
case "swift":
|
||||||
|
case "kotlin":
|
||||||
|
return { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS[lang]! };
|
||||||
|
case "ruby":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.ruby! };
|
||||||
|
case "sql":
|
||||||
|
return { lineComment: "--", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.sql! };
|
||||||
|
case "yaml":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.yaml! };
|
||||||
|
case "css":
|
||||||
|
return { blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.css! };
|
||||||
|
case "xml":
|
||||||
|
return { strings: ["sq", "dq"], keywords: [] };
|
||||||
|
case "docker":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.docker! };
|
||||||
|
case "make":
|
||||||
|
return { lineComment: "#", strings: [], keywords: KEYWORDS.make! };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRING_RE: Record<string, string> = {
|
||||||
|
sq: "'(?:[^'\\\\\\n]|\\\\.)*'",
|
||||||
|
dq: '"(?:[^"\\\\\\n]|\\\\.)*"',
|
||||||
|
bq: "`(?:[^`\\\\]|\\\\.)*`",
|
||||||
|
py3: '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tokenize(code: string, lang: string): Token[] {
|
||||||
|
const lk = langKey(lang);
|
||||||
|
|
||||||
|
// Diffs color whole lines — handled line by line, no grammar needed.
|
||||||
|
if (lk === "diff" || lk === "patch") {
|
||||||
|
return code.split("\n").flatMap((line, i, arr) => {
|
||||||
|
const kind: TokKind =
|
||||||
|
line.startsWith("+") && !line.startsWith("+++") ? "diffAdd" : line.startsWith("-") && !line.startsWith("---") ? "diffDel" : line.startsWith("@") ? "function" : "plain";
|
||||||
|
const toks: Token[] = [{ kind, text: line }];
|
||||||
|
if (i < arr.length - 1) toks.push({ kind: "plain", text: "\n" });
|
||||||
|
return toks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = grammarFor(lk);
|
||||||
|
// Unknown language: generic strings + comments + numbers.
|
||||||
|
const grammar: Grammar = g ?? { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: [] };
|
||||||
|
|
||||||
|
const commentAlts: string[] = [];
|
||||||
|
if (grammar.blockComment) commentAlts.push("/\\*[\\s\\S]*?(?:\\*/|$)");
|
||||||
|
if (lk === "xml") commentAlts.push("<!--[\\s\\S]*?(?:-->|$)");
|
||||||
|
if (grammar.lineComment) {
|
||||||
|
const lc = grammar.lineComment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
// `//` must not match the `://` of URLs (https://…).
|
||||||
|
commentAlts.push(grammar.lineComment === "//" ? `(?<!:)//[^\\n]*` : `${lc}[^\\n]*`);
|
||||||
|
}
|
||||||
|
const stringAlts = grammar.strings.map((s) => STRING_RE[s]!);
|
||||||
|
|
||||||
|
const named: [TokKind, string][] = [];
|
||||||
|
if (commentAlts.length > 0) named.push(["comment", commentAlts.join("|")]);
|
||||||
|
if (stringAlts.length > 0) named.push(["string", stringAlts.join("|")]);
|
||||||
|
if (grammar.keywords.length > 0) named.push(["keyword", `\\b(?:${escapeRe(grammar.keywords)})\\b`]);
|
||||||
|
named.push(["number", "\\b(?:0x[0-9a-fA-F]+|\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b"]);
|
||||||
|
named.push(["decorator", "@[A-Za-z_][\\w$]*"]);
|
||||||
|
named.push(["function", "[A-Za-z_][\\w$]*(?=\\s*\\()"]);
|
||||||
|
named.push(["type", "\\b[A-Z][A-Za-z0-9_]*\\b"]);
|
||||||
|
|
||||||
|
const re = new RegExp(named.map(([kind, p]) => `(?<${kind}>${p})`).join("|"), "g");
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
// Guard against pathological inputs: cap tokens, dump the rest as plain.
|
||||||
|
const MAX_TOKENS = 5000;
|
||||||
|
while ((m = re.exec(code)) !== null && tokens.length < MAX_TOKENS) {
|
||||||
|
if (m.index > last) tokens.push({ kind: "plain", text: code.slice(last, m.index) });
|
||||||
|
const text = m[0];
|
||||||
|
const groups = m.groups ?? {};
|
||||||
|
const kind: TokKind = (named.find(([k]) => groups[k] !== undefined)?.[0] ?? "plain") as TokKind;
|
||||||
|
tokens.push({ kind, text });
|
||||||
|
last = m.index + text.length;
|
||||||
|
if (text.length === 0) re.lastIndex++;
|
||||||
|
}
|
||||||
|
if (last < code.length) tokens.push({ kind: "plain", text: code.slice(last) });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightCode(code: string, lang: string): ReactNode[] {
|
||||||
|
const tokens = tokenize(code, lang);
|
||||||
|
return tokens.map((t, i) =>
|
||||||
|
t.kind === "plain" ? (
|
||||||
|
<span key={i}>{t.text}</span>
|
||||||
|
) : (
|
||||||
|
<span key={i} className={CLS[t.kind]}>
|
||||||
|
{t.text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Browser-local UI persistence (no server round-trip, per browser profile).
|
||||||
|
*
|
||||||
|
* - Selected model per account (`ka_model:<username>`): restored on boot when
|
||||||
|
* still offered, otherwise the first available model wins.
|
||||||
|
* - Failed model attempts (`ka_failed_v1`): the error bubble + retry state
|
||||||
|
* survive a refresh. The fake error text is NEVER stored server-side, so
|
||||||
|
* model history stays clean — the bubble is re-created locally on load and
|
||||||
|
* filtered out of every outgoing payload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FailedEntry {
|
||||||
|
/** Position of the error bubble in the message list (trailing). */
|
||||||
|
index: number;
|
||||||
|
retryCount: number;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILED_KEY = "ka_failed_v1";
|
||||||
|
const FAILED_MAX_AGE_MS = 30 * 24 * 3600_000;
|
||||||
|
|
||||||
|
function readFailedMap(): Record<string, FailedEntry> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FAILED_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const out: Record<string, FailedEntry> = {};
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of Object.entries(obj ?? {})) {
|
||||||
|
const e = v as FailedEntry;
|
||||||
|
if (
|
||||||
|
v !== null &&
|
||||||
|
typeof v === "object" &&
|
||||||
|
Number.isInteger(e.index) &&
|
||||||
|
e.index >= 0 &&
|
||||||
|
typeof e.retryCount === "number" &&
|
||||||
|
typeof e.at === "number" &&
|
||||||
|
now - e.at < FAILED_MAX_AGE_MS
|
||||||
|
) {
|
||||||
|
out[k] = { index: e.index, retryCount: e.retryCount, at: e.at };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFailedMap(map: Record<string, FailedEntry>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FAILED_KEY, JSON.stringify(map));
|
||||||
|
} catch {
|
||||||
|
// Private mode / quota — retry simply won't survive a refresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFailedEntry(convId: string): FailedEntry | null {
|
||||||
|
return readFailedMap()[convId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFailedEntry(convId: string, entry: FailedEntry): void {
|
||||||
|
const map = readFailedMap();
|
||||||
|
map[convId] = entry;
|
||||||
|
writeFailedMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFailedEntry(convId: string): void {
|
||||||
|
const map = readFailedMap();
|
||||||
|
if (map[convId] !== undefined) {
|
||||||
|
delete map[convId];
|
||||||
|
writeFailedMap(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelKey(username: string): string {
|
||||||
|
return `ka_model:${username.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedModel(username: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(modelKey(username));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSavedModel(username: string, id: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(modelKey(username), id);
|
||||||
|
} catch {
|
||||||
|
// ignore — falls back to first model next boot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved pick when still offered, otherwise the first available model. */
|
||||||
|
export function pickModel(available: { id: string }[], saved: string | null): string {
|
||||||
|
if (saved && available.some((m) => m.id === saved)) return saved;
|
||||||
|
return available[0]?.id ?? "";
|
||||||
|
}
|
||||||
@@ -11,6 +11,26 @@ export function getPath(): string {
|
|||||||
return normalizePath(window.location.pathname);
|
return normalizePath(window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** /chat itself or one conversation page (/chat/<id>). */
|
||||||
|
export function isChatPath(pathname: string): boolean {
|
||||||
|
const p = normalizePath(pathname);
|
||||||
|
return p === "/chat" || p.startsWith("/chat/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conversation id from /chat/<id>, or null on /chat itself. */
|
||||||
|
export function chatIdFromPath(pathname: string): string | null {
|
||||||
|
const m = normalizePath(pathname).match(/^\/chat\/([^/]+)$/);
|
||||||
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin section from /admin itself (null) or /admin/<tab>. */
|
||||||
|
export function adminTabFromPath(pathname: string): string | null {
|
||||||
|
const p = normalizePath(pathname);
|
||||||
|
if (p === "/admin") return null;
|
||||||
|
const m = p.match(/^\/admin\/([^/]+)$/);
|
||||||
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Client-side navigation without reload (same-origin path only). */
|
/** Client-side navigation without reload (same-origin path only). */
|
||||||
export function navigate(to: string, replace = false): void {
|
export function navigate(to: string, replace = false): void {
|
||||||
const dest = normalizePath(to);
|
const dest = normalizePath(to);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* "Sunset" theme: follows the browser's local time instead of the OS setting.
|
||||||
|
* Day (07:00–19:00 local) -> light, night -> dark. No geolocation needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SOLAR_DAY_START_HOUR = 7;
|
||||||
|
export const SOLAR_NIGHT_START_HOUR = 19;
|
||||||
|
|
||||||
|
/** True when the given local time falls in the night range (dark theme). */
|
||||||
|
export function isSolarDark(now: Date = new Date()): boolean {
|
||||||
|
const h = now.getHours();
|
||||||
|
return h < SOLAR_DAY_START_HOUR || h >= SOLAR_NIGHT_START_HOUR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Milliseconds from `now` until the next 07:00 / 19:00 local boundary. */
|
||||||
|
export function msUntilNextSolarSwitch(now: Date = new Date()): number {
|
||||||
|
const next = new Date(now);
|
||||||
|
const h = now.getHours();
|
||||||
|
if (h < SOLAR_DAY_START_HOUR) {
|
||||||
|
next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0);
|
||||||
|
} else if (h < SOLAR_NIGHT_START_HOUR) {
|
||||||
|
next.setHours(SOLAR_NIGHT_START_HOUR, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
next.setDate(next.getDate() + 1);
|
||||||
|
next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0);
|
||||||
|
}
|
||||||
|
return Math.max(1000, next.getTime() - now.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a stored theme value to dark/light. `sunset` uses local time. */
|
||||||
|
export function resolveThemeDark(theme: string, now: Date = new Date()): boolean {
|
||||||
|
if (theme === "dark") return true;
|
||||||
|
if (theme === "light") return false;
|
||||||
|
if (theme === "sunset") return isSolarDark(now);
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia) {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-pill notifications (toasts): one-line confirmations when the user
|
||||||
|
* takes an action (chat deleted/archived, model/theme/accent changed…).
|
||||||
|
* Same subscribe pattern as features.ts / i18n.ts — callable from anywhere
|
||||||
|
* (App, settings sections) without prop drilling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ToastIcon =
|
||||||
|
| "trash"
|
||||||
|
| "archive"
|
||||||
|
| "unarchive"
|
||||||
|
| "model"
|
||||||
|
| "theme"
|
||||||
|
| "accent"
|
||||||
|
| "unshare"
|
||||||
|
| "edit"
|
||||||
|
| "check"
|
||||||
|
| "mail"
|
||||||
|
| "inbox";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
icon: ToastIcon;
|
||||||
|
/** Same-tag pushes replace the pill instead of stacking (accent, model). */
|
||||||
|
tag: string | null;
|
||||||
|
/** Bumped on replace so the pill restarts its enter/dismiss timers. */
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a pill stays fully visible before its exit animation. */
|
||||||
|
export const TOAST_DISPLAY_MS = 3000;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
let toasts: Toast[] = [];
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushToast(text: string, opts: { icon?: ToastIcon; tag?: string } = {}): void {
|
||||||
|
const icon = opts.icon ?? "check";
|
||||||
|
const tag = opts.tag ?? null;
|
||||||
|
if (tag) {
|
||||||
|
const existing = toasts.find((t) => t.tag === tag);
|
||||||
|
if (existing) {
|
||||||
|
toasts = toasts.map((t) =>
|
||||||
|
t.id === existing.id ? { ...t, text, icon, nonce: t.nonce + 1 } : t,
|
||||||
|
);
|
||||||
|
emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cap the stack so rapid actions never flood the bottom of the screen.
|
||||||
|
toasts = [...toasts.slice(-2), { id: nextId++, text, icon, tag, nonce: 0 }];
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToast(id: number): void {
|
||||||
|
if (!toasts.some((t) => t.id === id)) return;
|
||||||
|
toasts = toasts.filter((t) => t.id !== id);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToasts(): Toast[] {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => toasts,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export interface Conversation {
|
|||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Fully-qualified model id that produced this message (assistant only, "" = unknown). */
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriverModel {
|
export interface DriverModel {
|
||||||
@@ -31,6 +33,8 @@ export interface DriverModel {
|
|||||||
driver: string;
|
driver: string;
|
||||||
label: string;
|
label: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
|
/** Served through your own provider key (BYOK) — shown in its own menu section. */
|
||||||
|
personal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* Voice: speech-to-text (composer) + text-to-speech (message playback).
|
||||||
|
*
|
||||||
|
* STT runs on the browser SpeechRecognition API when available
|
||||||
|
* (Chrome/Edge); otherwise the mic is disabled with an explanatory tooltip.
|
||||||
|
* TTS runs on speechSynthesis with per-user prefs (voice, rate, pitch,
|
||||||
|
* language incl. auto-detect). Server-side whisper/piper binaries are the
|
||||||
|
* documented future path (see docs) — no silent stub is shipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VoicePrefs {
|
||||||
|
/** SpeechSynthesis voiceURI, or null = best match for the language. */
|
||||||
|
voiceURI: string | null;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
/** "auto" detects per message, otherwise forces a BCP-47 prefix. */
|
||||||
|
lang: string;
|
||||||
|
/** Playback engine: neural server TTS when available, else browser. */
|
||||||
|
engine: "auto" | "browser" | "server";
|
||||||
|
/** Server neural voice id (ElevenLabs voice_id / OpenAI voice name). */
|
||||||
|
serverVoice: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFS_KEY = "ka:voice";
|
||||||
|
|
||||||
|
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto", engine: "auto", serverVoice: null };
|
||||||
|
|
||||||
|
export function getVoicePrefs(): VoicePrefs {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "null") as Partial<VoicePrefs> | null;
|
||||||
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_VOICE_PREFS };
|
||||||
|
const engine = raw.engine === "browser" || raw.engine === "server" ? raw.engine : "auto";
|
||||||
|
return {
|
||||||
|
voiceURI: typeof raw.voiceURI === "string" ? raw.voiceURI : null,
|
||||||
|
rate: typeof raw.rate === "number" && raw.rate >= 0.5 && raw.rate <= 2 ? raw.rate : 1,
|
||||||
|
pitch: typeof raw.pitch === "number" && raw.pitch >= 0.5 && raw.pitch <= 2 ? raw.pitch : 1,
|
||||||
|
lang: typeof raw.lang === "string" && raw.lang.length <= 12 ? raw.lang : "auto",
|
||||||
|
engine,
|
||||||
|
serverVoice: typeof raw.serverVoice === "string" && raw.serverVoice.length > 0 ? raw.serverVoice : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_VOICE_PREFS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVoicePrefs(p: VoicePrefs): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max chars sent to any TTS engine (server enforces the same cap). */
|
||||||
|
export const SPEECH_MAX_CHARS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown → clean speakable text. Code blocks become a pause (reading
|
||||||
|
* code aloud is noise), formatting/links/URLs are stripped to their
|
||||||
|
* readable part, paragraphs become pauses. Over-long texts are cut at a
|
||||||
|
* sentence boundary so playback never stops mid-word.
|
||||||
|
*/
|
||||||
|
export function toSpeechText(md: string): string {
|
||||||
|
let s = md;
|
||||||
|
s = s.replace(/```[\s\S]*?```/g, ". ");
|
||||||
|
s = s.replace(/`([^`]+)`/g, "$1");
|
||||||
|
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||||
|
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||||
|
s = s.replace(/^#{1,6}\s+/gm, "");
|
||||||
|
s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
|
||||||
|
s = s.replace(/(^|[\s(])[*_]([^*_\n]+)[*_]/g, "$1$2");
|
||||||
|
s = s.replace(/^>\s?/gm, "");
|
||||||
|
s = s.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "");
|
||||||
|
s = s.replace(/^(\*\*\*|---|___)\s*$/gm, "");
|
||||||
|
s = s.replace(/<[^>]+>/g, " ");
|
||||||
|
s = s.replace(/https?:\/\/[^\s)]+/g, " ");
|
||||||
|
s = s.replace(/[ \t]+/g, " ");
|
||||||
|
s = s.replace(/\n{2,}/g, ". ");
|
||||||
|
s = s.replace(/\n/g, " ");
|
||||||
|
s = s.replace(/\s{2,}/g, " ").trim();
|
||||||
|
if (s.length > SPEECH_MAX_CHARS) {
|
||||||
|
const cut = s.slice(0, SPEECH_MAX_CHARS);
|
||||||
|
const m = cut.match(/.*[.!?…]["»\s]/);
|
||||||
|
s = (m ? m[0] : cut).trim();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Naive per-message language guess (accents + stop-words). */export function detectLang(text: string): string {
|
||||||
|
const t = ` ${text.toLowerCase()} `;
|
||||||
|
if (/[àâäéèêëîïôöùûüÿçœæ]/.test(text)) return "fr";
|
||||||
|
const fr = [" le ", " la ", " les ", " un ", " une ", " des ", " est ", " sont ", " que ", " qui ", " pour ", " dans ", " avec ", " je ", " tu ", " vous ", " pas ", " plus ", " cette ", " comme "];
|
||||||
|
const en = [" the ", " and ", " is ", " are ", " you ", " that ", " with ", " for ", " not ", " this ", " have ", " from "];
|
||||||
|
let fs = 0;
|
||||||
|
for (const w of fr) if (t.includes(w)) fs++;
|
||||||
|
let es = 0;
|
||||||
|
for (const w of en) if (t.includes(w)) es++;
|
||||||
|
if (fs === 0 && es === 0) return "en";
|
||||||
|
return fs >= es ? "fr" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ttsSupported(): boolean {
|
||||||
|
return typeof window !== "undefined" && "speechSynthesis" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTtsVoices(): Promise<SpeechSynthesisVoice[]> {
|
||||||
|
if (!ttsSupported()) return Promise.resolve([]);
|
||||||
|
const synth = window.speechSynthesis;
|
||||||
|
const snapshot = synth.getVoices();
|
||||||
|
if (snapshot.length > 0) return Promise.resolve(snapshot);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const done = () => resolve(synth.getVoices());
|
||||||
|
synth.addEventListener("voiceschanged", done, { once: true });
|
||||||
|
// Some engines never fire the event after getVoices polling — bail out.
|
||||||
|
setTimeout(done, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakOpts {
|
||||||
|
onstart?: () => void;
|
||||||
|
onend?: () => void;
|
||||||
|
onerror?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceTimbre = "masculine" | "feminine" | "any";
|
||||||
|
|
||||||
|
/** Stock OpenAI voices (static catalog — selectable even before any key). */
|
||||||
|
export const OPENAI_STATIC_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||||
|
|
||||||
|
// Name fragments hinting a masculine / feminine voice (Chrome, Edge,
|
||||||
|
// Safari and Android voices carry such markers; absence falls back to
|
||||||
|
// any same-language voice — never an error).
|
||||||
|
const MASCULINE_HINTS = ["male", "homme", "masculin", "man", "garcon", "garçon", "david", "daniel", "thomas", "paul", "pierre", "jean", "marco", "jorge", "diego", "alex "];
|
||||||
|
const FEMININE_HINTS = ["female", "femme", "féminin", "feminin", "woman", "girl", "fille", "samantha", "marie", "sophie", "anna", "alice", "amelie", "amélie", "camille", "léa", "lea", "chloe", "chloé", "zira", "eva"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a voice for a language + timbre character. Explicit URI wins, then
|
||||||
|
* same-language voices matching the timbre hints, then any same-language
|
||||||
|
* voice, then the engine default.
|
||||||
|
*/
|
||||||
|
export function pickVoice(
|
||||||
|
voices: SpeechSynthesisVoice[],
|
||||||
|
opts: { lang: string; timbre?: VoiceTimbre; voiceURI?: string | null },
|
||||||
|
): SpeechSynthesisVoice | null {
|
||||||
|
if (opts.voiceURI) {
|
||||||
|
const exact = voices.find((v) => v.voiceURI === opts.voiceURI);
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
const lang = opts.lang.toLowerCase();
|
||||||
|
const sameLang = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
|
||||||
|
const pool = sameLang.length > 0 ? sameLang : voices;
|
||||||
|
if (opts.timbre === "masculine" || opts.timbre === "feminine") {
|
||||||
|
const hints = opts.timbre === "masculine" ? MASCULINE_HINTS : FEMININE_HINTS;
|
||||||
|
const match = pool.find((v) => {
|
||||||
|
const n = `${v.name} ${v.voiceURI}`.toLowerCase();
|
||||||
|
return hints.some((h) => n.includes(h));
|
||||||
|
});
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return pool.find((v) => v.default) ?? pool[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speak one message; cancels any current playback first. Returns false when unsupported. */
|
||||||
|
export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVoice[], opts: SpeakOpts = {}): boolean {
|
||||||
|
if (!ttsSupported()) return false;
|
||||||
|
const synth = window.speechSynthesis;
|
||||||
|
try {
|
||||||
|
synth.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const lang = prefs.lang === "auto" ? detectLang(text) : prefs.lang;
|
||||||
|
const utter = new SpeechSynthesisUtterance(text.slice(0, 2000));
|
||||||
|
utter.lang = lang;
|
||||||
|
utter.rate = prefs.rate;
|
||||||
|
utter.pitch = prefs.pitch;
|
||||||
|
const pick = pickVoice(voices, { lang, voiceURI: prefs.voiceURI });
|
||||||
|
if (pick) utter.voice = pick;
|
||||||
|
utter.onstart = () => opts.onstart?.();
|
||||||
|
utter.onend = () => opts.onend?.();
|
||||||
|
utter.onerror = (e) => opts.onerror?.(e.error || "error");
|
||||||
|
try {
|
||||||
|
synth.speak(utter);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSpeak(): void {
|
||||||
|
try {
|
||||||
|
window.speechSynthesis?.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- unified playback (neural server audio with browser fallback) ---
|
||||||
|
|
||||||
|
export interface PlayHandle {
|
||||||
|
/** Engine that ended up playing (may differ from the request in auto). */
|
||||||
|
engine: "server" | "browser";
|
||||||
|
stop: () => void;
|
||||||
|
done: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentAudio: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
|
function stopCurrentAudio(): void {
|
||||||
|
if (currentAudio) {
|
||||||
|
try {
|
||||||
|
currentAudio.pause();
|
||||||
|
const url = currentAudio.src;
|
||||||
|
currentAudio.removeAttribute("src");
|
||||||
|
currentAudio.load();
|
||||||
|
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
currentAudio = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.speechSynthesis?.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop whatever is playing (server audio or browser speech). */
|
||||||
|
export function stopPlayback(): void {
|
||||||
|
stopCurrentAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayRequest {
|
||||||
|
text: string;
|
||||||
|
/** "server" forces neural, "browser" forces synthesis, "auto" tries neural first. */
|
||||||
|
engine: "auto" | "browser" | "server";
|
||||||
|
browser: {
|
||||||
|
voices: SpeechSynthesisVoice[];
|
||||||
|
/** "auto" detects per text, otherwise a BCP-47 prefix. */
|
||||||
|
lang: string;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
/** Explicit voice URI (user pref); otherwise timbre-matched. */
|
||||||
|
voiceURI?: string | null;
|
||||||
|
/** Preview character timbre (ignored when voiceURI is set). */
|
||||||
|
timbre?: VoiceTimbre;
|
||||||
|
};
|
||||||
|
/** Server neural voice id (preview line voice or user serverVoice). */
|
||||||
|
serverVoice?: string | null;
|
||||||
|
/** True when the platform TTS tool is available. */
|
||||||
|
serverAvailable: boolean;
|
||||||
|
fetchAudio: (text: string, voice?: string) => Promise<Blob>;
|
||||||
|
onstart?: () => void;
|
||||||
|
onend?: () => void;
|
||||||
|
onerror?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play one text with the best engine: neural server audio when requested
|
||||||
|
* and available (shared <audio> element, user speed), otherwise the
|
||||||
|
* browser character voice. Single global playback — a new play stops the
|
||||||
|
* previous one. `done` resolves when playback fully ends (fallback
|
||||||
|
* included); it never rejects (errors go to onend/onerror like speech).
|
||||||
|
*/
|
||||||
|
export function playText(req: PlayRequest): PlayHandle {
|
||||||
|
stopPlayback();
|
||||||
|
// Never read markdown cruft aloud; fall back to raw text when cleaning
|
||||||
|
// leaves nothing (e.g. a code-only message).
|
||||||
|
const clean = toSpeechText(req.text) || req.text.slice(0, SPEECH_MAX_CHARS);
|
||||||
|
const cleanReq: PlayRequest = { ...req, text: clean };
|
||||||
|
const wantServer = cleanReq.serverAvailable && cleanReq.engine !== "browser";
|
||||||
|
if (wantServer) return playServer(cleanReq, cleanReq.engine === "auto");
|
||||||
|
return playBrowser(cleanReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playServer(req: PlayRequest, allowFallback: boolean): PlayHandle {
|
||||||
|
let settled = false;
|
||||||
|
const stop = () => {
|
||||||
|
settled = true;
|
||||||
|
stopCurrentAudio();
|
||||||
|
};
|
||||||
|
const done = (async () => {
|
||||||
|
try {
|
||||||
|
const blob = await req.fetchAudio(req.text.slice(0, 2000), req.serverVoice ?? undefined);
|
||||||
|
if (settled) return;
|
||||||
|
await playAudioBlob(blob, req, () => settled);
|
||||||
|
} catch (e) {
|
||||||
|
if (settled) return;
|
||||||
|
if (allowFallback) {
|
||||||
|
await playBrowserAudio(req, () => settled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.onerror?.(e instanceof Error ? e.message : "playback failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return { engine: "server", stop, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAudioBlob(blob: Blob, req: PlayRequest, cancelled: () => boolean): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.playbackRate = req.browser.rate;
|
||||||
|
currentAudio = audio;
|
||||||
|
const finish = (ok: boolean) => {
|
||||||
|
if (currentAudio === audio) stopCurrentAudio();
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled() && ok) req.onend?.();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
audio.onended = () => finish(true);
|
||||||
|
audio.onerror = () => finish(false);
|
||||||
|
req.onstart?.();
|
||||||
|
audio.play().catch(() => finish(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playBrowser(req: PlayRequest): PlayHandle {
|
||||||
|
let settled = false;
|
||||||
|
const stop = () => {
|
||||||
|
settled = true;
|
||||||
|
stopSpeak();
|
||||||
|
};
|
||||||
|
const done = playBrowserAudio(req, () => settled);
|
||||||
|
return { engine: "browser", stop, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
function playBrowserAudio(req: PlayRequest, cancelled: () => boolean): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const b = req.browser;
|
||||||
|
const lang = b.lang === "auto" ? detectLang(req.text) : b.lang;
|
||||||
|
const explicit = b.voiceURI ? b.voices.find((v) => v.voiceURI === b.voiceURI) : undefined;
|
||||||
|
const pick = explicit ?? pickVoice(b.voices, { lang, timbre: b.timbre ?? "any" });
|
||||||
|
const finish = (ok: boolean, msg?: string) => {
|
||||||
|
if (cancelled()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ok) req.onend?.();
|
||||||
|
else req.onerror?.(msg ?? "error");
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const ok = speak(
|
||||||
|
req.text,
|
||||||
|
{ voiceURI: pick?.voiceURI ?? null, rate: b.rate, pitch: b.pitch, lang, engine: "browser", serverVoice: null },
|
||||||
|
b.voices,
|
||||||
|
{ onstart: () => req.onstart?.(), onend: () => finish(true), onerror: (m) => finish(false, m) },
|
||||||
|
);
|
||||||
|
if (!ok) finish(false, "unsupported");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recognizer {
|
||||||
|
lang: string;
|
||||||
|
interimResults: boolean;
|
||||||
|
continuous: boolean;
|
||||||
|
onresult: ((ev: { results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null;
|
||||||
|
onerror: ((ev: { error?: string }) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
abort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecognizerCtor = new () => Recognizer;
|
||||||
|
|
||||||
|
export function recognizerCtor(): RecognizerCtor | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const w = window as unknown as Record<string, unknown>;
|
||||||
|
return ((w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as RecognizerCtor | null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sttSupported(): boolean {
|
||||||
|
return recognizerCtor() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
+39
-6
@@ -1,11 +1,44 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "@fontsource-variable/inter";
|
import "@fontsource-variable/inter";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { App } from "./App.tsx";
|
import { App } from "./App.tsx";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
/**
|
||||||
<React.StrictMode>
|
* Lock page zoom app-wide (viewport meta + CSS touch-action cover most
|
||||||
<App />
|
* browsers; these listeners catch the rest):
|
||||||
</React.StrictMode>,
|
* - iOS Safari ignores `user-scalable=no`, so `gesturestart` is cancelled.
|
||||||
);
|
* - Desktop: Ctrl+wheel (incl. trackpad pinch) and Ctrl + +/-/0 are blocked.
|
||||||
|
*/
|
||||||
|
function useZoomLock() {
|
||||||
|
useEffect(() => {
|
||||||
|
const stop = (e: Event) => e.preventDefault();
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (e.ctrlKey) e.preventDefault();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && (e.key === "+" || e.key === "-" || e.key === "=" || e.key === "0")) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("gesturestart", stop);
|
||||||
|
document.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("gesturestart", stop);
|
||||||
|
document.removeEventListener("wheel", onWheel);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
useZoomLock();
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(<Root />);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default {
|
|||||||
700: "rgb(var(--ka-accent-700) / <alpha-value>)",
|
700: "rgb(var(--ka-accent-700) / <alpha-value>)",
|
||||||
800: "rgb(var(--ka-accent-800) / <alpha-value>)",
|
800: "rgb(var(--ka-accent-800) / <alpha-value>)",
|
||||||
900: "rgb(var(--ka-accent-900) / <alpha-value>)",
|
900: "rgb(var(--ka-accent-900) / <alpha-value>)",
|
||||||
|
950: "rgb(var(--ka-accent-950) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+11
@@ -29,6 +29,15 @@ in {
|
|||||||
description = "File containing APP_PASSWORD (use agenix/sops-nix, never store in the store).";
|
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"; };
|
ollamaHost = lib.mkOption { type = lib.types.str; default = "http://localhost:11434"; };
|
||||||
|
postgresqlUrl = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Postgres connection URL (e.g. postgres://user:pass@localhost:5432/kisassistant,
|
||||||
|
typically a docker container). Null (default) keeps the local SQLite file.
|
||||||
|
Prefer a credential file + extraEnv for secrets in production.
|
||||||
|
'';
|
||||||
|
};
|
||||||
domain = lib.mkOption {
|
domain = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = null;
|
default = null;
|
||||||
@@ -93,6 +102,8 @@ in {
|
|||||||
CDN_DIR = "${cfg.dataDir}/cdn";
|
CDN_DIR = "${cfg.dataDir}/cdn";
|
||||||
DRIVER_DEBUG = "false";
|
DRIVER_DEBUG = "false";
|
||||||
OLLAMA_HOST = cfg.ollamaHost;
|
OLLAMA_HOST = cfg.ollamaHost;
|
||||||
|
} // lib.optionalAttrs (cfg.postgresqlUrl != null) {
|
||||||
|
POSTGRESQL_URL = cfg.postgresqlUrl;
|
||||||
} // lib.optionalAttrs cfg.enableBrowserDriver {
|
} // lib.optionalAttrs cfg.enableBrowserDriver {
|
||||||
ARCAIC_ENABLED = "true";
|
ARCAIC_ENABLED = "true";
|
||||||
ARCAIC_HEADLESS = "true";
|
ARCAIC_HEADLESS = "true";
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
Il y as plusieurs bug d'UX bien chiant:
|
||||||
|
Quand un modèle as une erreur: Le modèle le dit, je refresh, le message d'errur à disparue -> Rendre ces message perisstant
|
||||||
|
Quand j'envoie un message dans une conversation , l'IA think: je vais sur une autre, sont overly aussi overide une autre conversation: bien segmenter sa
|
||||||
|
Le modèles choisis n'est pas sauvegarder, rend l'user choix perssistant
|
||||||
|
|
||||||
|
|
||||||
|
____
|
||||||
|
|
||||||
|
Pouvoir ajouter d'autre node ollama dans l'admin center. modale, composant, gestion de priorité avec des "poids" comme un firewall
|
||||||
|
Gestions des rate-limites pour recovery password...
|
||||||
|
admin center: gérer les url pages
|
||||||
|
|
||||||
|
exemple: ouverture catégorie modèles: <host>/admin/models
|
||||||
|
fait un timeout sur les requete envoyez au ollama , configurable dans l'admin center.
|
||||||
|
Faire un catalog des modèles ollama via internet /hugging face compatible ollama.
|
||||||
|
voir les détails des nodes ollama (charge cpu, mémoire, vram..., sur le temps (1h,6h,12h,24h,7w,14w,1m))
|
||||||
|
leur composants,...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Changer le favicon en fonction de la couleur accentique.
|
||||||
|
Revoir la plate-forme
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Proposer un Text-to-speech dans le composer de message via un Modèle comme whisper ou autre (voir tts)
|
||||||
|
le composer message qui fait les message vocaux, doit respecter la DA du site, un beau truc, avec les waves form jolie du message vocal en real time, proposer de stoper la vocal, transcribe en texte, proposer l'édition avant envoie
|
||||||
|
|
||||||
|
---- pour les telemtry des nodes ollama
|
||||||
|
Vraiment rendre le truc super clean: telemtry avec très beau graphe, dock bar, pour changer le type de graphique, faire une vrai sous-page dans le admin center, avec de magnifique graphe, mangifique tooltip vraiment sympathique, voir le host, kernel, uptime, network, ip, etc
|
||||||
|
|
||||||
|
--- pour les waves formes du message vocal, rendre le tout vraiment vraiment beau, un beau composer unique.
|
||||||
|
--- Nouvel outil, l'ia en temps réel:
|
||||||
|
Faire un Speech-To-Speech, soit avec whisper + LLM ,etc proposer à l'utilisateur sont combo modèle + très belle animation quand le modèle parle, un peu style siri ai. (Apple Inteligence) , outil potentiel à inclur: FlowerlyTTS local ou bien modèle whisper , speech2speech. Proposer dans l'admin center sa configuration , avec les outils, etc...
|
||||||
|
Le modèle doit détecter la langue par le texte/etc. l'user peut customiser l'accent sur sa langue configuré(accent féminin, accent masculin, avec preview audio sur un text déjà pré-encoder: Exemple:
|
||||||
|
"Salut, euhm, comment je peux t'aider aujourd'hui ?"> voix grave héistante
|
||||||
|
"5 sur 5 je recois! Que-veux-tu ?" voix grave ton serieux
|
||||||
|
"Owwww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi " voix fémine, mignonne
|
||||||
|
"Hey , que puis-je faire pour toi ? " voix féminine médium par exemple
|
||||||
|
|
||||||
|
Ces exemples sont les default voix preview . dans l'admin center , l'utilisateur peut les modifier /revenir à défaut
|
||||||
|
L'utilisateur doit pouvoir configurée ces ai,vocaux,voix,langue dans une sous-section dédier dans ces paramètres.
|
||||||
|
|
||||||
|
--- actually the favicon have an background svg as the same color of the accenthique theme. should be indepandant .
|
||||||
|
white = day theme
|
||||||
|
black / night theme
|
||||||
|
witht the flower itself following thr accentic color
|
||||||
|
|
||||||
|
|
||||||
|
-- in specify
|
||||||
|
specify the model used in each footer of assistant message
|
||||||
|
like <model> <button>
|
||||||
|
|
||||||
|
|
||||||
|
---- pouvoir dans admin centers, sur users, envoyez un lien mdp reset à un user
|
||||||
|
|
||||||
|
|
||||||
|
--- <input type="color" aria-label="Custom accent color" class="absolute inset-0 cursor-pointer opacity-0" value="#10b981">
|
||||||
|
|
||||||
|
Ajouter un vrai composant pour pouvoir chosisir la couleur accentique sans dépendre du système.
|
||||||
|
Utiliser un vrai copiosant come shadcdn.
|
||||||
|
|
||||||
|
Avec un vrai accent color picket etpouvoir mettre sontpropr e code hex pour la couleur.
|
||||||
+77
-60
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { getDb } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Access-request wishlist (registration approval queue) + minimal ticket
|
* Access-request wishlist (registration approval queue) + minimal ticket
|
||||||
@@ -33,8 +33,40 @@ export interface AccessMessage {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initAccessTables(): void {
|
export async function initAccessTables(): Promise<void> {
|
||||||
const d = getDb();
|
const d = db();
|
||||||
|
const autoId = d.kind === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
|
||||||
|
await d.exec(`CREATE TABLE IF NOT EXISTS access_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
if (d.kind === "sqlite") {
|
||||||
|
await repairSqliteAccessTables();
|
||||||
|
}
|
||||||
|
await d.exec(`CREATE TABLE IF NOT EXISTS access_messages (
|
||||||
|
id ${autoId},
|
||||||
|
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-only repairs for DBs shaped by older builds (PRAGMA / sqlite_master
|
||||||
|
* introspection + DROP COLUMN don't exist on Postgres; fresh Postgres tables
|
||||||
|
* are created clean above).
|
||||||
|
*/
|
||||||
|
async function repairSqliteAccessTables(): Promise<void> {
|
||||||
|
const d = db();
|
||||||
const createRequests = `CREATE TABLE access_requests (
|
const createRequests = `CREATE TABLE access_requests (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
@@ -52,16 +84,6 @@ export function initAccessTables(): void {
|
|||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
)`;
|
)`;
|
||||||
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL DEFAULT '',
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
reason TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)`).run();
|
|
||||||
// Idempotent migration (must run BEFORE access_messages exists with an FK
|
// Idempotent migration (must run BEFORE access_messages exists with an FK
|
||||||
// on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
|
// on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
|
||||||
// would otherwise poison access_messages with a dangling reference).
|
// would otherwise poison access_messages with a dangling reference).
|
||||||
@@ -70,22 +92,22 @@ export function initAccessTables(): void {
|
|||||||
// CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without
|
// CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without
|
||||||
// token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
|
// token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
|
||||||
try {
|
try {
|
||||||
const cols = d.query("PRAGMA table_info(access_requests)").all() as { name: string }[];
|
const cols = await d.all<{ name: string }>("PRAGMA table_info(access_requests)");
|
||||||
if (cols.some((c) => c.name === "token_hash")) {
|
if (cols.some((c) => c.name === "token_hash")) {
|
||||||
try {
|
try {
|
||||||
d.query("PRAGMA foreign_keys=OFF").run();
|
await d.exec("PRAGMA foreign_keys=OFF");
|
||||||
try {
|
try {
|
||||||
d.query("ALTER TABLE access_requests DROP COLUMN token_hash").run();
|
await d.exec("ALTER TABLE access_requests DROP COLUMN token_hash");
|
||||||
} catch {
|
} catch {
|
||||||
// SQLite builds without DROP COLUMN support: rebuild table.
|
// SQLite builds without DROP COLUMN support: rebuild table.
|
||||||
d.query("ALTER TABLE access_requests RENAME TO access_requests_old").run();
|
await d.exec("ALTER TABLE access_requests RENAME TO access_requests_old");
|
||||||
d.query(createRequests).run();
|
await d.exec(createRequests);
|
||||||
d.query(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
|
await d.exec(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
|
||||||
SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`).run();
|
SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`);
|
||||||
d.query("DROP TABLE access_requests_old").run();
|
await d.exec("DROP TABLE access_requests_old");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
d.query("PRAGMA foreign_keys=ON").run();
|
await d.exec("PRAGMA foreign_keys=ON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -95,39 +117,30 @@ export function initAccessTables(): void {
|
|||||||
// created before the DROP COLUMN), leaving access_messages with
|
// created before the DROP COLUMN), leaving access_messages with
|
||||||
// REFERENCES "access_requests_old". Rebuild it with the correct FK.
|
// REFERENCES "access_requests_old". Rebuild it with the correct FK.
|
||||||
try {
|
try {
|
||||||
const row = d.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'").get() as { sql: string } | null;
|
const row = await d.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'");
|
||||||
if (row?.sql?.includes("access_requests_old")) {
|
if (row?.sql?.includes("access_requests_old")) {
|
||||||
d.query("PRAGMA foreign_keys=OFF").run();
|
await d.exec("PRAGMA foreign_keys=OFF");
|
||||||
try {
|
try {
|
||||||
d.query("DROP TABLE IF EXISTS access_messages_old").run();
|
await d.exec("DROP TABLE IF EXISTS access_messages_old");
|
||||||
d.query("ALTER TABLE access_messages RENAME TO access_messages_old").run();
|
await d.exec("ALTER TABLE access_messages RENAME TO access_messages_old");
|
||||||
d.query(createMessages).run();
|
await d.exec(createMessages);
|
||||||
d.query(`INSERT INTO access_messages (id, request_id, author, body, created_at)
|
await d.exec(`INSERT INTO access_messages (id, request_id, author, body, created_at)
|
||||||
SELECT id, request_id, author, body, created_at FROM access_messages_old`).run();
|
SELECT id, request_id, author, body, created_at FROM access_messages_old`);
|
||||||
d.query("DROP TABLE access_messages_old").run();
|
await d.exec("DROP TABLE access_messages_old");
|
||||||
} finally {
|
} finally {
|
||||||
d.query("PRAGMA foreign_keys=ON").run();
|
await d.exec("PRAGMA foreign_keys=ON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Leave schema as-is; errors will surface on write.
|
// Leave schema as-is; errors will surface on write.
|
||||||
}
|
}
|
||||||
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
|
||||||
author TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)`).run();
|
|
||||||
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
|
|
||||||
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAccessRequest(opts: {
|
export async function createAccessRequest(opts: {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
message: string;
|
message: string;
|
||||||
}): AccessRequest {
|
}): Promise<AccessRequest> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const row: AccessRequest = {
|
const row: AccessRequest = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -139,43 +152,47 @@ export function createAccessRequest(opts: {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
getDb().query(
|
await db().run(
|
||||||
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at);
|
row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at,
|
||||||
|
);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccessRequest(id: string): AccessRequest | null {
|
export async function getAccessRequest(id: string): Promise<AccessRequest | null> {
|
||||||
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
|
return db().get<AccessRequest>("SELECT * FROM access_requests WHERE id = ?", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open (non-terminal) request already reserving this username or email. */
|
/** Open (non-terminal) request already reserving this username or email. */
|
||||||
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
|
export async function findOpenAccessRequest(username: string, email: string): Promise<AccessRequest | null> {
|
||||||
return getDb().query(
|
return db().get<AccessRequest>(
|
||||||
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
|
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
|
||||||
).get(username, email) as AccessRequest | null;
|
username, email,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAccessRequests(): (AccessRequest & { message_count: number })[] {
|
export async function listAccessRequests(): Promise<(AccessRequest & { message_count: number })[]> {
|
||||||
return getDb().query(
|
return db().all<AccessRequest & { message_count: number }>(
|
||||||
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
|
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
|
||||||
FROM access_requests r ORDER BY r.updated_at DESC`,
|
FROM access_requests r ORDER BY r.updated_at DESC`,
|
||||||
).all() as (AccessRequest & { message_count: number })[];
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
|
export async function setAccessStatus(id: string, status: AccessStatus, reason: string): Promise<AccessRequest | null> {
|
||||||
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
|
await db().run("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?", status, reason, Date.now(), id);
|
||||||
return getAccessRequest(id);
|
return getAccessRequest(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAccessMessages(requestId: string): AccessMessage[] {
|
export async function listAccessMessages(requestId: string): Promise<AccessMessage[]> {
|
||||||
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
|
return db().all<AccessMessage>("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC", requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage {
|
export async function addAccessMessage(requestId: string, author: "user" | "admin", body: string): Promise<AccessMessage> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
|
const id = await db().insertReturningId(
|
||||||
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
|
"INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)",
|
||||||
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
|
requestId, author, body, now,
|
||||||
|
);
|
||||||
|
await db().run("UPDATE access_requests SET updated_at = ? WHERE id = ?", now, requestId);
|
||||||
return { id, request_id: requestId, author, body, created_at: now };
|
return { id, request_id: requestId, author, body, created_at: now };
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { AccessMessage, AccessRequest } from "./access.ts";
|
import type { AccessMessage, AccessRequest } from "./access.ts";
|
||||||
|
import type { Report } from "./reports.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live access-request updates over WebSocket (replaces HTTP polling).
|
* Live access-request updates over WebSocket (replaces HTTP polling).
|
||||||
@@ -8,14 +9,19 @@ import type { AccessMessage, AccessRequest } from "./access.ts";
|
|||||||
* UUID, exactly like GET /api/access/ticket/:id. Receives this ticket's
|
* UUID, exactly like GET /api/access/ticket/:id. Receives this ticket's
|
||||||
* messages + status changes.
|
* messages + status changes.
|
||||||
* - admin socket `/api/admin/ws?token=…` — admin Bearer (query param
|
* - admin socket `/api/admin/ws?token=…` — admin Bearer (query param
|
||||||
* because browsers can't set WS headers). Receives every access event,
|
* because browsers can't set WS headers). Receives every access event
|
||||||
* driving the AdminCenter badge + triage list.
|
* plus content-report events, driving the AdminCenter badges + lists.
|
||||||
|
*
|
||||||
|
* Every event carries its full payload: clients apply it straight to state
|
||||||
|
* (no HTTP refetch on event — the socket IS the data, not a hint to poll).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AccessLiveEvent =
|
export type AccessLiveEvent =
|
||||||
| { type: "access_message"; request_id: string; message: AccessMessage }
|
| { type: "access_message"; request_id: string; message: AccessMessage }
|
||||||
| { type: "access_status"; request_id: string; request: AccessRequest }
|
| { type: "access_status"; request_id: string; request: AccessRequest }
|
||||||
| { type: "access_created"; request_id: string; request: AccessRequest };
|
| { type: "access_created"; request_id: string; request: AccessRequest; message: AccessMessage; message_count: number }
|
||||||
|
| { type: "report_created"; report: Report }
|
||||||
|
| { type: "report_status"; report: Report };
|
||||||
|
|
||||||
export interface AccessLiveSubscriber {
|
export interface AccessLiveSubscriber {
|
||||||
/** Ticket to follow, or null for the admin firehose. */
|
/** Ticket to follow, or null for the admin firehose. */
|
||||||
@@ -37,8 +43,15 @@ export function subscribeAccessLive(s: AccessLiveSubscriber): () => void {
|
|||||||
export function broadcastAccessLive(evt: AccessLiveEvent): void {
|
export function broadcastAccessLive(evt: AccessLiveEvent): void {
|
||||||
if (subscribers.size === 0) return;
|
if (subscribers.size === 0) return;
|
||||||
const data = JSON.stringify(evt);
|
const data = JSON.stringify(evt);
|
||||||
|
// Ticket-scoped events route to that ticket's socket; report events and
|
||||||
|
// everything else fan out to the admin firehose only.
|
||||||
|
const ticketId = "request_id" in evt ? evt.request_id : null;
|
||||||
for (const s of [...subscribers]) {
|
for (const s of [...subscribers]) {
|
||||||
if (s.ticketId !== evt.request_id && !(s.isAdmin && s.ticketId === null)) continue;
|
if (s.isAdmin && s.ticketId === null) {
|
||||||
|
// Admin firehose: every event.
|
||||||
|
} else if (s.ticketId === null || s.ticketId !== ticketId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!s.send(data)) subscribers.delete(s);
|
if (!s.send(data)) subscribers.delete(s);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+49
-16
@@ -3,17 +3,14 @@ import { config } from "./config.ts";
|
|||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getDb,
|
|
||||||
getSession,
|
getSession,
|
||||||
|
getSetting,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
toPublicUser,
|
toPublicUser,
|
||||||
type PublicUser,
|
type PublicUser,
|
||||||
} from "./db.ts";
|
} from "./db.ts";
|
||||||
|
|
||||||
// Ensure DB + admin row exist at startup.
|
|
||||||
getDb();
|
|
||||||
|
|
||||||
export type { PublicUser };
|
export type { PublicUser };
|
||||||
|
|
||||||
function sha256hex(s: string): string {
|
function sha256hex(s: string): string {
|
||||||
@@ -49,40 +46,40 @@ function checkAdminKey(candidate: string): boolean {
|
|||||||
* - username "admin" authenticates against APP_PASSWORD (.env).
|
* - username "admin" authenticates against APP_PASSWORD (.env).
|
||||||
* - other usernames authenticate against their admin-issued access key.
|
* - other usernames authenticate against their admin-issued access key.
|
||||||
*/
|
*/
|
||||||
export function login(username: string, key: string): PublicUser | null {
|
export async function login(username: string, key: string): Promise<PublicUser | null> {
|
||||||
if (username === "admin") {
|
if (username === "admin") {
|
||||||
if (!checkAdminKey(key)) return null;
|
if (!checkAdminKey(key)) return null;
|
||||||
const admin = getUserByUsername("admin");
|
const admin = await getUserByUsername("admin");
|
||||||
if (!admin) return null;
|
if (!admin) return null;
|
||||||
return toPublicUser(admin);
|
return toPublicUser(admin);
|
||||||
}
|
}
|
||||||
const u = getUserByUsername(username);
|
const u = await getUserByUsername(username);
|
||||||
if (!u || !u.key_hash) return null;
|
if (!u || !u.key_hash) return null;
|
||||||
if (!secretsEqual(key, u.key_hash)) return null;
|
if (!secretsEqual(key, u.key_hash)) return null;
|
||||||
return toPublicUser(u);
|
return toPublicUser(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
|
/** Issue a fresh one-time Bearer token per successful login (stored hashed server-side). */
|
||||||
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
|
export async function issueToken(user: PublicUser): Promise<{ token: string; expiresAt: number }> {
|
||||||
const token = randomBytes(32).toString("hex"); // 256-bit
|
const token = randomBytes(32).toString("hex"); // 256-bit
|
||||||
const expiresAt = Date.now() + config.sessionTtlMs;
|
const expiresAt = Date.now() + config.sessionTtlMs;
|
||||||
createSession(sha256hex(token), user.id, expiresAt);
|
await createSession(sha256hex(token), user.id, expiresAt);
|
||||||
return { token, expiresAt };
|
return { token, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyToken(token: string): PublicUser | null {
|
export async function verifyToken(token: string): Promise<PublicUser | null> {
|
||||||
const s = getSession(sha256hex(token));
|
const s = await getSession(sha256hex(token));
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const u = getUserById(s.user_id);
|
const u = await getUserById(s.user_id);
|
||||||
if (!u) {
|
if (!u) {
|
||||||
deleteSession(sha256hex(token));
|
await deleteSession(sha256hex(token));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return toPublicUser(u);
|
return toPublicUser(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function revokeToken(token: string): void {
|
export async function revokeToken(token: string): Promise<void> {
|
||||||
deleteSession(sha256hex(token));
|
await deleteSession(sha256hex(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractBearer(req: Request): string | null {
|
export function extractBearer(req: Request): string | null {
|
||||||
@@ -113,3 +110,39 @@ export function recordAttempt(ip: string): void {
|
|||||||
export function clearAttempts(ip: string): void {
|
export function clearAttempts(ip: string): void {
|
||||||
attempts.delete(ip);
|
attempts.delete(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- dedicated rate limiter for password recovery (admin-configurable) ---
|
||||||
|
// Separate bucket from login: recovery is anonymous-by-design (always-ok
|
||||||
|
// answers) so it needs its own, usually stricter, envelope.
|
||||||
|
|
||||||
|
const recoveryAttempts = new Map<string, number[]>();
|
||||||
|
|
||||||
|
function clampSettingInt(raw: string, min: number, max: number, fallback: number): number {
|
||||||
|
const v = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(v)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoveryLimitMax(): Promise<number> {
|
||||||
|
return clampSettingInt(await getSetting("recovery_limit_max", "5"), 1, 100, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoveryLimitWindowMs(): Promise<number> {
|
||||||
|
return clampSettingInt(await getSetting("recovery_limit_window_min", "60"), 1, 1440, 60) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isRecoveryLimited(ip: string): Promise<{ limited: boolean; retryAfterSec: number }> {
|
||||||
|
const windowMs = await recoveryLimitWindowMs();
|
||||||
|
const max = await recoveryLimitMax();
|
||||||
|
const now = Date.now();
|
||||||
|
const list = (recoveryAttempts.get(ip) ?? []).filter((t) => t > now - windowMs);
|
||||||
|
recoveryAttempts.set(ip, list);
|
||||||
|
if (list.length < max) return { limited: false, retryAfterSec: 0 };
|
||||||
|
return { limited: true, retryAfterSec: Math.max(1, Math.ceil((list[0]! + windowMs - now) / 1000)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecoveryAttempt(ip: string): void {
|
||||||
|
const list = recoveryAttempts.get(ip) ?? [];
|
||||||
|
list.push(Date.now());
|
||||||
|
recoveryAttempts.set(ip, list);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export const config = {
|
|||||||
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
|
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
|
||||||
dataDir: env("DATA_DIR", "./data"),
|
dataDir: env("DATA_DIR", "./data"),
|
||||||
|
|
||||||
|
// Database: empty POSTGRESQL_URL = local SQLite in DATA_DIR (default).
|
||||||
|
// Set POSTGRESQL_URL=postgres://user:pass@host:5432/db to run on Postgres
|
||||||
|
// (typically a docker container) via Bun's native SQL driver instead.
|
||||||
|
postgresqlUrl: env("POSTGRESQL_URL", ""),
|
||||||
|
|
||||||
rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
|
rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
|
||||||
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
|
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
|
||||||
|
|
||||||
@@ -42,6 +47,9 @@ export const config = {
|
|||||||
openaiApiKey: env("OPENAI_API_KEY", ""),
|
openaiApiKey: env("OPENAI_API_KEY", ""),
|
||||||
openaiEnabled: envBool("OPENAI_ENABLED", false),
|
openaiEnabled: envBool("OPENAI_ENABLED", false),
|
||||||
|
|
||||||
|
geminiApiKey: env("GEMINI_API_KEY", ""),
|
||||||
|
geminiEnabled: envBool("GEMINI_ENABLED", false),
|
||||||
|
|
||||||
glmApiKey: env("GLM_API_KEY", ""),
|
glmApiKey: env("GLM_API_KEY", ""),
|
||||||
glmEnabled: envBool("GLM_ENABLED", false),
|
glmEnabled: envBool("GLM_ENABLED", false),
|
||||||
// Zhipu has two platforms: international (api.z.ai, default) and
|
// Zhipu has two platforms: international (api.z.ai, default) and
|
||||||
@@ -72,6 +80,14 @@ export const config = {
|
|||||||
ocrLang: env("OCR_LANG", "eng"),
|
ocrLang: env("OCR_LANG", "eng"),
|
||||||
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
|
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
|
||||||
|
|
||||||
|
// Neural TTS tool (ElevenLabs / OpenAI / local piper, first configured
|
||||||
|
// wins unless TTS_PROVIDER forces one). Without any of these the tool
|
||||||
|
// reports unavailable and clients fall back to browser speech.
|
||||||
|
elevenlabsApiKey: env("ELEVENLABS_API_KEY", ""),
|
||||||
|
elevenlabsVoice: env("ELEVENLABS_VOICE", ""),
|
||||||
|
piperModel: env("PIPER_MODEL", ""),
|
||||||
|
ttsVoice: env("TTS_VOICE", "alloy"),
|
||||||
|
|
||||||
// Key recovery via email (optional — empty SMTP_HOST disables it).
|
// Key recovery via email (optional — empty SMTP_HOST disables it).
|
||||||
smtpHost: env("SMTP_HOST", ""),
|
smtpHost: env("SMTP_HOST", ""),
|
||||||
smtpPort: envInt("SMTP_PORT", 587),
|
smtpPort: envInt("SMTP_PORT", 587),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import { config } from "./config.ts";
|
import { db, initDb, closeDb, dbKind } from "./db/client.ts";
|
||||||
|
import type { DbKind } from "./db/client.ts";
|
||||||
|
|
||||||
|
export { db, initDb, closeDb, dbKind };
|
||||||
|
export type { DbKind };
|
||||||
|
|
||||||
// --- row types ---
|
// --- row types ---
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export interface MessageRow {
|
|||||||
conv_id: string;
|
conv_id: string;
|
||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Fully-qualified model id that produced this message ("" = unknown/pre-migration). */
|
||||||
|
model: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,87 +57,6 @@ export interface PublicUser {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- db handle ---
|
|
||||||
|
|
||||||
let db: Database | null = null;
|
|
||||||
|
|
||||||
export function getDb(): Database {
|
|
||||||
if (db) return db;
|
|
||||||
mkdirSync(config.dataDir, { recursive: true });
|
|
||||||
db = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
|
|
||||||
db.exec("PRAGMA journal_mode = WAL;");
|
|
||||||
db.exec("PRAGMA foreign_keys = ON;");
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
avatar_url TEXT NOT NULL DEFAULT '',
|
|
||||||
theme TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
key_hash TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions(
|
|
||||||
token_hash TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS conversations(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL DEFAULT 'New chat',
|
|
||||||
topic TEXT NOT NULL DEFAULT '',
|
|
||||||
model TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
|
|
||||||
CREATE TABLE IF NOT EXISTS messages(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
|
|
||||||
CREATE TABLE IF NOT EXISTS shares(
|
|
||||||
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
|
||||||
public_id TEXT UNIQUE NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS resets(
|
|
||||||
token_hash TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS settings(
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
// Email column added after the initial schema — keep idempotent.
|
|
||||||
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
|
||||||
if (!cols.some((c) => c.name === "email")) {
|
|
||||||
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
|
|
||||||
}
|
|
||||||
// Archive flag added after the initial schema — keep idempotent.
|
|
||||||
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
|
|
||||||
if (!convCols.some((c) => c.name === "archived_at")) {
|
|
||||||
db.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
|
||||||
}
|
|
||||||
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
|
|
||||||
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
|
|
||||||
if (!admin) {
|
|
||||||
db.query(
|
|
||||||
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
).run(randomUUID(), "admin", "Administrator", "", "auto", "", Date.now());
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toPublicUser(u: UserRow): PublicUser {
|
export function toPublicUser(u: UserRow): PublicUser {
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@@ -150,20 +72,19 @@ export function toPublicUser(u: UserRow): PublicUser {
|
|||||||
|
|
||||||
// --- users ---
|
// --- users ---
|
||||||
|
|
||||||
export function getUserByUsername(username: string): UserRow | null {
|
export async function getUserByUsername(username: string): Promise<UserRow | null> {
|
||||||
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null;
|
return db().get<UserRow>("SELECT * FROM users WHERE username = ?", username);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserById(id: string): UserRow | null {
|
export async function getUserById(id: string): Promise<UserRow | null> {
|
||||||
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
|
return db().get<UserRow>("SELECT * FROM users WHERE id = ?", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listUsers(): UserRow[] {
|
export async function listUsers(): Promise<UserRow[]> {
|
||||||
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
|
return db().all<UserRow>("SELECT * FROM users ORDER BY created_at ASC");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow {
|
export async function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): Promise<UserRow> {
|
||||||
const d = getDb();
|
|
||||||
const row: UserRow = {
|
const row: UserRow = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username: opts.username,
|
username: opts.username,
|
||||||
@@ -174,73 +95,79 @@ export function createUser(opts: { username: string; displayName: string; avatar
|
|||||||
key_hash: opts.keyHash,
|
key_hash: opts.keyHash,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
await db().run(
|
||||||
|
"INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
|
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
|
||||||
);
|
);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
|
export async function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): Promise<UserRow | null> {
|
||||||
const d = getDb();
|
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const vals: (string | number)[] = [];
|
const vals: (string | number)[] = [];
|
||||||
|
if (patch.username !== undefined) { sets.push("username = ?"); vals.push(patch.username); }
|
||||||
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
|
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
|
||||||
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
|
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.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
|
||||||
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
|
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
|
||||||
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
|
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
|
||||||
if (sets.length > 0) {
|
if (sets.length > 0) {
|
||||||
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
|
await db().run(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
|
||||||
|
}
|
||||||
|
if (patch.username !== undefined) {
|
||||||
|
// Username is denormalized onto past reports — keep the triage list consistent.
|
||||||
|
await db().run("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?", patch.username, id);
|
||||||
}
|
}
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUser(id: string): void {
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
const d = getDb();
|
const d = db();
|
||||||
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[];
|
const convs = await d.all<{ id: string }>("SELECT id FROM conversations WHERE user_id = ?", id);
|
||||||
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?");
|
await d.transaction(async (tx) => {
|
||||||
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?");
|
for (const c of convs) {
|
||||||
const delConv = d.query("DELETE FROM conversations WHERE id = ?");
|
await tx.run("DELETE FROM messages WHERE conv_id = ?", c.id);
|
||||||
const txn = d.transaction((cid: string) => {
|
await tx.run("DELETE FROM shares WHERE conv_id = ?", c.id);
|
||||||
delMsg.run(cid);
|
await tx.run("DELETE FROM conversations WHERE id = ?", c.id);
|
||||||
delShare.run(cid);
|
}
|
||||||
delConv.run(cid);
|
await tx.run("DELETE FROM sessions WHERE user_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM resets WHERE user_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM user_provider_keys WHERE user_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM users WHERE id = ?", id);
|
||||||
});
|
});
|
||||||
for (const c of convs) txn(c.id);
|
|
||||||
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
|
|
||||||
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
|
|
||||||
d.query("DELETE FROM users WHERE id = ?").run(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- sessions ---
|
// --- sessions ---
|
||||||
|
|
||||||
export function createSession(tokenHash: string, userId: string, expiresAt: number): void {
|
export async function createSession(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
getDb().query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(tokenHash, userId, now, expiresAt);
|
await db().run(
|
||||||
|
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
|
tokenHash, userId, now, expiresAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSession(tokenHash: string): SessionRow | null {
|
export async function getSession(tokenHash: string): Promise<SessionRow | null> {
|
||||||
const d = getDb();
|
const s = await db().get<SessionRow>("SELECT * FROM sessions WHERE token_hash = ?", tokenHash);
|
||||||
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
|
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
if (s.expires_at <= Date.now()) {
|
if (s.expires_at <= Date.now()) {
|
||||||
d.query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
|
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSession(tokenHash: string): void {
|
export async function deleteSession(tokenHash: string): Promise<void> {
|
||||||
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
|
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUserSessions(userId: string): void {
|
export async function deleteUserSessions(userId: string): Promise<void> {
|
||||||
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
await db().run("DELETE FROM sessions WHERE user_id = ?", userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- conversations ---
|
// --- conversations ---
|
||||||
|
|
||||||
export function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): ConversationRow {
|
export async function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): Promise<ConversationRow> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const row: ConversationRow = {
|
const row: ConversationRow = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -252,69 +179,74 @@ export function createConversation(userId: string, opts: { title?: string; topic
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
getDb().query(
|
await db().run(
|
||||||
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"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);
|
row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at,
|
||||||
|
);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listConversations(userId: string): ConversationRow[] {
|
export async function listConversations(userId: string): Promise<ConversationRow[]> {
|
||||||
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
return db().all<ConversationRow>(
|
||||||
|
"SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC", userId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Archived chats: hidden from the sidebar, managed in settings. */
|
/** Archived chats: hidden from the sidebar, managed in settings. */
|
||||||
export function listArchivedConversations(userId: string): ConversationRow[] {
|
export async function listArchivedConversations(userId: string): Promise<ConversationRow[]> {
|
||||||
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
return db().all<ConversationRow>(
|
||||||
|
"SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC", userId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (Un)archive a conversation. archived_at is left out of updated_at on
|
* (Un)archive a conversation. archived_at is left out of updated_at on
|
||||||
* purpose so an unarchived chat returns to its chronological place.
|
* purpose so an unarchived chat returns to its chronological place.
|
||||||
*/
|
*/
|
||||||
export function setConversationArchived(id: string, archived: boolean): ConversationRow | null {
|
export async function setConversationArchived(id: string, archived: boolean): Promise<ConversationRow | null> {
|
||||||
getDb().query("UPDATE conversations SET archived_at = ? WHERE id = ?").run(archived ? Date.now() : 0, id);
|
await db().run("UPDATE conversations SET archived_at = ? WHERE id = ?", archived ? Date.now() : 0, id);
|
||||||
return getConversation(id);
|
return getConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConversation(id: string): ConversationRow | null {
|
export async function getConversation(id: string): Promise<ConversationRow | null> {
|
||||||
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null;
|
return db().get<ConversationRow>("SELECT * FROM conversations WHERE id = ?", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): ConversationRow | null {
|
export async function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): Promise<ConversationRow | null> {
|
||||||
const d = getDb();
|
|
||||||
const sets: string[] = ["updated_at = ?"];
|
const sets: string[] = ["updated_at = ?"];
|
||||||
const vals: (string | number)[] = [Date.now()];
|
const vals: (string | number)[] = [Date.now()];
|
||||||
if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); }
|
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.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)); }
|
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);
|
await db().run(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
|
||||||
return getConversation(id);
|
return getConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function touchConversation(id: string, model?: string): void {
|
export async function touchConversation(id: string, model?: string): Promise<void> {
|
||||||
if (model) {
|
if (model) {
|
||||||
getDb().query("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?").run(Date.now(), model.slice(0, 160), id);
|
await db().run("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?", Date.now(), model.slice(0, 160), id);
|
||||||
} else {
|
} else {
|
||||||
getDb().query("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id);
|
await db().run("UPDATE conversations SET updated_at = ? WHERE id = ?", Date.now(), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteConversation(id: string): void {
|
export async function deleteConversation(id: string): Promise<void> {
|
||||||
const d = getDb();
|
await db().transaction(async (tx) => {
|
||||||
const txn = d.transaction(() => {
|
await tx.run("DELETE FROM messages WHERE conv_id = ?", id);
|
||||||
d.query("DELETE FROM messages WHERE conv_id = ?").run(id);
|
await tx.run("DELETE FROM shares WHERE conv_id = ?", id);
|
||||||
d.query("DELETE FROM shares WHERE conv_id = ?").run(id);
|
await tx.run("DELETE FROM conversations WHERE id = ?", id);
|
||||||
d.query("DELETE FROM conversations WHERE id = ?").run(id);
|
|
||||||
});
|
});
|
||||||
txn();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addMessage(convId: string, role: string, content: string): void {
|
export async function addMessage(convId: string, role: string, content: string, model = ""): Promise<void> {
|
||||||
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now());
|
await db().run(
|
||||||
|
"INSERT INTO messages (conv_id, role, content, model, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
convId, role, content, model.slice(0, 160), Date.now(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessages(convId: string): MessageRow[] {
|
export async function getMessages(convId: string): Promise<MessageRow[]> {
|
||||||
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
|
return db().all<MessageRow>("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500", convId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- password-recovery resets (one-time, hashed, expiring) ---
|
// --- password-recovery resets (one-time, hashed, expiring) ---
|
||||||
@@ -326,71 +258,85 @@ export interface ResetRow {
|
|||||||
expires_at: number;
|
expires_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
|
export async function createReset(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
|
||||||
const d = getDb();
|
|
||||||
// One active reset per account: replace any previous one.
|
// One active reset per account: replace any previous one.
|
||||||
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
|
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
|
||||||
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
|
await db().run(
|
||||||
|
"INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
tokenHash, userId, Date.now(), expiresAt,
|
tokenHash, userId, Date.now(), expiresAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function peekReset(tokenHash: string): ResetRow | null {
|
export async function peekReset(tokenHash: string): Promise<ResetRow | null> {
|
||||||
const d = getDb();
|
const r = await db().get<ResetRow>("SELECT * FROM resets WHERE token_hash = ?", tokenHash);
|
||||||
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
|
|
||||||
if (!r) return null;
|
if (!r) return null;
|
||||||
if (r.expires_at <= Date.now()) {
|
if (r.expires_at <= Date.now()) {
|
||||||
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
|
await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
|
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
|
||||||
export function consumeReset(tokenHash: string): ResetRow | null {
|
export async function consumeReset(tokenHash: string): Promise<ResetRow | null> {
|
||||||
const r = peekReset(tokenHash);
|
const r = await peekReset(tokenHash);
|
||||||
if (!r) return null;
|
if (!r) return null;
|
||||||
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
|
await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUserResets(userId: string): void {
|
export async function deleteUserResets(userId: string): Promise<void> {
|
||||||
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
|
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- platform settings (admin-controlled feature flags) ---
|
// --- platform settings (admin-controlled feature flags) ---
|
||||||
|
|
||||||
export function getSetting(key: string, fallback: string): string {
|
export async function getSetting(key: string, fallback: string): Promise<string> {
|
||||||
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
|
const r = await db().get<{ value: string }>("SELECT value FROM settings WHERE key = ?", key);
|
||||||
return r?.value ?? fallback;
|
return r?.value ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSetting(key: string, value: string): void {
|
export async function setSetting(key: string, value: string): Promise<void> {
|
||||||
getDb().query(
|
await db().run(
|
||||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||||
).run(key, value, Date.now());
|
key, value, Date.now(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public self-registration switch (default off = admin-created accounts only). */
|
/** Public self-registration switch (default off = admin-created accounts only). */
|
||||||
export function isRegistrationEnabled(): boolean {
|
export async function isRegistrationEnabled(): Promise<boolean> {
|
||||||
return getSetting("registration_enabled", "0") === "1";
|
return (await getSetting("registration_enabled", "0")) === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin kill-switch for the OCR platform tool (default on). */
|
/** Admin kill-switch for the OCR platform tool (default on). */
|
||||||
export function isOcrToolEnabled(): boolean {
|
export async function isOcrToolEnabled(): Promise<boolean> {
|
||||||
return getSetting("tools_ocr_enabled", "1") === "1";
|
return (await getSetting("tools_ocr_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for the neural TTS platform tool (default on). */
|
||||||
|
export async function isTtsToolEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("tools_tts_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for message reports / flag button (default on). */
|
||||||
|
export async function isReportsEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("reports_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for self-service username changes (default on; admins bypass). */
|
||||||
|
export async function isUsernameChangeEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("username_change_enabled", "1")) === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- public shares ---
|
// --- public shares ---
|
||||||
|
|
||||||
export function ensureShare(convId: string): string {
|
export async function ensureShare(convId: string): Promise<string> {
|
||||||
const d = getDb();
|
const existing = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
|
||||||
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;
|
if (existing) return existing.public_id;
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const publicId = randomBytes(9).toString("base64url");
|
const publicId = randomBytes(9).toString("base64url");
|
||||||
try {
|
try {
|
||||||
d.query("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)").run(convId, publicId, Date.now());
|
await db().run("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)", convId, publicId, Date.now());
|
||||||
return publicId;
|
return publicId;
|
||||||
} catch {
|
} catch {
|
||||||
// collision — retry
|
// collision — retry
|
||||||
@@ -399,25 +345,24 @@ export function ensureShare(convId: string): string {
|
|||||||
throw new Error("could not generate share id");
|
throw new Error("could not generate share id");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShareByConv(convId: string): string | null {
|
export async function getShareByConv(convId: string): Promise<string | null> {
|
||||||
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
|
const r = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
|
||||||
return r?.public_id ?? null;
|
return r?.public_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteShare(convId: string): void {
|
export async function deleteShare(convId: string): Promise<void> {
|
||||||
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
|
await db().run("DELETE FROM shares WHERE conv_id = ?", convId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null {
|
export async function getShareByPublic(publicId: string): Promise<{ conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null> {
|
||||||
const d = getDb();
|
const s = await db().get<{ conv_id: string; created_at: number }>("SELECT * FROM shares WHERE public_id = ?", publicId);
|
||||||
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
|
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const conv = getConversation(s.conv_id);
|
const conv = await getConversation(s.conv_id);
|
||||||
if (!conv) return null;
|
if (!conv) return null;
|
||||||
const author = getUserById(conv.user_id);
|
const author = await getUserById(conv.user_id);
|
||||||
return {
|
return {
|
||||||
conv,
|
conv,
|
||||||
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
|
messages: (await getMessages(conv.id)).filter((m) => m.role !== "system"),
|
||||||
authorName: author?.display_name || "Someone",
|
authorName: author?.display_name || "Someone",
|
||||||
authorAvatarUrl: author?.avatar_url || "",
|
authorAvatarUrl: author?.avatar_url || "",
|
||||||
archived: conv.archived_at !== 0,
|
archived: conv.archived_at !== 0,
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { SQL } from "bun";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database driver abstraction (SQLite default, Postgres opt-in).
|
||||||
|
*
|
||||||
|
* - SQLite: Bun's native `bun:sqlite` (synchronous underneath, single file
|
||||||
|
* in DATA_DIR). Zero setup, the historical default.
|
||||||
|
* - Postgres: Bun's native `bun:sql` driver (`new SQL(url)`), typically a
|
||||||
|
* docker container pointed at by POSTGRESQL_URL.
|
||||||
|
*
|
||||||
|
* All queries in the codebase use `?` placeholders (SQLite style); the
|
||||||
|
* Postgres client rewrites them to `$1, $2, …` (quote/comment aware).
|
||||||
|
* Postgres returns BIGINT cells as JS `bigint` — the client normalizes them
|
||||||
|
* to `number` (every integer column fits: ms timestamps, counts, ids).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DbKind = "sqlite" | "postgres";
|
||||||
|
export type SqlParam = string | number | bigint | null;
|
||||||
|
export type SqlParams = SqlParam[];
|
||||||
|
|
||||||
|
export interface DbRunResult {
|
||||||
|
changes: number;
|
||||||
|
lastInsertId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbTx {
|
||||||
|
get<T>(sql: string, ...params: SqlParams): Promise<T | null>;
|
||||||
|
all<T>(sql: string, ...params: SqlParams): Promise<T[]>;
|
||||||
|
run(sql: string, ...params: SqlParams): Promise<DbRunResult>;
|
||||||
|
exec(sql: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbClient extends DbTx {
|
||||||
|
readonly kind: DbKind;
|
||||||
|
/** INSERT a single row and get its auto id back (RETURNING on pg). */
|
||||||
|
insertReturningId(sql: string, ...params: SqlParams): Promise<number>;
|
||||||
|
transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite `?` placeholders to `$n` for Postgres (skips quoted literals). */
|
||||||
|
export function sqliteToPgPlaceholders(sql: string): string {
|
||||||
|
let out = "";
|
||||||
|
let n = 0;
|
||||||
|
let i = 0;
|
||||||
|
const len = sql.length;
|
||||||
|
while (i < len) {
|
||||||
|
const c = sql[i]!;
|
||||||
|
// Single-quoted string literal ('' = escaped quote).
|
||||||
|
if (c === "'") {
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
while (i < len) {
|
||||||
|
out += sql[i]!;
|
||||||
|
if (sql[i] === "'") {
|
||||||
|
if (sql[i + 1] === "'") { out += sql[i + 1]!; i += 2; continue; }
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Double-quoted identifier ("..." with "" escape).
|
||||||
|
if (c === '"') {
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
while (i < len) {
|
||||||
|
out += sql[i]!;
|
||||||
|
if (sql[i] === '"') {
|
||||||
|
if (sql[i + 1] === '"') { out += sql[i + 1]!; i += 2; continue; }
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Line comment.
|
||||||
|
if (c === "-" && sql[i + 1] === "-") {
|
||||||
|
while (i < len && sql[i] !== "\n") out += sql[i++]!;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Block comment.
|
||||||
|
if (c === "/" && sql[i + 1] === "*") {
|
||||||
|
out += "/*";
|
||||||
|
i += 2;
|
||||||
|
while (i < len && !(sql[i] === "*" && sql[i + 1] === "/")) out += sql[i++]!;
|
||||||
|
out += "*/";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "?") {
|
||||||
|
n++;
|
||||||
|
out += `$${n}`;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively normalize driver row values (bigint -> number). */
|
||||||
|
export function normalizeValue<T>(v: T): T {
|
||||||
|
if (typeof v === "bigint") return Number(v) as T;
|
||||||
|
if (Array.isArray(v)) return v.map(normalizeValue) as T;
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
for (const k of Object.keys(o)) o[k] = normalizeValue(o[k]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redacted one-liner for logs (never print credentials). */
|
||||||
|
export function redactedUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return `${u.protocol}//${u.host}${u.pathname}`;
|
||||||
|
} catch {
|
||||||
|
return "(unparseable url)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SQLite implementation (bun:sqlite, sync underneath) ---
|
||||||
|
|
||||||
|
class SqliteTx implements DbTx {
|
||||||
|
constructor(private d: Database) {}
|
||||||
|
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
|
||||||
|
return (this.d.query(sql).get(...params) as T | null) ?? null;
|
||||||
|
}
|
||||||
|
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
|
||||||
|
return this.d.query(sql).all(...params) as T[];
|
||||||
|
}
|
||||||
|
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
|
||||||
|
const r = this.d.query(sql).run(...params) as { changes: number; lastInsertRowid: number | bigint };
|
||||||
|
return { changes: Number(r.changes), lastInsertId: Number(r.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
async exec(sql: string): Promise<void> {
|
||||||
|
this.d.exec(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SqliteClient extends SqliteTx implements DbClient {
|
||||||
|
readonly kind: DbKind = "sqlite";
|
||||||
|
constructor(private db: Database) {
|
||||||
|
super(db);
|
||||||
|
}
|
||||||
|
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
|
||||||
|
const r = await this.run(sql, ...params);
|
||||||
|
return r.lastInsertId ?? 0;
|
||||||
|
}
|
||||||
|
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
|
||||||
|
// Manual BEGIN/COMMIT (bun:sqlite's transaction() only takes sync
|
||||||
|
// callbacks, so it can't wrap our async bodies — this is equivalent).
|
||||||
|
this.db.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const out = await fn(this);
|
||||||
|
this.db.exec("COMMIT");
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
this.db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// already rolled back / never began — report the original error
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Postgres implementation (Bun native bun:sql) ---
|
||||||
|
|
||||||
|
type PgRunner = {
|
||||||
|
unsafe: (sql: string, params?: SqlParams) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PgTx implements DbTx {
|
||||||
|
constructor(private runner: PgRunner) {}
|
||||||
|
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
|
||||||
|
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
|
||||||
|
return rows.length > 0 ? normalizeValue(rows[0]!) : null;
|
||||||
|
}
|
||||||
|
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
|
||||||
|
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
|
||||||
|
return normalizeValue(rows);
|
||||||
|
}
|
||||||
|
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
|
||||||
|
const res = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as { count?: unknown } | null;
|
||||||
|
const changes = typeof res?.count === "number" ? res.count : 0;
|
||||||
|
return { changes, lastInsertId: null };
|
||||||
|
}
|
||||||
|
async exec(sql: string): Promise<void> {
|
||||||
|
await this.runner.unsafe(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PgClient extends PgTx implements DbClient {
|
||||||
|
readonly kind: DbKind = "postgres";
|
||||||
|
constructor(private sql: SQL) {
|
||||||
|
super(sql as unknown as PgRunner);
|
||||||
|
}
|
||||||
|
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
|
||||||
|
const row = await this.get<{ id: number | bigint }>(`${sql} RETURNING id`, ...params);
|
||||||
|
return row ? Number(row.id) : 0;
|
||||||
|
}
|
||||||
|
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
|
||||||
|
return this.sql.begin(async (tx) => fn(new PgTx(tx as unknown as PgRunner)));
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.sql.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- singleton ---
|
||||||
|
|
||||||
|
let client: DbClient | null = null;
|
||||||
|
|
||||||
|
export function db(): DbClient {
|
||||||
|
if (!client) throw new Error("database not initialized — call initDb() first");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbKind(): DbKind {
|
||||||
|
return client?.kind ?? (config.postgresqlUrl ? "postgres" : "sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect + create schema idempotently. Fails fast when unreachable. */
|
||||||
|
export async function initDb(): Promise<DbClient> {
|
||||||
|
if (client) return client;
|
||||||
|
const url = config.postgresqlUrl.trim();
|
||||||
|
if (url) {
|
||||||
|
if (!/^postgres(ql)?:\/\//i.test(url)) {
|
||||||
|
throw new Error(`POSTGRESQL_URL must start with postgres:// or postgresql:// (got ${redactedUrl(url)})`);
|
||||||
|
}
|
||||||
|
const sql = new SQL(url);
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1 AS ok`;
|
||||||
|
} catch (e) {
|
||||||
|
await sql.close().catch(() => {});
|
||||||
|
throw new Error(`cannot reach Postgres at ${redactedUrl(url)}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
client = new PgClient(sql);
|
||||||
|
await initPgSchema(client);
|
||||||
|
console.log(`[db] using postgres (${redactedUrl(url)})`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
const dbo = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
|
||||||
|
dbo.exec("PRAGMA journal_mode = WAL;");
|
||||||
|
dbo.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
client = new SqliteClient(dbo);
|
||||||
|
await initSqliteSchema(client);
|
||||||
|
console.log(`[db] using sqlite (${join(config.dataDir, "kisassistant.db")})`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- core schema (users/sessions/conversations/messages/shares/resets/settings/providers) ---
|
||||||
|
|
||||||
|
const CORE_TABLES_SQLITE = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
|
theme TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
key_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT 'New chat',
|
||||||
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
|
||||||
|
CREATE TABLE IF NOT EXISTS shares(
|
||||||
|
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
public_id TEXT UNIQUE NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS resets(
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings(
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_provider_keys(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, provider)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CORE_TABLES_PG = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
|
theme TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
key_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT 'New chat',
|
||||||
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages(
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
|
||||||
|
CREATE TABLE IF NOT EXISTS shares(
|
||||||
|
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
public_id TEXT UNIQUE NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS resets(
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings(
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_provider_keys(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, provider)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Columns added after the initial schema — idempotent on both drivers. */
|
||||||
|
const LATER_COLUMNS = [
|
||||||
|
"ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Split a multi-statement DDL string into single statements. */
|
||||||
|
function splitStatements(ddl: string): string[] {
|
||||||
|
return ddl
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminRow(c: DbClient): Promise<void> {
|
||||||
|
const admin = await c.get<{ id: string }>("SELECT id FROM users WHERE username = 'admin'");
|
||||||
|
if (!admin) {
|
||||||
|
const { randomUUID } = await import("node:crypto");
|
||||||
|
await c.run(
|
||||||
|
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
randomUUID(), "admin", "Administrator", "", "auto", "", Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSqliteSchema(c: DbClient): Promise<void> {
|
||||||
|
await c.exec(CORE_TABLES_SQLITE);
|
||||||
|
// Column guards via PRAGMA (kept from the single-driver era).
|
||||||
|
const cols = await c.all<{ name: string }>("PRAGMA table_info(users)");
|
||||||
|
const names = new Set(cols.map((col) => col.name));
|
||||||
|
if (!names.has("email")) await c.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
|
||||||
|
if (!names.has("totp_secret")) await c.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
|
||||||
|
if (!names.has("report_shadowbanned")) await c.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
|
||||||
|
const convCols = await c.all<{ name: string }>("PRAGMA table_info(conversations)");
|
||||||
|
if (!convCols.some((col) => col.name === "archived_at")) {
|
||||||
|
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
const msgCols = await c.all<{ name: string }>("PRAGMA table_info(messages)");
|
||||||
|
if (!msgCols.some((col) => col.name === "model")) {
|
||||||
|
await c.exec("ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
await ensureAdminRow(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPgSchema(c: DbClient): Promise<void> {
|
||||||
|
for (const stmt of splitStatements(CORE_TABLES_PG)) await c.exec(stmt);
|
||||||
|
// ADD COLUMN IF NOT EXISTS is natively idempotent on Postgres.
|
||||||
|
for (const ddl of LATER_COLUMNS) {
|
||||||
|
const m = ddl.match(/^ALTER TABLE (\S+) ADD COLUMN (\S+) (.+)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
await c.exec(`ALTER TABLE ${m[1]} ADD COLUMN IF NOT EXISTS ${m[2]} ${m[3]}`);
|
||||||
|
}
|
||||||
|
await ensureAdminRow(c);
|
||||||
|
}
|
||||||
+45
-12
@@ -1,14 +1,25 @@
|
|||||||
import { config } from "../config.ts";
|
import { config } from "../config.ts";
|
||||||
import { ApiDriverBase } from "./apiBase.ts";
|
import { ApiDriverBase } from "./apiBase.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete API drivers read their key from the server env by default.
|
||||||
|
* Pass `apiKeyOverride` to build a per-user (BYOK) instance instead: the
|
||||||
|
* driver is then enabled whenever the override key is non-empty, ignoring
|
||||||
|
* the global `*_ENABLED` flag. See `src/userProviders.ts`.
|
||||||
|
*/
|
||||||
|
function globalEnabled(flag: boolean, key: string): boolean {
|
||||||
|
return flag && key.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Mistral API driver. Global priority #2 (after local ollama). */
|
/** Mistral API driver. Global priority #2 (after local ollama). */
|
||||||
export class MistralDriver extends ApiDriverBase {
|
export class MistralDriver extends ApiDriverBase {
|
||||||
readonly name = "mistral";
|
readonly name = "mistral";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.mistralApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.mistral.ai/v1",
|
baseUrl: "https://api.mistral.ai/v1",
|
||||||
apiKey: config.mistralApiKey,
|
apiKey,
|
||||||
enabled: config.mistralEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.mistralEnabled, apiKey),
|
||||||
defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"],
|
defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -17,11 +28,12 @@ export class MistralDriver extends ApiDriverBase {
|
|||||||
/** DeepSeek API driver. Priority within APIs: DeepSeek first. */
|
/** DeepSeek API driver. Priority within APIs: DeepSeek first. */
|
||||||
export class DeepSeekDriver extends ApiDriverBase {
|
export class DeepSeekDriver extends ApiDriverBase {
|
||||||
readonly name = "deepseek";
|
readonly name = "deepseek";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.deepseekApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.deepseek.com/v1",
|
baseUrl: "https://api.deepseek.com/v1",
|
||||||
apiKey: config.deepseekApiKey,
|
apiKey,
|
||||||
enabled: config.deepseekEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.deepseekEnabled, apiKey),
|
||||||
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
|
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -30,11 +42,12 @@ export class DeepSeekDriver extends ApiDriverBase {
|
|||||||
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
|
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
|
||||||
export class AnthropicDriver extends ApiDriverBase {
|
export class AnthropicDriver extends ApiDriverBase {
|
||||||
readonly name = "anthropic";
|
readonly name = "anthropic";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.anthropicApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.anthropic.com/v1",
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
apiKey: config.anthropicApiKey,
|
apiKey,
|
||||||
enabled: config.anthropicEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.anthropicEnabled, apiKey),
|
||||||
defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"],
|
defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,16 +56,36 @@ export class AnthropicDriver extends ApiDriverBase {
|
|||||||
/** OpenAI API driver. Priority: last among APIs. */
|
/** OpenAI API driver. Priority: last among APIs. */
|
||||||
export class OpenAIDriver extends ApiDriverBase {
|
export class OpenAIDriver extends ApiDriverBase {
|
||||||
readonly name = "openai";
|
readonly name = "openai";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.openaiApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.openai.com/v1",
|
baseUrl: "https://api.openai.com/v1",
|
||||||
apiKey: config.openaiApiKey,
|
apiKey,
|
||||||
enabled: config.openaiEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.openaiEnabled, apiKey),
|
||||||
defaultModels: ["gpt-4o", "gpt-4o-mini"],
|
defaultModels: ["gpt-4o", "gpt-4o-mini"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini driver (Google AI, OpenAI-compatible endpoint). Priority: after GLM.
|
||||||
|
* Docs: https://ai.google.dev/gemini-api/docs/openai — key from
|
||||||
|
* https://aistudio.google.com/apikey (server: GEMINI_API_KEY, or per-user
|
||||||
|
* BYOK in settings).
|
||||||
|
*/
|
||||||
|
export class GeminiDriver extends ApiDriverBase {
|
||||||
|
readonly name = "gemini";
|
||||||
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.geminiApiKey;
|
||||||
|
super({
|
||||||
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
apiKey,
|
||||||
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.geminiEnabled, apiKey),
|
||||||
|
defaultModels: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GLM driver (Zhipu AI, OpenAI-compatible endpoint). Priority: after Mistral.
|
* GLM driver (Zhipu AI, OpenAI-compatible endpoint). Priority: after Mistral.
|
||||||
* Docs: https://docs.z.ai — international base is api.z.ai, mainland China
|
* Docs: https://docs.z.ai — international base is api.z.ai, mainland China
|
||||||
|
|||||||
+99
-23
@@ -1,12 +1,28 @@
|
|||||||
import { config } from "../config.ts";
|
import { config } from "../config.ts";
|
||||||
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
||||||
|
import {
|
||||||
|
effectiveOllamaNodes,
|
||||||
|
getOllamaTimeoutMs,
|
||||||
|
pickOllamaRoute,
|
||||||
|
recordOllamaStat,
|
||||||
|
type EffectiveNode,
|
||||||
|
} from "../ollamaNodes.ts";
|
||||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||||
|
|
||||||
interface OllamaTag {
|
interface OllamaTag {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local LLM via Ollama. Priority #1. */
|
interface OllamaDone {
|
||||||
|
done?: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: { content?: string };
|
||||||
|
prompt_eval_count?: number;
|
||||||
|
eval_count?: number;
|
||||||
|
eval_duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local LLM via Ollama (multi-node pool). Priority #1. */
|
||||||
export class OllamaDriver implements LLMDriver {
|
export class OllamaDriver implements LLMDriver {
|
||||||
readonly name = "ollama";
|
readonly name = "ollama";
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
@@ -16,22 +32,31 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Model list merged across every enabled node (deduped by name). */
|
||||||
async listModels(): Promise<DriverModel[]> {
|
async listModels(): Promise<DriverModel[]> {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
const nodes = await effectiveOllamaNodes();
|
||||||
if (!res.ok) {
|
if (nodes.length === 0) throw new Error("no ollama node available (all disabled or weight 0)");
|
||||||
driverLog(this.name, `listModels ERROR: /api/tags -> ${res.status} (${fmtMs(Date.now() - t0)})`);
|
const seen = new Map<string, DriverModel>();
|
||||||
throw new Error(`ollama /api/tags failed: ${res.status}`);
|
const errors: string[] = [];
|
||||||
}
|
await Promise.all(nodes.map(async (n) => {
|
||||||
const data = (await res.json()) as { models?: OllamaTag[] };
|
try {
|
||||||
const models = data.models ?? [];
|
const res = await fetch(`${n.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
||||||
driverLog(this.name, `listModels -> ${models.length} model(s) [${models.map((m) => m.name).join(", ")}] (${fmtMs(Date.now() - t0)})`);
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
return models.map((m) => ({
|
const data = (await res.json()) as { models?: OllamaTag[] };
|
||||||
id: `ollama:${m.name}`,
|
for (const m of data.models ?? []) {
|
||||||
name: m.name,
|
if (typeof m.name === "string" && !seen.has(m.name)) {
|
||||||
driver: "ollama",
|
seen.set(m.name, { id: `ollama:${m.name}`, name: m.name, driver: "ollama", label: m.name });
|
||||||
label: m.name,
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${n.name}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
const models = [...seen.values()];
|
||||||
|
driverLog(this.name, `listModels -> ${models.length} model(s) across ${nodes.length} node(s) (${fmtMs(Date.now() - t0)})${errors.length > 0 ? ` [skipped: ${errors.join("; ")}]` : ""}`);
|
||||||
|
if (models.length === 0 && errors.length > 0) throw new Error(`ollama listModels failed: ${errors.join("; ")}`);
|
||||||
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||||
@@ -41,17 +66,56 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
||||||
|
const timeoutMs = await getOllamaTimeoutMs();
|
||||||
|
const { order } = await pickOllamaRoute();
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (const node of order) {
|
||||||
|
// Failover only while nothing was produced yet: once tokens reached
|
||||||
|
// the client, restarting on another node would duplicate the reply.
|
||||||
|
let produced = false;
|
||||||
|
const counting: ChatOptions = {
|
||||||
|
...opts,
|
||||||
|
onToken: (t) => {
|
||||||
|
produced = true;
|
||||||
|
opts.onToken?.(t);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
yield* this.streamFromNode(node, messages, counting, timeoutMs);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
// Caller hangup: never fail over (nobody is listening anymore).
|
||||||
|
if (opts.signal?.aborted) throw e;
|
||||||
|
if (produced) throw e;
|
||||||
|
lastError = e as Error;
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node ${node.name} failed, failing over: ${lastError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error("ollama chat failed on every node");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One streaming attempt against a single node (throws on any failure). */
|
||||||
|
private async *streamFromNode(
|
||||||
|
node: EffectiveNode,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
opts: ChatOptions,
|
||||||
|
timeoutMs: number,
|
||||||
|
): AsyncGenerator<string, void, void> {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} stream=open`);
|
||||||
const res = await fetch(`${this.host}/api/chat`, {
|
const signals = [AbortSignal.timeout(timeoutMs)];
|
||||||
|
if (opts.signal) signals.push(opts.signal);
|
||||||
|
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any(signals) : signals[0]!;
|
||||||
|
const res = await fetch(`${node.host}/api/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
||||||
signal: opts.signal,
|
signal,
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
driverLog(this.name, `chat model=${opts.model} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: 0, eval_tokens: 0, model: opts.model });
|
||||||
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
|
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
@@ -59,6 +123,8 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
let buf = "";
|
let buf = "";
|
||||||
let chunks = 0;
|
let chunks = 0;
|
||||||
let chars = 0;
|
let chars = 0;
|
||||||
|
let promptTokens = 0;
|
||||||
|
let evalTokens = 0;
|
||||||
let status = "done";
|
let status = "done";
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -71,7 +137,7 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(trimmed) as { message?: { content?: string }; error?: string; done?: boolean };
|
const obj = JSON.parse(trimmed) as OllamaDone;
|
||||||
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
|
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
|
||||||
const token = obj.message?.content ?? "";
|
const token = obj.message?.content ?? "";
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -80,8 +146,11 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
opts.onToken?.(token);
|
opts.onToken?.(token);
|
||||||
yield token;
|
yield token;
|
||||||
}
|
}
|
||||||
|
if (typeof obj.prompt_eval_count === "number") promptTokens = obj.prompt_eval_count;
|
||||||
|
if (typeof obj.eval_count === "number") evalTokens = obj.eval_count;
|
||||||
if (obj.done) {
|
if (obj.done) {
|
||||||
driverLog(this.name, `chat model=${opts.model} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -90,9 +159,16 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
driverLog(this.name, `chat model=${opts.model} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
// Truncated stream (no done:true): still a usable partial reply.
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status = (e as Error).name === "AbortError" ? "aborted-by-caller" : `ERROR: ${(e as Error).message}`;
|
const name = (e as Error).name;
|
||||||
|
status = name === "AbortError" ? "aborted-by-caller" : name === "TimeoutError" ? "timeout" : `ERROR: ${(e as Error).message}`;
|
||||||
|
// Caller aborts are not node failures — don't pollute telemetry.
|
||||||
|
if (name !== "AbortError" || !opts.signal?.aborted) {
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
// Stop the upstream generation when the consumer goes away
|
// Stop the upstream generation when the consumer goes away
|
||||||
@@ -104,7 +180,7 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
}
|
}
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
if (status !== "done") {
|
if (status !== "done") {
|
||||||
driverLog(this.name, `chat model=${opts.model} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
||||||
import { OllamaDriver } from "./ollama.ts";
|
import { OllamaDriver } from "./ollama.ts";
|
||||||
import { createArcaicDrivers } from "./arcaic.ts";
|
import { createArcaicDrivers } from "./arcaic.ts";
|
||||||
import type { DriverModel, LLMDriver } from "./types.ts";
|
import type { DriverModel, LLMDriver } from "./types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global driver priority (per spec):
|
* Global driver priority (per spec):
|
||||||
* 1. ollama (local) -> 2. mistral -> 3. glm
|
* 1. ollama (local) -> 2. mistral -> 3. glm -> 4. gemini
|
||||||
* -> 4. arcaic-openai -> 5. arcaic-gemini -> 6. arcaic-qwen
|
* -> 5. arcaic-openai -> 6. arcaic-gemini -> 7. arcaic-qwen
|
||||||
* -> 7. deepseek -> 8. anthropic -> 9. openai
|
* -> 8. deepseek -> 9. anthropic -> 10. openai
|
||||||
*/
|
*/
|
||||||
const PRIORITY = [
|
const PRIORITY = [
|
||||||
"ollama",
|
"ollama",
|
||||||
"mistral",
|
"mistral",
|
||||||
"glm",
|
"glm",
|
||||||
|
"gemini",
|
||||||
"arcaic-openai",
|
"arcaic-openai",
|
||||||
"arcaic-gemini",
|
"arcaic-gemini",
|
||||||
"arcaic-qwen",
|
"arcaic-qwen",
|
||||||
@@ -37,6 +38,7 @@ export class DriverRegistry {
|
|||||||
new OllamaDriver(),
|
new OllamaDriver(),
|
||||||
new MistralDriver(),
|
new MistralDriver(),
|
||||||
new GlmDriver(),
|
new GlmDriver(),
|
||||||
|
new GeminiDriver(),
|
||||||
...createArcaicDrivers(),
|
...createArcaicDrivers(),
|
||||||
new DeepSeekDriver(),
|
new DeepSeekDriver(),
|
||||||
new AnthropicDriver(),
|
new AnthropicDriver(),
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface DriverModel {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Display category in the model picker (defaults to `driver`). */
|
/** Display category in the model picker (defaults to `driver`). */
|
||||||
group?: string;
|
group?: string;
|
||||||
|
/** True when served through the caller's own provider key (BYOK) rather
|
||||||
|
* than the platform key — the UI lists these in their own section. */
|
||||||
|
personal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
|
|||||||
+832
-136
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
import { db, getSetting, setSetting } from "./db.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-model access policy (admin kill-switch + per-user rate limits).
|
||||||
|
* Stored as one JSON blob in the `settings` table (`model_policy` key);
|
||||||
|
* usage counters live in `model_usage` (hourly + daily fixed windows).
|
||||||
|
*
|
||||||
|
* Policy is global, not per-user; admins always bypass checks.
|
||||||
|
* Limits of 0 mean "unlimited". Models without an entry use the default
|
||||||
|
* (enabled, no limits); entries equal to the default are pruned.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelPolicyEntry {
|
||||||
|
enabled: boolean;
|
||||||
|
/** Max requests per UTC hour (0 = unlimited). */
|
||||||
|
hourly: number;
|
||||||
|
/** Max requests per UTC day (0 = unlimited). */
|
||||||
|
daily: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_POLICY: ModelPolicyEntry = { enabled: true, hourly: 0, daily: 0 };
|
||||||
|
export type ModelPolicy = Record<string, ModelPolicyEntry>;
|
||||||
|
|
||||||
|
const SETTINGS_KEY = "model_policy";
|
||||||
|
export const MAX_LIMIT = 1_000_000;
|
||||||
|
|
||||||
|
function numOrZero(v: unknown): number {
|
||||||
|
return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= MAX_LIMIT ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEntry(v: unknown): ModelPolicyEntry {
|
||||||
|
const o = (v ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
enabled: typeof o.enabled === "boolean" ? o.enabled : true,
|
||||||
|
hourly: numOrZero(o.hourly),
|
||||||
|
daily: numOrZero(o.daily),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefault(e: ModelPolicyEntry): boolean {
|
||||||
|
return e.enabled && e.hourly === 0 && e.daily === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelPolicy(): Promise<ModelPolicy> {
|
||||||
|
const raw = await getSetting(SETTINGS_KEY, "");
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (!obj || typeof obj !== "object") return {};
|
||||||
|
const out: ModelPolicy = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (k && k.length <= 200) out[k] = sanitizeEntry(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function policyFor(policy: ModelPolicy, modelId: string): ModelPolicyEntry {
|
||||||
|
return policy[modelId] ?? { ...DEFAULT_POLICY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setModelPolicyEntry(
|
||||||
|
modelId: string,
|
||||||
|
patch: { enabled?: boolean; hourly?: number; daily?: number },
|
||||||
|
): Promise<ModelPolicyEntry> {
|
||||||
|
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("BAD_POLICY");
|
||||||
|
if (patch.hourly !== undefined && !Number.isInteger(patch.hourly)) throw new Error("BAD_POLICY");
|
||||||
|
if (patch.daily !== undefined && !Number.isInteger(patch.daily)) throw new Error("BAD_POLICY");
|
||||||
|
const policy = await getModelPolicy();
|
||||||
|
const cur = policy[modelId] ?? { ...DEFAULT_POLICY };
|
||||||
|
const next: ModelPolicyEntry = {
|
||||||
|
enabled: patch.enabled ?? cur.enabled,
|
||||||
|
hourly: patch.hourly ?? cur.hourly,
|
||||||
|
daily: patch.daily ?? cur.daily,
|
||||||
|
};
|
||||||
|
if (next.hourly < 0 || next.hourly > MAX_LIMIT || next.daily < 0 || next.daily > MAX_LIMIT) {
|
||||||
|
throw new Error("BAD_POLICY");
|
||||||
|
}
|
||||||
|
if (isDefault(next)) delete policy[modelId];
|
||||||
|
else policy[modelId] = next;
|
||||||
|
await setSetting(SETTINGS_KEY, JSON.stringify(policy));
|
||||||
|
return { ...DEFAULT_POLICY, ...policy[modelId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initModelUsageTables(): Promise<void> {
|
||||||
|
// "window" is quoted: WINDOW is a reserved keyword on Postgres (and the
|
||||||
|
// quotes are harmless on SQLite).
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS model_usage (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
"window" TEXT NOT NULL,
|
||||||
|
bucket BIGINT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, model, "window", bucket)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buckets(now = Date.now()): { h: number; d: number } {
|
||||||
|
return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): Promise<number> {
|
||||||
|
const row = await db().get<{ n: number }>(
|
||||||
|
'SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND "window" = ? AND bucket = ?',
|
||||||
|
userId, modelId, window, bucket,
|
||||||
|
);
|
||||||
|
return row?.n ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
|
||||||
|
|
||||||
|
export async function checkModelAccess(userId: string, modelId: string): Promise<ModelAccess> {
|
||||||
|
const p = policyFor(await getModelPolicy(), modelId);
|
||||||
|
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
|
||||||
|
const { h, d } = buckets();
|
||||||
|
if (p.hourly > 0 && (await windowCount(userId, modelId, "h", h)) >= p.hourly) {
|
||||||
|
return { ok: false, code: 429, message: "hourly limit reached for this model, try again later" };
|
||||||
|
}
|
||||||
|
if (p.daily > 0 && (await windowCount(userId, modelId, "d", d)) >= p.daily) {
|
||||||
|
return { ok: false, code: 429, message: "daily limit reached for this model, try again tomorrow" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordModelUse(userId: string, modelId: string): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
const { h, d: day } = buckets();
|
||||||
|
await d.run(`INSERT INTO model_usage (user_id, model, "window", bucket, count) VALUES (?, ?, 'h', ?, 1)
|
||||||
|
ON CONFLICT(user_id, model, "window", bucket) DO UPDATE SET count = count + 1`, userId, modelId, h);
|
||||||
|
await d.run(`INSERT INTO model_usage (user_id, model, "window", bucket, count) VALUES (?, ?, 'd', ?, 1)
|
||||||
|
ON CONFLICT(user_id, model, "window", bucket) DO UPDATE SET count = count + 1`, userId, modelId, day);
|
||||||
|
await d.run('DELETE FROM model_usage WHERE ("window" = \'h\' AND bucket < ?) OR ("window" = \'d\' AND bucket < ?)', h - 1, day - 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { db, dbKind, getSetting } from "./db.ts";
|
||||||
|
import { config } from "./config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-node Ollama pool + generation telemetry.
|
||||||
|
*
|
||||||
|
* - `ollama_nodes`: admin-managed extra Ollama hosts. The env-configured
|
||||||
|
* host (`OLLAMA_HOST`) always exists as the implicit "default" node; its
|
||||||
|
* traffic share is `ollama_default_weight` (0 = never use it).
|
||||||
|
* - Routing is firewall-like: nodes are ordered by weight (highest first)
|
||||||
|
* and each generation picks a node by weighted random among the enabled
|
||||||
|
* ones, failing over to the next heaviest node on error/timeout.
|
||||||
|
* - `ollama_stats`: one row per generation attempt (node, ok, latency,
|
||||||
|
* token counts from Ollama's final chunk) powering the per-node detail
|
||||||
|
* charts. Rows older than 35 days are pruned on write.
|
||||||
|
*
|
||||||
|
* NOTE on host metrics: Ollama exposes no CPU/RAM API — only per-model
|
||||||
|
* VRAM via /api/ps (surfaced in the node detail view). Load-over-time
|
||||||
|
* charts come from app-side telemetry (requests, errors, latency,
|
||||||
|
* tokens/s), which is also what drives routing decisions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveNode {
|
||||||
|
/** "default" for the env host, otherwise the row id. */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_ID = "default";
|
||||||
|
const STATS_RETENTION_MS = 35 * 86400_000;
|
||||||
|
|
||||||
|
export async function initOllamaNodes(): Promise<void> {
|
||||||
|
const autoId = dbKind() === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS ollama_nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL UNIQUE,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 100,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS ollama_stats (
|
||||||
|
id ${autoId},
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
ts BIGINT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
eval_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT ''
|
||||||
|
)`);
|
||||||
|
await db().exec(`CREATE INDEX IF NOT EXISTS idx_ollama_stats_node_ts ON ollama_stats(node_id, ts)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToNode(r: Record<string, number | string>): OllamaNode {
|
||||||
|
return {
|
||||||
|
id: String(r.id),
|
||||||
|
name: String(r.name),
|
||||||
|
host: String(r.host),
|
||||||
|
weight: Number(r.weight),
|
||||||
|
enabled: Number(r.enabled) === 1,
|
||||||
|
created_at: Number(r.created_at),
|
||||||
|
updated_at: Number(r.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOllamaNodes(): Promise<OllamaNode[]> {
|
||||||
|
const rows = await db().all<Record<string, number | string>>(
|
||||||
|
"SELECT * FROM ollama_nodes ORDER BY weight DESC, created_at ASC",
|
||||||
|
);
|
||||||
|
return rows.map(rowToNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOllamaNode(id: string): Promise<OllamaNode | null> {
|
||||||
|
const row = await db().get<Record<string, number | string>>("SELECT * FROM ollama_nodes WHERE id = ?", id);
|
||||||
|
return row ? rowToNode(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newNodeId(): string {
|
||||||
|
return "node_" + randomBytes(9).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize + validate an Ollama base URL (scheme + host, no path). */
|
||||||
|
export function sanitizeNodeHost(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const t = raw.trim().replace(/\/+$/, "");
|
||||||
|
if (t.length < 10 || t.length > 200) return null;
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(t);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
if (!u.hostname || u.username || u.password) return null;
|
||||||
|
// Drop default ports and any path/query/fragment for canonical form.
|
||||||
|
const port = u.port && !((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443"))
|
||||||
|
? `:${u.port}`
|
||||||
|
: "";
|
||||||
|
if (u.pathname !== "/" && u.pathname !== "") return null;
|
||||||
|
return `${u.protocol}//${u.hostname}${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNodeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.length === 0 || t.length > 60) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeWeight(raw: unknown): number | null {
|
||||||
|
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw > 1000) return null;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOllamaNode(opts: { name: string; host: string; weight: number; enabled: boolean }): Promise<OllamaNode> {
|
||||||
|
const now = Date.now();
|
||||||
|
const row: OllamaNode = { id: newNodeId(), ...opts, created_at: now, updated_at: now };
|
||||||
|
try {
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO ollama_nodes (id, name, host, weight, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.id, row.name, row.host, row.weight, row.enabled ? 1 : 0, now, now,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOllamaNode(
|
||||||
|
id: string,
|
||||||
|
patch: { name?: string; host?: string; weight?: number; enabled?: boolean },
|
||||||
|
): Promise<OllamaNode | null> {
|
||||||
|
const cur = await getOllamaNode(id);
|
||||||
|
if (!cur) return null;
|
||||||
|
const next: OllamaNode = {
|
||||||
|
...cur,
|
||||||
|
name: patch.name ?? cur.name,
|
||||||
|
host: patch.host ?? cur.host,
|
||||||
|
weight: patch.weight ?? cur.weight,
|
||||||
|
enabled: patch.enabled ?? cur.enabled,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await db().run(
|
||||||
|
"UPDATE ollama_nodes SET name = ?, host = ?, weight = ?, enabled = ?, updated_at = ? WHERE id = ?",
|
||||||
|
next.name, next.host, next.weight, next.enabled ? 1 : 0, next.updated_at, id,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOllamaNode(id: string): Promise<boolean> {
|
||||||
|
const r = await db().run("DELETE FROM ollama_nodes WHERE id = ?", id);
|
||||||
|
return r.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- effective pool (env default + custom rows) ---
|
||||||
|
|
||||||
|
function clampInt(raw: string, min: number, max: number, fallback: number): number {
|
||||||
|
const v = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(v)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generation timeout for Ollama chat requests (admin setting, seconds). */
|
||||||
|
export async function getOllamaTimeoutMs(): Promise<number> {
|
||||||
|
return clampInt(await getSetting("ollama_timeout_s", "300"), 5, 3600, 300) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traffic weight of the implicit env node (0 = never use it). */
|
||||||
|
export async function getOllamaDefaultWeight(): Promise<number> {
|
||||||
|
return clampInt(await getSetting("ollama_default_weight", "100"), 0, 1000, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All routable nodes, heaviest first (default env node included). */
|
||||||
|
export async function effectiveOllamaNodes(): Promise<EffectiveNode[]> {
|
||||||
|
const out: EffectiveNode[] = [];
|
||||||
|
if (config.ollamaEnabled) {
|
||||||
|
const w = await getOllamaDefaultWeight();
|
||||||
|
if (w > 0) {
|
||||||
|
out.push({ id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const n of await listOllamaNodes()) {
|
||||||
|
if (n.enabled && n.weight > 0) out.push({ id: n.id, name: n.name, host: n.host, weight: n.weight });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.weight - a.weight);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the node for one generation (weighted random) + the failover order
|
||||||
|
* (picked node first, then heaviest-first). Throws when nothing is routable.
|
||||||
|
*/
|
||||||
|
export async function pickOllamaRoute(): Promise<{ first: EffectiveNode; order: EffectiveNode[] }> {
|
||||||
|
const nodes = await effectiveOllamaNodes();
|
||||||
|
if (nodes.length === 0) throw new Error('no ollama node available (all disabled or weight 0)');
|
||||||
|
const total = nodes.reduce((s, n) => s + n.weight, 0);
|
||||||
|
let r = Math.random() * total;
|
||||||
|
let first = nodes[0]!;
|
||||||
|
for (const n of nodes) {
|
||||||
|
r -= n.weight;
|
||||||
|
if (r <= 0) {
|
||||||
|
first = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = [first, ...nodes.filter((n) => n.id !== first.id)];
|
||||||
|
return { first, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an admin-selected node id (pull/delete/status) to its host. */
|
||||||
|
export async function resolveOllamaHost(nodeId: string | undefined): Promise<EffectiveNode> {
|
||||||
|
if (!nodeId || nodeId === DEFAULT_NODE_ID) {
|
||||||
|
return { id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: 0 };
|
||||||
|
}
|
||||||
|
const n = await getOllamaNode(nodeId);
|
||||||
|
if (!n) throw new Error("unknown ollama node");
|
||||||
|
return { id: n.id, name: n.name, host: n.host, weight: n.weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- telemetry ---
|
||||||
|
|
||||||
|
export async function recordOllamaStat(s: {
|
||||||
|
node_id: string;
|
||||||
|
ok: boolean;
|
||||||
|
latency_ms: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
eval_tokens: number;
|
||||||
|
model: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
await d.run(
|
||||||
|
"INSERT INTO ollama_stats (node_id, ts, ok, latency_ms, prompt_tokens, eval_tokens, model) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
s.node_id, Date.now(), s.ok ? 1 : 0, Math.max(0, Math.round(s.latency_ms)),
|
||||||
|
Math.max(0, Math.round(s.prompt_tokens)), Math.max(0, Math.round(s.eval_tokens)), s.model.slice(0, 160),
|
||||||
|
);
|
||||||
|
// Best-effort retention (indexed range delete, cheap).
|
||||||
|
try {
|
||||||
|
await d.run("DELETE FROM ollama_stats WHERE ts < ?", Date.now() - STATS_RETENTION_MS);
|
||||||
|
} catch {
|
||||||
|
// stats must never break generations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatsWindow = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
|
||||||
|
export function statsWindowMs(w: StatsWindow): number {
|
||||||
|
return { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 86400_000, "7d": 7 * 86400_000, "30d": 30 * 86400_000 }[w];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsBucket {
|
||||||
|
t: number;
|
||||||
|
req: number;
|
||||||
|
err: number;
|
||||||
|
avgMs: number | null;
|
||||||
|
promptTok: number;
|
||||||
|
evalTok: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStats {
|
||||||
|
window: StatsWindow;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null };
|
||||||
|
buckets: StatsBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKETS = 24;
|
||||||
|
|
||||||
|
export async function nodeStats(nodeId: string, window: StatsWindow): Promise<NodeStats> {
|
||||||
|
const to = Date.now();
|
||||||
|
const span = statsWindowMs(window);
|
||||||
|
const from = to - span;
|
||||||
|
const bucketMs = Math.floor(span / BUCKETS);
|
||||||
|
// GROUP BY output alias works on both SQLite and Postgres; AVG may come
|
||||||
|
// back as text (pg numeric) so every aggregate is coerced in JS.
|
||||||
|
const rows = await db().all<Record<string, number | string | null>>(
|
||||||
|
`SELECT CAST((ts - ?) / ? AS INTEGER) AS b, COUNT(*) AS req,
|
||||||
|
COUNT(*) - COALESCE(SUM(ok), 0) AS err,
|
||||||
|
AVG(CASE WHEN ok = 1 THEN latency_ms END) AS avgms,
|
||||||
|
COALESCE(SUM(prompt_tokens), 0) AS pt, COALESCE(SUM(eval_tokens), 0) AS et
|
||||||
|
FROM ollama_stats WHERE node_id = ? AND ts >= ? GROUP BY b ORDER BY b ASC`,
|
||||||
|
from, bucketMs, nodeId, from,
|
||||||
|
);
|
||||||
|
const buckets: StatsBucket[] = Array.from({ length: BUCKETS }, (_, i) => ({
|
||||||
|
t: from + i * bucketMs,
|
||||||
|
req: 0,
|
||||||
|
err: 0,
|
||||||
|
avgMs: null,
|
||||||
|
promptTok: 0,
|
||||||
|
evalTok: 0,
|
||||||
|
}));
|
||||||
|
for (const r of rows) {
|
||||||
|
const b = Number(r.b);
|
||||||
|
if (!Number.isInteger(b) || b < 0 || b >= BUCKETS) continue;
|
||||||
|
const slot = buckets[b]!;
|
||||||
|
slot.req = Number(r.req);
|
||||||
|
slot.err = Number(r.err);
|
||||||
|
slot.avgMs = r.avgms === null ? null : Number(r.avgms);
|
||||||
|
slot.promptTok = Number(r.pt);
|
||||||
|
slot.evalTok = Number(r.et);
|
||||||
|
}
|
||||||
|
const totals = buckets.reduce(
|
||||||
|
(a, b) => ({ req: a.req + b.req, err: a.err + b.err, promptTok: a.promptTok + b.promptTok, evalTok: a.evalTok + b.evalTok }),
|
||||||
|
{ req: 0, err: 0, promptTok: 0, evalTok: 0 },
|
||||||
|
);
|
||||||
|
const latRows = buckets.filter((b) => b.avgMs !== null && b.req - b.err > 0);
|
||||||
|
const latWeight = latRows.reduce((s, b) => s + (b.req - b.err), 0);
|
||||||
|
const avgMs = latWeight > 0
|
||||||
|
? latRows.reduce((s, b) => s + b.avgMs! * (b.req - b.err), 0) / latWeight
|
||||||
|
: null;
|
||||||
|
// Tokens/s over the window's active span (first to last non-empty bucket).
|
||||||
|
const active = buckets.filter((b) => b.req > 0);
|
||||||
|
const activeMs = active.length > 1 ? active[active.length - 1]!.t - active[0]!.t + bucketMs : active.length === 1 ? bucketMs : 0;
|
||||||
|
const tokPerSec = activeMs > 0 ? (totals.promptTok + totals.evalTok) / (activeMs / 1000) : null;
|
||||||
|
return { window, from, to, totals: { ...totals, avgMs, tokPerSec }, buckets };
|
||||||
|
}
|
||||||
+52
-45
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { getConversation, getDb, getMessages } from "./db.ts";
|
import { getConversation, getMessages, db } from "./db.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content reports (user-flagged AI responses). Tables are created
|
* Content reports (user-flagged AI responses). Tables are created
|
||||||
@@ -38,14 +38,14 @@ export interface Report {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initReportsTables(): void {
|
export async function initReportsTables(): Promise<void> {
|
||||||
const d = getDb();
|
const d = db();
|
||||||
d.query(`CREATE TABLE IF NOT EXISTS reports (
|
await d.exec(`CREATE TABLE IF NOT EXISTS reports (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
reporter_id TEXT NOT NULL,
|
reporter_id TEXT NOT NULL,
|
||||||
reporter_name TEXT NOT NULL DEFAULT '',
|
reporter_name TEXT NOT NULL DEFAULT '',
|
||||||
conversation_id TEXT NOT NULL DEFAULT '',
|
conversation_id TEXT NOT NULL DEFAULT '',
|
||||||
message_index INTEGER NOT NULL DEFAULT -1,
|
message_index BIGINT NOT NULL DEFAULT -1,
|
||||||
content TEXT NOT NULL DEFAULT '',
|
content TEXT NOT NULL DEFAULT '',
|
||||||
prompt TEXT NOT NULL DEFAULT '',
|
prompt TEXT NOT NULL DEFAULT '',
|
||||||
model TEXT NOT NULL DEFAULT '',
|
model TEXT NOT NULL DEFAULT '',
|
||||||
@@ -53,21 +53,27 @@ export function initReportsTables(): void {
|
|||||||
details TEXT NOT NULL DEFAULT '',
|
details TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
admin_note TEXT NOT NULL DEFAULT '',
|
admin_note TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at BIGINT NOT NULL
|
||||||
)`).run();
|
)`);
|
||||||
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run();
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`);
|
||||||
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run();
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`);
|
||||||
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run();
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`);
|
||||||
// Prompt snapshot added after the initial schema — keep idempotent.
|
if (d.kind === "sqlite") {
|
||||||
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[];
|
// Prompt snapshot added after the initial schema — keep idempotent.
|
||||||
if (!rcols.some((c) => c.name === "prompt")) {
|
const rcols = await d.all<{ name: string }>("PRAGMA table_info(reports)");
|
||||||
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run();
|
if (!rcols.some((c) => c.name === "prompt")) {
|
||||||
}
|
await d.exec("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''");
|
||||||
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
|
}
|
||||||
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[];
|
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
|
||||||
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
|
const ucols = await d.all<{ name: string }>("PRAGMA table_info(users)");
|
||||||
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run();
|
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Postgres: ADD COLUMN IF NOT EXISTS is natively idempotent.
|
||||||
|
await d.exec("ALTER TABLE reports ADD COLUMN IF NOT EXISTS prompt TEXT NOT NULL DEFAULT ''");
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS report_shadowbanned INTEGER NOT NULL DEFAULT 0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +81,15 @@ export function initReportsTables(): void {
|
|||||||
export const MAX_OPEN_REPORTS = 20;
|
export const MAX_OPEN_REPORTS = 20;
|
||||||
|
|
||||||
/** Still-triageable report already covering this message, if any. */
|
/** Still-triageable report already covering this message, if any. */
|
||||||
export function findOpenReport(conversationId: string, messageIndex: number): Report | null {
|
export async function findOpenReport(conversationId: string, messageIndex: number): Promise<Report | null> {
|
||||||
if (!conversationId || messageIndex < 0) return null;
|
if (!conversationId || messageIndex < 0) return null;
|
||||||
return getDb().query(
|
return db().get<Report>(
|
||||||
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.conversation_id = ? AND r.message_index = ? AND r.status IN ('open','reviewing') ORDER BY r.created_at DESC LIMIT 1",
|
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.conversation_id = ? AND r.message_index = ? AND r.status IN ('open','reviewing') ORDER BY r.created_at DESC LIMIT 1",
|
||||||
).get(conversationId, messageIndex) as Report | null;
|
conversationId, messageIndex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReport(opts: {
|
export async function createReport(opts: {
|
||||||
reporterId: string;
|
reporterId: string;
|
||||||
reporterName: string;
|
reporterName: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -92,28 +99,28 @@ export function createReport(opts: {
|
|||||||
clientContent: string;
|
clientContent: string;
|
||||||
clientPrompt: string;
|
clientPrompt: string;
|
||||||
clientModel: string;
|
clientModel: string;
|
||||||
}): Report {
|
}): Promise<Report> {
|
||||||
const d = getDb();
|
|
||||||
const dup = opts.conversationId && opts.messageIndex >= 0
|
const dup = opts.conversationId && opts.messageIndex >= 0
|
||||||
? findOpenReport(opts.conversationId, opts.messageIndex)
|
? await findOpenReport(opts.conversationId, opts.messageIndex)
|
||||||
: null;
|
: null;
|
||||||
if (dup) {
|
if (dup) {
|
||||||
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
|
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
|
||||||
err.report = dup;
|
err.report = dup;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const open = d.query(
|
const open = await db().get<{ n: number }>(
|
||||||
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
|
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
|
||||||
).get(opts.reporterId) as { n: number };
|
opts.reporterId,
|
||||||
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
|
);
|
||||||
|
if ((open?.n ?? 0) >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
|
||||||
let content = opts.clientContent;
|
let content = opts.clientContent;
|
||||||
let prompt = opts.clientPrompt;
|
let prompt = opts.clientPrompt;
|
||||||
let model = opts.clientModel;
|
let model = opts.clientModel;
|
||||||
if (opts.conversationId) {
|
if (opts.conversationId) {
|
||||||
const conv = getConversation(opts.conversationId);
|
const conv = await getConversation(opts.conversationId);
|
||||||
if (conv && conv.user_id === opts.reporterId) {
|
if (conv && conv.user_id === opts.reporterId) {
|
||||||
if (conv.model) model = conv.model;
|
if (conv.model) model = conv.model;
|
||||||
const msgs = getMessages(conv.id).filter((m) => m.role !== "system");
|
const msgs = (await getMessages(conv.id)).filter((m) => m.role !== "system");
|
||||||
if (opts.messageIndex >= 0) {
|
if (opts.messageIndex >= 0) {
|
||||||
const at = msgs[opts.messageIndex];
|
const at = msgs[opts.messageIndex];
|
||||||
if (at) content = at.content;
|
if (at) content = at.content;
|
||||||
@@ -140,9 +147,8 @@ export function createReport(opts: {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
d.query(
|
await db().run(
|
||||||
"INSERT INTO reports (id, reporter_id, reporter_name, conversation_id, message_index, content, prompt, model, reason, details, status, admin_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO reports (id, reporter_id, reporter_name, conversation_id, message_index, content, prompt, model, reason, details, status, admin_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
).run(
|
|
||||||
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
|
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
|
||||||
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
|
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
|
||||||
row.created_at, row.updated_at,
|
row.created_at, row.updated_at,
|
||||||
@@ -150,20 +156,21 @@ export function createReport(opts: {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listReports(): Report[] {
|
export async function listReports(): Promise<Report[]> {
|
||||||
return getDb().query(
|
return db().all<Report>(
|
||||||
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id ORDER BY r.updated_at DESC LIMIT 500",
|
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id ORDER BY r.updated_at DESC LIMIT 500",
|
||||||
).all() as Report[];
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReport(id: string): Report | null {
|
export async function getReport(id: string): Promise<Report | null> {
|
||||||
return getDb().query(
|
return db().get<Report>(
|
||||||
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.id = ?",
|
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.id = ?",
|
||||||
).get(id) as Report | null;
|
id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null {
|
export async function setReportStatus(id: string, status: ReportStatus, adminNote: string): Promise<Report | null> {
|
||||||
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run(
|
await db().run("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?",
|
||||||
status, adminNote, Date.now(), id,
|
status, adminNote, Date.now(), id,
|
||||||
);
|
);
|
||||||
return getReport(id);
|
return getReport(id);
|
||||||
@@ -173,8 +180,8 @@ export function setReportStatus(id: string, status: ReportStatus, adminNote: str
|
|||||||
* Shadow-ban a reporter: their reports are still accepted but flagged
|
* Shadow-ban a reporter: their reports are still accepted but flagged
|
||||||
* unreliable (excluded from the admin badge). Returns the new flag.
|
* unreliable (excluded from the admin badge). Returns the new flag.
|
||||||
*/
|
*/
|
||||||
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number {
|
export async function setReportShadowbanned(userId: string, shadowbanned: boolean): Promise<number> {
|
||||||
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId);
|
await db().run("UPDATE users SET report_shadowbanned = ? WHERE id = ?", shadowbanned ? 1 : 0, userId);
|
||||||
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null;
|
const row = await db().get<{ v: number }>("SELECT report_shadowbanned AS v FROM users WHERE id = ?", userId);
|
||||||
return row?.v ?? 0;
|
return row?.v ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-8
@@ -62,23 +62,22 @@ export class OcrTool implements PlatformTool {
|
|||||||
this.available = availableCache;
|
this.available = availableCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAvailable(): boolean {
|
async isAvailable(): Promise<boolean> {
|
||||||
return (this.available ?? false) && isOcrToolEnabled();
|
return (this.available ?? false) && (await isOcrToolEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
unavailableReason(): string | null {
|
async unavailableReason(): Promise<string | null> {
|
||||||
if (!(this.available ?? false)) {
|
if (!(this.available ?? false)) {
|
||||||
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
|
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
|
||||||
}
|
}
|
||||||
if (!isOcrToolEnabled()) return "OCR is disabled by the administrator (Admin Center → Features).";
|
if (!(await isOcrToolEnabled())) return "OCR is disabled by the administrator (Admin Center → Features).";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
|
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
|
||||||
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable");
|
if (!(await this.isAvailable())) throw new Error((await this.unavailableReason()) ?? "ocr unavailable");
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const maxChars = config.ocrMaxChars;
|
const maxChars = config.ocrMaxChars; let proc;
|
||||||
let proc;
|
|
||||||
try {
|
try {
|
||||||
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
|
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
|
||||||
stdin: image,
|
stdin: image,
|
||||||
@@ -104,7 +103,7 @@ export class OcrTool implements PlatformTool {
|
|||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
|
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
|
||||||
}
|
}
|
||||||
let text = out.replace(/\f/g, "").trim();
|
let text = sanitizeOcrText(out);
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
if (text.length > maxChars) {
|
if (text.length > maxChars) {
|
||||||
text = text.slice(0, maxChars);
|
text = text.slice(0, maxChars);
|
||||||
@@ -118,4 +117,50 @@ export class OcrTool implements PlatformTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip unrecognizable output from the OCR engine while preserving every
|
||||||
|
* legitimate character (accents, CJK, punctuation, emoji…).
|
||||||
|
*
|
||||||
|
* Tesseract emits raw control bytes, Unicode private-use / unassigned /
|
||||||
|
* surrogate code points and U+FFFD replacement chars when glyphs are
|
||||||
|
* unreadable, plus form-feeds between pages and \r line endings. Those
|
||||||
|
* poison the model prompt and the attachment preview, so they are removed
|
||||||
|
* here. Visible-but-dubious glyphs (|, ~, …) are kept: they may be real
|
||||||
|
* content and only the user can judge them in the review step.
|
||||||
|
*/
|
||||||
|
export function sanitizeOcrText(raw: string): string {
|
||||||
|
// Normalize line endings first; form-feeds are page breaks, not content.
|
||||||
|
let text = raw.replace(/\r\n?/g, "\n").replace(/\f/g, "\n");
|
||||||
|
// Strip BOMs / zero-width no-break spaces anywhere (keep ZWJ + VS16 so
|
||||||
|
// emoji sequences survive).
|
||||||
|
text = text.replace(/[\uFEFF\u200B\u200C\u2060\u180E]/g, "");
|
||||||
|
let out = "";
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === "\n" || ch === "\t" || ch === "\u200D" || ch === "\uFE0F") {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cp = ch.codePointAt(0)!;
|
||||||
|
// U+FFFD (replacement char) and U+FFFE/FFFF are the engine's markers
|
||||||
|
// for unreadable glyphs — never real content.
|
||||||
|
if (cp === 0xfffd || cp === 0xfffe || cp === 0xffff) continue;
|
||||||
|
// Keep letters, marks, numbers, punctuation, symbols and spaces.
|
||||||
|
// Drops Cc/Cf/Cs/Co/Cn (controls, formats, surrogates, private-use,
|
||||||
|
// unassigned) and Zl/Zp separators.
|
||||||
|
if (/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]$/u.test(ch)) {
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
// Anything else is unrecognizable — drop it.
|
||||||
|
}
|
||||||
|
// Tidy whitespace without touching indentation: trim line ends, collapse
|
||||||
|
// 3+ blank lines, drop leading/trailing blank lines.
|
||||||
|
out = out
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/[ \t\u00A0]+$/g, ""))
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export { OCR_MAX_BYTES };
|
export { OCR_MAX_BYTES };
|
||||||
|
|||||||
+13
-8
@@ -1,22 +1,27 @@
|
|||||||
import { OcrTool } from "./ocr.ts";
|
import { OcrTool } from "./ocr.ts";
|
||||||
|
import { TtsTool } from "./tts.ts";
|
||||||
import type { PlatformTool } from "./types.ts";
|
import type { PlatformTool } from "./types.ts";
|
||||||
|
|
||||||
/** Platform tools registry (upload flows always go through tools). */
|
/** Platform tools registry (upload flows always go through tools). */
|
||||||
class ToolRegistry {
|
class ToolRegistry {
|
||||||
readonly ocr = new OcrTool();
|
readonly ocr = new OcrTool();
|
||||||
private tools: PlatformTool[] = [this.ocr];
|
readonly tts = new TtsTool();
|
||||||
|
private tools: PlatformTool[] = [this.ocr, this.tts];
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
await this.ocr.init();
|
await this.ocr.init();
|
||||||
|
await this.tts.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
|
async list(): Promise<{ name: string; description: string; available: boolean; reason: string | null }[]> {
|
||||||
return this.tools.map((t) => ({
|
return Promise.all(
|
||||||
name: t.name,
|
this.tools.map(async (t) => ({
|
||||||
description: t.description,
|
name: t.name,
|
||||||
available: t.isAvailable(),
|
description: t.description,
|
||||||
reason: t.unavailableReason(),
|
available: await t.isAvailable(),
|
||||||
}));
|
reason: await t.unavailableReason(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
import { isTtsToolEnabled } from "../db.ts";
|
||||||
|
import { driverLog, fmtBytes, fmtMs } from "../drivers/log.ts";
|
||||||
|
import type { PlatformTool } from "./types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neural TTS tool: turns assistant replies / preview lines into natural
|
||||||
|
* speech (far beyond the browser's robotic speechSynthesis).
|
||||||
|
*
|
||||||
|
* Providers, tried in order (first configured wins; `TTS_PROVIDER` forces
|
||||||
|
* one explicitly):
|
||||||
|
* - elevenlabs (ELEVENLABS_API_KEY [+ ELEVENLABS_VOICE]) — most natural,
|
||||||
|
* any voice_id (20-char ids, clones included).
|
||||||
|
* - openai (OPENAI_API_KEY, reuses the LLM provider key) — tts-1 with the
|
||||||
|
* 6 stock voices (alloy/echo/fable/onyx/nova/shimmer).
|
||||||
|
* - piper (PIPER_BIN + PIPER_MODEL, local binary like tesseract) — offline,
|
||||||
|
* single configured voice, WAV output.
|
||||||
|
*
|
||||||
|
* Output is cached on disk (DATA_DIR/tts-cache, sha of provider|voice|text,
|
||||||
|
* 200 files cap) so repeated lines cost nothing. Without any provider the
|
||||||
|
* tool reports unavailable and the frontend falls back to browser speech.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TtsProvider = "elevenlabs" | "openai" | "piper";
|
||||||
|
|
||||||
|
export const OPENAI_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] as const;
|
||||||
|
|
||||||
|
const TTS_MAX_CHARS = 2000;
|
||||||
|
const TTS_TIMEOUT_MS = 60_000;
|
||||||
|
const TTS_CACHE_CAP = 200;
|
||||||
|
|
||||||
|
// Abuse protection: neural TTS costs money per byte.
|
||||||
|
const TTS_WINDOW_MS = 3600_000;
|
||||||
|
const TTS_MAX_JOBS = 30;
|
||||||
|
const ttsJobs = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function ttsAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const list = (ttsJobs.get(userId) ?? []).filter((t) => t > now - TTS_WINDOW_MS);
|
||||||
|
ttsJobs.set(userId, list);
|
||||||
|
if (list.length >= TTS_MAX_JOBS) {
|
||||||
|
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + TTS_WINDOW_MS - now) / 1000)) };
|
||||||
|
}
|
||||||
|
return { ok: true, retryAfterSec: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordTtsJob(userId: string): void {
|
||||||
|
const list = ttsJobs.get(userId) ?? [];
|
||||||
|
list.push(Date.now());
|
||||||
|
ttsJobs.set(userId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevenlabsBase(): string {
|
||||||
|
return (process.env["ELEVENLABS_BASE_URL"] || "https://api.elevenlabs.io").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openaiBase(): string {
|
||||||
|
return (process.env["OPENAI_BASE_URL"] || "https://api.openai.com").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function piperBin(): string {
|
||||||
|
return process.env["PIPER_BIN"] || "piper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsVoice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function piperAvailable(): Promise<boolean> {
|
||||||
|
if (!config.piperModel) return false;
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn([piperBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const code = await proc.exited;
|
||||||
|
return code === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which provider would serve right now (null = none configured). */
|
||||||
|
export async function activeTtsProvider(): Promise<TtsProvider | null> {
|
||||||
|
const forced = (process.env["TTS_PROVIDER"] || "auto").toLowerCase();
|
||||||
|
const eleven = config.elevenlabsApiKey.length > 0;
|
||||||
|
const openai = config.openaiApiKey.length > 0;
|
||||||
|
const piper = await piperAvailable();
|
||||||
|
const pick = (p: TtsProvider): TtsProvider | null =>
|
||||||
|
p === "elevenlabs" ? (eleven ? p : null) : p === "openai" ? (openai ? p : null) : piper ? p : null;
|
||||||
|
if (forced === "elevenlabs" || forced === "openai" || forced === "piper") return pick(forced);
|
||||||
|
return pick("elevenlabs") ?? pick("openai") ?? pick("piper");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeTtsVoice(provider: TtsProvider, raw: unknown): string | null {
|
||||||
|
if (raw === undefined || raw === null || raw === "") return null;
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const v = raw.trim();
|
||||||
|
if (provider === "openai") {
|
||||||
|
return (OPENAI_VOICES as readonly string[]).includes(v.toLowerCase()) ? v.toLowerCase() : null;
|
||||||
|
}
|
||||||
|
if (provider === "elevenlabs") {
|
||||||
|
return /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null;
|
||||||
|
}
|
||||||
|
return null; // piper: single configured model voice
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheDir(): string {
|
||||||
|
const dir = join(config.dataDir, "tts-cache");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(provider: TtsProvider, voice: string, text: string): string {
|
||||||
|
return createHash("sha256").update(`${provider}|${voice}|${text}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedPath(key: string, ext: string): string {
|
||||||
|
return join(cacheDir(), `${key}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneCache(): void {
|
||||||
|
try {
|
||||||
|
const dir = cacheDir();
|
||||||
|
const files = readdirSync(dir)
|
||||||
|
.map((f) => {
|
||||||
|
try {
|
||||||
|
return { f, mtime: statSync(join(dir, f)).mtimeMs };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((x): x is { f: string; mtime: number } => x !== null)
|
||||||
|
.sort((a, b) => a.mtime - b.mtime);
|
||||||
|
for (const old of files.slice(0, Math.max(0, files.length - TTS_CACHE_CAP))) {
|
||||||
|
try {
|
||||||
|
rmSync(join(dir, old.f));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cache must never break synthesis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ElevenLabs voice catalog (cached 5 min, best-effort — drives the admin
|
||||||
|
// voice select menus; never blocks synthesis when unreachable).
|
||||||
|
let elVoicesCache: { at: number; voices: TtsVoice[] } | null = null;
|
||||||
|
|
||||||
|
export async function elevenlabsVoices(): Promise<TtsVoice[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (elVoicesCache && now - elVoicesCache.at < 300_000) return elVoicesCache.voices;
|
||||||
|
const fallback = config.elevenlabsVoice ? [{ id: config.elevenlabsVoice, name: config.elevenlabsVoice }] : [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${elevenlabsBase()}/v1/voices`, {
|
||||||
|
headers: { "xi-api-key": config.elevenlabsApiKey },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
const data = (await res.json()) as { voices?: { voice_id?: unknown; name?: unknown }[] };
|
||||||
|
const voices = (data.voices ?? [])
|
||||||
|
.filter((v) => typeof v.voice_id === "string" && typeof v.name === "string")
|
||||||
|
.map((v) => ({ id: v.voice_id as string, name: v.name as string }));
|
||||||
|
if (voices.length === 0) return fallback;
|
||||||
|
elVoicesCache = { at: now, voices };
|
||||||
|
return [...voices, ...fallback.filter((f) => !voices.some((v) => v.id === f.id))];
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthElevenlabs(text: string, voiceId: string): Promise<{ audio: Uint8Array; contentType: string }> { const res = await fetch(`${elevenlabsBase()}/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "audio/mpeg",
|
||||||
|
"xi-api-key": config.elevenlabsApiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text, model_id: "eleven_multilingual_v2" }),
|
||||||
|
signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(`elevenlabs tts failed (${res.status}): ${err.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return { audio: new Uint8Array(await res.arrayBuffer()), contentType: "audio/mpeg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthOpenai(text: string, voice: string): Promise<{ audio: Uint8Array; contentType: string }> {
|
||||||
|
const res = await fetch(`${openaiBase()}/v1/audio/speech`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.openaiApiKey}` },
|
||||||
|
body: JSON.stringify({ model: "tts-1", input: text, voice, response_format: "mp3" }),
|
||||||
|
signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(`openai tts failed (${res.status}): ${err.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return { audio: new Uint8Array(await res.arrayBuffer()), contentType: "audio/mpeg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthPiper(text: string): Promise<{ audio: Uint8Array; contentType: string }> {
|
||||||
|
const out = join(cacheDir(), `piper-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}.wav`);
|
||||||
|
let proc;
|
||||||
|
try {
|
||||||
|
proc = Bun.spawn([piperBin(), "--model", config.piperModel, "--output_file", out], {
|
||||||
|
stdin: new TextEncoder().encode(text),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`tts engine failed to start: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
// already exited
|
||||||
|
}
|
||||||
|
}, TTS_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const [code, errText] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
||||||
|
if (code !== 0) throw new Error(`piper failed (exit ${code}): ${errText.trim().slice(0, 200)}`);
|
||||||
|
const file = Bun.file(out);
|
||||||
|
if (!(await file.exists())) throw new Error("piper produced no output");
|
||||||
|
return { audio: new Uint8Array(await file.arrayBuffer()), contentType: "audio/wav" };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try {
|
||||||
|
rmSync(out);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TtsTool implements PlatformTool {
|
||||||
|
readonly name = "tts";
|
||||||
|
readonly description = "Neural text-to-speech (ElevenLabs / OpenAI / local piper) for natural voice playback.";
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
const p = await activeTtsProvider();
|
||||||
|
driverLog("tools", `tts engine: ${p ?? "none configured — browser speech fallback only"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return (await activeTtsProvider()) !== null && (await isTtsToolEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
async unavailableReason(): Promise<string | null> {
|
||||||
|
if ((await activeTtsProvider()) === null) {
|
||||||
|
return "no TTS provider configured (ELEVENLABS_API_KEY, OPENAI_API_KEY or PIPER_BIN+PIPER_MODEL). Browser speech is used instead.";
|
||||||
|
}
|
||||||
|
if (!(await isTtsToolEnabled())) return "TTS is disabled by the administrator (Admin Center → Features).";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async providerInfo(): Promise<{ provider: TtsProvider | null; voices: TtsVoice[] }> {
|
||||||
|
const p = await activeTtsProvider();
|
||||||
|
if (!p) return { provider: null, voices: [] };
|
||||||
|
if (p === "openai") return { provider: p, voices: OPENAI_VOICES.map((v) => ({ id: v, name: v })) };
|
||||||
|
if (p === "elevenlabs") return { provider: p, voices: await elevenlabsVoices() };
|
||||||
|
return { provider: p, voices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultVoice(provider: TtsProvider): string {
|
||||||
|
if (provider === "openai") {
|
||||||
|
const v = sanitizeTtsVoice("openai", config.ttsVoice);
|
||||||
|
return v ?? "alloy";
|
||||||
|
}
|
||||||
|
if (provider === "elevenlabs") return config.elevenlabsVoice || "pNInz6obpgDQGcFmaJgB";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(text: string, voice?: string): Promise<{ audio: Uint8Array; contentType: string; provider: TtsProvider; fromCache: boolean }> {
|
||||||
|
const provider = await activeTtsProvider();
|
||||||
|
if (!provider) throw new Error((await this.unavailableReason()) ?? "tts unavailable");
|
||||||
|
if (!(await isTtsToolEnabled())) throw new Error("tts disabled");
|
||||||
|
const clean = text.trim().slice(0, TTS_MAX_CHARS);
|
||||||
|
if (!clean) throw new Error("empty text");
|
||||||
|
const resolved = sanitizeTtsVoice(provider, voice) ?? this.defaultVoice(provider);
|
||||||
|
const ext = provider === "piper" ? "wav" : "mp3";
|
||||||
|
const key = cacheKey(provider, resolved, clean);
|
||||||
|
const hit = Bun.file(cachedPath(key, ext));
|
||||||
|
if (await hit.exists()) {
|
||||||
|
return {
|
||||||
|
audio: new Uint8Array(await hit.arrayBuffer()),
|
||||||
|
contentType: provider === "piper" ? "audio/wav" : "audio/mpeg",
|
||||||
|
provider,
|
||||||
|
fromCache: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const t0 = Date.now();
|
||||||
|
const out = provider === "elevenlabs"
|
||||||
|
? await synthElevenlabs(clean, resolved)
|
||||||
|
: provider === "openai"
|
||||||
|
? await synthOpenai(clean, resolved)
|
||||||
|
: await synthPiper(clean);
|
||||||
|
await Bun.write(cachedPath(key, ext), out.audio);
|
||||||
|
pruneCache();
|
||||||
|
driverLog("tools", `tts ${provider} voice=${resolved || "default"}: ${fmtBytes(out.audio.length)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
return { ...out, provider, fromCache: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TTS_MAX_CHARS };
|
||||||
+4
-3
@@ -1,8 +1,9 @@
|
|||||||
/** Platform tool: a server-side capability the frontend invokes explicitly
|
/** Platform tool: a server-side capability the frontend invokes explicitly
|
||||||
* (uploads always go through tools — never raw to the model). */
|
* (uploads always go through tools — never raw to the model). Availability
|
||||||
|
* is async: feature flags live in the database now. */
|
||||||
export interface PlatformTool {
|
export interface PlatformTool {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
isAvailable(): boolean;
|
isAvailable(): Promise<boolean>;
|
||||||
unavailableReason(): string | null;
|
unavailableReason(): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-24
@@ -1,5 +1,5 @@
|
|||||||
import { createHash, createHmac, randomBytes } from "node:crypto";
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||||
import { getDb } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
|
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
|
||||||
@@ -13,19 +13,23 @@ const DIGITS = 6;
|
|||||||
const WINDOW = 1;
|
const WINDOW = 1;
|
||||||
const CHALLENGE_TTL_MS = 5 * 60_000;
|
const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
export function initTotpTables(): void {
|
export async function initTotpTables(): Promise<void> {
|
||||||
const d = getDb();
|
const d = db();
|
||||||
try {
|
if (d.kind === "sqlite") {
|
||||||
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
|
try {
|
||||||
} catch {
|
await d.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
|
||||||
// column already exists — fine
|
} catch {
|
||||||
|
// column already exists — fine
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''");
|
||||||
}
|
}
|
||||||
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
await d.exec(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
||||||
token_hash TEXT PRIMARY KEY,
|
token_hash TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at BIGINT NOT NULL
|
||||||
)`).run();
|
)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
@@ -104,36 +108,41 @@ export function verifyTotp(secret: string, code: string): boolean {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTotpSecret(userId: string): string {
|
export async function getTotpSecret(userId: string): Promise<string> {
|
||||||
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
|
const row = await db().get<{ s: string }>("SELECT totp_secret AS s FROM users WHERE id = ?", userId);
|
||||||
return row?.s ?? "";
|
return row?.s ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTotpSecret(userId: string, secret: string): void {
|
export async function setTotpSecret(userId: string, secret: string): Promise<void> {
|
||||||
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
|
await db().run("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short-lived login challenge after a correct key; returns the raw token. */
|
/** Short-lived login challenge after a correct key; returns the raw token. */
|
||||||
export function createTotpChallenge(userId: string): string {
|
export async function createTotpChallenge(userId: string): Promise<string> {
|
||||||
const token = "tc_" + randomBytes(24).toString("base64url");
|
const token = "tc_" + randomBytes(24).toString("base64url");
|
||||||
const hash = createHash("sha256").update(token).digest("hex");
|
const hash = createHash("sha256").update(token).digest("hex");
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
|
await db().run(
|
||||||
|
"INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
hash, userId, now, now + CHALLENGE_TTL_MS,
|
hash, userId, now, now + CHALLENGE_TTL_MS,
|
||||||
);
|
);
|
||||||
// Opportunistic cleanup.
|
// Opportunistic cleanup.
|
||||||
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
|
await db().run("DELETE FROM totp_challenges WHERE expires_at < ?", now);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function consumeTotpChallenge(token: string): string | null {
|
export async function consumeTotpChallenge(token: string): Promise<string | null> {
|
||||||
const hash = createHash("sha256").update(token).digest("hex");
|
const hash = createHash("sha256").update(token).digest("hex");
|
||||||
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
|
const row = await db().get<{ user_id: string; expires_at: number }>(
|
||||||
user_id: string;
|
"SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?", hash,
|
||||||
expires_at: number;
|
);
|
||||||
} | null;
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
|
await db().run("DELETE FROM totp_challenges WHERE token_hash = ?", hash);
|
||||||
if (row.expires_at < Date.now()) return null;
|
if (row.expires_at < Date.now()) return null;
|
||||||
return row.user_id;
|
return row.user_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Housekeeping for account deletion (no other caller needs it). */
|
||||||
|
export async function deleteTotpChallenges(userId: string): Promise<void> {
|
||||||
|
await db().run("DELETE FROM totp_challenges WHERE user_id = ?", userId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { db } from "./db.ts";
|
||||||
|
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts";
|
||||||
|
import type { DriverModel, LLMDriver } from "./drivers/types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal LLM providers (BYOK): users attach their own API keys in
|
||||||
|
* settings, and the matching models show up in their model menu.
|
||||||
|
*
|
||||||
|
* Security notes (do not weaken):
|
||||||
|
* - Keys are stored reversibly server-side (they must be sent upstream) and
|
||||||
|
* are NEVER returned to any client — GET only exposes presence + last4.
|
||||||
|
* - Never log or print keys. Key material only travels server -> provider.
|
||||||
|
* - Scoping is per account: every helper takes userId and all chat/model
|
||||||
|
* routes resolve keys for the caller only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Providers a user may connect themselves. Keep in sync with the settings
|
||||||
|
// UI (frontend PROVIDER_META) and the docs.
|
||||||
|
export const USER_PROVIDER_IDS = ["openai", "anthropic", "deepseek", "gemini"] as const;
|
||||||
|
export type UserProviderId = (typeof USER_PROVIDER_IDS)[number];
|
||||||
|
|
||||||
|
export const USER_PROVIDER_LABELS: Record<UserProviderId, string> = {
|
||||||
|
openai: "OpenAI",
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
deepseek: "DeepSeek",
|
||||||
|
gemini: "Gemini",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isUserProviderId(v: string): v is UserProviderId {
|
||||||
|
return (USER_PROVIDER_IDS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-supplied key validation: trimmed printable secret, 8–256 chars. */
|
||||||
|
export function validProviderKey(v: unknown): string | null {
|
||||||
|
if (typeof v !== "string") return null;
|
||||||
|
const t = v.trim();
|
||||||
|
if (t.length < 8 || t.length > 256) return null;
|
||||||
|
if (/[\s]/.test(t)) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProviderStatus {
|
||||||
|
provider: UserProviderId;
|
||||||
|
label: string;
|
||||||
|
hasKey: boolean;
|
||||||
|
/** Last 4 chars of the stored key (for recognition), or null when unset. */
|
||||||
|
last4: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserProviderKey(userId: string, provider: UserProviderId): Promise<string | null> {
|
||||||
|
const r = await db().get<{ api_key: string }>(
|
||||||
|
"SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?",
|
||||||
|
userId, provider,
|
||||||
|
);
|
||||||
|
return r?.api_key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserProviders(userId: string): Promise<UserProviderStatus[]> {
|
||||||
|
const rows = await db().all<{ provider: string; api_key: string; updated_at: number }>(
|
||||||
|
"SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
const byId = new Map(rows.map((r) => [r.provider, r]));
|
||||||
|
return USER_PROVIDER_IDS.map((p) => {
|
||||||
|
const r = byId.get(p);
|
||||||
|
return {
|
||||||
|
provider: p,
|
||||||
|
label: USER_PROVIDER_LABELS[p],
|
||||||
|
hasKey: !!r,
|
||||||
|
last4: r ? r.api_key.slice(-4) : null,
|
||||||
|
updatedAt: r?.updated_at ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): Promise<void> {
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at",
|
||||||
|
userId, provider, apiKey, Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserProviderKey(userId: string, provider: UserProviderId): Promise<void> {
|
||||||
|
await db().run("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?", userId, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a per-user driver instance authenticated with the user's own key. */
|
||||||
|
export function buildUserProviderDriver(provider: UserProviderId, apiKey: string): LLMDriver {
|
||||||
|
switch (provider) {
|
||||||
|
case "openai":
|
||||||
|
return new OpenAIDriver(apiKey);
|
||||||
|
case "anthropic":
|
||||||
|
return new AnthropicDriver(apiKey);
|
||||||
|
case "deepseek":
|
||||||
|
return new DeepSeekDriver(apiKey);
|
||||||
|
case "gemini":
|
||||||
|
return new GeminiDriver(apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List models reachable with the user's own keys. Every entry is marked
|
||||||
|
* `personal: true` so the UI can list them in their own menu section.
|
||||||
|
* Never throws: a failing provider is skipped (its driver falls back to a
|
||||||
|
* static list, and only a total failure is swallowed here).
|
||||||
|
*/
|
||||||
|
export async function listUserProviderModels(userId: string): Promise<DriverModel[]> {
|
||||||
|
const out: DriverModel[] = [];
|
||||||
|
const jobs = USER_PROVIDER_IDS.map(async (p) => {
|
||||||
|
const key = await getUserProviderKey(userId, p);
|
||||||
|
if (!key) return;
|
||||||
|
try {
|
||||||
|
for (const m of await buildUserProviderDriver(p, key).listModels()) {
|
||||||
|
out.push({ ...m, personal: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[providers] user ${p} listModels failed:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(jobs);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user