From af0f57fa773c95bd804d28b92bc09db61d77dbae Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 12 Oct 2026 08:50:47 +0700 Subject: [PATCH 1/9] chore: scaffold WXT Chrome MV3 project and tooling TypeScript, React, WXT, pnpm, Vitest, Playwright, ESLint and Zod, with a pinned extension key for sideload builds and a store zip without it. Co-Authored-By: Claude Opus 5.5 --- .env.example | 11 + .gitignore | 13 + LICENSE | 21 + eslint.config.js | 32 + extension-public-key.json | 4 + package.json | 51 + playwright.config.ts | 10 + pnpm-lock.yaml | 3229 +++++++++++++++++++++++++++++++ public/icons/128.png | Bin 0 -> 1748 bytes public/icons/16.png | Bin 0 -> 231 bytes public/icons/32.png | Bin 0 -> 432 bytes public/icons/48.png | Bin 0 -> 616 bytes scripts/build-e2e.mjs | 4 + scripts/generate-icons.mjs | 76 + scripts/sync-dewee-contract.mjs | 18 + scripts/zip-store.mjs | 4 + tsconfig.json | 11 + vitest.config.ts | 12 + wxt.config.ts | 37 + 19 files changed, 3533 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 extension-public-key.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 public/icons/128.png create mode 100644 public/icons/16.png create mode 100644 public/icons/32.png create mode 100644 public/icons/48.png create mode 100644 scripts/build-e2e.mjs create mode 100644 scripts/generate-icons.mjs create mode 100644 scripts/sync-dewee-contract.mjs create mode 100644 scripts/zip-store.mjs create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 wxt.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c74411c --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Build-time, non-secret. Cloud origin used by the "Dewee Cloud" profile. +WXT_DEWEE_CLOUD_ORIGIN=https://app.dewee.sh + +# Live Playwright scenarios only (never baked into the build). Use a personal, +# owner-bound API key with operator.read + operator.write — never the master token. +E2E_GATEWAY_URL=http://localhost:18790 +E2E_GATEWAY_API_KEY= +E2E_WORKSPACE_ID= +# Live revocation scenario only, local test gateway: the key owner and the gateway admin token. +E2E_OWNER_ID= +E2E_GATEWAY_ADMIN_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9a9569 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.output/ +.output-e2e/ +.output-store/ +.wxt/ +dist/ +coverage/ +test-results/ +playwright-report/ +*.log +.env +.env.* +!.env.example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e072693 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Next Level Builder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0c6c686 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; + +export default tseslint.config( + { ignores: ['.output/', '.output-e2e/', '.output-store/', '.wxt/', 'node_modules/', 'coverage/', 'test-results/', 'playwright-report/'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { globals: { ...globals.browser, ...globals.node, chrome: 'readonly' } }, + plugins: { 'react-hooks': reactHooks }, + rules: { + ...reactHooks.configs.recommended.rules, + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-console': ['error', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, + { + // Playwright fixtures call `use()`, which is not React's hook. + files: ['tests/e2e/**/*.ts'], + rules: { 'react-hooks/rules-of-hooks': 'off' }, + }, + { + files: ['scripts/**/*.{js,mjs}', 'tests/e2e/support/**/*.mjs'], + languageOptions: { globals: { ...globals.node } }, + }, +); diff --git a/extension-public-key.json b/extension-public-key.json new file mode 100644 index 0000000..1d41efb --- /dev/null +++ b/extension-public-key.json @@ -0,0 +1,4 @@ +{ + "id": "poicgajeapmbcilfbeobdfedibiffbbm", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl1IGEAWHjb7MJznCoe+jgdSCl1UWDdxF2+bK+RwV3Ldk2hWigxT8AYrtzDBHyKd7Hrv9liQjdEOelqwDu1ScjjxAUhWXwa/gFc1rOqk+iay8eb/B8vSrqvJRnLdMIXUZ3qNvzTMY7IvZNnRp2mTPrYK32RlzI+GgiMjldqN8Oq1ZoMcIltzw7Q4ZNCK2Fm5V+2oOq7xvPYhxQynPpkfX4+LTgAP8k9iYX7vk6N7k/LYb5/pFFNLJ/V/+Y3xdCBuGI3rJgE3A0gESR152WH+NsXSkwxD0tS7Fr1JGuWyarFfnzOGOKRfIpci65AftUhekn8fjoITLK0m0Z/Ma8tADVwIDAQAB" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ce5b01 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "dewee-chrome-ext", + "version": "0.1.0", + "private": true, + "description": "Dewee Chrome Extension: secure multi-workspace agent chat, Super Agent and credential requests in a Chrome side panel", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.32.1", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "wxt", + "build": "wxt build", + "zip": "wxt zip", + "zip:store": "node scripts/zip-store.mjs", + "postinstall": "wxt prepare", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "build:e2e": "node scripts/build-e2e.mjs", + "test:e2e": "node scripts/build-e2e.mjs && playwright test" + }, + "dependencies": { + "dompurify": "3.4.16", + "marked": "18.1.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "zod": "4.6.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "1.64.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/chrome": "0.3.5", + "@types/node": "^24.19.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@wxt-dev/module-react": "1.2.2", + "eslint": "10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.13.0", + "jsdom": "^30.1.2", + "typescript": "5.9.3", + "typescript-eslint": "8.71.1", + "vitest": "5.0.3", + "wxt": "0.21.4" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..dedc478 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'tests/e2e', + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { trace: 'retain-on-failure', screenshot: 'only-on-failure' }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2afe490 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3229 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + dompurify: + specifier: 3.4.16 + version: 3.4.16 + marked: + specifier: 18.1.0 + version: 18.1.0 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.12.0(jiti@2.7.0)) + '@playwright/test': + specifier: 1.64.0 + version: 1.64.0 + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) + '@types/chrome': + specifier: 0.3.5 + version: 0.3.5 + '@types/node': + specifier: ^24.19.2 + version: 24.19.2 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@wxt-dev/module-react': + specifier: 1.2.2 + version: 1.2.2(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))(wxt@0.21.4(eslint@10.12.0(jiti@2.7.0))(rolldown@1.2.13)(typescript@5.9.3)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.12.0(jiti@2.7.0)) + globals: + specifier: ^17.13.0 + version: 17.13.0 + jsdom: + specifier: ^30.1.2 + version: 30.1.2 + typescript: + specifier: 5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: 8.71.1 + version: 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@24.19.2)(jsdom@30.1.2)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + wxt: + specifier: 0.21.4 + version: 0.21.4(eslint@10.12.0(jiti@2.7.0))(rolldown@1.2.13)(typescript@5.9.3)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + +packages: + + '@1natsu/wait-element@4.2.0': + resolution: {integrity: sha512-Om0Q+WE9mNrpY4AwMTvkFiYHv8VM7TML3PvOqXy+w6kAjLjKhGYHYX+305+a6J8RVpds9s7IF2Z5aOPYwULFNw==} + + '@aklinker1/rollup-plugin-visualizer@5.12.0': + resolution: {integrity: sha512-X24LvEGw6UFmy0lpGJDmXsMyBD58XmX1bbwsaMLhNoM+UMQfQ3b2RtC+nz4b/NoRK5r6QJSKJHBNVeUdwqybaQ==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rollup: + optional: true + + '@aklinker1/zero-zip@1.0.1': + resolution: {integrity: sha512-07a596Bd1QO0bi3tIyt2xruq6vKk7hCUt9enHBwggvipB0iiycoJ9/CqzN6UFawaKbOi6NBfMlUxUXzIOZ7Dsg==} + + '@asamuzakjp/css-color@7.1.5': + resolution: {integrity: sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.4': + resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.10': + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.10': + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@topcli/prompts@4.0.0': + resolution: {integrity: sha512-kkKYPb4k/6kRdnESt77B5vJrrYnYHIYczwv0P1c+tfN/IWEx3KZeKEHoUq7sImIyBEXWdA2uakIroD9pmRJziQ==} + engines: {node: '>=22.0.0'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/chrome@0.3.5': + resolution: {integrity: sha512-7HYsK7xalNxzLwGrP9lkCQPAA6V56VtPpOmv2CQnBnMzX5/Hpr+Iv5fqaIOIY/H/Q6ZBksLmurWh7zXPcZ4B7Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + '@webext-core/fake-browser@2.0.1': + resolution: {integrity: sha512-4x5z1z8F0KU8ShF4ForXJ8qnA8oZTy7HYjI91Mbpdzp3H3hU9HR6TNPUxfWtSpFz2FwgEircuJKQg0qFIn6RLg==} + + '@webext-core/isolated-element@3.0.0': + resolution: {integrity: sha512-PmSIBMSe+rFw6eXzpJoV+glc1HPO40clcT9FJ7rSfCQzNewK8/nQZoBYWen2cMqdXdBNM9to/IbNNu5mdQLaPg==} + + '@webext-core/match-patterns@2.0.0': + resolution: {integrity: sha512-EsyMMKfQuSE4rWCjP/TifwvUWlhC/OhiGn5OK3p9F0d57UmtLib0nzguzPKyxM1/kZV7SkE+jYXkE8gsdPRLmw==} + + '@wxt-dev/browser@0.3.5': + resolution: {integrity: sha512-N2oz6r7dE4HYwmpxAco4yzP7vLBNidqH3yXK2vEjvJlbKiUZvA2jG5nRwoK2quKPEo1ZWxB5HR1rBZ3xZ2e0fQ==} + + '@wxt-dev/module-react@1.2.2': + resolution: {integrity: sha512-+lRLi1r9dAXpLySWSIWHLJ1h/nFzR20iQnx3RNrKyA6oJg4+ClOluVXozHjfPg9Okfy/umtffiOopGayASrg6w==} + peerDependencies: + vite: ^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0 + wxt: '>=0.19.16' + + '@wxt-dev/storage@1.3.0': + resolution: {integrity: sha512-egzUSlRwOVxtZmX9io1/OwRF5Bo3orKnm/ydjpgf5H8VB//neAqUo0Br1jVmnNfSKMulk2C0sj6wRVC5yFRlSg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.28: + resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@7.0.1: + resolution: {integrity: sha512-86diuUyeeWM8CrqhpGCaDafrzjS1s++7sWy2AbAPoFNz/xKv7ID57CR4pZsMuGCh60PtsnEx4z68b+Vgt07VPw==} + engines: {node: '>=20.19.0'} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + caniuse-lite@1.0.30001815: + resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + dotenv-expand@13.0.0: + resolution: {integrity: sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + electron-to-chromium@1.5.452: + resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.12.0: + resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.1.3: + resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + filesize@11.0.25: + resolution: {integrity: sha512-oPVK4S+B5nRK8eL5FORi4RSzbz/1PmUx17NQLMIWv8tlNVDUFgLY0GNApL7tEebL4WgkBqSJGB3AJsCXUs01hw==} + engines: {node: '>= 10.8.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.13.0: + resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} + engines: {node: '>=18'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.1.2: + resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + many-keys-map@3.0.3: + resolution: {integrity: sha512-1DiZmDHPXMBgMRjeUtHy1q1VYmeJscHxhIAexX9z/zjRMP80+0ETuPfssi8z+kMY4DwUgsKuHqpjxgmeA9gBNA==} + engines: {node: '>=18'} + + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} + engines: {node: '>= 20'} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.58: + resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} + engines: {node: '>=18'} + + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + + nypm@0.6.10: + resolution: {integrity: sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==} + engines: {node: '>=18'} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + publish-browser-extension@6.2.0: + resolution: {integrity: sha512-yIxoJ54RdTqyfdLrc+8mswCmEeh9x/03JrtF8Ney8zjzKplKgg4fdTs3hRP33IcWImak98yAHSI8ZBVAflsDNg==} + engines: {node: '>=22.0.0'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-literal@4.0.0: + resolution: {integrity: sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw==} + + superlock@1.3.5: + resolution: {integrity: sha512-XpWNthvezZnWp0u7/UL8rBbBOnq2Qx39fw+0RNMC/+eotOd81glzsmIWVH0ejarhTeDQihxOKSbjm2XXFo5a8w==} + engines: {node: '>= 14'} + + tasuku@2.3.0: + resolution: {integrity: sha512-9Jtk+XAnttslCw4i9RceSZGr2PT5TLn0vUyWnoP2SdlSYzkiYRY6kB5qnPi5IQ/Em8e4oBow1rpaA39iijTIrA==} + + tiny-open@1.3.0: + resolution: {integrity: sha512-GUFS8yjJZq0oWqCKCJVHcBgMpmi2WEGXY1le3E5ncR0DsgTII5uUyxtfk8/vGyDDGBE42UYHR6Ocvlk8mkXSRg==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + unimport@6.5.0: + resolution: {integrity: sha512-t0KLJLRbebz3f7NrQe+vy2ObeugVeM1XqI2oeYnauddmHSglYj3U6bxOJ65IbXmOFkcAGFJK2OyGH7aqHPM6Ug==} + engines: {node: '>=18.12.0'} + peerDependencies: + oxc-parser: '*' + rolldown: ^1.0.0 + peerDependenciesMeta: + oxc-parser: + optional: true + rolldown: + optional: true + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.4.0: + resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rsbuild/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rsbuild/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.2.0: + resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==} + engines: {node: ^22.14.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wxt@0.21.4: + resolution: {integrity: sha512-iyVMTXb37BQAbsPHCuPzrK3ZxnO9pZmvbKl7VJWIHe3RG40On2NyP2FOjArtOITO96plEYnh3o+iUlRp9qE90w==} + engines: {bun: '>=1.2.0', node: '>=22'} + hasBin: true + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=5.4' + vite: ^6.3.4 || ^7.0.0 || ^8.0.0-0 + web-ext: '>=9.2.0' + peerDependenciesMeta: + eslint: + optional: true + typescript: + optional: true + web-ext: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@1natsu/wait-element@4.2.0': + dependencies: + defu: 6.1.7 + many-keys-map: 3.0.3 + + '@aklinker1/rollup-plugin-visualizer@5.12.0': + dependencies: + open: 8.4.2 + picomatch: 2.3.2 + source-map: 0.7.6 + yargs: 17.7.3 + + '@aklinker1/zero-zip@1.0.1': {} + + '@asamuzakjp/css-color@7.1.5': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.4': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.10 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.10 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.10 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.10 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.10': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.10': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.10': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(jiti@2.7.0))': + dependencies: + eslint: 10.12.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.12.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.12.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.16.0': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@oxc-project/types@0.153.0': {} + + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@testing-library/dom': 10.4.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@topcli/prompts@4.0.0': {} + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/chrome@0.3.5': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.1 + eslint: 10.12.0(jiti@2.7.0) + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.1 + debug: 4.4.3 + eslint: 10.12.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.71.1': + dependencies: + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 + + '@typescript-eslint/tsconfig-utils@8.71.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.12.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.71.1': {} + + '@typescript-eslint/typescript-estree@8.71.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + eslint: 10.12.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.71.1': + dependencies: + '@typescript-eslint/types': 8.71.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + + '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + + '@vitest/spy@5.0.3': {} + + '@webext-core/fake-browser@2.0.1': + dependencies: + '@wxt-dev/browser': 0.3.5 + lodash.merge: 4.6.2 + + '@webext-core/isolated-element@3.0.0': + dependencies: + is-potential-custom-element-name: 1.0.1 + + '@webext-core/match-patterns@2.0.0': {} + + '@wxt-dev/browser@0.3.5': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@wxt-dev/module-react@1.2.2(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))(wxt@0.21.4(eslint@10.12.0(jiti@2.7.0))(rolldown@1.2.13)(typescript@5.9.3)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)))': + dependencies: + '@vitejs/plugin-react': 6.1.2(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + wxt: 0.21.4(eslint@10.12.0(jiti@2.7.0))(rolldown@1.2.13)(typescript@5.9.3)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + transitivePeerDependencies: + - '@rolldown/plugin-babel' + - babel-plugin-react-compiler + - oxc-transform-react + + '@wxt-dev/storage@1.3.0': + dependencies: + '@wxt-dev/browser': 0.3.5 + superlock: 1.3.5 + + acorn-jsx@5.3.2(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + + acorn@8.19.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.28: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + boolbase@2.0.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.28 + caniuse-lite: 1.0.30001815 + electron-to-chromium: 1.5.452 + node-releases: 2.0.58 + update-browserslist-db: 1.3.4(browserslist@4.29.3) + + c12@3.3.4(magicast@0.5.5): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.3 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.3 + rc9: 3.1.0 + optionalDependencies: + magicast: 0.5.5 + + cac@7.0.1: {} + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + caniuse-lite@1.0.30001815: {} + + chai@6.3.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + citty@0.2.2: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + confbox@0.3.1: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css-what@8.0.0: {} + + cssom@0.5.0: {} + + csstype@3.2.3: {} + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + define-lazy-prop@2.0.0: {} + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + dotenv-expand@13.0.0: + dependencies: + dotenv: 17.4.2 + + dotenv@17.4.2: {} + + electron-to-chromium@1.5.452: {} + + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.1.0: {} + + es-module-lexer@2.3.2: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.12.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + eslint: 10.12.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.12.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(jiti@2.7.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.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + 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.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + exsolve@1.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + filesize@11.0.25: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + giget@3.3.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.13.0: {} + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hookable@6.1.2: {} + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@3.0.3: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + ignore@5.3.2: {} + + ignore@7.0.12: {} + + imurmurhash@0.1.4: {} + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@30.1.2: + dependencies: + '@asamuzakjp/css-color': 7.1.5 + '@asamuzakjp/dom-selector': 9.2.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.3 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@11.5.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + + many-keys-map@3.0.3: {} + + marked@18.1.0: {} + + mdn-data@2.27.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + mlly@1.8.2: + dependencies: + acorn: 8.19.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.58: {} + + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + + nypm@0.6.10: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.1 + + obug@2.2.1: {} + + ohash@2.0.12: {} + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.3 + pathe: 2.0.3 + + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + publish-browser-extension@6.2.0: + dependencies: + '@topcli/prompts': 4.0.0 + cac: 7.0.1 + tasuku: 2.3.0 + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + quansync@0.2.11: {} + + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-is@17.0.2: {} + + react@19.3.0: {} + + readdirp@5.1.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + std-env@4.3.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-literal@4.0.0: + dependencies: + js-tokens: 10.0.0 + + superlock@1.3.5: {} + + tasuku@2.3.0: {} + + tiny-open@1.3.0: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.12.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + uhyphen@0.2.0: {} + + undici-types@7.24.6: {} + + undici@8.11.2: {} + + unimport@6.5.0(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)): + dependencies: + acorn: 8.19.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 1.4.3 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.7 + pkg-types: 2.3.3 + scule: 1.3.0 + strip-literal: 4.0.0 + tinyglobby: 0.2.17 + unplugin: 3.4.0(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + unplugin-utils: 0.3.2 + optionalDependencies: + rolldown: 1.2.13 + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rollup + - unloader + - vite + - webpack + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.7 + + unplugin@3.4.0(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.13 + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + + update-browserslist-db@1.3.4(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.2 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.0.3(@types/node@24.19.2)(jsdom@30.1.2)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.19.2 + jsdom: 30.1.2 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.2.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wxt@0.21.4(eslint@10.12.0(jiti@2.7.0))(rolldown@1.2.13)(typescript@5.9.3)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)): + dependencies: + '@1natsu/wait-element': 4.2.0 + '@aklinker1/rollup-plugin-visualizer': 5.12.0 + '@aklinker1/zero-zip': 1.0.1 + '@topcli/prompts': 4.0.0 + '@webext-core/fake-browser': 2.0.1 + '@webext-core/isolated-element': 3.0.0 + '@webext-core/match-patterns': 2.0.0 + '@wxt-dev/browser': 0.3.5 + '@wxt-dev/storage': 1.3.0 + c12: 3.3.4(magicast@0.5.5) + cac: 7.0.1 + chokidar: 5.0.0 + consola: 3.4.2 + defu: 6.1.7 + dotenv-expand: 13.0.0 + filesize: 11.0.25 + giget: 3.3.1 + hookable: 6.1.2 + json5: 2.2.3 + linkedom: 0.18.13 + magicast: 0.5.5 + nypm: 0.6.10 + picomatch: 4.0.7 + publish-browser-extension: 6.2.0 + superlock: 1.3.5 + tiny-open: 1.3.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + unimport: 6.5.0(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + optionalDependencies: + eslint: 10.12.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - '@rspack/core' + - bun-types-no-globals + - canvas + - esbuild + - oxc-parser + - rolldown + - rollup + - unloader + - webpack + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + + zod@4.6.5: {} diff --git a/public/icons/128.png b/public/icons/128.png new file mode 100644 index 0000000000000000000000000000000000000000..89ffdbb2b2062862e76377146ea968e3de05d85d GIT binary patch literal 1748 zcmV;_1}piAP) zzmD8C5XScr?oG50;NseJTe;cNNWXP}04iL$@RdO-6?U5`XSOC;s+fAtJW1$AXz1Mh? zYWvtE1>nKJgSxKkAq4<;eMZWDIM*_rm*xgbga2sYpxA+P_WJQvi71za6!g02~ZB< z5f?|N;`{%XKZWq~uOa;MTL>Ti`L54j!V|;44gZgy#qZ#E+5RV1Q{1ZG52p~`ybIya zzn=#AR>QS$OL(Va|@{58F1oep+^4xR|sFf zr4Rh2;hwm6?yW+W0HF50|3g3Ma|T~>Z4t5q0DUo|#y@B93BIw*r&s{c)FMN-ZtyXu zZXpW*kaStB^>YSa;WNA3iU0tKR;~u$+2xll07$AN$MP)0GgwX#O#*-d(-_lh8J@)| z1m*xJE%<|3g}@vDIb`~`8lH_^zNP`lIpJGs))GTA0ASd(6|*!%GywpXT{Xt}!;&wS zd#5zFR4i?HRx3-*CICp8^kEo=8nXaInGDMNp%nN;Ck zl6ecW6`%niRr^^pY?3pU%vOK`fK=^gPp?Wsz-k3307zv1780mRFp*`A2q*wZOtxqw zd`ksiVhv@o2>_5l#Tq+vo{~gP!LWo7_*%HUn;h%a{wkRrx!2bs{0&@;==)#qN00-V~w=Kx}fv*fFzKv5;#gyQ%=h){Y#yf!GbN+O3@Wmay1; zbC!pLu`L|RM%!m~0AhE{SZ)PkYd>ShlR5wrCxKX88H`;445Zdg9RP{#(^lRJ8YJ;{ z5No?T<;@_8mnf{9uu%z}aKhT~N&z5tv4mY{(l7_&Vi)c8@Dg6FmVCvX^$ausB#(t- zKp=U#33F&mJt9W(lnu|BNlfK)CpSn6~7Q+&68z0rjR0H!=soePZX zN){}xmBLpGOWZU7q-+0_#eCu|^{42p%w-e}z}OH-tkfZ>xc9j5@pt?i{trK+aa_HX zHI2;xV96}ZUpN3HW1np101(YezwZDL%+e9@4uG;*4ZZ`QV3vdL030F=_V<|0!7c8PFp;Lel5O{nW*{R9AS1-7Aj9P>*6 zJQpZi?Pa{D0Jt~cs4I=`mjD1g7;x4ltB0vr&jCPNC3Gdxc`nY^06?brXux?_oF2zn z{~7>%XqdmcYr$UvfVTws>T1nj1Hb@*=LGrayd?N*0PxX4KAOz-r2rV_34F51p4zE# zehL5v)t(@lP*+_{?`i=sjog3K$v^h z03;J-E>$8i^}0+g-gs_88}4C4wBA*Y^FV;Y_=@h2M89Wh8OAW@3!?^jc_;e&Zwqrn z3}T}K96tMnx#5~90LVtN@1IHpj&sR0mFNV(dh)VE42{QQF~&h~ZC_7x0`Ss9VD)rA z9&;R`Zz(zfc=l4}QYCm5f&d=3?d-Lk&8vWWc?0lF@QiB#5UZ`68sPOq4}jRg0JATF z)P$PXEMlTGp5Vw(MZ_D?wbNJCY;c@QrfJ+@fIf@zF>(PkpJJcP zy#OGNe!zMnGYJM`n2Y*D0)R$bnsePp)C=JGE&S$MfH8JKS}zvyTR!{7WcIDuA~vV9 aZO<>Pq6U2Z+C~lXkrgHD{;oW|`S&7fd+@&{Qq$XJ9O+ z``$(?Kr;gaeYF_^lr!&n+enm$0CV*DG3^XMJp*GxwJ!QFmD`X|-6nCL(+$!2^|9h! z0)Vd2stiUxzY+cZ5i59xckr%OyU_q=mqhQMywRMX49aE&xOtEpJ`~iE1puy~C8%ps z08Qg#A;bWi+i_!dUqp9L$BKJng1VCeoLso03?`;4!r+-}!lQ1qkqiJm$%(`TTrP+< zk^ykNAL73p1lmXf!1@07k9^pTJEE*jBy+(V;PJ)YSWFz}))BO&4!}*mm>dUXZGyJc z0c^u9^;etP<`vpf2Z-${>By=B*m^3}2uSLUo&zBf$SBIjj;uET7q_qO!vRp%CX#i< z8-Qy}GU0~Gtpk!y@dj|^LDJ>Fa-=2Fd`Of^xN;atuW_R(bssSRvPqY`ez+GlJi|Mj zp!cN~zfbU%7?p!=~3Cd_} za0LUv_``UO(C9N!@lMR@tpYF>?o8w{I}#=$X&D^_0OK{b1hW93C+B3m0r1{lk4c>q zX}5|4j>R7_E&h;i700ZLKj>b?uLi65*<*3P+x!EyjnIklGSj^P0000 { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +const crc32 = (buf) => { + let c = 0xffffffff; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; +const chunk = (type, data) => { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +}; + +function coverage(x, y) { + // Coordinates in a 0..1 square. Returns [tile, ring] coverage flags. + const r = 0.22; + const dx = Math.max(Math.abs(x - 0.5) - (0.5 - r), 0); + const dy = Math.max(Math.abs(y - 0.5) - (0.5 - r), 0); + const tile = dx * dx + dy * dy <= r * r; + const d = Math.hypot(x - 0.5, y - 0.47); + const ring = d <= 0.29 && d >= 0.17; + // Bubble tail at the lower left. + const tail = x > 0.24 && x < 0.42 && y > 0.62 && y < 0.82 && y - 0.62 < (0.42 - x) * 1.1; + return [tile, ring || tail]; +} + +function png(size) { + const ss = 4; + const rows = []; + for (let y = 0; y < size; y++) { + const row = Buffer.alloc(1 + size * 4); + for (let x = 0; x < size; x++) { + let tile = 0; + let ring = 0; + for (let sy = 0; sy < ss; sy++) + for (let sx = 0; sx < ss; sx++) { + const [t, r] = coverage((x + (sx + 0.5) / ss) / size, (y + (sy + 0.5) / ss) / size); + if (t) tile++; + if (t && r) ring++; + } + const n = ss * ss; + const white = ring / n; + const base = [0x2f, 0x5b, 0xff]; + const o = 1 + x * 4; + for (let i = 0; i < 3; i++) row[o + i] = Math.round(base[i] * (1 - white / (tile / n || 1)) + 255 * (white / (tile / n || 1))); + row[o + 3] = Math.round((tile / n) * 255); + } + rows.push(row); + } + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; + header[9] = 6; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(Buffer.concat(rows))), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +mkdirSync('public/icons', { recursive: true }); +for (const size of [16, 32, 48, 128]) writeFileSync(`public/icons/${size}.png`, png(size)); +console.log('icons written'); diff --git a/scripts/sync-dewee-contract.mjs b/scripts/sync-dewee-contract.mjs new file mode 100644 index 0000000..23b1632 --- /dev/null +++ b/scripts/sync-dewee-contract.mjs @@ -0,0 +1,18 @@ +// Copies the versioned `dewee.ui-action.v1` contract (golden JSON Schema + examples) from a local +// checkout of nextlevelbuilder/dewee into tests/contract-fixtures, and records the source commit. +// Usage: node scripts/sync-dewee-contract.mjs +import { execFileSync } from 'node:child_process'; +import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const dewee = resolve(process.argv[2] ?? '../dewee'); +const source = join(dewee, 'internal/setup/uiaction/testdata'); +const target = resolve('tests/contract-fixtures/ui-action-v1'); + +const commit = execFileSync('git', ['-C', dewee, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +rmSync(target, { recursive: true, force: true }); +mkdirSync(target, { recursive: true }); +cpSync(join(source, 'ui-action.v1.schema.golden.json'), join(target, 'schema.json')); +cpSync(join(source, 'examples'), join(target, 'examples'), { recursive: true }); +writeFileSync(join(target, 'SOURCE.json'), `${JSON.stringify({ repo: 'nextlevelbuilder/dewee', commit }, null, 2)}\n`); +console.warn(`synced ui-action contract from ${commit}`); diff --git a/scripts/zip-store.mjs b/scripts/zip-store.mjs new file mode 100644 index 0000000..df72083 --- /dev/null +++ b/scripts/zip-store.mjs @@ -0,0 +1,4 @@ +// Zips the Chrome Web Store build into .output-store: same code, manifest without `key` (see wxt.config.ts). +import { execSync } from 'node:child_process'; + +execSync('pnpm exec wxt zip', { stdio: 'inherit', env: { ...process.env, DEWEE_STORE: '1' } }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b38cab3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "types": ["chrome", "node", "vitest/globals"] + }, + "include": [".wxt/wxt.d.ts", "src", "tests", "*.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..975bb49 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; +import { WxtVitest } from 'wxt/testing/vitest-plugin'; + +export default defineConfig({ + plugins: [WxtVitest()], + test: { + globals: true, + environment: 'jsdom', + include: ['tests/unit/**/*.test.{ts,tsx}', 'tests/integration/**/*.test.{ts,tsx}'], + setupFiles: ['tests/setup.ts'], + }, +}); diff --git a/wxt.config.ts b/wxt.config.ts new file mode 100644 index 0000000..aab4bd6 --- /dev/null +++ b/wxt.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'wxt'; +import extensionKey from './extension-public-key.json' with { type: 'json' }; + +// End-to-end builds pre-grant loopback hosts so Playwright never meets Chrome's permission prompt, +// and go to their own folder so they cannot be zipped or shipped by mistake. +const e2e = process.env.DEWEE_E2E === '1'; +// The Chrome Web Store refuses a manifest with `key` and assigns its own ID, so store builds drop it. +const store = process.env.DEWEE_STORE === '1'; + +// Thin entrypoints live in src/entrypoints; feature code lives in src/{background,sidepanel,options,...}. +export default defineConfig({ + srcDir: 'src', + outDir: e2e ? '.output-e2e' : store ? '.output-store' : '.output', + modules: ['@wxt-dev/module-react'], + manifest: { + name: 'Dewee', + // Public key only: pins the unpacked/dev extension ID (poicgajeapmbcilfbeobdfedibiffbbm) so OAuth redirect URIs and + // Gateway origin allowlists stay stable. The Chrome Web Store build gets its own store-assigned ID. + ...(store ? {} : { key: extensionKey.key }), + description: + 'Chat with your Dewee agents and Super Agent in a side panel, and answer credential requests without exposing secrets.', + minimum_chrome_version: '116', + permissions: ['sidePanel', 'storage', 'identity', 'alarms'], + // Gateway origins are requested at runtime, one origin at a time, after the user adds a profile. + optional_host_permissions: ['https://*/*', 'http://localhost/*', 'http://127.0.0.1/*'], + ...(e2e ? { host_permissions: ['http://localhost/*', 'http://127.0.0.1/*'] } : {}), + icons: { 16: 'icons/16.png', 32: 'icons/32.png', 48: 'icons/48.png', 128: 'icons/128.png' }, + action: { default_title: 'Open Dewee', default_icon: { 16: 'icons/16.png', 32: 'icons/32.png' } }, + // The action opens the side panel (setPanelBehavior), so this shortcut opens it too. + commands: { + _execute_action: { suggested_key: { default: 'Alt+Shift+D', mac: 'Alt+Shift+D' }, description: 'Open the Dewee side panel' }, + }, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self'; base-uri 'none'; frame-ancestors 'none'", + }, + }, +}); From f822fbb09c1cea7e6377a5bd18d62ec259500623 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 12 Oct 2026 08:50:47 +0700 Subject: [PATCH 2/9] feat: add Dewee side panel with chat, sessions and credential cards Cloud (OAuth PKCE S256 via the BFF) and self-hosted (gateway URL plus a scoped personal API key) modes share one typed client. Workspace and agent pickers pin Super Agent first; streaming chat supports sessions, history, stop, and reconcile without automatic resend. Server-defined secret_request and oauth_request cards are validated with Zod, secrets stay in the form and go only to the respond endpoint, and card continuations are queued by action id until delivery is proven. Includes the badge, vi/en, themes and accessibility. Co-Authored-By: Claude Opus 5.5 --- src/api/client-factory.ts | 35 + src/api/dewee-client.ts | 239 +++++++ src/api/errors.ts | 97 +++ src/api/schemas.ts | 123 ++++ src/auth/cloud-oauth.ts | 123 ++++ src/auth/credential-store.ts | 50 ++ src/auth/pkce.ts | 31 + src/background/pending-badge.ts | 51 ++ src/background/service-worker.ts | 56 ++ src/background/token-manager.ts | 84 +++ src/chat/chat-state.ts | 305 +++++++++ src/chat/history.ts | 48 ++ src/chat/pending-continuations.ts | 67 ++ src/chat/use-chat.ts | 782 +++++++++++++++++++++++ src/credentials/ActionCard.tsx | 221 +++++++ src/credentials/CredentialsForm.tsx | 142 ++++ src/credentials/labels.ts | 16 + src/credentials/respond-types.ts | 11 + src/entrypoints/background.ts | 5 + src/entrypoints/options/index.html | 13 + src/entrypoints/options/main.tsx | 4 + src/entrypoints/sidepanel/index.html | 12 + src/entrypoints/sidepanel/main.tsx | 4 + src/env.d.ts | 3 + src/options/Settings.tsx | 147 +++++ src/options/options.css | 54 ++ src/protocol/chat-events.ts | 174 +++++ src/protocol/messages.ts | 34 + src/protocol/sse-reader.ts | 62 ++ src/protocol/ui-action.ts | 218 +++++++ src/shared/ConnectForm.tsx | 140 ++++ src/shared/components.css | 114 ++++ src/shared/config.ts | 5 + src/shared/connect.ts | 106 +++ src/shared/i18n.ts | 412 ++++++++++++ src/shared/markdown.ts | 34 + src/shared/origin.ts | 34 + src/shared/settings.ts | 123 ++++ src/shared/theme.css | 201 ++++++ src/shared/use-settings.ts | 74 +++ src/sidepanel/App.tsx | 79 +++ src/sidepanel/ChatShell.tsx | 302 +++++++++ src/sidepanel/components/AgentPicker.tsx | 116 ++++ src/sidepanel/components/Composer.tsx | 63 ++ src/sidepanel/components/MessageList.tsx | 154 +++++ src/sidepanel/components/SessionList.tsx | 70 ++ src/sidepanel/sidepanel.css | 369 +++++++++++ src/sidepanel/use-directory.ts | 93 +++ 48 files changed, 5700 insertions(+) create mode 100644 src/api/client-factory.ts create mode 100644 src/api/dewee-client.ts create mode 100644 src/api/errors.ts create mode 100644 src/api/schemas.ts create mode 100644 src/auth/cloud-oauth.ts create mode 100644 src/auth/credential-store.ts create mode 100644 src/auth/pkce.ts create mode 100644 src/background/pending-badge.ts create mode 100644 src/background/service-worker.ts create mode 100644 src/background/token-manager.ts create mode 100644 src/chat/chat-state.ts create mode 100644 src/chat/history.ts create mode 100644 src/chat/pending-continuations.ts create mode 100644 src/chat/use-chat.ts create mode 100644 src/credentials/ActionCard.tsx create mode 100644 src/credentials/CredentialsForm.tsx create mode 100644 src/credentials/labels.ts create mode 100644 src/credentials/respond-types.ts create mode 100644 src/entrypoints/background.ts create mode 100644 src/entrypoints/options/index.html create mode 100644 src/entrypoints/options/main.tsx create mode 100644 src/entrypoints/sidepanel/index.html create mode 100644 src/entrypoints/sidepanel/main.tsx create mode 100644 src/env.d.ts create mode 100644 src/options/Settings.tsx create mode 100644 src/options/options.css create mode 100644 src/protocol/chat-events.ts create mode 100644 src/protocol/messages.ts create mode 100644 src/protocol/sse-reader.ts create mode 100644 src/protocol/ui-action.ts create mode 100644 src/shared/ConnectForm.tsx create mode 100644 src/shared/components.css create mode 100644 src/shared/config.ts create mode 100644 src/shared/connect.ts create mode 100644 src/shared/i18n.ts create mode 100644 src/shared/markdown.ts create mode 100644 src/shared/origin.ts create mode 100644 src/shared/settings.ts create mode 100644 src/shared/theme.css create mode 100644 src/shared/use-settings.ts create mode 100644 src/sidepanel/App.tsx create mode 100644 src/sidepanel/ChatShell.tsx create mode 100644 src/sidepanel/components/AgentPicker.tsx create mode 100644 src/sidepanel/components/Composer.tsx create mode 100644 src/sidepanel/components/MessageList.tsx create mode 100644 src/sidepanel/components/SessionList.tsx create mode 100644 src/sidepanel/sidepanel.css create mode 100644 src/sidepanel/use-directory.ts diff --git a/src/api/client-factory.ts b/src/api/client-factory.ts new file mode 100644 index 0000000..1d1cd68 --- /dev/null +++ b/src/api/client-factory.ts @@ -0,0 +1,35 @@ +/** Builds the `DeweeClient` for a connection profile with the right base URL and auth adapter. */ +import { callBackground } from '@/protocol/messages'; +import type { Profile } from '@/shared/settings'; +import { cloudBaseUrl, DeweeClient, gatewayBaseUrl, type AuthAdapter } from './dewee-client'; +import { DeweeApiError } from './errors'; + +export function baseUrlFor(profile: Pick): string { + return profile.kind === 'cloud' ? cloudBaseUrl(profile.origin) : gatewayBaseUrl(profile.origin); +} + +/** Pages ask the service worker for credentials so Cloud renewal stays single-flight. */ +export function pageAuthAdapter(profileId: string): AuthAdapter { + return { + async authorization() { + const result = await callBackground({ type: 'auth.authorization', profileId }); + if (result.ok && result.authorization) return result.authorization; + if (result.ok || result.status === 401) throw new DeweeApiError(401, result.ok ? 'not_connected' : result.code, 'Not signed in'); + // A network error during renewal or an unavailable worker is not a sign-out: keep the + // credential and report a transport failure the caller can retry. + throw new DeweeApiError(result.status ?? 0, result.code, result.message ?? 'Could not get credentials'); + }, + async renew() { + const result = await callBackground({ type: 'auth.renew', profileId }); + if (result.ok) return true; + if (result.status === 401) return false; + // Renewal could not reach the server: report that instead of the 401, so the caller keeps + // the credential and can retry rather than signing the person out. + throw new DeweeApiError(result.status ?? 0, result.code, result.message ?? 'Could not renew the session'); + }, + }; +} + +export function createClient(profile: Pick, auth: AuthAdapter, fetchImpl?: typeof fetch): DeweeClient { + return new DeweeClient({ baseUrl: baseUrlFor(profile), auth, fetch: fetchImpl }); +} diff --git a/src/api/dewee-client.ts b/src/api/dewee-client.ts new file mode 100644 index 0000000..56878b6 --- /dev/null +++ b/src/api/dewee-client.ts @@ -0,0 +1,239 @@ +/** + * Typed transport for Extension API v1 (docs/api-contract.md). One client serves both modes; the + * only difference is the base URL and the `AuthAdapter` (a static API key for self-hosted, an + * OAuth access token with renewal for Cloud). The client holds no app state and never logs. + */ +import { z } from 'zod'; +import { mapRuntimeFrame, type ChatEvent, type RawSseFrame } from '@/protocol/chat-events'; +import { readSseFrames } from '@/protocol/sse-reader'; +import { DeweeApiError, errorFromResponse } from './errors'; +import { + abortResponse, + actionsResponse, + agentsResponse, + historyResponse, + meResponse, + respondResponse, + sessionsResponse, + statusResponse, + workspacesResponse, + type History, + type RespondResult, + type RunStatus, +} from './schemas'; +import type { UiActionCard } from '@/protocol/ui-action'; + +export interface AuthAdapter { + /** Value of the `Authorization` header. */ + authorization(): Promise; + /** Called once after a 401; resolves true when new credentials are ready to retry with. */ + renew?(): Promise; +} + +export interface ClientOptions { + baseUrl: string; + auth: AuthAdapter; + fetch?: typeof fetch; + /** Timeout for JSON requests. */ + timeoutMs?: number; + /** A stream with no bytes for this long is aborted as stalled. */ + streamIdleMs?: number; +} + +export interface StreamRequest { + agent_key: string; + message?: string; + session_key?: string; + setup_action_id?: string; +} + +export interface RespondBody { + response?: Record; + secret?: Record; +} + +const DEFAULT_TIMEOUT = 15_000; +const DEFAULT_STREAM_IDLE = 120_000; + +export class DeweeClient { + private readonly baseUrl: string; + private readonly auth: AuthAdapter; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private readonly streamIdleMs: number; + + constructor(options: ClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.auth = options.auth; + this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init)); + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT; + this.streamIdleMs = options.streamIdleMs ?? DEFAULT_STREAM_IDLE; + } + + me() { + return this.json('GET', '/me', meResponse); + } + + async workspaces() { + return (await this.json('GET', '/workspaces', workspacesResponse)).workspaces; + } + + agents(workspaceId: string) { + return this.json('GET', `${ws(workspaceId)}/agents`, agentsResponse); + } + + sessions(workspaceId: string, query: { agentKey?: string; limit?: number; offset?: number } = {}) { + const params = new URLSearchParams(); + if (query.agentKey) params.set('agent_key', query.agentKey); + params.set('limit', String(query.limit ?? 20)); + params.set('offset', String(query.offset ?? 0)); + return this.json('GET', `${ws(workspaceId)}/sessions?${params.toString()}`, sessionsResponse); + } + + history(workspaceId: string, body: { session_key: string; cursor?: string; limit?: number }): Promise { + return this.json('POST', `${ws(workspaceId)}/chat/history`, historyResponse, body); + } + + async abort(workspaceId: string, body: { session_key: string; run_id?: string; agent_key?: string }): Promise { + return (await this.json('POST', `${ws(workspaceId)}/chat/abort`, abortResponse, body)).aborted; + } + + status(workspaceId: string, sessionKey: string): Promise { + return this.json('GET', `${ws(workspaceId)}/sessions/${encodeURIComponent(sessionKey)}/status`, statusResponse); + } + + async actions(workspaceId: string, query: { sessionKey?: string; status?: string } = {}): Promise { + const params = new URLSearchParams(); + if (query.sessionKey) params.set('session_key', query.sessionKey); + if (query.status) params.set('status', query.status); + const suffix = params.size ? `?${params.toString()}` : ''; + return (await this.json('GET', `${ws(workspaceId)}/actions${suffix}`, actionsResponse)).actions; + } + + /** + * Answers a card. `body.secret` is sent only here, only over this request, and is never kept + * by the client; callers clear their own copy before awaiting. + */ + respond(workspaceId: string, actionId: string, body: RespondBody): Promise { + return this.json('POST', `${ws(workspaceId)}/actions/${encodeURIComponent(actionId)}/respond`, respondResponse, body); + } + + /** Streams one chat turn. Ends after a terminal event, a stall, or when `signal` aborts. */ + async *streamChat(workspaceId: string, body: StreamRequest, signal?: AbortSignal): AsyncGenerator { + const controller = new AbortController(); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + let stalled = false; + // Set once the server answered 200: from then on the turn is accepted and must never be resent. + let responded = false; + let idle: ReturnType | undefined; + let frames: AsyncGenerator | undefined; + const arm = () => { + clearTimeout(idle); + idle = setTimeout(() => { + stalled = true; + controller.abort(); + }, this.streamIdleMs); + }; + try { + arm(); + const response = await this.send('POST', `${ws(workspaceId)}/chat/stream`, body, controller.signal, 'text/event-stream'); + responded = true; + if (!response.body) throw new DeweeApiError(0, 'invalid_response', 'Empty stream', undefined, true); + frames = readSseFrames(response.body); + for (;;) { + let next: IteratorResult; + try { + next = await frames.next(); + } catch (error) { + // Only a broken body is a lost connection (the run may still be going on the server), so + // callers reconcile, never resend. + if (!stalled && !signal?.aborted && isNetworkFailure(error)) { + throw new DeweeApiError(0, 'connection_lost', 'The connection was lost', undefined, true); + } + throw error; + } + if (next.done) break; + arm(); + const event = mapRuntimeFrame(next.value); + if (event) yield event; + } + } catch (error) { + if (stalled) throw new DeweeApiError(0, 'stream_stalled', 'The stream stopped responding', undefined, responded); + // Any other fault after the 200 (a decoder or mapping bug) still means the server took the + // turn: mark it so callers reconcile instead of offering a resend. The original is the cause. + if (responded && !signal?.aborted && !(error instanceof DeweeApiError)) { + throw new DeweeApiError(0, 'runtime_request_failed', 'The reply could not be read', undefined, true, error); + } + throw error; + } finally { + clearTimeout(idle); + signal?.removeEventListener('abort', onAbort); + controller.abort(); + // Releases the body reader when the caller stops iterating early. + void frames?.return(undefined).catch(() => {}); + } + } + + private async json(method: string, path: string, schema: T, body?: unknown): Promise> { + const signal = AbortSignal.timeout(this.timeoutMs); + const response = await this.send(method, path, body, signal, 'application/json'); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new DeweeApiError(response.status, 'invalid_response', 'The server returned an unreadable response'); + } + const parsed = schema.safeParse(payload); + if (!parsed.success) throw new DeweeApiError(response.status, 'invalid_response', 'The server returned an unexpected response'); + return parsed.data; + } + + private async send(method: string, path: string, body: unknown, signal: AbortSignal, accept: string): Promise { + let response = await this.attempt(method, path, body, signal, accept); + if (response.status === 401 && this.auth.renew && (await this.auth.renew())) { + response = await this.attempt(method, path, body, signal, accept); + } + if (!response.ok) throw await errorFromResponse(response); + return response; + } + + private async attempt(method: string, path: string, body: unknown, signal: AbortSignal, accept: string): Promise { + const headers: Record = { Accept: accept, Authorization: await this.auth.authorization() }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + try { + return await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + }); + } catch (error) { + if (signal.aborted && signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError') { + throw new DeweeApiError(0, 'timeout', 'The server did not respond in time'); + } + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw new DeweeApiError(0, 'network_error', 'Could not reach the server'); + } + } +} + +/** A fetch body read fails with a TypeError (or a NetworkError DOMException) when the transport drops. */ +function isNetworkFailure(error: unknown): boolean { + return error instanceof TypeError || (error instanceof DOMException && error.name === 'NetworkError'); +} + +function ws(workspaceId: string): string { + return `/workspaces/${encodeURIComponent(workspaceId)}`; +} + +export function gatewayBaseUrl(origin: string): string { + return `${origin}/v1/extension`; +} + +export function cloudBaseUrl(origin: string): string { + return `${origin}/api/extension/v1`; +} diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..4f04d91 --- /dev/null +++ b/src/api/errors.ts @@ -0,0 +1,97 @@ +/** + * One error type for every transport failure. `code` is the server's error code (see + * docs/api-contract.md) or a client code: `network_error` (rejected fetch), `timeout`, + * `invalid_response`, `stream_stalled`, `connection_lost`, `runtime_request_failed` (a client + * fault after the stream answered), `history_changed` (the history kept shrinking while it was + * read), `not_connected`, or a background code such as + * `background_unavailable`. Messages never contain request bodies or credentials. + */ +import { z } from 'zod'; + +export type ErrorKind = 'auth' | 'forbidden' | 'not_found' | 'conflict' | 'rate_limited' | 'invalid' | 'server' | 'network'; + +export class DeweeApiError extends Error { + readonly status: number; + readonly code: string; + readonly retryAfterSeconds?: number; + /** + * A transport failure (status 0) that happened after the server answered 200: the request was + * accepted, so it must be reconciled, never sent again. False for failures before any response. + */ + readonly afterResponse: boolean; + + constructor(status: number, code: string, message: string, retryAfterSeconds?: number, afterResponse = false, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'DeweeApiError'; + this.status = status; + this.code = code; + this.retryAfterSeconds = retryAfterSeconds; + this.afterResponse = afterResponse; + } + + get kind(): ErrorKind { + if (this.status === 0) return 'network'; + if (this.status === 401) return 'auth'; + if (this.status === 403) return 'forbidden'; + if (this.status === 404) return 'not_found'; + if (this.status === 409) return 'conflict'; + if (this.status === 429) return 'rate_limited'; + if (this.status >= 500) return 'server'; + return 'invalid'; + } +} + +const envelope = z.object({ + error: z.object({ + code: z.string().max(80), + // Optional siblings are dropped when malformed, so they never cost the code or `details.reason`. + message: z.string().max(500).optional().catch(undefined), + retry_after_seconds: z.number().int().positive().max(3600).optional().catch(undefined), + // Parsed on its own below: a malformed `details` must not cost the code, message and retry. + details: z.unknown().optional(), + }), +}); + +const detailsReason = z.object({ reason: z.string() }); + +/** `error.details.reason` values that name the refusal more precisely than `error.code`. */ +const specificReasons = new Set(['credential_policy_revoked']); + +const statusCodes: Record = { + 400: 'invalid_request', + 401: 'unauthenticated', + 403: 'forbidden', + 404: 'not_found', + 409: 'conflict', + 413: 'invalid_request', + 429: 'rate_limited', +}; + +function retryAfterHeader(response: Response): number | undefined { + const raw = response.headers.get('Retry-After'); + const seconds = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 3600) : undefined; +} + +/** Builds the error for a non-2xx response from its envelope, falling back to the status. */ +export async function errorFromResponse(response: Response): Promise { + let body: unknown = null; + try { + body = await response.json(); + } catch { + // Non-JSON error bodies (proxies, HTML pages) are ignored: only the status is trusted. + } + const parsed = envelope.safeParse(body); + const fallbackCode = statusCodes[response.status] ?? (response.status >= 500 ? 'runtime_unavailable' : 'request_failed'); + if (parsed.success) { + const { code, message, retry_after_seconds, details } = parsed.data.error; + const detail = detailsReason.safeParse(details); + const specific = detail.success && specificReasons.has(detail.data.reason) ? detail.data.reason : code; + return new DeweeApiError(response.status, specific, message ?? code, retry_after_seconds ?? retryAfterHeader(response)); + } + return new DeweeApiError(response.status, fallbackCode, `HTTP ${response.status}`, retryAfterHeader(response)); +} + +export function isApiError(value: unknown): value is DeweeApiError { + return value instanceof DeweeApiError; +} diff --git a/src/api/schemas.ts b/src/api/schemas.ts new file mode 100644 index 0000000..5456b64 --- /dev/null +++ b/src/api/schemas.ts @@ -0,0 +1,123 @@ +/** + * Response shapes of Extension API v1 (docs/api-contract.md). Objects are lenient about extra + * keys (forward compatible) but strict about the fields the client reads. + */ +import { z } from 'zod'; +import { parseUiActionView, type UiActionCard } from '@/protocol/ui-action'; + +const nullableString = z.string().nullish().transform((v) => v ?? undefined); + +export const meResponse = z.object({ + user: z.object({ id: z.string(), email: nullableString, display_name: nullableString }), + auth: z.object({ + mode: z.enum(['api_key', 'oauth']), + scopes: z.array(z.string()).default([]), + expires_at: nullableString, + }), +}); +export type Me = z.infer; + +export const workspace = z.object({ + id: z.string().min(1), + name: z.string().default(''), + role: nullableString, + status: z.string().default('active'), +}); +export type Workspace = z.infer; +export const workspacesResponse = z.object({ workspaces: z.array(workspace) }); + +export const agent = z.object({ + key: z.string().min(1), + id: nullableString, + name: z.string().default(''), + status: z.string().default('active'), + model: nullableString, + provider: nullableString, + is_super: z.boolean().default(false), +}); +export type Agent = z.infer; +export const agentsResponse = z.object({ agents: z.array(agent), super_agent_key: nullableString }); + +export const session = z.object({ + key: z.string().min(1), + agent_key: nullableString, + title: nullableString, + updated_at: nullableString, + message_count: z.number().int().nonnegative().optional(), +}); +export type Session = z.infer; +export const sessionsResponse = z.object({ + sessions: z.array(session), + total: z.number().int().nonnegative().default(0), + limit: z.number().int().optional(), + offset: z.number().int().optional(), +}); + +export const historyMessage = z.object({ + id: nullableString, + role: z.enum(['user', 'assistant', 'tool', 'system']).catch('assistant'), + content: z.string().default(''), + created_at: nullableString, + /** Written by the server, not the model (e.g. the marker of a run stopped before replying). */ + injected: z.boolean().optional(), +}); +export type HistoryMessage = z.infer; +export const historyResponse = z.object({ + session_key: nullableString, + cursor: nullableString, + next_cursor: nullableString, + reset: z.boolean().default(false), + messages: z.array(historyMessage).default([]), +}); +export type History = z.infer; + +export const abortResponse = z.object({ aborted: z.boolean() }); +export const statusResponse = z.object({ running: z.boolean(), run_id: nullableString }); +export type RunStatus = z.infer; + +/** Cards are validated one by one; an invalid or unknown card is dropped, not fatal. */ +const uiActionList = z.array(z.unknown()).transform((items) => + items.map((item) => parseUiActionView(item)).filter((item): item is UiActionCard => item !== null), +); +export const actionsResponse = z.object({ actions: uiActionList }); + +export const continuation = z.object({ + session_key: z.string().min(1), + agent_key: z.string().min(1), + setup_action_id: z.string().min(1), +}); +export type Continuation = z.infer; + +export const respondResponse = z.object({ + action: z.unknown().transform((value, ctx) => { + const parsed = parseUiActionView(value); + if (!parsed) { + ctx.addIssue({ code: 'custom', message: 'invalid ui action' }); + return z.NEVER; + } + return parsed; + }), + replayed: z.boolean().default(false), + result: z + .object({ + target_type: nullableString, + name: nullableString, + connected: z.boolean().optional(), + credential_ref: nullableString, + error_code: nullableString, + }) + .passthrough() + .nullish(), + continuation: continuation.nullish(), +}); +export type RespondResult = z.infer; + +export const tokenResponse = z.object({ + access_token: z.string().min(1), + token_type: z.string().refine((v) => v.toLowerCase() === 'bearer'), + expires_in: z.number().int().positive(), + refresh_token: z.string().min(1), + refresh_expires_in: z.number().int().positive().optional(), + scope: z.string().optional(), +}); +export type TokenResponse = z.infer; diff --git a/src/auth/cloud-oauth.ts b/src/auth/cloud-oauth.ts new file mode 100644 index 0000000..9f79547 --- /dev/null +++ b/src/auth/cloud-oauth.ts @@ -0,0 +1,123 @@ +/** + * Dewee Cloud sign-in: Authorization Code + PKCE S256 through `chrome.identity.launchWebAuthFlow`, + * then token exchange, rotation and revocation against the Extension OAuth client + * (docs/api-contract.md, "Cloud authentication"). Tokens never appear in URLs or logs. + */ +import { browser } from 'wxt/browser'; +import { DeweeApiError, errorFromResponse } from '@/api/errors'; +import { cloudBaseUrl } from '@/api/dewee-client'; +import { tokenResponse, type TokenResponse } from '@/api/schemas'; +import { OAUTH_CLIENT_ID, OAUTH_SCOPE } from '@/shared/config'; +import type { OAuthCredential } from './credential-store'; +import { codeChallengeS256, createCodeVerifier, randomToken, safeEqual } from './pkce'; + +export function redirectUri(): string { + return browser.identity.getRedirectURL('oauth2'); +} + +export function buildAuthorizeUrl(origin: string, params: { challenge: string; state: string; redirect: string }): string { + const url = new URL('/oauth/extension/authorize', origin); + url.search = new URLSearchParams({ + response_type: 'code', + client_id: OAUTH_CLIENT_ID, + redirect_uri: params.redirect, + code_challenge: params.challenge, + code_challenge_method: 'S256', + state: params.state, + scope: OAUTH_SCOPE, + }).toString(); + return url.toString(); +} + +/** Extracts the code from the redirect, rejecting a state mismatch or an error response. */ +export function parseRedirect(responseUrl: string, expectedState: string, redirect: string): string { + const url = new URL(responseUrl); + const expected = new URL(redirect); + if (url.origin !== expected.origin || url.pathname !== expected.pathname) { + throw new DeweeApiError(400, 'oauth_invalid_redirect', 'Unexpected sign-in redirect'); + } + const params = url.searchParams; + const state = params.get('state') ?? ''; + if (!safeEqual(state, expectedState)) throw new DeweeApiError(400, 'oauth_state_mismatch', 'Sign-in state did not match'); + const error = params.get('error'); + if (error) { + throw new DeweeApiError(error === 'access_denied' ? 403 : 400, `oauth_${error.replace(/[^a-z_]/gi, '').slice(0, 40)}`, 'Sign-in was not completed'); + } + const code = params.get('code'); + if (!code) throw new DeweeApiError(400, 'oauth_missing_code', 'Sign-in returned no code'); + return code; +} + +export function credentialFromTokens(tokens: TokenResponse, now = Date.now()): OAuthCredential { + return { + kind: 'oauth', + accessToken: tokens.access_token, + accessExpiresAt: now + tokens.expires_in * 1000, + refreshToken: tokens.refresh_token, + refreshExpiresAt: tokens.refresh_expires_in ? now + tokens.refresh_expires_in * 1000 : undefined, + scope: tokens.scope, + }; +} + +async function postAuth(origin: string, path: string, body: Record, fetchImpl: typeof fetch): Promise { + let response: Response; + try { + response = await fetchImpl(`${cloudBaseUrl(origin)}/auth/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }); + } catch { + throw new DeweeApiError(0, 'network_error', 'Could not reach Dewee Cloud'); + } + if (!response.ok) throw await errorFromResponse(response); + return response; +} + +async function readTokens(response: Response): Promise { + const parsed = tokenResponse.safeParse(await response.json().catch(() => null)); + if (!parsed.success) throw new DeweeApiError(response.status, 'invalid_response', 'Dewee Cloud returned an unexpected token response'); + return parsed.data; +} + +/** Interactive sign-in; must start from a user gesture. */ +export async function signInWithCloud(origin: string, fetchImpl: typeof fetch = fetch): Promise { + const verifier = createCodeVerifier(); + const state = randomToken(24); + const redirect = redirectUri(); + const authorizeUrl = buildAuthorizeUrl(origin, { challenge: await codeChallengeS256(verifier), state, redirect }); + let responseUrl: string | undefined; + try { + responseUrl = await browser.identity.launchWebAuthFlow({ url: authorizeUrl, interactive: true }); + } catch { + throw new DeweeApiError(0, 'oauth_cancelled', 'Sign-in window was closed'); + } + if (!responseUrl) throw new DeweeApiError(0, 'oauth_cancelled', 'Sign-in window was closed'); + const code = parseRedirect(responseUrl, state, redirect); + const response = await postAuth( + origin, + 'exchange', + { grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirect, client_id: OAUTH_CLIENT_ID }, + fetchImpl, + ); + return credentialFromTokens(await readTokens(response)); +} + +export async function refreshCloudTokens(origin: string, refreshToken: string, fetchImpl: typeof fetch = fetch): Promise { + const response = await postAuth(origin, 'refresh', { refresh_token: refreshToken, client_id: OAUTH_CLIENT_ID }, fetchImpl); + return credentialFromTokens(await readTokens(response)); +} + +/** Best effort: a failed revoke still signs out locally. */ +export async function revokeCloudToken(origin: string, token: string, fetchImpl: typeof fetch = fetch): Promise { + try { + await postAuth(origin, 'revoke', { token }, fetchImpl); + return true; + } catch { + return false; + } +} diff --git a/src/auth/credential-store.ts b/src/auth/credential-store.ts new file mode 100644 index 0000000..1407087 --- /dev/null +++ b/src/auth/credential-store.ts @@ -0,0 +1,50 @@ +/** + * Connection credentials (a self-hosted API key, or Cloud OAuth tokens), one record per profile. + * + * Default: `storage.session` (memory-backed, cleared when the browser closes, readable only by + * extension pages and the service worker). With the profile's opt-in "remember on this device", + * the record goes to `storage.local` instead; the UI discloses that trade-off. Never `sync`. + * Clearing always removes the record from both areas so a logout cannot leave a copy behind. + */ +import { browser } from 'wxt/browser'; +import { z } from 'zod'; + +const apiKeyCredential = z.object({ kind: z.literal('api_key'), apiKey: z.string().min(1) }); +const oauthCredential = z.object({ + kind: z.literal('oauth'), + accessToken: z.string().min(1), + accessExpiresAt: z.number(), + refreshToken: z.string().min(1), + refreshExpiresAt: z.number().optional(), + scope: z.string().optional(), +}); +const credentialSchema = z.discriminatedUnion('kind', [apiKeyCredential, oauthCredential]); +export type Credential = z.infer; +export type OAuthCredential = z.infer; + +export const credentialKey = (profileId: string) => `credential:${profileId}`; + +export async function readCredential(profileId: string): Promise { + const key = credentialKey(profileId); + const fromSession: unknown = (await browser.storage.session.get(key))[key]; + const value: unknown = fromSession ?? (await browser.storage.local.get(key))[key]; + const parsed = credentialSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export async function writeCredential(profileId: string, credential: Credential, remember: boolean): Promise { + const key = credentialKey(profileId); + const value = credentialSchema.parse(credential); + if (remember) { + await browser.storage.local.set({ [key]: value }); + await browser.storage.session.remove(key); + } else { + await browser.storage.session.set({ [key]: value }); + await browser.storage.local.remove(key); + } +} + +export async function clearCredential(profileId: string): Promise { + const key = credentialKey(profileId); + await Promise.all([browser.storage.session.remove(key), browser.storage.local.remove(key)]); +} diff --git a/src/auth/pkce.ts b/src/auth/pkce.ts new file mode 100644 index 0000000..8ff4ae7 --- /dev/null +++ b/src/auth/pkce.ts @@ -0,0 +1,31 @@ +/** PKCE (RFC 7636, S256 only) and OAuth `state` helpers built on Web Crypto. */ + +function base64Url(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function randomToken(byteLength = 32): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64Url(bytes); +} + +/** 43–128 characters from the unreserved set; 32 random bytes give 43. */ +export function createCodeVerifier(): string { + return randomToken(32); +} + +export async function codeChallengeS256(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64Url(new Uint8Array(digest)); +} + +/** Constant-time comparison for short ASCII values such as `state`. */ +export function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/src/background/pending-badge.ts b/src/background/pending-badge.ts new file mode 100644 index 0000000..c7f89ad --- /dev/null +++ b/src/background/pending-badge.ts @@ -0,0 +1,51 @@ +/** + * Toolbar badge with the number of open cards in the selected workspace. Cards stay stored on + * the Gateway while the panel is closed; the badge only hints that something waits. It never + * opens the panel (that needs a user gesture). + */ +import { browser } from 'wxt/browser'; +import { createClient } from '@/api/client-factory'; +import { isUiActionOpen } from '@/protocol/ui-action'; +import { activeProfile, loadSettings } from '@/shared/settings'; +import { authorizationFor, renewFor } from './token-manager'; + +export const BADGE_ALARM = 'pending-actions'; + +export async function setBadgeCount(count: number): Promise { + await browser.action.setBadgeText({ text: count > 0 ? String(Math.min(count, 99)) : '' }); + if (count > 0) await browser.action.setBadgeBackgroundColor({ color: '#1f2bff' }); +} + +export async function refreshPendingBadge(fetchImpl: typeof fetch = fetch): Promise { + const settings = await loadSettings(); + const profile = activeProfile(settings); + const workspaceId = profile ? settings.selections[profile.id]?.workspaceId : undefined; + if (!profile || !workspaceId) { + await setBadgeCount(0); + return 0; + } + const client = createClient( + profile, + { + authorization: () => authorizationFor(profile.id, fetchImpl), + renew: async () => { + try { + await renewFor(profile.id, fetchImpl); + return true; + } catch { + return false; + } + }, + }, + fetchImpl, + ); + try { + const actions = await client.actions(workspaceId, { status: 'pending' }); + const count = actions.filter((action) => isUiActionOpen(action)).length; + await setBadgeCount(count); + return count; + } catch { + await setBadgeCount(0); + return 0; + } +} diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts new file mode 100644 index 0000000..2978119 --- /dev/null +++ b/src/background/service-worker.ts @@ -0,0 +1,56 @@ +/** + * MV3 service worker. It may be suspended at any time, so it keeps no state that matters beyond + * a single message: credentials live in `chrome.storage`, runs and cards live on the server. + */ +import { browser } from 'wxt/browser'; +import { isApiError } from '@/api/errors'; +import { requestSchema, type BackgroundResponse } from '@/protocol/messages'; +import { BADGE_ALARM, refreshPendingBadge } from './pending-badge'; +import { authorizationFor, renewFor, signIn, signOut } from './token-manager'; + +async function handle(message: unknown, senderId: string | undefined): Promise { + // Only this extension's own pages may talk to the worker. + if (senderId !== browser.runtime.id) return { ok: false, code: 'forbidden' }; + const parsed = requestSchema.safeParse(message); + if (!parsed.success) return { ok: false, code: 'invalid_request' }; + const request = parsed.data; + try { + switch (request.type) { + case 'auth.authorization': + return { ok: true, authorization: await authorizationFor(request.profileId) }; + case 'auth.renew': + await renewFor(request.profileId); + return { ok: true }; + case 'auth.signIn': + await signIn(request.profileId); + void refreshPendingBadge(); + return { ok: true }; + case 'auth.signOut': + await signOut(request.profileId); + void refreshPendingBadge(); + return { ok: true }; + case 'badge.refresh': + await refreshPendingBadge(); + return { ok: true }; + } + } catch (error) { + return isApiError(error) ? { ok: false, code: error.code, message: error.message, status: error.status } : { ok: false, code: 'internal_error', status: 500 }; + } +} + +export function startServiceWorker(): void { + // Open the side panel when the toolbar icon is clicked (a user gesture); never inject into pages. + void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + void handle(message, sender.id).then(sendResponse); + return true; + }); + + void browser.alarms.create(BADGE_ALARM, { periodInMinutes: 1 }); + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === BADGE_ALARM) void refreshPendingBadge(); + }); + browser.runtime.onStartup.addListener(() => void refreshPendingBadge()); + browser.runtime.onInstalled.addListener(() => void refreshPendingBadge()); +} diff --git a/src/background/token-manager.ts b/src/background/token-manager.ts new file mode 100644 index 0000000..83b0840 --- /dev/null +++ b/src/background/token-manager.ts @@ -0,0 +1,84 @@ +/** + * Service-worker side of authentication. Cloud access tokens are renewed here only, one refresh + * per profile at a time: concurrent callers share the in-flight promise, so a rotated refresh + * token is never presented twice (which the server treats as replay and revokes the family). + */ +import { DeweeApiError, isApiError } from '@/api/errors'; +import { clearCredential, readCredential, writeCredential, type Credential } from '@/auth/credential-store'; +import { refreshCloudTokens, revokeCloudToken, signInWithCloud } from '@/auth/cloud-oauth'; +import { loadSettings, type Profile } from '@/shared/settings'; + +const RENEW_MARGIN_MS = 60_000; +const inflight = new Map>(); + +async function profileOf(profileId: string): Promise { + const profile = (await loadSettings()).profiles.find((p) => p.id === profileId); + if (!profile) throw new DeweeApiError(404, 'profile_not_found', 'Connection profile not found'); + return profile; +} + +async function credentialOf(profileId: string): Promise { + const credential = await readCredential(profileId); + if (!credential) throw new DeweeApiError(401, 'not_connected', 'Not signed in'); + return credential; +} + +/** + * `seenRefreshToken` is the token the caller read. If a refresh finished after that read, the + * stored token has already rotated and is returned as is: presenting the old one would be replay. + */ +function renew(profile: Profile, seenRefreshToken: string, fetchImpl: typeof fetch): Promise { + const running = inflight.get(profile.id); + if (running) return running; + const task = (async () => { + try { + const current = await credentialOf(profile.id); + if (current.kind !== 'oauth' || current.refreshToken !== seenRefreshToken) return current; + const next = await refreshCloudTokens(profile.origin, current.refreshToken, fetchImpl); + await writeCredential(profile.id, next, profile.remember); + return next as Credential; + } catch (error) { + // A rejected refresh (expired, revoked, replayed) ends the session; network errors do not. + if (isApiError(error) && (error.status === 400 || error.status === 401 || error.status === 403)) { + await clearCredential(profile.id); + throw new DeweeApiError(401, 'token_revoked', 'Signed out: please sign in again'); + } + throw error; + } finally { + inflight.delete(profile.id); + } + })(); + inflight.set(profile.id, task); + return task; +} + +/** `Authorization` header value for the profile, renewing a Cloud token close to expiry. */ +export async function authorizationFor(profileId: string, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise { + const credential = await credentialOf(profileId); + if (credential.kind === 'api_key') return `Bearer ${credential.apiKey}`; + if (credential.accessExpiresAt - RENEW_MARGIN_MS > now) return `Bearer ${credential.accessToken}`; + const next = await renew(await profileOf(profileId), credential.refreshToken, fetchImpl); + return `Bearer ${next.kind === 'oauth' ? next.accessToken : next.apiKey}`; +} + +/** Forced renewal after a 401. API keys cannot be renewed: the profile needs a new key. */ +export async function renewFor(profileId: string, fetchImpl: typeof fetch = fetch): Promise { + const credential = await credentialOf(profileId); + if (credential.kind === 'api_key') throw new DeweeApiError(401, 'api_key_rejected', 'The API key was rejected'); + await renew(await profileOf(profileId), credential.refreshToken, fetchImpl); +} + +export async function signIn(profileId: string, fetchImpl: typeof fetch = fetch): Promise { + const profile = await profileOf(profileId); + if (profile.kind !== 'cloud') throw new DeweeApiError(400, 'invalid_request', 'Only Cloud profiles sign in with OAuth'); + const credential = await signInWithCloud(profile.origin, fetchImpl); + await writeCredential(profile.id, credential, profile.remember); +} + +/** Revokes the Cloud token family (best effort) and always clears local credentials. */ +export async function signOut(profileId: string, fetchImpl: typeof fetch = fetch): Promise { + const credential = await readCredential(profileId); + const profile = (await loadSettings()).profiles.find((p) => p.id === profileId); + if (credential?.kind === 'oauth' && profile) await revokeCloudToken(profile.origin, credential.refreshToken, fetchImpl); + await clearCredential(profileId); +} diff --git a/src/chat/chat-state.ts b/src/chat/chat-state.ts new file mode 100644 index 0000000..3d4651a --- /dev/null +++ b/src/chat/chat-state.ts @@ -0,0 +1,305 @@ +/** + * Pure chat state for one conversation (profile + workspace + agent + session). The reducer folds + * history pages and stream events into messages and cards; it never holds secrets (cards carry + * metadata only, and form values stay inside the credential form component). + */ +import type { Continuation, HistoryMessage } from '@/api/schemas'; +import type { ChatEvent, RecoveryAction, RecoveryCode } from '@/protocol/chat-events'; +import type { UiActionCard } from '@/protocol/ui-action'; + +export interface ToolActivity { + id: string; + name: string; + state: 'running' | 'done' | 'error'; + viaAgent?: string; +} + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'tool' | 'system'; + content: string; + createdAt?: string; + /** Assistant message still receiving deltas. */ + streaming?: boolean; + thinking?: string; + tools?: ToolActivity[]; + cardIds?: string[]; + error?: { code: RecoveryCode | string; action?: RecoveryAction; retryAfterSeconds?: number; message?: string }; + stopped?: boolean; + /** User text that can be re-sent from the error state. */ + retryText?: string; + /** A card continuation not proven delivered; the Gateway sends each at most once, so "Continue" is safe. */ + retryContinuation?: Continuation; + /** + * Offered instead of "Send again" when delivery could not be proven either way: reload the + * conversation (a known session) or refresh the conversation list (a new chat). + */ + recover?: 'reload' | 'sessions'; + /** A local turn the server history may not hold: kept across history reloads until settled. */ + local?: boolean; +} + +export type ChatPhase = 'idle' | 'loading' | 'streaming' | 'reconciling'; + +export interface ChatState { + sessionKey?: string; + runId?: string; + phase: ChatPhase; + messages: ChatMessage[]; + cards: UiActionCard[]; + /** Messages at the start of `messages` not shown yet; "show earlier" reveals them a page at a time. */ + hiddenBefore: number; + loadError?: string; + /** Reconciling is paused until the browser is back online. */ + offline?: boolean; +} + +export const initialChatState = (sessionKey?: string): ChatState => ({ + sessionKey, + phase: sessionKey ? 'loading' : 'idle', + messages: [], + cards: [], + hiddenBefore: 0, +}); + +/** Messages shown at first and revealed per "show earlier" step. */ +export const VISIBLE_PAGE = 50; + +export type ChatAction = + | { type: 'reset'; sessionKey?: string } + | { type: 'history'; messages: HistoryMessage[]; sessionKey?: string } + | { type: 'older' } + | { type: 'loadFailed'; message: string } + | { type: 'send'; text?: string; localId: string } + | { type: 'event'; event: ChatEvent } + | { + type: 'streamFailed'; + code: string; + message: string; + reconcile: boolean; + retryText?: string; + retryContinuation?: Continuation; + recover?: 'reload' | 'sessions'; + retryAfterSeconds?: number; + /** Keep the turn across history reloads until a reconcile settles it. */ + local?: boolean; + } + | { type: 'reconciling'; offline?: boolean } + /** + * After a reconcile, for a turn the history does not prove delivered: a continuation gets + * "Continue", a user message gets `delivery_unconfirmed` with a reload action, never a retry. + */ + | { type: 'restoreTurn'; localId: string; text?: string; continuation?: Continuation; code: string; message: string } + /** + * A continuation whose run ended without delivering it (`chat.error`, `chat.stopped`): its reply + * keeps the error the stream reported (or gets `code`) and offers "Continue". + */ + | { type: 'offerContinue'; localId: string; continuation: Continuation; code: string; message: string } + /** Removes a local turn: the server history holds it, or it is being sent again. */ + | { type: 'dropTurn'; localId: string } + /** Removes the "Continue" turns of a card continuation that is no longer held here (delivered, or sent automatically again). */ + | { type: 'dropContinue'; setupActionId: string } + | { type: 'idle' } + | { type: 'cards'; cards: UiActionCard[] } + | { type: 'card'; card: UiActionCard }; + +let seq = 0; +export const localId = (prefix = 'local') => `${prefix}-${Date.now().toString(36)}-${(seq++).toString(36)}`; + +/** + * Server-written (`injected`) messages never render as model text: an injected assistant turn is + * the marker of a run stopped before replying, shown like a live Stop (an empty "Stopped" reply); + * any other injected message is dropped. + */ +function fromHistory(messages: HistoryMessage[]): ChatMessage[] { + return messages.flatMap((m, index): ChatMessage[] => { + if (m.role !== 'user' && m.role !== 'assistant') return []; + if (m.injected && m.role !== 'assistant') return []; + const message: ChatMessage = { id: m.id ?? `h-${index}-${m.created_at ?? ''}`, role: m.role, content: m.content, createdAt: m.created_at }; + return [m.injected ? { ...message, content: '', stopped: true } : message]; + }); +} + +function upsertCard(cards: UiActionCard[], card: UiActionCard): UiActionCard[] { + const index = cards.findIndex((c) => c.id === card.id); + if (index < 0) return [...cards, card]; + const next = cards.slice(); + next[index] = card; + return next; +} + +/** Applies a change to the assistant message currently streaming (the last one). */ +function patchStreaming(state: ChatState, patch: (message: ChatMessage) => ChatMessage): ChatState { + const index = state.messages.findLastIndex((m) => m.role === 'assistant' && m.streaming); + if (index < 0) return state; + const messages = state.messages.slice(); + messages[index] = patch(messages[index]!); + return { ...state, messages }; +} + +function upsertTool(tools: ToolActivity[] | undefined, tool: ToolActivity): ToolActivity[] { + const list = tools ?? []; + const index = list.findIndex((t) => t.id === tool.id); + if (index < 0) return [...list, tool]; + const next = list.slice(); + next[index] = { ...next[index]!, ...tool }; + return next; +} + +function applyEvent(state: ChatState, event: ChatEvent): ChatState { + switch (event.type) { + case 'chat.started': + return { ...state, sessionKey: event.sessionKey ?? state.sessionKey, runId: event.runId }; + case 'message.delta': + return patchStreaming(state, (m) => ({ ...m, content: m.content + event.delta })); + case 'thinking.delta': + return patchStreaming(state, (m) => ({ ...m, thinking: (m.thinking ?? '') + event.delta })); + case 'tool.call.started': + return patchStreaming(state, (m) => ({ + ...m, + tools: upsertTool(m.tools, { id: event.toolCall.id ?? localId('tool'), name: event.toolCall.name ?? 'tool', state: 'running', viaAgent: event.toolCall.viaAgent }), + })); + case 'tool.call.completed': + case 'tool.call.error': { + const call = event.toolCall; + const card = call.uiAction; + const next = patchStreaming(state, (m) => ({ + ...m, + tools: upsertTool(m.tools, { + id: call.id ?? localId('tool'), + name: call.name ?? 'tool', + state: event.type === 'tool.call.error' ? 'error' : 'done', + viaAgent: call.viaAgent, + }), + cardIds: card && !(m.cardIds ?? []).includes(card.id) ? [...(m.cardIds ?? []), card.id] : m.cardIds, + })); + return card ? { ...next, cards: upsertCard(next.cards, card) } : next; + } + case 'reasoning.activity': + return state; + case 'message.completed': { + const next = patchStreaming(state, (m) => ({ + ...m, + content: event.content || m.content, + createdAt: event.createdAt ?? m.createdAt, + streaming: false, + })); + return { ...next, phase: 'idle', runId: undefined }; + } + case 'chat.error': { + const next = patchStreaming(state, (m) => ({ + ...m, + streaming: false, + error: { code: event.code, action: event.action, retryAfterSeconds: event.retryAfterSeconds }, + })); + return { ...next, phase: 'idle', runId: undefined }; + } + case 'chat.stopped': { + const next = patchStreaming(state, (m) => ({ ...m, streaming: false, stopped: true })); + return { ...next, phase: 'idle', runId: undefined }; + } + } +} + +export function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case 'reset': + return initialChatState(action.sessionKey); + case 'history': { + // Local turns not proven delivered stay after the server's messages until settled. + const messages = [...fromHistory(action.messages), ...state.messages.filter((m) => m.local)]; + return { + ...state, + sessionKey: action.sessionKey ?? state.sessionKey, + messages, + hiddenBefore: Math.max(0, messages.length - VISIBLE_PAGE), + phase: state.phase === 'loading' ? 'idle' : state.phase, + loadError: undefined, + }; + } + case 'older': + return { ...state, hiddenBefore: Math.max(0, state.hiddenBefore - VISIBLE_PAGE) }; + case 'loadFailed': + return { ...state, phase: 'idle', loadError: action.message }; + case 'send': { + // A new send supersedes an earlier "Send again"; reload and "Continue" actions stay. + const messages = state.messages.map((m) => (m.retryText ? { ...m, retryText: undefined } : m)); + if (action.text !== undefined) messages.push({ id: action.localId, role: 'user', content: action.text }); + messages.push({ id: `${action.localId}-reply`, role: 'assistant', content: '', streaming: true }); + return { ...state, messages, phase: 'streaming', loadError: undefined }; + } + case 'event': + return applyEvent(state, action.event); + case 'streamFailed': { + const index = state.messages.findLastIndex((m) => m.role === 'assistant' && m.streaming); + const phase: ChatPhase = action.reconcile ? 'reconciling' : 'idle'; + if (index < 0) return { ...state, phase }; + const turnId = state.messages[index]!.id.replace(/-reply$/, ''); + const fix: Partial = { + streaming: false, + error: { code: action.code, message: action.message, retryAfterSeconds: action.retryAfterSeconds }, + retryText: action.reconcile ? undefined : action.retryText, + retryContinuation: action.reconcile ? undefined : action.retryContinuation, + recover: action.reconcile ? undefined : action.recover, + local: action.local || undefined, + }; + const messages = state.messages.map((m, i) => (i === index ? { ...m, ...fix } : action.local && m.id === turnId ? { ...m, local: true } : m)); + return { ...state, messages, phase }; + } + case 'reconciling': + return { ...state, phase: 'reconciling', offline: action.offline || undefined }; + case 'restoreTurn': { + // Keeps the turn shown once (in place, or appended if a reload dropped it): a continuation + // with "Continue", a user message as `delivery_unconfirmed` with a reload action. + const replyId = `${action.localId}-reply`; + const fix: Partial = { + streaming: false, + local: true, + error: { code: action.code, message: action.message }, + retryText: undefined, + retryContinuation: action.continuation, + recover: action.continuation ? undefined : 'reload', + }; + if (state.messages.some((m) => m.id === replyId)) { + return { ...state, messages: state.messages.map((m) => (m.id === replyId ? { ...m, ...fix } : m.id === action.localId ? { ...m, local: true } : m)) }; + } + const turn: ChatMessage[] = action.text !== undefined ? [{ id: action.localId, role: 'user', content: action.text, local: true }] : []; + return { ...state, messages: [...state.messages, ...turn, { id: replyId, role: 'assistant', content: '', ...fix }] }; + } + case 'offerContinue': { + const replyId = `${action.localId}-reply`; + const messages = state.messages.map((m) => + m.id === replyId ? { ...m, streaming: false, local: true, error: m.error ?? { code: action.code, message: action.message }, retryContinuation: action.continuation } : m, + ); + return { ...state, messages }; + } + case 'dropTurn': { + const replyId = `${action.localId}-reply`; + return { ...state, messages: state.messages.filter((m) => m.id !== action.localId && m.id !== replyId) }; + } + case 'dropContinue': { + const turns = new Set(state.messages.filter((m) => m.retryContinuation?.setup_action_id === action.setupActionId).map((m) => m.id.replace(/-reply$/, ''))); + if (!turns.size) return state; + return { ...state, messages: state.messages.filter((m) => !turns.has(m.id.replace(/-reply$/, ''))) }; + } + case 'idle': + // Nothing is running any more, so no reply can still be typing. + return { + ...state, + phase: 'idle', + offline: undefined, + runId: undefined, + messages: state.messages.some((m) => m.streaming) ? state.messages.map((m) => (m.streaming ? { ...m, streaming: false } : m)) : state.messages, + }; + case 'cards': + return { ...state, cards: action.cards.reduce(upsertCard, state.cards) }; + case 'card': + return { ...state, cards: upsertCard(state.cards, action.card) }; + } +} + +/** Cards not attached to any visible message (e.g. reloaded after the panel reopened). */ +export function looseCards(state: ChatState): UiActionCard[] { + const attached = new Set(state.messages.flatMap((m) => m.cardIds ?? [])); + return state.cards.filter((c) => !attached.has(c.id)); +} diff --git a/src/chat/history.ts b/src/chat/history.ts new file mode 100644 index 0000000..2016b05 --- /dev/null +++ b/src/chat/history.ts @@ -0,0 +1,48 @@ +/** + * Session history is a follow log: `cursor` is a message position (oldest first) and `next_cursor` + * is the position after the last returned message. A cursor past the end answers `reset: true` + * with `next_cursor` = the log length, so the panel asks for the length first and then follows + * only the newest messages to the end. "Show earlier" reveals them a page at a time without + * another request. + */ +import type { DeweeClient } from '@/api/dewee-client'; +import { DeweeApiError } from '@/api/errors'; +import type { HistoryMessage } from '@/api/schemas'; + +const PAGE_LIMIT = 200; +/** Newest messages kept in memory for one session. */ +export const MAX_KEPT_MESSAGES = 1_000; +/** Bounds the follow when messages keep arriving while it reads. */ +const MAX_PAGES = MAX_KEPT_MESSAGES / PAGE_LIMIT + 2; +/** Larger than any real log and within the 9-digit cursor both backends accept. */ +const PROBE_CURSOR = '999999999'; + +async function logLength(client: DeweeClient, workspaceId: string, sessionKey: string): Promise { + const probe = await client.history(workspaceId, { session_key: sessionKey, cursor: PROBE_CURSOR, limit: 1 }); + const total = Number(probe.next_cursor); + // A server that does not report the length is read from the start. + return probe.reset && Number.isInteger(total) && total >= 0 ? total : 0; +} + +export async function fetchSessionHistory(client: DeweeClient, workspaceId: string, sessionKey: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + let cursor = Math.max(0, (await logLength(client, workspaceId, sessionKey)) - MAX_KEPT_MESSAGES); + const messages: HistoryMessage[] = []; + let shrank = false; + for (let page = 0; page < MAX_PAGES; page++) { + const result = await client.history(workspaceId, { session_key: sessionKey, cursor: String(cursor), limit: PAGE_LIMIT }); + if (result.reset) { + // The log shrank under us (compaction); measure it again. + shrank = true; + break; + } + messages.push(...result.messages); + const next = Number(result.next_cursor); + if (!Number.isInteger(next) || next - cursor < PAGE_LIMIT) break; + cursor = next; + } + if (!shrank) return messages.slice(-MAX_KEPT_MESSAGES); + } + // Never an empty history: callers would take an unread log for an empty one. + throw new DeweeApiError(0, 'history_changed', 'The history kept changing while it was read'); +} diff --git a/src/chat/pending-continuations.ts b/src/chat/pending-continuations.ts new file mode 100644 index 0000000..cff94de --- /dev/null +++ b/src/chat/pending-continuations.ts @@ -0,0 +1,67 @@ +/** + * Card continuations not yet delivered, kept in `storage.session` so a session or agent switch, or + * a closed panel, never loses one. Each item holds IDs only (profile, workspace, session, agent, + * setup action), a `held` flag and the time its in-progress retries started, never a secret. Every + * item has its own storage key, so adding or removing one is a single write that never touches + * another item: panels in two windows cannot drop each other's items. The Gateway delivers a continuation at most once, so sending a stored one again is safe. + */ +import { browser } from 'wxt/browser'; +import { z } from 'zod'; +import { continuation } from '@/api/schemas'; + +const KEY_PREFIX = 'chat.pendingContinuation:'; + +/** + * `held`: not proven delivered; it waits for "Continue" instead of being sent automatically. + * `in_progress_since`: when (ms since epoch) the first 409 `continuation_in_progress` arrived, so a + * reopened panel keeps the same retry window instead of starting a new one. + */ +const pendingContinuation = continuation.extend({ + profile_id: z.string().min(1), + workspace_id: z.string().min(1), + held: z.boolean().optional(), + in_progress_since: z.number().int().nonnegative().optional(), +}); +export type PendingContinuation = z.infer; + +/** The item's identity and storage key: profile, workspace and setup action. */ +export const pendingKey = (item: Pick) => + KEY_PREFIX + [item.profile_id, item.workspace_id, item.setup_action_id].map(encodeURIComponent).join(':'); + +const parse = (value: unknown): PendingContinuation | null => { + const parsed = pendingContinuation.safeParse(value); + return parsed.success ? parsed.data : null; +}; + +export async function loadPendingContinuations(): Promise { + const all = await browser.storage.session.get(null); + return Object.entries(all).flatMap(([key, value]) => { + const item = key.startsWith(KEY_PREFIX) ? parse(value) : null; + return item && pendingKey(item) === key ? [item] : []; + }); +} + +export async function addPendingContinuation(item: PendingContinuation): Promise { + await browser.storage.session.set({ [pendingKey(item)]: item }); +} + +export async function removePendingContinuation(item: PendingContinuation): Promise { + await browser.storage.session.remove(pendingKey(item)); +} + +/** + * Reports items another panel (or this one) added or removed. Returns the unsubscribe function. + */ +export function watchPendingContinuations(handlers: { added(item: PendingContinuation): void; removed(key: string): void }): () => void { + const listener = (changes: Record, area: string) => { + if (area !== 'session') return; + for (const [key, change] of Object.entries(changes)) { + if (!key.startsWith(KEY_PREFIX)) continue; + const item = change.newValue == null ? null : parse(change.newValue); + if (item && pendingKey(item) === key) handlers.added(item); + else if (change.newValue == null) handlers.removed(key); + } + }; + browser.storage.onChanged.addListener(listener); + return () => browser.storage.onChanged.removeListener(listener); +} diff --git a/src/chat/use-chat.ts b/src/chat/use-chat.ts new file mode 100644 index 0000000..ae38344 --- /dev/null +++ b/src/chat/use-chat.ts @@ -0,0 +1,782 @@ +/** + * Chat engine for one conversation. Rules that keep runs from duplicating: + * - A user message is sent once and never re-sent automatically; retry is a user action. + * - "Send again" is offered only when the server provably did not receive the message: the panel + * failed before calling fetch, the fetch was rejected while the browser was offline, or the + * server refused it with a 4xx. Every other failure is uncertain: the turn stays shown once as + * `delivery_unconfirmed` with a reload action (or "Show conversations" in a new chat), and is + * dropped only when the server history provably holds it. + * - One reconcile loop runs per conversation; only its owner writes state, and only it sets idle. + * - Reopening the panel never re-sends a message: it loads history, run status and open cards. + * - Card continuations are queued per profile, workspace and setup action, persisted in + * `storage.session` (IDs only) and sent automatically, one at a time, while their own session is + * shown and idle. Unlike a user message, a continuation may be auto-sent and re-sent: the Gateway + * delivers each at most once. An item leaves the queue only when the agent provably got it + * (`message.completed`, 409 `continuation_sent`, `chat.error` `setup_result_delivered`) or when + * it can never succeed (400, 404, 410, and a 403 other than a key problem the person can fix). + * 409 `continuation_in_progress` (another request holds the send lease) is retried with backoff + * for about 11 minutes from the first such answer (stored with the item). Any other failure (a + * `chat.error`, an early close, a drop, Stop) holds the item: it is stored as held and waits for + * "Continue", because the Gateway releases the lease when a run fails before the result is saved. + * A conversation switch or unmount is not a failure: the item stays unheld and is sent again. + */ +import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react'; +import type { DeweeClient } from '@/api/dewee-client'; +import { DeweeApiError, isApiError } from '@/api/errors'; +import type { Continuation, HistoryMessage } from '@/api/schemas'; +import type { UiActionCard } from '@/protocol/ui-action'; +import { chatReducer, initialChatState, localId, type ChatMessage, type ChatState } from './chat-state'; +import { fetchSessionHistory } from './history'; +import { + addPendingContinuation, + loadPendingContinuations, + pendingKey, + removePendingContinuation, + watchPendingContinuations, + type PendingContinuation, +} from './pending-continuations'; + +export interface ChatContext { + client: DeweeClient; + profileId: string; + workspaceId: string; + agentKey: string; + sessionKey?: string; +} + +export interface ChatControls { + state: ChatState; + /** Starts sending `text`. False when it was refused locally (another stream started), so nothing left. */ + send(text: string): boolean; + /** + * Queues a card continuation; it is sent once its session is shown and idle. `scope` is the + * profile and workspace the card was answered in (default: the current ones). + */ + continueWith(continuation: Continuation, scope?: ContinuationScope): Promise; + stop(): Promise; + loadOlder(): Promise; + /** Reloads the conversation; re-checks turns whose delivery could not be confirmed. */ + reload(): Promise; + /** Sends again what a failed message offers: its text ("Send again") or its continuation ("Continue"). */ + retry(message: ChatMessage): Promise; + updateCard(card: UiActionCard): void; +} + +const RECONCILE_INTERVAL_MS = 2_000; +/** Polls at the fast interval; a run that is still going after them is polled at the slow one. */ +const RECONCILE_FAST_POLLS = 90; +const RECONCILE_SLOW_INTERVAL_MS = 15_000; +/** Consecutive failed status polls (while online) before the loop gives up and reads history. */ +const RECONCILE_MAX_FAILURES = 30; +const STOP_GRACE_MS = 5_000; +/** Waits before re-sending a continuation answered 409 `continuation_in_progress`; the last repeats. */ +export const IN_PROGRESS_RETRY_MS = [5_000, 15_000, 30_000, 60_000]; +/** How long after the first in-progress answer the panel keeps retrying; a lease goes stale after 10 min. */ +export const IN_PROGRESS_LIMIT_MS = 11 * 60_000; + +export interface ContinuationScope { + profileId: string; + workspaceId: string; +} + +/** Automatic retries of one continuation that another request is sending. */ +interface InProgressWait { + since: number; + attempts: number; + at: number; +} + +/** Refusals of a continuation that can never succeed: the card or its continuation is invalid, gone or not allowed. */ +const DEAD_CONTINUATION_STATUSES = new Set([400, 403, 404, 410]); +/** 403 codes the person can fix (by fixing their key), so the item is kept for "Continue" instead. */ +const FIXABLE_REFUSAL_CODES = new Set(['scope_required', 'owner_binding_required']); + +const isOffline = () => typeof navigator !== 'undefined' && navigator.onLine === false; + +/** Status-0 codes of a request that may have reached the server. Other status-0 codes come from the panel. */ +const TRANSPORT_CODES = new Set(['network_error', 'timeout', 'stream_stalled', 'connection_lost', 'invalid_response', 'runtime_request_failed']); + +/** + * True only when the server provably did not receive the request: the panel failed before the + * fetch, the fetch was rejected while the browser reported offline, or the server refused it with + * a 4xx. A 5xx, a stall, a drop after the response or a fetch rejected while online is uncertain. + */ +function notReceived(error: unknown, responded: boolean): boolean { + if (responded) return false; + // The client wraps every fault after the response, so a raw error means fetch was never called. + if (!isApiError(error)) return true; + if (error.afterResponse) return false; + if (error.status >= 400 && error.status < 500) return error.code !== 'continuation_sent'; + if (error.status !== 0) return false; + if (error.code === 'network_error') return isOffline(); + return !TRANSPORT_CODES.has(error.code); +} + +/** Resolves when the browser is back online, or once `live()` turns false (conversation switch, unmount). */ +function waitForOnline(live: () => boolean): Promise { + return new Promise((resolve) => { + const done = () => { + window.removeEventListener('online', done); + clearInterval(check); + resolve(); + }; + // A local check only, never a request. It also catches a missed `online` event. + const check = setInterval(() => { + if (!live() || !isOffline()) done(); + }, RECONCILE_INTERVAL_MS); + window.addEventListener('online', done); + }); +} + +/** + * The server history when a turn was sent: the last message (ids are `m`) as an anchor, + * plus the user turns the server confirmed since (`chat.started`). Null when unknown. + */ +interface Baseline { + position: number; + anchor?: { role: string; content: string }; + confirmed: number; +} + +/** A turn sent without proof that the server got it, kept until a reconcile settles it. */ +interface Unconfirmed { + localId: string; + text?: string; + continuation?: Continuation; + code: string; + message: string; + baseline: Baseline | null; +} + +const positionOf = (message: HistoryMessage): number | null => { + const match = /^m(\d+)$/.exec(message.id ?? ''); + return match ? Number(match[1]) : null; +}; + +function baselineOf(history: HistoryMessage[]): Baseline | null { + let position = -1; + let anchor: Baseline['anchor']; + for (const message of history) { + const at = positionOf(message); + if (at === null) return null; + if (at > position) { + position = at; + anchor = { role: message.role, content: message.content }; + } + } + return { position, anchor, confirmed: 0 }; +} + +const sameText = (a: string, b: string) => a.trim().replace(/\s+/g, ' ') === b.trim().replace(/\s+/g, ' '); + +/** + * True only when the history provably holds `text` as a user turn sent after the baseline: the + * anchor is still at its position and a matching user turn follows it. After compaction or + * renumbered ids the anchor is gone, so the turn stays unconfirmed rather than guessed. + */ +function holdsTurn(history: HistoryMessage[], text: string, baseline: Baseline | null): boolean { + if (!baseline) return false; + if (baseline.anchor) { + const { role, content } = baseline.anchor; + const anchor = history.find((m) => positionOf(m) === baseline.position); + if (!anchor || anchor.role !== role || !sameText(anchor.content, content)) return false; + } + // Server-written (injected) turns are not the person's messages, so they never count. + const after = history.filter((m) => m.role === 'user' && !m.injected && (positionOf(m) ?? -1) > baseline.position).slice(baseline.confirmed); + return after.some((m) => sameText(m.content, text)); +} + +const turnIdOf = (message: ChatMessage) => message.id.replace(/-reply$/, ''); + +export function useChat(ctx: ChatContext | null, hooks: { onSessionKey(key: string): void; onAuthError(error: DeweeApiError): void }): ChatControls { + const [state, dispatch] = useReducer(chatReducer, ctx?.sessionKey, initialChatState); + const stateRef = useRef(state); + const ctxRef = useRef(ctx); + const hooksRef = useRef(hooks); + // Layout effects run before the effects below, so callbacks always read the committed values. + useLayoutEffect(() => { + stateRef.current = state; + ctxRef.current = ctx; + hooksRef.current = hooks; + }); + const streamRef = useRef(null); + /** Bumped when a stream fully ends, so a queued continuation can start after it. */ + const [streamsEnded, setStreamsEnded] = useState(0); + /** Lets `loadSession` start a reconcile without the two callbacks depending on each other. */ + const reconcileRef = useRef<(sessionKey: string, unconfirmed?: Unconfirmed) => Promise>(async () => {}); + /** Bumped on every context switch so late results of an old conversation are dropped. */ + const epochRef = useRef(0); + /** Ends reconcile loops and offline waits once the panel unmounts. */ + const mountedRef = useRef(true); + /** The running reconcile loop's identity; null when none runs. A new owner retires the old one. */ + const ownerRef = useRef(null); + /** The server history as last seen, for deciding later whether a turn arrived. */ + const baselineRef = useRef(null); + /** Turns left as `delivery_unconfirmed`, by local id; every reconcile and reload checks them again. */ + const uncheckedRef = useRef(new Map()); + /** Continuations not yet delivered, for every session; each is also its own `storage.session` item. */ + const queueRef = useRef([]); + /** + * Queued continuations (by `pendingKey`) that wait for "Continue" instead of being sent again. Kept + * across conversation switches and stored with the item, so a reopened panel holds it too. + */ + const heldRef = useRef(new Set()); + /** Pending sleeps of reconcile loops and the Stop grace timer; unmount clears them. */ + const timersRef = useRef(new Set<() => void>()); + /** Continuations (by `pendingKey`) that were delivered, so a stale stored copy is not queued again. */ + const doneRef = useRef(new Set()); + /** Continuations (by `pendingKey`) waiting to be re-sent after 409 `continuation_in_progress`. */ + const waitRef = useRef(new Map()); + const [queueTick, setQueueTick] = useState(0); + + const isCurrent = (epoch: number) => epoch === epochRef.current; + + /** + * Adds stored items (loaded, or another panel's) to the in-memory queue, or refreshes the queued + * copy. The stored `held` flag wins: a held copy holds the item here too, and an unheld copy + * (another panel pressed "Continue") releases it, so its stale "Continue" here goes away. + */ + const enqueue = useCallback((items: PendingContinuation[]) => { + let changed = false; + for (const item of items) { + const key = pendingKey(item); + if (doneRef.current.has(key)) continue; + let released = false; + if (item.held) heldRef.current.add(key); + else if (heldRef.current.delete(key)) { + released = true; + dispatch({ type: 'dropContinue', setupActionId: item.setup_action_id }); + } + const queued = queueRef.current.some((q) => pendingKey(q) === key); + queueRef.current = queued ? queueRef.current.map((q) => (pendingKey(q) === key ? item : q)) : [...queueRef.current, item]; + if (!queued || released) changed = true; + } + if (changed) setQueueTick((n) => n + 1); + }, []); + + /** Forgets a delivered item here, with any "Continue" it shows; another panel's removal arrives the same way. */ + const forget = useCallback((key: string) => { + const item = queueRef.current.find((q) => pendingKey(q) === key); + doneRef.current.add(key); + heldRef.current.delete(key); + waitRef.current.delete(key); + queueRef.current = queueRef.current.filter((q) => pendingKey(q) !== key); + if (item) dispatch({ type: 'dropContinue', setupActionId: item.setup_action_id }); + }, []); + + /** The item was delivered: it leaves the queue and storage (only this item). */ + const dequeue = useCallback( + (item: PendingContinuation) => { + forget(pendingKey(item)); + void removePendingContinuation(item).catch(() => {}); + }, + [forget], + ); + + /** + * The item was not proven delivered: it stays queued and stored, marked held, so it waits for + * "Continue" here, after a conversation switch and in a reopened panel instead of being re-sent. + */ + const hold = useCallback((item: PendingContinuation) => { + const key = pendingKey(item); + waitRef.current.delete(key); + if (doneRef.current.has(key) || heldRef.current.has(key)) return; + heldRef.current.add(key); + void addPendingContinuation({ ...item, held: true }).catch(() => {}); + }, []); + + /** A timer that unmount clears; resolves after `ms`, or at once when the panel unmounts. */ + const sleep = useCallback( + (ms: number) => + new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + timersRef.current.delete(done); + resolve(); + }; + const timer = setTimeout(done, ms); + timersRef.current.add(done); + }), + [], + ); + + /** + * Schedules the next automatic re-send after 409 `continuation_in_progress`. The first wait is the + * server's `Retry-After` hint when present, then the backoff. False once the retry window (from + * the first in-progress answer, stored with the item so a reopened panel keeps it) would be over: + * the item then waits for "Continue". + */ + const scheduleInProgressRetry = useCallback((item: PendingContinuation, retryAfterSeconds?: number): boolean => { + const key = pendingKey(item); + const now = Date.now(); + const queued = queueRef.current.find((q) => pendingKey(q) === key) ?? item; + const wait = waitRef.current.get(key) ?? { since: queued.in_progress_since ?? now, attempts: 0, at: now }; + if (queued.in_progress_since === undefined && !doneRef.current.has(key)) { + const stamped = { ...queued, in_progress_since: wait.since }; + queueRef.current = queueRef.current.map((q) => (pendingKey(q) === key ? stamped : q)); + void addPendingContinuation(stamped).catch(() => {}); + } + const backoff = IN_PROGRESS_RETRY_MS[Math.min(wait.attempts, IN_PROGRESS_RETRY_MS.length - 1)]!; + const delay = wait.attempts === 0 && retryAfterSeconds ? retryAfterSeconds * 1_000 : backoff; + if (now + delay - wait.since > IN_PROGRESS_LIMIT_MS) { + waitRef.current.delete(key); + return false; + } + waitRef.current.set(key, { since: wait.since, attempts: wait.attempts + 1, at: now + delay }); + return true; + }, []); + + const handleError = useCallback((error: unknown): string => { + if (isApiError(error)) { + if (error.status === 401) hooksRef.current.onAuthError(error); + return error.code; + } + return 'runtime_request_failed'; + }, []); + + /** + * Loads history, run status and open cards while `live()` holds. Resolves to the history, or null + * when it failed. With `followRun`, a running run starts a reconcile unless one already runs. + */ + const loadSession = useCallback( + async (live: () => boolean, sessionKey: string, followRun = true): Promise => { + const current = ctxRef.current; + if (!current) return null; + try { + const [history, status, cards] = await Promise.all([ + fetchSessionHistory(current.client, current.workspaceId, sessionKey), + current.client.status(current.workspaceId, sessionKey).catch(() => ({ running: false, run_id: undefined })), + current.client.actions(current.workspaceId, { sessionKey, status: 'pending' }).catch(() => []), + ]); + if (!live()) return null; + dispatch({ type: 'history', messages: history, sessionKey }); + dispatch({ type: 'cards', cards }); + baselineRef.current = baselineOf(history); + if (followRun && status.running && ownerRef.current === null) void reconcileRef.current(sessionKey); + return history; + } catch (error) { + if (!live()) return null; + dispatch({ type: 'loadFailed', message: handleError(error) }); + return null; + } + }, + [handleError], + ); + + /** + * Waits for a server-side run to finish, then reloads history. Never re-sends anything. It owns + * the conversation until it ends: a newer loop, a context switch or unmount retires it, and a + * retired loop writes nothing. Then it settles the unconfirmed turns: a turn the history provably + * holds is dropped (the server copy shows), any other stays once as `delivery_unconfirmed`, and + * a continuation that was not proven delivered gets "Continue". + */ + const reconcile = useCallback( + async (sessionKey: string, unconfirmed?: Unconfirmed) => { + const current = ctxRef.current; + if (!current) return; + const epoch = epochRef.current; + const owner = {}; + ownerRef.current = owner; + const owns = () => ownerRef.current === owner && epoch === epochRef.current && mountedRef.current; + if (unconfirmed?.text !== undefined) uncheckedRef.current.set(unconfirmed.localId, unconfirmed); + dispatch({ type: 'reconciling' }); + try { + let failures = 0; + for (let poll = 0; owns(); ) { + if (isOffline()) { + // Polling cannot succeed offline: pause until the connection is back instead of spinning. + dispatch({ type: 'reconciling', offline: true }); + await waitForOnline(owns); + if (!owns()) return; + dispatch({ type: 'reconciling' }); + continue; + } + poll++; + try { + const status = await current.client.status(current.workspaceId, sessionKey); + if (!status.running) break; + failures = 0; + } catch (error) { + if (isApiError(error) && error.status !== 0) break; + if (isOffline()) continue; + if (++failures >= RECONCILE_MAX_FAILURES) break; + } + // A run still going after the fast polls keeps the conversation busy, polled more slowly. + await sleep(poll < RECONCILE_FAST_POLLS ? RECONCILE_INTERVAL_MS : RECONCILE_SLOW_INTERVAL_MS); + } + if (!owns()) return; + const history = await loadSession(owns, sessionKey, false); + if (!owns()) return; + if (unconfirmed?.continuation) { + const { localId: id, continuation, code, message } = unconfirmed; + dispatch({ type: 'restoreTurn', localId: id, continuation, code, message }); + } + for (const turn of uncheckedRef.current.values()) { + if (history && holdsTurn(history, turn.text!, turn.baseline)) { + uncheckedRef.current.delete(turn.localId); + dispatch({ type: 'dropTurn', localId: turn.localId }); + } else { + dispatch({ type: 'restoreTurn', localId: turn.localId, text: turn.text, code: 'delivery_unconfirmed', message: turn.message }); + } + } + dispatch({ type: 'idle' }); + } finally { + if (ownerRef.current === owner) ownerRef.current = null; + } + }, + [loadSession, sleep], + ); + useLayoutEffect(() => { + reconcileRef.current = reconcile; + }, [reconcile]); + + const scopeRef = useRef<{ client?: DeweeClient; workspaceId?: string; agentKey?: string } | null>(null); + useEffect(() => { + const scope = scopeRef.current; + const sameScope = scope !== null && scope.client === ctx?.client && scope.workspaceId === ctx?.workspaceId && scope.agentKey === ctx?.agentKey; + scopeRef.current = { client: ctx?.client, workspaceId: ctx?.workspaceId, agentKey: ctx?.agentKey }; + // A session key adopted from `chat.started` comes back through the props; the conversation + // on screen already is that session, so it must not be reset. + if (sameScope && ctx?.sessionKey !== undefined && ctx.sessionKey === stateRef.current.sessionKey) return; + epochRef.current += 1; + const epoch = epochRef.current; + streamRef.current?.abort(); + streamRef.current = null; + ownerRef.current = null; + baselineRef.current = null; + uncheckedRef.current = new Map(); + // Continuations of other sessions stay queued; reopening their session sends them (held ones + // stay held and show "Continue" there). + stateRef.current = initialChatState(ctx?.sessionKey); + dispatch({ type: 'reset', sessionKey: ctx?.sessionKey }); + if (ctx?.sessionKey) void loadSession(() => isCurrent(epoch) && mountedRef.current, ctx.sessionKey); + void loadPendingContinuations().then((items) => enqueue(items), () => {}); + }, [ctx?.client, ctx?.workspaceId, ctx?.agentKey, ctx?.sessionKey, loadSession, enqueue]); + + // Items another panel adds or delivers show up here too, so neither panel loses or repeats them. + useEffect(() => watchPendingContinuations({ added: (item) => enqueue([item]), removed: forget }), [enqueue, forget]); + + const abortOnServer = useCallback( + async (sessionKey: string, runId?: string) => { + const current = ctxRef.current; + if (!current) return; + try { + await current.client.abort(current.workspaceId, { session_key: sessionKey, ...(runId ? { run_id: runId } : {}), agent_key: current.agentKey }); + } catch (error) { + handleError(error); + } + }, + [handleError], + ); + + /** Stop was pressed before the server named the run. */ + const stopRequestedRef = useRef(false); + /** The server accepted the current run (`chat.started` arrived, with or without a run id). */ + const startedRef = useRef(false); + + /** + * Starts one stream: a user message (`text`) or a queued continuation (`item`). Returns false, + * synchronously, when it is refused because another stream or reconcile already runs; nothing is + * sent then. `streamRef` is set synchronously, so two calls in the same tick cannot both send. + */ + const runStream = useCallback( + (text?: string, item?: PendingContinuation): boolean => { + const current = ctxRef.current; + if (!current || streamRef.current || ownerRef.current || stateRef.current.phase === 'streaming' || stateRef.current.phase === 'reconciling') return false; + const epoch = epochRef.current; + // A conversation switch and unmount end the stream the same way: no state is written and a + // continuation is not held, so it is sent again when its session is shown (here or in a reopened panel). + const live = () => isCurrent(epoch) && mountedRef.current; + const controller = new AbortController(); + streamRef.current = controller; + stopRequestedRef.current = false; + startedRef.current = false; + // Taken at send time: a later reconcile compares the server history against it. + const baseline = baselineRef.current; + const turnId = localId(); + const continuation: Continuation | undefined = item ? { setup_action_id: item.setup_action_id, session_key: item.session_key, agent_key: item.agent_key } : undefined; + const body = continuation ?? { message: text }; + dispatch({ type: 'send', text, localId: turnId }); + void (async () => { + let accepted = false; + // Any frame means the server answered 200, so the turn was taken. + let responded = false; + // Tracked here, not from state: the reducer's phase only updates on the next commit. + let terminal = false; + // The continuation provably reached the agent's session and left the queue. + let settled = false; + const settle = () => { + if (!item || settled) return; + settled = true; + dequeue(item); + }; + try { + const sessionKey = continuation?.session_key ?? stateRef.current.sessionKey; + const stream = current.client.streamChat( + current.workspaceId, + { agent_key: current.agentKey, ...body, ...(sessionKey ? { session_key: sessionKey } : {}) }, + controller.signal, + ); + for await (const event of stream) { + responded = true; + // Only these frames prove the agent got the card result. Any earlier frame does not: the + // Gateway releases the lease when the run fails before the result is saved. + if (event.type === 'message.completed' || (event.type === 'chat.error' && event.code === 'setup_result_delivered')) settle(); + if (!live()) return; + if (event.type === 'message.completed' || event.type === 'chat.error' || event.type === 'chat.stopped') terminal = true; + // Recorded before anything awaits, so a session key adopted by the parent meets a + // conversation that already holds it and is not reset. + dispatch({ type: 'event', event }); + if (item && !settled && (event.type === 'chat.error' || event.type === 'chat.stopped')) { + // The run ended without delivering it: it waits for "Continue", which the Gateway + // answers `continuation_sent` if it was delivered after all. + hold(item); + dispatch({ type: 'offerContinue', localId: turnId, continuation: continuation!, code: 'continuation_stopped', message: 'Stopped' }); + } + if (event.type === 'chat.started') { + accepted = true; + startedRef.current = true; + if (text !== undefined) { + // The server now holds one more user turn than the baseline; a new chat holds only this one. + const known = stateRef.current.sessionKey ? baselineRef.current : { position: -1, confirmed: 0 }; + baselineRef.current = known ? { ...known, confirmed: known.confirmed + 1 } : null; + } + if (event.sessionKey && event.sessionKey !== stateRef.current.sessionKey) { + stateRef.current = { ...stateRef.current, sessionKey: event.sessionKey }; + hooksRef.current.onSessionKey(event.sessionKey); + } + if (stopRequestedRef.current) { + stopRequestedRef.current = false; + const key = event.sessionKey ?? stateRef.current.sessionKey; + if (key) await abortOnServer(key, event.runId); + if (!live()) return; + controller.abort(); + } + } + } + if (live() && !terminal) { + // The stream closed without a terminal frame: the run may still be going on the server. + if (item && !settled) { + // Not proven delivered: held, and the reconcile offers "Continue" once the run ends. + hold(item); + const message = 'The stream ended early'; + dispatch({ type: 'streamFailed', code: 'continuation_unconfirmed', message, reconcile: true, local: true }); + void reconcile(item.session_key, { localId: turnId, continuation, code: 'continuation_unconfirmed', message, baseline }); + } else if (stateRef.current.sessionKey) void reconcile(stateRef.current.sessionKey); + else dispatch({ type: 'streamFailed', code: 'delivery_unconfirmed', message: 'The stream ended early', reconcile: false, recover: 'sessions' }); + } + } catch (error) { + if (!live()) return; + if (error instanceof DOMException && error.name === 'AbortError') { + if (item && !settled) { + // Stopped before the agent provably got it: it stays queued for "Continue" (the Gateway + // delivers it at most once). + hold(item); + dispatch({ type: 'streamFailed', code: 'continuation_stopped', message: 'Stopped', reconcile: false, retryContinuation: continuation, local: true }); + return; + } + dispatch({ type: 'event', event: { type: 'chat.stopped' } }); + return; + } + const apiError = isApiError(error) ? error : null; + // A programming fault is logged (the error only, never the request) and handled as a failure. + if (!apiError || apiError.cause !== undefined) console.error('Chat stream failed', apiError?.cause ?? error); + const code = handleError(error); + const message = apiError ? apiError.message : 'Request failed'; + const sessionKey = stateRef.current.sessionKey ?? continuation?.session_key; + if (item && !settled && apiError?.status === 409 && code === 'continuation_sent') { + // Delivered earlier (by this or another panel): a silent success, so the turn goes away + // and the conversation reloads to show the agent's reply. + settle(); + dispatch({ type: 'dropTurn', localId: turnId }); + dispatch({ type: 'idle' }); + void loadSession(() => isCurrent(epoch) && mountedRef.current, item.session_key); + return; + } + if (item && !settled && apiError?.status === 409 && code === 'continuation_in_progress') { + // Another request holds the send lease; it may still fail and release it, so this is not + // delivery. The item stays queued and is sent again after a backoff, then waits for "Continue". + if (scheduleInProgressRetry(item, apiError.retryAfterSeconds)) { + dispatch({ type: 'dropTurn', localId: turnId }); + dispatch({ type: 'idle' }); + } else { + hold(item); + dispatch({ type: 'streamFailed', code, message, reconcile: false, retryContinuation: continuation, local: true }); + } + return; + } + const fixable = apiError?.status === 403 && FIXABLE_REFUSAL_CODES.has(code); + if (item && !settled && !responded && apiError && DEAD_CONTINUATION_STATUSES.has(apiError.status) && !fixable) { + // The card or its continuation is invalid, gone or not allowed: sending it again can never + // succeed, so it leaves the queue. The person is told once, with no "Continue", that the + // agent did not get the card's result; a 400 is a client bug, so the refusal is logged (no payload). + console.warn('Card continuation refused', { status: apiError.status, code }); + settle(); + dispatch({ type: 'streamFailed', code: 'continuation_refused', message, reconcile: false }); + return; + } + if (item && !settled) hold(item); + // The server took the turn: it answered 200 or named the run (a continuation: the agent got it). + const taken = item ? settled : accepted || responded || Boolean(apiError?.afterResponse); + if (notReceived(error, responded)) { + // Nothing reached the server (or it refused the turn), so sending it again cannot start a + // second run. A continuation stays queued for "Continue", whatever the refusal's code. + dispatch({ + type: 'streamFailed', + code, + message, + reconcile: false, + retryText: text, + retryContinuation: continuation, + retryAfterSeconds: apiError?.retryAfterSeconds, + local: Boolean(continuation), + }); + } else if (!sessionKey) { + // A new chat that may have created a session: point the person to the conversation list. + dispatch({ type: 'streamFailed', code: 'delivery_unconfirmed', message, reconcile: false, recover: 'sessions' }); + } else if (taken) { + dispatch({ type: 'streamFailed', code, message, reconcile: true, retryAfterSeconds: apiError?.retryAfterSeconds }); + void reconcile(sessionKey); + } else { + // Uncertain in a known session: the history settles it, and it never offers "Send again". + // A continuation (a drop after the response included) gets "Continue" once the run ends. + dispatch({ type: 'streamFailed', code, message, reconcile: true, local: true }); + void reconcile(sessionKey, { localId: turnId, text, continuation, code, message, baseline }); + } + } finally { + if (streamRef.current === controller) { + streamRef.current = null; + setStreamsEnded((n) => n + 1); + } + } + })(); + return true; + }, + [handleError, reconcile, abortOnServer, dequeue, hold, loadSession, scheduleInProgressRetry], + ); + + const send = useCallback( + (text: string): boolean => { + const trimmed = text.trim(); + return trimmed ? runStream(trimmed) : false; + }, + [runStream], + ); + + const continueWith = useCallback( + async (continuation: Continuation, scope?: ContinuationScope) => { + const profileId = scope?.profileId ?? ctxRef.current?.profileId; + const workspaceId = scope?.workspaceId ?? ctxRef.current?.workspaceId; + if (!profileId || !workspaceId) return; + const item: PendingContinuation = { + session_key: continuation.session_key, + agent_key: continuation.agent_key, + setup_action_id: continuation.setup_action_id, + profile_id: profileId, + workspace_id: workspaceId, + }; + const key = pendingKey(item); + if (doneRef.current.has(key)) return; + heldRef.current.delete(key); + waitRef.current.delete(key); + // Stored again without the held mark (it may be queued already), so it is sent automatically. + queueRef.current = [...queueRef.current.filter((q) => pendingKey(q) !== key), item]; + void addPendingContinuation(item).catch(() => {}); + setQueueTick((n) => n + 1); + }, + [], + ); + + // Sends the next queued continuation of the conversation on screen once it is idle. One at a + // time: the next waits until this stream ends. Other sessions' continuations wait for theirs. + // One answered `continuation_in_progress` waits for its retry time, and only while shown and idle. + useEffect(() => { + if (state.phase !== 'idle') return; + const current = ctxRef.current; + const sessionKey = stateRef.current.sessionKey; + if (!current || !sessionKey) return; + const shown = queueRef.current.filter( + (q) => q.profile_id === current.profileId && q.workspace_id === current.workspaceId && q.agent_key === current.agentKey && q.session_key === sessionKey, + ); + // A held item of this session (held before a switch or in an earlier panel) shows "Continue" once. + for (const q of shown) { + if (!heldRef.current.has(pendingKey(q)) || stateRef.current.messages.some((m) => m.retryContinuation?.setup_action_id === q.setup_action_id)) continue; + const continuation = { session_key: q.session_key, agent_key: q.agent_key, setup_action_id: q.setup_action_id }; + dispatch({ type: 'restoreTurn', localId: localId(), continuation, code: 'continuation_unconfirmed', message: 'Not delivered yet' }); + } + const mine = shown.filter((q) => !heldRef.current.has(pendingKey(q))); + const now = Date.now(); + const dueAt = (q: PendingContinuation) => waitRef.current.get(pendingKey(q))?.at ?? 0; + const next = mine.find((q) => dueAt(q) <= now); + if (next) { + runStream(undefined, next); + return; + } + if (!mine.length) return; + const timer = setTimeout(() => setQueueTick((n) => n + 1), Math.min(...mine.map(dueAt)) - now); + return () => clearTimeout(timer); + }, [state.phase, state.sessionKey, streamsEnded, queueTick, runStream]); + + const stop = useCallback(async () => { + const { sessionKey, runId, phase } = stateRef.current; + if (phase === 'streaming' && !startedRef.current) { + // The server has not reported the run yet: stop it as soon as `chat.started` names it, + // and give up waiting after a short while so the button always works. + stopRequestedRef.current = true; + const controller = streamRef.current; + void sleep(STOP_GRACE_MS).then(() => { + if (stopRequestedRef.current && streamRef.current === controller) { + stopRequestedRef.current = false; + controller?.abort(); + } + }); + return; + } + if (sessionKey) await abortOnServer(sessionKey, runId); + streamRef.current?.abort(); + }, [abortOnServer, sleep]); + + const loadOlder = useCallback(async () => dispatch({ type: 'older' }), []); + + const reload = useCallback(async () => { + const sessionKey = stateRef.current.sessionKey; + if (!sessionKey || ownerRef.current || streamRef.current) return; + if (uncheckedRef.current.size) { + await reconcile(sessionKey); + return; + } + const epoch = epochRef.current; + await loadSession(() => isCurrent(epoch) && mountedRef.current, sessionKey); + }, [loadSession, reconcile]); + + const retry = useCallback( + async (message: ChatMessage) => { + const { phase } = stateRef.current; + if (streamRef.current || ownerRef.current || phase === 'streaming' || phase === 'reconciling') return; + // The failed copy goes away: the turn is sent again as a new one. + if (message.retryContinuation) { + dispatch({ type: 'dropTurn', localId: turnIdOf(message) }); + await continueWith(message.retryContinuation); + } else if (message.retryText) { + dispatch({ type: 'dropTurn', localId: turnIdOf(message) }); + send(message.retryText); + } + }, + [send, continueWith], + ); + + const updateCard = useCallback((card: UiActionCard) => dispatch({ type: 'card', card }), []); + + useEffect(() => { + mountedRef.current = true; + const timers = timersRef.current; + return () => { + mountedRef.current = false; + streamRef.current?.abort(); + // Ends pending sleeps now (their timers are cleared); the loops waiting on them see the unmount and stop. + for (const done of [...timers]) done(); + }; + }, []); + + return { state, send, continueWith, stop, loadOlder, reload, retry, updateCard }; +} diff --git a/src/credentials/ActionCard.tsx b/src/credentials/ActionCard.tsx new file mode 100644 index 0000000..893ce8d --- /dev/null +++ b/src/credentials/ActionCard.tsx @@ -0,0 +1,221 @@ +/** + * Renders one validated `dewee.ui-action.v1` card from a fixed list of kinds. Agent text (title, + * body) is shown as plain text, never as HTML or Markdown. Kinds answered through other Dewee + * routes (approvals, uploads) are shown read-only with a pointer to Dewee. + */ +import { useState } from 'react'; +import { isUiActionOpen, type UiActionCard, type UiActionPayloadByKind } from '@/protocol/ui-action'; +import { errorText, useI18n } from '@/shared/i18n'; +import { CredentialsForm } from './CredentialsForm'; +import type { RespondFn } from './respond-types'; + +interface CardProps { + card: UiActionCard; + respond: RespondFn; + /** Web page where the person can finish steps the panel does not cover, if known. */ + deweeUrl?: string; +} + +function useRespond(card: UiActionCard, respond: RespondFn) { + const { t } = useI18n(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + async function submit(body: Parameters[1]) { + if (busy) return false; + setBusy(true); + setError(''); + const outcome = await respond(card, body); + setBusy(false); + if (!outcome.ok) setError(errorText(t, outcome.code, outcome.retryAfterSeconds)); + return outcome.ok; + } + return { busy, error, submit }; +} + +function ErrorLine({ message }: { message: string }) { + return message ? ( +

