From 5fea5a30955578c5dbeda3f682da860c7a036c7a Mon Sep 17 00:00:00 2001 From: Karan Singh Date: Sat, 21 Mar 2026 22:53:23 +0530 Subject: [PATCH 1/2] Add quality gates and align provider support --- .github/workflows/ci.yml | 4 +- .prettierignore | 3 + .prettierrc.json | 5 + Makefile | 8 +- README.md | 17 +- eslint.config.mjs | 37 + package-lock.json | 1724 ++++++++++++++++++++-- package.json | 13 +- src/main/context/assembler.ts | 4 +- src/main/context/notesProvider.ts | 15 +- src/main/context/todoProvider.ts | 9 +- src/main/main.ts | 188 ++- src/main/platformShell.ts | 58 +- src/main/providerService.ts | 369 +++-- src/main/providerServiceUtils.ts | 78 + src/main/providers/curatedCloudModels.ts | 10 +- src/main/providers/googleProvider.ts | 53 +- src/main/providers/ollamaProvider.ts | 123 +- src/main/providers/openaiProvider.ts | 30 +- src/main/providers/openrouterProvider.ts | 105 +- src/main/providers/perplexityProvider.ts | 12 +- src/main/secureConfig.ts | 72 +- src/main/settings.ts | 236 +++ src/main/storage.ts | 216 +-- src/main/tools/fetchUrl.ts | 13 +- src/main/tools/registry.ts | 4 +- src/main/tools/webSearch.ts | 37 +- src/preload/index.ts | 55 +- src/renderer/index.tsx | 18 +- src/shared/contracts.ts | 36 +- test/main/providerServiceUtils.test.ts | 79 + test/main/settings.test.ts | 45 + tsconfig.json | 5 +- 33 files changed, 2971 insertions(+), 710 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 eslint.config.mjs create mode 100644 src/main/providerServiceUtils.ts create mode 100644 src/main/settings.ts create mode 100644 test/main/providerServiceUtils.test.ts create mode 100644 test/main/settings.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0abf61..d77e761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: pull_request: jobs: - typecheck: + quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,7 +17,7 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci - - run: npm run typecheck + - run: npm test package-macos: runs-on: macos-14 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a980f1b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +.webpack +node_modules +out diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..2f34662 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "none" +} diff --git a/Makefile b/Makefile index ed7e7f2..0ba0592 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup dev start test check typecheck clean clean-dev clean-all package package-mac make make-mac install uninstall reinstall open-data create-release deploy-release release +.PHONY: help setup dev start lint test check typecheck clean clean-dev clean-all package package-mac make make-mac install uninstall reinstall open-data create-release deploy-release release help: @echo "Robin - Available commands:" @@ -6,7 +6,8 @@ help: @echo "Development:" @echo " make setup - Check local prerequisites" @echo " make dev - Start Robin in development mode" - @echo " make test - Run the current smoke gate (typecheck)" + @echo " make lint - Run ESLint across src/ and test/" + @echo " make test - Run lint, typecheck, unit tests, and format checks" @echo " make clean - Remove build artifacts" @echo " make clean-dev - Remove app data and stop Robin" @echo " make clean-all - Remove artifacts and local app data" @@ -37,6 +38,9 @@ dev: start: @./scripts/npmw run start +lint: + @./scripts/npmw run lint + typecheck: @./scripts/npmw run typecheck diff --git a/README.md b/README.md index 0ebe14e..001d964 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,19 @@ npm start npm run typecheck ``` +5. Run the full quality gate: + +```bash +npm test +``` + ## Common Commands ```bash make help make setup make dev +make lint make test make package make install @@ -57,6 +64,7 @@ If you prefer npm directly: ```bash npm run setup npm run dev +npm run lint npm run test npm run package:mac npm run make:mac @@ -86,10 +94,11 @@ After bootstrap, the repo-level wrappers [scripts/nodew](/Users/karansingh/proje ## Testing -Robin currently has a strong smoke-test workflow rather than a full automated test suite. +Robin now has a baseline automated quality gate plus a manual desktop smoke pass. 1. Run `npm install` 2. Run `npm run test` + This executes ESLint, `tsc --noEmit`, the Node-based unit tests under [test](/Users/karansingh/projects/robin/test), and Prettier checks. 3. Run `npm run dev` 4. Verify manually: - Tray icon appears @@ -167,6 +176,7 @@ Robin uses the standard Electron architecture with a **main process** (Node.js) ### Local-first persistence All data is stored as JSON files in Electron's `userData` directory: + - `settings.json` — app config, provider preferences, model selections - `threads.json` — full conversation history with messages and attachments - `todos.json` — todo items with ordering @@ -176,8 +186,8 @@ API keys are encrypted at rest using Electron's `safeStorage` API (macOS Keychai ### Multi-provider abstraction Robin supports multiple AI providers through a uniform streaming interface: + - **OpenAI** — GPT models with mode support (chat/responses) -- **Anthropic** — Claude models - **Google** — Gemini models with native image support - **Perplexity** — Search-grounded answers with citations - **OpenRouter** — Proxy to any model via user-provided model IDs @@ -188,6 +198,7 @@ Each provider implements `streamReply()` with delta callbacks. The `ProviderServ ### Context management Before sending messages to any provider, Robin optimises the payload: + 1. **Image stripping** — only the most recent user message retains image attachments. Older images remain in the thread for display but are excluded from API calls to avoid payload bloat. 2. **Context truncation** — if total message content exceeds ~100K characters (~25K tokens), the oldest messages are dropped while preserving at least the last 4 messages (2 turns). 3. **Model-agnostic threads** — threads store raw messages without model metadata. The model selection is applied at call time, so you can switch providers mid-conversation. @@ -198,6 +209,6 @@ The sidebar uses a 2x2 navigation grid (Chats, Todos, Notes, Calendar) that swit ## Next Steps -- Add real renderer/main-process automated tests +- Add Electron-level integration tests for IPC and window lifecycle - Add a richer settings surface for provider management - Add update distribution after the first beta diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ddcc5b2 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,37 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [".webpack/**", "node_modules/**", "out/**"] + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/renderer/**/*.{ts,tsx}"], + languageOptions: { + globals: { + ...globals.browser + } + } + }, + { + files: [ + "src/main/**/*.{ts,tsx}", + "src/preload/**/*.{ts,tsx}", + "src/shared/**/*.{ts,tsx}", + "test/**/*.ts" + ], + languageOptions: { + globals: { + ...globals.node + } + } + }, + { + rules: { + "@typescript-eslint/no-explicit-any": "off" + } + } +); diff --git a/package-lock.json b/package-lock.json index 47763ca..e9fa34c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { + "@fontsource/capriola": "^5.2.7", "@fontsource/dm-sans": "^5.2.8", "@fontsource/gochi-hand": "^5.2.8", "@hugeicons/core-free-icons": "^4.0.0", @@ -27,6 +28,7 @@ "@electron-forge/maker-squirrel": "7.10.2", "@electron-forge/maker-zip": "7.10.2", "@electron-forge/plugin-webpack": "7.10.2", + "@eslint/js": "^9.38.0", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -34,13 +36,18 @@ "autoprefixer": "^10.4.0", "css-loader": "^7.0.0", "electron": "41.0.2", + "eslint": "^9.38.0", + "globals": "^16.4.0", "node-loader": "^2.1.0", "postcss": "^8.5.0", "postcss-loader": "^8.1.0", + "prettier": "^3.6.2", "style-loader": "^4.0.0", "tailwindcss": "^3.4.0", "ts-loader": "^9.5.0", - "typescript": "^5.8.0" + "tsx": "^4.20.6", + "typescript": "^5.8.0", + "typescript-eslint": "^8.46.2" } }, "node_modules/@alloc/quick-lru": { @@ -888,6 +895,638 @@ "node": ">=14.14" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/capriola": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/capriola/-/capriola-5.2.7.tgz", + "integrity": "sha512-dVcZ+wHI50326I9w9CrXWE+At0A5FGSeCKsySteIM80nIzt+2sHzZ9k2HIxE8tzU6ZO+fNSMwOZRhb+nBymqxw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/dm-sans": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", @@ -928,6 +1567,58 @@ "react": ">=16.0.0" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/checkbox": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz", @@ -1794,127 +2485,409 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dev": true, "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, "peerDependencies": { - "@types/react": "^19.2.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/express": "*" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@types/mime": "^1", - "@types/node": "*" + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "node_modules/@typescript-eslint/utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/@ungap/structured-clone": { @@ -2203,6 +3176,16 @@ "acorn": "^8.14.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-private-class-elements": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/acorn-private-class-elements/-/acorn-private-class-elements-1.0.0.tgz", @@ -3730,6 +4713,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-gateway": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", @@ -4819,80 +5809,299 @@ "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "es-errors": "^1.3.0" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", - "optional": true + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": ">=6" + "node": ">=10.13.0" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=8.0.0" + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, "node_modules/esrecurse": { @@ -4945,6 +6154,16 @@ "dev": true, "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5262,6 +6481,20 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -5312,6 +6545,19 @@ "pend": "~1.2.0" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -5423,6 +6669,27 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/flora-colossus": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz", @@ -5752,6 +7019,19 @@ "node": ">=6" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5829,6 +7109,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -6377,6 +7670,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/image-size": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", @@ -6892,6 +8195,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -6978,6 +8288,20 @@ "shell-quote": "^1.8.3" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7165,6 +8489,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8670,6 +10001,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -9063,6 +10401,24 @@ } } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -9805,6 +11161,16 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -9915,6 +11281,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -10391,6 +11767,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -11302,6 +12688,19 @@ "node": ">=6" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-outer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", @@ -11870,6 +13269,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -11915,6 +13327,39 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -11956,6 +13401,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -12150,6 +13619,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/username": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/username/-/username-5.1.0.tgz", @@ -12521,7 +14000,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index cb8e806..be19021 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,12 @@ "dev": "bash ./scripts/dev.sh", "dev:raw": "electron-forge start", "start": "npm run dev", + "lint": "eslint src test --max-warnings=0", "typecheck": "tsc --noEmit", - "check": "npm run typecheck", + "test:unit": "node --import tsx --test \"test/**/*.test.ts\"", + "format": "prettier --write \"{src,test}/**/*.{ts,tsx}\" \"*.{js,json,md,mjs}\"", + "format:check": "prettier --check \"{src,test}/**/*.{ts,tsx}\" \"*.{js,json,md,mjs}\"", + "check": "npm run lint && npm run typecheck && npm run test:unit && npm run format:check", "test": "npm run check", "clean": "bash ./scripts/clean-artifacts.sh", "clean:app": "bash ./scripts/clean-dev.sh", @@ -36,6 +40,7 @@ "author": "Karan Singh", "license": "MIT", "dependencies": { + "@fontsource/capriola": "^5.2.7", "@fontsource/dm-sans": "^5.2.8", "@fontsource/gochi-hand": "^5.2.8", "@hugeicons/core-free-icons": "^4.0.0", @@ -54,6 +59,7 @@ "@electron-forge/maker-squirrel": "7.10.2", "@electron-forge/maker-zip": "7.10.2", "@electron-forge/plugin-webpack": "7.10.2", + "@eslint/js": "^9.38.0", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -61,12 +67,17 @@ "autoprefixer": "^10.4.0", "css-loader": "^7.0.0", "electron": "41.0.2", + "eslint": "^9.38.0", + "globals": "^16.4.0", "node-loader": "^2.1.0", "postcss": "^8.5.0", "postcss-loader": "^8.1.0", + "prettier": "^3.6.2", "style-loader": "^4.0.0", "tailwindcss": "^3.4.0", + "tsx": "^4.20.6", "ts-loader": "^9.5.0", + "typescript-eslint": "^8.46.2", "typescript": "^5.8.0" } } diff --git a/src/main/context/assembler.ts b/src/main/context/assembler.ts index 1fe6ac8..e760f56 100644 --- a/src/main/context/assembler.ts +++ b/src/main/context/assembler.ts @@ -41,7 +41,9 @@ export async function buildSystemPrompt( ]; if (filled.length > 0) { - parts.push("Use the following context about the user when relevant to their question:"); + parts.push( + "Use the following context about the user when relevant to their question:" + ); parts.push(...filled); } diff --git a/src/main/context/notesProvider.ts b/src/main/context/notesProvider.ts index 7ee7a4c..6227a2c 100644 --- a/src/main/context/notesProvider.ts +++ b/src/main/context/notesProvider.ts @@ -35,11 +35,16 @@ export class NotesContextProvider implements ContextProvider { return { note, score }; }); - const relevant = keywords.length > 0 - ? scored.filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score) - : scored; - - const toDisplay = relevant.slice(0, MAX_NOTES_IN_CONTEXT).map((entry) => entry.note); + const relevant = + keywords.length > 0 + ? scored + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score) + : scored; + + const toDisplay = relevant + .slice(0, MAX_NOTES_IN_CONTEXT) + .map((entry) => entry.note); if (toDisplay.length === 0) return ""; const sections = toDisplay.map((note) => { diff --git a/src/main/context/todoProvider.ts b/src/main/context/todoProvider.ts index 76f8813..70efd9e 100644 --- a/src/main/context/todoProvider.ts +++ b/src/main/context/todoProvider.ts @@ -23,9 +23,12 @@ export class TodoContextProvider implements ContextProvider { .split(/\s+/) .filter((w) => w.length > 1); - const matched = keywords.length > 0 - ? todos.filter((t) => keywords.some((kw) => t.title.toLowerCase().includes(kw))) - : []; + const matched = + keywords.length > 0 + ? todos.filter((t) => + keywords.some((kw) => t.title.toLowerCase().includes(kw)) + ) + : []; const toDisplay = matched.length > 0 ? matched : todos; const lines = toDisplay diff --git a/src/main/main.ts b/src/main/main.ts index 310f024..b41f1d5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,7 +4,13 @@ import { AppStorage } from "./storage"; import { SecureConfig } from "./secureConfig"; import { PlatformShell } from "./platformShell"; import { ProviderService } from "./providerService"; -import { ChatStreamEvent, ChatStreamRequest, CloudProviderId, SaveConfigInput, UpdateCheckResult } from "../shared/contracts"; +import { + ChatStreamEvent, + ChatStreamRequest, + CloudProviderId, + SaveConfigInput, + UpdateCheckResult +} from "../shared/contracts"; const IPC_CHANNELS = { togglePanel: "app:toggle-panel", @@ -67,10 +73,14 @@ function resolveUpdatesRepo(): string { return DEFAULT_UPDATES_REPO; } -function findPreferredDownloadUrl(assets: Array<{ browser_download_url?: string; name?: string }>): string | undefined { +function findPreferredDownloadUrl( + assets: Array<{ browser_download_url?: string; name?: string }> +): string | undefined { const candidates = assets .map((asset) => asset.browser_download_url) - .filter((url): url is string => typeof url === "string" && url.trim().length > 0); + .filter( + (url): url is string => typeof url === "string" && url.trim().length > 0 + ); if (candidates.length === 0) { return undefined; @@ -79,39 +89,48 @@ function findPreferredDownloadUrl(assets: Array<{ browser_download_url?: string; const lowered = candidates.map((url) => ({ url, lower: url.toLowerCase() })); if (process.platform === "darwin") { - return lowered.find((entry) => entry.lower.endsWith(".dmg"))?.url - ?? lowered.find((entry) => entry.lower.endsWith(".zip"))?.url - ?? lowered[0].url; + return ( + lowered.find((entry) => entry.lower.endsWith(".dmg"))?.url ?? + lowered.find((entry) => entry.lower.endsWith(".zip"))?.url ?? + lowered[0].url + ); } if (process.platform === "win32") { - return lowered.find((entry) => entry.lower.endsWith(".exe"))?.url - ?? lowered.find((entry) => entry.lower.endsWith(".msi"))?.url - ?? lowered[0].url; + return ( + lowered.find((entry) => entry.lower.endsWith(".exe"))?.url ?? + lowered.find((entry) => entry.lower.endsWith(".msi"))?.url ?? + lowered[0].url + ); } - return lowered.find((entry) => entry.lower.endsWith(".appimage"))?.url - ?? lowered.find((entry) => entry.lower.endsWith(".deb"))?.url - ?? lowered.find((entry) => entry.lower.endsWith(".rpm"))?.url - ?? lowered[0].url; + return ( + lowered.find((entry) => entry.lower.endsWith(".appimage"))?.url ?? + lowered.find((entry) => entry.lower.endsWith(".deb"))?.url ?? + lowered.find((entry) => entry.lower.endsWith(".rpm"))?.url ?? + lowered[0].url + ); } async function checkForUpdates(): Promise { const currentVersion = normalizeVersion(app.getVersion()); const repo = resolveUpdatesRepo(); - const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { - method: "GET", - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "Robin-App" + const response = await fetch( + `https://api.github.com/repos/${repo}/releases/latest`, + { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "Robin-App" + } } - }); + ); if (!response.ok) { throw new Error(`Could not check updates (${response.status}).`); } - const payload = await response.json() as { + const payload = (await response.json()) as { tag_name?: string; html_url?: string; published_at?: string; @@ -127,7 +146,10 @@ async function checkForUpdates(): Promise { updateAvailable, releaseUrl: payload.html_url, downloadUrl: findPreferredDownloadUrl(assets), - publishedAt: typeof payload.published_at === "string" ? payload.published_at : undefined, + publishedAt: + typeof payload.published_at === "string" + ? payload.published_at + : undefined, checkedAt: new Date().toISOString() }; } @@ -149,7 +171,8 @@ function toDisplayName(raw: string): string { function getProfileName(): string { try { - const username = os.userInfo().username || process.env.USER || process.env.USERNAME || ""; + const username = + os.userInfo().username || process.env.USER || process.env.USERNAME || ""; return toDisplayName(username); } catch { return "there"; @@ -252,48 +275,105 @@ async function bootstrap(): Promise { ipcMain.handle(IPC_CHANNELS.profile, async () => ({ name: getProfileName() })); - ipcMain.handle(IPC_CHANNELS.version, async () => normalizeVersion(app.getVersion())); + ipcMain.handle(IPC_CHANNELS.version, async () => + normalizeVersion(app.getVersion()) + ); ipcMain.handle(IPC_CHANNELS.checkUpdates, async () => checkForUpdates()); ipcMain.handle(IPC_CHANNELS.openExternal, async (_event, url: string) => { await electronShell.openExternal(url); }); ipcMain.handle(IPC_CHANNELS.listThreads, async () => storage.listThreads()); - ipcMain.handle(IPC_CHANNELS.loadThread, async (_event, id: string) => storage.loadThread(id)); - ipcMain.handle(IPC_CHANNELS.deleteThread, async (_event, id: string) => storage.deleteThread(id)); - ipcMain.handle(IPC_CHANNELS.stopStream, async (_event, payload?: { threadId?: string }) => { - await storage.finalizeStreamingMessages(payload?.threadId); - }); - ipcMain.handle(IPC_CHANNELS.providerStatus, async () => providerService.getStatus()); - ipcMain.handle(IPC_CHANNELS.saveConfig, async (_event, config: SaveConfigInput) => providerService.saveConfig(config)); - ipcMain.handle(IPC_CHANNELS.listCloudModels, async (_event, provider: CloudProviderId) => { - return providerService.listCloudModels(provider); - }); - ipcMain.handle(IPC_CHANNELS.ollamaDetect, async () => providerService.detectOllama()); - ipcMain.handle(IPC_CHANNELS.ollamaCatalog, async (_event, limit?: number) => providerService.listOllamaCatalog(limit)); - ipcMain.handle(IPC_CHANNELS.ollamaPull, async (_event, model: string) => providerService.pullOllamaModel(model)); - ipcMain.handle(IPC_CHANNELS.ollamaDelete, async (_event, model: string) => providerService.deleteOllamaModel(model)); + ipcMain.handle(IPC_CHANNELS.loadThread, async (_event, id: string) => + storage.loadThread(id) + ); + ipcMain.handle(IPC_CHANNELS.deleteThread, async (_event, id: string) => + storage.deleteThread(id) + ); + ipcMain.handle( + IPC_CHANNELS.stopStream, + async (_event, payload?: { threadId?: string }) => { + await storage.finalizeStreamingMessages(payload?.threadId); + } + ); + ipcMain.handle(IPC_CHANNELS.providerStatus, async () => + providerService.getStatus() + ); + ipcMain.handle( + IPC_CHANNELS.saveConfig, + async (_event, config: SaveConfigInput) => + providerService.saveConfig(config) + ); + ipcMain.handle( + IPC_CHANNELS.listCloudModels, + async (_event, provider: CloudProviderId) => { + return providerService.listCloudModels(provider); + } + ); + ipcMain.handle(IPC_CHANNELS.ollamaDetect, async () => + providerService.detectOllama() + ); + ipcMain.handle(IPC_CHANNELS.ollamaCatalog, async (_event, limit?: number) => + providerService.listOllamaCatalog(limit) + ); + ipcMain.handle(IPC_CHANNELS.ollamaPull, async (_event, model: string) => + providerService.pullOllamaModel(model) + ); + ipcMain.handle(IPC_CHANNELS.ollamaDelete, async (_event, model: string) => + providerService.deleteOllamaModel(model) + ); ipcMain.handle(IPC_CHANNELS.todosList, async () => storage.listTodos()); - ipcMain.handle(IPC_CHANNELS.todosCreate, async (_event, title: string) => storage.createTodo(title)); - ipcMain.handle(IPC_CHANNELS.todosUpdate, async (_event, id: string, changes: Partial<{ title: string; completed: boolean; order: number }>) => storage.updateTodo(id, changes)); - ipcMain.handle(IPC_CHANNELS.todosReorder, async (_event, orderedIds: string[]) => storage.reorderTodos(orderedIds)); - ipcMain.handle(IPC_CHANNELS.todosDelete, async (_event, id: string) => storage.deleteTodo(id)); + ipcMain.handle(IPC_CHANNELS.todosCreate, async (_event, title: string) => + storage.createTodo(title) + ); + ipcMain.handle( + IPC_CHANNELS.todosUpdate, + async ( + _event, + id: string, + changes: Partial<{ title: string; completed: boolean; order: number }> + ) => storage.updateTodo(id, changes) + ); + ipcMain.handle( + IPC_CHANNELS.todosReorder, + async (_event, orderedIds: string[]) => storage.reorderTodos(orderedIds) + ); + ipcMain.handle(IPC_CHANNELS.todosDelete, async (_event, id: string) => + storage.deleteTodo(id) + ); ipcMain.handle(IPC_CHANNELS.notesList, async () => storage.listNotes()); - ipcMain.handle(IPC_CHANNELS.notesCreate, async (_event, title: string) => storage.createNote(title)); - ipcMain.handle(IPC_CHANNELS.notesUpdate, async (_event, id: string, changes: Partial<{ title: string; content: string }>) => storage.updateNote(id, changes)); - ipcMain.handle(IPC_CHANNELS.notesDelete, async (_event, id: string) => storage.deleteNote(id)); - - ipcMain.handle(IPC_CHANNELS.startStream, async (event, request: ChatStreamRequest) => { - const sender = event.sender; - void providerService.streamChat(request, (streamEvent: ChatStreamEvent) => { - if (!sender.isDestroyed()) { - sender.send(IPC_CHANNELS.streamEvent, streamEvent); - } - }); - return request.streamId ?? null; - }); + ipcMain.handle(IPC_CHANNELS.notesCreate, async (_event, title: string) => + storage.createNote(title) + ); + ipcMain.handle( + IPC_CHANNELS.notesUpdate, + async ( + _event, + id: string, + changes: Partial<{ title: string; content: string }> + ) => storage.updateNote(id, changes) + ); + ipcMain.handle(IPC_CHANNELS.notesDelete, async (_event, id: string) => + storage.deleteNote(id) + ); + + ipcMain.handle( + IPC_CHANNELS.startStream, + async (event, request: ChatStreamRequest) => { + const sender = event.sender; + void providerService.streamChat( + request, + (streamEvent: ChatStreamEvent) => { + if (!sender.isDestroyed()) { + sender.send(IPC_CHANNELS.streamEvent, streamEvent); + } + } + ); + return request.streamId ?? null; + } + ); app.on("second-instance", () => { shell.togglePanel(); diff --git a/src/main/platformShell.ts b/src/main/platformShell.ts index aa5fa95..4e12120 100644 --- a/src/main/platformShell.ts +++ b/src/main/platformShell.ts @@ -9,7 +9,9 @@ export interface PlatformShellOptions { trayTitle?: string; } -function trimTransparentPadding(image: Electron.NativeImage): Electron.NativeImage { +function trimTransparentPadding( + image: Electron.NativeImage +): Electron.NativeImage { const { width, height } = image.getSize(); if (width <= 0 || height <= 0) { return image; @@ -47,9 +49,18 @@ function trimTransparentPadding(image: Electron.NativeImage): Electron.NativeIma function createTrayImage() { const assetCandidates = [ - { path: path.join(app.getAppPath(), "assets", "trayTemplate.png"), template: true }, - { path: path.join(app.getAppPath(), "assets", "image.png"), template: false }, - { path: path.join(process.cwd(), "assets", "trayTemplate.png"), template: true }, + { + path: path.join(app.getAppPath(), "assets", "trayTemplate.png"), + template: true + }, + { + path: path.join(app.getAppPath(), "assets", "image.png"), + template: false + }, + { + path: path.join(process.cwd(), "assets", "trayTemplate.png"), + template: true + }, { path: path.join(process.cwd(), "assets", "image.png"), template: false } ]; @@ -59,10 +70,15 @@ function createTrayImage() { const trimmedImage = trimTransparentPadding(image); const size = trimmedImage.getSize(); const targetHeight = 18; - const targetWidth = size.width > 0 && size.height > 0 - ? Math.max(18, Math.round((size.width / size.height) * targetHeight)) - : 18; - const resizedImage = trimmedImage.resize({ width: targetWidth, height: targetHeight, quality: "best" }); + const targetWidth = + size.width > 0 && size.height > 0 + ? Math.max(18, Math.round((size.width / size.height) * targetHeight)) + : 18; + const resizedImage = trimmedImage.resize({ + width: targetWidth, + height: targetHeight, + quality: "best" + }); if (process.platform === "darwin" && candidate.template) { resizedImage.setTemplateImage(true); } @@ -77,7 +93,9 @@ function createTrayImage() { `.trim(); const fallbackImage = nativeImage - .createFromDataURL(`data:image/svg+xml;base64,${Buffer.from(fallbackSvg).toString("base64")}`) + .createFromDataURL( + `data:image/svg+xml;base64,${Buffer.from(fallbackSvg).toString("base64")}` + ) .resize({ width: 18, height: 18, quality: "best" }); if (process.platform === "darwin") { @@ -96,7 +114,7 @@ export class PlatformShell { private pendingTrayBounds?: Electron.Rectangle; private lastTrayToggleAt = 0; - constructor(private readonly options: PlatformShellOptions) { } + constructor(private readonly options: PlatformShellOptions) {} create(): BrowserWindow { this.panel = new BrowserWindow({ @@ -160,7 +178,10 @@ export class PlatformShell { this.tray.setTitle(this.options.trayTitle); } const trayMenu = Menu.buildFromTemplate([ - { label: "Open Robin", click: () => this.openPanel(this.tray?.getBounds()) }, + { + label: "Open Robin", + click: () => this.openPanel(this.tray?.getBounds()) + }, { type: "separator" }, { label: "Quit", role: "quit" } ]); @@ -175,7 +196,9 @@ export class PlatformShell { }); } else { this.tray.setContextMenu(trayMenu); - this.tray.on("click", (_event, bounds) => this.togglePanelFromTray(bounds)); + this.tray.on("click", (_event, bounds) => + this.togglePanelFromTray(bounds) + ); } this.shortcut = this.options.defaultShortcut; @@ -183,7 +206,9 @@ export class PlatformShell { return this.panel; } - async updateShortcut(shortcut: string): Promise<{ success: boolean; shortcut: string }> { + async updateShortcut( + shortcut: string + ): Promise<{ success: boolean; shortcut: string }> { const success = await this.options.onShortcutChange(shortcut); if (success) { this.shortcut = shortcut; @@ -293,7 +318,7 @@ export class PlatformShell { } if (process.platform === "darwin" && trayBounds) { - const { width, height } = this.panel.getBounds(); + const { width } = this.panel.getBounds(); const display = screen.getDisplayNearestPoint({ x: Math.round(trayBounds.x), y: Math.round(trayBounds.y) @@ -301,7 +326,10 @@ export class PlatformShell { const x = Math.round(trayBounds.x + trayBounds.width / 2 - width / 2); const y = Math.round(trayBounds.y + trayBounds.height); this.panel.setPosition( - Math.min(Math.max(x, display.workArea.x + 12), display.workArea.x + display.workArea.width - width - 12), + Math.min( + Math.max(x, display.workArea.x + 12), + display.workArea.x + display.workArea.width - width - 12 + ), y ); this.panel.show(); diff --git a/src/main/providerService.ts b/src/main/providerService.ts index 46b0a74..385f39d 100644 --- a/src/main/providerService.ts +++ b/src/main/providerService.ts @@ -15,7 +15,8 @@ import { SaveConfigInput } from "../shared/contracts"; import { SecureConfig } from "./secureConfig"; -import { AppStorage, buildThreadTitle, SettingsData } from "./storage"; +import { SettingsData } from "./settings"; +import { AppStorage, buildThreadTitle } from "./storage"; import { OllamaProvider } from "./providers/ollamaProvider"; import { OpenAIProvider } from "./providers/openaiProvider"; import { PerplexityProvider } from "./providers/perplexityProvider"; @@ -25,8 +26,17 @@ import { CURATED_CLOUD_MODELS } from "./providers/curatedCloudModels"; import { TodoContextProvider } from "./context/todoProvider"; import { NotesContextProvider } from "./context/notesProvider"; import { buildSystemPrompt } from "./context/assembler"; -import { ToolRound, StreamReplyResult, ToolExecutor } from "./tools/types"; -import { buildToolExecutors, getToolDefinitions, executeToolCalls } from "./tools/registry"; +import { + parseActions, + prepareMessagesForAPI, + truncateContext +} from "./providerServiceUtils"; +import { ToolRound, StreamReplyResult } from "./tools/types"; +import { + buildToolExecutors, + getToolDefinitions, + executeToolCalls +} from "./tools/registry"; import os from "node:os"; import { createHash } from "node:crypto"; @@ -57,7 +67,12 @@ function resolveProviderSettings(settings: SettingsData | undefined) { cloud: { activeProvider: "openai" as CloudProviderId, selectedModels: {} as Partial>, - catalogCache: {} as Partial> + catalogCache: {} as Partial< + Record< + CloudProviderId, + { fetchedAt: string; models: CloudModelCatalogResult["models"] } + > + > }, perplexity: { model: "openai/gpt-5-mini", preset: "pro-search" }, ollama: { baseUrl: "http://localhost:11434", model: "" } @@ -80,60 +95,10 @@ function resolveProviderSettings(settings: SettingsData | undefined) { }; } -function prepareMessagesForAPI(messages: ChatMessage[]): ChatMessage[] { - let lastUserIndex = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") { - lastUserIndex = i; - break; - } - } - - return messages.map((msg, i) => { - if (msg.role === "user" && i !== lastUserIndex && msg.attachments?.length) { - return { ...msg, attachments: undefined }; - } - return msg; - }); -} - -interface TodoAction { - type: "create_todo" | "complete_todo" | "uncomplete_todo"; - id?: string; - title?: string; -} - -function parseActions(content: string): { cleanContent: string; actions: TodoAction[] } { - const actionBlocks = content.match(/[\s\S]*?<\/action>/g) ?? []; - const actions: TodoAction[] = []; - for (const block of actionBlocks) { - const json = block.replace(/<\/?action>/g, "").trim(); - try { - const parsed = JSON.parse(json) as TodoAction; - if (parsed.type === "create_todo" || parsed.type === "complete_todo" || parsed.type === "uncomplete_todo") { - actions.push(parsed); - } - } catch { - // ignore malformed blocks - } - } - const cleanContent = content.replace(/[\s\S]*?<\/action>/g, "").trimEnd(); - return { cleanContent, actions }; -} - -function truncateContext(messages: ChatMessage[], maxChars = 100_000): ChatMessage[] { - let total = messages.reduce((sum, m) => sum + m.content.length, 0); - const result = [...messages]; - while (total > maxChars && result.length > 4) { - const removed = result.shift()!; - total -= removed.content.length; - } - return result; -} - const MODEL_SETUP_WARNING = "You need to configure a model to use Robin. Download a local model or add a cloud provider key in Settings."; -const LOCAL_IMAGE_UNSUPPORTED_WARNING = "Image input is not supported in Local mode yet. Switch to a cloud model."; +const LOCAL_IMAGE_UNSUPPORTED_WARNING = + "Image input is not supported in Local mode yet. Switch to a cloud model."; export class ProviderService { private readonly ollama = new OllamaProvider(); @@ -141,13 +106,18 @@ export class ProviderService { private readonly perplexity = new PerplexityProvider(); private readonly google = new GoogleProvider(); private readonly openrouter = new OpenRouterProvider(); - private readonly providerKeyValidationCache = new Map(); + private readonly providerKeyValidationCache = new Map< + CloudProviderId, + { + keyHash: string; + valid: boolean; + checkedAt: number; + } + >(); - private readonly contextProviders: Array; + private readonly contextProviders: Array< + import("./context/types").ContextProvider + >; constructor( private readonly storage: AppStorage, @@ -163,7 +133,11 @@ export class ProviderService { return createHash("sha256").update(value).digest("hex"); } - private async fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 8000): Promise { + private async fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = 8000 + ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { @@ -176,15 +150,21 @@ export class ProviderService { } } - private async validateProviderKeyRemote(provider: CloudProviderId, apiKey: string): Promise { + private async validateProviderKeyRemote( + provider: CloudProviderId, + apiKey: string + ): Promise { try { if (provider === "openai") { - const response = await this.fetchWithTimeout("https://api.openai.com/v1/models", { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}` + const response = await this.fetchWithTimeout( + "https://api.openai.com/v1/models", + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}` + } } - }); + ); return response.ok; } @@ -196,34 +176,29 @@ export class ProviderService { return response.ok; } - if (provider === "anthropic") { - const response = await this.fetchWithTimeout("https://api.anthropic.com/v1/models", { - method: "GET", - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01" - } - }); - return response.ok; - } - if (provider === "perplexity") { - const response = await this.fetchWithTimeout("https://api.perplexity.ai/models", { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}` + const response = await this.fetchWithTimeout( + "https://api.perplexity.ai/models", + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}` + } } - }); + ); return response.ok; } if (provider === "openrouter") { - const response = await this.fetchWithTimeout("https://openrouter.ai/api/v1/models", { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}` + const response = await this.fetchWithTimeout( + "https://openrouter.ai/api/v1/models", + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}` + } } - }); + ); return response.ok; } } catch { @@ -233,10 +208,17 @@ export class ProviderService { return false; } - private async isProviderKeyValid(provider: CloudProviderId, apiKey: string): Promise { + private async isProviderKeyValid( + provider: CloudProviderId, + apiKey: string + ): Promise { const keyHash = this.keyHash(apiKey); const cached = this.providerKeyValidationCache.get(provider); - if (cached && cached.keyHash === keyHash && (Date.now() - cached.checkedAt) < 10 * 60 * 1000) { + if ( + cached && + cached.keyHash === keyHash && + Date.now() - cached.checkedAt < 10 * 60 * 1000 + ) { return cached.valid; } @@ -249,7 +231,9 @@ export class ProviderService { return valid; } - private async getValidProviderMap(providerApiKeys: Record): Promise> { + private async getValidProviderMap( + providerApiKeys: Record + ): Promise> { const checks = await Promise.all( CLOUD_PROVIDER_IDS.map(async (providerId) => { const apiKey = providerApiKeys[providerId]?.trim(); @@ -261,10 +245,13 @@ export class ProviderService { }) ); - return checks.reduce((result, [providerId, valid]) => { - result[providerId] = valid; - return result; - }, {} as Record); + return checks.reduce( + (result, [providerId, valid]) => { + result[providerId] = valid; + return result; + }, + {} as Record + ); } async getStatus(): Promise { @@ -281,29 +268,37 @@ export class ProviderService { onboardingCompleted: settings.onboardingCompleted, preferredMode: settings.preferredMode, shortcut: settings.shortcut, - systemMemoryGb: Number((os.totalmem() / (1024 ** 3)).toFixed(1)), + systemMemoryGb: Number((os.totalmem() / 1024 ** 3).toFixed(1)), activeCloudProvider: providers.cloud.activeProvider, cloudProviderKeys, providerApiKeys, - selectedCloudModels: CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - const selected = providers.cloud.selectedModels?.[providerId]; - result[providerId] = Array.isArray(selected) ? selected : []; - return result; - }, {} as Record), + selectedCloudModels: CLOUD_PROVIDER_IDS.reduce( + (result, providerId) => { + const selected = providers.cloud.selectedModels?.[providerId]; + result[providerId] = Array.isArray(selected) ? selected : []; + return result; + }, + {} as Record + ), perplexity: { configured: cloudProviderKeys.perplexity, model: providers.perplexity.model, preset: providers.perplexity.preset }, ollama, - braveSearchKeyConfigured: Boolean(await this.secureConfig.getToolApiKey("brave")), + braveSearchKeyConfigured: Boolean( + await this.secureConfig.getToolApiKey("brave") + ), toolToggles: settings.toolToggles }; } async saveConfig(config: SaveConfigInput): Promise { if (config.perplexityApiKey) { - await this.secureConfig.setProviderApiKey("perplexity", config.perplexityApiKey); + await this.secureConfig.setProviderApiKey( + "perplexity", + config.perplexityApiKey + ); } if (typeof config.braveSearchApiKey === "string") { @@ -320,7 +315,9 @@ export class ProviderService { if (typeof candidate === "string") { const normalized = candidate.trim(); if (normalized) { - saves.push(this.secureConfig.setProviderApiKey(providerId, normalized)); + saves.push( + this.secureConfig.setProviderApiKey(providerId, normalized) + ); } else { saves.push(this.secureConfig.clearProviderApiKey(providerId)); } @@ -334,12 +331,15 @@ export class ProviderService { await this.storage.saveSettings((current) => ({ ...current, - onboardingCompleted: config.onboardingCompleted ?? current.onboardingCompleted, + onboardingCompleted: + config.onboardingCompleted ?? current.onboardingCompleted, preferredMode: config.preferredMode ?? current.preferredMode, shortcut: config.shortcut ?? current.shortcut, providers: { cloud: { - activeProvider: config.activeCloudProvider ?? current.providers.cloud.activeProvider, + activeProvider: + config.activeCloudProvider ?? + current.providers.cloud.activeProvider, selectedModels: (() => { const selectedFromConfig = config.selectedCloudModels; if (!selectedFromConfig) { @@ -358,7 +358,9 @@ export class ProviderService { const normalized = Array.from( new Set( candidate - .filter((value): value is string => typeof value === "string") + .filter( + (value): value is string => typeof value === "string" + ) .map((value) => value.trim()) .filter(Boolean) ) @@ -385,7 +387,8 @@ export class ProviderService { }, toolToggles: { fetchUrl: config.toolToggles?.fetchUrl ?? current.toolToggles.fetchUrl, - webSearch: config.toolToggles?.webSearch ?? current.toolToggles.webSearch + webSearch: + config.toolToggles?.webSearch ?? current.toolToggles.webSearch } })); @@ -395,7 +398,10 @@ export class ProviderService { async detectOllama() { const settings = await this.storage.getSettings(); const providers = resolveProviderSettings(settings); - return this.ollama.detect(providers.ollama.baseUrl, providers.ollama.model || undefined); + return this.ollama.detect( + providers.ollama.baseUrl, + providers.ollama.model || undefined + ); } async listOllamaCatalog(limit = 100): Promise { @@ -411,13 +417,20 @@ export class ProviderService { ); if (ollamaStatus.state === "not_installed") { - throw new Error("Ollama is not installed yet. Install it from ollama.com/download and retry."); + throw new Error( + "Ollama is not installed yet. Install it from ollama.com/download and retry." + ); } if (ollamaStatus.state === "not_running") { - throw new Error(`Could not reach Ollama at ${ollamaStatus.baseUrl}. Open Ollama (or run 'ollama serve') and retry.`); + throw new Error( + `Could not reach Ollama at ${ollamaStatus.baseUrl}. Open Ollama (or run 'ollama serve') and retry.` + ); } - return this.ollama.pullModel(ollamaStatus.baseUrl || providers.ollama.baseUrl, model); + return this.ollama.pullModel( + ollamaStatus.baseUrl || providers.ollama.baseUrl, + model + ); } async deleteOllamaModel(model: string): Promise { @@ -432,13 +445,20 @@ export class ProviderService { throw new Error("Ollama is not installed yet."); } if (ollamaStatus.state === "not_running") { - throw new Error(`Could not reach Ollama at ${ollamaStatus.baseUrl}. Open Ollama (or run 'ollama serve') and retry.`); + throw new Error( + `Could not reach Ollama at ${ollamaStatus.baseUrl}. Open Ollama (or run 'ollama serve') and retry.` + ); } - return this.ollama.deleteModel(ollamaStatus.baseUrl || providers.ollama.baseUrl, model); + return this.ollama.deleteModel( + ollamaStatus.baseUrl || providers.ollama.baseUrl, + model + ); } - async listCloudModels(provider: CloudProviderId): Promise { + async listCloudModels( + provider: CloudProviderId + ): Promise { const apiKey = await this.secureConfig.getProviderApiKey(provider); if (!apiKey) { throw new Error(MODEL_SETUP_WARNING); @@ -467,11 +487,19 @@ export class ProviderService { ): Promise { const settings = await this.storage.getSettings(); const providers = resolveProviderSettings(settings); - const currentThread = - request.conversationId ? await this.storage.loadThread(request.conversationId) : null; - const userMessage = createMessage("user", request.prompt, "complete", request.attachments); + const currentThread = request.conversationId + ? await this.storage.loadThread(request.conversationId) + : null; + const userMessage = createMessage( + "user", + request.prompt, + "complete", + request.attachments + ); const assistantMessage = createMessage("assistant", "", "streaming"); - const seedTitle = request.prompt.trim() || (request.attachments?.length ? "Image" : "New chat"); + const seedTitle = + request.prompt.trim() || + (request.attachments?.length ? "Image" : "New chat"); const thread: ConversationThread = currentThread ?? { id: crypto.randomUUID(), title: buildThreadTitle(seedTitle), @@ -504,11 +532,27 @@ export class ProviderService { const onDelta = (delta: string) => { assistantMessage.content += delta; - emit({ streamId, type: "delta", threadId: thread.id, messageId: assistantMessage.id, delta }); + emit({ + streamId, + type: "delta", + threadId: thread.id, + messageId: assistantMessage.id, + delta + }); }; - const emitToolStatus = (toolName: string, status: "calling" | "complete") => { - emit({ streamId, type: "tool_status", threadId: thread.id, messageId: assistantMessage.id, toolName, status }); + const emitToolStatus = ( + toolName: string, + status: "calling" | "complete" + ) => { + emit({ + streamId, + type: "tool_status", + threadId: thread.id, + messageId: assistantMessage.id, + toolName, + status + }); }; const runToolLoop = async ( @@ -530,21 +574,28 @@ export class ProviderService { try { if (request.mode === "search") { - const requestedCloudProvider = request.cloudProvider ?? providers.cloud.activeProvider; + const requestedCloudProvider = + request.cloudProvider ?? providers.cloud.activeProvider; const streamMessages = truncateContext( prepareMessagesForAPI( - thread.messages.filter((message) => message.id !== assistantMessage.id) + thread.messages.filter( + (message) => message.id !== assistantMessage.id + ) ) ); if (requestedCloudProvider === "openai") { const apiKey = await this.secureConfig.getProviderApiKey("openai"); if (!apiKey) throw new Error(MODEL_SETUP_WARNING); - const preferredSelectedOpenAIModel = providers.cloud.selectedModels.openai?.[0]; + const preferredSelectedOpenAIModel = + providers.cloud.selectedModels.openai?.[0]; const { citations } = await runToolLoop((toolHistory) => this.openai.streamReply({ apiKey, - model: request.cloudModel?.trim() || preferredSelectedOpenAIModel || "gpt-5-mini", + model: + request.cloudModel?.trim() || + preferredSelectedOpenAIModel || + "gpt-5-mini", mode: request.cloudMode?.trim() || undefined, messages: streamMessages, systemPrompt: systemPrompt || undefined, @@ -555,12 +606,17 @@ export class ProviderService { ); finalCitations = citations; } else if (requestedCloudProvider === "perplexity") { - const apiKey = await this.secureConfig.getProviderApiKey("perplexity"); + const apiKey = + await this.secureConfig.getProviderApiKey("perplexity"); if (!apiKey) throw new Error(MODEL_SETUP_WARNING); - const preferredSelectedPerplexityModel = providers.cloud.selectedModels.perplexity?.[0]; + const preferredSelectedPerplexityModel = + providers.cloud.selectedModels.perplexity?.[0]; const result = await this.perplexity.streamReply({ apiKey, - model: request.cloudModel?.trim() || preferredSelectedPerplexityModel || providers.perplexity.model, + model: + request.cloudModel?.trim() || + preferredSelectedPerplexityModel || + providers.perplexity.model, preset: providers.perplexity.preset, messages: streamMessages, systemPrompt: systemPrompt || undefined, @@ -570,11 +626,15 @@ export class ProviderService { } else if (requestedCloudProvider === "google") { const apiKey = await this.secureConfig.getProviderApiKey("google"); if (!apiKey) throw new Error(MODEL_SETUP_WARNING); - const preferredSelectedGoogleModel = providers.cloud.selectedModels.google?.[0]; + const preferredSelectedGoogleModel = + providers.cloud.selectedModels.google?.[0]; const { citations } = await runToolLoop((toolHistory) => this.google.streamReply({ apiKey, - model: request.cloudModel?.trim() || preferredSelectedGoogleModel || "gemini-2.5-flash", + model: + request.cloudModel?.trim() || + preferredSelectedGoogleModel || + "gemini-2.5-flash", messages: streamMessages, systemPrompt: systemPrompt || undefined, tools: toolDefs.length > 0 ? toolDefs : undefined, @@ -584,11 +644,15 @@ export class ProviderService { ); finalCitations = citations; } else if (requestedCloudProvider === "openrouter") { - const apiKey = await this.secureConfig.getProviderApiKey("openrouter"); + const apiKey = + await this.secureConfig.getProviderApiKey("openrouter"); if (!apiKey) throw new Error(MODEL_SETUP_WARNING); - const preferredSelectedOpenRouterModel = providers.cloud.selectedModels.openrouter?.[0]; - const model = request.cloudModel?.trim() || preferredSelectedOpenRouterModel; - if (!model) throw new Error("Add an OpenRouter model ID in Settings first."); + const preferredSelectedOpenRouterModel = + providers.cloud.selectedModels.openrouter?.[0]; + const model = + request.cloudModel?.trim() || preferredSelectedOpenRouterModel; + if (!model) + throw new Error("Add an OpenRouter model ID in Settings first."); const { citations } = await runToolLoop((toolHistory) => this.openrouter.streamReply({ apiKey, @@ -612,20 +676,28 @@ export class ProviderService { providers.ollama.baseUrl, providers.ollama.model || undefined ); - if (ollamaStatus.state === "not_installed") throw new Error("Ollama is not installed yet."); - if (ollamaStatus.state === "not_running") throw new Error("Ollama is installed but not running."); - if (ollamaStatus.state === "no_model" || !ollamaStatus.selectedModel) throw new Error("Download an Ollama model before using Local mode."); + if (ollamaStatus.state === "not_installed") + throw new Error("Ollama is not installed yet."); + if (ollamaStatus.state === "not_running") + throw new Error("Ollama is installed but not running."); + if (ollamaStatus.state === "no_model" || !ollamaStatus.selectedModel) + throw new Error("Download an Ollama model before using Local mode."); const preferredLocalModel = providers.ollama.model?.trim(); - const resolvedLocalModel = preferredLocalModel && ollamaStatus.models.includes(preferredLocalModel) - ? preferredLocalModel - : ollamaStatus.selectedModel; - if (!resolvedLocalModel) throw new Error("Download an Ollama model before using Local mode."); + const resolvedLocalModel = + preferredLocalModel && + ollamaStatus.models.includes(preferredLocalModel) + ? preferredLocalModel + : ollamaStatus.selectedModel; + if (!resolvedLocalModel) + throw new Error("Download an Ollama model before using Local mode."); const { citations } = await runToolLoop((toolHistory) => this.ollama.streamReply({ baseUrl: providers.ollama.baseUrl, model: resolvedLocalModel, messages: truncateContext( - thread.messages.filter((message) => message.id !== assistantMessage.id) + thread.messages.filter( + (message) => message.id !== assistantMessage.id + ) ), systemPrompt: systemPrompt || undefined, tools: toolDefs.length > 0 ? toolDefs : undefined, @@ -688,7 +760,8 @@ export class ProviderService { }); } catch (error) { assistantMessage.status = "error"; - assistantMessage.content = assistantMessage.content || "I hit a snag before I could finish that."; + assistantMessage.content = + assistantMessage.content || "I hit a snag before I could finish that."; thread.updatedAt = isoNow(); await this.storage.upsertThread(thread); emit({ @@ -696,9 +769,9 @@ export class ProviderService { type: "error", threadId: thread.id, messageId: assistantMessage.id, - message: error instanceof Error ? error.message : "Unknown provider error." + message: + error instanceof Error ? error.message : "Unknown provider error." }); } } - } diff --git a/src/main/providerServiceUtils.ts b/src/main/providerServiceUtils.ts new file mode 100644 index 0000000..29d8988 --- /dev/null +++ b/src/main/providerServiceUtils.ts @@ -0,0 +1,78 @@ +import { ChatMessage } from "../shared/contracts"; + +export interface TodoAction { + type: "create_todo" | "complete_todo" | "uncomplete_todo"; + id?: string; + title?: string; +} + +export function prepareMessagesForAPI(messages: ChatMessage[]): ChatMessage[] { + let lastUserIndex = -1; + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === "user") { + lastUserIndex = index; + break; + } + } + + return messages.map((message, index) => { + if ( + message.role === "user" && + index !== lastUserIndex && + message.attachments?.length + ) { + return { ...message, attachments: undefined }; + } + return message; + }); +} + +export function parseActions(content: string): { + cleanContent: string; + actions: TodoAction[]; +} { + const actionBlocks = content.match(/[\s\S]*?<\/action>/g) ?? []; + const actions: TodoAction[] = []; + + for (const block of actionBlocks) { + const json = block.replace(/<\/?action>/g, "").trim(); + try { + const parsed = JSON.parse(json) as TodoAction; + if ( + parsed.type === "create_todo" || + parsed.type === "complete_todo" || + parsed.type === "uncomplete_todo" + ) { + actions.push(parsed); + } + } catch { + // Ignore malformed action blocks and keep the visible response intact. + } + } + + const cleanContent = content + .replace(/[\s\S]*?<\/action>/g, "") + .trimEnd(); + return { cleanContent, actions }; +} + +export function truncateContext( + messages: ChatMessage[], + maxChars = 100_000 +): ChatMessage[] { + let total = messages.reduce( + (sum, message) => sum + message.content.length, + 0 + ); + const result = [...messages]; + + while (total > maxChars && result.length > 4) { + const removed = result.shift(); + if (!removed) { + break; + } + total -= removed.content.length; + } + + return result; +} diff --git a/src/main/providers/curatedCloudModels.ts b/src/main/providers/curatedCloudModels.ts index d098b36..6a7fe74 100644 --- a/src/main/providers/curatedCloudModels.ts +++ b/src/main/providers/curatedCloudModels.ts @@ -1,6 +1,9 @@ import { CloudModelCatalogItem, CloudProviderId } from "../../shared/contracts"; -export const CURATED_CLOUD_MODELS: Record = { +export const CURATED_CLOUD_MODELS: Record< + CloudProviderId, + CloudModelCatalogItem[] +> = { openai: [ { id: "gpt-5.2-codex", modes: ["low", "medium", "high", "xhigh"] }, { id: "gpt-5.2", modes: ["none", "low", "medium", "high", "xhigh"] }, @@ -13,11 +16,6 @@ export const CURATED_CLOUD_MODELS: Record; } -function imagePartFromAttachment(attachment: ChatAttachment): { inlineData: { mimeType: string; data: string } } | null { +function imagePartFromAttachment( + attachment: ChatAttachment +): { inlineData: { mimeType: string; data: string } } | null { if (!attachment?.dataUrl || !attachment?.mimeType) { return null; } @@ -65,13 +71,17 @@ function toGeminiContents(messages: ChatMessage[]): GeminiContent[] { } function extractText(payload: unknown): string { - const candidates = Array.isArray((payload as { candidates?: unknown[] })?.candidates) + const candidates = Array.isArray( + (payload as { candidates?: unknown[] })?.candidates + ) ? (payload as { candidates: unknown[] }).candidates : []; - const first = candidates[0] as { content?: { parts?: Array<{ text?: string }> } } | undefined; + const first = candidates[0] as + | { content?: { parts?: Array<{ text?: string }> } } + | undefined; const parts = Array.isArray(first?.content?.parts) ? first.content.parts : []; return parts - .map((part) => typeof part?.text === "string" ? part.text : "") + .map((part) => (typeof part?.text === "string" ? part.text : "")) .filter(Boolean) .join(""); } @@ -104,7 +114,9 @@ function parseGoogleError(rawBody: string): string | null { } function extractFunctionCalls(payload: unknown): ToolCall[] { - const candidates = Array.isArray((payload as any)?.candidates) ? (payload as any).candidates : []; + const candidates = Array.isArray((payload as any)?.candidates) + ? (payload as any).candidates + : []; const first = candidates[0] as any; const parts = Array.isArray(first?.content?.parts) ? first.content.parts : []; return parts @@ -126,7 +138,9 @@ export class GoogleProvider { toolHistory?: ToolRound[]; onDelta: (delta: string) => void; }): Promise { - const rawModelId = input.model.startsWith("models/") ? input.model.slice("models/".length) : input.model; + const rawModelId = input.model.startsWith("models/") + ? input.model.slice("models/".length) + : input.model; const modelId = rawModelId.trim(); if (!modelId) { throw new Error("Pick a Google model first."); @@ -157,13 +171,15 @@ export class GoogleProvider { body.systemInstruction = { parts: [{ text: input.systemPrompt }] }; } if (input.tools?.length) { - body.tools = [{ - functionDeclarations: input.tools.map((t) => ({ - name: t.name, - description: t.description, - parameters: t.parameters - })) - }]; + body.tools = [ + { + functionDeclarations: input.tools.map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters + })) + } + ]; } const response = await fetch(url, { method: "POST", @@ -177,7 +193,10 @@ export class GoogleProvider { const body = await response.text(); const parsedError = parseGoogleError(body); if (response.status === 401 || response.status === 403) { - throw new Error(parsedError || "Google API key is invalid or does not have Gemini API access."); + throw new Error( + parsedError || + "Google API key is invalid or does not have Gemini API access." + ); } throw new Error(parsedError || "Google model request failed."); } diff --git a/src/main/providers/ollamaProvider.ts b/src/main/providers/ollamaProvider.ts index 8702236..569fc2c 100644 --- a/src/main/providers/ollamaProvider.ts +++ b/src/main/providers/ollamaProvider.ts @@ -8,7 +8,12 @@ import { ModelPullResult, OllamaStatus } from "../../shared/contracts"; -import { ToolDefinition, ToolCall, ToolRound, StreamReplyResult } from "../tools/types"; +import { + ToolDefinition, + ToolCall, + ToolRound, + StreamReplyResult +} from "../tools/types"; const execFileAsync = promisify(execFile); const OLLAMA_DOWNLOAD_URL = "https://ollama.com/download"; @@ -26,14 +31,16 @@ interface PullProgressChunk { function decodeHtml(input: string): string { return input .replace(/&/g, "&") - .replace(/"/g, "\"") + .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">"); } function normalizeText(input: string): string { - return decodeHtml(input.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim(); + return decodeHtml(input.replace(/<[^>]*>/g, " ")) + .replace(/\s+/g, " ") + .trim(); } function parseParamBillions(sizeToken: string): number { @@ -55,9 +62,15 @@ function parseParamBillions(sizeToken: string): number { return numeric; } -function buildModelDownloadEstimate(paramsBillions: number): { estimatedSizeMb: number; minRamGb: number } { +function buildModelDownloadEstimate(paramsBillions: number): { + estimatedSizeMb: number; + minRamGb: number; +} { const estimatedSizeMb = Math.max(256, Math.round(paramsBillions * 520)); - const minRamGb = Math.max(2, Math.round(((estimatedSizeMb / 1024) * 1.45 + 0.5) * 10) / 10); + const minRamGb = Math.max( + 2, + Math.round(((estimatedSizeMb / 1024) * 1.45 + 0.5) * 10) / 10 + ); return { estimatedSizeMb, minRamGb }; } @@ -91,12 +104,17 @@ function formatReachabilityError(baseUrl: string): string { return `Could not reach Ollama at ${normalizeBaseUrl(baseUrl)}. Open Ollama (or run 'ollama serve') and try again.`; } -function resolveSelectedModel(selectedModel: string | undefined, models: string[]): string | undefined { +function resolveSelectedModel( + selectedModel: string | undefined, + models: string[] +): string | undefined { if (!selectedModel) { return models[0]; } const normalized = selectedModel.trim().toLowerCase(); - const matched = models.find((model) => model.trim().toLowerCase() === normalized); + const matched = models.find( + (model) => model.trim().toLowerCase() === normalized + ); return matched ?? models[0]; } @@ -105,7 +123,10 @@ async function sleep(ms: number): Promise { } export class OllamaProvider { - private catalogCache: { loadedAt: number; items: LocalModelCatalogItem[] } | null = null; + private catalogCache: { + loadedAt: number; + items: LocalModelCatalogItem[]; + } | null = null; async detect(baseUrl: string, selectedModel?: string): Promise { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); @@ -129,7 +150,9 @@ export class OllamaProvider { continue; } - const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> }; + const payload = (await response.json()) as { + models?: Array<{ name?: string; model?: string }>; + }; const models = (payload.models ?? []) .map((item) => item.name ?? item.model ?? "") .filter(Boolean); @@ -156,7 +179,9 @@ export class OllamaProvider { continue; } - const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> }; + const payload = (await response.json()) as { + models?: Array<{ name?: string; model?: string }>; + }; const models = (payload.models ?? []) .map((item) => item.name ?? item.model ?? "") .filter(Boolean); @@ -225,7 +250,11 @@ export class OllamaProvider { if (input.tools?.length) { requestBody.tools = input.tools.map((t) => ({ type: "function", - function: { name: t.name, description: t.description, parameters: t.parameters } + function: { + name: t.name, + description: t.description, + parameters: t.parameters + } })); } @@ -266,7 +295,9 @@ export class OllamaProvider { }); response = nextResponse; if (response.ok && response.body) break; - } catch { continue; } + } catch { + continue; + } } } @@ -298,7 +329,12 @@ export class OllamaProvider { continue; } const chunk = JSON.parse(trimmed) as { - message?: { content?: string; tool_calls?: Array<{ function: { name: string; arguments: Record } }> }; + message?: { + content?: string; + tool_calls?: Array<{ + function: { name: string; arguments: Record }; + }>; + }; done?: boolean; }; const delta = chunk.message?.content ?? ""; @@ -317,7 +353,12 @@ export class OllamaProvider { if (buffer.trim()) { const chunk = JSON.parse(buffer) as { - message?: { content?: string; tool_calls?: Array<{ function: { name: string; arguments: Record } }> }; + message?: { + content?: string; + tool_calls?: Array<{ + function: { name: string; arguments: Record }; + }>; + }; done?: boolean; }; const delta = chunk.message?.content ?? ""; @@ -340,7 +381,10 @@ export class OllamaProvider { const safeLimit = Math.min(Math.max(limit, 1), 100); const now = Date.now(); - if (this.catalogCache && now - this.catalogCache.loadedAt < CATALOG_CACHE_TTL_MS) { + if ( + this.catalogCache && + now - this.catalogCache.loadedAt < CATALOG_CACHE_TTL_MS + ) { return this.catalogCache.items.slice(0, safeLimit); } @@ -356,34 +400,54 @@ export class OllamaProvider { for (const block of blocks) { const nameMatch = - block.match(/x-test-model-title[^>]*title="([^"]+)"/)?.[1] - ?? block.match(/href="\/library\/([^":?#"]+)"/)?.[1]; + block.match(/x-test-model-title[^>]*title="([^"]+)"/)?.[1] ?? + block.match(/href="\/library\/([^":?#"]+)"/)?.[1]; const modelName = nameMatch?.trim(); if (!modelName || dedupe.has(modelName)) { continue; } - const sizeMatches = Array.from(block.matchAll(/x-test-size[^>]*>([^<]+)]*>([^<]+) normalizeText(match[1])) .filter(Boolean); const uniqueSizes = Array.from(new Set(sizeMatches)); const sizeChoices = uniqueSizes - .map((sizeLabel) => ({ sizeLabel, paramsBillions: parseParamBillions(sizeLabel) })) + .map((sizeLabel) => ({ + sizeLabel, + paramsBillions: parseParamBillions(sizeLabel) + })) .filter((entry) => entry.paramsBillions > 0) .sort((left, right) => left.paramsBillions - right.paramsBillions); - const smallest = sizeChoices[0] ?? { sizeLabel: "latest", paramsBillions: 7 }; - const selectedTag = smallest.sizeLabel.toLowerCase() === "latest" ? "latest" : smallest.sizeLabel; + const smallest = sizeChoices[0] ?? { + sizeLabel: "latest", + paramsBillions: 7 + }; + const selectedTag = + smallest.sizeLabel.toLowerCase() === "latest" + ? "latest" + : smallest.sizeLabel; const modelTag = `${modelName}:${selectedTag}`; - const descriptionMatch = block.match(/

