diff --git a/.env.example b/.env.example index 4605aef..aa1088f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ SESSION_SECRET=replace-with-a-long-random-secret # Optional: reachable from the Express server, never from the browser. # SEARXNG_URL=http://searxng:8080 WEB_SEARCH_TIMEOUT_MS=8000 -UPLOAD_DIR=/app/data/uploads +# Production containers can set UPLOAD_DIR=/app/data/uploads. +# Locally, omitting UPLOAD_DIR uses data/uploads. UPLOAD_MAX_SIZE_MB=10 UPLOAD_MAX_FILES=5 UPLOAD_MAX_PIXELS=30000000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..44d49d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +on: [push, pull_request] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test diff --git a/.gitignore b/.gitignore index eb1d08e..0ba3a72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ node_modules/ -data/uploads/ +.env +.env.* +!.env.example +data/ +sessions/ +*.db +*.db-* +*.sqlite +*.sqlite-* +*.sqlite3 +*.sqlite3-* diff --git a/README.md b/README.md index 514737d..70e0f99 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,51 @@ A space for every conversation. Six appearance styles (Grove, Parchment, Aurora, ## Setup -Node.js 22 or newer is required. +Node.js 22.13 or newer is required. -1. Install dependencies with `npm install`. +Native dependencies also need a working build toolchain. On Windows, install Python +and Visual Studio's **Desktop development with C++** workload before `npm ci`. +On Linux, install Python, `make` and a C++ compiler; on macOS, use Xcode Command Line +Tools. See the [node-gyp prerequisites](https://github.com/nodejs/node-gyp#installation). + +1. Install the locked dependencies with `npm ci`. 2. Copy `.env.example` to `.env` and set at least `SESSION_SECRET`. 3. Start the app with `npm run dev` for local development or `npm start` for a normal server run. +Both startup scripts load `.env` when present; environment variables already set by the +host take precedence. `.env`, databases, uploads and session files are ignored by Git. +Use `npm install` when deliberately updating dependencies and include both +`package.json` and `package-lock.json` in the change. CI runs `npm ci`, lint and tests +on Node 22 and 24. + ## Scripts - `npm start`: run the production server entrypoint. - `npm run dev`: run the server with Node watch mode. - `npm run lint`: syntax-check the backend and browser modules. - `npm test`: run API tests with Node's built-in test runner. +- `npm run user:role -- `: assign or revoke a role from the server console. + +## Administrative access + +Accounts have a server-managed `users.role`, constrained to `user` or `admin`. Registration +and migrations default to `user`, including accounts named `admin`. Usernames, profile +updates and workspace imports never grant roles. Existing installations must explicitly +promote their intended administrator after deploying this migration: + +```sh +npm run user:role -- your-existing-username admin +``` + +Run this on the server with the application's `DB_PATH` and environment. To revoke +access, run the same command with `user`. Authorization reads the database role on each +request, so promotion and revocation apply to existing sessions; renaming retains the role. +There is no public role assignment endpoint. + +Async route handlers use `asyncHandler` to forward rejected promises to Express 4's +error middleware, following [Express's error handling guidance](https://expressjs.com/en/4x/guide/error-handling/). +Duplicate registrations, including concurrent inserts, return HTTP 409 with +`code: "USERNAME_TAKEN"`. ## Environment Variables @@ -196,7 +229,7 @@ The server is configured around an Ollama-compatible API surface: `qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection. -The composer has independent Web Search and Reasoning toggles, both off by default and reset on server acceptance. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. Existing SSE events carry `status` (accepted/searching/generating), `thinking` (chunk/fullThinking), `chunk` (chunk/fullReply), `done` (reply/sources), and `error`. Final answers stream incrementally, including persona responses. Persona validation still runs; a corrective pass resets provisional output before streaming its replacement. Only the validated complete answer is persisted. +The composer has independent Web Search and Reasoning toggles, both off by default and reset after a completed reply. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. SSE events carry `status` (accepted/analyzing/searching/generating), `thinking` (`{chunk}`), `chunk` (`{chunk}`), `replace` (`{target: "reply"|"thinking", text}`), `done` (reply/sources), and `error` (error/status/code/requestId when available). Text events send only incremental text. A corrective persona pass explicitly replaces provisional output before streaming its replacement. Both sends and regenerations batch Markdown rendering per animation frame. Only the validated complete answer is persisted. Clients consuming SSE must handle `replace` events and accumulate deltas; the browser also accepts the older accumulated event format. Every Ollama request explicitly sets `stream: true`, `think`, and `keep_alive`. Reasoning is requested and forwarded only when `reasoning === true` and the model supports the boolean thinking API. The shared conservative capability check supports Qwen3, DeepSeek R1 and DeepSeek v3.1 model IDs; unknown models, Qwen coder variants and models requiring thinking levels have the toggle disabled. Internal calls, roleplay openers and regeneration default to `think: false`. There is no extra model call to obtain reasoning. @@ -230,7 +263,12 @@ Legacy `chats` migration is handled during startup. New code only reads the sess The browser UI remains framework-free and uses native ES modules. Shared behavior lives under `public/js/`: -- `api.js`: JSON and streaming request helpers +- `api.js`: JSON and streaming request helpers with HTTP metadata, cancellation and deadlines +- `conversationState.js` / `conversationController.js`: shared state, chat selection, creation, search and drafts +- `messageRenderer.js`: sanitized Markdown, message cards, actions and scroll position +- `composerController.js`: immediate send locking, captured chat/model, Stop and send recovery +- `personaController.js`: persona forms, library actions and roleplay setup +- `streamRenderer.js`: delta accumulation, replacement events and batched rendering - `constants.js`: theme and onboarding metadata - `themeController.js`: validated theme/mode persistence and application - `theme-bootstrap.js`: applies stored appearance before page initialization @@ -238,6 +276,21 @@ The browser UI remains framework-free and uses native ES modules. Shared behavio - `statusNotice.js`: shared status and error presentation - `dom.js`: cached chat-page DOM references +JSON helpers retain the existing `{error}` result convention and add `status`, `code` +and `requestId` on failure. Successful empty responses return `{}`. Pass +`{signal, timeoutMs}` as the last argument to `get`, `post`, `put`, `del`, or `request`; +the default deadline is 30 seconds. `stream` accepts the same options (or the legacy +AbortSignal argument), defaults to 660 seconds, and rejects with `ApiError` unless +`onError` handles the failure. A `timeoutMs` of 0 disables the deadline. Cancellation, +timeout, network and malformed-response failures have distinct codes. HTTP errors +keep their status even if the response is HTML or invalid JSON. + +DOMPurify is an npm production dependency pinned in the lockfile and served only at +`/vendor/dompurify.js`; `node_modules` is not a static directory. The former CDN version +3.0.6 was affected by the maintainer's [mXSS advisory](https://github.com/cure53/DOMPurify/security/advisories/GHSA-gx9m-whjm-85jf). +The installed version is 3.4.16. Update it through npm so audits and reproducible installs +also cover the browser sanitizer. + `public/css/style.css` is a self-contained design system rebuilt around semantic tokens, shared components, and deliberate responsive breakpoints. It contains the six visual identities and their dark variants without legacy imports or precedence layers. `public/css/legal.css` contains only the legal-page layout. Pure chat message, retry, memory, summary, and persona-state formatting lives in `src/services/chatMessageHelpers.js`; `chatService.js` remains responsible for orchestration and persistence. @@ -250,7 +303,7 @@ Search uses `shouldSearch = webSearchEnabled && (explicitSearchIntent || automat The chat service retrieves up to 15 candidates per query and selects 1–5 useful sources dynamically, rejecting empty evidence and wrong-date results. SearXNG `content` maps to `snippet` (an existing non-empty snippet is also accepted). Exact-date event searches use the general index to include archives and try at most one alternate query if the first returns no usable evidence; candidates are merged and deduplicated. Current-news requests retain the news category. The same numbered selected titles, URLs and snippets go immediately before the current question in Ollama's context, into the response, and into storage. Retrieval finishes before either reasoning or answer streaming starts. Instructions require citations, supported partial answers rather than blanket claims of missing details, and honest disclosure when evidence is insufficient. Result content remains untrusted data; no result pages are fetched. -Set environment variables in the server/container environment, or load the example explicitly with `node --env-file=.env server.js` (the existing npm start command does not load `.env` automatically): +Set environment variables in the server/container environment, or in `.env` (loaded by `npm start` and `npm run dev`): ```dotenv SEARXNG_URL=http://searxng:8080 diff --git a/eslint.config.js b/eslint.config.js index 9851b1e..bb3fd61 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,4 +7,8 @@ import { defineConfig } from "eslint/config"; export default defineConfig([ { files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } }, + { + files: ["public/js/script.js", "public/js/composerController.js", "public/js/conversationController.js", "public/js/messageRenderer.js", "public/js/personaController.js"], + rules: {"no-shadow": "error"} + }, ]); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d87b06f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2756 @@ +{ + "name": "ollama-ai-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ollama-ai-frontend", + "version": "1.0.0", + "dependencies": { + "bcrypt": "^6.0.0", + "better-sqlite3": "^13.0.3", + "body-parser": "^1.20.2", + "dompurify": "3.4.16", + "express": "^4.19.2", + "express-session": "^1.17.3", + "multer": "^2.4.0", + "qs": "^6.15.0", + "session-file-store": "^1.5.0", + "sharp": "^0.35.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.11.0", + "globals": "^17.12.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/bagpipe": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", + "integrity": "sha512-42sAlmPDKes1nLm/aly+0VdaopSU9br+jkRELedhQxI5uXHgtk47I83Mpmf4zoNTRMASdLFtUkimlu/Z9zQ8+g==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kruptein": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-2.2.3.tgz", + "integrity": "sha512-BTwprBPTzkFT9oTugxKd3WnWrX630MqUDsnmBuoa98eQs12oD4n4TeI0GbpdGcYn/73Xueg2rfnw+oK4dovnJg==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.4.1" + }, + "engines": { + "node": ">6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/session-file-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/session-file-store/-/session-file-store-1.5.0.tgz", + "integrity": "sha512-60IZaJNzyu2tIeHutkYE8RiXVx3KRvacOxfLr2Mj92SIsRIroDsH0IlUUR6fJAjoTW4RQISbaOApa2IZpIwFdQ==", + "license": "Apache-2.0", + "dependencies": { + "bagpipe": "^0.3.5", + "fs-extra": "^8.0.1", + "kruptein": "^2.0.4", + "object-assign": "^4.1.1", + "retry": "^0.12.0", + "write-file-atomic": "3.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 9ce6d02..264195c 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "type": "module", "main": "server.js", "engines": { - "node": ">=22" + "node": ">=22.13.0" }, "scripts": { - "start": "node server.js", - "dev": "node --watch server.js", + "start": "node --env-file-if-exists=.env server.js", + "dev": "node --env-file-if-exists=.env --watch server.js", + "user:role": "node --env-file-if-exists=.env scripts/set-role.js", "lint": "eslint .", "test": "node --test" }, @@ -16,6 +17,7 @@ "bcrypt": "^6.0.0", "better-sqlite3": "^13.0.3", "body-parser": "^1.20.2", + "dompurify": "3.4.16", "express": "^4.19.2", "express-session": "^1.17.3", "multer": "^2.4.0", diff --git a/public/chat.html b/public/chat.html index 19ecd0a..1cf352c 100644 --- a/public/chat.html +++ b/public/chat.html @@ -469,7 +469,7 @@ - + diff --git a/public/js/api.js b/public/js/api.js index a8ec20b..6a8fa04 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -1,94 +1,168 @@ -/** - * @file Bündelt JSON-Anfragen und verarbeitet Ereignisse gestreamter Chat-Antworten im Browser. - */ -export async function request(url, data, method = "POST") { - try { - const res = await fetch(url, { - method, - credentials: "same-origin", - headers: {"Content-Type": "application/json"}, - body: data ? JSON.stringify(data) : undefined - }); - return await res.json(); - } catch { - return {error: "Network error - please try again."}; +/** JSON requests return error records; streaming requests reject unless onError handles them. */ +export class ApiError extends Error { + constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.requestId = requestId; + this.details = details; + } + + toJSON() { + return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; } } -export const get = (url) => request(url, null, "GET"); -export const post = (url, data) => request(url, data, "POST"); -export const put = (url, data) => request(url, data, "PUT"); -export const del = (url) => request(url, null, "DELETE"); +function requestScope({signal, timeoutMs = 30000} = {}) { + const controller = new AbortController(); + const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, {once: true}); + const timer = timeoutMs > 0 ? setTimeout(() => { + controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})); + }, timeoutMs) : null; + return { + signal: controller.signal, + close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); } + }; +} + +// Also bound body reads, even after fetch has resolved its response headers. +function abortable(operation, signal) { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); }; + signal.addEventListener("abort", abort, {once: true}); + try { + Promise.resolve(operation()).then(resolve, reject) + .finally(() => signal.removeEventListener("abort", abort)); + } catch (error) { + signal.removeEventListener("abort", abort); + reject(error); + } + }); +} + +function responseError(response, payload = {}, fallback = "", code = "") { + const details = payload && typeof payload === "object" ? payload : {}; + const error = details.error; + return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), { + ...details, + status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, + code: details.code || error?.code || code || `HTTP_${response.status}`, + requestId: details.requestId || response.headers.get("X-Request-ID") || null + }); +} + +async function readJson(response, signal) { + const text = await abortable(() => response.text(), signal); + let payload; + if (text.trim()) { + try { payload = JSON.parse(text); } + catch { + if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); + } + } + if (!response.ok) throw responseError(response, payload); + return payload ?? {}; +} + +function normalizeError(error, scope, response) { + const failure = scope.signal.aborted ? scope.signal.reason : error; + if (failure instanceof ApiError) { + if (!failure.status && response) failure.status = response.status; + failure.requestId ||= response?.headers.get("X-Request-ID") || null; + return failure; + } + return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", { + status: response?.status || 0, + code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", + requestId: response?.headers.get("X-Request-ID") || null + }); +} + +export async function request(url, data, method = "POST", options = {}) { + const scope = requestScope(options); + let response; + try { + response = await abortable(() => fetch(url, { + method, + credentials: "same-origin", + headers: {"Accept": "application/json", "Content-Type": "application/json"}, + body: data == null ? undefined : JSON.stringify(data), + signal: scope.signal + }), scope.signal); + return await readJson(response, scope.signal); + } catch (error) { + return normalizeError(error, scope, response).toJSON(); + } finally { + scope.close(); + } +} + +export const get = (url, options) => request(url, null, "GET", options); +export const post = (url, data, options) => request(url, data, "POST", options); +export const put = (url, data, options) => request(url, data, "PUT", options); +export const del = (url, options) => request(url, null, "DELETE", options); /** - * @typedef {"skipped"|"failed"|"no_results"|"no_relevant_results"|"succeeded"} SearchState - * @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number, searchState?: SearchState}} - * | {event: "chunk", data: {chunk: string, fullReply: string}} - * | {event: "thinking", data: {chunk: string, fullThinking: string}} - * | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[], searchState?: SearchState}} - * | {event: "error", data: {error: string, code?: string, searchState?: SearchState}}} ChatStreamEvent + * SSE text events carry {chunk}; corrections carry {target: "reply"|"thinking", text}. + * A legacy AbortSignal is also accepted as the fourth argument. Set timeoutMs: 0 to disable the deadline. */ -export async function stream(url, data, handlers = {}, signal = undefined) { - const response = await fetch(url, { - method: "POST", - credentials: "same-origin", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(data || {}), - signal - }); - - if (!response.ok || !response.body) { - let error = "Streaming request failed"; - try { - const payload = await response.json(); - error = payload.error || error; - } catch { - // Keep the generic error if the server did not send JSON. - } - if (handlers.onError) { - handlers.onError({error}); - return; - } - throw new Error(error); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let terminal = false; - +export async function stream(url, data, handlers = {}, options = {}) { + if (options && typeof options.addEventListener === "function") options = {signal: options}; + const scope = requestScope({timeoutMs: 660000, ...options}); + let response, reader; try { - while (true) { - const {value, done} = await reader.read(); + response = await abortable(() => fetch(url, { + method: "POST", + credentials: "same-origin", + headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"}, + body: JSON.stringify(data || {}), + signal: scope.signal + }), scope.signal); + if (!response.ok) await readJson(response, scope.signal); + if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE"); + reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let terminal = false; + while (!terminal) { + const {value, done} = await abortable(() => reader.read(), scope.signal); if (done) break; buffer += decoder.decode(value, {stream: true}); const parts = buffer.split(/\r?\n\r?\n/); - // Das letzte SSE-Ereignis kann unvollständig sein und wird mit dem nächsten Netzwerkblock ergänzt. buffer = parts.pop() || ""; for (const part of parts) { - const lines = part.split("\n"); - const eventLine = lines.find((line) => line.startsWith("event:")); - const dataLine = lines.find((line) => line.startsWith("data:")); - if (!eventLine || !dataLine) continue; - const event = eventLine.slice(6).trim(); - const payload = JSON.parse(dataLine.slice(5).trim()); + const lines = part.split(/\r?\n/); + const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); + const dataLines = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()); + if (!event || !dataLines.length) continue; + const payload = JSON.parse(dataLines.join("\n")); if (!payload || typeof payload !== "object") throw new Error("Invalid response stream."); - const textFields = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"}; + const textFields = {status: "status", done: "reply", error: "error", replace: "text"}; if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream."); - if (terminal) continue; - if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload); - if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload); - if (event === "status" && handlers.onStatus) handlers.onStatus(payload); - if (event === "done" && handlers.onDone) handlers.onDone(payload); - if (event === "error" && handlers.onError) handlers.onError(payload); - if (event === "done" || event === "error") terminal = true; + if (event === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new Error("Invalid response stream."); + if (event === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new Error("Invalid response stream."); + if (event === "replace" && !["reply", "thinking"].includes(payload.target)) throw new Error("Invalid replacement stream."); + if (event === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR"); + const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[event]; + if (handler) handlers[handler]?.(payload); + if (event === "done") { terminal = true; break; } } - if (terminal) break; } - // Ohne explizites Abschluss- oder Fehlerereignis gilt die Verbindung als vorzeitig abgebrochen. - if (!terminal) throw new Error("Response was interrupted. Please try again."); + if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED"); + } catch (error) { + const failure = normalizeError(error, scope, response); + if (handlers.onError) handlers.onError(failure.toJSON()); + else throw failure; } finally { - await reader.cancel().catch(() => {}); - reader.releaseLock(); + scope.close(); + if (reader) { + // Cancelling a custom or stalled source must not prevent the request from settling. + void reader.cancel().catch(() => {}); + reader.releaseLock(); + } } } diff --git a/public/js/attachments.js b/public/js/attachments.js index 3fd88e3..152f589 100644 --- a/public/js/attachments.js +++ b/public/js/attachments.js @@ -129,6 +129,15 @@ export function createAttachmentComposer({onChange, onError}) { drafts.delete(chatId); render(); }, + clearSentDraft(chatId, ids) { + const saved = drafts.get(chatId) || []; + const sent = new Set(ids); + drafts.set(chatId, saved.filter(item => { + if (!sent.has(item.metadata?.id)) return true; + URL.revokeObjectURL(item.preview); + return false; + })); + }, discardDraft(chatId) { const saved = drafts.get(chatId) || []; drafts.delete(chatId); diff --git a/public/js/composerController.js b/public/js/composerController.js new file mode 100644 index 0000000..a7d5879 --- /dev/null +++ b/public/js/composerController.js @@ -0,0 +1,194 @@ +/** Composer state, send serialization, cancellation, and streaming presentation. */ +import * as defaultApi from "./api.js"; +import {updateReasoning, finishReasoning} from "./reasoning.js"; +import {createStreamRenderer} from "./streamRenderer.js"; + +export function createComposerController({ + state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle, + setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById, + updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom, + setMessageContent, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, + api = defaultApi +}) { + const {msgInput, sendBtn, messagesDiv, modelSelect} = elements; + let sendController = null; + + function updateSendState() { + const hasText = Boolean(msgInput.value.trim()); + sendBtn.disabled = state.isProcessing ? false : attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); + sendBtn.setAttribute("aria-label", state.isProcessing ? "Stop generation" : "Send message"); + } + + function resizeComposerInput() { + msgInput.style.height = "auto"; + msgInput.style.height = `${Math.min(Math.max(msgInput.scrollHeight, 46), 96)}px`; + } + + function setLoadingState(loading, overlayOptions = null) { + state.isProcessing = loading; + msgInput.disabled = loading; + webSearchButton.disabled = loading; + reasoningToggle.setBusy(loading); + attachmentComposer.setBusy(loading); + sendBtn.classList.toggle("loading", loading); + messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach(button => { + button.disabled = loading || button.dataset.retryLimitReached === "true"; + }); + messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach(button => { button.disabled = loading; }); + if (loading) { + msgInput.placeholder = "Processing response..."; + if (overlayOptions !== false) setChatActivity(true, overlayOptions || undefined); + } else { + setChatActivity(false); + updateComposerPlaceholder(); + } + updateSendState(); + } + + async function stopGeneration() { + if (!state.isProcessing) return; + if (sendController) { + sendController.abort(); + setNotice("Stopping generation..."); + return; + } + const chatId = state.regenerationChatId; + if (!chatId) return; + const res = await api.post(`/chats/${chatId}/stop`, {}); + if (res.error) return setNotice(res.error, "error"); + setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); + } + + async function sendMessage({retryMessage = null} = {}) { + if (state.isProcessing || attachmentComposer.pending) return; + const attachments = [...(retryMessage?.attachments || attachmentComposer.attachments)]; + const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : ""); + if (!message) return; + // Claim the send before any async work (including creating or renaming a chat). + state.isProcessing = true; + const model = modelSelect.value; + const webSearch = webSearchToggle.enabled, reasoning = reasoningToggle.enabled; + let chatId = state.activeChatId; + state.sendingChatId = chatId; + sendController = new AbortController(); + const {signal} = sendController; + const visible = () => state.activeChatId === chatId; + let userCard, loadingMsg, rendering, completedReply = null; + const userMessage = {role: "user", content: message, attachments}; + try { + setLoadingState(true, false); + if (retryMessage && !msgInput.value.trim()) { + msgInput.value = message; + resizeComposerInput(); + } + if (!chatId) { + const created = await createNewChat({preserveComposer: true, signal}); + signal.throwIfAborted(); + if (!created?.id) throw new Error(created?.error || "Unable to create chat."); + chatId = created.id; + state.sendingChatId = chatId; + } + const currentChat = getChatById(chatId); + if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") { + const title = message.length > 40 ? `${message.slice(0, 40)}...` : message; + const renamed = await api.put(`/chats/${chatId}`, {title}, {signal}); + signal.throwIfAborted(); + if (renamed.error) throw new Error(renamed.error); + currentChat.title = title; + updateWorkspaceCopy(); renderChatList(); + } + signal.throwIfAborted(); + if (visible()) { + state.currentMessages.push(userMessage); + userCard = addMessage(userMessage, true); + loadingMsg = addMessage({role: "bot", content: ""}, false, true, {isNewest: true}); + } + updateChatActionState(); setNotice("Generating reply..."); + const loadingBody = loadingMsg?.querySelector(".msg-content"); + const loadingShell = loadingMsg?.querySelector(".loading-shell"); + const loadingLabel = loadingMsg?.querySelector(".loading-copy strong"); + if (loadingLabel) loadingLabel.textContent = "Preparing response..."; + const render = operation => { + if (!visible() || !loadingMsg?.isConnected) return; + const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; + operation(); + if (follow) scrollMessagesToBottom(); + }; + rendering = createStreamRenderer({ + onThinking: text => render(() => { + if (!reasoning) return; + updateReasoning(loadingMsg, text, true); + loadingShell?.classList.toggle("hidden", Boolean(text)); + }), + onReply: text => render(() => { + if (text) finishReasoning(loadingMsg); + loadingShell?.classList.toggle("hidden", Boolean(text)); + if (loadingLabel) loadingLabel.textContent = "Generating response..."; + loadingBody?.classList.add("has-content"); + if (loadingBody) setMessageContent(loadingBody, text); + }) + }); + await api.stream("/chat/stream", {message, attachments: attachments.map(item => item.id), model, chatId, webSearch, reasoning}, { + ...rendering, + onStatus: ({status, sourceCount, searchState}) => { + if (status === "accepted" || !visible() || !loadingLabel) return; + loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..." + : searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..." + : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; + }, + onDone: ({reply}) => { completedReply = reply; } + }, {signal}); + rendering.dispose(); + loadingMsg?.remove(); + webSearchToggle.reset(); reasoningToggle.reset(); + if (visible()) { + const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; + const previousScrollTop = messagesDiv.scrollTop; + if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); } + const currentAttachments = attachmentComposer.attachments; + if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear(); + await setActiveChat(chatId); + if (!followOutput && visible()) requestAnimationFrame(() => { + if (visible()) messagesDiv.scrollTop = previousScrollTop; + }); + } else { + if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId); + attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id)); + } + await loadSummary(); + markOnboardingCompleted(); maybeShowPersonaNudge(); + setNotice("Reply received.", "success"); + } catch (error) { + loadingMsg?.remove(); + const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message."; + if (completedReply !== null) { + if (visible() && userCard?.isConnected) { + state.currentMessages.push({role: "bot", content: completedReply}); + addMessage(completedReply); + } + setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error"); + } else { + if (visible()) { + if (!msgInput.value.trim()) { msgInput.value = message; resizeComposerInput(); } + userMessage.deliveryStatus = "failed"; + userMessage.errorMessage = messageText; + if (userCard?.isConnected) userCard.replaceWith(addMessage(userMessage, true, false, {detached: true})); + } + setNotice(messageText, "error"); + } + } finally { + rendering?.dispose(); + sendController = null; + state.sendingChatId = null; + setLoadingState(false); msgInput.focus(); updateChatActionState(); + } + } + + return { + updateSendState, + resizeComposerInput, + setLoadingState, + stopGeneration, + sendMessage + }; +} diff --git a/public/js/conversationController.js b/public/js/conversationController.js new file mode 100644 index 0000000..5ad98b1 --- /dev/null +++ b/public/js/conversationController.js @@ -0,0 +1,184 @@ +import {chatSearchInput, msgInput, messagesDiv} from "./dom.js?v=20260909-account-controls"; +import {get, post} from "./api.js?v=20260909-account-controls"; +import {requestedChatId} from "./constants.js?v=20260909-account-controls"; + +export function createConversationController({ + state, + renderChatList, + attachmentComposer, + resizeComposerInput, + isMobileLayout, + closeMobileSidebar, + setChatLoading, + setChatActivity, + updateWorkspaceCopy, + updateChatActionState, + setNotice, + populateGenerationSettings, + renderMessages, + updateJumpToLatest, + updateSendState, + loadSummary +}) { + function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); } + + async function openCreatedChat(chat) { + if (!chat?.id) throw new Error("The server did not return the newly created chat"); + if (chatSearchInput) { + chatSearchInput.value = ""; + scheduleChatSearch(); + } + const chatsResponse = await get("/chats"); + if (chatsResponse.error) throw new Error(chatsResponse.error); + state.chatSessions = chatsResponse.chats || []; + if (!state.chatSessions.some((item) => item.id === chat.id)) { + throw new Error("The newly created chat was not persisted"); + } + renderChatList(); + await setActiveChat(chat.id); + } + + async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) { + const openRequest = ++state.chatOpenRequest; + if (!preserveComposer && id !== state.activeChatId) { + if (state.activeChatId != null) { + state.chatDrafts.set(state.activeChatId, msgInput.value); + attachmentComposer.saveDraft(state.activeChatId); + msgInput.value = state.chatDrafts.get(id) || ""; + attachmentComposer.restoreDraft(id); + resizeComposerInput(); + } else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) { + msgInput.value = state.chatDrafts.get(id) || ""; + attachmentComposer.restoreDraft(id); + resizeComposerInput(); + } + } + if (isMobileLayout()) closeMobileSidebar(); + setChatLoading(true, "Opening chat..."); + setChatActivity(true, { + eyebrow: "Opening chat", + title: "Loading conversation", + detail: "Fetching messages and restoring the current scene." + }); + state.activeChatId = id; + history.replaceState(null, "", `/app/chats/${id}`); + state.messageRetryState = new Map(); + updateWorkspaceCopy(); renderChatList(); updateChatActionState(); + let res = await get(`/chats/${id}/messages`); + if (openRequest !== state.chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; } + if (res.error) { + setChatLoading(false); + setChatActivity(false); + return setNotice(res.error, "error"); + } + if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) { + const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true}); + if (!activated.error) res = await get(`/chats/${id}/messages`); + if (openRequest !== state.chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; } + } + const existing = getChatById(id); + if (res.chat && existing) Object.assign(existing, res.chat); + populateGenerationSettings(res.chat || existing); + state.currentMessages = res.messages || []; renderMessages(); + if (matchMessageId) requestAnimationFrame(() => { + const target = [...messagesDiv.querySelectorAll("[data-message-id]")] + .find(node => Number(node.dataset.messageId) === Number(matchMessageId)); + if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); } + }); + updateSendState(); + setChatLoading(false); + setChatActivity(false); + } + + async function loadChatSessions() { + setChatLoading(true, "Loading chats..."); + const res = await get("/chats"); + if (res.error) { + setChatLoading(false); + return setNotice(res.error, "error"); + } + state.chatSessions = res.chats || []; renderChatList(); + if (state.activeChatId && getChatById(state.activeChatId)) { + updateWorkspaceCopy(); + setChatLoading(false); + return; + } + if (location.pathname.endsWith("/new") && !state.activeChatId) { state.currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; } + if (requestedChatId && !getChatById(requestedChatId) && !state.activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); } + if (state.chatSessions.length) { + const preferredChatId = state.activeChatId && getChatById(state.activeChatId) ? state.activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : state.chatSessions[0].id; + setChatLoading(false); + return setActiveChat(preferredChatId); + } + state.activeChatId = null; + state.currentMessages = []; + renderMessages(); + updateWorkspaceCopy(); + updateSendState(); + setChatLoading(false); + return setNotice("Pick a starting action to begin.", "success"); + } + + async function searchChatSessions(query, requestId) { + const res = await get(`/chats/search?q=${encodeURIComponent(query)}`); + if (requestId !== state.chatSearchRequest || chatSearchInput.value.trim() !== query) return; + if (res.error) { + state.chatSearchError = "Unable to search chats."; + renderChatList(); + setNotice(res.error, "error"); + return; + } + state.chatSearchResults = res.chats || []; + renderChatList(); + } + + function scheduleChatSearch() { + clearTimeout(state.chatSearchTimer); + const requestId = ++state.chatSearchRequest; + const query = chatSearchInput.value.trim(); + state.chatSearchResults = null; + state.chatSearchError = ""; + if (!query) { + renderChatList(); + return; + } + renderChatList(); + state.chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300); + } + + async function createNewChat({preserveComposer = false, signal} = {}) { + const previousChatId = state.activeChatId; + setChatLoading(true, "Creating chat..."); + setChatActivity(true, { + eyebrow: "Creating chat", title: "Opening a new conversation", + detail: "Setting up a fresh chat so you can start sending messages." + }); + try { + const res = await post("/chats", {title: "New chat"}, {signal}); + if (res.error || !res.chat) { + const error = res.error || "Unable to create chat."; + setNotice(error, "error"); + return {error}; + } + state.chatSessions.unshift(res.chat); + await loadSummary(); renderChatList(); + if (!signal?.aborted && state.activeChatId === previousChatId) { + await setActiveChat(res.chat.id, {preserveComposer}); + msgInput.focus(); + } + setNotice("New chat created.", "success"); + return res.chat; + } finally { + setChatLoading(false); setChatActivity(false); + } + } + + return { + getChatById, + openCreatedChat, + setActiveChat, + loadChatSessions, + scheduleChatSearch, + createNewChat + }; +} diff --git a/public/js/conversationState.js b/public/js/conversationState.js new file mode 100644 index 0000000..a6c4ff0 --- /dev/null +++ b/public/js/conversationState.js @@ -0,0 +1,28 @@ +/** Shared conversation data; controllers receive an instance rather than owning globals. */ +export function createConversationState() { + return { + isProcessing: false, + activeChatId: null, + editingPersonaId: null, + editingPersonaType: "assistant", + currentUsername: "", + regenerationChatId: null, + chatSessions: [], + currentMessages: [], + assistantPersonas: [], + userPersonas: [], + activeUserPersonaId: null, + currentSummary: null, + publishedPersonaIds: new Set(), + roleplayPresetCharacterId: null, + roleplayStarterStep: 1, + messageRetryState: new Map(), + chatSearchResults: null, + chatSearchError: "", + chatSearchTimer: null, + chatSearchRequest: 0, + chatOpenRequest: 0, + chatDrafts: new Map(), + sendingChatId: null + }; +} diff --git a/public/js/messageRenderer.js b/public/js/messageRenderer.js new file mode 100644 index 0000000..841e4cc --- /dev/null +++ b/public/js/messageRenderer.js @@ -0,0 +1,342 @@ +import {messagesDiv, msgInput} from "./dom.js?v=20260909-account-controls"; +import {appendAttachments} from "./attachments.js?v=20260930-chat-usability"; +import {updateReasoning} from "./reasoning.js?v=20260921-reasoning-capability"; +import {appendSources} from "./webSearch.js"; + +export function createMessageRenderer({ + getChatById, + state, + setNotice, + sendMessage, + editChatMessage, + deleteChatMessage, + branchFromMessage, + retryLatestAssistantMessage, + switchResponseVersion, + switchRetryVariant, + renderDrawerSummary, + updateChatActionState, + updateComposerPlaceholder, + createNewChat, + openRoleplayStarter, + openPersonaForm +}) { + const jumpToLatest = document.getElementById("jumpToLatest"); + + let scrollFrame = null; + + let renderedChatId = null; + + let renderedMessages = new Map(); + + function setMessageContent(el, content) { + const markdownParser = window.marked; + const purifier = window.DOMPurify; + if (markdownParser?.parse && purifier?.sanitize) { + el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true })); + } else { + el.textContent = content; + } + } + + function scrollMessagesToBottom() { + if (scrollFrame !== null) cancelAnimationFrame(scrollFrame); + scrollFrame = requestAnimationFrame(() => { + scrollFrame = null; + messagesDiv.scrollTop = messagesDiv.scrollHeight; + updateJumpToLatest(); + }); + } + + function updateJumpToLatest() { + const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight; + jumpToLatest.classList.toggle("hidden", distance < 180); + } + + function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) { + const msgDiv = document.createElement("div"); + msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`; + msgDiv.setAttribute("role", "article"); + msgDiv.tabIndex = 0; + if (isLoading) { + msgDiv.setAttribute("aria-label", "Assistant is responding"); + msgDiv.innerHTML = ` +
Assistant
+
+
+ + + + +
+
+ Thinking +
+
+
`; + } else { + const message = typeof contentOrMessage === "object" && contentOrMessage + ? contentOrMessage + : { id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "") }; + const isMessageUser = message.role === "user"; + msgDiv.dataset.messageId = message.id; + msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`; + msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message"); + const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div"); + header.className = "msg-header"; body.className = "msg-content"; + actions.className = "msg-actions"; + actions.setAttribute("aria-label", "Message actions"); + const activeChat = getChatById(state.activeChatId); + header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); + setMessageContent(body, message.content); + + const copyBtn = document.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "msg-action-btn"; + copyBtn.textContent = "Copy"; + copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`); + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(message.content || ""); + setNotice("Message copied.", "success"); + } catch { + setNotice("Unable to copy message.", "error"); + } + }); + actions.appendChild(copyBtn); + + if (message.deliveryStatus === "failed" && isMessageUser) { + const retryFailedBtn = document.createElement("button"); + retryFailedBtn.type = "button"; + retryFailedBtn.className = "msg-action-btn"; + retryFailedBtn.dataset.messageAction = "retry-failed"; + retryFailedBtn.textContent = "Retry"; + retryFailedBtn.disabled = state.isProcessing; + retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); }); + actions.appendChild(retryFailedBtn); + } + + const menu = document.createElement("details"); + menu.className = "msg-action-menu"; + const menuTrigger = document.createElement("summary"); + menuTrigger.className = "msg-action-btn"; + menuTrigger.textContent = "More"; + menuTrigger.setAttribute("aria-label", "More message actions"); + const menuItems = document.createElement("div"); + menuItems.className = "msg-action-menu-items"; + menuItems.addEventListener("click", () => { menu.open = false; }); + menu.append(menuTrigger, menuItems); + if (message.id) actions.appendChild(menu); + + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.className = "msg-action-btn"; + editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit"; + editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`); + editBtn.addEventListener("click", () => { + void editChatMessage(message.id, options.messageIndex ?? -1); + }); + menuItems.appendChild(editBtn); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "msg-action-btn"; + deleteBtn.textContent = "Delete"; + deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`); + deleteBtn.addEventListener("click", () => { + void deleteChatMessage(message.id, options.messageIndex ?? -1); + }); + menuItems.appendChild(deleteBtn); + + const branchBtn = document.createElement("button"); + branchBtn.type = "button"; + branchBtn.className = "msg-action-btn"; + branchBtn.textContent = "Branch here"; + branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); }); + menuItems.appendChild(branchBtn); + + if (message.role === "bot") { + const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1); + const retryBtn = document.createElement("button"); + retryBtn.type = "button"; + retryBtn.className = "msg-action-btn"; + retryBtn.dataset.messageAction = "regenerate"; + retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5); + retryBtn.textContent = `Regenerate (${retriesUsed}/5)`; + retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`); + retryBtn.disabled = retriesUsed >= 5 || state.isProcessing; + retryBtn.addEventListener("click", () => { + void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1); + }); + actions.appendChild(retryBtn); + } + + for (const versioned of [true, false]) { + if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue; + const navigate = versioned ? switchResponseVersion : switchRetryVariant; + const prevBtn = document.createElement("button"); + prevBtn.type = "button"; + prevBtn.className = "msg-action-btn"; + prevBtn.textContent = "Prev"; + prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch"); + prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId; + prevBtn.addEventListener("click", () => { + void navigate(message.id, -1, options.messageIndex ?? -1); + }); + + const nextBtn = document.createElement("button"); + nextBtn.type = "button"; + nextBtn.className = "msg-action-btn"; + nextBtn.textContent = "Next"; + nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch"); + nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId; + nextBtn.addEventListener("click", () => { + void navigate(message.id, 1, options.messageIndex ?? -1); + }); + + const indexBadge = document.createElement("span"); + indexBadge.className = "msg-variant-index"; + indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`; + actions.append(prevBtn, indexBadge, nextBtn); + } + + msgDiv.append(header); + appendAttachments(msgDiv, message.attachments); + msgDiv.append(body); + if (!isMessageUser) updateReasoning(msgDiv, message.thinking); + if (!isMessageUser) appendSources(msgDiv, message.sources); + if (message.deliveryStatus === "failed" && message.errorMessage) { + const error = document.createElement("p"); + error.className = "status error"; + error.textContent = message.errorMessage; + msgDiv.appendChild(error); + } + if (actions.childElementCount) { + msgDiv.appendChild(actions); + } + } + if (!options.detached) { + messagesDiv.appendChild(msgDiv); + scrollMessagesToBottom(); + } + return msgDiv; + } + + function renderMessages() { + if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } + const sameChat = renderedChatId === state.activeChatId; + const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100; + const previousScrollTop = messagesDiv.scrollTop; + const chat = getChatById(state.activeChatId); + if (!state.currentMessages.length && (!chat || chat.assistant_persona_id)) { + messagesDiv.replaceChildren(); + renderMessageEmptyState(); + renderedMessages = new Map(); + } else { + const fragment = document.createDocumentFragment(); + const nextRendered = new Map(); + state.currentMessages.forEach((msg, index) => { + const key = msg.id ? String(msg.id) : null; + const snapshot = JSON.stringify(msg); + const previous = sameChat && key ? renderedMessages.get(key) : null; + const card = previous?.snapshot === snapshot && previous.node.isConnected + ? previous.node + : addMessage(msg, msg.role === "user", false, { + messageIndex: index, + isNewest: index === state.currentMessages.length - 1, + detached: true + }); + fragment.append(card); + if (key) nextRendered.set(key, {snapshot, node: card}); + }); + messagesDiv.replaceChildren(fragment); + renderedMessages = nextRendered; + } + renderedChatId = state.activeChatId; + renderDrawerSummary(); + updateChatActionState(); + updateComposerPlaceholder(); + if (wasReadingEarlier) { + messagesDiv.scrollTop = previousScrollTop; + updateJumpToLatest(); + } else if (state.currentMessages.length) { + scrollMessagesToBottom(); + } else { + updateJumpToLatest(); + } + } + + function createStarterAction(label, className, handler) { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = label; + button.addEventListener("click", handler); + return button; + } + + function createMessageStarterPrompt(label, prompt) { + return createStarterAction(label, "secondary-action", async () => { + if (!state.activeChatId) await createNewChat(); + msgInput.value = prompt; + msgInput.dispatchEvent(new Event("input")); + msgInput.focus(); + }); + } + + function renderMessageEmptyState() { + const empty = document.createElement("section"); + const title = document.createElement("h3"); + const description = document.createElement("p"); + const actions = document.createElement("div"); + const chat = getChatById(state.activeChatId); + + empty.className = "messages-empty"; + title.className = "messages-empty-title"; + description.className = "messages-empty-copy"; + actions.className = "messages-empty-actions"; + + if (!chat) { + title.textContent = "Where shall we start?"; + description.textContent = "Choose a starting point."; + actions.append( + createStarterAction("New chat", "primary-action", () => { void createNewChat(); }), + createStarterAction("Roleplay", "secondary-action", () => { + if (state.assistantPersonas.length) { + openRoleplayStarter(); + return; + } + openPersonaForm(null, "assistant"); + }), + createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.") + ); + } else if (chat.assistant_persona_id) { + title.textContent = "Roleplay is ready"; + description.textContent = "Send a line or pick a prompt."; + actions.append( + createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."), + createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene."), + createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter()) + ); + } else { + title.textContent = "What can I help with?"; + description.textContent = "Write a message below or start with one of these prompts."; + actions.append( + createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "), + createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "), + createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ") + ); + } + + empty.append(title, description, actions); + messagesDiv.appendChild(empty); + } + + return { + setMessageContent, + scrollMessagesToBottom, + updateJumpToLatest, + addMessage, + renderMessages + }; +} diff --git a/public/js/personaController.js b/public/js/personaController.js new file mode 100644 index 0000000..a3074cf --- /dev/null +++ b/public/js/personaController.js @@ -0,0 +1,422 @@ +import { + personaFormNotice, + roleplayCharacterSelect, + roleplayUserPersonaSelect, + roleplayStarterSuggestions, + roleplayScenarioInput, + roleplayStarterNotice, + roleplayStarterStepOne, + roleplayStarterStepTwo, + roleplayStarterBack, + roleplayStarterTitle, + roleplayStarterIntroCopy, + roleplayStarterCancel, + roleplayStarterConfirm, + activeUserPersonaStatus, + chatDrawerPersonaValue, + headerPersonaName, + clearUserPersonaBtn, + personaModal, + personaNameInput, + personaFormTitle, + personaTypeSelect, + personaPronounsInput, + personaAppearanceInput, + personaBackgroundInput, + personaDetailsInput, + personaExampleDialoguesInput, + personaExamplesField, + roleplayStarterModal, + userPersonaList, + modelSelect +} from "./dom.js?v=20260909-account-controls"; +import {closeChatDrawer} from "./chatDrawer.js?v=20260909-account-controls"; +import {openModal, closeModal} from "./modalFocus.js"; +import {get, post, put, del} from "./api.js?v=20260909-account-controls"; + +export function createPersonaController({ + state, + setNotice, + closePopup, + updateWorkspaceCopy, + loadChatSessions, + confirmPopup, + setChatLoading, + setChatActivity, + setActiveChat +}) { + function setPersonaFormNotice(message = "", noticeState = "") { + if (!personaFormNotice) return; + if (!message) { + personaFormNotice.textContent = ""; + personaFormNotice.className = "status hidden"; + return; + } + personaFormNotice.textContent = message; + personaFormNotice.className = noticeState ? `status ${noticeState}` : "status"; + } + + function getPersonaName(id, items) { + return items.find((persona) => persona.id === id)?.name || ""; + } + + function getRoleplayStarterSuggestions() { + const assistantPersonaId = Number(roleplayCharacterSelect?.value); + const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null; + const assistantName = getPersonaName(assistantPersonaId, state.assistantPersonas) || "the character"; + const userName = userPersonaId ? getPersonaName(userPersonaId, state.userPersonas) : "me"; + return [ + `${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`, + `${assistantName} already knows something important about ${userName} and confronts them with it.`, + `${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`, + `${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`, + `${assistantName} draws ${userName} into a high-stakes problem that starts immediately.` + ]; + } + + function updateRoleplaySuggestions() { + if (!roleplayStarterSuggestions) return; + roleplayStarterSuggestions.innerHTML = ""; + getRoleplayStarterSuggestions().forEach((suggestion) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "prompt-chip"; + button.textContent = suggestion; + button.addEventListener("click", () => { + if (roleplayScenarioInput) { + roleplayScenarioInput.value = suggestion; + roleplayScenarioInput.focus(); + } + }); + roleplayStarterSuggestions.appendChild(button); + }); + } + + function setRoleplayStarterNotice(message = "", noticeState = "") { + if (!roleplayStarterNotice) return; + if (!message) { + roleplayStarterNotice.textContent = ""; + roleplayStarterNotice.className = "status hidden"; + return; + } + roleplayStarterNotice.textContent = message; + roleplayStarterNotice.className = noticeState ? `status ${noticeState}` : "status"; + } + + function renderRoleplayStarterStep() { + const isSceneStep = state.roleplayStarterStep === 2; + if (roleplayStarterStepOne) roleplayStarterStepOne.classList.toggle("hidden", isSceneStep); + if (roleplayStarterStepTwo) roleplayStarterStepTwo.classList.toggle("hidden", !isSceneStep); + if (roleplayStarterBack) roleplayStarterBack.classList.toggle("hidden", !isSceneStep); + if (roleplayStarterTitle) { + roleplayStarterTitle.textContent = isSceneStep ? "Shape the opening scene" : "Choose your roleplay"; + } + if (roleplayStarterIntroCopy) { + roleplayStarterIntroCopy.textContent = isSceneStep + ? "Add an optional custom scenario. If you leave it empty, the AI Character will invent its own opening scene." + : "Pick a character and choose who you are before moving on to the opening scene."; + } + if (roleplayStarterCancel) { + roleplayStarterCancel.textContent = isSceneStep ? "Close" : "Cancel"; + } + if (roleplayStarterConfirm) { + roleplayStarterConfirm.textContent = isSceneStep ? "Start roleplay" : "Continue"; + } + } + + function renderPersonaSection() { + updateActivePersonaStatus(state.userPersonas, state.activeUserPersonaId, activeUserPersonaStatus, "No default user persona set.", "Default user persona"); + if (chatDrawerPersonaValue) { + const active = state.userPersonas.find((persona) => persona.id === state.activeUserPersonaId); + chatDrawerPersonaValue.textContent = active ? active.name : "None"; + if (headerPersonaName) headerPersonaName.textContent = active ? active.name : "None"; + } + if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !state.activeUserPersonaId; + } + + function maybeShowPersonaNudge() { + if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return; + const assistantOnlyChats = state.chatSessions.filter((chat) => !chat.assistant_persona_id).length; + if (assistantOnlyChats < 3) return; + localStorage.setItem("krishd-persona-nudge-shown", "true"); + setNotice("Want to personalize responses? Try personas in Tools.", "success"); + } + + function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix) { + if (!statusEl) return; + const active = items.find((persona) => persona.id === activeId); + statusEl.textContent = active ? `${prefix}: ${active.name}` : emptyText; + } + + function openPersonaForm(persona = null, personaType = "assistant") { + closeChatDrawer(); + closePopup(false); + openModal(personaModal, personaNameInput); + if (persona) { + state.editingPersonaId = persona.id; state.editingPersonaType = persona.persona_type || "assistant"; + personaFormTitle.textContent = `Edit ${state.editingPersonaType === "user" ? "your persona" : "AI Character"}`; + personaTypeSelect.value = state.editingPersonaType; personaTypeSelect.disabled = true; + personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || ""; + personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || ""; + personaExampleDialoguesInput.value = persona.example_dialogues || ""; + } else { + state.editingPersonaId = null; state.editingPersonaType = personaType; + personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`; + personaTypeSelect.value = personaType; personaTypeSelect.disabled = false; + personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = ""; + personaExampleDialoguesInput.value = ""; + } + personaExamplesField.classList.toggle("hidden", personaTypeSelect.value !== "assistant"); + setPersonaFormNotice(""); + personaNameInput.focus(); + } + + function closePersonaForm() { closeModal(personaModal); state.editingPersonaId = null; state.editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); } + + function populateRoleplayStarter() { + if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return; + roleplayCharacterSelect.innerHTML = ""; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Choose AI Character"; + placeholder.disabled = true; + placeholder.selected = true; + roleplayCharacterSelect.appendChild(placeholder); + state.assistantPersonas.forEach((persona) => { + const option = document.createElement("option"); + option.value = String(persona.id); + option.textContent = persona.name; + option.selected = persona.id === state.roleplayPresetCharacterId; + roleplayCharacterSelect.appendChild(option); + }); + if (state.roleplayPresetCharacterId) { + roleplayCharacterSelect.value = String(state.roleplayPresetCharacterId); + } + roleplayUserPersonaSelect.innerHTML = ""; + const noneOption = document.createElement("option"); + noneOption.value = ""; + noneOption.textContent = "Myself (no user persona)"; + roleplayUserPersonaSelect.appendChild(noneOption); + state.userPersonas.forEach((persona) => { + const option = document.createElement("option"); + option.value = String(persona.id); + option.textContent = persona.name; + option.selected = persona.id === state.activeUserPersonaId; + roleplayUserPersonaSelect.appendChild(option); + }); + if (state.assistantPersonas.length === 0) { + setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); + roleplayStarterConfirm.disabled = true; + } else { + setRoleplayStarterNotice(""); + roleplayStarterConfirm.disabled = false; + } + updateRoleplaySuggestions(); + renderRoleplayStarterStep(); + } + + function openRoleplayStarter(characterId = null) { + closeChatDrawer(); + state.roleplayPresetCharacterId = characterId; + state.roleplayStarterStep = 1; + populateRoleplayStarter(); + if (roleplayScenarioInput) roleplayScenarioInput.value = ""; + setRoleplayStarterNotice(""); + openModal(roleplayStarterModal, roleplayCharacterSelect); + } + + function closeRoleplayStarter() { + if (!roleplayStarterModal) return; + closeModal(roleplayStarterModal); + state.roleplayPresetCharacterId = null; + state.roleplayStarterStep = 1; + roleplayStarterConfirm.disabled = false; + if (roleplayScenarioInput) roleplayScenarioInput.value = ""; + setRoleplayStarterNotice(""); + renderRoleplayStarterStep(); + } + + function goToRoleplayStarterSceneStep() { + if (!state.assistantPersonas.length) { + return setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); + } + if (!roleplayCharacterSelect.value) { + return setRoleplayStarterNotice("Choose a character to continue.", "error"); + } + state.roleplayStarterStep = 2; + setRoleplayStarterNotice(""); + renderRoleplayStarterStep(); + if (roleplayScenarioInput) roleplayScenarioInput.focus(); + } + + function renderPersonaList(items, activeId, listElement, personaType) { + if (!listElement) { + renderPersonaSection(); + return; + } + listElement.innerHTML = ""; + if (!items.length) { + const empty = document.createElement("p"); + empty.className = "status persona-status"; + empty.textContent = "No personas yet. Create one to get started."; + listElement.appendChild(empty); + renderPersonaSection(); + return; + } + items.forEach((persona) => { + const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4"); + const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div"); + item.className = `persona-item${personaType === "user" && persona.id === activeId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions"; + tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI"; + title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; + [[personaType === "assistant" ? "Start roleplay" : "Set default", () => equipPersona(persona.id, personaType), personaType === "user" && persona.id === activeId], + ["Edit", () => openPersonaForm(persona), false], + [state.publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false], + ["Delete", () => deletePersona(persona.id), false]] + .forEach(([label, handler, disabled]) => { + const btn = document.createElement("button"); + btn.type = "button"; btn.textContent = label; btn.disabled = disabled; btn.addEventListener("click", handler); actions.appendChild(btn); + }); + if (state.publishedPersonaIds.has(persona.id)) { + const btn = document.createElement("button"); + btn.type = "button"; btn.textContent = "Unpublish"; btn.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(btn); + } + titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item); + }); + renderPersonaSection(); + } + + async function loadPersonas() { + const res = await get("/personas"); + if (res.error) return setNotice(res.error, "error"); + state.assistantPersonas = res.assistantPersonas || []; state.userPersonas = res.userPersonas || []; + state.activeUserPersonaId = res.activeUserPersonaId || null; state.publishedPersonaIds = new Set(res.publishedPersonaIds || []); + renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user"); + populateRoleplayStarter(); + renderPersonaSection(); + updateWorkspaceCopy(); + } + + async function equipPersona(id, type) { + if (type === "assistant") { + openRoleplayStarter(id); + return; + } + const res = await post(`/personas/${id}/equip-user`, {}); + if (res.error) return setNotice(res.error, "error"); + state.activeUserPersonaId = id; + renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user"); + setNotice("User persona updated.", "success"); + } + + async function clearPersona(type) { + if (type !== "user") return; + const res = await post("/personas/user/clear", {}); + if (res.error) return setNotice(res.error, "error"); + state.activeUserPersonaId = null; + renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user"); + setNotice("Persona cleared.", "success"); + } + + async function savePersona() { + const payload = { + personaType: personaTypeSelect.value, + name: personaNameInput.value.trim(), + pronouns: personaPronounsInput.value.trim(), + appearance: personaAppearanceInput.value.trim(), + background: personaBackgroundInput.value.trim(), + details: personaDetailsInput.value.trim(), + exampleDialogues: personaTypeSelect.value === "assistant" ? personaExampleDialoguesInput.value.trim() : "" + }; + if (!payload.name) return setPersonaFormNotice("Persona name is required.", "error"); + const res = state.editingPersonaId ? await put(`/personas/${state.editingPersonaId}`, payload) : await post("/personas", payload); + if (res.error) return setPersonaFormNotice(res.error, "error"); + const wasEditing = Boolean(state.editingPersonaId); + await loadPersonas(); + if (payload.personaType === "assistant") await loadChatSessions(); + closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success"); + } + + async function deletePersona(id) { + const persona = state.assistantPersonas.find((item) => item.id === id) || state.userPersonas.find((item) => item.id === id); + if (!persona) return; + const confirmed = await confirmPopup({ + eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character", + title: "Delete persona", + description: `Delete "${persona.name}"? This removes it from your library and any active slot.`, + confirmLabel: "Delete", + danger: true + }); + if (!confirmed) return; + const res = await del(`/personas/${id}`); + if (res.error) return setNotice(res.error, "error"); + await loadPersonas(); + if (persona.persona_type === "assistant") await loadChatSessions(); + setNotice("Persona deleted.", "success"); + } + + async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); } + + async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); } + + async function startRoleplay() { + if (state.roleplayStarterStep === 1) { + goToRoleplayStarterSceneStep(); + return; + } + setChatLoading(true, "Starting roleplay..."); + setChatActivity(true, { + eyebrow: "Starting roleplay", + title: "Building the opening scene", + detail: "Generating the first beat and opening the roleplay chat." + }); + const assistantPersonaId = Number(roleplayCharacterSelect.value); + const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null; + const scenarioPrompt = roleplayScenarioInput?.value.trim() || ""; + if (!assistantPersonaId) { + setChatLoading(false); + setChatActivity(false); + return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error"); + } + roleplayStarterConfirm.disabled = true; + setRoleplayStarterNotice("Building the opening scene..."); + const res = await post("/roleplays/start", { + assistantPersonaId, + userPersonaId, + scenarioPrompt, + model: modelSelect.value + }); + roleplayStarterConfirm.disabled = false; + if (res.error || !res.chat) { + setChatLoading(false); + setChatActivity(false); + return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error"); + } + closeRoleplayStarter(); + await loadChatSessions(); + await setActiveChat(res.chat.id); + setNotice( + res.degraded + ? "Roleplay started, but the opening scene timed out. Send the first message to continue." + : res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", + res.degraded ? "error" : "success" + ); + setChatLoading(false); + setChatActivity(false); + } + + return { + updateRoleplaySuggestions, + setRoleplayStarterNotice, + renderRoleplayStarterStep, + maybeShowPersonaNudge, + openPersonaForm, + closePersonaForm, + openRoleplayStarter, + closeRoleplayStarter, + loadPersonas, + clearPersona, + savePersona, + startRoleplay + }; +} diff --git a/public/js/regeneration.js b/public/js/regeneration.js index 75f4f99..2fca699 100644 --- a/public/js/regeneration.js +++ b/public/js/regeneration.js @@ -4,6 +4,7 @@ import {stream} from "./api.js"; import {updateReasoning, finishReasoning} from "./reasoning.js"; import {appendSources} from "./webSearch.js"; +import {createStreamRenderer} from "./streamRenderer.js"; // Keep the original nodes (including action handlers) until a committed answer arrives. export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) { @@ -25,8 +26,17 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende // Anchor to this response, including when a long old answer collapses. update(() => card.replaceChildren(original[0].cloneNode(true), status, body)); card.setAttribute("aria-busy", "true"); + const rendering = createStreamRenderer({ + onThinking: text => update(() => updateReasoning(card, text, true)), + onReply: text => update(() => { + status.hidden = Boolean(text); + if (text) finishReasoning(card); + renderContent(body, text); + }) + }); try { await stream(endpoint, payload, { + ...rendering, onStatus: data => update(() => { status.textContent = data.status === "searching" ? "Searching the web..." : data.status === "analyzing" ? "Analyzing attachments..." : "Generating..."; @@ -35,20 +45,15 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende appendSources(card, data.sources); } }), - onThinking: ({fullThinking}) => update(() => updateReasoning(card, fullThinking, true)), - onChunk: ({fullReply}) => update(() => { - status.hidden = Boolean(fullReply); - if (fullReply) finishReasoning(card); - renderContent(body, fullReply); - }), - onError: ({error}) => { throw new Error(error || "Unable to regenerate message."); }, onDone: ({message}) => { if (!message) throw new Error("Missing regenerated response."); + rendering.dispose(); update(() => complete(message)); committed = true; } }); } finally { + rendering.dispose(); // Bei Fehler oder Abbruch die ursprünglichen Knoten samt Ereignishandlern wieder einsetzen. if (!committed) update(() => card.replaceChildren(...original)); card.removeAttribute("aria-busy"); diff --git a/public/js/script.js b/public/js/script.js index b8bf469..54de54f 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -1,25 +1,32 @@ +import {createMessageRenderer} from "./messageRenderer.js"; +import {createPersonaController} from "./personaController.js"; +import {createConversationController} from "./conversationController.js"; +import {createComposerController} from "./composerController.js"; +import {createConversationState} from "./conversationState.js"; /** * @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung. */ -import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls"; +import {del, get, post, put} from "./api.js?v=20260909-account-controls"; import { regenerateInline } from "./regeneration.js"; -import { appendSources, createWebSearchToggle } from "./webSearch.js"; -import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability"; -import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability"; +import {createWebSearchToggle} from "./webSearch.js"; +import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability"; +import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability"; import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls"; import { openModal, closeModal, topModal } from "./modalFocus.js"; -import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls"; +import {onboardingPrompts} from "./constants.js?v=20260909-account-controls"; import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls"; -import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls"; +import { + applyTheme as applySharedTheme, + applyThemeMode as applySharedThemeMode, + readStoredAppearance +} from "./themeController.js?v=20260909-account-controls"; import { activeChatTitle, - activeUserPersonaStatus, chatActivityDetail, chatActivityEyebrow, chatActivityOverlay, chatActivityTitle, chatCharacterPill, - chatDrawerPersonaValue, chatDiv, chatList, chatListLoading, @@ -42,7 +49,6 @@ import { managePersonaStateBtn, importWorkspaceFileInput, headerModelName, - headerPersonaName, moveChatFolderBtn, messagesDiv, modelBadgeName, @@ -63,19 +69,12 @@ import { onboardingTitle, pinChatBtn, personaCloseBtn, - personaDetailsInput, personaExampleDialoguesInput, personaExamplesField, personaForm, - personaFormNotice, - personaFormTitle, personaModal, - personaNameInput, personaSection, - personaPronounsInput, personaTypeSelect, - personaAppearanceInput, - personaBackgroundInput, popupCancelBtn, popupCloseBtn, popupConfirmBtn, @@ -89,72 +88,135 @@ import { renameChatBtn, roleplayCharacterSelect, roleplayNewPersonaBtn, - roleplayScenarioInput, roleplayStarterBack, roleplayStarterCancel, roleplayStarterClose, roleplayStarterConfirm, - roleplayStarterIntroCopy, roleplayStarterModal, - roleplayStarterNotice, - roleplayStarterStepOne, - roleplayStarterStepTwo, - roleplayStarterSuggestions, - roleplayStarterTitle, roleplayUserPersonaSelect, sendBtn, saveGenerationSettingsBtn, brandLogoTargets, - brandNameTargets, - userPersonaList + brandNameTargets } from "./dom.js?v=20260909-account-controls"; +const conversation = createConversationState(); + const headerModelButton = document.getElementById("headerModelButton"); const webSearchButton = document.getElementById("webSearch"); const webSearchToggle = createWebSearchToggle(webSearchButton); const reasoningToggle = createReasoningToggle(document.getElementById("reasoning")); -let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = ""; -let regenerationChatId = null; -const attachmentComposer = createAttachmentComposer({ onChange: updateSendState, onError: message => setNotice(message, "error") }); -let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = []; -let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set(); +const attachmentComposer = createAttachmentComposer({ onChange: () => updateSendState(), onError: message => setNotice(message, "error") }); + let popupResolver = null, popupMode = null, popupLastFocus = null; -let roleplayPresetCharacterId = null; -let roleplayStarterStep = 1; + let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; let onboardingStep = 1, onboardingIntent = "ask"; let chatLoadingDepth = 0; let chatActivityDepth = 0; -let messageRetryState = new Map(); -let availableModelsById = new Map(); -let chatSearchResults = null; -let chatSearchError = ""; -let chatSearchTimer = null; -let chatSearchRequest = 0; -let chatOpenRequest = 0; -const chatDrafts = new Map(); -const jumpToLatest = document.getElementById("jumpToLatest"); -let scrollFrame = null; -let renderedChatId = null; -let renderedMessages = new Map(); +let availableModelsById = new Map(); + +const {setMessageContent, scrollMessagesToBottom, updateJumpToLatest, addMessage, renderMessages} = createMessageRenderer({ + getChatById: (...args) => getChatById(...args), + state: conversation, + setNotice: (...args) => setNotice(...args), + sendMessage: (...args) => sendMessage(...args), + editChatMessage: (...args) => editChatMessage(...args), + deleteChatMessage: (...args) => deleteChatMessage(...args), + branchFromMessage: (...args) => branchFromMessage(...args), + retryLatestAssistantMessage: (...args) => retryLatestAssistantMessage(...args), + switchResponseVersion: (...args) => switchResponseVersion(...args), + switchRetryVariant: (...args) => switchRetryVariant(...args), + renderDrawerSummary: (...args) => renderDrawerSummary(...args), + updateChatActionState: (...args) => updateChatActionState(...args), + updateComposerPlaceholder: (...args) => updateComposerPlaceholder(...args), + createNewChat: (...args) => createNewChat(...args), + openRoleplayStarter: (...args) => openRoleplayStarter(...args), + openPersonaForm: (...args) => openPersonaForm(...args) +}); + +const { + updateRoleplaySuggestions, + setRoleplayStarterNotice, + renderRoleplayStarterStep, + maybeShowPersonaNudge, + openPersonaForm, + closePersonaForm, + openRoleplayStarter, + closeRoleplayStarter, + loadPersonas, + clearPersona, + savePersona, + startRoleplay +} = createPersonaController({ + state: conversation, + setNotice: (...args) => setNotice(...args), + closePopup: (...args) => closePopup(...args), + updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args), + loadChatSessions: (...args) => loadChatSessions(...args), + confirmPopup: (...args) => confirmPopup(...args), + setChatLoading: (...args) => setChatLoading(...args), + setChatActivity: (...args) => setChatActivity(...args), + setActiveChat: (...args) => setActiveChat(...args) +}); + +const { + getChatById, + openCreatedChat, + setActiveChat, + loadChatSessions, + scheduleChatSearch, + createNewChat +} = createConversationController({ + state: conversation, + renderChatList: (...args) => renderChatList(...args), + attachmentComposer, + resizeComposerInput: (...args) => resizeComposerInput(...args), + isMobileLayout: (...args) => isMobileLayout(...args), + closeMobileSidebar: (...args) => closeMobileSidebar(...args), + setChatLoading: (...args) => setChatLoading(...args), + setChatActivity: (...args) => setChatActivity(...args), + updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args), + updateChatActionState: (...args) => updateChatActionState(...args), + setNotice: (...args) => setNotice(...args), + populateGenerationSettings: (...args) => populateGenerationSettings(...args), + renderMessages: (...args) => renderMessages(...args), + updateJumpToLatest: (...args) => updateJumpToLatest(...args), + updateSendState: (...args) => updateSendState(...args), + loadSummary: (...args) => loadSummary(...args) +}); + +const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({ + state: conversation, + elements: {msgInput, sendBtn, messagesDiv, modelSelect}, + attachmentComposer, + webSearchButton, + reasoningToggle, + setChatActivity: (...args) => setChatActivity(...args), + updateComposerPlaceholder: (...args) => updateComposerPlaceholder(...args), + setNotice: (...args) => setNotice(...args), + webSearchToggle, + createNewChat: (...args) => createNewChat(...args), + getChatById: (...args) => getChatById(...args), + updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args), + renderChatList: (...args) => renderChatList(...args), + addMessage: (...args) => addMessage(...args), + updateChatActionState: (...args) => updateChatActionState(...args), + scrollMessagesToBottom: (...args) => scrollMessagesToBottom(...args), + setMessageContent: (...args) => setMessageContent(...args), + setActiveChat: (...args) => setActiveChat(...args), + loadSummary: (...args) => loadSummary(...args), + markOnboardingCompleted: (...args) => markOnboardingCompleted(...args), + maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args) +}); function setNotice(message = "", state = "") { const notice = document.getElementById("workspaceNotice"); if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); } return { message, state }; } -function setPersonaFormNotice(message = "", state = "") { - if (!personaFormNotice) return; - if (!message) { - personaFormNotice.textContent = ""; - personaFormNotice.className = "status hidden"; - return; - } - personaFormNotice.textContent = message; - personaFormNotice.className = state ? `status ${state}` : "status"; -} function setChatLoading(active, message = "Loading chats...") { if (!chatListLoading || !chatListLoadingText) return; @@ -279,14 +341,14 @@ function renderOnboardingStep() { button.addEventListener("click", async () => { if (onboardingIntent === "roleplay") { closeOnboarding(); - if (assistantPersonas.length) { + if (conversation.assistantPersonas.length) { openRoleplayStarter(); } else { openPersonaForm(null, "assistant"); } return; } - if (!activeChatId) await createNewChat(); + if (!conversation.activeChatId) await createNewChat(); msgInput.value = template; msgInput.dispatchEvent(new Event("input")); msgInput.focus(); @@ -303,51 +365,9 @@ function maybeShowOnboarding() { renderOnboardingStep(); openModal(onboardingModal, onboardingChoices.querySelector("button")); } -function setMessageContent(el, content) { - const markdownParser = window.marked; - const purifier = window.DOMPurify; - if (markdownParser?.parse && purifier?.sanitize) { - el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true })); - } else { - el.textContent = content; - } -} -function getChatById(id) { return chatSessions.find((chat) => chat.id === id); } -function getPersonaName(id, items) { - return items.find((persona) => persona.id === id)?.name || ""; -} -function getRoleplayStarterSuggestions() { - const assistantPersonaId = Number(roleplayCharacterSelect?.value); - const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null; - const assistantName = getPersonaName(assistantPersonaId, assistantPersonas) || "the character"; - const userName = userPersonaId ? getPersonaName(userPersonaId, userPersonas) : "me"; - return [ - `${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`, - `${assistantName} already knows something important about ${userName} and confronts them with it.`, - `${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`, - `${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`, - `${assistantName} draws ${userName} into a high-stakes problem that starts immediately.` - ]; -} -function updateRoleplaySuggestions() { - if (!roleplayStarterSuggestions) return; - roleplayStarterSuggestions.innerHTML = ""; - getRoleplayStarterSuggestions().forEach((suggestion) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "prompt-chip"; - button.textContent = suggestion; - button.addEventListener("click", () => { - if (roleplayScenarioInput) { - roleplayScenarioInput.value = suggestion; - roleplayScenarioInput.focus(); - } - }); - roleplayStarterSuggestions.appendChild(button); - }); -} + function updateComposerPlaceholder() { - const activeChat = getChatById(activeChatId); + const activeChat = getChatById(conversation.activeChatId); if (!activeChat) { msgInput.placeholder = "Create a chat to begin..."; return; @@ -359,12 +379,6 @@ function updateComposerPlaceholder() { msgInput.placeholder = workspaceMode === "basic" ? "Ask anything..." : "Type your message..."; } -function updateSendState() { - const hasText = Boolean(msgInput.value.trim()); - sendBtn.disabled = isProcessing ? false : attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); - sendBtn.setAttribute("aria-label", isProcessing ? "Stop generation" : "Send message"); -} - function populateGenerationSettings(chat) { if (!chat) return; if (chat.preferred_model && Array.from(modelSelect.options).some((option) => option.value === chat.preferred_model)) { @@ -378,7 +392,7 @@ function populateGenerationSettings(chat) { } async function saveGenerationSettings() { - if (!activeChatId) return; + if (!conversation.activeChatId) return; const payload = { preferredModel: modelSelect.value || null, temperature: chatTemperatureInput.value, @@ -386,81 +400,61 @@ async function saveGenerationSettings() { responseLength: chatResponseLengthInput.value, systemInstruction: chatSystemInstructionInput.value }; - const res = await put(`/chats/${activeChatId}/generation-settings`, payload); + const res = await put(`/chats/${conversation.activeChatId}/generation-settings`, payload); if (res.error || !res.chat) return setNotice(res.error || "Unable to save generation settings.", "error"); - const chat = getChatById(activeChatId); + const chat = getChatById(conversation.activeChatId); if (chat) Object.assign(chat, res.chat); populateGenerationSettings(res.chat); setNotice("Generation settings saved.", "success"); } async function saveSelectedModel() { - if (!activeChatId || !modelSelect.value) return; - const chatId = activeChatId; + if (!conversation.activeChatId || !modelSelect.value) return; + const chatId = conversation.activeChatId; const selectedModel = modelSelect.value; const res = await put(`/chats/${chatId}/model`, { model: selectedModel }); if (res.error || !res.chat) return setNotice(res.error || "Unable to switch models.", "error"); const chat = getChatById(chatId); if (chat) Object.assign(chat, res.chat); - if (chatId === activeChatId && modelSelect.value === selectedModel) { + if (chatId === conversation.activeChatId && modelSelect.value === selectedModel) { renderModelSection(); setNotice(`Model switched to ${modelSelect.selectedOptions[0]?.textContent || selectedModel}.`, "success"); } } -function resizeComposerInput() { - msgInput.style.height = "auto"; - const nextHeight = Math.min(Math.max(msgInput.scrollHeight, 46), 96); - msgInput.style.height = `${nextHeight}px`; -} - -function scrollMessagesToBottom() { - if (scrollFrame !== null) cancelAnimationFrame(scrollFrame); - scrollFrame = requestAnimationFrame(() => { - scrollFrame = null; - messagesDiv.scrollTop = messagesDiv.scrollHeight; - updateJumpToLatest(); - }); -} - -function updateJumpToLatest() { - const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight; - jumpToLatest.classList.toggle("hidden", distance < 180); -} - messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true}); -jumpToLatest.addEventListener("click", scrollMessagesToBottom); +document.getElementById("jumpToLatest").addEventListener("click", scrollMessagesToBottom); function getMessageIndexById(messageId) { - return currentMessages.findIndex((message) => message.id === messageId); + return conversation.currentMessages.findIndex((message) => message.id === messageId); } async function resolveMessageTarget(messageId, fallbackIndex = -1) { if (messageId) { const index = getMessageIndexById(messageId); if (index >= 0) { - return { index, message: currentMessages[index] }; + return { index, message: conversation.currentMessages[index] }; } } - if (activeChatId) { - await setActiveChat(activeChatId); + if (conversation.activeChatId) { + await setActiveChat(conversation.activeChatId); } if (messageId) { const index = getMessageIndexById(messageId); if (index >= 0) { - return { index, message: currentMessages[index] }; + return { index, message: conversation.currentMessages[index] }; } } - if (fallbackIndex >= 0 && fallbackIndex < currentMessages.length) { - return { index: fallbackIndex, message: currentMessages[fallbackIndex] }; + if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) { + return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] }; } return null; } function getMessageEndpoint(target, suffix = "") { - if (!activeChatId || !target) return ""; - if (target.message?.id) return `/chats/${activeChatId}/messages/${target.message.id}${suffix}`; - if (target.index >= 0) return `/chats/${activeChatId}/messages/by-index/${target.index}${suffix}`; + if (!conversation.activeChatId || !target) return ""; + if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`; + if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`; return ""; } @@ -468,8 +462,8 @@ function getPreviousUserMessageId(messageId) { const index = getMessageIndexById(messageId); if (index <= 0) return null; for (let i = index - 1; i >= 0; i -= 1) { - if (currentMessages[i].role === "user" && currentMessages[i].id) { - return currentMessages[i].id; + if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) { + return conversation.currentMessages[i].id; } } return null; @@ -494,26 +488,10 @@ function getRetryStateFromMessage(message) { function syncRetryStateFromMessage(message) { if (!message?.id) return null; const state = getRetryStateFromMessage(message); - messageRetryState.set(message.id, state); + conversation.messageRetryState.set(message.id, state); return state; } -async function openCreatedChat(chat) { - if (!chat?.id) throw new Error("The server did not return the newly created chat"); - if (chatSearchInput) { - chatSearchInput.value = ""; - scheduleChatSearch(); - } - const chatsResponse = await get("/chats"); - if (chatsResponse.error) throw new Error(chatsResponse.error); - chatSessions = chatsResponse.chats || []; - if (!chatSessions.some((item) => item.id === chat.id)) { - throw new Error("The newly created chat was not persisted"); - } - renderChatList(); - await setActiveChat(chat.id); -} - async function editChatMessage(messageId, fallbackIndex = -1) { const target = await resolveMessageTarget(messageId, fallbackIndex); if (!target) return setNotice("Message is not ready yet. Try again.", "error"); @@ -536,7 +514,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) { confirmLabel: "Create branch" }); if (!confirmed) return; - const sourceChatId = activeChatId; + const sourceChatId = conversation.activeChatId; setLoadingState(true, { eyebrow: "Branching", title: "Editing and resending", @@ -562,8 +540,8 @@ async function editChatMessage(messageId, fallbackIndex = -1) { if (res.error || !res.message) { return setNotice(res.error || "Unable to edit message.", "error"); } - currentMessages[index] = { ...currentMessages[index], ...res.message }; - syncRetryStateFromMessage(currentMessages[index]); + conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message }; + syncRetryStateFromMessage(conversation.currentMessages[index]); renderMessages(); setNotice("Message updated.", "success"); } @@ -584,12 +562,12 @@ async function deleteChatMessage(messageId, fallbackIndex = -1) { if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error"); const res = await del(endpoint); if (res.error) return setNotice(res.error, "error"); - currentMessages = currentMessages.filter((item) => item.id !== message.id); + conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id); if (message.id) { - messageRetryState.delete(message.id); - for (const [key, state] of messageRetryState.entries()) { + conversation.messageRetryState.delete(message.id); + for (const [key, state] of conversation.messageRetryState.entries()) { if (state.promptMessageId === message.id) { - messageRetryState.delete(key); + conversation.messageRetryState.delete(key); } } } @@ -599,32 +577,32 @@ async function deleteChatMessage(messageId, fallbackIndex = -1) { } async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { - if (isProcessing) return; + if (conversation.isProcessing) return; const target = await resolveMessageTarget(messageId, fallbackIndex); - if (isProcessing) return; + if (conversation.isProcessing) return; if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error"); const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1); if (retriesUsed >= 5) { return setNotice("Retry limit reached (5).", "error"); } - const chatId = activeChatId; + const chatId = conversation.activeChatId; const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`); if (!card) return; const payload = { model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled }; - regenerationChatId = chatId; + conversation.regenerationChatId = chatId; setLoadingState(true, false); try { await regenerateInline({ card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload, renderContent: setMessageContent, complete: message => { - if (activeChatId !== chatId) return; + if (conversation.activeChatId !== chatId) return; const index = getMessageIndexById(message.id); if (index < 0) return; - currentMessages[index] = { ...currentMessages[index], ...message, thinking: message.thinking, sources: message.sources }; - syncRetryStateFromMessage(currentMessages[index]); - const rendered = addMessage(currentMessages[index], false, false, { messageIndex: index, detached: true }); + conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources }; + syncRetryStateFromMessage(conversation.currentMessages[index]); + const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true }); card.replaceChildren(...rendered.childNodes); } }); @@ -634,7 +612,7 @@ async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { } catch (error) { setNotice(error.message || "Unable to regenerate message.", "error"); } finally { - regenerationChatId = null; + conversation.regenerationChatId = null; setLoadingState(false); } } @@ -643,7 +621,7 @@ async function switchResponseVersion(messageId, direction, fallbackIndex = -1) { const target = await resolveMessageTarget(messageId, fallbackIndex); if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (Number(target.message.versionCount) > 1) { - const chatId = activeChatId; + const chatId = conversation.activeChatId; const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`); if (!card || card.getAttribute("aria-busy") === "true") return; const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]); @@ -652,12 +630,12 @@ async function switchResponseVersion(messageId, direction, fallbackIndex = -1) { try { const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {}); if (res.error) return setNotice(res.error, "error"); - if (activeChatId !== chatId) return; + if (conversation.activeChatId !== chatId) return; const index = getMessageIndexById(messageId); if (index < 0) return; - currentMessages[index] = { ...currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources }; - syncRetryStateFromMessage(currentMessages[index]); - const rendered = addMessage(currentMessages[index], false, false, { messageIndex: index, detached: true }); + conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources }; + syncRetryStateFromMessage(conversation.currentMessages[index]); + const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true }); card.replaceChildren(...rendered.childNodes); } finally { card.removeAttribute("aria-busy"); @@ -671,14 +649,14 @@ async function switchRetryVariant(messageId, direction, fallbackIndex = -1) { if (!target) return setNotice("Message is not ready yet. Try again.", "error"); const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId; if (!siblingId) return; - const res = await post(`/chats/${activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true }); + const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true }); if (res.error) return setNotice(res.error, "error"); - currentMessages = res.messages || []; + conversation.currentMessages = res.messages || []; renderMessages(); } async function branchFromMessage(messageId) { - if (!activeChatId || !messageId) return; + if (!conversation.activeChatId || !messageId) return; const confirmed = await confirmPopup({ eyebrow: "Create branch", title: "Create a new chat branch?", @@ -686,7 +664,7 @@ async function branchFromMessage(messageId) { confirmLabel: "Create branch" }); if (!confirmed) return; - const sourceChatId = activeChatId; + const sourceChatId = conversation.activeChatId; const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true }); if (res.error) return setNotice(res.error, "error"); await openCreatedChat(res.chat); @@ -694,287 +672,8 @@ async function branchFromMessage(messageId) { setNotice("Created and opened a new chat branch.", "success"); } -function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) { - const msgDiv = document.createElement("div"); - msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`; - msgDiv.setAttribute("role", "article"); - msgDiv.tabIndex = 0; - if (isLoading) { - msgDiv.setAttribute("aria-label", "Assistant is responding"); - msgDiv.innerHTML = ` -
Assistant
-
-
- - - - -
-
- Thinking -
-
-
`; - } else { - const message = typeof contentOrMessage === "object" && contentOrMessage - ? contentOrMessage - : { id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "") }; - const isMessageUser = message.role === "user"; - msgDiv.dataset.messageId = message.id; - msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`; - msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message"); - const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div"); - header.className = "msg-header"; body.className = "msg-content"; - actions.className = "msg-actions"; - actions.setAttribute("aria-label", "Message actions"); - const activeChat = getChatById(activeChatId); - header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); - setMessageContent(body, message.content); - - const copyBtn = document.createElement("button"); - copyBtn.type = "button"; - copyBtn.className = "msg-action-btn"; - copyBtn.textContent = "Copy"; - copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`); - copyBtn.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(message.content || ""); - setNotice("Message copied.", "success"); - } catch { - setNotice("Unable to copy message.", "error"); - } - }); - actions.appendChild(copyBtn); - - if (message.deliveryStatus === "failed" && isMessageUser) { - const retryFailedBtn = document.createElement("button"); - retryFailedBtn.type = "button"; - retryFailedBtn.className = "msg-action-btn"; - retryFailedBtn.dataset.messageAction = "retry-failed"; - retryFailedBtn.textContent = "Retry"; - retryFailedBtn.disabled = isProcessing; - retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); }); - actions.appendChild(retryFailedBtn); - } - - const menu = document.createElement("details"); - menu.className = "msg-action-menu"; - const menuTrigger = document.createElement("summary"); - menuTrigger.className = "msg-action-btn"; - menuTrigger.textContent = "More"; - menuTrigger.setAttribute("aria-label", "More message actions"); - const menuItems = document.createElement("div"); - menuItems.className = "msg-action-menu-items"; - menuItems.addEventListener("click", () => { menu.open = false; }); - menu.append(menuTrigger, menuItems); - if (message.id) actions.appendChild(menu); - - const editBtn = document.createElement("button"); - editBtn.type = "button"; - editBtn.className = "msg-action-btn"; - editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit"; - editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`); - editBtn.addEventListener("click", () => { - void editChatMessage(message.id, options.messageIndex ?? -1); - }); - menuItems.appendChild(editBtn); - - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - deleteBtn.className = "msg-action-btn"; - deleteBtn.textContent = "Delete"; - deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`); - deleteBtn.addEventListener("click", () => { - void deleteChatMessage(message.id, options.messageIndex ?? -1); - }); - menuItems.appendChild(deleteBtn); - - const branchBtn = document.createElement("button"); - branchBtn.type = "button"; - branchBtn.className = "msg-action-btn"; - branchBtn.textContent = "Branch here"; - branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); }); - menuItems.appendChild(branchBtn); - - if (message.role === "bot") { - const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1); - const retryBtn = document.createElement("button"); - retryBtn.type = "button"; - retryBtn.className = "msg-action-btn"; - retryBtn.dataset.messageAction = "regenerate"; - retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5); - retryBtn.textContent = `Regenerate (${retriesUsed}/5)`; - retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`); - retryBtn.disabled = retriesUsed >= 5 || isProcessing; - retryBtn.addEventListener("click", () => { - void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1); - }); - actions.appendChild(retryBtn); - } - - for (const versioned of [true, false]) { - if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue; - const navigate = versioned ? switchResponseVersion : switchRetryVariant; - const prevBtn = document.createElement("button"); - prevBtn.type = "button"; - prevBtn.className = "msg-action-btn"; - prevBtn.textContent = "Prev"; - prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch"); - prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId; - prevBtn.addEventListener("click", () => { - void navigate(message.id, -1, options.messageIndex ?? -1); - }); - - const nextBtn = document.createElement("button"); - nextBtn.type = "button"; - nextBtn.className = "msg-action-btn"; - nextBtn.textContent = "Next"; - nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch"); - nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId; - nextBtn.addEventListener("click", () => { - void navigate(message.id, 1, options.messageIndex ?? -1); - }); - - const indexBadge = document.createElement("span"); - indexBadge.className = "msg-variant-index"; - indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`; - actions.append(prevBtn, indexBadge, nextBtn); - } - - msgDiv.append(header); - appendAttachments(msgDiv, message.attachments); - msgDiv.append(body); - if (!isMessageUser) updateReasoning(msgDiv, message.thinking); - if (!isMessageUser) appendSources(msgDiv, message.sources); - if (message.deliveryStatus === "failed" && message.errorMessage) { - const error = document.createElement("p"); - error.className = "status error"; - error.textContent = message.errorMessage; - msgDiv.appendChild(error); - } - if (actions.childElementCount) { - msgDiv.appendChild(actions); - } - } - if (!options.detached) { - messagesDiv.appendChild(msgDiv); - scrollMessagesToBottom(); - } - return msgDiv; -} - -function renderMessages() { - if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } - const sameChat = renderedChatId === activeChatId; - const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100; - const previousScrollTop = messagesDiv.scrollTop; - const chat = getChatById(activeChatId); - if (!currentMessages.length && (!chat || chat.assistant_persona_id)) { - messagesDiv.replaceChildren(); - renderMessageEmptyState(); - renderedMessages = new Map(); - } else { - const fragment = document.createDocumentFragment(); - const nextRendered = new Map(); - currentMessages.forEach((msg, index) => { - const key = msg.id ? String(msg.id) : null; - const snapshot = JSON.stringify(msg); - const previous = sameChat && key ? renderedMessages.get(key) : null; - const card = previous?.snapshot === snapshot && previous.node.isConnected - ? previous.node - : addMessage(msg, msg.role === "user", false, { - messageIndex: index, - isNewest: index === currentMessages.length - 1, - detached: true - }); - fragment.append(card); - if (key) nextRendered.set(key, {snapshot, node: card}); - }); - messagesDiv.replaceChildren(fragment); - renderedMessages = nextRendered; - } - renderedChatId = activeChatId; - renderDrawerSummary(); - updateChatActionState(); - updateComposerPlaceholder(); - if (wasReadingEarlier) { - messagesDiv.scrollTop = previousScrollTop; - updateJumpToLatest(); - } else if (currentMessages.length) { - scrollMessagesToBottom(); - } else { - updateJumpToLatest(); - } -} - -function createStarterAction(label, className, handler) { - const button = document.createElement("button"); - button.type = "button"; - button.className = className; - button.textContent = label; - button.addEventListener("click", handler); - return button; -} - -function createMessageStarterPrompt(label, prompt) { - return createStarterAction(label, "secondary-action", async () => { - if (!activeChatId) await createNewChat(); - msgInput.value = prompt; - msgInput.dispatchEvent(new Event("input")); - msgInput.focus(); - }); -} - -function renderMessageEmptyState() { - const empty = document.createElement("section"); - const title = document.createElement("h3"); - const description = document.createElement("p"); - const actions = document.createElement("div"); - const chat = getChatById(activeChatId); - - empty.className = "messages-empty"; - title.className = "messages-empty-title"; - description.className = "messages-empty-copy"; - actions.className = "messages-empty-actions"; - - if (!chat) { - title.textContent = "Where shall we start?"; - description.textContent = "Choose a starting point."; - actions.append( - createStarterAction("New chat", "primary-action", () => { void createNewChat(); }), - createStarterAction("Roleplay", "secondary-action", () => { - if (assistantPersonas.length) { - openRoleplayStarter(); - return; - } - openPersonaForm(null, "assistant"); - }), - createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.") - ); - } else if (chat.assistant_persona_id) { - title.textContent = "Roleplay is ready"; - description.textContent = "Send a line or pick a prompt."; - actions.append( - createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."), - createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene."), - createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter()) - ); - } else { - title.textContent = "What can I help with?"; - description.textContent = "Write a message below or start with one of these prompts."; - actions.append( - createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "), - createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "), - createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ") - ); - } - - empty.append(title, description, actions); - messagesDiv.appendChild(empty); -} - function updateWorkspaceCopy() { - const activeChat = getChatById(activeChatId); + const activeChat = getChatById(conversation.activeChatId); if (activeChatTitle) { activeChatTitle.textContent = activeChat ? activeChat.title : "New chat"; } @@ -983,40 +682,8 @@ function updateWorkspaceCopy() { updateComposerPlaceholder(); } -function setRoleplayStarterNotice(message = "", state = "") { - if (!roleplayStarterNotice) return; - if (!message) { - roleplayStarterNotice.textContent = ""; - roleplayStarterNotice.className = "status hidden"; - return; - } - roleplayStarterNotice.textContent = message; - roleplayStarterNotice.className = state ? `status ${state}` : "status"; -} - -function renderRoleplayStarterStep() { - const isSceneStep = roleplayStarterStep === 2; - if (roleplayStarterStepOne) roleplayStarterStepOne.classList.toggle("hidden", isSceneStep); - if (roleplayStarterStepTwo) roleplayStarterStepTwo.classList.toggle("hidden", !isSceneStep); - if (roleplayStarterBack) roleplayStarterBack.classList.toggle("hidden", !isSceneStep); - if (roleplayStarterTitle) { - roleplayStarterTitle.textContent = isSceneStep ? "Shape the opening scene" : "Choose your roleplay"; - } - if (roleplayStarterIntroCopy) { - roleplayStarterIntroCopy.textContent = isSceneStep - ? "Add an optional custom scenario. If you leave it empty, the AI Character will invent its own opening scene." - : "Pick a character and choose who you are before moving on to the opening scene."; - } - if (roleplayStarterCancel) { - roleplayStarterCancel.textContent = isSceneStep ? "Close" : "Cancel"; - } - if (roleplayStarterConfirm) { - roleplayStarterConfirm.textContent = isSceneStep ? "Start roleplay" : "Continue"; - } -} - function renderDrawerSummary() { - const chat = getChatById(activeChatId); + const chat = getChatById(conversation.activeChatId); if (!chatCharacterPill || !chatUserPersonaPill) return; if (!chat) { chatCharacterPill.classList.add("hidden"); @@ -1089,7 +756,7 @@ function applyThemeMode(modeKey, persist = true) { } function updateChatActionState() { - const activeChat = getChatById(activeChatId); + const activeChat = getChatById(conversation.activeChatId); const hasChat = Boolean(activeChat); renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id); if (pinChatBtn) { @@ -1104,41 +771,23 @@ function updateChatActionState() { if (hasChat) archiveChatBtn.textContent = activeChat.archived_at ? "Unarchive" : "Archive"; } clearChatBtn.disabled = !hasChat; - exportChatBtn.disabled = !hasChat || currentMessages.length === 0; - if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !currentUsername; - if (importWorkspaceBtn) importWorkspaceBtn.disabled = !currentUsername; + exportChatBtn.disabled = !hasChat || conversation.currentMessages.length === 0; + if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !conversation.currentUsername; + if (importWorkspaceBtn) importWorkspaceBtn.disabled = !conversation.currentUsername; if (headerModelButton) headerModelButton.disabled = !hasChat; } function openModelSettings() { - if (!activeChatId || !modelSection) return; + if (!conversation.activeChatId || !modelSection) return; modelSection.classList.remove("hidden"); modelSection.open = true; openChatDrawer(); requestAnimationFrame(() => modelSelect?.focus()); } -function renderPersonaSection() { - updateActivePersonaStatus(userPersonas, activeUserPersonaId, activeUserPersonaStatus, "No default user persona set.", "Default user persona"); - if (chatDrawerPersonaValue) { - const active = userPersonas.find((persona) => persona.id === activeUserPersonaId); - chatDrawerPersonaValue.textContent = active ? active.name : "None"; - if (headerPersonaName) headerPersonaName.textContent = active ? active.name : "None"; - } - if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !activeUserPersonaId; -} - -function maybeShowPersonaNudge() { - if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return; - const assistantOnlyChats = chatSessions.filter((chat) => !chat.assistant_persona_id).length; - if (assistantOnlyChats < 3) return; - localStorage.setItem("krishd-persona-nudge-shown", "true"); - setNotice("Want to personalize responses? Try personas in Tools.", "success"); -} - function renderChatList() { const query = chatSearchInput.value.trim().toLowerCase(); - const source = query ? (chatSearchResults ?? []) : chatSessions; + const source = query ? (conversation.chatSearchResults ?? []) : conversation.chatSessions; const filtered = source.filter((chat) => !query || chat.title.toLowerCase().includes(query) || String(chat.matched_content || "").toLowerCase().includes(query)); @@ -1146,8 +795,8 @@ function renderChatList() { if (!filtered.length) { const empty = document.createElement("p"); empty.className = "status"; - empty.textContent = query && chatSearchError ? chatSearchError - : query && chatSearchResults === null ? "Searching chats..." + empty.textContent = query && conversation.chatSearchError ? conversation.chatSearchError + : query && conversation.chatSearchResults === null ? "Searching chats..." : query ? "No chats match this search." : "No chats yet. Start a new one."; chatList.appendChild(empty); return; @@ -1179,12 +828,12 @@ function renderChatList() { section.append(heading, list); group.items.forEach((chat) => { const item = document.createElement("div"), content = document.createElement("button"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button"); - item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`; + item.className = `chat-item${chat.id === conversation.activeChatId ? " active" : ""}`; item.setAttribute("role", "listitem"); content.type = "button"; content.className = "chat-item-copy secondary-action"; content.setAttribute("aria-label", `Open chat ${chat.title}`); - if (chat.id === activeChatId) content.setAttribute("aria-current", "page"); + if (chat.id === conversation.activeChatId) content.setAttribute("aria-current", "page"); title.textContent = chat.title; meta.className = "chat-item-meta"; const chatType = chat.assistant_persona_id @@ -1221,109 +870,6 @@ function renderChatList() { }); } -function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix) { - if (!statusEl) return; - const active = items.find((persona) => persona.id === activeId); - statusEl.textContent = active ? `${prefix}: ${active.name}` : emptyText; -} - -function openPersonaForm(persona = null, personaType = "assistant") { - closeChatDrawer(); - closePopup(false); - openModal(personaModal, personaNameInput); - if (persona) { - editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant"; - personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`; - personaTypeSelect.value = editingPersonaType; personaTypeSelect.disabled = true; - personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || ""; - personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || ""; - personaExampleDialoguesInput.value = persona.example_dialogues || ""; - } else { - editingPersonaId = null; editingPersonaType = personaType; - personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`; - personaTypeSelect.value = personaType; personaTypeSelect.disabled = false; - personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = ""; - personaExampleDialoguesInput.value = ""; - } - personaExamplesField.classList.toggle("hidden", personaTypeSelect.value !== "assistant"); - setPersonaFormNotice(""); - personaNameInput.focus(); -} - -function closePersonaForm() { closeModal(personaModal); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); } -function populateRoleplayStarter() { - if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return; - roleplayCharacterSelect.innerHTML = ""; - const placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.textContent = "Choose AI Character"; - placeholder.disabled = true; - placeholder.selected = true; - roleplayCharacterSelect.appendChild(placeholder); - assistantPersonas.forEach((persona) => { - const option = document.createElement("option"); - option.value = String(persona.id); - option.textContent = persona.name; - option.selected = persona.id === roleplayPresetCharacterId; - roleplayCharacterSelect.appendChild(option); - }); - if (roleplayPresetCharacterId) { - roleplayCharacterSelect.value = String(roleplayPresetCharacterId); - } - roleplayUserPersonaSelect.innerHTML = ""; - const noneOption = document.createElement("option"); - noneOption.value = ""; - noneOption.textContent = "Myself (no user persona)"; - roleplayUserPersonaSelect.appendChild(noneOption); - userPersonas.forEach((persona) => { - const option = document.createElement("option"); - option.value = String(persona.id); - option.textContent = persona.name; - option.selected = persona.id === activeUserPersonaId; - roleplayUserPersonaSelect.appendChild(option); - }); - if (assistantPersonas.length === 0) { - setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); - roleplayStarterConfirm.disabled = true; - } else { - setRoleplayStarterNotice(""); - roleplayStarterConfirm.disabled = false; - } - updateRoleplaySuggestions(); - renderRoleplayStarterStep(); -} -function openRoleplayStarter(characterId = null) { - closeChatDrawer(); - roleplayPresetCharacterId = characterId; - roleplayStarterStep = 1; - populateRoleplayStarter(); - if (roleplayScenarioInput) roleplayScenarioInput.value = ""; - setRoleplayStarterNotice(""); - openModal(roleplayStarterModal, roleplayCharacterSelect); -} -function closeRoleplayStarter() { - if (!roleplayStarterModal) return; - closeModal(roleplayStarterModal); - roleplayPresetCharacterId = null; - roleplayStarterStep = 1; - roleplayStarterConfirm.disabled = false; - if (roleplayScenarioInput) roleplayScenarioInput.value = ""; - setRoleplayStarterNotice(""); - renderRoleplayStarterStep(); -} - -function goToRoleplayStarterSceneStep() { - if (!assistantPersonas.length) { - return setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); - } - if (!roleplayCharacterSelect.value) { - return setRoleplayStarterNotice("Choose a character to continue.", "error"); - } - roleplayStarterStep = 2; - setRoleplayStarterNotice(""); - renderRoleplayStarterStep(); - if (roleplayScenarioInput) roleplayScenarioInput.focus(); -} function isPopupOpen() { return popupModal && !popupModal.classList.contains("hidden"); } function finishPopup(result) { if (typeof popupResolver === "function") popupResolver(result); @@ -1380,325 +926,9 @@ function promptPopup(options) { return openPopup({ ...options, mode: "prompt" }).then((result) => typeof result === "string" ? result : null); } -function renderPersonaList(items, activeId, listElement, personaType) { - if (!listElement) { - renderPersonaSection(); - return; - } - listElement.innerHTML = ""; - if (!items.length) { - const empty = document.createElement("p"); - empty.className = "status persona-status"; - empty.textContent = "No personas yet. Create one to get started."; - listElement.appendChild(empty); - renderPersonaSection(); - return; - } - items.forEach((persona) => { - const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4"); - const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div"); - item.className = `persona-item${personaType === "user" && persona.id === activeId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions"; - tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI"; - title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; - [[personaType === "assistant" ? "Start roleplay" : "Set default", () => equipPersona(persona.id, personaType), personaType === "user" && persona.id === activeId], - ["Edit", () => openPersonaForm(persona), false], - [publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false], - ["Delete", () => deletePersona(persona.id), false]] - .forEach(([label, handler, disabled]) => { - const btn = document.createElement("button"); - btn.type = "button"; btn.textContent = label; btn.disabled = disabled; btn.addEventListener("click", handler); actions.appendChild(btn); - }); - if (publishedPersonaIds.has(persona.id)) { - const btn = document.createElement("button"); - btn.type = "button"; btn.textContent = "Unpublish"; btn.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(btn); - } - titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item); - }); - renderPersonaSection(); -} - async function loadSummary() { updateWorkspaceCopy(); - return currentSummary; -} - -async function loadPersonas() { - const res = await get("/personas"); - if (res.error) return setNotice(res.error, "error"); - assistantPersonas = res.assistantPersonas || []; userPersonas = res.userPersonas || []; - activeUserPersonaId = res.activeUserPersonaId || null; publishedPersonaIds = new Set(res.publishedPersonaIds || []); - renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); - populateRoleplayStarter(); - renderPersonaSection(); - updateWorkspaceCopy(); -} - -async function equipPersona(id, type) { - if (type === "assistant") { - openRoleplayStarter(id); - return; - } - const res = await post(`/personas/${id}/equip-user`, {}); - if (res.error) return setNotice(res.error, "error"); - activeUserPersonaId = id; - renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); - setNotice("User persona updated.", "success"); -} - -async function clearPersona(type) { - if (type !== "user") return; - const res = await post("/personas/user/clear", {}); - if (res.error) return setNotice(res.error, "error"); - activeUserPersonaId = null; - renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); - setNotice("Persona cleared.", "success"); -} - -async function savePersona() { - const payload = { - personaType: personaTypeSelect.value, - name: personaNameInput.value.trim(), - pronouns: personaPronounsInput.value.trim(), - appearance: personaAppearanceInput.value.trim(), - background: personaBackgroundInput.value.trim(), - details: personaDetailsInput.value.trim(), - exampleDialogues: personaTypeSelect.value === "assistant" ? personaExampleDialoguesInput.value.trim() : "" - }; - if (!payload.name) return setPersonaFormNotice("Persona name is required.", "error"); - const res = editingPersonaId ? await put(`/personas/${editingPersonaId}`, payload) : await post("/personas", payload); - if (res.error) return setPersonaFormNotice(res.error, "error"); - const wasEditing = Boolean(editingPersonaId); - await loadPersonas(); - if (payload.personaType === "assistant") await loadChatSessions(); - closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success"); -} - -async function deletePersona(id) { - const persona = assistantPersonas.find((item) => item.id === id) || userPersonas.find((item) => item.id === id); - if (!persona) return; - const confirmed = await confirmPopup({ - eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character", - title: "Delete persona", - description: `Delete "${persona.name}"? This removes it from your library and any active slot.`, - confirmLabel: "Delete", - danger: true - }); - if (!confirmed) return; - const res = await del(`/personas/${id}`); - if (res.error) return setNotice(res.error, "error"); - await loadPersonas(); - if (persona.persona_type === "assistant") await loadChatSessions(); - setNotice("Persona deleted.", "success"); -} -async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); } -async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); } - -function setLoadingState(loading, overlayOptions = null) { - isProcessing = loading; - msgInput.disabled = loading; - webSearchButton.disabled = loading; - reasoningToggle.setBusy(loading); - attachmentComposer.setBusy(loading); - sendBtn.classList.toggle("loading", loading); - messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => { - button.disabled = loading || button.dataset.retryLimitReached === "true"; - }); - messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach((button) => { - button.disabled = loading; - }); - if (loading) { - msgInput.placeholder = "Processing response..."; - if (overlayOptions === false) { - setChatActivity(false); - } else { - setChatActivity(true, overlayOptions || undefined); - } - } else { - setChatActivity(false); - updateComposerPlaceholder(); - } - updateSendState(); -} - -async function stopGeneration() { - const chatId = regenerationChatId ?? activeChatId; - if (!isProcessing || !chatId) return; - const res = await post(`/chats/${chatId}/stop`, {}); - if (res.error) return setNotice(res.error, "error"); - setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); -} - -async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) { - const openRequest = ++chatOpenRequest; - if (!preserveComposer && id !== activeChatId) { - if (activeChatId != null) { - chatDrafts.set(activeChatId, msgInput.value); - attachmentComposer.saveDraft(activeChatId); - msgInput.value = chatDrafts.get(id) || ""; - attachmentComposer.restoreDraft(id); - resizeComposerInput(); - } else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) { - msgInput.value = chatDrafts.get(id) || ""; - attachmentComposer.restoreDraft(id); - resizeComposerInput(); - } - } - if (isMobileLayout()) closeMobileSidebar(); - setChatLoading(true, "Opening chat..."); - setChatActivity(true, { - eyebrow: "Opening chat", - title: "Loading conversation", - detail: "Fetching messages and restoring the current scene." - }); - activeChatId = id; - history.replaceState(null, "", `/app/chats/${id}`); - messageRetryState = new Map(); - updateWorkspaceCopy(); renderChatList(); updateChatActionState(); - let res = await get(`/chats/${id}/messages`); - if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; } - if (res.error) { - setChatLoading(false); - setChatActivity(false); - return setNotice(res.error, "error"); - } - if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) { - const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true}); - if (!activated.error) res = await get(`/chats/${id}/messages`); - if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; } - } - const existing = getChatById(id); - if (res.chat && existing) Object.assign(existing, res.chat); - populateGenerationSettings(res.chat || existing); - currentMessages = res.messages || []; renderMessages(); - if (matchMessageId) requestAnimationFrame(() => { - const target = [...messagesDiv.querySelectorAll("[data-message-id]")] - .find(node => Number(node.dataset.messageId) === Number(matchMessageId)); - if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); } - }); - updateSendState(); - setChatLoading(false); - setChatActivity(false); -} - -async function loadChatSessions() { - setChatLoading(true, "Loading chats..."); - const res = await get("/chats"); - if (res.error) { - setChatLoading(false); - return setNotice(res.error, "error"); - } - chatSessions = res.chats || []; renderChatList(); - if (activeChatId && getChatById(activeChatId)) { - updateWorkspaceCopy(); - setChatLoading(false); - return; - } - if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; } - if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); } - if (chatSessions.length) { - const preferredChatId = activeChatId && getChatById(activeChatId) ? activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : chatSessions[0].id; - setChatLoading(false); - return setActiveChat(preferredChatId); - } - activeChatId = null; - currentMessages = []; - renderMessages(); - updateWorkspaceCopy(); - updateSendState(); - setChatLoading(false); - return setNotice("Pick a starting action to begin.", "success"); -} - -async function searchChatSessions(query, requestId) { - const res = await get(`/chats/search?q=${encodeURIComponent(query)}`); - if (requestId !== chatSearchRequest || chatSearchInput.value.trim() !== query) return; - if (res.error) { - chatSearchError = "Unable to search chats."; - renderChatList(); - setNotice(res.error, "error"); - return; - } - chatSearchResults = res.chats || []; - renderChatList(); -} - -function scheduleChatSearch() { - clearTimeout(chatSearchTimer); - const requestId = ++chatSearchRequest; - const query = chatSearchInput.value.trim(); - chatSearchResults = null; - chatSearchError = ""; - if (!query) { - renderChatList(); - return; - } - renderChatList(); - chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300); -} - -async function createNewChat({preserveComposer = false} = {}) { - setChatLoading(true, "Creating chat..."); - setChatActivity(true, { - eyebrow: "Creating chat", - title: "Opening a new conversation", - detail: "Setting up a fresh chat so you can start sending messages." - }); - const res = await post("/chats", { title: "New chat" }); - if (res.error || !res.chat) { - setChatLoading(false); - setChatActivity(false); - return setNotice(res.error || "Unable to create chat.", "error"); - } - chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id, {preserveComposer}); setNotice("New chat created.", "success"); - msgInput.focus(); - setChatLoading(false); - setChatActivity(false); -} - -async function startRoleplay() { - if (roleplayStarterStep === 1) { - goToRoleplayStarterSceneStep(); - return; - } - setChatLoading(true, "Starting roleplay..."); - setChatActivity(true, { - eyebrow: "Starting roleplay", - title: "Building the opening scene", - detail: "Generating the first beat and opening the roleplay chat." - }); - const assistantPersonaId = Number(roleplayCharacterSelect.value); - const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null; - const scenarioPrompt = roleplayScenarioInput?.value.trim() || ""; - if (!assistantPersonaId) { - setChatLoading(false); - setChatActivity(false); - return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error"); - } - roleplayStarterConfirm.disabled = true; - setRoleplayStarterNotice("Building the opening scene..."); - const res = await post("/roleplays/start", { - assistantPersonaId, - userPersonaId, - scenarioPrompt, - model: modelSelect.value - }); - roleplayStarterConfirm.disabled = false; - if (res.error || !res.chat) { - setChatLoading(false); - setChatActivity(false); - return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error"); - } - closeRoleplayStarter(); - await loadChatSessions(); - await setActiveChat(res.chat.id); - setNotice( - res.degraded - ? "Roleplay started, but the opening scene timed out. Send the first message to continue." - : res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", - res.degraded ? "error" : "success" - ); - setChatLoading(false); - setChatActivity(false); + return conversation.currentSummary; } async function renameChat(id) { @@ -1729,8 +959,8 @@ async function updateChatOrganization(id, changes) { }; const res = await put(`/chats/${id}/organization`, payload); if (res.error) return setNotice(res.error, "error"); - const index = chatSessions.findIndex((item) => item.id === id); - if (index >= 0) chatSessions[index] = res.chat; + const index = conversation.chatSessions.findIndex((item) => item.id === id); + if (index >= 0) conversation.chatSessions[index] = res.chat; updateWorkspaceCopy(); renderChatList(); updateChatActionState(); @@ -1749,13 +979,13 @@ async function clearChat(id) { if (!confirmed) return; const res = await post(`/chats/${id}/clear`, {}); if (res.error) return setNotice(res.error, "error"); - messageRetryState = new Map(); - currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success"); + conversation.messageRetryState = new Map(); + conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success"); } function exportCurrentChat() { - const chat = getChatById(activeChatId); - if (!chat || !currentMessages.length) return; + const chat = getChatById(conversation.activeChatId); + if (!chat || !conversation.currentMessages.length) return; void (async () => { const format = await promptPopup({ eyebrow: "Export", @@ -1769,7 +999,7 @@ function exportCurrentChat() { const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase()) ? String(format).trim().toLowerCase() : "md"; - const response = await fetch(`/exports/chats/${activeChatId}?format=${normalized}`); + const response = await fetch(`/exports/chats/${conversation.activeChatId}?format=${normalized}`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -1882,8 +1112,8 @@ function formatMemoryList(memories) { } async function manageChatMemory() { - if (!activeChatId) return; - const loaded = await get(`/chats/${activeChatId}/memories`); + if (!conversation.activeChatId) return; + const loaded = await get(`/chats/${conversation.activeChatId}/memories`); if (loaded.error) return setNotice(loaded.error, "error"); const memories = loaded.memories || []; const action = await promptPopup({ @@ -1900,11 +1130,11 @@ async function manageChatMemory() { if (deleteMatch) { const target = memories[Number(deleteMatch[1]) - 1]; if (!target) return setNotice("Memory number not found.", "error"); - const removed = await del(`/chats/${activeChatId}/memories/${target.id}`); + const removed = await del(`/chats/${conversation.activeChatId}/memories/${target.id}`); if (removed.error) return setNotice(removed.error, "error"); return setNotice("Memory fact deleted.", "success"); } - const saved = await post(`/chats/${activeChatId}/memories`, { fact: value }); + const saved = await post(`/chats/${conversation.activeChatId}/memories`, { fact: value }); if (saved.error) return setNotice(saved.error, "error"); return setNotice("Memory fact saved.", "success"); } @@ -1927,8 +1157,8 @@ function formatPersonaState(state = {}) { } async function managePersonaState() { - if (!activeChatId) return; - const loaded = await get(`/chats/${activeChatId}/persona-state`); + if (!conversation.activeChatId) return; + const loaded = await get(`/chats/${conversation.activeChatId}/persona-state`); if (loaded.error) return setNotice(loaded.error, "error"); const state = loaded.state || {}; const fieldChoice = await promptPopup({ @@ -1952,7 +1182,7 @@ async function managePersonaState() { }); if (value === null) return; const nextState = { ...state, [key]: String(value || "").trim() || null }; - const saved = await put(`/chats/${activeChatId}/persona-state`, nextState); + const saved = await put(`/chats/${conversation.activeChatId}/persona-state`, nextState); if (saved.error) return setNotice(saved.error, "error"); return setNotice("Roleplay state saved.", "success"); } @@ -1980,13 +1210,13 @@ async function deleteChat(id) { setChatLoading(false); return setNotice(res.error, "error"); } - chatSessions = chatSessions.filter((item) => item.id !== id); - chatDrafts.delete(id); + conversation.chatSessions = conversation.chatSessions.filter((item) => item.id !== id); + conversation.chatDrafts.delete(id); attachmentComposer.discardDraft(id); - if (activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; activeChatId = null; currentMessages = []; renderMessages(); } - messageRetryState = new Map(); + if (conversation.activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; conversation.activeChatId = null; conversation.currentMessages = []; renderMessages(); } + conversation.messageRetryState = new Map(); await loadSummary(); renderChatList(); - if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat(); + if (conversation.chatSessions.length) await setActiveChat(conversation.chatSessions[0].id); else await createNewChat(); setNotice("Chat deleted.", "success"); setChatLoading(false); } @@ -2013,7 +1243,7 @@ async function displayModels() { option.selected = preferredIndex >= 0 ? index === preferredIndex : index === 0; modelSelect.appendChild(option); }); - const activeChat = getChatById(activeChatId); + const activeChat = getChatById(conversation.activeChatId); if (activeChat?.preferred_model && models.some((model) => (model.model || model.name) === activeChat.preferred_model)) { modelSelect.value = activeChat.preferred_model; } @@ -2023,141 +1253,29 @@ async function displayModels() { async function checkSession() { const res = await get("/session"); if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; } - currentUsername = res.user; chatDiv.classList.remove("hidden"); + conversation.currentUsername = res.user; chatDiv.classList.remove("hidden"); await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]); // Starters are offered on workspace home. const starter = new URLSearchParams(location.search).get("prompt"); if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); } msgInput.focus(); - if (!starter && !activeChatId) maybeShowOnboarding(); + if (!starter && !conversation.activeChatId) maybeShowOnboarding(); return true; } -async function sendMessage({ retryMessage = null } = {}) { - if (isProcessing) return; - if (attachmentComposer.pending) return; - const attachments = retryMessage?.attachments || attachmentComposer.attachments; - const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : ""); - if (!message) return; - if (retryMessage && !msgInput.value.trim()) { - msgInput.value = message; - resizeComposerInput(); - } - const webSearch = webSearchToggle.enabled; - const reasoning = reasoningToggle.enabled; - if (!activeChatId) await createNewChat({preserveComposer: true}); - if (!activeChatId) return; - const currentChat = getChatById(activeChatId); - if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") { - const autoTitle = message.length > 40 ? `${message.slice(0, 40)}...` : message; - currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle }); - } - const userMessage = { role: "user", content: message, attachments }; - currentMessages.push(userMessage); - const userCard = addMessage(userMessage, true); - updateChatActionState(); - setLoadingState(true, false); setNotice("Generating reply..."); - const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true }); - const loadingBody = loadingMsg.querySelector(".msg-content"); - const loadingShell = loadingMsg.querySelector(".loading-shell"); - const loadingLabel = loadingMsg.querySelector(".loading-copy strong"); - loadingLabel.textContent = "Preparing response..."; - const showSendFailure = (errorMessage) => { - if (!msgInput.value.trim()) { - msgInput.value = message; - resizeComposerInput(); - } - userMessage.deliveryStatus = "failed"; - userMessage.errorMessage = errorMessage; - userCard.replaceWith(addMessage(userMessage, true, false, { detached: true })); - setNotice(errorMessage, "error"); - }; - let completedReply = null; - try { - let streamError = null; - await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, { - onStatus: ({ status, sourceCount, searchState }) => { - if (status === "accepted") return; - loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..." - : searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..." - : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; - }, - onThinking: ({ fullThinking }) => { - if (!reasoning) return; - const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; - updateReasoning(loadingMsg, fullThinking, true); - loadingShell?.classList.toggle("hidden", Boolean(fullThinking)); - if (follow) scrollMessagesToBottom(); - }, - onChunk: ({ fullReply }) => { - const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; - if (fullReply) finishReasoning(loadingMsg); - if (loadingBody) { - loadingShell?.classList.toggle("hidden", Boolean(fullReply)); - loadingLabel.textContent = "Generating response..."; - loadingBody.classList.add("has-content"); - setMessageContent(loadingBody, fullReply); - } - if (follow) scrollMessagesToBottom(); - }, - onDone: ({ reply }) => { - completedReply = reply; - webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); - }, - onError: ({ error }) => { - streamError = error || "Failed to generate reply."; - } - }); - const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; - const previousScrollTop = messagesDiv.scrollTop; - if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); - if (streamError) { - showSendFailure(streamError); - return; - } - if (msgInput.value.trim() === message) { - msgInput.value = ""; - resizeComposerInput(); - } - const composerAttachments = attachmentComposer.attachments; - if (composerAttachments.length === attachments.length - && composerAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear(); - await setActiveChat(activeChatId); - await loadSummary(); - markOnboardingCompleted(); - if (!followOutput) requestAnimationFrame(() => { messagesDiv.scrollTop = previousScrollTop; }); - maybeShowPersonaNudge(); - setNotice("Reply received.", "success"); - } catch (error) { - if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); - const errorMessage = error?.message || "Network error while sending message."; - if (completedReply !== null) { - if (userCard.isConnected) { - currentMessages.push({ role: "bot", content: completedReply }); - addMessage(completedReply); - } - setNotice(`Reply sent, but the conversation could not be refreshed: ${errorMessage}`, "error"); - } else { - showSendFailure(errorMessage); - } - } finally { - setLoadingState(false); msgInput.focus(); updateChatActionState(); - } -} - const chatActions = { archive: async () => { - const chat = getChatById(activeChatId); + const chat = getChatById(conversation.activeChatId); if (chat) await updateChatOrganization(chat.id, { archived: !chat.archived_at }); }, clear: async () => { - if (activeChatId) await clearChat(activeChatId); + if (conversation.activeChatId) await clearChat(conversation.activeChatId); }, export: () => { exportCurrentChat(); }, folder: async () => { - const chat = getChatById(activeChatId); + const chat = getChatById(conversation.activeChatId); if (!chat) return; const folderName = await promptPopup({ eyebrow: "Folder", @@ -2177,11 +1295,11 @@ const chatActions = { await managePersonaState(); }, pin: async () => { - const chat = getChatById(activeChatId); + const chat = getChatById(conversation.activeChatId); if (chat) await updateChatOrganization(chat.id, { isPinned: !chat.is_pinned }); }, rename: async () => { - if (activeChatId) await renameChat(activeChatId); + if (conversation.activeChatId) await renameChat(conversation.activeChatId); } }; @@ -2195,7 +1313,7 @@ function bindChatDrawerActions() { }); } -sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); }); +sendBtn.addEventListener("click", () => { void (conversation.isProcessing ? stopGeneration() : sendMessage()); }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } }); msgInput.addEventListener("input", function () { resizeComposerInput(); @@ -2245,7 +1363,7 @@ roleplayStarterClose.addEventListener("click", closeRoleplayStarter); roleplayStarterCancel.addEventListener("click", closeRoleplayStarter); if (roleplayStarterBack) { roleplayStarterBack.addEventListener("click", () => { - roleplayStarterStep = 1; + conversation.roleplayStarterStep = 1; setRoleplayStarterNotice(""); renderRoleplayStarterStep(); roleplayCharacterSelect.focus(); diff --git a/public/js/settings.js b/public/js/settings.js index 8237117..d8c8d4f 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -316,7 +316,7 @@ async function loadProfile() { if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; } currentUsernameInput.value = res.username || ""; newUsernameInput.value = res.username || ""; - isAdminUser = res.username === "admin"; + isAdminUser = res.role === "admin"; diagnosticsNavItem.classList.toggle("hidden", !isAdminUser); return true; } diff --git a/public/js/streamRenderer.js b/public/js/streamRenderer.js new file mode 100644 index 0000000..925c1cf --- /dev/null +++ b/public/js/streamRenderer.js @@ -0,0 +1,29 @@ +/** Accumulate deltas and render at most once per animation frame. */ +export function createStreamRenderer({onReply, onThinking, schedule = callback => requestAnimationFrame(callback), cancel = id => cancelAnimationFrame(id)}) { + const text = {reply: "", thinking: ""}; + const dirty = new Set(); + let frame = null; + const flush = () => { + if (frame !== null) cancel(frame); + frame = null; + if (dirty.has("thinking")) onThinking?.(text.thinking); + if (dirty.has("reply")) onReply?.(text.reply); + dirty.clear(); + }; + const update = (target, value, replace = false) => { + text[target] = replace ? value : text[target] + value; + dirty.add(target); + if (frame === null) frame = schedule(flush); + }; + return { + onChunk: ({chunk, fullReply}) => update("reply", typeof fullReply === "string" ? fullReply : chunk, typeof fullReply === "string"), + onThinking: ({chunk, fullThinking}) => update("thinking", typeof fullThinking === "string" ? fullThinking : chunk, typeof fullThinking === "string"), + onReplace: ({target, text: value}) => update(target, value, true), + flush, + dispose() { + if (frame !== null) cancel(frame); + frame = null; + dirty.clear(); + } + }; +} diff --git a/scripts/set-role.js b/scripts/set-role.js new file mode 100644 index 0000000..0089363 --- /dev/null +++ b/scripts/set-role.js @@ -0,0 +1,18 @@ +/** Operator-only role assignment; never exposed through an HTTP endpoint. */ +import {createConfig} from "../src/config.js"; +import {createDatabase} from "../src/db/index.js"; + +const [username, role] = process.argv.slice(2); +if (!username || !["user", "admin"].includes(role) || process.argv.length !== 4) { + console.error("Usage: npm run user:role -- "); + process.exitCode = 1; +} else { + const db = createDatabase(createConfig()); + try { + const result = db.prepare("UPDATE users SET role = ? WHERE username = ?").run(role, username); + if (!result.changes) throw new Error("Account not found. Register the account first."); + console.log(`Role for ${username} set to ${role}.`); + } finally { + db.close(); + } +} diff --git a/src/app.js b/src/app.js index 33ee7be..4027425 100644 --- a/src/app.js +++ b/src/app.js @@ -2,6 +2,7 @@ * @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus. */ import fs from "fs"; +import {createRequire} from "node:module"; import {randomUUID} from "crypto"; import {performance} from "perf_hooks"; import express from "express"; @@ -225,7 +226,11 @@ function enforceSessionVersion(repositories) { const user = repositories.getUser(req.session.user); const currentVersion = Number(user?.session_version || 0); const sessionVersion = Number(req.session.sessionVersion || 0); - if (user && currentVersion === sessionVersion) return next(); + if (user && currentVersion === sessionVersion) { + // Read the role on every request so revocation also affects existing sessions. + req.account = user; + return next(); + } req.session.destroy(() => { if (req.path === "/session") { @@ -296,6 +301,12 @@ export function createApp(options = {}) { app.locals.close = createCloseHandler(closeHandlers); app.use(createPagesRouter(config)); + const require = createRequire(import.meta.url); + const dompurifyPath = require.resolve("dompurify").replace(/purify\.cjs\.js$/, "purify.min.js"); + app.get("/vendor/dompurify.js", (_req, res) => { + res.set("Cache-Control", "no-cache"); + res.sendFile(dompurifyPath); + }); app.use(express.static(config.publicDir, { maxAge: config.staticMaxAge, etag: config.staticEtag, diff --git a/src/db/repositories.js b/src/db/repositories.js index c4f47f4..b7dbf97 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -46,7 +46,7 @@ function normalizeMarketPersonaType(personaType) { export function createRepositories(db, config) { const statements = { insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"), - getUser: db.prepare("SELECT username, password, session_version FROM users WHERE username = ?"), + getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"), updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"), updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"), incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"), diff --git a/src/db/schema.js b/src/db/schema.js index b9724c4..6264320 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -10,6 +10,7 @@ export function initializeSchema(db) { ( username TEXT PRIMARY KEY, password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), session_version INTEGER DEFAULT 0 ) `).run(); @@ -256,6 +257,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) { }; const userColumns = db.prepare("PRAGMA table_info(users)").all(); + ensureColumn("users", userColumns, "role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))"); ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0"); db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run(); diff --git a/src/middleware/asyncHandler.js b/src/middleware/asyncHandler.js new file mode 100644 index 0000000..cc0d42e --- /dev/null +++ b/src/middleware/asyncHandler.js @@ -0,0 +1,3 @@ +/** Forward rejected route promises to Express 4's error middleware. */ +export const asyncHandler = (handler) => (req, res, next) => + Promise.resolve().then(() => handler(req, res, next)).catch(next); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index bd16d6f..b7ef0f4 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -7,3 +7,10 @@ export function requireLogin(req, res, next) { } return next(); } + +export function requireAdmin(req, res, next) { + if (req.account?.role !== "admin") { + return res.status(403).json({error: "Admin only"}); + } + return next(); +} diff --git a/src/routes/auth.js b/src/routes/auth.js index 911a137..3fdb6a3 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -2,6 +2,7 @@ * @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung. */ import express from "express"; +import {asyncHandler} from "../middleware/asyncHandler.js"; import bcrypt from "bcrypt"; import {validateBody} from "../middleware/validate.js"; @@ -19,23 +20,31 @@ const credentialsValidator = (body) => { export function createAuthRouter({repositories, authRateLimiters, config}) { const router = express.Router(); - router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => { + router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { const {username, password, inviteCode} = req.validatedBody; if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) { return res.status(403).json({error: "Valid invite code is required"}); } if (repositories.getUser(username)) { - return res.status(400).json({error: "User already exists"}); + return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"}); } const hashed = await bcrypt.hash(password, 10); - repositories.insertUser(username, hashed); + try { + repositories.insertUser(username, hashed); + } catch (error) { + // The precheck can race while bcrypt yields; the database is authoritative. + if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) { + return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"}); + } + throw error; + } req.session.user = username; req.session.sessionVersion = 0; return res.json({message: "Registration successful"}); - }); + })); - router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => { + router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { const {username, password} = req.validatedBody; const user = repositories.getUser(username); if (!user) return res.status(400).json({error: "User not found"}); @@ -44,10 +53,10 @@ export function createAuthRouter({repositories, authRateLimiters, config}) { req.session.user = username; req.session.sessionVersion = Number(user.session_version || 0); return res.json({message: "Login successful"}); - }); + })); - router.post("/logout", (req, res) => { - req.session.destroy(() => res.json({message: "Logged out"})); + router.post("/logout", (req, res, next) => { + req.session.destroy(error => error ? next(error) : res.json({message: "Logged out"})); }); router.get("/session", (req, res) => { diff --git a/src/routes/chats.js b/src/routes/chats.js index 1f92f12..339b71a 100644 --- a/src/routes/chats.js +++ b/src/routes/chats.js @@ -2,6 +2,8 @@ * @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit. */ import express from "express"; +import {createTextStreamEvents} from "../services/streamEvents.js"; +import {asyncHandler} from "../middleware/asyncHandler.js"; import multer from "multer"; import {requireLogin} from "../middleware/auth.js"; import {validateBody} from "../middleware/validate.js"; @@ -139,14 +141,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf next(); }); - router.get("/models", async (req, res) => { + router.get("/models", asyncHandler(async (req, res) => { try { return res.json(await modelService.listModels()); } catch (error) { const mapped = modelService.mapError(error); return res.status(mapped.status).json(mapped.body); } - }); + })); router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)})); router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")})); @@ -262,7 +264,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf return res.json(result); }); - router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), async (req, res) => { + router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), asyncHandler(async (req, res) => { if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating an edited branch"}); const chatId = Number(req.params.id); const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId)); @@ -285,7 +287,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf const mapped = modelService.mapError(error); return res.status(mapped.status).json(mapped.body); } - }); + })); router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => { const chatId = Number(req.params.id); @@ -446,7 +448,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf return res.json({state}); }); - router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => { + router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), asyncHandler(async (req, res) => { try { const result = await chatService.sendChatMessage({ user: req.session.user, @@ -464,9 +466,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf const mapped = modelService.mapError(error); return res.status(mapped.status).json(mapped.body); } - }); + })); - router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => { + router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), asyncHandler(async (req, res) => { const generationController = new AbortController(); let generationFinished = false; res.once("close", () => { @@ -493,26 +495,25 @@ export function createChatsRouter({repositories, chatService, modelService, conf message: req.validatedBody.message, attachments: req.validatedBody.attachments, model: req.validatedBody.model, - onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}), - onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}), + ...createTextStreamEvents(sendEvent), webSearch: req.validatedBody.webSearch, reasoning: req.validatedBody.reasoning, onStatus: (status) => sendEvent("status", status), signal: generationController.signal }); if (result.error) { - sendEvent("error", {error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})}); + sendEvent("error", {error: result.error, status: result.status, requestId: req.id, ...(result.searchState ? {searchState: result.searchState} : {})}); } else { sendEvent("done", {reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})}); } } catch (error) { const mapped = modelService.mapError(error); - sendEvent("error", mapped.body); + sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id}); } finally { generationFinished = true; res.end(); } - }); + })); router.post("/chats/:id/messages/:messageId/versions/:index", (req, res) => { const chatId = Number(req.params.id), messageId = Number(req.params.messageId); @@ -528,7 +529,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))}); }); - router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, async (req, res) => { + router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, asyncHandler(async (req, res) => { const chatId = Number(req.params.id); const messageId = Number(req.params.messageId); const chat = repositories.getChat(chatId, req.session.user); @@ -559,28 +560,27 @@ export function createChatsRouter({repositories, chatService, modelService, conf reasoning: req.body?.reasoning === true, signal: controller.signal, ...(streaming ? { - onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}), - onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}), + ...createTextStreamEvents(sendEvent), onStatus: status => sendEvent("status", status) } : {}) }); if (streaming) { - sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, searchState: result.searchState} : {...result, reply: result.message.content}); + sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, status: result.status, requestId: req.id, searchState: result.searchState} : {...result, reply: result.message.content}); return; } if (result.error) return res.status(result.status).json({error: result.error}); return res.json(result); } catch (error) { const mapped = modelService.mapError(error); - if (streaming) { sendEvent("error", mapped.body); return; } + if (streaming) { sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id}); return; } return res.status(mapped.status).json(mapped.body); } finally { finished = true; if (streaming) res.end(); } - }); + })); - router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, async (req, res) => { + router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, asyncHandler(async (req, res) => { const chatId = Number(req.params.id); const chat = repositories.getChat(chatId, req.session.user); if (!chat) return res.status(404).json({error: "Chat not found"}); @@ -605,7 +605,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf const mapped = modelService.mapError(error); return res.status(mapped.status).json(mapped.body); } - }); + })); router.get("/exports/chats/:id", (req, res) => { const chatId = Number(req.params.id); @@ -627,7 +627,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf }); router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)})); - router.get("/exports/workspace/archive", async (req, res) => { + router.get("/exports/workspace/archive", asyncHandler(async (req, res) => { try { const workspace = repositories.exportWorkspace(req.session.user); const images = new Map(); @@ -642,7 +642,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf } catch (error) { return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"}); } - }); + })); router.post("/imports/workspace/archive/preview", readArchive, (req, res) => { const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user)); if (validation.ok) { @@ -651,7 +651,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf } return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview}); }); - router.post("/imports/workspace/archive", readArchive, async (req, res) => { + router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => { const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user)); if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors}); if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") { @@ -677,7 +677,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf for (const id of restored) await attachmentService.remove(id, req.session.user).catch(() => {}); return res.status(error.status || 400).json({error: error.message || "Workspace archive import failed"}); } - }); + })); router.post("/imports/workspace/preview", (req, res) => { const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user)); return res.status(validation.ok ? 200 : 400).json({ diff --git a/src/routes/personas.js b/src/routes/personas.js index 2238d42..923f157 100644 --- a/src/routes/personas.js +++ b/src/routes/personas.js @@ -2,6 +2,7 @@ * @file Verwaltet eigene Personas sowie Veröffentlichung, Suche und Interaktionen auf dem Persona-Marktplatz. */ import express from "express"; +import {asyncHandler} from "../middleware/asyncHandler.js"; import {requireLogin} from "../middleware/auth.js"; import {validateBody} from "../middleware/validate.js"; import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js"; @@ -49,7 +50,7 @@ const templateValidator = (body) => { export function createPersonasRouter({repositories, chatService, modelService, config}) { const router = express.Router(); router.use(requireLogin); - const isAdmin = (req) => req.session.user === "admin"; + const isAdmin = (req) => req.account?.role === "admin"; const parseMarketPersonaType = (value) => ["assistant", "user"].includes(value) ? value : null; router.get("/personas", (req, res) => { @@ -214,7 +215,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c return res.json({message: "Report submitted"}); }); - router.post("/personas/market/:id/chat", async (req, res) => { + router.post("/personas/market/:id/chat", asyncHandler(async (req, res) => { const user = req.session.user; const marketPersona = repositories.getMarketPersona(Number(req.params.id)); const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null; @@ -281,9 +282,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c } catch { return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true}); } - }); + })); - router.post("/roleplays/start", validateBody(roleplayStartValidator), async (req, res) => { + router.post("/roleplays/start", validateBody(roleplayStartValidator), asyncHandler(async (req, res) => { const user = req.session.user; const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody; const assistantPersona = repositories.getPersona(assistantPersonaId, user); @@ -345,7 +346,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c modelErrorCode: mapped.body.code }); } - }); + })); router.get("/templates", (req, res) => { const personaId = req.query.personaId ? Number(req.query.personaId) : null; diff --git a/src/routes/settings.js b/src/routes/settings.js index 81c2125..535e0e5 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -2,6 +2,7 @@ * @file Verarbeitet Benutzer- und Kontoeinstellungen sowie Export und Import des Arbeitsbereichs. */ import express from "express"; +import {asyncHandler} from "../middleware/asyncHandler.js"; import bcrypt from "bcrypt"; import {requireLogin} from "../middleware/auth.js"; import {validateBody} from "../middleware/validate.js"; @@ -29,7 +30,7 @@ const currentPasswordValidator = (body) => { return {value: {currentPassword}}; }; -const destroySession = (req) => new Promise((resolve) => req.session.destroy(() => resolve())); +const destroySession = (req) => new Promise((resolve, reject) => req.session.destroy(error => error ? reject(error) : resolve())); export function createSettingsRouter({repositories}) { const router = express.Router(); @@ -38,10 +39,10 @@ export function createSettingsRouter({repositories}) { router.get("/settings/profile", (req, res) => { const user = repositories.getUser(req.session.user); if (!user) return res.status(404).json({error: "User not found"}); - return res.json({username: user.username}); + return res.json({username: user.username, role: user.role}); }); - router.put("/settings/username", validateBody(usernameValidator), async (req, res) => { + router.put("/settings/username", validateBody(usernameValidator), asyncHandler(async (req, res) => { const currentUsername = req.session.user; const {username: nextUsername, password} = req.validatedBody; if (nextUsername === currentUsername) { @@ -53,15 +54,22 @@ export function createSettingsRouter({repositories}) { const valid = await bcrypt.compare(password, currentUser.password); if (!valid) return res.status(403).json({error: "Current password is incorrect"}); if (repositories.getUser(nextUsername)) { - return res.status(400).json({error: "Username already exists"}); + return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"}); } - repositories.renameUser(currentUsername, nextUsername); + try { + repositories.renameUser(currentUsername, nextUsername); + } catch (error) { + if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) { + return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"}); + } + throw error; + } req.session.user = nextUsername; return res.json({username: nextUsername}); - }); + })); - router.put("/settings/password", validateBody(passwordValidator), async (req, res) => { + router.put("/settings/password", validateBody(passwordValidator), asyncHandler(async (req, res) => { const username = req.session.user; const {currentPassword, newPassword} = req.validatedBody; const user = repositories.getUser(username); @@ -73,11 +81,11 @@ export function createSettingsRouter({repositories}) { const updatedUser = repositories.incrementSessionVersion(username); req.session.sessionVersion = Number(updatedUser.session_version || 0); return res.json({message: "Password updated"}); - }); + })); - router.post("/settings/logout-all-sessions", (req, res) => { + router.post("/settings/logout-all-sessions", (req, res, next) => { repositories.incrementSessionVersion(req.session.user); - req.session.destroy(() => res.json({message: "All sessions logged out"})); + req.session.destroy(error => error ? next(error) : res.json({message: "All sessions logged out"})); }); router.get("/settings/export", (req, res) => { @@ -92,7 +100,7 @@ export function createSettingsRouter({repositories}) { }); }); - router.delete("/settings/account", validateBody(currentPasswordValidator), async (req, res) => { + router.delete("/settings/account", validateBody(currentPasswordValidator), asyncHandler(async (req, res) => { const username = req.session.user; const user = repositories.getUser(username); if (!user) return res.status(404).json({error: "User not found"}); @@ -101,7 +109,7 @@ export function createSettingsRouter({repositories}) { repositories.deleteUser(username); await destroySession(req); return res.json({message: "Account deleted"}); - }); + })); return router; } diff --git a/src/routes/system.js b/src/routes/system.js index bc16784..31a67d6 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -2,14 +2,8 @@ * @file Stellt Modellinformationen und administrative Diagnosedaten bereit. */ import express from "express"; -import {requireLogin} from "../middleware/auth.js"; - -function requireAdmin(req, res, next) { - if (req.session.user !== "admin") { - return res.status(403).json({error: "Admin only"}); - } - return next(); -} +import {asyncHandler} from "../middleware/asyncHandler.js"; +import {requireLogin, requireAdmin} from "../middleware/auth.js"; function buildConfigWarnings(config) { const warnings = []; @@ -34,18 +28,18 @@ function buildConfigWarnings(config) { export function createSystemRouter({modelService, config}) { const router = express.Router(); - router.get("/health", async (req, res) => { + router.get("/health", asyncHandler(async (req, res) => { const model = await modelService.checkHealth(); return res.status(model.ok ? 200 : 503).json({ok: model.ok, model}); - }); + })); - router.get("/admin/diagnostics", requireLogin, requireAdmin, async (req, res) => { + router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => { const diagnostics = await modelService.getDiagnostics(); return res.status(diagnostics.model.ok ? 200 : 503).json({ model: diagnostics.model, configWarnings: buildConfigWarnings(config) }); - }); + })); return router; } diff --git a/src/routes/uploads.js b/src/routes/uploads.js index 1dc3f62..45742dd 100644 --- a/src/routes/uploads.js +++ b/src/routes/uploads.js @@ -2,6 +2,7 @@ * @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle. */ import express from "express"; +import {asyncHandler} from "../middleware/asyncHandler.js"; import multer from "multer"; import {requireLogin} from "../middleware/auth.js"; import {AttachmentError} from "../services/attachmentService.js"; @@ -31,17 +32,17 @@ export function createUploadsRouter(service, config) { catch (failure) { fail(res, failure); } }); }); - router.get("/attachments/:id", requireLogin, async (req, res) => { + router.get("/attachments/:id", requireLogin, asyncHandler(async (req, res) => { try { const row = service.owned(req.params.id, req.session.user); const buffer = await service.storage.read(row.storage_key); res.set({"Content-Type": row.mime_type, "Content-Length": buffer.length, "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": "inline"}).send(buffer); } catch (error) { fail(res, error); } - }); - router.delete("/attachments/:id", requireLogin, async (req, res) => { + })); + router.delete("/attachments/:id", requireLogin, asyncHandler(async (req, res) => { try { await service.remove(req.params.id, req.session.user); res.json({success: true}); } catch (error) { fail(res, error); } - }); + })); return router; } diff --git a/src/services/streamEvents.js b/src/services/streamEvents.js new file mode 100644 index 0000000..131acc8 --- /dev/null +++ b/src/services/streamEvents.js @@ -0,0 +1,8 @@ +/** Translate model callbacks to small wire events, including corrective-pass resets. */ +export function createTextStreamEvents(sendEvent) { + const emit = (event, target) => (chunk, fullText) => { + if (chunk === "" && fullText === "") sendEvent("replace", {target, text: ""}); + else if (chunk) sendEvent(event, {chunk}); + }; + return {onChunk: emit("chunk", "reply"), onThinking: emit("thinking", "thinking")}; +} diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..f77512c --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {ApiError, get, post, stream} from "../public/js/api.js"; + +test("JSON requests retain HTTP metadata, handle empty success, and distinguish bad bodies from network errors", async t => { + let response; + t.mock.method(globalThis, "fetch", async () => response); + for (const status of [204, 205, 200]) { + response = new Response(null, {status}); + assert.deepEqual(await get("/empty"), {}); + } + response = new Response("Proxy unavailable", {status: 503, statusText: "Service Unavailable", headers: {"X-Request-ID": "proxy-123"}}); + assert.deepEqual(await get("/proxy"), {error: "HTTP 503 Service Unavailable", status: 503, code: "HTTP_503", requestId: "proxy-123"}); + response = Response.json({error: "Already exists", code: "USERNAME_TAKEN", status: 400, requestId: "body-123", retryAfter: 5}, {status: 409}); + assert.deepEqual(await post("/register", {}), {error: "Already exists", code: "USERNAME_TAKEN", status: 409, requestId: "body-123", retryAfter: 5}); + response = Response.json({error: {message: "Forbidden", code: "DENIED"}}, {status: 403}); + assert.equal((await get("/private")).code, "DENIED"); + response = new Response("not json", {headers: {"X-Request-ID": "invalid-body"}}); + assert.deepEqual(await get("/invalid"), {error: "Invalid JSON response.", status: 200, code: "INVALID_RESPONSE", requestId: "invalid-body"}); + t.mock.method(globalThis, "fetch", async () => { throw new TypeError("Failed to fetch"); }); + assert.equal((await get("/offline")).code, "NETWORK_ERROR"); +}); + +test("JSON requests support caller abort and deadlines across headers and body reads", async t => { + let signal; + t.mock.method(globalThis, "fetch", async (_url, options) => { + signal = options.signal; + return new Promise(() => {}); + }); + const controller = new AbortController(); + const cancelled = get("/cancel", {signal: controller.signal}); + controller.abort(); + assert.equal((await cancelled).code, "ABORTED"); + assert.equal((await get("/timeout", {timeoutMs: 10})).code, "TIMEOUT"); + assert.equal(signal.aborted, true); + t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream(), {headers: {"X-Request-ID": "slow-body"}})); + const bodyTimeout = await get("/slow-body", {timeoutMs: 10}); + assert.equal(bodyTimeout.code, "TIMEOUT"); + assert.equal(bodyTimeout.status, 200); + assert.equal(bodyTimeout.requestId, "slow-body"); +}); + +test("SSE errors reject by default and deliver complete metadata to an explicit handler", async t => { + const payload = {error: "Model unavailable", status: 503, code: "MODEL_UNAVAILABLE", requestId: "model-123"}; + t.mock.method(globalThis, "fetch", async () => new Response(`event: error\ndata: ${JSON.stringify(payload)}\n\n`)); + await assert.rejects(stream("/stream", {}), error => error instanceof ApiError && error.status === 503 && error.code === payload.code && error.requestId === payload.requestId); + let handled; + await stream("/stream", {}, {onError: error => { handled = error; }}); + assert.deepEqual(handled, payload); + t.mock.method(globalThis, "fetch", async () => new Response("upstream unavailable", {status: 503, headers: {"X-Request-ID": "proxy-123"}})); + await assert.rejects(stream("/stream", {}), error => error.status === 503 && error.code === "HTTP_503" && error.requestId === "proxy-123"); +}); + +test("SSE supports fragmented UTF-8, multiline data, replacements, cancellation and stalled reads", async t => { + const received = []; + const wire = 'event: chunk\r\ndata: {"chunk":"Hello 🌳"}\r\n\r\nevent: replace\ndata: {"target":"reply",\ndata: "text":"Corrected"}\n\nevent: done\ndata: {"reply":"Corrected"}\n\n'; + t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({start(controller) { + for (const byte of new TextEncoder().encode(wire)) controller.enqueue(Uint8Array.of(byte)); + controller.close(); + }}))); + await stream("/stream", {}, {onChunk: data => received.push(data.chunk), onReplace: data => received.push(data.text), onDone: data => received.push(data.reply)}); + assert.deepEqual(received, ["Hello 🌳", "Corrected", "Corrected"]); + let cancelled = 0; + t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({cancel() { cancelled++; }}), {headers: {"X-Request-ID": "stream-123"}})); + await assert.rejects(stream("/stream", {}, {}, {timeoutMs: 10}), error => error.code === "TIMEOUT" && error.requestId === "stream-123"); + assert.equal(cancelled, 1); + const controller = new AbortController(); + const pending = stream("/stream", {}, {}, controller.signal); + controller.abort(); + await assert.rejects(pending, error => error.code === "ABORTED"); +}); diff --git a/test/app.test.js b/test/app.test.js index 27cb379..01daed2 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -7,6 +7,9 @@ import fs from "fs"; import os from "os"; import path from "path"; import session from "express-session"; +import bcrypt from "bcrypt"; +import Database from "better-sqlite3"; +import {initializeSchema, runMigrations} from "../src/db/schema.js"; import {createApp, createCloseHandler} from "../src/app.js"; class CookieJar { @@ -271,7 +274,7 @@ test("username and password changes keep account data and sessions consistent", .run(attachmentId, "accountowner", "picture.png", "rename-test-image", "image/png", 1, 1, 1); assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "wrong"}, jar})).status, 403); - assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 400); + assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 409); const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar}); assert.equal(renamed.status, 200); assert.equal(renamed.json.username, "newowner"); @@ -312,6 +315,7 @@ test("admin diagnostics require admin and return model status", async () => { assert.equal(userDiagnostics.json.error, "Admin only"); await request(server.baseUrl, "/register", {method: "POST", body: {username: "admin", password: "password123"}}); + server.app.locals.db.prepare("UPDATE users SET role = 'admin' WHERE username = ?").run("admin"); await request(server.baseUrl, "/login", {method: "POST", body: {username: "admin", password: "password123"}, jar: adminJar}); const diagnostics = await request(server.baseUrl, "/admin/diagnostics", {jar: adminJar}); assert.equal(diagnostics.status, 200); @@ -1193,3 +1197,124 @@ test("every served page references existing local assets", async () => { } } finally { await server.close(); } }); + +test("roles cannot be obtained by registration or rename and revocation affects existing sessions", async t => { + for (const entry of ["register", "rename"]) { + const server = await startTestServer(); + t.after(() => server.close()); + const jar = new CookieJar(); + await request(server.baseUrl, "/register", {method: "POST", jar, + body: {username: entry === "register" ? "admin" : "ordinary", password: "password123", role: "admin", isAdmin: true}}); + if (entry === "rename") { + assert.equal((await request(server.baseUrl, "/admin/reports", {jar})).status, 403); + assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", jar, + body: {username: "admin", password: "password123", role: "admin"}})).status, 200); + } + const assertPermissions = async status => { + for (const [url, method] of [["/admin/reports", "GET"], ["/admin/diagnostics", "GET"], + ["/admin/reports/999", "POST"], ["/admin/personas/market/999/moderate", "POST"]]) { + assert.equal((await request(server.baseUrl, url, {method, jar, body: method === "POST" ? {} : undefined})).status, status, url); + } + }; + assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.role, "user"); + await assertPermissions(403); + server.app.locals.db.prepare("UPDATE users SET role = 'admin' WHERE username = 'admin'").run(); + await assertPermissions(200); + await request(server.baseUrl, "/settings/username", {method: "PUT", jar, body: {username: "moderator", password: "password123"}}); + await assertPermissions(200); + server.app.locals.db.prepare("UPDATE users SET role = 'user' WHERE username = 'moderator'").run(); + await assertPermissions(403); + } +}); + +test("legacy users named admin migrate to an unprivileged role, idempotently", () => { + const db = new Database(":memory:"); + try { + db.exec("CREATE TABLE users (username TEXT PRIMARY KEY, password TEXT NOT NULL); INSERT INTO users VALUES ('admin', 'hash')"); + initializeSchema(db); + runMigrations(db, {dropLegacyChats: true}); + runMigrations(db, {dropLegacyChats: true}); + assert.equal(db.prepare("SELECT role FROM users WHERE username = 'admin'").get().role, "user"); + assert.throws(() => db.prepare("UPDATE users SET role = 'owner'").run(), /CHECK/); + } finally { db.close(); } +}); + +test("simultaneous registrations race at insertion and return one 200 and one 409", async t => { + const server = await startTestServer(); + t.after(() => server.close()); + const hash = bcrypt.hash; + let hashing = 0, release; + const barrier = new Promise(resolve => { release = resolve; }); + t.mock.method(bcrypt, "hash", async (...args) => { + if (++hashing === 2) release(); + await barrier; + return hash(...args); + }); + const results = await Promise.all([new CookieJar(), new CookieJar()].map(jar => request(server.baseUrl, "/register", { + method: "POST", jar, body: {username: "registration-race", password: "password123"} + }))); + assert.deepEqual(results.map(result => result.status).sort(), [200, 409]); + assert.equal(results.find(result => result.status === 409).json.code, "USERNAME_TAKEN"); + assert.equal(server.app.locals.db.prepare("SELECT COUNT(*) AS count FROM users").get().count, 1); + assert.equal((await request(server.baseUrl, "/register", {method: "POST", body: {username: "registration-race", password: "password123"}})).status, 409); +}); + +test("unexpected async auth and health failures reach Express error handling", async t => { + const server = await startTestServer(); + t.after(() => server.close()); + const jar = new CookieJar(); + await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "health-check", password: "password123"}}); + const failure = Object.assign(new Error("private database details"), {code: "SQLITE_ERROR"}); + t.mock.method(server.app.locals.repositories, "insertUser", () => { throw failure; }); + const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "broken-insert", password: "password123"}}); + assert.equal(result.status, 500); + assert.equal(result.json.error, "Internal server error"); + t.mock.method(server.app.locals.modelService, "checkHealth", async () => { throw failure; }); + assert.equal((await request(server.baseUrl, "/health", {jar})).status, 500); + assert.equal(server.app.locals.observability.getSnapshot().errorsTotal, 2); + assert.equal((await request(server.baseUrl, "/session")).status, 200); +}); + +test("browser sanitizer is served from the npm dependency without exposing node_modules", async t => { + const server = await startTestServer(); + t.after(() => server.close()); + const response = await fetch(server.baseUrl + "/vendor/dompurify.js"); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-cache"); + assert.equal(await response.text(), fs.readFileSync(new URL("../node_modules/dompurify/dist/purify.min.js", import.meta.url), "utf8")); + assert.ok((await fetch(server.baseUrl + "/node_modules/dompurify/package.json")).status >= 400); +}); + +test("persona corrections explicitly replace provisional SSE output before persisting the final answer", async t => { + let pass = 0; + const modelService = { + async streamReply(_model, _messages, onChunk, options) { + const reply = ++pass === 1 ? 'You: I agree.' : 'The guide waits quietly.'; + options.onThinking?.('considering', 'considering'); + onChunk(reply, reply); + return reply; + }, + mapError: error => ({status: 500, body: {error: error.message}}) + }; + const server = await startTestServer({modelService}); + t.after(() => server.close()); + const jar = new CookieJar(); + await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "correction", password: "password123"}}); + const {json: {persona}} = await request(server.baseUrl, "/personas", {method: "POST", jar, body: {personaType: "assistant", name: "Guide"}}); + const {json: {chat}} = await request(server.baseUrl, `/personas/${persona.id}/chat`, {method: "POST", jar, body: {}}); + const response = await fetch(server.baseUrl + "/chat/stream", {method: "POST", headers: jar.headers({"Content-Type": "application/json"}), + body: JSON.stringify({chatId: chat.id, message: "Hello", model: "qwen3:14b", reasoning: true})}); + const events = (await response.text()).trim().split("\n\n").map(event => ({ + type: event.split("\n")[0].slice(7), data: JSON.parse(event.split("\n")[1].slice(6)) + })); + assert.equal(pass, 2); + const textEvents = events.filter(event => ["chunk", "replace"].includes(event.type)); + assert.deepEqual(textEvents, [ + {type: "chunk", data: {chunk: "You: I agree."}}, + {type: "replace", data: {target: "thinking", text: ""}}, + {type: "replace", data: {target: "reply", text: ""}}, + {type: "chunk", data: {chunk: "The guide waits quietly."}} + ]); + assert.equal(events.at(-1).type, "done"); + assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar})).json.messages.at(-1).content, "The guide waits quietly."); +}); diff --git a/test/composer.test.js b/test/composer.test.js new file mode 100644 index 0000000..195d875 --- /dev/null +++ b/test/composer.test.js @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createComposerController} from "../public/js/composerController.js"; +import {createConversationState} from "../public/js/conversationState.js"; + +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; }; +const noop = () => {}; +const element = () => ({value: "Hello", style: {}, scrollHeight: 0, scrollTop: 0, clientHeight: 0, + classList: {toggle: noop, add: noop}, setAttribute: noop, focus: noop, querySelectorAll: () => []}); +function fixture(overrides = {}) { + const state = createConversationState(); + const elements = {msgInput: element(), sendBtn: element(), messagesDiv: element(), modelSelect: {value: "original-model"}}; + const calls = {create: 0, streams: [], refresh: [], notices: []}; + const controller = createComposerController({state, elements, + attachmentComposer: {pending: false, attachments: [], setBusy: noop, clear: noop, clearSentDraft: noop}, + webSearchButton: element(), reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle: {enabled: true, reset: noop}, + setChatActivity: noop, updateComposerPlaceholder: noop, setNotice: message => calls.notices.push(message), + createNewChat: async () => { + calls.create++; + const chat = overrides.creation ? await overrides.creation : {id: 1, title: "New chat"}; + if (chat?.id) { state.activeChatId = chat.id; state.chatSessions.push(chat); } + return chat; + }, + getChatById: id => state.chatSessions.find(chat => chat.id === id), + updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop, + addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}), + setMessageContent: noop, setActiveChat: async id => calls.refresh.push(id), loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop, + api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"), + stream: async (_url, payload, handlers) => { + calls.streams.push(payload); + if (overrides.failure) throw new Error(overrides.failure); + handlers.onDone({reply: "Done"}); + }} + }); + return {state, elements, calls, ...controller}; +} + +test("rapid sends lock before chat creation and keep the captured chat/model after navigation during rename", async () => { + const creation = deferred(), rename = deferred(); + const s = fixture({creation: creation.promise, rename: rename.promise}); + const first = s.sendMessage(); + await s.sendMessage(); + assert.equal(s.state.isProcessing, true); + assert.equal(s.calls.create, 1); + creation.resolve({id: 7, title: "New chat"}); + await new Promise(resolve => setImmediate(resolve)); + await s.sendMessage(); + s.state.activeChatId = 8; + s.elements.modelSelect.value = "changed-model"; + s.elements.msgInput.value = "Draft in another conversation"; + rename.resolve({}); + await first; + assert.equal(s.calls.streams.length, 1); + assert.equal(s.calls.streams[0].chatId, 7); + assert.equal(s.calls.streams[0].model, "original-model"); + assert.equal(s.calls.streams[0].reasoning, true); + assert.deepEqual(s.calls.refresh, []); + assert.deepEqual(s.state.currentMessages, []); + assert.equal(s.elements.msgInput.value, "Draft in another conversation"); + assert.equal(s.state.isProcessing, false); +}); + +test("creation, rename and stream failures unlock sending and retain the draft", async () => { + for (const options of [{creation: Promise.resolve({error: "Creation failed"})}, {rename: Promise.resolve({error: "Rename failed"})}, {failure: "Stream failed"}]) { + const s = fixture(options); + await s.sendMessage(); + assert.equal(s.state.isProcessing, false); + assert.equal(s.elements.msgInput.disabled, false); + assert.equal(s.elements.msgInput.value, "Hello"); + assert.match(s.calls.notices.at(-1), /failed/); + if (options.failure) assert.equal(s.state.currentMessages[0].deliveryStatus, "failed"); + else assert.equal(s.calls.streams.length, 0); + } +}); + +test("Stop during chat creation prevents a later stream and releases the send lock", async () => { + const creation = deferred(); + const s = fixture({creation: creation.promise}); + const pending = s.sendMessage(); + await s.stopGeneration(); + creation.resolve({id: 4, title: "New chat"}); + await pending; + assert.equal(s.calls.streams.length, 0); + assert.equal(s.state.isProcessing, false); + assert.equal(s.elements.msgInput.value, "Hello"); +}); diff --git a/test/reasoning.test.js b/test/reasoning.test.js index 3664ba0..c260487 100644 --- a/test/reasoning.test.js +++ b/test/reasoning.test.js @@ -76,11 +76,12 @@ test("thinking streams separately and survives reload, retry, branch and import" globalThis.fetch = async () => new Response(wire); try { await stream("/fixture", {}, { - onThinking: data => events.push(["thinking", data.fullThinking]), - onChunk: data => events.push(["answer", data.fullReply]) + onThinking: data => events.push(["thinking", data.chunk]), + onChunk: data => events.push(["answer", data.chunk]) }); } finally { globalThis.fetch = originalFetch; } - assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "Consider carefully."], ["answer", "Answer"]]); + assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "carefully."], ["answer", "Answer"]]); + assert.doesNotMatch(wire, /fullReply|fullThinking/); const bot = (await api(`/chats/${chatId}/messages`)).messages.at(-1); assert.equal(bot.thinking, "Consider carefully."); assert.equal(bot.content, "Answer"); diff --git a/test/regenerationUi.test.js b/test/regenerationUi.test.js index bfeb3df..612f5b0 100644 --- a/test/regenerationUi.test.js +++ b/test/regenerationUi.test.js @@ -35,6 +35,12 @@ test("inline renderer streams in place, follows only nearby output, commits or r const oldDocument = globalThis.document, oldFetch = globalThis.fetch; t.after(() => { globalThis.document = oldDocument; globalThis.fetch = oldFetch; }); globalThis.document = {createElement: tag => new Element(tag)}; + const frames = new Map(); + let frameId = 0; + const oldRequestFrame = globalThis.requestAnimationFrame, oldCancelFrame = globalThis.cancelAnimationFrame; + globalThis.requestAnimationFrame = callback => { frames.set(++frameId, callback); return frameId; }; + globalThis.cancelAnimationFrame = id => frames.delete(id); + t.after(() => { globalThis.requestAnimationFrame = oldRequestFrame; globalThis.cancelAnimationFrame = oldCancelFrame; }); for (const outcome of ["success", "error", "disconnect"]) { const scroller = new Element(), card = new Element(), later = new Element(); const header = new Element(), oldBody = new Element(), actions = new Element(), oldSources = new Element(); @@ -52,6 +58,7 @@ test("inline renderer streams in place, follows only nearby output, commits or r const event = async (name, data) => { controller.enqueue(new TextEncoder().encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)); await new Promise(resolve => setTimeout(resolve, 0)); + for (const callback of [...frames.values()]) callback(); }; assert.equal(card.attributes["aria-busy"], "true"); assert.equal(card.querySelector(".msg-sources"), null); @@ -60,15 +67,15 @@ test("inline renderer streams in place, follows only nearby output, commits or r assert.equal(card.querySelector(".loading-shell").textContent, "Searching the web..."); await event("status", {status: "generating", sources: [{title: "New source", url: "https://example.org/new"}]}); assert.notEqual(card.querySelector(".msg-sources"), oldSources); - await event("thinking", {fullThinking: "New reasoning"}); + await event("thinking", {chunk: "New reasoning"}); assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning"); - await event("chunk", {fullReply: "Partial"}); + await event("chunk", {chunk: "Partial"}); assert.equal(card.querySelector(".msg-content").textContent, "Partial"); assert.equal(scroller.scrollTop, 20); assert.equal(completed, undefined); assert.deepEqual(scroller.childNodes, [card, later]); card.bottom = 1000; // User has scrolled away from this response. - await event("chunk", {fullReply: "Partial answer"}); + await event("chunk", {chunk: " answer"}); assert.equal(scroller.scrollTop, 20); if (outcome === "success") { await event("done", {reply: "Partial answer", message: {id: 2, content: "Partial answer"}}); diff --git a/test/streamRenderer.test.js b/test/streamRenderer.test.js new file mode 100644 index 0000000..9848d5d --- /dev/null +++ b/test/streamRenderer.test.js @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createStreamRenderer} from "../public/js/streamRenderer.js"; +import {createTextStreamEvents} from "../src/services/streamEvents.js"; + +test("streaming sends linear-sized deltas, applies replacements and batches rendering", () => { + const events = [], frames = new Map(), rendered = []; + let nextId = 0; + const renderer = createStreamRenderer({ + onReply: text => rendered.push(["reply", text]), onThinking: text => rendered.push(["thinking", text]), + schedule: callback => { frames.set(++nextId, callback); return nextId; }, cancel: id => frames.delete(id) + }); + const emit = createTextStreamEvents((type, data) => { + events.push({type, data}); + renderer[{chunk: "onChunk", thinking: "onThinking", replace: "onReplace"}[type]](data); + }); + for (let i = 0; i < 1000; i++) emit.onChunk("a", "a".repeat(i + 1)); + assert.equal(JSON.stringify(events).length < 45000, true); + assert.equal(frames.size, 1); + assert.equal(rendered.length, 0); + renderer.flush(); + assert.deepEqual(rendered, [["reply", "a".repeat(1000)]]); + emit.onThinking("provisional", "provisional"); + emit.onThinking("", ""); emit.onChunk("", ""); + emit.onChunk("Corrected", "Corrected"); + renderer.flush(); + assert.deepEqual(rendered.slice(1), [["thinking", ""], ["reply", "Corrected"]]); + assert.ok(events.some(event => event.type === "replace" && event.data.target === "reply")); + renderer.onChunk({chunk: " stale"}); + renderer.dispose(); + assert.equal(frames.size, 0); + renderer.flush(); + assert.equal(rendered.at(-1)[1], "Corrected"); + renderer.onChunk({chunk: "", fullReply: ""}); + renderer.onChunk({chunk: "Legacy", fullReply: "Legacy"}); + renderer.flush(); + assert.equal(rendered.at(-1)[1], "Legacy"); +});