+ {message} +

+ ) : null; +} + +function ChoiceCard({ card, respond }: CardProps & { card: Extract }) { + const { t } = useI18n(); + const payload: UiActionPayloadByKind['choice_request'] = card.payload; + const [selected, setSelected] = useState([]); + const [freeText, setFreeText] = useState(''); + const { busy, error, submit } = useRespond(card, respond); + + async function send(ids: string[]) { + const response: Record = {}; + if (ids.length) response.selected = payload.options.map((o) => o.id).filter((id) => ids.includes(id)); + if (freeText.trim()) response.free_text = freeText.trim(); + if (await submit({ response })) setFreeText(''); + } + + return ( +
+
+ {payload.options.map((option) => + payload.multi ? ( + + ) : ( + + ), + )} +
+ {payload.allow_free_text ? ( + + ) : null} + + {payload.multi || payload.allow_free_text ? ( +
+ +
+ ) : null} +
+ ); +} + +function OAuthCard({ card, respond, deweeUrl }: CardProps & { card: Extract }) { + const { t } = useI18n(); + const { busy, error, submit } = useRespond(card, respond); + const provider = card.payload.provider; + const notCompleted = card.result?.error_code === 'oauth_not_completed'; + return ( +
+

{t('oauth.thirdParty', { provider })}

+ {notCompleted && !error ?

{t('oauth.notCompleted')}

: null} + +
+ {deweeUrl ? ( + + {t('oauth.openDewee')} + + ) : null} + +
+
+ ); +} + +function DecisionCard({ card, respond, decisions }: CardProps & { decisions: { value: string; label: string; primary?: boolean }[] }) { + const { busy, error, submit } = useRespond(card, respond); + return ( +
+ +
+ {decisions.map((d) => ( + + ))} +
+
+ ); +} + +function CardBody({ card, respond, deweeUrl }: CardProps) { + const { t } = useI18n(); + const open = isUiActionOpen(card); + switch (card.kind) { + case 'secret_request': + return ; + case 'choice_request': + return open ? : null; + case 'oauth_request': + return open ? : null; + case 'command_preview': + return ( + <> +
{card.payload.command.join(' ')}
+ {card.payload.description ?

{card.payload.description}

: null} + {open && card.payload.policy_outcome !== 'blocked' ? ( + + ) : null} + + ); + case 'setup_progress': { + const { completed, total } = card.payload; + return ( + + {completed}/{total} + + ); + } + case 'plan_review': + case 'permission_request': + case 'install_request': + case 'upload_request': + case 'connection_test': + case 'handoff': + return open ? ( +

+ {t('card.openInDewee')}{' '} + {deweeUrl ? ( + + Dewee ↗ + + ) : null} +

+ ) : null; + } +} + +function formatTime(iso: string, locale: string): string { + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso)); + } catch { + return iso; + } +} + +export function ActionCard(props: CardProps) { + const { t, locale } = useI18n(); + const { card } = props; + const expired = card.status === 'pending' && !isUiActionOpen(card); + const status = expired ? 'expired' : card.status; + return ( +
+
+

{card.title}

+ {t(`card.status.${status}`)} +
+ {card.body ?

{card.body}

: null} + + {status === 'resolved' && card.kind === 'secret_request' ?

{t('secret.saved')}

: null} + {status === 'pending' ?

{t('card.expires', { time: formatTime(card.expires_at, locale) })}

: null} +
+ ); +} diff --git a/src/credentials/CredentialsForm.tsx b/src/credentials/CredentialsForm.tsx new file mode 100644 index 0000000..23c2fed --- /dev/null +++ b/src/credentials/CredentialsForm.tsx @@ -0,0 +1,142 @@ +/** + * Secure credential form for `secret_request` cards. The only place a secret is typed: + * - values live in this component's local state only (never app state, storage, messages, logs); + * - they are cleared from state before the request is awaited, success or failure; + * - they travel only in the body of `POST …/actions/{id}/respond`, never through chat; + * - the destination is the Gateway-validated target, labelled from extension copy, not agent text. + */ +import { useId, useState, type FormEvent } from 'react'; +import type { SecretRequestEnvelope } from '@/protocol/ui-action'; +import { errorText, useI18n } from '@/shared/i18n'; +import { fieldLabel, targetLabel } from './labels'; +import type { RespondFn } from './respond-types'; + +type Values = Record; + +export function CredentialsForm({ card, respond, interactive }: { card: SecretRequestEnvelope; respond: RespondFn; interactive: boolean }) { + const { t } = useI18n(); + const formId = useId(); + const { target, fields } = card.payload; + const [values, setValues] = useState({}); + const [missing, setMissing] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + // Replacing a stored credential is an explicit step, so a stray card cannot look routine. + const [rotating, setRotating] = useState(target.mode === 'create'); + // Typed identifiers the Gateway validates come before the free-form name the agent chose. + const destination = targetLabel(t, target.type, target.provider_type ?? target.channel_type ?? target.binary_name ?? target.name); + // A card that closes without a submit (expired, revoked, answered elsewhere) drops what was typed. + // Done while rendering, so the closed card never commits with the values still in state. + if (!interactive && (Object.keys(values).length > 0 || missing.length > 0)) { + setValues({}); + setMissing([]); + } + + const head = ( +
+

+ {' '} + {/* A card closed without an answer (cancelled, expired, failed) must not claim it saved anything. */} + {t(interactive || card.status === 'resolved' ? 'secret.destination' : 'secret.destinationClosed', { target: destination })} +

+

{target.mode === 'update' ? t('secret.update') : t('secret.create')}

+
+ ); + const errorLine = error ? ( +

+ {error} +

+ ) : null; + // A refused answer (409) reloads the card as closed, so the reason must outlive the form. + if (!interactive) + return ( + <> + {head} + {errorLine} + + ); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + if (busy) return; + const blank = fields.filter((f) => f.required && !(values[f.key] ?? '').trim()).map((f) => f.key); + setMissing(blank); + if (blank.length) return; + const secret: Values = {}; + const response: Values = {}; + for (const field of fields) { + const value = values[field.key] ?? ''; + if (!value) continue; + if (field.kind === 'secret') secret[field.key] = value; + else response[field.key] = value.trim(); + } + // Drop the values before the request is in flight: the panel never holds them again. + setValues({}); + setBusy(true); + setError(''); + const outcome = await respond(card, { ...(Object.keys(response).length ? { response: { fields: response } } : {}), secret }); + setBusy(false); + if (!outcome.ok) setError(errorText(t, outcome.code, outcome.retryAfterSeconds)); + } + + if (!rotating) { + return ( + <> + {head} +
+ +
+ + ); + } + + return ( +
void onSubmit(event)} autoComplete="off" noValidate aria-describedby={`${formId}-privacy`}> + {head} + {fields.map((field) => { + const label = fieldLabel(t, field.key); + const inputId = `${formId}-${field.key}`; + const invalid = missing.includes(field.key); + return ( + + ); + })} +