([\s\S]*?)<\/p>/)?.[1] ?? ""; - const pulls = normalizeText(block.match(/x-test-pull-count>([^<]+)([\s\S]*?)<\/p>/ + )?.[1] ?? ""; + const pulls = normalizeText( + block.match(/x-test-pull-count>([^<]+) { + async deleteModel( + baseUrl: string, + model: string + ): Promise { const targetModel = model.trim(); if (!targetModel) { throw new Error("Model name is required."); diff --git a/src/main/providers/openaiProvider.ts b/src/main/providers/openaiProvider.ts index ea76e8c..c9807c2 100644 --- a/src/main/providers/openaiProvider.ts +++ b/src/main/providers/openaiProvider.ts @@ -1,6 +1,11 @@ import OpenAI from "openai"; import { ChatMessage, CloudModelCatalogItem } from "../../shared/contracts"; -import { ToolDefinition, ToolCall, ToolRound, StreamReplyResult } from "../tools/types"; +import { + ToolDefinition, + ToolCall, + ToolRound, + StreamReplyResult +} from "../tools/types"; function buildResponsesInput(messages: ChatMessage[]): Array<{ role: "user" | "assistant"; @@ -62,11 +67,20 @@ function buildResponsesInput(messages: ChatMessage[]): Array<{ function isLikelyChatModel(id: string): boolean { const normalized = id.toLowerCase(); - if (/(embedding|moderation|omni-moderation|whisper|transcribe|tts|image|dall-e|realtime)/.test(normalized)) { + if ( + /(embedding|moderation|omni-moderation|whisper|transcribe|tts|image|dall-e|realtime)/.test( + normalized + ) + ) { return false; } - return normalized.startsWith("gpt-") || normalized.startsWith("o1") || normalized.startsWith("o3") || normalized.startsWith("o4"); + return ( + normalized.startsWith("gpt-") || + normalized.startsWith("o1") || + normalized.startsWith("o3") || + normalized.startsWith("o4") + ); } function modesForModel(modelId: string): string[] { @@ -185,13 +199,19 @@ export class OpenAIProvider { } const stream = await (client.responses.create as any)(createParams); - const pendingToolCalls = new Map(); + const pendingToolCalls = new Map< + number, + { id: string; name: string; arguments: string } + >(); for await (const event of stream) { if (event?.type === "response.output_text.delta" && event.delta) { input.onDelta(event.delta); } - if (event?.type === "response.output_item.added" && event.item?.type === "function_call") { + if ( + event?.type === "response.output_item.added" && + event.item?.type === "function_call" + ) { pendingToolCalls.set(event.output_index, { id: event.item.call_id || "", name: event.item.name || "", diff --git a/src/main/providers/openrouterProvider.ts b/src/main/providers/openrouterProvider.ts index ea69b7e..707a708 100644 --- a/src/main/providers/openrouterProvider.ts +++ b/src/main/providers/openrouterProvider.ts @@ -1,5 +1,10 @@ import { ChatAttachment, ChatMessage } from "../../shared/contracts"; -import { ToolDefinition, ToolCall, ToolRound, StreamReplyResult } from "../tools/types"; +import { + ToolDefinition, + ToolCall, + ToolRound, + StreamReplyResult +} from "../tools/types"; type OpenRouterContentPart = | { type: "text"; text: string } @@ -23,7 +28,9 @@ interface OpenRouterModelDataEntry { }; } -function imagePartFromAttachment(attachment: ChatAttachment): OpenRouterContentPart | null { +function imagePartFromAttachment( + attachment: ChatAttachment +): OpenRouterContentPart | null { if (!attachment?.dataUrl) { return null; } @@ -64,7 +71,8 @@ function toOpenRouterMessages(messages: ChatMessage[]): OpenRouterMessage[] { result.push({ role: message.role, - content: parts.length === 1 && parts[0]?.type === "text" ? parts[0].text : parts + content: + parts.length === 1 && parts[0]?.type === "text" ? parts[0].text : parts }); } @@ -113,7 +121,11 @@ export class OpenRouterProvider { private modelCapabilitiesFetchedAt = 0; private async fetchModelCapabilitiesIfNeeded(): Promise { - if ((Date.now() - this.modelCapabilitiesFetchedAt) < this.modelCapabilitiesTtlMs && this.modelInputModalitiesCache.size > 0) { + if ( + Date.now() - this.modelCapabilitiesFetchedAt < + this.modelCapabilitiesTtlMs && + this.modelInputModalitiesCache.size > 0 + ) { return; } @@ -128,20 +140,23 @@ export class OpenRouterProvider { throw new Error("Could not verify OpenRouter model capabilities."); } - const payload = await response.json() as { data?: unknown[] }; + const payload = (await response.json()) as { data?: unknown[] }; const items = Array.isArray(payload?.data) ? payload.data : []; const nextCache = new Map>(); for (const item of items) { const model = item as OpenRouterModelDataEntry; - const id = typeof model.id === "string" ? model.id.trim().toLowerCase() : ""; + const id = + typeof model.id === "string" ? model.id.trim().toLowerCase() : ""; if (!id) { continue; } const rawInputModalities = model.architecture?.input_modalities; const normalized = Array.isArray(rawInputModalities) ? rawInputModalities - .filter((modality): modality is string => typeof modality === "string") + .filter( + (modality): modality is string => typeof modality === "string" + ) .map((modality) => modality.trim().toLowerCase()) .filter(Boolean) : []; @@ -153,19 +168,24 @@ export class OpenRouterProvider { } private hasImageAttachments(messages: ChatMessage[]): boolean { - return messages.some((message) => ( - message.role === "user" && (message.attachments?.length ?? 0) > 0 - )); + return messages.some( + (message) => + message.role === "user" && (message.attachments?.length ?? 0) > 0 + ); } - private async modelSupportsImageInput(model: string): Promise { + private async modelSupportsImageInput( + model: string + ): Promise { try { await this.fetchModelCapabilitiesIfNeeded(); } catch { return null; } - const modalities = this.modelInputModalitiesCache.get(model.trim().toLowerCase()); + const modalities = this.modelInputModalitiesCache.get( + model.trim().toLowerCase() + ); if (!modalities) { return null; } @@ -190,7 +210,9 @@ export class OpenRouterProvider { ? await this.modelSupportsImageInput(model) : null; if (supportsImage === false) { - throw new Error("Selected model does not support image input. Use a model with image input support."); + throw new Error( + "Selected model does not support image input. Use a model with image input support." + ); } const userMessages = toOpenRouterMessages(input.messages); @@ -224,37 +246,52 @@ export class OpenRouterProvider { } } - const hasImages = userMessages.some((m) => - Array.isArray(m.content) && m.content.some((p) => p.type === "image_url") + const hasImages = userMessages.some( + (m) => + Array.isArray(m.content) && + m.content.some((p) => p.type === "image_url") ); const body: Record = { model, messages, stream: true }; if (input.tools?.length) { body.tools = input.tools.map((t) => ({ type: "function", - function: { name: t.name, description: t.description, parameters: t.parameters } + function: { + name: t.name, + description: t.description, + parameters: t.parameters + } })); } const bodyStr = JSON.stringify(body); - console.log(`[OpenRouter] sending ${bodyStr.length} bytes, hasImages=${hasImages}, msgCount=${messages.length}`); + console.log( + `[OpenRouter] sending ${bodyStr.length} bytes, hasImages=${hasImages}, msgCount=${messages.length}` + ); - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - Authorization: `Bearer ${input.apiKey}`, - "Content-Type": "application/json" - }, - body: bodyStr - }); + const response = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${input.apiKey}`, + "Content-Type": "application/json" + }, + body: bodyStr + } + ); - console.log(`[OpenRouter] response: ${response.status} ${response.statusText}`); + console.log( + `[OpenRouter] response: ${response.status} ${response.statusText}` + ); if (!response.ok) { const body = await response.text(); const parsed = parseProviderError(body); if (/no endpoints found that support image input/i.test(parsed || "")) { - throw new Error("This model does not support image input. Choose a vision-capable model or send text only."); + throw new Error( + "This model does not support image input. Choose a vision-capable model or send text only." + ); } if (response.status === 401 || response.status === 403) { throw new Error(parsed || "OpenRouter API key is invalid."); @@ -269,7 +306,10 @@ export class OpenRouterProvider { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - const accumulatedToolCalls = new Map(); + const accumulatedToolCalls = new Map< + number, + { id: string; name: string; arguments: string } + >(); while (true) { const { done, value } = await reader.read(); @@ -316,12 +356,17 @@ export class OpenRouterProvider { for (const tc of toolCallDeltas) { const idx = typeof tc.index === "number" ? tc.index : 0; if (!accumulatedToolCalls.has(idx)) { - accumulatedToolCalls.set(idx, { id: tc.id || "", name: "", arguments: "" }); + accumulatedToolCalls.set(idx, { + id: tc.id || "", + name: "", + arguments: "" + }); } const acc = accumulatedToolCalls.get(idx)!; if (tc.id) acc.id = tc.id; if (tc.function?.name) acc.name = tc.function.name; - if (tc.function?.arguments) acc.arguments += tc.function.arguments; + if (tc.function?.arguments) + acc.arguments += tc.function.arguments; } } } catch { diff --git a/src/main/providers/perplexityProvider.ts b/src/main/providers/perplexityProvider.ts index fa1e3e4..e4047e5 100644 --- a/src/main/providers/perplexityProvider.ts +++ b/src/main/providers/perplexityProvider.ts @@ -4,12 +4,16 @@ import { StreamReplyResult } from "../tools/types"; function buildTranscript(messages: ChatMessage[]): string { return messages - .filter((message) => message.role === "user" || message.role === "assistant") + .filter( + (message) => message.role === "user" || message.role === "assistant" + ) .map((message) => { const imageNotes = (message.attachments ?? []) .map((attachment) => `[Image attached: ${attachment.name || "image"}]`) .join(" "); - const payload = [message.content, imageNotes].filter((chunk) => chunk.trim().length > 0).join(" "); + const payload = [message.content, imageNotes] + .filter((chunk) => chunk.trim().length > 0) + .join(" "); return `${message.role.toUpperCase()}: ${payload}`; }) .join("\n\n"); @@ -33,7 +37,9 @@ function extractCitations(response: any): Citation[] { for (const item of output) { const content = Array.isArray(item?.content) ? item.content : []; for (const part of content) { - const annotations = Array.isArray(part?.annotations) ? part.annotations : []; + const annotations = Array.isArray(part?.annotations) + ? part.annotations + : []; for (const annotation of annotations) { const url = annotation?.url ?? annotation?.source?.url; const title = annotation?.title ?? annotation?.source?.title ?? url; diff --git a/src/main/secureConfig.ts b/src/main/secureConfig.ts index 7d93e16..c07155c 100644 --- a/src/main/secureConfig.ts +++ b/src/main/secureConfig.ts @@ -55,10 +55,15 @@ export class SecureConfig { return decrypted.trim() || null; } - async setProviderApiKey(provider: CloudProviderId, apiKey: string): Promise { + async setProviderApiKey( + provider: CloudProviderId, + apiKey: string + ): Promise { this.assertEncryption(); const secrets = await this.readSecrets(); - const encrypted = safeStorage.encryptString(apiKey.trim()).toString("base64"); + const encrypted = safeStorage + .encryptString(apiKey.trim()) + .toString("base64"); secrets.providerApiKeys[provider] = encrypted; await this.writeSecrets(secrets); } @@ -71,37 +76,48 @@ export class SecureConfig { async getConfiguredProviderMap(): Promise> { const secrets = await this.readSecrets(); - return CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - result[providerId] = Boolean(secrets.providerApiKeys[providerId]); - return result; - }, {} as Record); + return CLOUD_PROVIDER_IDS.reduce( + (result, providerId) => { + result[providerId] = Boolean(secrets.providerApiKeys[providerId]); + return result; + }, + {} as Record + ); } async getProviderApiKeys(): Promise> { const secrets = await this.readSecrets(); if (!safeStorage.isEncryptionAvailable()) { - return CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - result[providerId] = ""; - return result; - }, {} as Record); + return CLOUD_PROVIDER_IDS.reduce( + (result, providerId) => { + result[providerId] = ""; + return result; + }, + {} as Record + ); } - return CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - const encoded = secrets.providerApiKeys[providerId]; - if (!encoded) { - result[providerId] = ""; + return CLOUD_PROVIDER_IDS.reduce( + (result, providerId) => { + const encoded = secrets.providerApiKeys[providerId]; + if (!encoded) { + result[providerId] = ""; + return result; + } + + try { + const decrypted = safeStorage.decryptString( + Buffer.from(encoded, "base64") + ); + result[providerId] = decrypted.trim(); + } catch { + result[providerId] = ""; + } return result; - } - - try { - const decrypted = safeStorage.decryptString(Buffer.from(encoded, "base64")); - result[providerId] = decrypted.trim(); - } catch { - result[providerId] = ""; - } - return result; - }, {} as Record); + }, + {} as Record + ); } async getToolApiKey(name: string): Promise { @@ -116,7 +132,9 @@ export class SecureConfig { async setToolApiKey(name: string, key: string): Promise { this.assertEncryption(); const secrets = await this.readSecrets(); - secrets.toolApiKeys[name] = safeStorage.encryptString(key.trim()).toString("base64"); + secrets.toolApiKeys[name] = safeStorage + .encryptString(key.trim()) + .toString("base64"); await this.writeSecrets(secrets); } @@ -147,7 +165,9 @@ export class SecureConfig { private assertEncryption(): void { if (!safeStorage.isEncryptionAvailable()) { - throw new Error("System encryption is not available. Robin cannot store API keys securely on this device."); + throw new Error( + "System encryption is not available. Robin cannot store API keys securely on this device." + ); } } } diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 0000000..5db4be4 --- /dev/null +++ b/src/main/settings.ts @@ -0,0 +1,236 @@ +import { + CLOUD_PROVIDER_IDS, + CloudModelCatalogItem, + CloudProviderId +} from "../shared/contracts"; + +export interface SettingsData { + onboardingCompleted: boolean; + preferredMode: "search" | "local"; + shortcut: string; + providers: { + cloud: { + activeProvider: CloudProviderId; + selectedModels: Partial>; + catalogCache: Partial< + Record< + CloudProviderId, + { fetchedAt: string; models: CloudModelCatalogItem[] } + > + >; + }; + perplexity: { + model: string; + preset: string; + }; + ollama: { + baseUrl: string; + model: string; + }; + }; + toolToggles: { + fetchUrl: boolean; + webSearch: boolean; + }; +} + +type LegacySettingsShape = { + onboardingCompleted?: boolean; + preferredMode?: "search" | "local"; + shortcut?: string; + activeCloudProvider?: CloudProviderId; + perplexityModel?: string; + perplexityPreset?: string; + ollamaBaseUrl?: string; + ollamaModel?: string; +}; + +export const DEFAULT_SETTINGS: SettingsData = { + onboardingCompleted: false, + preferredMode: "search", + shortcut: "CommandOrControl+Shift+Space", + providers: { + cloud: { + activeProvider: "openai", + selectedModels: {}, + catalogCache: {} + }, + perplexity: { + model: "openai/gpt-5-mini", + preset: "pro-search" + }, + ollama: { + baseUrl: "http://localhost:11434", + model: "" + } + }, + toolToggles: { + fetchUrl: true, + webSearch: true + } +}; + +const CLOUD_PROVIDER_ID_SET = new Set(CLOUD_PROVIDER_IDS); + +export function normalizeSettings(raw: unknown): SettingsData { + const source = + raw && typeof raw === "object" + ? (raw as Partial & LegacySettingsShape) + : {}; + const sourceProviders = (source.providers ?? {}) as Partial< + SettingsData["providers"] + >; + const sourceCloud = (sourceProviders.cloud ?? {}) as Partial< + SettingsData["providers"]["cloud"] + >; + const sourcePerplexity = (sourceProviders.perplexity ?? {}) as Partial< + SettingsData["providers"]["perplexity"] + >; + const sourceOllama = (sourceProviders.ollama ?? {}) as Partial< + SettingsData["providers"]["ollama"] + >; + + const preferredMode = source.preferredMode === "local" ? "local" : "search"; + const shortcut = + typeof source.shortcut === "string" && source.shortcut.trim() + ? source.shortcut + : DEFAULT_SETTINGS.shortcut; + + const normalizedSelectedCloudModels = CLOUD_PROVIDER_IDS.reduce( + (result, providerId) => { + const rawModels = sourceCloud.selectedModels?.[providerId]; + if (!Array.isArray(rawModels)) { + return result; + } + + const normalizedModels = Array.from( + new Set( + rawModels + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + ) + ); + + if (normalizedModels.length > 0) { + result[providerId] = normalizedModels; + } + + return result; + }, + {} as Partial> + ); + + const normalizedCloudCatalogCache = CLOUD_PROVIDER_IDS.reduce( + (result, providerId) => { + const rawEntry = sourceCloud.catalogCache?.[providerId]; + if (!rawEntry || typeof rawEntry !== "object") { + return result; + } + + const fetchedAt = + typeof rawEntry.fetchedAt === "string" ? rawEntry.fetchedAt : ""; + const rawModels = Array.isArray(rawEntry.models) ? rawEntry.models : []; + const normalizedModels: CloudModelCatalogItem[] = rawModels + .filter((item): item is CloudModelCatalogItem => + Boolean( + item && typeof item === "object" && typeof item.id === "string" + ) + ) + .map((item) => ({ + id: item.id.trim(), + modes: Array.isArray(item.modes) + ? Array.from( + new Set( + item.modes + .filter((mode): mode is string => typeof mode === "string") + .map((mode) => mode.trim()) + .filter(Boolean) + ) + ) + : [] + })) + .filter((item) => item.id.length > 0); + + if (!fetchedAt || normalizedModels.length === 0) { + return result; + } + + result[providerId] = { + fetchedAt, + models: normalizedModels + }; + return result; + }, + {} as Partial< + Record< + CloudProviderId, + { fetchedAt: string; models: CloudModelCatalogItem[] } + > + > + ); + + return { + onboardingCompleted: Boolean(source.onboardingCompleted), + preferredMode, + shortcut, + providers: { + cloud: { + activeProvider: + typeof sourceCloud.activeProvider === "string" && + CLOUD_PROVIDER_ID_SET.has( + sourceCloud.activeProvider as CloudProviderId + ) + ? (sourceCloud.activeProvider as CloudProviderId) + : typeof source.activeCloudProvider === "string" && + CLOUD_PROVIDER_ID_SET.has( + source.activeCloudProvider as CloudProviderId + ) + ? (source.activeCloudProvider as CloudProviderId) + : DEFAULT_SETTINGS.providers.cloud.activeProvider, + selectedModels: normalizedSelectedCloudModels, + catalogCache: normalizedCloudCatalogCache + }, + perplexity: { + model: + typeof sourcePerplexity.model === "string" && + sourcePerplexity.model.trim() + ? sourcePerplexity.model + : typeof source.perplexityModel === "string" && + source.perplexityModel.trim() + ? source.perplexityModel + : DEFAULT_SETTINGS.providers.perplexity.model, + preset: + typeof sourcePerplexity.preset === "string" && + sourcePerplexity.preset.trim() + ? sourcePerplexity.preset + : typeof source.perplexityPreset === "string" && + source.perplexityPreset.trim() + ? source.perplexityPreset + : DEFAULT_SETTINGS.providers.perplexity.preset + }, + ollama: { + baseUrl: + typeof sourceOllama.baseUrl === "string" && + sourceOllama.baseUrl.trim() + ? sourceOllama.baseUrl + : typeof source.ollamaBaseUrl === "string" && + source.ollamaBaseUrl.trim() + ? source.ollamaBaseUrl + : DEFAULT_SETTINGS.providers.ollama.baseUrl, + model: + typeof sourceOllama.model === "string" + ? sourceOllama.model + : typeof source.ollamaModel === "string" + ? source.ollamaModel + : DEFAULT_SETTINGS.providers.ollama.model + } + }, + toolToggles: { + fetchUrl: + (source as Partial).toolToggles?.fetchUrl !== false, + webSearch: + (source as Partial).toolToggles?.webSearch !== false + } + }; +} diff --git a/src/main/storage.ts b/src/main/storage.ts index caa95d9..1d6e0f5 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -3,193 +3,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { ChatMessage, - CLOUD_PROVIDER_IDS, - CloudModelCatalogItem, - CloudProviderId, ConversationThread, NoteItem, ThreadSummary, TodoItem } from "../shared/contracts"; - -export interface SettingsData { - onboardingCompleted: boolean; - preferredMode: "search" | "local"; - shortcut: string; - providers: { - cloud: { - activeProvider: CloudProviderId; - selectedModels: Partial>; - catalogCache: Partial>; - }; - perplexity: { - model: string; - preset: string; - }; - ollama: { - baseUrl: string; - model: string; - }; - }; - toolToggles: { - fetchUrl: boolean; - webSearch: boolean; - }; -} - -type LegacySettingsShape = { - onboardingCompleted?: boolean; - preferredMode?: "search" | "local"; - shortcut?: string; - activeCloudProvider?: CloudProviderId; - perplexityModel?: string; - perplexityPreset?: string; - ollamaBaseUrl?: string; - ollamaModel?: string; -}; - -const DEFAULT_SETTINGS: SettingsData = { - onboardingCompleted: false, - preferredMode: "search", - shortcut: "CommandOrControl+Shift+Space", - providers: { - cloud: { - activeProvider: "openai", - selectedModels: {}, - catalogCache: {} - }, - perplexity: { - model: "openai/gpt-5-mini", - preset: "pro-search" - }, - ollama: { - baseUrl: "http://localhost:11434", - model: "" - } - }, - toolToggles: { - fetchUrl: true, - webSearch: true - } -}; - -const CLOUD_PROVIDER_ID_SET = new Set(CLOUD_PROVIDER_IDS); - -function normalizeSettings(raw: unknown): SettingsData { - const source = (raw && typeof raw === "object") ? (raw as Partial & LegacySettingsShape) : {}; - const sourceProviders = (source.providers ?? {}) as Partial; - const sourceCloud = (sourceProviders.cloud ?? {}) as Partial; - const sourcePerplexity = (sourceProviders.perplexity ?? {}) as Partial; - const sourceOllama = (sourceProviders.ollama ?? {}) as Partial; - - const preferredMode = source.preferredMode === "local" ? "local" : "search"; - const shortcut = typeof source.shortcut === "string" && source.shortcut.trim() - ? source.shortcut - : DEFAULT_SETTINGS.shortcut; - - const normalizedSelectedCloudModels = CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - const rawModels = sourceCloud.selectedModels?.[providerId]; - if (!Array.isArray(rawModels)) { - return result; - } - - const normalizedModels = Array.from( - new Set( - rawModels - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) - ) - ); - - if (normalizedModels.length > 0) { - result[providerId] = normalizedModels; - } - - return result; - }, {} as Partial>); - - const normalizedCloudCatalogCache = CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - const rawEntry = sourceCloud.catalogCache?.[providerId]; - if (!rawEntry || typeof rawEntry !== "object") { - return result; - } - - const fetchedAt = typeof rawEntry.fetchedAt === "string" ? rawEntry.fetchedAt : ""; - const rawModels = Array.isArray(rawEntry.models) ? rawEntry.models : []; - const normalizedModels: CloudModelCatalogItem[] = rawModels - .filter((item): item is CloudModelCatalogItem => Boolean(item && typeof item === "object" && typeof item.id === "string")) - .map((item) => ({ - id: item.id.trim(), - modes: Array.isArray(item.modes) - ? Array.from( - new Set( - item.modes - .filter((mode): mode is string => typeof mode === "string") - .map((mode) => mode.trim()) - .filter(Boolean) - ) - ) - : [] - })) - .filter((item) => item.id.length > 0); - - if (!fetchedAt || normalizedModels.length === 0) { - return result; - } - - result[providerId] = { - fetchedAt, - models: normalizedModels - }; - return result; - }, {} as Partial>); - - return { - onboardingCompleted: Boolean(source.onboardingCompleted), - preferredMode, - shortcut, - providers: { - cloud: { - activeProvider: typeof sourceCloud.activeProvider === "string" && CLOUD_PROVIDER_ID_SET.has(sourceCloud.activeProvider as CloudProviderId) - ? sourceCloud.activeProvider as CloudProviderId - : typeof source.activeCloudProvider === "string" && CLOUD_PROVIDER_ID_SET.has(source.activeCloudProvider as CloudProviderId) - ? source.activeCloudProvider as CloudProviderId - : DEFAULT_SETTINGS.providers.cloud.activeProvider, - selectedModels: normalizedSelectedCloudModels, - catalogCache: normalizedCloudCatalogCache - }, - perplexity: { - model: typeof sourcePerplexity.model === "string" && sourcePerplexity.model.trim() - ? sourcePerplexity.model - : typeof source.perplexityModel === "string" && source.perplexityModel.trim() - ? source.perplexityModel - : DEFAULT_SETTINGS.providers.perplexity.model, - preset: typeof sourcePerplexity.preset === "string" && sourcePerplexity.preset.trim() - ? sourcePerplexity.preset - : typeof source.perplexityPreset === "string" && source.perplexityPreset.trim() - ? source.perplexityPreset - : DEFAULT_SETTINGS.providers.perplexity.preset - }, - ollama: { - baseUrl: typeof sourceOllama.baseUrl === "string" && sourceOllama.baseUrl.trim() - ? sourceOllama.baseUrl - : typeof source.ollamaBaseUrl === "string" && source.ollamaBaseUrl.trim() - ? source.ollamaBaseUrl - : DEFAULT_SETTINGS.providers.ollama.baseUrl, - model: typeof sourceOllama.model === "string" - ? sourceOllama.model - : typeof source.ollamaModel === "string" - ? source.ollamaModel - : DEFAULT_SETTINGS.providers.ollama.model - } - }, - toolToggles: { - fetchUrl: (source as Partial).toolToggles?.fetchUrl !== false, - webSearch: (source as Partial).toolToggles?.webSearch !== false - } - }; -} +import { DEFAULT_SETTINGS, normalizeSettings, SettingsData } from "./settings"; interface ThreadsFile { threads: ConversationThread[]; @@ -219,11 +38,16 @@ export class AppStorage { } async getSettings(): Promise { - const raw = await this.readJson(this.settingsPath, DEFAULT_SETTINGS); + const raw = await this.readJson( + this.settingsPath, + DEFAULT_SETTINGS + ); return normalizeSettings(raw); } - async saveSettings(updater: (current: SettingsData) => SettingsData): Promise { + async saveSettings( + updater: (current: SettingsData) => SettingsData + ): Promise { const current = await this.getSettings(); const next = normalizeSettings(updater(current)); await this.writeJson(this.settingsPath, next); @@ -302,9 +126,9 @@ export class AppStorage { threadChanged = true; changed = true; const emptyAssistant = - message.role === "assistant" - && message.content.trim().length === 0 - && (message.attachments?.length ?? 0) === 0; + message.role === "assistant" && + message.content.trim().length === 0 && + (message.attachments?.length ?? 0) === 0; if (emptyAssistant) { continue; @@ -356,7 +180,10 @@ export class AppStorage { return todo; } - async updateTodo(id: string, changes: Partial>): Promise { + async updateTodo( + id: string, + changes: Partial> + ): Promise { const file = await this.readJson(this.todosPath, { todos: [] }); const todo = file.todos.find((t) => t.id === id); if (!todo) return null; @@ -393,7 +220,9 @@ export class AppStorage { async listNotes(): Promise { const file = await this.readJson(this.notesPath, { notes: [] }); - return file.notes.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return file.notes + .slice() + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } async createNote(title: string): Promise { @@ -411,7 +240,10 @@ export class AppStorage { return note; } - async updateNote(id: string, changes: Partial>): Promise { + async updateNote( + id: string, + changes: Partial> + ): Promise { const file = await this.readJson(this.notesPath, { notes: [] }); const note = file.notes.find((n) => n.id === id); if (!note) return null; @@ -431,7 +263,9 @@ export class AppStorage { } private async getThreads(): Promise { - const file = await this.readJson(this.threadsPath, { threads: [] }); + const file = await this.readJson(this.threadsPath, { + threads: [] + }); return file.threads; } diff --git a/src/main/tools/fetchUrl.ts b/src/main/tools/fetchUrl.ts index b3f5a6e..8ca8717 100644 --- a/src/main/tools/fetchUrl.ts +++ b/src/main/tools/fetchUrl.ts @@ -6,7 +6,10 @@ const USER_AGENT = "Robin/1.0 (Personal AI Assistant)"; function stripHtml(html: string): string { // Remove script/style/noscript blocks entirely - let text = html.replace(/<(script|style|noscript|svg|head)[^>]*>[\s\S]*?<\/\1>/gi, ""); + let text = html.replace( + /<(script|style|noscript|svg|head)[^>]*>[\s\S]*?<\/\1>/gi, + "" + ); // Prefer article/main content if present const articleMatch = text.match(/<(article|main)[^>]*>([\s\S]*?)<\/\1>/i); if (articleMatch) { @@ -27,7 +30,10 @@ function stripHtml(html: string): string { .replace(/ /g, " ") .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))); // Collapse whitespace - text = text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim(); + text = text + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); return text; } @@ -39,7 +45,8 @@ function extractTitle(html: string): string { export const fetchUrlTool: ToolExecutor = { definition: { name: "fetch_url", - description: "Fetch the content of a web page at the given URL and return its text. Use this when the user shares a URL or asks about the content of a specific web page.", + description: + "Fetch the content of a web page at the given URL and return its text. Use this when the user shares a URL or asks about the content of a specific web page.", parameters: { type: "object", properties: { diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 9fa3fd5..81f1a6e 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -23,7 +23,9 @@ export function buildToolExecutors( return tools; } -export function getToolDefinitions(executors: ToolExecutor[]): ToolDefinition[] { +export function getToolDefinitions( + executors: ToolExecutor[] +): ToolDefinition[] { return executors.map((t) => t.definition); } diff --git a/src/main/tools/webSearch.ts b/src/main/tools/webSearch.ts index 2d3cbb6..c07f75c 100644 --- a/src/main/tools/webSearch.ts +++ b/src/main/tools/webSearch.ts @@ -16,12 +16,16 @@ export function createWebSearchTool(braveApiKey: string): ToolExecutor { return { definition: { name: "web_search", - description: "Search the web for current information. Use this when the user asks about recent events, facts, or topics that require up-to-date data.", + description: + "Search the web for current information. Use this when the user asks about recent events, facts, or topics that require up-to-date data.", parameters: { type: "object", properties: { query: { type: "string", description: "The search query" }, - count: { type: "number", description: "Number of results (1-10, default 5)" } + count: { + type: "number", + description: "Number of results (1-10, default 5)" + } }, required: ["query"] } @@ -33,21 +37,27 @@ export function createWebSearchTool(braveApiKey: string): ToolExecutor { return "Error: No search query provided."; } - const count = Math.min(Math.max(typeof args.count === "number" ? args.count : 5, 1), 10); + const count = Math.min( + Math.max(typeof args.count === "number" ? args.count : 5, 1), + 10 + ); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS); const params = new URLSearchParams({ q: query, count: String(count) }); - const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, { - method: "GET", - headers: { - "X-Subscription-Token": braveApiKey, - Accept: "application/json" - }, - signal: controller.signal - }); + const response = await fetch( + `https://api.search.brave.com/res/v1/web/search?${params}`, + { + method: "GET", + headers: { + "X-Subscription-Token": braveApiKey, + Accept: "application/json" + }, + signal: controller.signal + } + ); clearTimeout(timeout); @@ -66,7 +76,10 @@ export function createWebSearchTool(braveApiKey: string): ToolExecutor { } return results - .map((r, i) => `${i + 1}. ${r.title ?? "Untitled"}\n ${r.url ?? ""}\n ${r.description ?? ""}`) + .map( + (r, i) => + `${i + 1}. ${r.title ?? "Untitled"}\n ${r.url ?? ""}\n ${r.description ?? ""}` + ) .join("\n\n"); } catch (error) { if (error instanceof Error && error.name === "AbortError") { diff --git a/src/preload/index.ts b/src/preload/index.ts index 28ed31f..4febd6b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,10 @@ import { contextBridge, ipcRenderer } from "electron"; -import { ChatStreamEvent, ChatStreamRequest, RobinBridge, SaveConfigInput } from "../shared/contracts"; +import { + ChatStreamEvent, + ChatStreamRequest, + RobinBridge, + SaveConfigInput +} from "../shared/contracts"; const CHANNELS = { togglePanel: "app:toggle-panel", @@ -33,7 +38,10 @@ const CHANNELS = { notesDelete: "notes:delete" } as const; -const activeStreamListeners = new Map void>(); +const activeStreamListeners = new Map< + string, + (_event: Electron.IpcRendererEvent, payload: ChatStreamEvent) => void +>(); function clearStreamListener(streamId: string) { const listener = activeStreamListeners.get(streamId); @@ -52,7 +60,8 @@ const bridge: RobinBridge = { openWindow: async () => { await ipcRenderer.invoke(CHANNELS.openWindow); }, - setShortcut: async (accelerator) => ipcRenderer.invoke(CHANNELS.setShortcut, accelerator), + setShortcut: async (accelerator) => + ipcRenderer.invoke(CHANNELS.setShortcut, accelerator), getProfile: async () => ipcRenderer.invoke(CHANNELS.profile), getVersion: async () => ipcRenderer.invoke(CHANNELS.version), checkForUpdates: async () => ipcRenderer.invoke(CHANNELS.checkUpdates), @@ -63,7 +72,10 @@ const bridge: RobinBridge = { chat: { streamReply: async (request: ChatStreamRequest, handlers = {}) => { const streamId = crypto.randomUUID(); - const listener = (_event: Electron.IpcRendererEvent, payload: ChatStreamEvent) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: ChatStreamEvent + ) => { if (payload.streamId !== streamId) { return; } @@ -95,7 +107,10 @@ const bridge: RobinBridge = { ipcRenderer.on(CHANNELS.streamEvent, listener); activeStreamListeners.set(streamId, listener); try { - await ipcRenderer.invoke(CHANNELS.startStream, { ...request, streamId }); + await ipcRenderer.invoke(CHANNELS.startStream, { + ...request, + streamId + }); } catch (error) { clearStreamListener(streamId); throw error; @@ -119,26 +134,36 @@ const bridge: RobinBridge = { }, providers: { getStatus: async () => ipcRenderer.invoke(CHANNELS.providerStatus), - saveConfig: async (config: SaveConfigInput) => ipcRenderer.invoke(CHANNELS.saveConfig, config), - listCloudModels: async (provider) => ipcRenderer.invoke(CHANNELS.listCloudModels, provider) + saveConfig: async (config: SaveConfigInput) => + ipcRenderer.invoke(CHANNELS.saveConfig, config), + listCloudModels: async (provider) => + ipcRenderer.invoke(CHANNELS.listCloudModels, provider) }, ollama: { detect: async () => ipcRenderer.invoke(CHANNELS.ollamaDetect), - listCatalog: async (limit?: number) => ipcRenderer.invoke(CHANNELS.ollamaCatalog, limit), - pullModel: async (model: string) => ipcRenderer.invoke(CHANNELS.ollamaPull, model), - deleteModel: async (model: string) => ipcRenderer.invoke(CHANNELS.ollamaDelete, model) + listCatalog: async (limit?: number) => + ipcRenderer.invoke(CHANNELS.ollamaCatalog, limit), + pullModel: async (model: string) => + ipcRenderer.invoke(CHANNELS.ollamaPull, model), + deleteModel: async (model: string) => + ipcRenderer.invoke(CHANNELS.ollamaDelete, model) }, todos: { list: async () => ipcRenderer.invoke(CHANNELS.todosList), - create: async (title: string) => ipcRenderer.invoke(CHANNELS.todosCreate, title), - update: async (id: string, changes) => ipcRenderer.invoke(CHANNELS.todosUpdate, id, changes), - reorder: async (orderedIds: string[]) => ipcRenderer.invoke(CHANNELS.todosReorder, orderedIds), + create: async (title: string) => + ipcRenderer.invoke(CHANNELS.todosCreate, title), + update: async (id: string, changes) => + ipcRenderer.invoke(CHANNELS.todosUpdate, id, changes), + reorder: async (orderedIds: string[]) => + ipcRenderer.invoke(CHANNELS.todosReorder, orderedIds), delete: async (id: string) => ipcRenderer.invoke(CHANNELS.todosDelete, id) }, notes: { list: async () => ipcRenderer.invoke(CHANNELS.notesList), - create: async (title: string) => ipcRenderer.invoke(CHANNELS.notesCreate, title), - update: async (id: string, changes) => ipcRenderer.invoke(CHANNELS.notesUpdate, id, changes), + create: async (title: string) => + ipcRenderer.invoke(CHANNELS.notesCreate, title), + update: async (id: string, changes) => + ipcRenderer.invoke(CHANNELS.notesUpdate, id, changes), delete: async (id: string) => ipcRenderer.invoke(CHANNELS.notesDelete, id) } }; diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 0c8d4a1..9767047 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -18,7 +18,10 @@ type RootBoundaryState = { message: string; }; -class RootBoundary extends React.Component { +class RootBoundary extends React.Component< + RootBoundaryProps, + RootBoundaryState +> { state: RootBoundaryState = { hasError: false, message: "" @@ -27,7 +30,8 @@ class RootBoundary extends React.Component static getDerivedStateFromError(error: unknown): RootBoundaryState { return { hasError: true, - message: error instanceof Error ? error.message : "Unknown renderer error." + message: + error instanceof Error ? error.message : "Unknown renderer error." }; } @@ -50,14 +54,18 @@ class RootBoundary extends React.Component placeItems: "center", background: "#050507", color: "#efefef", - fontFamily: "SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + fontFamily: + "SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", padding: "24px" }} >

-

Robin hit a renderer error.

+

+ Robin hit a renderer error. +

- {this.state.message || "Please restart Robin. If this continues, clear local app data and retry."} + {this.state.message || + "Please restart Robin. If this continues, clear local app data and retry."}

diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index aae0afe..b7fa89c 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -2,7 +2,6 @@ export type AssistantMode = "search" | "local"; export const CLOUD_PROVIDER_IDS = [ "openai", - "anthropic", "google", "perplexity", "openrouter" @@ -227,7 +226,9 @@ export interface RobinBridge { app: { togglePanel: () => Promise; openWindow: () => Promise; - setShortcut: (accelerator: string) => Promise<{ success: boolean; shortcut: string }>; + setShortcut: ( + accelerator: string + ) => Promise<{ success: boolean; shortcut: string }>; openExternal: (url: string) => Promise; getProfile: () => Promise; getVersion: () => Promise; @@ -239,22 +240,33 @@ export interface RobinBridge { handlers?: Partial<{ onThread: (event: Extract) => void; onDelta: (event: Extract) => void; - onCitations: (event: Extract) => void; + onCitations: ( + event: Extract + ) => void; onDone: (event: Extract) => void; onError: (event: Extract) => void; - onContextUpdate: (event: Extract) => void; - onToolStatus: (event: Extract) => void; + onContextUpdate: ( + event: Extract + ) => void; + onToolStatus: ( + event: Extract + ) => void; }> ) => Promise; listThreads: () => Promise; loadThread: (id: string) => Promise; deleteThread: (id: string) => Promise; - stopStream: (payload?: { streamId?: string; threadId?: string }) => Promise; + stopStream: (payload?: { + streamId?: string; + threadId?: string; + }) => Promise; }; providers: { getStatus: () => Promise; saveConfig: (config: SaveConfigInput) => Promise; - listCloudModels: (provider: CloudProviderId) => Promise; + listCloudModels: ( + provider: CloudProviderId + ) => Promise; }; ollama: { detect: () => Promise; @@ -265,14 +277,20 @@ export interface RobinBridge { todos: { list: () => Promise; create: (title: string) => Promise; - update: (id: string, changes: Partial>) => Promise; + update: ( + id: string, + changes: Partial> + ) => Promise; reorder: (orderedIds: string[]) => Promise; delete: (id: string) => Promise; }; notes: { list: () => Promise; create: (title: string) => Promise; - update: (id: string, changes: Partial>) => Promise; + update: ( + id: string, + changes: Partial> + ) => Promise; delete: (id: string) => Promise; }; } diff --git a/test/main/providerServiceUtils.test.ts b/test/main/providerServiceUtils.test.ts new file mode 100644 index 0000000..ceb5aa0 --- /dev/null +++ b/test/main/providerServiceUtils.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ChatMessage } from "../../src/shared/contracts"; +import { + parseActions, + prepareMessagesForAPI, + truncateContext +} from "../../src/main/providerServiceUtils"; + +function message( + id: string, + role: ChatMessage["role"], + content: string, + attachments = false +): ChatMessage { + return { + id, + role, + content, + createdAt: new Date(0).toISOString(), + attachments: attachments + ? [ + { + id: `${id}-attachment`, + name: "image.png", + mimeType: "image/png", + dataUrl: "data:image/png;base64,abc" + } + ] + : undefined + }; +} + +test("prepareMessagesForAPI strips attachments from older user messages only", () => { + const prepared = prepareMessagesForAPI([ + message("1", "user", "first", true), + message("2", "assistant", "reply"), + message("3", "user", "latest", true) + ]); + + assert.equal(prepared[0].attachments, undefined); + assert.equal(prepared[2].attachments?.length, 1); +}); + +test("parseActions extracts valid todo actions and removes action blocks from content", () => { + const parsed = parseActions( + [ + "Visible response", + '{"type":"create_todo","title":"Ship linting"}', + '{"type":"complete_todo","id":"todo-1"}', + '{"type":"unknown"}', + "not json" + ].join("\n") + ); + + assert.equal(parsed.cleanContent, "Visible response"); + assert.deepEqual(parsed.actions, [ + { type: "create_todo", title: "Ship linting" }, + { type: "complete_todo", id: "todo-1" } + ]); +}); + +test("truncateContext drops oldest messages but preserves at least four", () => { + const messages = [ + message("1", "user", "aaaa"), + message("2", "assistant", "bbbb"), + message("3", "user", "cccc"), + message("4", "assistant", "dddd"), + message("5", "user", "eeee"), + message("6", "assistant", "ffff") + ]; + + const truncated = truncateContext(messages, 12); + + assert.deepEqual( + truncated.map((entry) => entry.id), + ["3", "4", "5", "6"] + ); +}); diff --git a/test/main/settings.test.ts b/test/main/settings.test.ts new file mode 100644 index 0000000..b30dfe9 --- /dev/null +++ b/test/main/settings.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DEFAULT_SETTINGS, normalizeSettings } from "../../src/main/settings"; + +test("normalizeSettings falls back to defaults for invalid input", () => { + assert.deepEqual(normalizeSettings(null), DEFAULT_SETTINGS); +}); + +test("normalizeSettings trims and deduplicates selected cloud models", () => { + const normalized = normalizeSettings({ + providers: { + cloud: { + selectedModels: { + openai: [" gpt-5.2 ", "gpt-5.2", "", "gpt-5.2-mini"] + } + } + } + }); + + assert.deepEqual(normalized.providers.cloud.selectedModels.openai, [ + "gpt-5.2", + "gpt-5.2-mini" + ]); +}); + +test("normalizeSettings drops legacy providers that are no longer supported", () => { + const normalized = normalizeSettings({ + activeCloudProvider: "anthropic", + providers: { + cloud: { + activeProvider: "anthropic", + selectedModels: { + anthropic: ["claude-sonnet-4-5"], + openai: ["gpt-5.2"] + } + } + } + }); + + assert.equal(normalized.providers.cloud.activeProvider, "openai"); + assert.deepEqual(normalized.providers.cloud.selectedModels.openai, [ + "gpt-5.2" + ]); + assert.equal("anthropic" in normalized.providers.cloud.selectedModels, false); +}); diff --git a/tsconfig.json b/tsconfig.json index d1255b8..8f7543d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,13 +9,12 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "resolveJsonModule": true, - "types": [ - "node" - ], + "types": ["node"], "baseUrl": "." }, "include": [ "src/**/*", + "test/**/*", "forge.config.js", "webpack.*.config.js", "tailwind.config.js", From d6da5405fe9c5dda10708b3aae1a68d8c1ac1623 Mon Sep 17 00:00:00 2001 From: Karan Singh Date: Sat, 21 Mar 2026 22:53:45 +0530 Subject: [PATCH 2/2] Split renderer app into reusable modules --- src/renderer/App.tsx | 1960 +++++++++++--------- src/renderer/components/EmptyState.tsx | 10 + src/renderer/components/ErrorBanner.tsx | 24 + src/renderer/components/SidebarFooter.tsx | 34 + src/renderer/components/ThemedDropdown.tsx | 132 ++ src/renderer/components/icons.tsx | 250 +++ src/renderer/lib/cloudProviders.ts | 178 ++ src/renderer/lib/modelSelection.ts | 83 + src/renderer/styles.css | 227 ++- test/renderer/cloudProviders.test.ts | 74 + test/renderer/modelSelection.test.ts | 35 + 11 files changed, 2007 insertions(+), 1000 deletions(-) create mode 100644 src/renderer/components/EmptyState.tsx create mode 100644 src/renderer/components/ErrorBanner.tsx create mode 100644 src/renderer/components/SidebarFooter.tsx create mode 100644 src/renderer/components/ThemedDropdown.tsx create mode 100644 src/renderer/components/icons.tsx create mode 100644 src/renderer/lib/cloudProviders.ts create mode 100644 src/renderer/lib/modelSelection.ts create mode 100644 test/renderer/cloudProviders.test.ts create mode 100644 test/renderer/modelSelection.test.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 72acbba..14a7524 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,13 +1,52 @@ import React, { FormEvent, useEffect, useMemo, useRef, useState } from "react"; -import { HugeiconsIcon, IconSvgElement } from "@hugeicons/react"; +import "@fontsource/capriola"; import "@fontsource/gochi-hand"; import "@fontsource/dm-sans/500.css"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { EmptyState } from "./components/EmptyState"; +import { ErrorBanner } from "./components/ErrorBanner"; +import { + IconCalendar, + IconChat, + IconChevron, + IconCheck, + IconClose, + IconExpand, + IconImage, + IconNote, + IconPlus, + IconSend, + IconSidebar, + IconStop, + IconTodo +} from "./components/icons"; +import { SidebarFooter } from "./components/SidebarFooter"; +import { DropdownOption, ThemedDropdown } from "./components/ThemedDropdown"; +import { + buildCloudProviderStateMap, + buildProviderDrafts, + CLOUD_PROVIDERS, + findAvailableCloudProvider, + formatModelFootprint, + inferCatalogProviderCategory, + moveCloudModelToFront, + normalizeCloudProviderKeys, + normalizeProviderKeyDrafts, + normalizeSelectedCloudModels, + ramFitTier +} from "./lib/cloudProviders"; +import { + cloudComposerValue, + localComposerValue, + modelKey, + parseComposerValue, + parseModelKey, + resolveCloudProviderId +} from "./lib/modelSelection"; const REMARK_PLUGINS = [remarkGfm]; import { - AssistantMode, CLOUD_PROVIDER_IDS, ChatAttachment, CloudModelCatalogItem, @@ -24,34 +63,6 @@ import { UpdateCheckResult } from "../shared/contracts"; -const FALLBACK_DASHBOARD_ICON: IconSvgElement = [ - ["path", { d: "M13.6903 19.4567C13.5 18.9973 13.5 18.4149 13.5 17.25C13.5 16.0851 13.5 15.5027 13.6903 15.0433C13.944 14.4307 14.4307 13.944 15.0433 13.6903C15.5027 13.5 16.0851 13.5 17.25 13.5C18.4149 13.5 18.9973 13.5 19.4567 13.6903C20.0693 13.944 20.556 14.4307 20.8097 15.0433C21 15.5027 21 16.0851 21 17.25C21 18.4149 21 18.9973 20.8097 19.4567C20.556 20.0693 20.0693 20.556 19.4567 20.8097C18.9973 21 18.4149 21 17.25 21C16.0851 21 15.5027 21 15.0433 20.8097C14.4307 20.556 13.944 20.0693 13.6903 19.4567Z", stroke: "currentColor", strokeLinecap: "square", strokeLinejoin: "round", strokeWidth: "1.5", key: "0" }], - ["path", { d: "M13.6903 8.95671C13.5 8.49728 13.5 7.91485 13.5 6.75C13.5 5.58515 13.5 5.00272 13.6903 4.54329C13.944 3.93072 14.4307 3.44404 15.0433 3.1903C15.5027 3 16.0851 3 17.25 3C18.4149 3 18.9973 3 19.4567 3.1903C20.0693 3.44404 20.556 3.93072 20.8097 4.54329C21 5.00272 21 5.58515 21 6.75C21 7.91485 21 8.49728 20.8097 8.95671C20.556 9.56928 20.0693 10.056 19.4567 10.3097C18.9973 10.5 18.4149 10.5 17.25 10.5C16.0851 10.5 15.5027 10.5 15.0433 10.3097C14.4307 10.056 13.944 9.56928 13.6903 8.95671Z", stroke: "currentColor", strokeLinecap: "square", strokeLinejoin: "round", strokeWidth: "1.5", key: "1" }], - ["path", { d: "M3.1903 19.4567C3 18.9973 3 18.4149 3 17.25C3 16.0851 3 15.5027 3.1903 15.0433C3.44404 14.4307 3.93072 13.944 4.54329 13.6903C5.00272 13.5 5.58515 13.5 6.75 13.5C7.91485 13.5 8.49728 13.5 8.95671 13.6903C9.56928 13.944 10.056 14.4307 10.3097 15.0433C10.5 15.5027 10.5 16.0851 10.5 17.25C10.5 18.4149 10.5 18.9973 10.3097 19.4567C10.056 20.0693 9.56928 20.556 8.95671 20.8097C8.49728 21 7.91485 21 6.75 21C5.58515 21 5.00272 21 4.54329 20.8097C3.93072 20.556 3.44404 20.0693 3.1903 19.4567Z", stroke: "currentColor", strokeLinecap: "square", strokeLinejoin: "round", strokeWidth: "1.5", key: "2" }], - ["path", { d: "M3.1903 8.95671C3 8.49728 3 7.91485 3 6.75C3 5.58515 3 5.00272 3.1903 4.54329C3.44404 3.93072 3.93072 3.44404 4.54329 3.1903C5.00272 3 5.58515 3 6.75 3C7.91485 3 8.49728 3 8.95671 3.1903C9.56928 3.44404 10.056 3.93072 10.3097 4.54329C10.5 5.00272 10.5 5.58515 10.5 6.75C10.5 7.91485 10.5 8.49728 10.3097 8.95671C10.056 9.56928 9.56928 10.056 8.95671 10.3097C8.49728 10.5 7.91485 10.5 6.75 10.5C5.58515 10.5 5.00272 10.5 4.54329 10.3097C3.93072 10.056 3.44404 9.56928 3.1903 8.95671Z", stroke: "currentColor", strokeLinecap: "square", strokeLinejoin: "round", strokeWidth: "1.5", key: "3" }] -] as const; - -const FALLBACK_SETTINGS_ICON: IconSvgElement = [ - ["path", { d: "M15.5 12C15.5 13.933 13.933 15.5 12 15.5C10.067 15.5 8.5 13.933 8.5 12C8.5 10.067 10.067 8.5 12 8.5C13.933 8.5 15.5 10.067 15.5 12Z", stroke: "currentColor", strokeWidth: "1.5", key: "0" }], - ["path", { d: "M21.011 14.0965C21.5329 13.9558 21.7939 13.8854 21.8969 13.7508C22 13.6163 22 13.3998 22 12.9669V11.0332C22 10.6003 22 10.3838 21.8969 10.2493C21.7938 10.1147 21.5329 10.0443 21.011 9.90358C19.0606 9.37759 17.8399 7.33851 18.3433 5.40087C18.4817 4.86799 18.5509 4.60156 18.4848 4.44529C18.4187 4.28902 18.2291 4.18134 17.8497 3.96596L16.125 2.98673C15.7528 2.77539 15.5667 2.66972 15.3997 2.69222C15.2326 2.71472 15.0442 2.90273 14.6672 3.27873C13.208 4.73448 10.7936 4.73442 9.33434 3.27864C8.95743 2.90263 8.76898 2.71463 8.60193 2.69212C8.43489 2.66962 8.24877 2.77529 7.87653 2.98663L6.15184 3.96587C5.77253 4.18123 5.58287 4.28891 5.51678 4.44515C5.45068 4.6014 5.51987 4.86787 5.65825 5.4008C6.16137 7.3385 4.93972 9.37763 2.98902 9.9036C2.46712 10.0443 2.20617 10.1147 2.10308 10.2492C2 10.3838 2 10.6003 2 11.0332V12.9669C2 13.3998 2 13.6163 2.10308 13.7508C2.20615 13.8854 2.46711 13.9558 2.98902 14.0965C4.9394 14.6225 6.16008 16.6616 5.65672 18.5992C5.51829 19.1321 5.44907 19.3985 5.51516 19.5548C5.58126 19.7111 5.77092 19.8188 6.15025 20.0341L7.87495 21.0134C8.24721 21.2247 8.43334 21.3304 8.6004 21.3079C8.76746 21.2854 8.95588 21.0973 9.33271 20.7213C10.7927 19.2644 13.2088 19.2643 14.6689 20.7212C15.0457 21.0973 15.2341 21.2853 15.4012 21.3078C15.5682 21.3303 15.7544 21.2246 16.1266 21.0133L17.8513 20.034C18.2307 19.8187 18.4204 19.711 18.4864 19.5547C18.5525 19.3984 18.4833 19.132 18.3448 18.5991C17.8412 16.6616 19.0609 14.6226 21.011 14.0965Z", stroke: "currentColor", strokeLinecap: "round", strokeWidth: "1.5", key: "1" }] -] as const; - -const DashboardSquare01Icon = FALLBACK_DASHBOARD_ICON; -const Settings02Icon = FALLBACK_SETTINGS_ICON; - -interface CloudProviderMeta { - id: CloudProviderId; - label: string; -} - -const CLOUD_PROVIDERS: CloudProviderMeta[] = [ - { id: "openai", label: "OpenAI" }, - { id: "anthropic", label: "Anthropic" }, - { id: "google", label: "Google" }, - { id: "perplexity", label: "Perplexity" }, - { id: "openrouter", label: "OpenRouter" } -]; - type SidebarTab = "chats" | "todos" | "notes" | "calendar"; type SettingsModeTab = "cloud" | "local"; @@ -78,455 +89,10 @@ const CATALOG_PROVIDER_GROUP_ORDER = [ ] as const; function formatTime(iso: string): string { - return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); -} - -function buildProviderDrafts(): Record { - return CLOUD_PROVIDER_IDS.reduce((result, id) => { - result[id] = ""; - return result; - }, {} as Record); -} - -function normalizeCloudProviderKeys( - source?: Partial> -): Record { - return CLOUD_PROVIDER_IDS.reduce((result, id) => { - result[id] = Boolean(source?.[id]); - return result; - }, {} as Record); -} - -function normalizeProviderKeyDrafts( - source?: Partial> -): Record { - return CLOUD_PROVIDER_IDS.reduce((result, id) => { - result[id] = typeof source?.[id] === "string" ? source[id] ?? "" : ""; - return result; - }, {} as Record); -} - -function normalizeSelectedCloudModels( - source?: Partial> -): Record { - return CLOUD_PROVIDER_IDS.reduce((result, id) => { - const candidate = source?.[id]; - if (!Array.isArray(candidate)) { - result[id] = []; - return result; - } - result[id] = Array.from( - new Set( - candidate - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) - ) - ); - return result; - }, {} as Record); -} - -function buildCloudProviderStateMap(factory: (providerId: CloudProviderId) => T): Record { - return CLOUD_PROVIDER_IDS.reduce((result, providerId) => { - result[providerId] = factory(providerId); - return result; - }, {} as Record); -} - -function formatModelFootprint(sizeMb: number): string { - if (sizeMb >= 1024) { - const gb = sizeMb / 1024; - return `${gb >= 10 ? gb.toFixed(0) : gb.toFixed(1)} GB`; - } - return `${sizeMb} MB`; -} - -function ramFitTier(minRamGb: number, systemMemoryGb?: number): { label: string; tone: "good" | "maybe" | "bad" } { - if (!systemMemoryGb || systemMemoryGb <= 0) { - return { label: "Maybe", tone: "maybe" }; - } - - const recommendedLimit = systemMemoryGb * 0.62; - const maybeLimit = systemMemoryGb * 0.9; - - if (minRamGb <= recommendedLimit) { - return { label: "Recommended", tone: "good" }; - } - if (minRamGb <= maybeLimit) { - return { label: "Maybe", tone: "maybe" }; - } - return { label: "Not recommended", tone: "bad" }; -} - -function inferCatalogProviderCategory(item: LocalModelCatalogItem): string { - const haystack = `${item.title} ${item.model}`.toLowerCase(); - - if (/(\bllama\b|\bmeta\b|codellama)/i.test(haystack)) return "Meta"; - if (/\bqwen\b/i.test(haystack)) return "Alibaba (Qwen)"; - if (/\bgemma\b/i.test(haystack)) return "Google"; - if (/\bmistral\b|\bmixtral\b|\bcodestral\b/i.test(haystack)) return "Mistral"; - if (/\bdeepseek\b/i.test(haystack)) return "DeepSeek"; - if (/\bphi\b/i.test(haystack)) return "Microsoft"; - if (/\bcohere\b|\bcommand-r\b/i.test(haystack)) return "Cohere"; - if (/\bgranite\b|\bibm\b/i.test(haystack)) return "IBM"; - return "Community"; -} - -function RobinIconGlyph({ - icon, - size = 17, - strokeWidth = 1.8, - className -}: { - icon?: IconSvgElement; - size?: number; - strokeWidth?: number; - className?: string; -}) { - if (!icon || !Array.isArray(icon)) { - return null; - } - - return ( -