From 2e3b400c2f17c3d4e38d28ad1f4c19363a689278 Mon Sep 17 00:00:00 2001 From: Jazz Macedo Date: Wed, 5 Nov 2025 16:01:13 -0500 Subject: [PATCH 1/4] add final ollama integration with small fixes to the UX --- .claude/settings.local.json | 5 +- package-lock.json | 1073 ++++++++++++++++- package.json | 4 + src/main/index.ts | 89 +- src/main/ipc/handlers.ts | 222 +++- src/main/preload.ts | 10 + src/main/services/capture.ts | 242 ++++ src/main/services/database.ts | 36 + src/main/services/ollama.ts | 135 ++- .../Browser/MultiWebViewContainer.tsx | 18 +- .../components/Browser/NavigationBar.tsx | 153 ++- src/renderer/components/Browser/TabBar.tsx | 2 +- src/renderer/components/Chat/ChatSidebar.tsx | 479 +++++++- .../components/Models/AvailableModels.tsx | 7 +- .../components/Models/InstalledModels.tsx | 24 +- .../components/Models/ModelManager.tsx | 4 +- src/renderer/index.html | 34 +- src/renderer/store/chat.ts | 365 ++++++ src/renderer/store/tabs.ts | 30 +- src/shared/contextManager.ts | 206 ++++ src/shared/modelRegistry.json | 3 +- src/shared/modelRegistry.ts | 9 + src/shared/tools.ts | 241 ++++ src/shared/types.ts | 3 +- vite.config.ts | 2 +- 25 files changed, 3216 insertions(+), 180 deletions(-) create mode 100644 src/main/services/capture.ts create mode 100644 src/shared/contextManager.ts create mode 100644 src/shared/tools.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c35621d..999d987 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,10 @@ "Bash(npm run lint:*)", "Bash(curl:*)", "Bash(unzip:*)", - "Bash(chmod:*)" + "Bash(chmod:*)", + "Bash(npm run format:*)", + "Bash(npx electron-rebuild:*)", + "Bash(taskkill:*)" ], "deny": [], "ask": [] diff --git a/package-lock.json b/package-lock.json index 6dc8877..dcd7ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,17 +7,22 @@ "": { "name": "browser-llm", "version": "0.1.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { + "@mozilla/readability": "^0.6.0", "axios": "^1.7.0", "better-sqlite3": "^12.4.1", + "jsdom": "^27.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "sharp": "^0.34.4", "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/better-sqlite3": "^7.6.13", + "@types/jsdom": "^27.0.0", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -49,6 +54,12 @@ "@rollup/rollup-win32-x64-msvc": "^4.52.5" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.19", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.19.tgz", + "integrity": "sha512-Pp2gAQXPZ2o7lt4j0IMwNRXqQ3pagxtDj5wctL5U2Lz4oV0ocDNlkgx4DpxfyKav4S/bePuI+SMqcBSUHLy9kg==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -62,6 +73,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", + "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.1" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -361,6 +422,135 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz", + "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -924,6 +1114,16 @@ "node": ">= 10.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", + "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1635,35 +1835,462 @@ "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=18.18.0" + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.22" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" } }, - "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", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" } }, "node_modules/@isaacs/balanced-match": { @@ -1920,6 +2547,15 @@ "node": ">= 10.0.0" } }, + "node_modules/@mozilla/readability": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2483,6 +3119,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2559,6 +3220,13 @@ "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -2924,7 +3592,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -3500,6 +4167,15 @@ "node": "20.x || 22.x || 23.x || 24.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4342,6 +5018,19 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4355,6 +5044,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.2.tgz", + "integrity": "sha512-zDMqXh8Vs1CdRYZQ2M633m/SFgcjlu8RB8b/1h82i+6vpArF507NSYIWJHGlJaTWoS+imcnctmEz43txhbVkOw==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4362,6 +5065,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4420,7 +5136,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4434,6 +5149,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5002,6 +5723,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6462,6 +7195,18 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6473,7 +7218,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -6501,7 +7245,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -6559,7 +7302,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6991,6 +7733,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7277,6 +8025,45 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz", + "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.19", + "@asamuzakjp/dom-selector": "^6.7.3", + "cssstyle": "^5.3.2", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8000,6 +8787,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8249,7 +9042,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -8711,6 +9503,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9213,7 +10017,6 @@ "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" @@ -9423,6 +10226,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -9725,7 +10537,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -9745,6 +10556,18 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9835,6 +10658,60 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10109,7 +10986,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10487,6 +11363,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -10756,6 +11638,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.17" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -10789,6 +11689,30 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10833,7 +11757,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -11196,6 +12120,18 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.1.tgz", @@ -11226,6 +12162,49 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -11384,6 +12363,36 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -11394,6 +12403,12 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7060ffb..82d4695 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@types/better-sqlite3": "^7.6.13", + "@types/jsdom": "^27.0.0", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -67,10 +68,13 @@ "wait-on": "^9.0.1" }, "dependencies": { + "@mozilla/readability": "^0.6.0", "axios": "^1.7.0", "better-sqlite3": "^12.4.1", + "jsdom": "^27.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "sharp": "^0.34.4", "zustand": "^5.0.8" }, "optionalDependencies": { diff --git a/src/main/index.ts b/src/main/index.ts index bd401ff..71739a3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,11 +16,64 @@ console.log('===== SETUP COMPLETED ====='); let mainWindow: BrowserWindow | null = null; +// Window state persistence +interface WindowState { + width: number; + height: number; + x?: number; + y?: number; + isMaximized: boolean; +} + +let windowState: WindowState = { + width: 1200, + height: 800, + isMaximized: false, +}; + +// Load window state from database +const loadWindowState = (): WindowState => { + try { + const stateJson = databaseService.getSetting('window-state'); + if (stateJson) { + return JSON.parse(stateJson); + } + } catch (error) { + console.error('Failed to load window state:', error); + } + return windowState; +}; + +// Save window state to database +const saveWindowState = () => { + try { + if (!mainWindow) return; + + const bounds = mainWindow.getBounds(); + windowState = { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: mainWindow.isMaximized(), + }; + + databaseService.setSetting('window-state', JSON.stringify(windowState)); + } catch (error) { + console.error('Failed to save window state:', error); + } +}; + const createWindow = () => { + // Load previous window state + windowState = loadWindowState(); + // Create the browser window mainWindow = new BrowserWindow({ - width: 1200, - height: 800, + width: windowState.width, + height: windowState.height, + x: windowState.x, + y: windowState.y, minWidth: 800, minHeight: 600, webPreferences: { @@ -67,9 +120,34 @@ const createWindow = () => { // Show window when ready to prevent flicker mainWindow.once('ready-to-show', () => { + // Restore maximized state + if (windowState.isMaximized) { + mainWindow?.maximize(); + } mainWindow?.show(); }); + // Save window state on resize/move + mainWindow.on('resize', () => { + if (!mainWindow?.isMaximized()) { + saveWindowState(); + } + }); + + mainWindow.on('move', () => { + if (!mainWindow?.isMaximized()) { + saveWindowState(); + } + }); + + mainWindow.on('maximize', saveWindowState); + mainWindow.on('unmaximize', saveWindowState); + + // Save state before closing + mainWindow.on('close', () => { + saveWindowState(); + }); + // Emitted when the window is closed mainWindow.on('closed', () => { mainWindow = null; @@ -81,6 +159,9 @@ app.whenReady().then(async () => { // Initialize database databaseService.initialize(); + // Set flag to indicate app is running - used for crash detection + databaseService.setSetting('app-running', 'true'); + // Register IPC handlers console.log('Registering IPC handlers...'); try { @@ -167,6 +248,10 @@ app.on('window-all-closed', () => { // Cleanup on app quit app.on('before-quit', () => { + // Clear tabs on clean exit (not on crash) + databaseService.clearTabs(); + // Mark app as cleanly closed + databaseService.setSetting('app-running', 'false'); databaseService.close(); ollamaService.stop(); }); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9a9f8b8..346b055 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { ipcMain, BrowserWindow, webContents } from 'electron'; import { databaseService, HistoryEntry, Bookmark, Tab } from '../services/database'; import { validateUrl, @@ -7,6 +7,7 @@ import { validateBoolean, } from '../utils/validation'; import { ollamaService } from '../services/ollama'; +import { captureService } from '../services/capture'; import type { GenerateOptions, ChatOptions } from '../../shared/types'; export function registerIpcHandlers() { @@ -265,6 +266,12 @@ export function registerIpcHandlers() { return databaseService.clearTabs(); }); + ipcMain.handle('tabs:wasCrash', async () => { + // If app-running is "true", it means the app crashed (didn't cleanly exit) + const wasRunning = databaseService.getSetting('app-running'); + return wasRunning === 'true'; + }); + // Webview control handlers ipcMain.handle('webview:openDevTools', async (_event) => { // The webview will handle this via executeJavaScript @@ -288,6 +295,93 @@ export function registerIpcHandlers() { } }); + // Page capture handlers + ipcMain.handle('capture:page', async (event, options?: any) => { + try { + const webContents = event.sender; + + // Get the focused webview's webContents instead of the main window + const focusedWebContents = webContents.isFocused() + ? webContents + : BrowserWindow.getFocusedWindow()?.webContents; + + if (!focusedWebContents) { + throw new Error('No active page to capture'); + } + + const captureOptions = { + includeScreenshot: options?.includeScreenshot ?? true, + extractReadable: options?.extractReadable ?? true, + maxWidth: options?.maxWidth ?? 1280, + maxHeight: options?.maxHeight ?? 720, + quality: options?.quality ?? 80, + }; + + return await captureService.capturePage(focusedWebContents, captureOptions); + } catch (error: any) { + console.error('capture:page error:', error.message); + throw error; + } + }); + + ipcMain.handle('capture:screenshot', async (event, options?: any) => { + try { + // Find the active webview (browser tab) instead of the chat window + const allWebContents = webContents.getAllWebContents(); + const webviewContents = allWebContents.find(wc => wc.getType() === 'webview'); + + if (!webviewContents) { + throw new Error('No active browser tab to capture screenshot'); + } + + const captureOptions = { + maxWidth: options?.maxWidth ?? 1280, + maxHeight: options?.maxHeight ?? 720, + quality: options?.quality ?? 80, + }; + + return await captureService.captureScreenshotOnly(webviewContents, captureOptions); + } catch (error: any) { + console.error('capture:screenshot error:', error.message); + throw error; + } + }); + + ipcMain.handle('capture:forVision', async (event) => { + try { + // Find the active webview (browser tab) instead of the chat window + const allWebContents = webContents.getAllWebContents(); + const webviewContents = allWebContents.find(wc => wc.getType() === 'webview'); + + if (!webviewContents) { + throw new Error('No active browser tab for vision capture'); + } + + return await captureService.captureForVision(webviewContents); + } catch (error: any) { + console.error('capture:forVision error:', error.message); + throw error; + } + }); + + ipcMain.handle('capture:forText', async (event) => { + try { + // Find the active webview (browser tab) instead of the chat window + // Webviews have type 'webview' and are guest windows + const allWebContents = webContents.getAllWebContents(); + const webviewContents = allWebContents.find(wc => wc.getType() === 'webview'); + + if (!webviewContents) { + throw new Error('No active browser tab for text capture'); + } + + return await captureService.captureForText(webviewContents); + } catch (error: any) { + console.error('capture:forText error:', error.message); + throw error; + } + }); + // Ollama/LLM handlers ipcMain.handle('ollama:isRunning', async () => { try { @@ -390,7 +484,7 @@ export function registerIpcHandlers() { } }); - ipcMain.handle('ollama:chat', async (event, options: ChatOptions) => { + ipcMain.handle('ollama:chat', async (event, options: ChatOptions & { planningMode?: boolean; tools?: any[] }) => { try { if (!options || typeof options !== 'object') { throw new Error('Invalid chat options'); @@ -408,7 +502,7 @@ export function registerIpcHandlers() { throw new Error('Invalid message object'); } validateString(msg.content, 'Message content', 50000); - if (!['system', 'user', 'assistant'].includes(msg.role)) { + if (!['system', 'user', 'assistant', 'tool'].includes(msg.role)) { throw new Error('Invalid message role'); } } @@ -429,10 +523,18 @@ export function registerIpcHandlers() { messages: options.messages, context: options.context, stream: true, + planningMode: options.planningMode, + tools: options.tools, }); for await (const token of generator) { - event.sender.send('ollama:chatToken', token); + // Handle both string tokens and tool call objects + if (typeof token === 'string') { + event.sender.send('ollama:chatToken', token); + } else if (token.type === 'tool_calls') { + // Send tool calls to renderer for display + event.sender.send('ollama:toolCalls', token.tool_calls); + } } return { success: true }; @@ -441,4 +543,116 @@ export function registerIpcHandlers() { throw error; } }); + + // Tool execution handlers + ipcMain.handle('tool:search_history', async (event, args: any) => { + try { + const { query = '', limit = 10 } = args || {}; + const history = await databaseService.searchHistory(query, limit); + return history.map((h) => ({ + title: h.title, + url: h.url, + visitTime: h.visitTime, + })); + } catch (error: any) { + console.error('tool:search_history error:', error.message); + throw error; + } + }); + + ipcMain.handle('tool:get_bookmarks', async (event, args: any) => { + try { + const { query = '' } = args || {}; + const bookmarks = await databaseService.getBookmarks(); + // Filter by query if provided + if (query) { + const lowerQuery = query.toLowerCase(); + return bookmarks.filter( + (b) => + b.title.toLowerCase().includes(lowerQuery) || b.url.toLowerCase().includes(lowerQuery) + ); + } + return bookmarks.map((b) => ({ + title: b.title, + url: b.url, + tags: b.tags, + })); + } catch (error: any) { + console.error('tool:get_bookmarks error:', error.message); + throw error; + } + }); + + ipcMain.handle('tool:analyze_page_content', async (event) => { + try { + const webContents = event.sender; + const focusedWebContents = webContents.isFocused() + ? webContents + : BrowserWindow.getFocusedWindow()?.webContents; + + if (!focusedWebContents) { + throw new Error('No active page to analyze'); + } + + const capture = await captureService.capturePage(focusedWebContents, { + includeScreenshot: false, + extractReadable: true, + }); + + return { + url: capture.url, + title: capture.title, + content: capture.readable?.textContent || '', + excerpt: capture.readable?.excerpt || '', + }; + } catch (error: any) { + console.error('tool:analyze_page_content error:', error.message); + throw error; + } + }); + + ipcMain.handle('tool:capture_screenshot', async (event) => { + try { + const webContents = event.sender; + const focusedWebContents = webContents.isFocused() + ? webContents + : BrowserWindow.getFocusedWindow()?.webContents; + + if (!focusedWebContents) { + throw new Error('No active page to capture'); + } + + const screenshot = await captureService.captureScreenshot(focusedWebContents); + return { screenshot }; + } catch (error: any) { + console.error('tool:capture_screenshot error:', error.message); + throw error; + } + }); + + ipcMain.handle('tool:get_page_metadata', async (event) => { + try { + const webContents = event.sender; + const focusedWebContents = webContents.isFocused() + ? webContents + : BrowserWindow.getFocusedWindow()?.webContents; + + if (!focusedWebContents) { + throw new Error('No active page'); + } + + const url = focusedWebContents.getURL(); + const title = focusedWebContents.getTitle(); + + return { + url, + title, + canGoBack: focusedWebContents.canGoBack(), + canGoForward: focusedWebContents.canGoForward(), + }; + } catch (error: any) { + console.error('tool:get_page_metadata error:', error.message); + throw error; + } + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 0839835..15b6748 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -22,6 +22,10 @@ const ALLOWED_INVOKE_CHANNELS = [ 'webview:openDevTools', 'webview:print', 'webview:viewSource', + 'capture:page', + 'capture:screenshot', + 'capture:forVision', + 'capture:forText', 'ollama:isRunning', 'ollama:start', 'ollama:listModels', @@ -29,6 +33,11 @@ const ALLOWED_INVOKE_CHANNELS = [ 'ollama:deleteModel', 'ollama:generate', 'ollama:chat', + 'tool:search_history', + 'tool:get_bookmarks', + 'tool:analyze_page_content', + 'tool:capture_screenshot', + 'tool:get_page_metadata', ]; const ALLOWED_LISTEN_CHANNELS = [ @@ -36,6 +45,7 @@ const ALLOWED_LISTEN_CHANNELS = [ 'ollama:pullProgress', 'ollama:generateToken', 'ollama:chatToken', + 'ollama:toolCalls', ]; // Expose protected methods that allow the renderer process to use diff --git a/src/main/services/capture.ts b/src/main/services/capture.ts new file mode 100644 index 0000000..c903684 --- /dev/null +++ b/src/main/services/capture.ts @@ -0,0 +1,242 @@ +import { WebContents } from 'electron'; +import { Readability } from '@mozilla/readability'; +import { JSDOM } from 'jsdom'; + +// Try to load sharp, but make it optional +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let sharp: any = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + sharp = require('sharp'); +} catch (_error) { + console.warn('Sharp module not available. Screenshots will use PNG format without optimization.'); +} + +export interface CaptureOptions { + /** + * Maximum width for the captured image + */ + maxWidth?: number; + /** + * Maximum height for the captured image + */ + maxHeight?: number; + /** + * JPEG quality (0-100) + */ + quality?: number; + /** + * Whether to extract readable content using Mozilla Readability + */ + extractReadable?: boolean; + /** + * Whether to capture a screenshot + */ + includeScreenshot?: boolean; +} + +export interface PageCapture { + /** + * Screenshot as base64-encoded JPEG (if includeScreenshot is true) + */ + screenshot?: string; + /** + * Page URL + */ + url: string; + /** + * Page title + */ + title: string; + /** + * Selected text (if any) + */ + selectedText?: string; + /** + * Full page HTML (if extractReadable is false) + */ + html?: string; + /** + * Readable content extracted by Mozilla Readability (if extractReadable is true) + */ + readable?: { + title: string; + byline: string | null; + content: string; + textContent: string; + excerpt: string; + }; +} + +export class CaptureService { + /** + * Capture page screenshot and optimize it for AI vision models + */ + private async captureScreenshot( + webContents: WebContents, + options: CaptureOptions = {} + ): Promise { + try { + const { maxWidth = 1280, maxHeight = 720, quality = 80 } = options; + + // Capture the page as a NativeImage + const image = await webContents.capturePage(); + + // If sharp is available, optimize the image + if (sharp) { + try { + const buffer = image.toPNG(); + const optimized = await sharp(buffer) + .resize(maxWidth, maxHeight, { + fit: 'inside', + withoutEnlargement: true, + }) + .jpeg({ + quality, + mozjpeg: true, // Better compression + }) + .toBuffer(); + + return optimized.toString('base64'); + } catch (sharpError) { + console.warn('Sharp optimization failed, using PNG fallback:', sharpError); + } + } + + // Fallback: use JPEG from Electron (no resize) + const jpegBuffer = image.toJPEG(quality); + return jpegBuffer.toString('base64'); + } catch (error) { + console.error('Failed to capture screenshot:', error); + return null; + } + } + + /** + * Extract readable content from HTML using Mozilla Readability + */ + private extractReadableContent(html: string, url: string): PageCapture['readable'] | null { + try { + const dom = new JSDOM(html, { url }); + const reader = new Readability(dom.window.document); + const article = reader.parse(); + + if (!article) { + return null; + } + + return { + title: article.title, + byline: article.byline, + content: article.content, + textContent: article.textContent, + excerpt: article.excerpt, + }; + } catch (error) { + console.error('Failed to extract readable content:', error); + return null; + } + } + + /** + * Get selected text from the page + */ + private async getSelectedText(webContents: WebContents): Promise { + try { + const result = await webContents.executeJavaScript('window.getSelection().toString()'); + return result || undefined; + } catch (error) { + console.error('Failed to get selected text:', error); + return undefined; + } + } + + /** + * Get page HTML content + */ + private async getPageHTML(webContents: WebContents): Promise { + try { + return await webContents.executeJavaScript('document.documentElement.outerHTML'); + } catch (error) { + console.error('Failed to get page HTML:', error); + return ''; + } + } + + /** + * Capture comprehensive page context including screenshot, text, and metadata + */ + async capturePage(webContents: WebContents, options: CaptureOptions = {}): Promise { + const { extractReadable = true, includeScreenshot = true } = options; + + // Get basic page info + const url = webContents.getURL(); + const title = webContents.getTitle(); + + // Capture screenshot if requested + let screenshot: string | undefined; + if (includeScreenshot) { + const screenshotData = await this.captureScreenshot(webContents, options); + screenshot = screenshotData || undefined; + } + + // Get selected text + const selectedText = await this.getSelectedText(webContents); + + // Get page HTML + const html = await this.getPageHTML(webContents); + + // Extract readable content if requested + let readable: PageCapture['readable'] | undefined; + if (extractReadable && html) { + readable = this.extractReadableContent(html, url) || undefined; + } + + return { + screenshot, + url, + title, + selectedText, + html: extractReadable ? undefined : html, + readable, + }; + } + + /** + * Capture page with optimized settings for vision models + * Includes screenshot and readable text content + */ + async captureForVision(webContents: WebContents): Promise { + return this.capturePage(webContents, { + includeScreenshot: true, + extractReadable: true, + maxWidth: 1280, + maxHeight: 720, + quality: 80, + }); + } + + /** + * Capture page with text-only content (no screenshot) + * Useful for text-only models + */ + async captureForText(webContents: WebContents): Promise { + return this.capturePage(webContents, { + includeScreenshot: false, + extractReadable: true, + }); + } + + /** + * Quick capture of just the screenshot + */ + async captureScreenshotOnly( + webContents: WebContents, + options: CaptureOptions = {} + ): Promise { + return this.captureScreenshot(webContents, options); + } +} + +// Export singleton instance +export const captureService = new CaptureService(); diff --git a/src/main/services/database.ts b/src/main/services/database.ts index a417425..583b960 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -141,6 +141,15 @@ class DatabaseService { CREATE INDEX IF NOT EXISTS idx_tabs_position ON tabs(position); `); + + // Settings table for app preferences (window state, etc.) + this.db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); } // History operations @@ -477,6 +486,33 @@ class DatabaseService { }; } + // Settings operations + getSetting(key: string): string | null { + if (!this.db) throw new Error('Database not initialized'); + + const row = this.db + .prepare('SELECT value FROM settings WHERE key = ?') + .get(key) as { value: string } | undefined; + + return row ? row.value : null; + } + + setSetting(key: string, value: string): void { + if (!this.db) throw new Error('Database not initialized'); + + this.db + .prepare( + ` + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + ` + ) + .run(key, value, Date.now()); + } + close() { this.db?.close(); } diff --git a/src/main/services/ollama.ts b/src/main/services/ollama.ts index af8c3d8..8c87e47 100644 --- a/src/main/services/ollama.ts +++ b/src/main/services/ollama.ts @@ -3,6 +3,8 @@ import { spawn, ChildProcess } from 'child_process'; import { app } from 'electron'; import path from 'path'; import fs from 'fs'; +import http from 'http'; +import https from 'https'; export interface OllamaModel { name: string; @@ -55,7 +57,7 @@ export interface GenerateResponse { } export interface ChatMessage { - role: 'system' | 'user' | 'assistant'; + role: 'system' | 'user' | 'assistant' | 'tool'; content: string; images?: string[]; } @@ -65,6 +67,8 @@ export interface ChatRequest { messages: ChatMessage[]; stream?: boolean; context?: AIContext; + tools?: any[]; // Tool definitions in Ollama format + planningMode?: boolean; // Enable tool calling behavior } export class OllamaService { @@ -79,6 +83,10 @@ export class OllamaService { this.client = axios.create({ baseURL: this.baseURL, timeout: 120000, // 2 minutes for model operations + decompress: false, // Disable automatic decompression to match curl behavior + headers: { + 'Accept-Encoding': 'identity', // Disable compression to match curl + }, }); } @@ -156,7 +164,8 @@ export class OllamaService { if (content) { // Limit content to first 5000 characters to avoid token limits - const truncatedContent = content.length > 5000 ? content.substring(0, 5000) + '...' : content; + const truncatedContent = + content.length > 5000 ? content.substring(0, 5000) + '...' : content; contextParts.push(`\nPage Content:\n${truncatedContent}`); } } @@ -164,18 +173,18 @@ export class OllamaService { // Add browsing history context if (context.browsingHistory && context.browsingHistory.length > 0) { contextParts.push('\n## Recent Browsing History'); - const historyItems = context.browsingHistory.slice(0, 10).map( - (h: any) => `- ${h.title || 'Untitled'} (${h.url})` - ); + const historyItems = context.browsingHistory + .slice(0, 10) + .map((h: any) => `- ${h.title || 'Untitled'} (${h.url})`); contextParts.push(historyItems.join('\n')); } // Add bookmarks context if (context.bookmarks && context.bookmarks.length > 0) { contextParts.push('\n## Bookmarks'); - const bookmarkItems = context.bookmarks.slice(0, 10).map( - (b: any) => `- ${b.title || 'Untitled'} (${b.url})` - ); + const bookmarkItems = context.bookmarks + .slice(0, 10) + .map((b: any) => `- ${b.title || 'Untitled'} (${b.url})`); contextParts.push(bookmarkItems.join('\n')); } @@ -388,11 +397,11 @@ export class OllamaService { responseType: 'stream', timeout: 0, // No timeout for downloads // Add socket timeout to detect stalled connections - httpAgent: new (require('http').Agent)({ + httpAgent: new http.Agent({ keepAlive: true, timeout: 60000, // 60 second socket timeout }), - httpsAgent: new (require('https').Agent)({ + httpsAgent: new https.Agent({ keepAlive: true, timeout: 60000, // 60 second socket timeout }), @@ -438,7 +447,7 @@ export class OllamaService { clearInterval(heartbeatInterval); throw new Error(progress.error || 'Unknown error during download'); } - } catch (parseError) { + } catch (_parseError) { console.warn('Failed to parse progress line:', line); } } @@ -459,11 +468,7 @@ export class OllamaService { const errorCode = error.code || 'UNKNOWN'; const errorMessage = error.message || 'Unknown error'; - console.error( - `Pull model attempt ${attempt + 1} failed:`, - errorCode, - errorMessage - ); + console.error(`Pull model attempt ${attempt + 1} failed:`, errorCode, errorMessage); // If not retryable or max retries reached, throw if (!this.isRetryableError(error) || attempt >= maxRetries) { @@ -595,52 +600,97 @@ export class OllamaService { * Chat completion with conversation history and context awareness * Returns an async generator for streaming responses */ - async *chat(request: ChatRequest): AsyncGenerator { + async *chat(request: ChatRequest): AsyncGenerator { await this.ensureRunning(); try { let messages = [...request.messages]; - // If context is provided, prepend it as a system message or enhance existing system message + // If context is provided, prepend it to the first user message instead of using system role + // This avoids Ollama 500 errors with llama3.2-vision when using system messages with streaming if (request.context) { const contextualSystem = this.buildContextualSystemPrompt('', request.context); if (contextualSystem) { - // Check if there's already a system message - const systemMessageIndex = messages.findIndex((m) => m.role === 'system'); - - if (systemMessageIndex >= 0) { - // Enhance existing system message - messages[systemMessageIndex] = { - ...messages[systemMessageIndex], - content: messages[systemMessageIndex].content + '\n\n' + contextualSystem, + // Find the first user message and prepend context + const firstUserIndex = messages.findIndex((m) => m.role === 'user'); + if (firstUserIndex >= 0) { + messages[firstUserIndex] = { + ...messages[firstUserIndex], + content: contextualSystem + '\n\n' + messages[firstUserIndex].content, }; - } else { - // Add new system message at the beginning - messages = [ - { - role: 'system', - content: contextualSystem, - }, - ...messages, - ]; } } } // Build the request with enhanced messages - const ollamaRequest = { + const ollamaRequest: any = { model: request.model, messages: messages, stream: request.stream, }; - const response = await this.client.post('/api/chat', ollamaRequest, { - responseType: 'stream', - timeout: 0, + // Add tools if planning mode is enabled + if (request.planningMode && request.tools && request.tools.length > 0) { + ollamaRequest.tools = request.tools; + } + + console.log('[Ollama] Sending chat request:', JSON.stringify(ollamaRequest, null, 2)); + + // Check if any message has images to determine timeout + const hasImages = messages.some((m: any) => m.images && m.images.length > 0); + // Vision models with images can take 5+ minutes to process + const requestTimeout = hasImages ? 300000 : 60000; // 5 minutes for images, 60 seconds for text + + console.log(`[Ollama] Using timeout: ${requestTimeout}ms (hasImages: ${hasImages})`); + + // Use native Node.js http instead of axios for streaming to match curl behavior + const stream = await new Promise((resolve, reject) => { + const data = JSON.stringify(ollamaRequest); + const options = { + hostname: 'localhost', + port: 11434, + path: '/api/chat', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + 'Connection': 'keep-alive', // Keep connection alive for streaming + }, + timeout: requestTimeout, + }; + + const req = http.request(options, (res) => { + console.log('[Ollama] Got response:', res.statusCode, res.statusMessage); + + // Handle non-200 responses + if (res.statusCode !== 200) { + let errorBody = ''; + res.on('data', chunk => errorBody += chunk); + res.on('end', () => { + reject(new Error(`HTTP ${res.statusCode}: ${errorBody}`)); + }); + return; + } + + resolve(res); + }); + + req.on('error', (error) => { + console.error('[Ollama] Request error:', error); + reject(error); + }); + + req.on('timeout', () => { + console.error('[Ollama] Request timeout'); + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.write(data); + req.end(); }); - const stream = response.data; let buffer = ''; for await (const chunk of stream) { @@ -653,6 +703,11 @@ export class OllamaService { try { const data = JSON.parse(line); + // Check for tool calls + if (data.message?.tool_calls && data.message.tool_calls.length > 0) { + yield { type: 'tool_calls', tool_calls: data.message.tool_calls }; + } + if (data.message?.content) { yield data.message.content; } diff --git a/src/renderer/components/Browser/MultiWebViewContainer.tsx b/src/renderer/components/Browser/MultiWebViewContainer.tsx index 1911780..0809421 100644 --- a/src/renderer/components/Browser/MultiWebViewContainer.tsx +++ b/src/renderer/components/Browser/MultiWebViewContainer.tsx @@ -280,7 +280,9 @@ export const MultiWebViewContainer = forwardRef((props, ref) => { // Update active tab info in browser store useEffect(() => { + const activeTab = tabs.find((t) => t.id === activeTabId); const activeWebview = getActiveWebview(); + if (activeWebview) { try { setCanGoBack(activeWebview.canGoBack()); @@ -288,10 +290,20 @@ export const MultiWebViewContainer = forwardRef((props, ref) => { setCurrentUrl(activeWebview.getURL() || ''); setPageTitle(activeWebview.getTitle() || ''); } catch { - // Webview not ready yet + // Webview not ready yet - use tab data + setCurrentUrl(activeTab?.url || ''); + setPageTitle(activeTab?.title || ''); + setCanGoBack(false); + setCanGoForward(false); } + } else if (activeTab) { + // No webview yet (new tab or suspended), use tab data + setCurrentUrl(activeTab.url || ''); + setPageTitle(activeTab.title || ''); + setCanGoBack(false); + setCanGoForward(false); } - }, [activeTabId]); + }, [activeTabId, tabs]); // Navigate tab when URL changes (after initial mount) useEffect(() => { @@ -387,7 +399,7 @@ export const MultiWebViewContainer = forwardRef((props, ref) => { d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /> -

Welcome to Browser-LLM

+

Welcome to Open Browser

Enter a URL or search query in the address bar to get started.

diff --git a/src/renderer/components/Browser/NavigationBar.tsx b/src/renderer/components/Browser/NavigationBar.tsx index 21846a9..b4cc9c3 100644 --- a/src/renderer/components/Browser/NavigationBar.tsx +++ b/src/renderer/components/Browser/NavigationBar.tsx @@ -2,9 +2,11 @@ import React, { useState, KeyboardEvent, RefObject, useEffect } from 'react'; import { useBrowserStore } from '../../store/browser'; import { useTabsStore } from '../../store/tabs'; import { useModelStore } from '../../store/models'; +import { useChatStore } from '../../store/chat'; import { WebViewHandle } from './MultiWebViewContainer'; import { browserDataService } from '../../services/browserData'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; +import { supportsVision } from '../../../shared/modelRegistry'; interface NavigationBarProps { webviewRef: RefObject; @@ -28,7 +30,8 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { toggleBookmarks, } = useBrowserStore(); const { updateTab, activeTabId } = useTabsStore(); - const { setIsModelManagerOpen } = useModelStore(); + const { setIsModelManagerOpen, defaultModel } = useModelStore(); + const { sendChatMessage } = useChatStore(); const [inputValue, setInputValue] = useState(''); const [isFocused, setIsFocused] = useState(false); @@ -39,6 +42,13 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { const [showMenu, setShowMenu] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + // Sync inputValue with currentUrl when not focused (for tab changes) + useEffect(() => { + if (!isFocused) { + setInputValue(''); + } + }, [currentUrl, isFocused]); + // Check bookmark status when URL changes useEffect(() => { if (currentUrl) { @@ -158,6 +168,10 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { }; const handleFocus = () => { + // Pre-populate with current URL when focusing + if (!inputValue && currentUrl) { + setInputValue(currentUrl); + } setIsFocused(true); // Select all on focus for easy editing setTimeout(() => { @@ -210,11 +224,148 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { setShowMenu(true); }; + const handleAskAI = async () => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + + try { + // Capture page content based on model capability + const hasVision = supportsVision(defaultModel); + const capture = await window.electron.invoke( + hasVision ? 'capture:forVision' : 'capture:forText' + ); + + // Open chat if not already open + if (!isChatOpen) { + toggleChat(); + } + + // Send to AI with page context (this will add the user message and context info) + const prompt = hasVision + ? 'What is on this page? Please analyze the screenshot and content.' + : 'What is on this page? Please summarize the content.'; + + await sendChatMessage(prompt, capture.screenshot ? [capture.screenshot] : undefined, capture); + } catch (error) { + console.error('Failed to ask AI about page:', error); + alert('Failed to analyze page. Please try again.'); + } + }; + + const handleExplainSelection = async () => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + + try { + // Get selected text + const selectedText = await webviewRef.current?.executeJavaScript( + 'window.getSelection().toString()' + ); + + if (!selectedText) { + alert('Please select some text first'); + return; + } + + // Open chat if not already open + if (!isChatOpen) { + toggleChat(); + } + + // Capture page context + const pageCapture = await window.electron.invoke('capture:forText'); + + // Send to AI with selected text in context + const prompt = `Please explain the following text:\n\n"${selectedText}"`; + await sendChatMessage(prompt, undefined, { + ...pageCapture, + selectedText, + }); + } catch (error) { + console.error('Failed to explain selection:', error); + alert('Failed to explain text. Please try again.'); + } + }; + + const handleSummarizePage = async () => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + + try { + // Capture page content for text (for context) + const pageCapture = await window.electron.invoke('capture:forText'); + + // Open chat if not already open + if (!isChatOpen) { + toggleChat(); + } + + // Send to AI with page context + const prompt = 'Please provide a concise summary of this page.'; + await sendChatMessage(prompt, undefined, pageCapture); + } catch (error) { + console.error('Failed to summarize page:', error); + alert('Failed to summarize page. Please try again.'); + } + }; + // Check if URL is secure const isSecure = currentUrl.startsWith('https://'); const hasUrl = !!currentUrl; const menuItems: ContextMenuItem[] = [ + { + label: 'Ask AI about this page', + icon: ( + + + + ), + onClick: handleAskAI, + disabled: !hasUrl || !defaultModel, + }, + { + label: 'Explain selected text', + icon: ( + + + + ), + onClick: handleExplainSelection, + disabled: !hasUrl || !defaultModel, + }, + { + label: 'Summarize page', + icon: ( + + + + ), + onClick: handleSummarizePage, + disabled: !hasUrl || !defaultModel, + }, + { label: '', separator: true, onClick: () => {} }, { label: 'Zoom In', shortcut: 'Ctrl++', diff --git a/src/renderer/components/Browser/TabBar.tsx b/src/renderer/components/Browser/TabBar.tsx index 768977a..a6c067c 100644 --- a/src/renderer/components/Browser/TabBar.tsx +++ b/src/renderer/components/Browser/TabBar.tsx @@ -20,7 +20,7 @@ export const TabBar: React.FC = () => { return (
{/* Tabs */} -
+
{tabs.map((tab) => (
{ const { messages, isStreaming, currentModel, - addMessage, - appendToLastMessage, - setIsStreaming, + error, + planningMode, setCurrentModel, setError, - startNewMessage, + setPlanningMode, + sendChatMessage, + clearMessages, } = useChatStore(); const { models, @@ -25,11 +27,16 @@ export const ChatSidebar: React.FC = () => { } = useModelStore(); const { isChatOpen, toggleChat } = useBrowserStore(); const [input, setInput] = useState(''); + const [attachedImages, setAttachedImages] = useState([]); + const [isCapturing, setIsCapturing] = useState(false); + const [includeContext, setIncludeContext] = useState(false); // User toggle for page context + const [contextSent, setContextSent] = useState(false); // Track if context has been sent const messagesEndRef = useRef(null); // Get current model metadata const currentModelInfo = models.find((m) => m.name === currentModel); - const supportsVision = currentModelInfo?.metadata?.capabilities.vision ?? false; + const hasVisionSupport = currentModel ? supportsVision(currentModel) : false; + const hasToolCallingSupport = currentModel ? supportsToolCalling(currentModel) : false; // Load models on mount useEffect(() => { @@ -68,57 +75,61 @@ export const ChatSidebar: React.FC = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + // Reset context sent flag when conversation is cleared + useEffect(() => { + if (messages.length === 0) { + setContextSent(false); + } + }, [messages]); + const handleSend = async () => { if (!input.trim() || isStreaming || !currentModel) return; const userMessage = input.trim(); + const messageImages = [...attachedImages]; setInput(''); - - // Add user message - addMessage({ - role: 'user', - content: userMessage, - }); - - // Start assistant message - setIsStreaming(true); - setError(null); - startNewMessage('assistant'); + setAttachedImages([]); try { - // Set up streaming listener - const unsubscribe = window.electron.on('ollama:chatToken', (token: string) => { - appendToLastMessage(token); - }); - - // Convert messages to Ollama format - const chatMessages = messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })); - - // Add the new user message - chatMessages.push({ - role: 'user' as const, - content: userMessage, - }); - - // Send chat request - await window.electron.invoke('ollama:chat', { - model: currentModel, - messages: chatMessages, - }); + // Capture page context only if user enabled it AND context hasn't been sent yet + let pageCapture = undefined; + if (includeContext && !contextSent) { + pageCapture = await window.electron.invoke('capture:forText'); + setContextSent(true); // Mark that we've sent context + } - // Cleanup listener - unsubscribe(); + await sendChatMessage( + userMessage, + messageImages.length > 0 ? messageImages : undefined, + pageCapture + ); } catch (error: any) { console.error('Chat error:', error); setError(error.message || 'Failed to get response from AI'); + } + }; + + const handleCaptureScreenshot = async () => { + if (!hasVisionSupport || isCapturing) return; + + setIsCapturing(true); + try { + const screenshot = await window.electron.invoke('capture:screenshot'); + if (screenshot) { + setAttachedImages((prev) => [...prev, screenshot]); + } + } catch (error) { + console.error('Failed to capture screenshot:', error); + alert('Failed to capture screenshot. Please try again.'); } finally { - setIsStreaming(false); + setIsCapturing(false); } }; + const handleRemoveImage = (index: number) => { + setAttachedImages((prev) => prev.filter((_, i) => i !== index)); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -198,9 +209,32 @@ export const ChatSidebar: React.FC = () => { {currentModelInfo && (
-
- {supportsVision ? ( - Vision +
+ {hasVisionSupport ? ( + <> + + + + + + Vision + + ) : ( Text-Only )} @@ -231,10 +265,49 @@ export const ChatSidebar: React.FC = () => { )}
+ {/* Error Banner */} + {error && !isStreaming && ( +
+
+ + + +
+

Connection Error

+

{error}

+
+ +
+
+ )} + {/* Messages */}
{messages.length === 0 ? ( -
+
{ />
-
+

Start a conversation

Ask questions about the current page or anything else

+ {hasVisionSupport && ( +
+

Vision Model Active

+

+ This model can analyze images! Try using the three-dot menu to ask about the + current page with visual context. +

+
+ )} + {hasToolCallingSupport && ( +
+

Tool Calling Supported

+

+ Enable Planning Mode in the input area below to let the AI use tools for searching history, analyzing pages, and more. +

+
+ )}
) : ( @@ -283,21 +373,126 @@ export const ChatSidebar: React.FC = () => {
{/* Input */} -
+
+ {/* Image Attachments Preview */} + {attachedImages.length > 0 && ( +
+ {attachedImages.map((image, index) => ( +
+ {`Attachment + +
+ ))} +
+ )} + + {/* Input Area */}
-