+ {t('secret.privacy')} +

+ {errorLine} +
+ +
+
+ ); +} diff --git a/src/credentials/labels.ts b/src/credentials/labels.ts new file mode 100644 index 0000000..2b02080 --- /dev/null +++ b/src/credentials/labels.ts @@ -0,0 +1,16 @@ +/** + * Labels for credential cards come from the extension's own copy, keyed by target type and field + * key. The agent-supplied label is never shown, so an agent cannot word a field to phish for a + * different secret than the one the Gateway will store. + */ +import { hasMessage, type Translate } from '@/shared/i18n'; + +export function targetLabel(t: Translate, type: string, name?: string): string { + const key = `target.${type}`; + return hasMessage(key) ? t(key, { name: name ?? '' }).trim() : `${type} ${name ?? ''}`.trim(); +} + +export function fieldLabel(t: Translate, fieldKey: string): string { + const key = `secret.field.${fieldKey}`; + return hasMessage(key) ? t(key) : t('secret.field.generic', { key: fieldKey }); +} diff --git a/src/credentials/respond-types.ts b/src/credentials/respond-types.ts new file mode 100644 index 0000000..f12e67f --- /dev/null +++ b/src/credentials/respond-types.ts @@ -0,0 +1,11 @@ +import type { RespondBody } from '@/api/dewee-client'; +import type { RespondResult } from '@/api/schemas'; +import type { UiActionCard } from '@/protocol/ui-action'; + +export type RespondOutcome = { ok: true; result: RespondResult } | { ok: false; code: string; retryAfterSeconds?: number }; + +/** + * Answers one card. Implementations send `body` straight to the respond endpoint and must not + * keep, log or forward it (it may contain `secret`). + */ +export type RespondFn = (card: UiActionCard, body: RespondBody) => Promise; diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts new file mode 100644 index 0000000..915cece --- /dev/null +++ b/src/entrypoints/background.ts @@ -0,0 +1,5 @@ +import { startServiceWorker } from '@/background/service-worker'; + +export default defineBackground(() => { + startServiceWorker(); +}); diff --git a/src/entrypoints/options/index.html b/src/entrypoints/options/index.html new file mode 100644 index 0000000..6046edb --- /dev/null +++ b/src/entrypoints/options/index.html @@ -0,0 +1,13 @@ + + + + + + Dewee settings + + + +
+ + + diff --git a/src/entrypoints/options/main.tsx b/src/entrypoints/options/main.tsx new file mode 100644 index 0000000..b01e3f0 --- /dev/null +++ b/src/entrypoints/options/main.tsx @@ -0,0 +1,4 @@ +import { createRoot } from 'react-dom/client'; +import { Settings } from '@/options/Settings'; + +createRoot(document.getElementById('root')!).render(); diff --git a/src/entrypoints/sidepanel/index.html b/src/entrypoints/sidepanel/index.html new file mode 100644 index 0000000..4c660d5 --- /dev/null +++ b/src/entrypoints/sidepanel/index.html @@ -0,0 +1,12 @@ + + + + + + Dewee + + +
+ + + diff --git a/src/entrypoints/sidepanel/main.tsx b/src/entrypoints/sidepanel/main.tsx new file mode 100644 index 0000000..383e49d --- /dev/null +++ b/src/entrypoints/sidepanel/main.tsx @@ -0,0 +1,4 @@ +import { createRoot } from 'react-dom/client'; +import { App } from '@/sidepanel/App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..9f589de --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,3 @@ +interface ImportMetaEnv { + readonly WXT_DEWEE_CLOUD_ORIGIN?: string; +} diff --git a/src/options/Settings.tsx b/src/options/Settings.tsx new file mode 100644 index 0000000..efd5a67 --- /dev/null +++ b/src/options/Settings.tsx @@ -0,0 +1,147 @@ +/** + * Options page: manage connection profiles (add, verify, use, sign out, remove) and client + * preferences. Workspace settings stay in Dewee; these are only this browser's preferences. + */ +import { useState } from 'react'; +import { ConnectForm } from '@/shared/ConnectForm'; +import { removeProfile, signOutProfile, verifyProfile } from '@/shared/connect'; +import { errorText, I18nContext, useI18n } from '@/shared/i18n'; +import { localePrefs, themes, type Profile } from '@/shared/settings'; +import { useSettings, type SettingsState } from '@/shared/use-settings'; +import './options.css'; + +function ProfileRow({ profile, active, connected, update }: { profile: Profile; active: boolean; connected: boolean; update: SettingsState['update'] }) { + const { t } = useI18n(); + const [status, setStatus] = useState(''); + const [busy, setBusy] = useState(false); + const [reconnecting, setReconnecting] = useState(false); + + async function run(task: () => Promise) { + setBusy(true); + try { + await task(); + } finally { + setBusy(false); + } + } + + return ( +
  • +
    + {profile.name} + {active ? {t('settings.active')} : null} + {profile.origin} + + {profile.kind === 'cloud' ? t('connect.cloud') : t('connect.selfHosted')} · {connected ? status || '✓' : t('settings.notConnected')} + +
    +
    + {!active ? ( + + ) : null} + {connected ? ( + <> + + + + ) : ( + + )} + +
    + {reconnecting && !connected ? setReconnecting(false)} /> : null} +
  • + ); +} + +export function Settings() { + const state = useSettings(); + const { settings, connected, update, t, locale } = state; + const [adding, setAdding] = useState(false); + + return ( + +
    +

    {t('settings.title')}

    +
    +
    +

    {t('settings.profiles')}

    + +
    + {adding ? ( +
    + setAdding(false)} /> +
    + ) : null} +
      + {settings?.profiles.map((profile) => ( + + ))} +
    +
    +
    +

    {t('settings.preferences')}

    +
    + + +
    +

    {t('settings.privacy')}

    +
    +
    +
    + ); +} diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..660b5e3 --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,54 @@ +@import '../shared/components.css'; + +.options { + max-width: 720px; + margin: 0 auto; + padding: 24px 16px 48px; + display: grid; + gap: 24px; +} +.options h1 { + margin: 0; + font-size: 22px; +} +.options h2 { + margin: 0 0 10px; + font-size: 16px; +} +.section-head { + display: flex; + justify-content: space-between; + align-items: center; +} +.panel { + padding: 14px; + margin-bottom: 12px; + border: 1px solid var(--hairline); + border-radius: var(--radius); + background: var(--paper-2); +} +.profile-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} +.profile-row { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--hairline); + border-radius: var(--radius); + background: var(--paper-2); +} +.profile-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.prefs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} diff --git a/src/protocol/chat-events.ts b/src/protocol/chat-events.ts new file mode 100644 index 0000000..d00155e --- /dev/null +++ b/src/protocol/chat-events.ts @@ -0,0 +1,174 @@ +/** + * Chat stream events as the extension consumes them. The wire format is the Dewee runtime SSE + * contract (snake_case payloads, event names below) emitted by the Gateway's workspace chat + * stream and forwarded verbatim by the Cloud extension BFF. `mapRuntimeFrame` turns one raw frame + * into a typed, camelCase `ChatEvent`; unknown events and malformed JSON are dropped so a single + * bad frame never breaks the stream. + */ +import { z } from 'zod'; +import { parseUiAction, type UiActionEnvelope } from './ui-action'; + +export const chatEventTypes = [ + 'chat.started', + 'message.delta', + 'thinking.delta', + 'tool.call.started', + 'tool.call.completed', + 'tool.call.error', + 'reasoning.activity', + 'message.completed', + 'chat.error', + 'chat.stopped', +] as const; +export type ChatEventType = (typeof chatEventTypes)[number]; + +export const recoveryActionsByCode = { + provider_unavailable: 'review_provider_settings', + provider_auth: 'review_provider_settings', + provider_billing: 'review_provider_settings', + provider_rate_limited: 'wait_and_retry', + provider_overloaded: 'retry', + provider_timeout: 'retry', + provider_model_unavailable: 'review_provider_settings', + provider_request_invalid: 'contact_workspace_admin', + provider_content_policy: 'contact_workspace_admin', + context_limit: 'start_new_chat', + runtime_unavailable: 'retry', + runtime_request_failed: 'retry', + setup_result_delivered: 'continue_in_chat', +} as const; +export type RecoveryCode = keyof typeof recoveryActionsByCode; +export type RecoveryAction = (typeof recoveryActionsByCode)[RecoveryCode]; + +export interface ToolCall { + id?: string; + name?: string; + arguments?: Record; + resultSummary?: string; + error?: string; + viaAgent?: string; + uiAction?: UiActionEnvelope; +} + +export interface MediaItem { + path: string; + contentType?: string; + size?: number; +} + +export type ChatEvent = + | { type: 'chat.started'; runId?: string; sessionKey?: string; agentKey?: string } + | { type: 'message.delta' | 'thinking.delta'; delta: string } + | { type: 'tool.call.started' | 'tool.call.completed' | 'tool.call.error'; toolCall: ToolCall } + | { type: 'reasoning.activity'; phase?: string } + | { type: 'message.completed'; content: string; createdAt?: string; media: MediaItem[] } + | { type: 'chat.error'; code: RecoveryCode; action: RecoveryAction; retryAfterSeconds?: number } + | { type: 'chat.stopped' }; + +export interface RawSseFrame { + event: string; + data: string; + id?: string; +} + +const str = z.string().optional().catch(undefined); +const record = z.record(z.string(), z.unknown()); + +const toolCallPayload = z + .object({ id: str, name: str, arguments: record.optional().catch(undefined), via_agent: str }) + .catch({}); + +const toolResultPayload = z.object({ + tool_call: toolCallPayload.optional(), + id: str, + name: str, + result_summary: str, + result: str, + error: str, + via_agent: str, + ui_action: z.unknown().optional(), +}); + +const mediaPayload = z + .array(z.object({ path: z.string(), content_type: str, size: z.number().optional().catch(undefined) })) + .catch([]); + +function isKnown(event: string): event is ChatEventType { + return (chatEventTypes as readonly string[]).includes(event); +} + +function toolResult(payload: unknown): ToolCall { + const parsed = toolResultPayload.safeParse(payload); + if (!parsed.success) return {}; + const p = parsed.data; + const call = p.tool_call ?? {}; + const uiAction = p.ui_action === undefined ? null : parseUiAction(p.ui_action); + return { + id: call.id ?? p.id, + name: call.name ?? p.name, + resultSummary: p.result_summary ?? p.result, + error: p.error, + viaAgent: call.via_agent ?? p.via_agent, + ...(uiAction ? { uiAction } : {}), + }; +} + +function recovery(payload: Record): Extract { + const code = payload.code as RecoveryCode | undefined; + const action = payload.recovery_action; + if (!code || !(code in recoveryActionsByCode) || recoveryActionsByCode[code] !== action) { + return { type: 'chat.error', code: 'runtime_request_failed', action: 'retry' }; + } + const retry = payload.retry_after_seconds; + const retryAfterSeconds = typeof retry === 'number' && Number.isInteger(retry) && retry > 0 && retry <= 900 ? retry : undefined; + return { type: 'chat.error', code, action: recoveryActionsByCode[code], ...(retryAfterSeconds ? { retryAfterSeconds } : {}) }; +} + +export function mapRuntimeFrame(frame: RawSseFrame): ChatEvent | null { + if (!isKnown(frame.event)) return null; + let payload: Record; + try { + const decoded: unknown = frame.data ? JSON.parse(frame.data) : {}; + if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) return null; + payload = decoded as Record; + } catch { + return null; + } + const s = (key: string) => (typeof payload[key] === 'string' ? (payload[key] as string) : undefined); + switch (frame.event) { + case 'chat.started': + return { type: 'chat.started', runId: s('run_id'), sessionKey: s('session_key'), agentKey: s('agent_key') }; + case 'message.delta': + case 'thinking.delta': + return { type: frame.event, delta: s('delta') ?? '' }; + case 'tool.call.started': { + const call = toolCallPayload.parse(payload.tool_call ?? {}); + return { + type: 'tool.call.started', + toolCall: { id: call.id, name: call.name, arguments: call.arguments, viaAgent: call.via_agent }, + }; + } + case 'tool.call.completed': + case 'tool.call.error': + return { type: frame.event, toolCall: toolResult(payload) }; + case 'reasoning.activity': + return { type: 'reasoning.activity', phase: s('phase') }; + case 'message.completed': { + const message = (payload.message ?? {}) as Record; + return { + type: 'message.completed', + content: typeof message.content === 'string' ? message.content : '', + createdAt: typeof message.created_at === 'string' ? message.created_at : undefined, + media: mediaPayload.parse(payload.media ?? []).map((m) => ({ path: m.path, contentType: m.content_type, size: m.size })), + }; + } + case 'chat.error': + return recovery(payload); + case 'chat.stopped': + return { type: 'chat.stopped' }; + } +} + +export function isTerminal(event: ChatEvent): boolean { + return event.type === 'message.completed' || event.type === 'chat.error' || event.type === 'chat.stopped'; +} diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts new file mode 100644 index 0000000..7ce7d5c --- /dev/null +++ b/src/protocol/messages.ts @@ -0,0 +1,34 @@ +/** + * Typed messages between extension pages (side panel, options) and the service worker. The + * worker owns Cloud token renewal (single flight, so rotated refresh tokens are never replayed), + * sign-in/out and the pending-action badge. Secret form values never travel over this channel: + * the credential form posts them straight to the respond endpoint. + */ +import { z } from 'zod'; +import { browser } from 'wxt/browser'; + +const profileRef = { profileId: z.string().min(1) }; + +export const requestSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('auth.authorization'), ...profileRef }), + z.object({ type: z.literal('auth.renew'), ...profileRef }), + z.object({ type: z.literal('auth.signIn'), ...profileRef }), + z.object({ type: z.literal('auth.signOut'), ...profileRef }), + z.object({ type: z.literal('badge.refresh') }), +]); +export type BackgroundRequest = z.infer; + +export type BackgroundResponse = + | { ok: true; authorization?: string } + /** `status` is the HTTP-style status of the failure; absent means the worker could not answer. */ + | { ok: false; code: string; message?: string; status?: number }; + +/** Sends a request to the service worker; transport failures become `{ ok: false }`. */ +export async function callBackground(request: BackgroundRequest): Promise { + try { + const response = (await browser.runtime.sendMessage(request)) as BackgroundResponse | undefined; + return response ?? { ok: false, code: 'no_response' }; + } catch { + return { ok: false, code: 'background_unavailable' }; + } +} diff --git a/src/protocol/sse-reader.ts b/src/protocol/sse-reader.ts new file mode 100644 index 0000000..db0483a --- /dev/null +++ b/src/protocol/sse-reader.ts @@ -0,0 +1,62 @@ +import type { RawSseFrame } from './chat-events'; + +/** + * Incremental Server-Sent Events parser (WHATWG event-stream format). Feed decoded text chunks; + * complete frames are returned as soon as their blank-line terminator arrives. + */ +export class SseParser { + private buffer = ''; + private event = ''; + private data: string[] = []; + private id: string | undefined; + + push(chunk: string): RawSseFrame[] { + this.buffer += chunk; + const frames: RawSseFrame[] = []; + let newline: number; + while ((newline = this.buffer.search(/\r\n|\r|\n/)) >= 0) { + const line = this.buffer.slice(0, newline); + const width = this.buffer.startsWith('\r\n', newline) ? 2 : 1; + this.buffer = this.buffer.slice(newline + width); + const frame = this.line(line); + if (frame) frames.push(frame); + } + return frames; + } + + private line(line: string): RawSseFrame | null { + if (line === '') { + if (this.data.length === 0 && !this.event) return null; + const frame: RawSseFrame = { event: this.event || 'message', data: this.data.join('\n'), ...(this.id ? { id: this.id } : {}) }; + this.event = ''; + this.data = []; + return frame; + } + if (line.startsWith(':')) return null; + const colon = line.indexOf(':'); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'event') this.event = value; + else if (field === 'data') this.data.push(value); + else if (field === 'id') this.id = value; + return null; + } +} + +/** Reads a fetch Response body as SSE frames until the stream ends or the signal aborts. */ +export async function* readSseFrames(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + yield* parser.push(decoder.decode(value, { stream: true })); + } + yield* parser.push(decoder.decode() + '\n\n'); + } finally { + reader.releaseLock(); + } +} diff --git a/src/protocol/ui-action.ts b/src/protocol/ui-action.ts new file mode 100644 index 0000000..d6d484a --- /dev/null +++ b/src/protocol/ui-action.ts @@ -0,0 +1,218 @@ +/** + * Client-side validation of `dewee.ui-action.v1` envelopes. The Gateway is authoritative and + * already validates every card; the extension re-validates at its boundary so a malformed or + * unknown card is dropped instead of rendered. Objects are strict (unknown keys reject), mirroring + * Go's DisallowUnknownFields decoding. `tests/unit/protocol/ui-action.contract.test.ts` pins these + * shapes to the Go golden schema vendored in `tests/contract-fixtures/ui-action-v1`. + */ +import { z } from 'zod'; + +export const UI_ACTION_SCHEMA_V1 = 'dewee.ui-action.v1'; + +export const uiActionKinds = [ + 'choice_request', + 'secret_request', + 'oauth_request', + 'permission_request', + 'install_request', + 'upload_request', + 'plan_review', + 'connection_test', + 'command_preview', + 'setup_progress', + 'handoff', +] as const; +export type UiActionKind = (typeof uiActionKinds)[number]; + +export const uiActionStatuses = ['pending', 'resolved', 'cancelled', 'expired', 'failed'] as const; +export type UiActionStatus = (typeof uiActionStatuses)[number]; + +export const secretTargetTypes = ['provider', 'channel_instance', 'cli_agent_credential', 'mcp_server', 'tts', 'stt'] as const; +export const credentialRefKinds = ['provider', 'channel_instance', 'cli_agent_credential', 'mcp_server'] as const; + +const code = z.string().max(64).regex(/^[a-z0-9][a-z0-9_.:-]*$/i); +const uuid = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); +const text = (max: number) => z.string().max(max); +const rfc3339 = z.string().refine((v) => !Number.isNaN(Date.parse(v)), 'must be an RFC 3339 timestamp'); + +export const choiceRequestPayload = z.strictObject({ + purpose: code.optional(), + options: z + .array(z.strictObject({ id: code, label: text(80).min(1), description: text(200).optional() })) + .min(1) + .max(12), + multi: z.boolean().optional(), + allow_free_text: z.boolean().optional(), +}); + +export const secretTarget = z.strictObject({ + type: z.enum(secretTargetTypes), + mode: z.enum(['create', 'update']), + id: uuid.optional(), + name: text(100).optional(), + provider_type: code.optional(), + channel_type: code.optional(), + api_base: z.string().optional(), + url: z.string().optional(), + binary_name: code.optional(), + agent_key: code.optional(), +}); + +export const secretField = z.strictObject({ + key: code, + label: text(80).min(1), + kind: z.enum(['secret', 'text']), + required: z.boolean().optional(), +}); + +export const secretRequestPayload = z + .strictObject({ target: secretTarget, fields: z.array(secretField).min(1).max(8) }) + .refine((p) => p.fields.some((f) => f.kind === 'secret'), 'fields must include a secret field') + .refine((p) => new Set(p.fields.map((f) => f.key)).size === p.fields.length, 'fields.key must be unique') + // Exfiltration guard: a card may never name where a secret is sent. + .refine((p) => p.target.url === undefined, 'target.url is not allowed'); + +export const oauthRequestPayload = z.strictObject({ + provider: code, + server_id: uuid.optional(), + scopes: z.array(z.string().min(1).max(200).regex(/^\S+$/)).max(20).optional(), +}); + +export const permissionRequestPayload = z.strictObject({ + approval_id: text(100).min(1), + risk_class: text(40).startsWith('setup.'), + command_path: text(200).min(1), + command: z.array(z.string()).optional(), + reason: code.optional(), + confirm_name: text(255).optional(), + scope: z.enum(['read', 'write', 'admin']).optional(), +}); + +export const installRequestPayload = z.strictObject({ + approval_id: text(100).min(1), + package: text(200).min(1), + runtime: code.optional(), + version: text(100).optional(), + reason: code.optional(), +}); + +export const uploadRequestPayload = z.strictObject({ + purpose: code, + accept: z.array(text(100).min(1)).max(10).optional(), + max_bytes: z.number().int().min(1).max(50 * 1024 * 1024), + multiple: z.boolean().optional(), +}); + +export const planReviewPayload = z.strictObject({ plan_revision: z.number().int().min(1) }); + +export const connectionTestPayload = z.strictObject({ + target: z.strictObject({ type: z.enum(credentialRefKinds), id: uuid, name: text(100).optional() }), + step_id: code.optional(), + credential_ref: z.string().optional(), +}); + +export const commandPreviewPayload = z.strictObject({ + command: z.array(z.string()).min(1), + description: text(300).optional(), + policy_class: code.optional(), + policy_outcome: code.optional(), +}); + +export const setupProgressPayload = z + .strictObject({ + plan_revision: z.number().int().min(1), + completed: z.number().int().min(0), + total: z.number().int().min(0).max(64), + current_step_id: code.optional(), + }) + .refine((p) => p.completed <= p.total, 'completed must not exceed total'); + +export const handoffPayload = z.strictObject({ + purpose: z.enum(['super_agent', 'channel_probe']), + session_key: text(255).optional(), + agent_key: code.optional(), + channel_type: code.optional(), + channel_instance_id: uuid.optional(), + probe_expires_at: rfc3339.optional(), +}); + +export const uiActionPayloadSchemas = { + choice_request: choiceRequestPayload, + secret_request: secretRequestPayload, + oauth_request: oauthRequestPayload, + permission_request: permissionRequestPayload, + install_request: installRequestPayload, + upload_request: uploadRequestPayload, + plan_review: planReviewPayload, + connection_test: connectionTestPayload, + command_preview: commandPreviewPayload, + setup_progress: setupProgressPayload, + handoff: handoffPayload, +} as const satisfies Record; + +export type UiActionPayloadByKind = { [K in UiActionKind]: z.infer<(typeof uiActionPayloadSchemas)[K]> }; + +const envelopeBase = { + schema: z.literal(UI_ACTION_SCHEMA_V1), + id: uuid, + status: z.enum(uiActionStatuses), + title: text(120).min(1), + body: text(1000).optional(), + created_at: rfc3339, + expires_at: rfc3339, +}; + +function envelopeOf(kind: K) { + return z.strictObject({ ...envelopeBase, kind: z.literal(kind), payload: uiActionPayloadSchemas[kind] }); +} + +export const uiActionEnvelope = z + .discriminatedUnion('kind', [ + envelopeOf('choice_request'), + envelopeOf('secret_request'), + envelopeOf('oauth_request'), + envelopeOf('permission_request'), + envelopeOf('install_request'), + envelopeOf('upload_request'), + envelopeOf('plan_review'), + envelopeOf('connection_test'), + envelopeOf('command_preview'), + envelopeOf('setup_progress'), + envelopeOf('handoff'), + ]) + .refine((e) => Date.parse(e.expires_at) > Date.parse(e.created_at), 'expires_at must be after created_at') + .refine((e) => JSON.stringify(e.payload).length <= 16 * 1024, 'payload exceeds 16 KiB'); + +export type UiActionEnvelope = z.infer; +export type SecretRequestEnvelope = Extract; + +/** The validated envelope, or null when the value is not a card this client may render. */ +export function parseUiAction(value: unknown): UiActionEnvelope | null { + const result = uiActionEnvelope.safeParse(value); + return result.success ? result.data : null; +} + +/** + * A card as the actions/respond routes return it: the envelope plus stored-state fields + * (`session_key`, `result`, …). Those fields are split off before the strict envelope check; + * only `result` (non-secret outcome such as `credential_ref` or `error_code`) is kept. + */ +export type UiActionCard = UiActionEnvelope & { result?: Record }; + +const viewOnlyKeys = ['session_key', 'step_id', 'agent_id', 'attempt_count', 'result', 'resolved_at'] as const; + +export function parseUiActionView(value: unknown): UiActionCard | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + const record = { ...(value as Record) }; + const result = record.result; + for (const key of viewOnlyKeys) delete record[key]; + const envelope = parseUiAction(record); + if (!envelope) return null; + const isRecord = result !== null && typeof result === 'object' && !Array.isArray(result); + return isRecord ? { ...envelope, result: result as Record } : envelope; +} + +/** True when the card can still be answered (pending and not past its expiry). */ +export function isUiActionOpen(envelope: UiActionEnvelope, now = Date.now()): boolean { + return envelope.status === 'pending' && Date.parse(envelope.expires_at) > now; +} diff --git a/src/shared/ConnectForm.tsx b/src/shared/ConnectForm.tsx new file mode 100644 index 0000000..cd046e6 --- /dev/null +++ b/src/shared/ConnectForm.tsx @@ -0,0 +1,140 @@ +/** + * Connect a Dewee Cloud account (OAuth PKCE) or a self-hosted Gateway (URL + personal API key). + * The API key field is cleared as soon as the attempt starts; it is never echoed in errors. + */ +import { useId, useState, type FormEvent } from 'react'; +import { DEFAULT_CLOUD_ORIGIN } from './config'; +import { connectCloud, connectGateway, requestOriginAccess } from './connect'; +import { errorText, useI18n } from './i18n'; +import { checkOrigin } from './origin'; +import type { Profile } from './settings'; + +type Mode = 'cloud' | 'gateway'; + +export function ConnectForm({ reconnect, onDone }: { reconnect?: Profile; onDone(profile: Profile): void }) { + const { t } = useI18n(); + const id = useId(); + const [mode, setMode] = useState(reconnect?.kind ?? 'cloud'); + const [name, setName] = useState(reconnect?.name ?? ''); + const [gatewayUrl, setGatewayUrl] = useState(reconnect?.kind === 'gateway' ? reconnect.origin : ''); + const [cloudUrl, setCloudUrl] = useState(reconnect?.kind === 'cloud' ? reconnect.origin : DEFAULT_CLOUD_ORIGIN); + const [apiKey, setApiKey] = useState(''); + const [remember, setRemember] = useState(reconnect?.remember ?? false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const urlInput = mode === 'gateway' ? gatewayUrl : cloudUrl; + const check = urlInput ? checkOrigin(urlInput) : null; + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + if (busy) return; + setError(''); + const origin = checkOrigin(urlInput); + if (!origin.ok) { + setError(t(`origin.${origin.reason}`)); + return; + } + // First call in the gesture: Chrome only shows the permission prompt during a user action. + const granted = await requestOriginAccess(origin.origin); + if (!granted) { + setError(t('connect.permissionDenied')); + return; + } + setBusy(true); + const key = apiKey; + setApiKey(''); + const result = + mode === 'gateway' + ? await connectGateway({ profileId: reconnect?.id, name, origin: origin.origin, apiKey: key, remember }) + : await connectCloud({ profileId: reconnect?.id, name, origin: origin.origin, remember }); + setBusy(false); + if (result.ok) onDone(result.profile); + else setError(errorText(t, result.code)); + } + + return ( +
    void onSubmit(event)} autoComplete="off" noValidate> + {reconnect ? null : ( +
    + {(['cloud', 'gateway'] as const).map((value) => ( + + ))} +
    + )} +

    {mode === 'cloud' ? t('connect.cloudHint') : t('connect.selfHostedHint')}

    + + {reconnect ? null : ( + + )} + + {mode === 'gateway' ? ( + <> + + + + ) : reconnect ? null : ( +
    + Cloud URL + setCloudUrl(e.target.value)} /> +
    + )} + + {check?.ok && check.insecure ? ( +

    + {t('connect.insecure')} +

    + ) : null} + + + + {error ? ( +

    + {error} +

    + ) : null} + + +
    + ); +} diff --git a/src/shared/components.css b/src/shared/components.css new file mode 100644 index 0000000..6662c3a --- /dev/null +++ b/src/shared/components.css @@ -0,0 +1,114 @@ +/* Components shared by the side panel and the options page. */ +@import './theme.css'; + +.muted { + color: var(--ink-soft); +} +.small { + font-size: 12px; +} +.block { + display: block; +} +.mono { + font-family: var(--font-mono); +} +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.grow { + flex: 1 1 auto; + min-width: 0; +} +.stack { + display: grid; + gap: 8px; +} +.pad { + padding: 12px; +} +.link { + border: 0; + padding: 0; + background: none; + color: var(--indigo); + text-decoration: underline; + cursor: pointer; +} +.badge { + display: inline-block; + margin-left: 6px; + padding: 0 6px; + border-radius: 999px; + background: var(--paper-3); + border: 1px solid var(--hairline); + font: 600 10px/16px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-muted); + vertical-align: middle; +} +.btn.small { + min-height: 26px; + padding: 0 8px; + font-size: 12px; +} +.btn.icon { + min-width: 32px; + padding: 0; +} +select.input { + appearance: auto; +} +.input.compact { + min-height: 30px; + padding: 3px 6px; +} + +.connect-form { + display: grid; + gap: 12px; +} +.segmented { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-sm); + overflow: hidden; +} +.segment { + min-height: 34px; + border: 0; + background: var(--paper-2); + cursor: pointer; +} +.segment.active { + background: var(--electric); + color: var(--on-electric); +} +.checkbox { + display: flex; + gap: 8px; + align-items: flex-start; +} +.checkbox input { + margin-top: 3px; +} +.hint-code { + font-family: var(--font-mono); + word-break: break-word; +} +.advanced summary { + cursor: pointer; + color: var(--ink-soft); +} +.advanced .input { + margin-top: 6px; +} +.field-error { + color: var(--danger); + font-size: 12px; +} diff --git a/src/shared/config.ts b/src/shared/config.ts new file mode 100644 index 0000000..4b7ce12 --- /dev/null +++ b/src/shared/config.ts @@ -0,0 +1,5 @@ +/** Build-time configuration. `WXT_DEWEE_CLOUD_ORIGIN` overrides the Cloud origin (staging, local CCP). */ +export const DEFAULT_CLOUD_ORIGIN: string = import.meta.env.WXT_DEWEE_CLOUD_ORIGIN || 'https://app.dewee.sh'; +export const OAUTH_CLIENT_ID = 'dewee-chrome-extension'; +export const OAUTH_SCOPE = 'chat workspaces actions'; +export const SUPER_AGENT_KEY = 'super-agent'; diff --git a/src/shared/connect.ts b/src/shared/connect.ts new file mode 100644 index 0000000..65d60a1 --- /dev/null +++ b/src/shared/connect.ts @@ -0,0 +1,106 @@ +/** + * Adding, verifying and removing connection profiles. Host access is requested per origin at + * runtime (`optional_host_permissions`), never ``; the request must start inside the + * click handler, so `requestOriginAccess` is the first call a UI handler makes. + */ +import { browser } from 'wxt/browser'; +import { createClient, pageAuthAdapter } from '@/api/client-factory'; +import { DeweeApiError, isApiError } from '@/api/errors'; +import type { Me } from '@/api/schemas'; +import { clearCredential, writeCredential } from '@/auth/credential-store'; +import { callBackground } from '@/protocol/messages'; +import { originPermissionPattern } from './origin'; +import { loadSettings, newProfileId, updateSettings, withoutProfile, type Profile } from './settings'; + +export type ConnectResult = { ok: true; profile: Profile; me: Me } | { ok: false; code: string }; + +export async function requestOriginAccess(origin: string): Promise { + try { + return await browser.permissions.request({ origins: [originPermissionPattern(origin)] }); + } catch { + return false; + } +} + +function failure(error: unknown): { ok: false; code: string } { + return { ok: false, code: isApiError(error) ? error.code : 'internal_error' }; +} + +async function saveProfile(profile: Profile): Promise { + await updateSettings((s) => ({ + ...s, + profiles: s.profiles.some((p) => p.id === profile.id) ? s.profiles.map((p) => (p.id === profile.id ? profile : p)) : [...s.profiles, profile], + activeProfileId: profile.id, + })); +} + +/** Verifies the key with `GET /me` before anything is stored; a rejected key is never saved. */ +export async function connectGateway(input: { profileId?: string; name: string; origin: string; apiKey: string; remember: boolean }): Promise { + const apiKey = input.apiKey.trim(); + const profile: Profile = { id: input.profileId ?? newProfileId(), name: input.name.trim() || new URL(input.origin).host, kind: 'gateway', origin: input.origin, remember: input.remember }; + const probe = createClient(profile, { authorization: async () => `Bearer ${apiKey}` }); + try { + const me = await probe.me(); + if (me.auth.mode !== 'api_key') throw new DeweeApiError(400, 'invalid_response', 'Unexpected auth mode'); + await writeCredential(profile.id, { kind: 'api_key', apiKey }, profile.remember); + try { + await saveProfile(profile); + } catch (error) { + // Never leave a stored key without the profile that owns it. + await clearCredential(profile.id); + throw error; + } + void callBackground({ type: 'badge.refresh' }); + return { ok: true, profile, me }; + } catch (error) { + return failure(error); + } +} + +/** Saves the profile, runs the OAuth flow in the service worker, then verifies with `GET /me`. */ +export async function connectCloud(input: { profileId?: string; name: string; origin: string; remember: boolean }): Promise { + const existing = input.profileId ? (await loadSettings()).profiles.find((p) => p.id === input.profileId) : undefined; + const profile: Profile = existing + ? { ...existing, remember: input.remember } + : { id: newProfileId(), name: input.name.trim() || 'Dewee Cloud', kind: 'cloud', origin: input.origin, remember: input.remember }; + await saveProfile(profile); + const signedIn = await callBackground({ type: 'auth.signIn', profileId: profile.id }); + if (!signedIn.ok) { + if (!existing) await updateSettings((s) => withoutProfile(s, profile.id)); + return { ok: false, code: signedIn.code }; + } + try { + const me = await createClient(profile, pageAuthAdapter(profile.id)).me(); + return { ok: true, profile, me }; + } catch (error) { + return failure(error); + } +} + +export async function verifyProfile(profile: Profile): Promise<{ ok: true; me: Me } | { ok: false; code: string }> { + try { + return { ok: true, me: await createClient(profile, pageAuthAdapter(profile.id)).me() }; + } catch (error) { + return failure(error); + } +} + +/** Signs out (Cloud: revokes the token family) and clears the stored credential. */ +export async function signOutProfile(profile: Profile): Promise { + const result = await callBackground({ type: 'auth.signOut', profileId: profile.id }); + if (!result.ok) await clearCredential(profile.id); +} + +/** Removes the profile, its credential, remembered selections and host access. */ +export async function removeProfile(profile: Profile): Promise { + await signOutProfile(profile); + await updateSettings((s) => withoutProfile(s, profile.id)); + const remaining = (await loadSettings()).profiles.some((p) => p.origin === profile.origin); + if (!remaining) { + try { + await browser.permissions.remove({ origins: [originPermissionPattern(profile.origin)] }); + } catch { + // Permission may already be gone; nothing else to clean up. + } + } +} diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts new file mode 100644 index 0000000..a766484 --- /dev/null +++ b/src/shared/i18n.ts @@ -0,0 +1,412 @@ +/** + * Tiny runtime i18n (English, Vietnamese). `chrome.i18n` cannot switch language at runtime, so + * the UI keeps its own catalog; `vi` must define every key of `en` (enforced by the type). + */ +import { createContext, useContext } from 'react'; +import type { LocalePref } from './settings'; + +const en = { + 'app.title': 'Dewee', + 'app.settings': 'Settings', + 'app.online': 'Online', + 'app.offline': 'Offline', + 'app.loading': 'Loading…', + 'app.retry': 'Retry', + 'app.close': 'Close', + 'app.cancel': 'Cancel', + + 'connect.title': 'Connect to Dewee', + 'connect.intro': 'Chat with your Dewee agents from the side panel. Choose how you use Dewee.', + 'connect.cloud': 'Dewee Cloud', + 'connect.cloudHint': 'Sign in with your Dewee account. Your workspaces appear after sign-in.', + 'connect.cloudSignIn': 'Sign in with Dewee', + 'connect.selfHosted': 'Self-hosted Gateway', + 'connect.selfHostedHint': 'Use your Gateway URL and a personal API key with the operator.read and operator.write scopes. Never use the gateway master token.', + 'connect.name': 'Profile name', + 'connect.gatewayUrl': 'Gateway URL', + 'connect.apiKey': 'Personal API key', + 'connect.apiKeyHint': 'Create one with: dewee api-keys create --name chrome-extension --scopes operator.read,operator.write --tenant-id --owner-id ', + 'connect.remember': 'Remember on this device', + 'connect.rememberHint': 'Off: the credential is kept in memory and cleared when Chrome closes. On: it is stored on this device until you sign out or remove the profile.', + 'connect.submit': 'Connect', + 'connect.insecure': 'Plain HTTP is only allowed for local development on this computer. Traffic is not encrypted.', + 'connect.permissionDenied': 'Chrome did not grant access to this address, so the extension cannot reach it.', + 'connect.reconnectGateway': 'This profile needs a new API key.', + 'connect.reconnectCloud': 'Your Dewee Cloud session ended. Sign in again.', + + 'origin.invalid': 'Enter a valid URL, for example https://gateway.example.com', + 'origin.https_required': 'Use HTTPS. Plain HTTP works only for localhost.', + 'origin.credentials_in_url': 'Do not put credentials in the URL.', + + 'header.profile': 'Connection', + 'header.workspace': 'Workspace', + 'header.agent': 'Agent', + 'header.searchAgents': 'Search agents', + 'header.noAgents': 'No agents you can chat with', + 'header.superAgent': 'Super Agent', + 'header.noWorkspaces': 'No workspaces available for this account', + 'header.workspaceNotReady': 'not ready', + 'header.workspaceSuspended': 'suspended', + + 'sessions.title': 'Conversations', + 'sessions.new': 'New chat', + 'sessions.empty': 'No earlier conversations with this agent.', + 'sessions.untitled': 'Untitled conversation', + 'sessions.more': 'Show more', + 'sessions.toggle': 'Conversations', + + 'chat.empty': 'Ask {agent} anything.', + 'chat.placeholder': 'Message {agent}', + 'chat.send': 'Send', + 'chat.stop': 'Stop', + 'chat.stopped': 'Stopped', + 'chat.olderMessages': 'Load earlier messages', + 'chat.thinking': 'Thinking', + 'chat.reconciling': 'Reconnecting to the running reply…', + 'chat.offlineWaiting': 'You are offline. The reply will load when the connection is back.', + 'chat.tool.running': 'Running {name}', + 'chat.tool.done': 'Used {name}', + 'chat.tool.error': '{name} failed', + 'chat.viaAgent': 'via {agent}', + 'chat.composerHint': 'Enter to send, Shift+Enter for a new line, Esc to stop', + 'chat.retryMessage': 'Send again', + 'chat.continue': 'Continue', + 'chat.reloadConversation': 'Reload conversation', + 'chat.showConversations': 'Show conversations', + 'chat.historyFailed': 'Could not load this conversation.', + + 'error.unauthenticated': 'Your session ended. Connect again.', + 'error.token_expired': 'Your session expired. Connect again.', + 'error.token_revoked': 'Your session was revoked. Sign in again.', + 'error.api_key_rejected': 'The API key was rejected or revoked.', + 'error.not_connected': 'Not connected.', + 'error.forbidden': 'You do not have permission for this.', + 'error.owner_binding_required': 'This key is not a personal key. Create a key bound to your user and workspace.', + 'error.scope_required': 'This key is missing a required scope (operator.read / operator.write).', + 'error.setup_human_actor_required': 'Only a person with owner or admin rights can answer this card.', + 'error.membership_suspended': 'Your membership in this workspace is suspended.', + 'error.not_found': 'Not found, or you no longer have access.', + 'error.rate_limited': 'Too many requests. Try again in {seconds}s.', + 'error.conflict': 'This changed on the server. The latest state was reloaded.', + 'error.continuation_sent': 'The agent already continued after this card.', + 'error.continuation_in_progress': 'Another request is still sending the continuation of this card. Continue to try again.', + 'error.continuation_stopped': 'Stopped before the agent continued after this card. Continue when you are ready.', + 'error.continuation_unconfirmed': 'The agent may not have received the result of this card. Continue to send it; Dewee never delivers it twice.', + 'error.continuation_refused': 'The result of this card could not be delivered to the agent. Ask the agent again in the chat.', + 'error.credential_policy_revoked': 'An admin no longer allows this agent to ask for this credential. The card was closed.', + 'error.profile_not_found': 'This connection no longer exists. Add it again in settings.', + 'error.background_unavailable': 'The extension is restarting. Try again in a moment.', + 'error.no_response': 'The extension did not answer. Try again in a moment.', + 'error.internal_error': 'The extension hit an unexpected error. Try again.', + 'error.oauth_invalid_redirect': 'Sign-in returned to an unexpected address and was stopped.', + 'error.oauth_missing_code': 'Sign-in did not finish. Try again.', + 'error.oauth_access_denied': 'Sign-in was cancelled or access was denied.', + 'error.network_error': 'Cannot reach the server. Check your connection.', + 'error.timeout': 'The server did not respond in time.', + 'error.stream_stalled': 'The reply stopped arriving.', + 'error.connection_lost': 'The connection dropped during the reply.', + 'error.delivery_unconfirmed': 'Delivery could not be confirmed. The message may have reached Dewee and may still be answered, so check the conversation before sending it again.', + 'error.runtime_unavailable': 'Dewee is temporarily unavailable.', + 'error.run_in_progress': 'This conversation is still running.', + 'error.action_closed': 'This request is already closed.', + 'error.action_in_progress': 'This request is being processed.', + 'error.response_conflict': 'This request was answered differently. Reloaded.', + 'error.rotate_required': 'Confirm replacing the existing credential first.', + 'error.invalid_request': 'The request was rejected. Check the values and try again.', + 'error.invalid_response': 'The server returned an unexpected response.', + 'error.oauth_cancelled': 'Sign-in was cancelled.', + 'error.oauth_state_mismatch': 'Sign-in could not be verified. Try again.', + 'error.generic': 'Something went wrong ({code}).', + + 'recovery.review_provider_settings': 'The model provider rejected the request. A workspace admin should review provider settings.', + 'recovery.wait_and_retry': 'The model provider is rate limiting. Wait and try again.', + 'recovery.retry': 'The reply failed. Try again.', + 'recovery.contact_workspace_admin': 'The request was not accepted. Contact your workspace admin.', + 'recovery.start_new_chat': 'This conversation is too long. Start a new chat.', + 'recovery.continue_in_chat': 'The result was delivered. Continue in the chat.', + + 'card.status.pending': 'Waiting for you', + 'card.status.resolved': 'Done', + 'card.status.cancelled': 'Cancelled', + 'card.status.expired': 'Expired', + 'card.status.failed': 'Failed', + 'card.expires': 'Expires {time}', + 'card.openInDewee': 'Open Dewee to answer this request.', + 'card.submit': 'Submit', + 'card.confirm': 'Confirm', + + 'secret.destination': 'Saved to {target}', + 'secret.destinationClosed': 'For {target}. Nothing was saved.', + 'secret.create': 'Adds a new credential', + 'secret.update': 'Replaces the stored credential', + 'secret.rotate': 'Replace stored credential', + 'secret.save': 'Save securely', + 'secret.required': '{label} is required', + 'secret.privacy': 'Sent directly to Dewee\'s encrypted store. It is never shown in the chat, to the agent or in logs.', + 'secret.saved': 'Saved. The agent only receives a reference, never the value.', + 'secret.notVerified': 'Saved, but the connection could not be verified.', + 'secret.field.api_key': 'API key', + 'secret.field.token': 'Token', + 'secret.field.bot_token': 'Bot token', + 'secret.field.secret': 'Secret', + 'secret.field.password': 'Password', + 'secret.field.client_secret': 'Client secret', + 'secret.field.app_token': 'App token', + 'secret.field.name': 'Name', + 'secret.field.generic': 'Value ({key})', + + 'target.provider': 'AI provider {name}', + 'target.channel_instance': 'channel {name}', + 'target.cli_agent_credential': 'CLI agent credential {name}', + 'target.mcp_server': 'MCP server {name}', + 'target.tts': 'text-to-speech {name}', + 'target.stt': 'speech-to-text {name}', + + 'oauth.title': 'Sign in to {provider}', + 'oauth.thirdParty': 'You will sign in on {provider}\'s own site, not in this panel.', + 'oauth.openDewee': 'Open Dewee to sign in', + 'oauth.check': 'I have signed in', + 'oauth.notCompleted': 'Dewee could not confirm the sign-in yet.', + + 'choice.freeText': 'Or type your answer', + + 'settings.title': 'Dewee settings', + 'settings.profiles': 'Connections', + 'settings.addProfile': 'Add connection', + 'settings.active': 'Active', + 'settings.makeActive': 'Use', + 'settings.verify': 'Verify', + 'settings.verified': 'Connected as {user}', + 'settings.signOut': 'Sign out', + 'settings.remove': 'Remove', + 'settings.removeConfirm': 'Remove this connection and its stored credential?', + 'settings.notConnected': 'Not connected', + 'settings.preferences': 'Preferences', + 'settings.theme': 'Theme', + 'settings.theme.system': 'System', + 'settings.theme.light': 'Light', + 'settings.theme.dark': 'Dark', + 'settings.language': 'Language', + 'settings.language.auto': 'Browser default', + 'settings.privacy': 'The extension never reads the pages you visit. Credentials are kept only in this browser and sent only to the connection they belong to.', +} as const; + +export type MessageKey = keyof typeof en; + +const vi: Record = { + 'app.title': 'Dewee', + 'app.settings': 'Cài đặt', + 'app.online': 'Trực tuyến', + 'app.offline': 'Ngoại tuyến', + 'app.loading': 'Đang tải…', + 'app.retry': 'Thử lại', + 'app.close': 'Đóng', + 'app.cancel': 'Huỷ', + + 'connect.title': 'Kết nối Dewee', + 'connect.intro': 'Trò chuyện với các agent Dewee ngay trong side panel. Chọn cách bạn dùng Dewee.', + 'connect.cloud': 'Dewee Cloud', + 'connect.cloudHint': 'Đăng nhập bằng tài khoản Dewee. Workspace của bạn sẽ hiện sau khi đăng nhập.', + 'connect.cloudSignIn': 'Đăng nhập với Dewee', + 'connect.selfHosted': 'Gateway tự host', + 'connect.selfHostedHint': 'Dùng URL Gateway và API key cá nhân có scope operator.read và operator.write. Không bao giờ dùng gateway master token.', + 'connect.name': 'Tên hồ sơ', + 'connect.gatewayUrl': 'URL Gateway', + 'connect.apiKey': 'API key cá nhân', + 'connect.apiKeyHint': 'Tạo bằng: dewee api-keys create --name chrome-extension --scopes operator.read,operator.write --tenant-id --owner-id ', + 'connect.remember': 'Ghi nhớ trên thiết bị này', + 'connect.rememberHint': 'Tắt: thông tin xác thực chỉ nằm trong bộ nhớ và bị xoá khi đóng Chrome. Bật: được lưu trên thiết bị này cho đến khi bạn đăng xuất hoặc xoá hồ sơ.', + 'connect.submit': 'Kết nối', + 'connect.insecure': 'HTTP thường chỉ được phép khi phát triển trên máy này. Dữ liệu không được mã hoá.', + 'connect.permissionDenied': 'Chrome chưa cấp quyền truy cập địa chỉ này nên extension không thể kết nối.', + 'connect.reconnectGateway': 'Hồ sơ này cần API key mới.', + 'connect.reconnectCloud': 'Phiên Dewee Cloud đã kết thúc. Hãy đăng nhập lại.', + + 'origin.invalid': 'Nhập URL hợp lệ, ví dụ https://gateway.example.com', + 'origin.https_required': 'Hãy dùng HTTPS. HTTP thường chỉ dùng được cho localhost.', + 'origin.credentials_in_url': 'Không đặt thông tin xác thực trong URL.', + + 'header.profile': 'Kết nối', + 'header.workspace': 'Workspace', + 'header.agent': 'Agent', + 'header.searchAgents': 'Tìm agent', + 'header.noAgents': 'Không có agent nào bạn có thể trò chuyện', + 'header.superAgent': 'Super Agent', + 'header.noWorkspaces': 'Tài khoản này chưa có workspace khả dụng', + 'header.workspaceNotReady': 'chưa sẵn sàng', + 'header.workspaceSuspended': 'bị tạm ngưng', + + 'sessions.title': 'Cuộc trò chuyện', + 'sessions.new': 'Chat mới', + 'sessions.empty': 'Chưa có cuộc trò chuyện nào với agent này.', + 'sessions.untitled': 'Cuộc trò chuyện chưa đặt tên', + 'sessions.more': 'Xem thêm', + 'sessions.toggle': 'Cuộc trò chuyện', + + 'chat.empty': 'Hỏi {agent} bất cứ điều gì.', + 'chat.placeholder': 'Nhắn cho {agent}', + 'chat.send': 'Gửi', + 'chat.stop': 'Dừng', + 'chat.stopped': 'Đã dừng', + 'chat.olderMessages': 'Tải tin nhắn cũ hơn', + 'chat.thinking': 'Đang suy nghĩ', + 'chat.reconciling': 'Đang kết nối lại với câu trả lời đang chạy…', + 'chat.offlineWaiting': 'Bạn đang ngoại tuyến. Câu trả lời sẽ tải khi có kết nối trở lại.', + 'chat.tool.running': 'Đang chạy {name}', + 'chat.tool.done': 'Đã dùng {name}', + 'chat.tool.error': '{name} bị lỗi', + 'chat.viaAgent': 'qua {agent}', + 'chat.composerHint': 'Enter để gửi, Shift+Enter xuống dòng, Esc để dừng', + 'chat.retryMessage': 'Gửi lại', + 'chat.continue': 'Tiếp tục', + 'chat.reloadConversation': 'Tải lại cuộc trò chuyện', + 'chat.showConversations': 'Xem các cuộc trò chuyện', + 'chat.historyFailed': 'Không tải được cuộc trò chuyện này.', + + 'error.unauthenticated': 'Phiên đã kết thúc. Hãy kết nối lại.', + 'error.token_expired': 'Phiên đã hết hạn. Hãy kết nối lại.', + 'error.token_revoked': 'Phiên đã bị thu hồi. Hãy đăng nhập lại.', + 'error.api_key_rejected': 'API key bị từ chối hoặc đã bị thu hồi.', + 'error.not_connected': 'Chưa kết nối.', + 'error.forbidden': 'Bạn không có quyền thực hiện việc này.', + 'error.owner_binding_required': 'Key này không phải key cá nhân. Hãy tạo key gắn với người dùng và workspace của bạn.', + 'error.scope_required': 'Key này thiếu scope bắt buộc (operator.read / operator.write).', + 'error.setup_human_actor_required': 'Chỉ người có quyền owner hoặc admin mới trả lời được thẻ này.', + 'error.membership_suspended': 'Tư cách thành viên của bạn trong workspace này đang bị tạm ngưng.', + 'error.not_found': 'Không tìm thấy, hoặc bạn không còn quyền truy cập.', + 'error.rate_limited': 'Quá nhiều yêu cầu. Thử lại sau {seconds} giây.', + 'error.conflict': 'Dữ liệu đã thay đổi trên máy chủ. Trạng thái mới nhất đã được tải lại.', + 'error.continuation_sent': 'Agent đã tiếp tục sau thẻ này rồi.', + 'error.continuation_in_progress': 'Một yêu cầu khác vẫn đang gửi phần tiếp tục của thẻ này. Nhấn Tiếp tục để thử lại.', + 'error.continuation_stopped': 'Đã dừng trước khi agent tiếp tục sau thẻ này. Nhấn Tiếp tục khi bạn sẵn sàng.', + 'error.continuation_unconfirmed': 'Agent có thể chưa nhận được kết quả của thẻ này. Nhấn Tiếp tục để gửi; Dewee không bao giờ gửi hai lần.', + 'error.continuation_refused': 'Không thể chuyển kết quả của thẻ này đến agent. Hãy hỏi lại agent trong cuộc trò chuyện.', + 'error.credential_policy_revoked': 'Admin không còn cho phép agent này yêu cầu thông tin xác thực này. Thẻ đã được đóng.', + 'error.profile_not_found': 'Kết nối này không còn tồn tại. Hãy thêm lại trong phần cài đặt.', + 'error.background_unavailable': 'Extension đang khởi động lại. Hãy thử lại sau giây lát.', + 'error.no_response': 'Extension không phản hồi. Hãy thử lại sau giây lát.', + 'error.internal_error': 'Extension gặp lỗi không mong muốn. Hãy thử lại.', + 'error.oauth_invalid_redirect': 'Đăng nhập trả về một địa chỉ không mong đợi nên đã bị dừng.', + 'error.oauth_missing_code': 'Đăng nhập chưa hoàn tất. Hãy thử lại.', + 'error.oauth_access_denied': 'Đăng nhập đã bị huỷ hoặc quyền truy cập bị từ chối.', + 'error.network_error': 'Không kết nối được máy chủ. Kiểm tra mạng của bạn.', + 'error.timeout': 'Máy chủ không phản hồi kịp.', + 'error.stream_stalled': 'Câu trả lời đã ngừng truyền về.', + 'error.connection_lost': 'Kết nối bị ngắt trong lúc nhận câu trả lời.', + 'error.delivery_unconfirmed': 'Không thể xác nhận tin nhắn đã được gửi. Tin nhắn có thể đã đến Dewee và vẫn sẽ có câu trả lời, nên hãy kiểm tra cuộc trò chuyện trước khi gửi lại.', + 'error.runtime_unavailable': 'Dewee tạm thời không khả dụng.', + 'error.run_in_progress': 'Cuộc trò chuyện này vẫn đang chạy.', + 'error.action_closed': 'Yêu cầu này đã đóng.', + 'error.action_in_progress': 'Yêu cầu này đang được xử lý.', + 'error.response_conflict': 'Yêu cầu này đã được trả lời khác. Đã tải lại.', + 'error.rotate_required': 'Hãy xác nhận thay thế thông tin xác thực hiện có trước.', + 'error.invalid_request': 'Yêu cầu bị từ chối. Kiểm tra lại giá trị và thử lại.', + 'error.invalid_response': 'Máy chủ trả về phản hồi không mong đợi.', + 'error.oauth_cancelled': 'Đăng nhập đã bị huỷ.', + 'error.oauth_state_mismatch': 'Không xác minh được đăng nhập. Hãy thử lại.', + 'error.generic': 'Đã xảy ra lỗi ({code}).', + + 'recovery.review_provider_settings': 'Nhà cung cấp mô hình từ chối yêu cầu. Admin workspace cần kiểm tra cấu hình provider.', + 'recovery.wait_and_retry': 'Nhà cung cấp mô hình đang giới hạn tốc độ. Hãy chờ rồi thử lại.', + 'recovery.retry': 'Câu trả lời bị lỗi. Hãy thử lại.', + 'recovery.contact_workspace_admin': 'Yêu cầu không được chấp nhận. Hãy liên hệ admin workspace.', + 'recovery.start_new_chat': 'Cuộc trò chuyện quá dài. Hãy bắt đầu chat mới.', + 'recovery.continue_in_chat': 'Kết quả đã được gửi. Tiếp tục trong cuộc trò chuyện.', + + 'card.status.pending': 'Đang chờ bạn', + 'card.status.resolved': 'Hoàn tất', + 'card.status.cancelled': 'Đã huỷ', + 'card.status.expired': 'Đã hết hạn', + 'card.status.failed': 'Thất bại', + 'card.expires': 'Hết hạn {time}', + 'card.openInDewee': 'Mở Dewee để trả lời yêu cầu này.', + 'card.submit': 'Gửi', + 'card.confirm': 'Xác nhận', + + 'secret.destination': 'Lưu vào {target}', + 'secret.destinationClosed': 'Dành cho {target}. Chưa có gì được lưu.', + 'secret.create': 'Thêm thông tin xác thực mới', + 'secret.update': 'Thay thế thông tin xác thực đã lưu', + 'secret.rotate': 'Thay thế thông tin xác thực đã lưu', + 'secret.save': 'Lưu an toàn', + 'secret.required': 'Cần nhập {label}', + 'secret.privacy': 'Được gửi thẳng vào kho mã hoá của Dewee. Không bao giờ hiện trong chat, với agent hay trong log.', + 'secret.saved': 'Đã lưu. Agent chỉ nhận một tham chiếu, không bao giờ nhận giá trị.', + 'secret.notVerified': 'Đã lưu, nhưng chưa kiểm tra được kết nối.', + 'secret.field.api_key': 'API key', + 'secret.field.token': 'Token', + 'secret.field.bot_token': 'Bot token', + 'secret.field.secret': 'Secret', + 'secret.field.password': 'Mật khẩu', + 'secret.field.client_secret': 'Client secret', + 'secret.field.app_token': 'App token', + 'secret.field.name': 'Tên', + 'secret.field.generic': 'Giá trị ({key})', + + 'target.provider': 'AI provider {name}', + 'target.channel_instance': 'kênh {name}', + 'target.cli_agent_credential': 'thông tin xác thực CLI agent {name}', + 'target.mcp_server': 'MCP server {name}', + 'target.tts': 'chuyển văn bản thành giọng nói {name}', + 'target.stt': 'chuyển giọng nói thành văn bản {name}', + + 'oauth.title': 'Đăng nhập {provider}', + 'oauth.thirdParty': 'Bạn sẽ đăng nhập trên trang của chính {provider}, không phải trong panel này.', + 'oauth.openDewee': 'Mở Dewee để đăng nhập', + 'oauth.check': 'Tôi đã đăng nhập', + 'oauth.notCompleted': 'Dewee chưa xác nhận được việc đăng nhập.', + + 'choice.freeText': 'Hoặc nhập câu trả lời', + + 'settings.title': 'Cài đặt Dewee', + 'settings.profiles': 'Kết nối', + 'settings.addProfile': 'Thêm kết nối', + 'settings.active': 'Đang dùng', + 'settings.makeActive': 'Dùng', + 'settings.verify': 'Kiểm tra', + 'settings.verified': 'Đã kết nối với tư cách {user}', + 'settings.signOut': 'Đăng xuất', + 'settings.remove': 'Xoá', + 'settings.removeConfirm': 'Xoá kết nối này và thông tin xác thực đã lưu?', + 'settings.notConnected': 'Chưa kết nối', + 'settings.preferences': 'Tuỳ chọn', + 'settings.theme': 'Giao diện', + 'settings.theme.system': 'Theo hệ thống', + 'settings.theme.light': 'Sáng', + 'settings.theme.dark': 'Tối', + 'settings.language': 'Ngôn ngữ', + 'settings.language.auto': 'Theo trình duyệt', + 'settings.privacy': 'Extension không bao giờ đọc các trang bạn truy cập. Thông tin xác thực chỉ nằm trong trình duyệt này và chỉ được gửi tới đúng kết nối của nó.', +}; + +export type Locale = 'en' | 'vi'; +const catalogs: Record> = { en, vi }; + +export function resolveLocale(pref: LocalePref, browserLanguage = typeof navigator === 'undefined' ? 'en' : navigator.language): Locale { + if (pref === 'en' || pref === 'vi') return pref; + return browserLanguage.toLowerCase().startsWith('vi') ? 'vi' : 'en'; +} + +export type Translate = (key: MessageKey, vars?: Record) => string; + +export function translator(locale: Locale): Translate { + const catalog = catalogs[locale]; + return (key, vars) => { + const template = catalog[key] ?? en[key] ?? key; + return vars ? template.replace(/\{(\w+)\}/g, (match, name: string) => (name in vars ? String(vars[name]) : match)) : template; + }; +} + +export function hasMessage(key: string): key is MessageKey { + return key in en; +} + +export const I18nContext = createContext<{ t: Translate; locale: Locale }>({ t: translator('en'), locale: 'en' }); +export const useI18n = () => useContext(I18nContext); + +/** Localized text for an error code; unknown codes show a generic message with the code. */ +export function errorText(t: Translate, code: string, retryAfterSeconds?: number): string { + const key = `error.${code}`; + if (hasMessage(key)) return t(key, { seconds: retryAfterSeconds ?? 30 }); + return t('error.generic', { code }); +} diff --git a/src/shared/markdown.ts b/src/shared/markdown.ts new file mode 100644 index 0000000..70be001 --- /dev/null +++ b/src/shared/markdown.ts @@ -0,0 +1,34 @@ +/** + * Assistant text is Markdown written by a model, so it is untrusted: render with `marked`, then + * sanitize with DOMPurify. Links open in a new tab without referrer; only http(s) and mailto + * survive. Images are dropped (remote images would leak the viewer's IP and act as beacons). + */ +import DOMPurify from 'dompurify'; +import { marked } from 'marked'; + +const ALLOWED_TAGS = [ + 'a', 'b', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 's', + 'strong', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', +]; + +let hooked = false; +function ensureHooks(): void { + if (hooked) return; + hooked = true; + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName !== 'A') return; + const href = node.getAttribute('href') ?? ''; + if (!/^(https?:|mailto:)/i.test(href)) { + node.removeAttribute('href'); + return; + } + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer nofollow'); + }); +} + +export function renderMarkdown(text: string): string { + ensureHooks(); + const html = marked.parse(text, { async: false, gfm: true, breaks: true }); + return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: ['href', 'title', 'target', 'rel'], ALLOW_DATA_ATTR: false }); +} diff --git a/src/shared/origin.ts b/src/shared/origin.ts new file mode 100644 index 0000000..5dfb8a4 --- /dev/null +++ b/src/shared/origin.ts @@ -0,0 +1,34 @@ +/** + * Gateway / Cloud origin validation. Only an origin is kept (no path, query, credentials or + * fragment), HTTPS is required, and plain HTTP is accepted only for loopback hosts so local + * development works; the UI warns whenever `insecure` is true. + */ +export type OriginCheck = { ok: true; origin: string; insecure: boolean } | { ok: false; reason: 'invalid' | 'https_required' | 'credentials_in_url' }; + +/** Exactly the HTTP hosts the manifest lists in optional_host_permissions; Chrome cannot grant others. */ +const LOOPBACK = new Set(['localhost', '127.0.0.1']); + +export function isLoopbackHost(hostname: string): boolean { + return LOOPBACK.has(hostname); +} + +export function checkOrigin(input: string): OriginCheck { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return { ok: false, reason: 'invalid' }; + } + if (url.username || url.password) return { ok: false, reason: 'credentials_in_url' }; + if (url.protocol === 'https:') return { ok: true, origin: url.origin, insecure: false }; + if (url.protocol === 'http:') { + return isLoopbackHost(url.hostname) ? { ok: true, origin: url.origin, insecure: true } : { ok: false, reason: 'https_required' }; + } + return { ok: false, reason: 'invalid' }; +} + +/** Match pattern for `chrome.permissions` covering exactly one origin (any port must be kept). */ +export function originPermissionPattern(origin: string): string { + const url = new URL(origin); + return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}/*`; +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts new file mode 100644 index 0000000..2fd9e2f --- /dev/null +++ b/src/shared/settings.ts @@ -0,0 +1,123 @@ +/** + * Client preferences and connection profiles, kept in `storage.local`. Nothing here is secret: + * credentials live in `src/auth/credential-store.ts`. Remembered selections are namespaced by + * profile, then workspace, then agent, so switching never mixes histories. + */ +import { browser } from 'wxt/browser'; +import { z } from 'zod'; + +export const SETTINGS_KEY = 'settings'; + +const profileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(60), + kind: z.enum(['cloud', 'gateway']), + origin: z.string().url(), + /** Persist the credential across browser restarts (opt-in, disclosed in the UI). */ + remember: z.boolean().default(false), +}); +export type Profile = z.infer; + +const workspaceSelection = z.object({ + agentKey: z.string().optional(), + /** Last open session per agent key. */ + sessions: z.record(z.string(), z.string()).default({}), +}); +export type WorkspaceSelection = z.infer; + +const profileSelection = z.object({ + workspaceId: z.string().optional(), + workspaces: z.record(z.string(), workspaceSelection).default({}), +}); + +export const themes = ['system', 'light', 'dark'] as const; +export const localePrefs = ['auto', 'en', 'vi'] as const; +export type ThemePref = (typeof themes)[number]; +export type LocalePref = (typeof localePrefs)[number]; + +const settingsSchema = z.object({ + version: z.literal(1).default(1), + profiles: z.array(profileSchema).default([]), + activeProfileId: z.string().optional(), + selections: z.record(z.string(), profileSelection).default({}), + prefs: z + .object({ theme: z.enum(themes).default('system'), locale: z.enum(localePrefs).default('auto') }) + .default({ theme: 'system', locale: 'auto' }), +}); +export type Settings = z.infer; + +export function defaultSettings(): Settings { + return settingsSchema.parse({}); +} + +export async function loadSettings(): Promise { + const stored: unknown = (await browser.storage.local.get(SETTINGS_KEY))[SETTINGS_KEY]; + const parsed = settingsSchema.safeParse(stored ?? {}); + return parsed.success ? parsed.data : defaultSettings(); +} + +export async function saveSettings(settings: Settings): Promise { + await browser.storage.local.set({ [SETTINGS_KEY]: settingsSchema.parse(settings) }); +} + +let writes: Promise = Promise.resolve(); + +/** + * Read-modify-write helper; returns the saved value. Writes from one page run one after another so + * two quick changes (a session key and a theme) cannot overwrite each other. + */ +export function updateSettings(change: (current: Settings) => Settings): Promise { + const run = writes.then(async () => { + const next = change(await loadSettings()); + await saveSettings(next); + return next; + }); + writes = run.catch(() => undefined); + return run; +} + +export function activeProfile(settings: Settings): Profile | undefined { + return settings.profiles.find((p) => p.id === settings.activeProfileId) ?? settings.profiles[0]; +} + +export function workspaceSelectionOf(settings: Settings, profileId: string, workspaceId: string): WorkspaceSelection { + return settings.selections[profileId]?.workspaces[workspaceId] ?? { sessions: {} }; +} + +export function withWorkspaceSelection( + settings: Settings, + profileId: string, + workspaceId: string, + patch: (current: WorkspaceSelection) => WorkspaceSelection, +): Settings { + const profileSel = settings.selections[profileId] ?? { workspaces: {} }; + const current = profileSel.workspaces[workspaceId] ?? { sessions: {} }; + return { + ...settings, + selections: { + ...settings.selections, + [profileId]: { ...profileSel, workspaces: { ...profileSel.workspaces, [workspaceId]: patch(current) } }, + }, + }; +} + +export function withSelectedWorkspace(settings: Settings, profileId: string, workspaceId: string | undefined): Settings { + const profileSel = settings.selections[profileId] ?? { workspaces: {} }; + return { ...settings, selections: { ...settings.selections, [profileId]: { ...profileSel, workspaceId } } }; +} + +/** Removes a profile and every remembered selection under it. */ +export function withoutProfile(settings: Settings, profileId: string): Settings { + const { [profileId]: _removed, ...selections } = settings.selections; + const profiles = settings.profiles.filter((p) => p.id !== profileId); + return { + ...settings, + profiles, + selections, + activeProfileId: settings.activeProfileId === profileId ? profiles[0]?.id : settings.activeProfileId, + }; +} + +export function newProfileId(): string { + return `p_${crypto.randomUUID()}`; +} diff --git a/src/shared/theme.css b/src/shared/theme.css new file mode 100644 index 0000000..d0c481c --- /dev/null +++ b/src/shared/theme.css @@ -0,0 +1,201 @@ +/* Dewee palette (see nextlevelbuilder/dewee docs/design-guidelines.md), tuned for a narrow side panel. + System fonts only: the extension loads no remote assets. */ +:root { + color-scheme: light; + --canvas: #fff9f3; + --paper: #fffdf8; + --paper-2: #ffffff; + --paper-3: #f6efe6; + --hairline: #e3d8cb; + --hairline-strong: #cdbfac; + --ink: #0b1426; + --ink-muted: #344256; + --ink-soft: #6a7283; + --indigo: #1f2bff; + --electric: #4a6bff; + --on-electric: #ffffff; + --orchid: #b66bff; + --verdigris: #0c8b78; + --amber: #b66f17; + --danger: #b42318; + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 1px 2px rgba(11, 20, 38, 0.06), 0 4px 12px rgba(11, 20, 38, 0.06); + --font-body: 'Be Vietnam Pro', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --canvas: #0d121e; + --paper: #121a29; + --paper-2: #172033; + --paper-3: #1b2538; + --hairline: #26324a; + --hairline-strong: #34425f; + --ink: #f4efe8; + --ink-muted: #c9cfdb; + --ink-soft: #8e97aa; + --indigo: #b7c2ff; + --electric: #8eafff; + --on-electric: #0b1426; + --verdigris: #3cc7b0; + --amber: #e7a54a; + --danger: #ff8a7a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + color-scheme: dark; + --canvas: #0d121e; + --paper: #121a29; + --paper-2: #172033; + --paper-3: #1b2538; + --hairline: #26324a; + --hairline-strong: #34425f; + --ink: #f4efe8; + --ink-muted: #c9cfdb; + --ink-soft: #8e97aa; + --indigo: #b7c2ff; + --electric: #8eafff; + --on-electric: #0b1426; + --verdigris: #3cc7b0; + --amber: #e7a54a; + --danger: #ff8a7a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: var(--canvas); + color: var(--ink); + font: 14px/1.5 var(--font-body); +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +:focus-visible { + outline: 2px solid var(--indigo); + outline-offset: 2px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-sm); + background: var(--paper-2); + cursor: pointer; +} +.btn:hover:not(:disabled) { + border-color: var(--indigo); +} +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.btn-primary { + background: var(--electric); + border-color: var(--electric); + color: var(--on-electric); +} +.btn-danger { + color: var(--danger); +} +.btn-ghost { + border-color: transparent; + background: transparent; +} + +.field { + display: grid; + gap: 4px; +} +.field > span { + font-size: 12px; + color: var(--ink-soft); +} +.input { + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-sm); + background: var(--paper-2); +} +.input:focus { + border-color: var(--indigo); + outline: none; +} + +.label-mono { + font: 600 11px/1.4 var(--font-mono); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.notice { + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--hairline); + background: var(--paper-3); + font-size: 13px; +} +.notice-error { + border-color: var(--danger); + color: var(--danger); +} +.notice-warn { + border-color: var(--amber); +} + +.skeleton { + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--paper-3), var(--paper-2), var(--paper-3)); + background-size: 200% 100%; + animation: shimmer 1.2s infinite linear; +} +@keyframes shimmer { + to { + background-position: -200% 0; + } +} +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/src/shared/use-settings.ts b/src/shared/use-settings.ts new file mode 100644 index 0000000..b58baa3 --- /dev/null +++ b/src/shared/use-settings.ts @@ -0,0 +1,74 @@ +/** + * Settings and connection state shared by the side panel and the options page. Both pages watch + * `chrome.storage` so a change in one (new profile, sign-out, theme) shows up in the other. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { browser } from 'wxt/browser'; +import { readCredential } from '@/auth/credential-store'; +import { resolveLocale, translator, type Locale, type Translate } from './i18n'; +import { loadSettings, SETTINGS_KEY, updateSettings, type Settings, type ThemePref } from './settings'; + +export function applyTheme(theme: ThemePref, root: HTMLElement = document.documentElement): void { + if (theme === 'system') delete root.dataset.theme; + else root.dataset.theme = theme; +} + +export interface SettingsState { + settings: Settings | null; + /** Profile ids that currently hold a credential. */ + connected: Set; + update(change: (current: Settings) => Settings): Promise; + t: Translate; + locale: Locale; +} + +async function readSnapshot(): Promise<{ settings: Settings; connected: Set }> { + const settings = await loadSettings(); + const ids = await Promise.all(settings.profiles.map(async (p) => ((await readCredential(p.id)) ? p.id : null))); + return { settings, connected: new Set(ids.filter((id): id is string => id !== null)) }; +} + +export function useSettings(): SettingsState { + const [settings, setSettings] = useState(null); + const [connected, setConnected] = useState>(new Set()); + + useEffect(() => { + let live = true; + // Only the latest read may land, so an older slow read never overwrites a newer one. + let latest = 0; + const refresh = () => { + const seq = ++latest; + void readSnapshot().then((snapshot) => { + if (!live || seq !== latest) return; + setSettings(snapshot.settings); + setConnected(snapshot.connected); + }); + }; + refresh(); + const listener = (changes: Record) => { + if (Object.keys(changes).some((key) => key === SETTINGS_KEY || key.startsWith('credential:'))) refresh(); + }; + browser.storage.onChanged.addListener(listener); + return () => { + live = false; + browser.storage.onChanged.removeListener(listener); + }; + }, []); + + const theme = settings?.prefs.theme ?? 'system'; + useEffect(() => applyTheme(theme), [theme]); + + const locale = resolveLocale(settings?.prefs.locale ?? 'auto'); + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + const t = useMemo(() => translator(locale), [locale]); + + const update = useCallback(async (change: (current: Settings) => Settings) => { + const next = await updateSettings(change); + setSettings(next); + return next; + }, []); + + return { settings, connected, update, t, locale }; +} diff --git a/src/sidepanel/App.tsx b/src/sidepanel/App.tsx new file mode 100644 index 0000000..e699641 --- /dev/null +++ b/src/sidepanel/App.tsx @@ -0,0 +1,79 @@ +/** + * Side panel root. Without a profile it shows the connect screen; with a profile that lost its + * credential (expired, revoked, signed out) it asks to reconnect that same profile. + */ +import { useState } from 'react'; +import type { DeweeApiError } from '@/api/errors'; +import { clearCredential } from '@/auth/credential-store'; +import { ConnectForm } from '@/shared/ConnectForm'; +import { errorText, I18nContext, useI18n } from '@/shared/i18n'; +import { activeProfile } from '@/shared/settings'; +import { useSettings } from '@/shared/use-settings'; +import { ChatShell } from './ChatShell'; +import './sidepanel.css'; + +function Welcome({ children }: { children: React.ReactNode }) { + const { t } = useI18n(); + return ( +
    +

    Dewee

    +

    {t('connect.intro')}

    + {children} +
    + ); +} + +export function App() { + const { settings, connected, update, t, locale } = useSettings(); + const [authNotice, setAuthNotice] = useState(null); + const profile = settings ? activeProfile(settings) : undefined; + + // `useChat` reads this through a ref, so a new identity per render is harmless. + const onAuthError = (error: DeweeApiError) => { + if (!profile) return; + setAuthNotice(error.code); + // The credential no longer works for this profile; drop it so nothing keeps using it. + void clearCredential(profile.id); + }; + + let body: React.ReactNode; + if (!settings) { + body = ( +
    +
    +
    + ); + } else if (!profile) { + body = ( + + setAuthNotice(null)} /> + + ); + } else if (!connected.has(profile.id)) { + body = ( + +

    + {authNotice ? `${errorText(t, authNotice)} ` : ''} + {profile.kind === 'gateway' ? t('connect.reconnectGateway') : t('connect.reconnectCloud')} +

    + {settings.profiles.length > 1 ? ( + + ) : null} + setAuthNotice(null)} /> +
    + ); + } else { + body = ; + } + + return {body}; +} diff --git a/src/sidepanel/ChatShell.tsx b/src/sidepanel/ChatShell.tsx new file mode 100644 index 0000000..4a75646 --- /dev/null +++ b/src/sidepanel/ChatShell.tsx @@ -0,0 +1,302 @@ +/** + * Connected side panel: header (connection, workspace, agent, settings, online state), the + * conversation list and the chat. Every request is scoped to the selected profile + workspace; + * a failure is shown for that scope and never retried against another workspace or profile. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { browser } from 'wxt/browser'; +import { createClient, pageAuthAdapter } from '@/api/client-factory'; +import { isApiError, type DeweeApiError } from '@/api/errors'; +import { useChat } from '@/chat/use-chat'; +import type { RespondFn } from '@/credentials/respond-types'; +import { callBackground } from '@/protocol/messages'; +import { errorText, useI18n } from '@/shared/i18n'; +import { withSelectedWorkspace, withWorkspaceSelection, workspaceSelectionOf, type Profile, type Settings } from '@/shared/settings'; +import { AgentPicker } from './components/AgentPicker'; +import { Composer } from './components/Composer'; +import { MessageList } from './components/MessageList'; +import { SessionList } from './components/SessionList'; +import { useAgents, useSessions, useWorkspaces } from './use-directory'; + +/** Largest limit the sessions endpoint accepts. */ +const MAX_SESSION_LIMIT = 50; + +function useOnline(): boolean { + const [online, setOnline] = useState(typeof navigator === 'undefined' ? true : navigator.onLine); + useEffect(() => { + const on = () => setOnline(true); + const off = () => setOnline(false); + window.addEventListener('online', on); + window.addEventListener('offline', off); + return () => { + window.removeEventListener('online', on); + window.removeEventListener('offline', off); + }; + }, []); + return online; +} + +export function ChatShell({ + profile, + settings, + update, + onAuthError, +}: { + profile: Profile; + settings: Settings; + update(change: (s: Settings) => Settings): Promise; + onAuthError(error: DeweeApiError): void; +}) { + const { t } = useI18n(); + const online = useOnline(); + // Settings are re-read as new objects on every write (session key, theme, selection). Keying the + // client on the connection itself keeps those writes from resetting lists and running chats. + const { id: profileId, kind, origin } = profile; + const client = useMemo(() => createClient({ kind, origin }, pageAuthAdapter(profileId)), [profileId, kind, origin]); + const [showSessions, setShowSessions] = useState(false); + const [sessionLimit, setSessionLimit] = useState(20); + const composerRef = useRef(null); + + const workspaces = useWorkspaces(client, onAuthError); + const remembered = settings.selections[profile.id]?.workspaceId; + const workspaceList = workspaces.data ?? []; + const workspace = workspaceList.find((w) => w.id === remembered) ?? (remembered ? undefined : workspaceList.find((w) => w.status === 'active') ?? workspaceList[0]); + const workspaceId = workspace?.id; + // A remembered workspace that is no longer listed is reported, not silently swapped. + const workspaceMissing = Boolean(remembered && workspaces.data && !workspace); + + useEffect(() => { + if (workspaceId && workspaceId !== remembered) void update((s) => withSelectedWorkspace(s, profile.id, workspaceId)); + }, [workspaceId, remembered, profile.id, update]); + + const agents = useAgents(client, workspace?.status === 'active' ? workspaceId : undefined, onAuthError); + const selection = workspaceId ? workspaceSelectionOf(settings, profile.id, workspaceId) : { sessions: {} as Record }; + const agentList = agents.data ?? []; + const agent = agentList.find((a) => a.key === selection.agentKey) ?? agentList[0]; + const agentKey = agent?.key; + const sessionKey = agentKey ? selection.sessions[agentKey] : undefined; + const agentName = agent ? agent.name || agent.key : ''; + + const sessions = useSessions(client, workspaceId, showSessions ? agentKey : undefined, sessionLimit, onAuthError); + + const setSession = useCallback( + (key: string | undefined) => { + if (!workspaceId || !agentKey) return; + void update((s) => + withWorkspaceSelection(s, profile.id, workspaceId, (cur) => { + const next = { ...cur.sessions }; + if (key) next[agentKey] = key; + else delete next[agentKey]; + return { ...cur, agentKey, sessions: next }; + }), + ); + }, + [workspaceId, agentKey, profile.id, update], + ); + + const chat = useChat(workspaceId && agentKey ? { client, profileId, workspaceId, agentKey, sessionKey } : null, { + onSessionKey: (key) => setSession(key), + onAuthError, + }); + + const respond: RespondFn = useCallback( + async (card, body) => { + if (!workspaceId) return { ok: false, code: 'not_found' }; + // Captured when the call starts: a profile or workspace switch during it must not re-tag the continuation. + const scope = { profileId, workspaceId }; + // The conversation the card was answered in: a lost response rebuilds its continuation from + // these, since the actions API does not return the continuation's target. + const sessionKeyAtStart = chat.state.sessionKey; + const agentKeyAtStart = agentKey; + try { + const result = await client.respond(workspaceId, card.id, body); + chat.updateCard(result.action); + void callBackground({ type: 'badge.refresh' }); + if (result.continuation) void chat.continueWith(result.continuation, scope); + return { ok: true, result }; + } catch (error) { + const apiError = isApiError(error) ? error : null; + if (apiError?.status === 401) onAuthError(apiError); + // Lost response or conflict: the server state decides. Reload the card instead of re-sending. + if (apiError && (apiError.status === 0 || apiError.status === 409)) { + try { + const fresh = (await client.actions(workspaceId, { sessionKey: sessionKeyAtStart })).find((c) => c.id === card.id); + if (fresh) { + chat.updateCard(fresh); + if (apiError.status === 0 && fresh.status === 'resolved') { + if (sessionKeyAtStart && agentKeyAtStart) void chat.continueWith({ session_key: sessionKeyAtStart, agent_key: agentKeyAtStart, setup_action_id: card.id }, scope); + return { ok: true, result: { action: fresh, replayed: true } }; + } + } + } catch { + // Keep the original error below. + } + } + return { ok: false, code: apiError?.code ?? 'runtime_request_failed', retryAfterSeconds: apiError?.retryAfterSeconds }; + } + }, + [client, profileId, workspaceId, agentKey, chat, onAuthError], + ); + + const busy = chat.state.phase === 'streaming'; + const newChat = useCallback(() => { + setSession(undefined); + setShowSessions(false); + composerRef.current?.focus(); + }, [setSession]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.altKey && (event.key === 'n' || event.key === 'N')) { + event.preventDefault(); + // Same rule as the button: a running reply is stopped first, never abandoned. + if (!busy) newChat(); + } else if (event.key === '/' && document.activeElement === document.body) { + event.preventDefault(); + composerRef.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [newChat, busy]); + + const deweeUrl = workspaceId && profile.kind === 'cloud' ? `${profile.origin}/workspaces/${encodeURIComponent(workspaceId)}` : profile.origin; + const profileOptions = settings.profiles; + + return ( +
    +
    +
    + Dewee + {profileOptions.length > 1 ? ( + + ) : ( + {profile.name} + )} + + +
    +
    + {workspaces.loading ?
    : null} + {workspaceList.length ? ( + + ) : null} + {agentList.length ? ( + { + if (!workspaceId) return; + void update((s) => withWorkspaceSelection(s, profile.id, workspaceId, (cur) => ({ ...cur, agentKey: key }))); + }} + /> + ) : null} +
    + {workspaces.error ? ( +

    + {errorText(t, isApiError(workspaces.error) ? workspaces.error.code : 'runtime_request_failed')}{' '} + +

    + ) : null} + {workspaces.data && workspaceList.length === 0 ?

    {t('header.noWorkspaces')}

    : null} + {workspaceMissing ?

    {t('error.not_found')}

    : null} + {agents.error ? ( +

    + {errorText(t, isApiError(agents.error) ? agents.error.code : 'runtime_request_failed')}{' '} + +

    + ) : null} + {agents.data && agentList.length === 0 ?

    {t('header.noAgents')}

    : null} + {agentKey ? ( +
    + + + +
    + ) : null} +
    + + {showSessions && agentKey ? ( + { + setSession(key); + setShowSessions(false); + }} + onNew={newChat} + onMore={() => setSessionLimit((n) => Math.min(n + 20, MAX_SESSION_LIMIT))} + canLoadMore={sessionLimit < MAX_SESSION_LIMIT} + onRetry={sessions.reload} + /> + ) : null} + +
    + {agentKey && workspaceId ? ( + void chat.retry(message)} onLoadOlder={() => void chat.loadOlder()} onReload={() => void chat.reload()} onShowSessions={() => { + sessions.reload(); + setShowSessions(true); + }} /> + ) : agents.loading || workspaces.loading ? ( +
    +
    +
    +
    + ) : null} +
    + + {agentKey && workspaceId ? ( + chat.send(text)} + onStop={() => void chat.stop()} + /> + ) : null} +
    + ); +} diff --git a/src/sidepanel/components/AgentPicker.tsx b/src/sidepanel/components/AgentPicker.tsx new file mode 100644 index 0000000..ced7d49 --- /dev/null +++ b/src/sidepanel/components/AgentPicker.tsx @@ -0,0 +1,116 @@ +/** Searchable agent picker (combobox + listbox) with Super Agent pinned first. */ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { Agent } from '@/api/schemas'; +import { SUPER_AGENT_KEY } from '@/shared/config'; +import { useI18n } from '@/shared/i18n'; + +export function AgentPicker({ agents, value, onChange, disabled }: { agents: Agent[]; value?: string; onChange(key: string): void; disabled?: boolean }) { + const { t } = useI18n(); + const listId = useId(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + const rootRef = useRef(null); + const inputRef = useRef(null); + const selected = agents.find((a) => a.key === value); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? agents.filter((a) => `${a.name} ${a.key}`.toLowerCase().includes(q)) : agents; + }, [agents, query]); + + useEffect(() => { + if (!open) return; + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', close); + inputRef.current?.focus(); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + function choose(agent: Agent | undefined) { + if (!agent) return; + onChange(agent.key); + setOpen(false); + setQuery(''); + } + + const isSuper = (a: Agent) => a.is_super || a.key === SUPER_AGENT_KEY; + + return ( +
    + + {open ? ( +
    + { + setQuery(e.target.value); + setActive(0); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + choose(filtered[active]); + } else if (e.key === 'Escape') { + setOpen(false); + } + }} + /> +
      + {filtered.length === 0 ?
    • {t('header.noAgents')}
    • : null} + {filtered.map((agent, index) => ( +
    • setActive(index)} + onMouseDown={(e) => e.preventDefault()} + onClick={() => choose(agent)} + > + + {agent.name || agent.key} + {isSuper(agent) ? {t('header.superAgent')} : null} + + {agent.key} +
    • + ))} +
    +
    + ) : null} +
    + ); +} diff --git a/src/sidepanel/components/Composer.tsx b/src/sidepanel/components/Composer.tsx new file mode 100644 index 0000000..b1b8dd5 --- /dev/null +++ b/src/sidepanel/components/Composer.tsx @@ -0,0 +1,63 @@ +/** Message composer. Enter sends, Shift+Enter adds a line, Escape stops a running reply. */ +import { forwardRef, useState } from 'react'; +import { useI18n } from '@/shared/i18n'; + +export const Composer = forwardRef( + function Composer({ agentName, busy, disabled, onSend, onStop }, ref) { + const { t } = useI18n(); + const [text, setText] = useState(''); + + function submit() { + const value = text.trim(); + if (!value || busy || disabled) return; + if (onSend(value) === false) return; + setText(''); + } + + return ( +
    { + event.preventDefault(); + submit(); + }} + > +