diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c35621d..caa7f13 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,11 @@ "Bash(npm run lint:*)", "Bash(curl:*)", "Bash(unzip:*)", - "Bash(chmod:*)" + "Bash(chmod:*)", + "Bash(npm run format:*)", + "Bash(npx electron-rebuild:*)", + "WebFetch(domain:ollama.com)", + "Bash(npm run lint:fix:*)" ], "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..c9f1308 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "package": "npm run build && electron-builder", "setup:ollama": "node scripts/download-ollama.js", "postinstall": "node scripts/download-ollama.js", - "lint": "eslint . --ext .ts,.tsx,.js,.jsx --max-warnings 50", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx --max-warnings 120", "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", @@ -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..9553da6 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 { @@ -90,6 +171,15 @@ app.whenReady().then(async () => { console.error('Failed to register IPC handlers:', error); } + // Clean up any orphan Ollama processes from previous sessions + console.log('Cleaning up orphan Ollama processes...'); + try { + await ollamaService.killOrphanProcesses(); + console.log('Orphan process cleanup complete'); + } catch (error) { + console.error('Failed to clean up orphan processes:', error); + } + // Auto-start Ollama service console.log('Starting Ollama service...'); try { @@ -165,10 +255,61 @@ app.on('window-all-closed', () => { } }); -// Cleanup on app quit -app.on('before-quit', () => { +// Track if cleanup has been performed +let cleanupPerformed = false; + +// Perform cleanup +async function performCleanup(): Promise { + if (cleanupPerformed) { + console.log('[Main] Cleanup already performed, skipping...'); + return; + } + + cleanupPerformed = true; + console.log('[Main] Performing cleanup...'); + + try { + // Stop Ollama service first + await ollamaService.stop(); + console.log('[Main] Ollama service stopped'); + } catch (error) { + console.error('[Main] Error stopping Ollama service:', error); + } + + // Clear tabs on clean exit (not on crash) + databaseService.clearTabs(); + // Mark app as cleanly closed + databaseService.setSetting('app-running', 'false'); databaseService.close(); - ollamaService.stop(); + + console.log('[Main] Cleanup complete'); +} + +// Cleanup on app quit (primary handler) +app.on('before-quit', async (e) => { + if (!cleanupPerformed) { + // Prevent default quit to allow async cleanup + e.preventDefault(); + + console.log('[Main] App quitting (before-quit)...'); + await performCleanup(); + + // Now actually quit the app + app.exit(0); + } +}); + +// Backup cleanup handler in case before-quit doesn't fire +app.on('will-quit', async (e) => { + if (!cleanupPerformed) { + e.preventDefault(); + + console.log('[Main] App quitting (will-quit)...'); + await performCleanup(); + + // Now actually quit the app + app.exit(0); + } }); // Security: Configure web contents behavior @@ -288,6 +429,47 @@ app.on('web-contents-created', (event, contents) => { menu.append(new MenuItem({ type: 'separator' })); } + // AI features for selected text + if (params.selectionText && params.selectionText.trim().length > 0) { + menu.append( + new MenuItem({ + label: 'Ask AI about this', + click: () => { + mainWindow?.webContents.send('ai-ask-about-selection', params.selectionText); + }, + }) + ); + + menu.append( + new MenuItem({ + label: 'Explain this', + click: () => { + mainWindow?.webContents.send('ai-explain-selection', params.selectionText); + }, + }) + ); + + menu.append( + new MenuItem({ + label: 'Translate this', + click: () => { + mainWindow?.webContents.send('ai-translate-selection', params.selectionText); + }, + }) + ); + + menu.append( + new MenuItem({ + label: 'Summarize this', + click: () => { + mainWindow?.webContents.send('ai-summarize-selection', params.selectionText); + }, + }) + ); + + menu.append(new MenuItem({ type: 'separator' })); + } + // View Page Source menu.append( new MenuItem({ diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9a9f8b8..5b47e40 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,5 @@ -import { ipcMain } from 'electron'; +import { ipcMain, BrowserWindow, webContents, dialog, shell } from 'electron'; +import path from 'path'; import { databaseService, HistoryEntry, Bookmark, Tab } from '../services/database'; import { validateUrl, @@ -7,6 +8,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 +267,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 +296,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,54 +485,443 @@ export function registerIpcHandlers() { } }); - ipcMain.handle('ollama:chat', async (event, options: ChatOptions) => { - try { - if (!options || typeof options !== 'object') { - throw new Error('Invalid chat options'); - } + ipcMain.handle( + 'ollama:chat', + async (event, options: ChatOptions & { planningMode?: boolean; tools?: any[] }) => { + try { + if (!options || typeof options !== 'object') { + throw new Error('Invalid chat options'); + } - validateString(options.model, 'Model name', 256); + validateString(options.model, 'Model name', 256); - if (!Array.isArray(options.messages)) { - throw new Error('Messages must be an array'); - } + if (!Array.isArray(options.messages)) { + throw new Error('Messages must be an array'); + } - // Validate messages - for (const msg of options.messages) { - if (!msg || typeof msg !== 'object') { - throw new Error('Invalid message object'); + // Validate messages + for (const msg of options.messages) { + if (!msg || typeof msg !== 'object') { + throw new Error('Invalid message object'); + } + validateString(msg.content, 'Message content', 50000); + if (!['system', 'user', 'assistant', 'tool'].includes(msg.role)) { + throw new Error('Invalid message role'); + } } - validateString(msg.content, 'Message content', 50000); - if (!['system', 'user', 'assistant'].includes(msg.role)) { - throw new Error('Invalid message role'); + + // Validate context if provided + if (options.context) { + if (options.context.page?.url) { + validateString(options.context.page.url, 'Page URL', 2048); + } + if (options.context.page?.title) { + validateString(options.context.page.title, 'Page title', 1024); + } } - } - // Validate context if provided - if (options.context) { - if (options.context.page?.url) { - validateString(options.context.page.url, 'Page URL', 2048); + const messages = [...options.messages]; + + // Add system message if not already present + // System messages should be the first message in the conversation + if (messages.length === 0 || messages[0].role !== 'system') { + const defaultSystemPrompt = `You are an AI assistant integrated directly into a web browser, giving you unique capabilities to help users browse, research, and understand the web. + +## Your Environment +- You are running inside a desktop browser application called "Browser-LLM" +- You can see and interact with web pages the user is viewing +- You have access to the user's browsing history and bookmarks +- You can execute tools to help users accomplish tasks + +## Your Capabilities +When Planning Mode is enabled, you have access to these tools: +- **analyze_page_content**: Extract and analyze the full text content of the current webpage +- **capture_screenshot**: Take a screenshot of the current page (vision models only) +- **get_page_metadata**: Get metadata like title, URL, description, etc. +- **search_history**: Search through the user's browsing history +- **get_bookmarks**: Access the user's saved bookmarks +- **web_search**: Perform a Google search and retrieve results + +## How to Help Users +1. **Context First**: If the user's message includes "## Current Page Context" with page content, USE THAT CONTEXT DIRECTLY - you don't need to call tools to get what you already have. Only call tools when: + - The context is missing or incomplete + - The user asks you to search history or bookmarks + - The user asks you to perform a web search + - You need a screenshot and one isn't provided + +2. **Working with Page Context**: When page context is provided in the message: + - URL and page title are shown at the top + - Page content is included in the context + - Simply analyze and respond based on what's provided + - No need to call analyze_page_content unless you need fresh data + +3. **When to Use Tools**: + - **analyze_page_content**: Only if context is missing or user explicitly asks for fresh analysis + - **capture_screenshot**: Only if user asks about visuals and no screenshot is provided + - **search_history**: When user asks about past browsing or finding previously visited pages + - **get_bookmarks**: When user asks about their saved bookmarks + - **web_search**: When user asks you to search for new information online + +4. **Be Specific**: Reference specific content from pages, use exact quotes, cite URLs + +5. **Research Mode**: When asked to research or find information: + - Use search_history to see if the user has already visited relevant pages + - Use web_search to find new information + - Combine multiple sources for comprehensive answers + +6. **Accuracy**: Always verify information when possible by checking multiple sources + +## Communication Style +- Be clear, concise, and helpful +- Use markdown formatting for better readability +- When using tools, explain what you're doing and why +- If you can't help with something, explain why and suggest alternatives + +## Important Notes +- You are a LOCAL AI running on the user's machine - respect their privacy +- Page context and history are ONLY available when the user enables those features +- Always be honest about your capabilities and limitations`; + + // Always use the default system prompt as the base + // User customizations are ADDED, not replaced + const userCustomPrompt = databaseService.getSetting('system-prompt') || ''; + const userInfo = databaseService.getSetting('user-info') || ''; + const customInstructions = databaseService.getSetting('custom-instructions') || ''; + + // Get current date and time + const now = new Date(); + const dateTimeInfo = `Current date and time: ${now.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + })}`; + + // Build full system message - start with base prompt + let fullSystemMessage = `${defaultSystemPrompt}\n\n${dateTimeInfo}`; + + // Add user customizations at the bottom + if (userCustomPrompt && userCustomPrompt.trim()) { + fullSystemMessage += `\n\n## Additional Instructions\n${userCustomPrompt}`; + } + if (userInfo && userInfo.trim()) { + fullSystemMessage += `\n\n## User Information\n${userInfo}`; + } + if (customInstructions && customInstructions.trim()) { + fullSystemMessage += `\n\n## Custom Instructions\n${customInstructions}`; + } + + // Add system message as the first message + messages.unshift({ + role: 'system', + content: fullSystemMessage, + }); } - if (options.context.page?.title) { - validateString(options.context.page.title, 'Page title', 1024); + + // Stream response tokens back to renderer + const generator = ollamaService.chat({ + model: options.model, + messages, + context: options.context, + stream: true, + planningMode: options.planningMode, + tools: options.tools, + }); + + for await (const token of generator) { + // Handle both string tokens and special objects (tool calls, thinking) + 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); + } else if (token.type === 'thinking') { + // Send thinking tokens separately to renderer (using 'reasoning' to avoid reserved word) + event.sender.send('ollama:reasoning', token.content); + } } + + return { success: true }; + } catch (error: any) { + console.error('ollama:chat error:', error.message); + throw error; } + } + ); - // Stream response tokens back to renderer - const generator = ollamaService.chat({ - model: options.model, - messages: options.messages, - context: options.context, - stream: true, + // 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 { + // Find the active webview (browser tab) instead of the main window + const allWebContents = webContents.getAllWebContents(); + const webviewContents = allWebContents.find((wc) => wc.getType() === 'webview'); + + if (!webviewContents) { + throw new Error('No browser tab is currently open. Please open a webpage first, then try again.'); + } + + const capture = await captureService.capturePage(webviewContents, { + includeScreenshot: false, + extractReadable: true, }); - for await (const token of generator) { - event.sender.send('ollama:chatToken', token); + 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 { + // Find the active webview (browser tab) instead of the main window + const allWebContents = webContents.getAllWebContents(); + const webviewContents = allWebContents.find((wc) => wc.getType() === 'webview'); + + if (!webviewContents) { + throw new Error('No browser tab is currently open. Please open a webpage first, then try again.'); + } + + const screenshot = await captureService.captureScreenshot(webviewContents); + 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; + } + }); + + ipcMain.handle('tool:web_search', async (event, args: any) => { + try { + const query = args.query; + if (!query) { + throw new Error('Search query is required'); + } + + validateString(query, 'Search query', 2048); + + const captureScreenshot = args.capture_screenshot !== false; // Default to true + + // This is a placeholder - the actual implementation will be handled + // by the renderer process since it needs to coordinate with the tab system + // and webview. We return instructions for the renderer to execute. + return { + action: 'open_search_tab', + query, + url: `https://www.google.com/search?q=${encodeURIComponent(query)}`, + captureScreenshot, + message: `Opening Google search for: "${query}"`, + }; + } catch (error: any) { + console.error('tool:web_search error:', error.message); + throw error; + } + }); + + // Settings handlers + ipcMain.handle('settings:get', async (_event, key: string) => { + try { + validateString(key, 'Settings key', 256); + return databaseService.getSetting(key); + } catch (error: any) { + console.error('settings:get error:', error.message); + throw error; + } + }); + + ipcMain.handle('settings:set', async (_event, key: string, value: string) => { + try { + validateString(key, 'Settings key', 256); + validateString(value, 'Settings value', 10000); // Allow long values for system prompts + return databaseService.setSetting(key, value); + } catch (error: any) { + console.error('settings:set error:', error.message); + throw error; + } + }); + + // Models folder handlers + ipcMain.handle('models:getFolder', async () => { + try { + const ollamaHome = + process.env.OLLAMA_MODELS || + (process.platform === 'win32' + ? path.join(process.env.USERPROFILE || '', '.ollama', 'models') + : path.join(process.env.HOME || '', '.ollama', 'models')); + return ollamaHome; + } catch (error: any) { + console.error('models:getFolder error:', error.message); + throw error; + } + }); + + ipcMain.handle('models:selectFolder', async () => { + try { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select Models Folder', + buttonLabel: 'Select Folder', + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; } + return result.filePaths[0]; + } catch (error: any) { + console.error('models:selectFolder error:', error.message); + throw error; + } + }); + + ipcMain.handle('models:openFolder', async (_event, folderPath?: string) => { + try { + const targetPath = + folderPath || + process.env.OLLAMA_MODELS || + (process.platform === 'win32' + ? path.join(process.env.USERPROFILE || '', '.ollama', 'models') + : path.join(process.env.HOME || '', '.ollama', 'models')); + + await shell.openPath(targetPath); + return { success: true }; + } catch (error: any) { + console.error('models:openFolder error:', error.message); + throw error; + } + }); + + // Download control handlers + ipcMain.handle('ollama:cancelPull', async (_event, modelName: string) => { + try { + validateString(modelName, 'Model name', 256); + ollamaService.cancelPull(modelName); + return { success: true }; + } catch (error: any) { + console.error('ollama:cancelPull error:', error.message); + throw error; + } + }); + + // Chat control handlers + ipcMain.handle('ollama:cancelChat', async () => { + try { + ollamaService.cancelChat(); + return { success: true }; + } catch (error: any) { + console.error('ollama:cancelChat error:', error.message); + throw error; + } + }); + + // Service monitoring and control handlers + ipcMain.handle('ollama:getStatus', async () => { + try { + return await ollamaService.getServiceStatus(); + } catch (error: any) { + console.error('ollama:getStatus error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:restart', async () => { + try { + await ollamaService.restart(); + return { success: true }; + } catch (error: any) { + console.error('ollama:restart error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:forceKill', async () => { + try { + await ollamaService.forceKill(); + return { success: true }; + } catch (error: any) { + console.error('ollama:forceKill error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:stop', async () => { + try { + await ollamaService.stop(); return { success: true }; } catch (error: any) { - console.error('ollama:chat error:', error.message); + console.error('ollama:stop error:', error.message); throw error; } }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 0839835..39e6294 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,20 @@ const ALLOWED_INVOKE_CHANNELS = [ 'ollama:deleteModel', 'ollama:generate', 'ollama:chat', + 'ollama:getStatus', + 'tool:search_history', + 'tool:get_bookmarks', + 'tool:analyze_page_content', + 'tool:capture_screenshot', + 'tool:get_page_metadata', + 'tool:web_search', + 'settings:get', + 'settings:set', + 'models:getFolder', + 'models:list', + 'models:pull-progress', + 'models:openFolder', + 'models:selectFolder', ]; const ALLOWED_LISTEN_CHANNELS = [ @@ -36,6 +54,13 @@ const ALLOWED_LISTEN_CHANNELS = [ 'ollama:pullProgress', 'ollama:generateToken', 'ollama:chatToken', + 'ollama:toolCalls', + 'ollama:reasoning', + 'ollama:getStatus', + 'ai-ask-about-selection', + 'ai-explain-selection', + 'ai-translate-selection', + 'ai-summarize-selection', ]; // 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..610a83c 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -141,6 +141,34 @@ 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 + ); + `); + + // Initialize default system prompt if not exists + const systemPrompt = this.getSetting('system-prompt'); + if (!systemPrompt) { + this.setSetting( + 'system-prompt', + 'You are a helpful AI assistant integrated into a web browser. Provide clear, concise, and accurate responses.' + ); + } + + const userInfo = this.getSetting('user-info'); + if (!userInfo) { + this.setSetting('user-info', ''); + } + + const customInstructions = this.getSetting('custom-instructions'); + if (!customInstructions) { + this.setSetting('custom-instructions', ''); + } } // History operations @@ -477,6 +505,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..a1c8541 100644 --- a/src/main/services/ollama.ts +++ b/src/main/services/ollama.ts @@ -1,8 +1,10 @@ import axios, { AxiosInstance } from 'axios'; -import { spawn, ChildProcess } from 'child_process'; +import { spawn, ChildProcess, exec } 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,26 @@ export interface ChatRequest { messages: ChatMessage[]; stream?: boolean; context?: AIContext; + tools?: any[]; // Tool definitions in Ollama format + planningMode?: boolean; // Enable tool calling behavior +} + +export interface ProcessStats { + pid: number; + memory: { + rss: number; // Resident Set Size in bytes + heapTotal: number; + heapUsed: number; + external: number; + }; + cpu: number; // CPU usage percentage + uptime: number; // Process uptime in seconds +} + +export interface OllamaServiceStatus { + isRunning: boolean; + processStats?: ProcessStats; + error?: string; } export class OllamaService { @@ -73,12 +95,20 @@ export class OllamaService { private process: ChildProcess | null = null; private isServerRunning = false; private activePulls: Map = new Map(); + private activeRequest: http.ClientRequest | null = null; + private processStartTime: number = 0; + private isStarting = false; + private isStopping = false; constructor(baseURL = 'http://localhost:11434') { this.baseURL = baseURL; 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 +186,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 +195,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')); } @@ -260,11 +291,44 @@ export class OllamaService { * Start Ollama server process */ async start(): Promise { - if (await this.isRunning()) { - console.log('Ollama server is already running'); + console.log('[Ollama] start() called, current state:', { + isStarting: this.isStarting, + isStopping: this.isStopping, + hasProcess: !!this.process, + processPid: this.process?.pid, + }); + + if (this.isStarting) { + console.warn('[Ollama] Already starting, rejecting duplicate start'); + throw new Error('Ollama is already starting'); + } + + if (this.isStopping) { + console.warn('[Ollama] Currently stopping, rejecting start'); + throw new Error('Ollama is currently stopping, please wait'); + } + + const isRunning = await this.isRunning(); + console.log('[Ollama] isRunning check:', isRunning); + + if (isRunning) { + console.log('[Ollama] Server is already running, not starting new process'); + + // If running but we don't have a process reference, try to find it + if (!this.process) { + console.log('[Ollama] No process reference, attempting to find existing Ollama PID'); + const existingPid = await this.findOllamaPid(); + if (existingPid) { + console.log('[Ollama] Found existing Ollama process with PID:', existingPid); + // We can't re-attach to the process, but at least we know it exists + } + } return; } + this.isStarting = true; + console.log('[Ollama] Starting new Ollama process...'); + return new Promise((resolve, reject) => { try { // Get bundled Ollama path @@ -283,33 +347,102 @@ export class OllamaService { env.PATH = `${libPath};${env.PATH}`; } + // Enable GPU acceleration if available + // Ollama will automatically detect and use CUDA (NVIDIA) or ROCm (AMD) if installed + // These environment variables ensure optimal GPU usage + env.OLLAMA_NUM_PARALLEL = '1'; // Number of parallel requests (1 for better single-request performance) + env.OLLAMA_MAX_LOADED_MODELS = '1'; // Keep only 1 model in memory for better performance + + // For NVIDIA GPUs, ensure all layers are offloaded to GPU + // Set to 0 to let Ollama auto-detect optimal layer count, or set a high number like 999 + if (!env.OLLAMA_NUM_GPU) { + env.OLLAMA_NUM_GPU = '999'; // Offload all layers to GPU if available + } + + console.log('Ollama environment:', { + numGpu: env.OLLAMA_NUM_GPU, + numParallel: env.OLLAMA_NUM_PARALLEL, + maxLoaded: env.OLLAMA_MAX_LOADED_MODELS, + }); + // Spawn ollama serve process with bundled executable - this.process = spawn(ollamaPath, ['serve'], { + // On Windows, we need special handling to ensure child processes are killed + const spawnOptions: any = { stdio: 'pipe', - detached: false, env, - }); + }; + + if (process.platform === 'win32') { + // On Windows, DO NOT detach so it stays in the same process group + // We'll use taskkill /T to kill the entire tree when stopping + spawnOptions.detached = false; + // Set the process to be killed when parent dies (Windows-specific) + spawnOptions.windowsHide = true; + } else { + // On Unix, create a new process group so we can kill it and its children + spawnOptions.detached = false; + } + + this.process = spawn(ollamaPath, ['serve'], spawnOptions); + + // Track process start time + this.processStartTime = Date.now(); + const startedPid = this.process.pid; + console.log('[Ollama] Spawned process with PID:', startedPid); this.process.on('error', (error) => { - console.error('Failed to start Ollama:', error); + console.error('[Ollama] Process error event:', error); + this.processStartTime = 0; + this.isStarting = false; reject(new Error('Failed to start Ollama. Please check the installation.')); }); + // Track unexpected exits + this.process.on('exit', (code, signal) => { + console.warn('[Ollama] Process exited unexpectedly!', { + pid: startedPid, + code, + signal, + wasExpected: this.isStopping, + }); + // Only clean up if we're not already stopping (unexpected exit) + if (!this.isStopping) { + console.error('[Ollama] UNEXPECTED EXIT - process died without being stopped!'); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + } + }); + // Wait for server to be ready + let isChecking = false; const checkInterval = setInterval(async () => { - if (await this.isRunning()) { - clearInterval(checkInterval); - console.log('Ollama server started successfully'); - resolve(); + // Skip if a check is already in progress + if (isChecking) { + return; + } + + isChecking = true; + try { + if (await this.isRunning()) { + clearInterval(checkInterval); + console.log('Ollama server started successfully'); + this.isStarting = false; + resolve(); + } + } finally { + isChecking = false; } }, 500); // Timeout after 10 seconds setTimeout(() => { clearInterval(checkInterval); + this.isStarting = false; reject(new Error('Ollama server failed to start within timeout')); }, 10000); } catch (error) { + this.isStarting = false; reject(error); } }); @@ -319,20 +452,483 @@ export class OllamaService { * Ensure Ollama is running, start it if not */ async ensureRunning(): Promise { - if (!(await this.isRunning())) { + const isRunning = await this.isRunning(); + console.log('[Ollama] ensureRunning check:', isRunning); + + if (!isRunning) { + console.log('[Ollama] Service not running, calling start()'); await this.start(); + } else { + console.log('[Ollama] Service already running, skipping start'); + } + } + + /** + * Kill any orphan Ollama processes left from previous sessions + */ + async killOrphanProcesses(): Promise { + console.log('[Ollama] Checking for orphan Ollama processes...'); + + if (process.platform === 'win32') { + // On Windows, find and kill all ollama.exe processes + return new Promise((resolve) => { + exec('tasklist /FI "IMAGENAME eq ollama.exe" /FO CSV /NH', (error, stdout) => { + if (error || !stdout || stdout.includes('INFO: No tasks are running')) { + console.log('[Ollama] No orphan processes found'); + resolve(); + return; + } + + // Parse the output to get PIDs + const lines = stdout.trim().split('\n'); + const pids: number[] = []; + + for (const line of lines) { + const match = line.match(/"ollama\.exe","(\d+)"/); + if (match && match[1]) { + pids.push(parseInt(match[1], 10)); + } + } + + if (pids.length === 0) { + console.log('[Ollama] No orphan processes found'); + resolve(); + return; + } + + console.log(`[Ollama] Found ${pids.length} orphan Ollama process(es), terminating...`); + + // Kill all found processes + const killPromises = pids.map((pid) => { + return new Promise((resolveKill) => { + exec(`taskkill /F /PID ${pid} /T`, (killError) => { + if (killError) { + console.error(`[Ollama] Failed to kill process ${pid}:`, killError); + } else { + console.log(`[Ollama] Killed orphan process ${pid}`); + } + resolveKill(); + }); + }); + }); + + Promise.all(killPromises).then(() => { + console.log('[Ollama] All orphan processes terminated'); + resolve(); + }); + }); + }); + } else if (process.platform === 'darwin') { + // On macOS, find and kill Ollama processes + return new Promise((resolve) => { + exec('pgrep -f "ollama"', (error, stdout) => { + if (error || !stdout.trim()) { + console.log('[Ollama] No orphan processes found'); + resolve(); + return; + } + + const pids = stdout + .trim() + .split('\n') + .map((pid) => parseInt(pid, 10)) + .filter((pid) => !isNaN(pid)); + + if (pids.length === 0) { + console.log('[Ollama] No orphan processes found'); + resolve(); + return; + } + + console.log(`[Ollama] Found ${pids.length} orphan Ollama process(es), terminating...`); + + // Kill all found processes + const killPromises = pids.map((pid) => { + return new Promise((resolveKill) => { + exec(`kill -9 ${pid}`, (killError) => { + if (killError) { + console.error(`[Ollama] Failed to kill process ${pid}:`, killError); + } else { + console.log(`[Ollama] Killed orphan process ${pid}`); + } + resolveKill(); + }); + }); + }); + + Promise.all(killPromises).then(() => { + console.log('[Ollama] All orphan processes terminated'); + resolve(); + }); + }); + }); + } else { + console.log('[Ollama] Orphan process cleanup not implemented for this platform'); + return Promise.resolve(); + } + } + + /** + * Stop Ollama server process with force + */ + async stop(): Promise { + if (this.isStopping) { + throw new Error('Ollama is already stopping'); + } + + if (!this.process) { + console.log('[Ollama] No process to stop'); + return; + } + + this.isStopping = true; + const pid = this.process.pid; + console.log(`[Ollama] Stopping Ollama process (PID: ${pid})`); + + return new Promise((resolve) => { + if (!this.process) { + this.isStopping = false; + resolve(); + return; + } + + // On Windows, Ollama spawns child processes (especially for vision models) + // We need to kill ALL ollama.exe processes, not just the tree from our PID + // This is because vision models may spawn detached worker processes + if (process.platform === 'win32') { + console.log('[Ollama] Killing all ollama.exe processes on Windows'); + exec('taskkill /F /IM ollama.exe /T', (error) => { + if (error) { + // This might error if no processes found, which is fine + console.log('[Ollama] taskkill completed (may have been no processes)'); + } else { + console.log('[Ollama] All ollama.exe processes killed successfully'); + } + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + }); + return; + } + + // On Unix, try graceful shutdown first + const timeout = setTimeout(() => { + console.warn('[Ollama] Process did not exit gracefully, forcing termination'); + + if (pid) { + // Use SIGKILL on Unix-like systems + try { + process.kill(pid, 'SIGKILL'); + } catch (err) { + console.error('[Ollama] Failed to send SIGKILL:', err); + } + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + } + }, 3000); // Wait 3 seconds for graceful shutdown + + this.process.once('exit', (code, signal) => { + clearTimeout(timeout); + console.log(`[Ollama] Process exited with code ${code} and signal ${signal}`); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + }); + + // Try graceful shutdown first on Unix + try { + this.process.kill('SIGTERM'); + } catch (err) { + console.error('[Ollama] Failed to send termination signal:', err); + clearTimeout(timeout); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + } + }); + } + + /** + * Find the PID of a running Ollama process + */ + private async findOllamaPid(): Promise { + return new Promise((resolve) => { + if (process.platform === 'win32') { + exec('tasklist /FI "IMAGENAME eq ollama.exe" /FO CSV /NH', { timeout: 3000 }, (error, stdout) => { + if (error || !stdout || stdout.includes('INFO: No tasks are running')) { + resolve(null); + return; + } + + // Parse the first ollama.exe process found + const match = stdout.match(/"ollama\.exe","(\d+)"/); + if (match && match[1]) { + resolve(parseInt(match[1], 10)); + } else { + resolve(null); + } + }); + } else { + exec('pgrep -f "ollama"', { timeout: 3000 }, (error, stdout) => { + if (error || !stdout.trim()) { + resolve(null); + return; + } + + const pids = stdout.trim().split('\n'); + if (pids.length > 0 && pids[0]) { + resolve(parseInt(pids[0], 10)); + } else { + resolve(null); + } + }); + } + }); + } + + /** + * Get process statistics for the Ollama server + */ + async getProcessStats(): Promise { + let pid: number | undefined; + + // If we have a reference to the process, use its PID + if (this.process && this.process.pid) { + pid = this.process.pid; + } else { + // Otherwise, try to find the Ollama process by name + pid = await this.findOllamaPid(); + if (!pid) { + return null; + } + } + + return new Promise((resolve) => { + // Set a timeout to prevent hanging + const timeout = setTimeout(() => { + console.warn('[Ollama] getProcessStats timeout'); + resolve(null); + }, 5000); + + if (process.platform === 'win32') { + // On Windows, use wmic to get process info + exec( + `wmic process where processid=${pid} get WorkingSetSize,KernelModeTime,UserModeTime /format:csv`, + { timeout: 5000 }, + (error, stdout) => { + clearTimeout(timeout); + if (error) { + console.error('[Ollama] Failed to get process stats:', error); + resolve(null); + return; + } + + try { + // Parse CSV output (skip header and node line) + const lines = stdout.trim().split('\n').filter(line => line.trim()); + if (lines.length < 2) { + resolve(null); + return; + } + + const dataLine = lines[lines.length - 1]; + const parts = dataLine.split(','); + + if (parts.length >= 4) { + const workingSetSize = parseInt(parts[3], 10); // Memory in bytes + + // Calculate uptime (0 if we don't have start time) + const uptime = this.processStartTime > 0 + ? Math.floor((Date.now() - this.processStartTime) / 1000) + : 0; + + resolve({ + pid, + memory: { + rss: workingSetSize || 0, + heapTotal: 0, + heapUsed: 0, + external: 0, + }, + cpu: 0, // CPU calculation is complex on Windows, would need multiple samples + uptime, + }); + } else { + resolve(null); + } + } catch (parseError) { + console.error('[Ollama] Failed to parse process stats:', parseError); + resolve(null); + } + } + ); + } else { + // On Unix, use ps command + exec(`ps -p ${pid} -o rss=,pcpu=`, { timeout: 5000 }, (error, stdout) => { + clearTimeout(timeout); + if (error) { + console.error('[Ollama] Failed to get process stats:', error); + resolve(null); + return; + } + + try { + const output = stdout.trim().split(/\s+/); + const rss = parseInt(output[0], 10) * 1024; // Convert KB to bytes + const cpu = parseFloat(output[1]); + + const uptime = this.processStartTime > 0 + ? Math.floor((Date.now() - this.processStartTime) / 1000) + : 0; + + resolve({ + pid, + memory: { + rss, + heapTotal: 0, + heapUsed: 0, + external: 0, + }, + cpu, + uptime, + }); + } catch (parseError) { + console.error('[Ollama] Failed to parse process stats:', parseError); + resolve(null); + } + }); + } + }); + } + + /** + * Get comprehensive service status including process stats + */ + async getServiceStatus(): Promise { + const isRunning = await this.isRunning(); + console.log('[Ollama] getServiceStatus - isRunning:', isRunning); + + if (!isRunning) { + return { + isRunning: false, + error: 'Ollama service is not running', + }; } + + const processStats = await this.getProcessStats(); + console.log('[Ollama] getServiceStatus - processStats:', processStats); + + return { + isRunning: true, + processStats: processStats || undefined, + }; } /** - * Stop Ollama server process + * Restart the Ollama service */ - stop(): void { + async restart(): Promise { + console.log('[Ollama] Restarting service...'); + + // Stop the service and wait for it to fully stop + await this.stop(); + + // Wait for stop to complete (isStopping flag will be reset by stop()) + // Add extra delay to ensure cleanup is complete + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Verify process is fully stopped before starting if (this.process) { - this.process.kill(); - this.process = null; - this.isServerRunning = false; + throw new Error('Failed to stop Ollama completely before restart'); } + + await this.start(); + console.log('[Ollama] Service restarted successfully'); + } + + /** + * Force kill the Ollama process immediately + */ + async forceKill(): Promise { + if (!this.process || !this.process.pid) { + console.log('[Ollama] No process to kill'); + return; + } + + if (this.isStopping) { + throw new Error('Ollama is already stopping, use stop() instead'); + } + + const pid = this.process.pid; + const processRef = this.process; + console.log(`[Ollama] Force killing process (PID: ${pid})`); + + return new Promise((resolve) => { + // Set up exit listener before killing + const exitHandler = () => { + console.log('[Ollama] Process killed'); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + }; + + // Add timeout in case exit event doesn't fire + const timeout = setTimeout(() => { + processRef.removeListener('exit', exitHandler); + console.warn('[Ollama] Force kill timeout, cleaning up anyway'); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + }, 3000); + + processRef.once('exit', () => { + clearTimeout(timeout); + exitHandler(); + }); + + if (process.platform === 'win32') { + // Kill ALL ollama.exe processes to ensure vision model workers are killed too + exec('taskkill /F /IM ollama.exe /T', (error) => { + if (error) { + console.error('[Ollama] Failed to force kill processes:', error); + } + // Always clean up our reference + clearTimeout(timeout); + processRef.removeListener('exit', exitHandler); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + }); + } else { + try { + process.kill(pid, 'SIGKILL'); + } catch (err) { + console.error('[Ollama] Failed to send SIGKILL:', err); + clearTimeout(timeout); + processRef.removeListener('exit', exitHandler); + this.process = null; + this.isServerRunning = false; + this.processStartTime = 0; + this.isStopping = false; + resolve(); + } + } + }); } /** @@ -388,11 +984,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 +1034,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 +1055,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) { @@ -518,6 +1110,17 @@ export class OllamaService { this.activePulls.delete(modelName); } + /** + * Cancel the active chat request + */ + cancelChat(): void { + if (this.activeRequest) { + console.log('[Ollama] Canceling active chat request'); + this.activeRequest.destroy(); + this.activeRequest = null; + } + } + /** * Delete a model */ @@ -595,79 +1198,333 @@ 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 + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', // Disable nginx buffering + }, + 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', () => { + this.activeRequest = null; + reject(new Error(`HTTP ${res.statusCode}: ${errorBody}`)); + }); + return; + } + + resolve(res); + }); + + req.on('error', (error) => { + console.error('[Ollama] Request error:', error); + this.activeRequest = null; + reject(error); + }); + + req.on('timeout', () => { + console.error('[Ollama] Request timeout'); + req.destroy(); + this.activeRequest = null; + reject(new Error('Request timeout')); + }); + + // Store the request so it can be canceled + this.activeRequest = req; + + req.write(data); + req.end(); }); - const stream = response.data; + // Determine parsing strategy based on model + const modelName = request.model.toLowerCase(); + const useAggressiveParsing = modelName.includes('qwen'); + let buffer = ''; + let chunkCount = 0; + let tokenCount = 0; + + console.log( + `[Ollama] Using ${useAggressiveParsing ? 'aggressive' : 'standard'} parsing for model: ${request.model}` + ); + console.log('[Ollama] Waiting for stream data...'); + + const streamStartTime = Date.now(); + let firstChunkTime: number | undefined; for await (const chunk of stream) { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + chunkCount++; - for (const line of lines) { - if (line.trim()) { - try { - const data = JSON.parse(line); + // Track when first chunk arrives + if (!firstChunkTime) { + firstChunkTime = Date.now(); + const waitTime = firstChunkTime - streamStartTime; + console.log(`[Ollama] First chunk received after ${waitTime}ms`); + } + + const chunkStr = chunk.toString(); + buffer += chunkStr; + + // Log periodically to show stream is alive + if (chunkCount % 100 === 0) { + console.log(`[Ollama] Received ${chunkCount} chunks, ${tokenCount} tokens, buffer size: ${buffer.length}`); + // Debug: Show buffer content if no tokens are being extracted + if (tokenCount === 0 && chunkCount >= 100) { + console.log('[Ollama] DEBUG - Buffer sample:', buffer.substring(0, 200)); + } + } + + // Aggressive parsing for models that concatenate JSON without newlines (e.g., Qwen) + if (useAggressiveParsing) { + let processedSomething = true; + while (processedSomething && buffer.length > 0) { + processedSomething = false; + + // Strategy 1: Try to parse buffer as complete JSON (for small chunks) + if (buffer.length < 500 && buffer.trim().startsWith('{') && buffer.trim().endsWith('}')) { + try { + const data = JSON.parse(buffer); + processedSomething = true; + buffer = ''; + + if (data.message?.tool_calls && data.message.tool_calls.length > 0) { + yield { type: 'tool_calls', tool_calls: data.message.tool_calls }; + } + + // Qwen sends 'thinking' field (internal reasoning) - yield it separately from content + if (data.message?.thinking) { + yield { type: 'thinking', content: data.message.thinking }; + } - if (data.message?.content) { - yield data.message.content; + if (data.message?.content) { + tokenCount++; + yield data.message.content; + } + + if (data.done) { + console.log( + `[Ollama] Stream completed. Chunks: ${chunkCount}, Tokens: ${tokenCount}` + ); + this.activeRequest = null; + return; + } + continue; // Skip other strategies if this worked + } catch (_e) { + // Not valid JSON yet, try other strategies } + } - if (data.done) { - return; + // Strategy 2: Try to find concatenated JSON objects (}{) + if (buffer.includes('}{')) { + const parts = buffer.split(/(?<=\})(?=\{)/); + if (parts.length > 1) { + buffer = parts.pop() || ''; + + for (const part of parts) { + if (part.trim()) { + try { + const data = JSON.parse(part); + processedSomething = true; + + if (data.message?.tool_calls && data.message.tool_calls.length > 0) { + yield { type: 'tool_calls', tool_calls: data.message.tool_calls }; + } + + // Yield thinking separately from content + if (data.message?.thinking) { + yield { type: 'thinking', content: data.message.thinking }; + } + + if (data.message?.content) { + tokenCount++; + yield data.message.content; + } + + if (data.done) { + console.log( + `[Ollama] Stream completed. Chunks: ${chunkCount}, Tokens: ${tokenCount}` + ); + this.activeRequest = null; + return; + } + } catch (_e) { + // Invalid JSON, skip + } + } + } } - } catch (_e) { - console.warn('Failed to parse chat response line:', line); } + + // Strategy 3: Line-based parsing (in case format changes) + if (buffer.includes('\n')) { + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + processedSomething = true; + + if (data.message?.tool_calls && data.message.tool_calls.length > 0) { + yield { type: 'tool_calls', tool_calls: data.message.tool_calls }; + } + + // Yield thinking separately from content + if (data.message?.thinking) { + yield { type: 'thinking', content: data.message.thinking }; + } + + if (data.message?.content) { + tokenCount++; + yield data.message.content; + } + + if (data.done) { + console.log( + `[Ollama] Stream completed. Chunks: ${chunkCount}, Tokens: ${tokenCount}` + ); + this.activeRequest = null; + return; + } + } catch (_e) { + // Invalid JSON, skip + } + } + } + } + } + } else { + // Standard line-based parsing for most models (LLaMA, Mistral, etc.) + if (buffer.includes('\n')) { + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + + if (data.message?.tool_calls && data.message.tool_calls.length > 0) { + yield { type: 'tool_calls', tool_calls: data.message.tool_calls }; + } + + // Yield thinking separately from content + if (data.message?.thinking) { + yield { type: 'thinking', content: data.message.thinking }; + } + + if (data.message?.content) { + tokenCount++; + yield data.message.content; + } + + if (data.done) { + console.log( + `[Ollama] Stream completed. Chunks: ${chunkCount}, Tokens: ${tokenCount}` + ); + this.activeRequest = null; + return; + } + } catch (_e) { + console.warn('[Ollama] Failed to parse line:', line.substring(0, 50)); + } + } + } + } + } + } + + // Process any remaining buffer + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + if (data.message?.tool_calls && data.message.tool_calls.length > 0) { + yield { type: 'tool_calls', tool_calls: data.message.tool_calls }; + } + // Yield thinking separately from content + if (data.message?.thinking) { + yield { type: 'thinking', content: data.message.thinking }; + } + + if (data.message?.content) { + tokenCount++; + yield data.message.content; + } + if (data.done) { + console.log(`[Ollama] Stream completed. Chunks: ${chunkCount}, Tokens: ${tokenCount}`); + this.activeRequest = null; } + } catch (_e) { + // Ignore parse errors for final buffer } } } catch (error) { console.error('Failed to chat:', error); + this.activeRequest = null; throw new Error('Failed to chat with Ollama'); } } diff --git a/src/renderer/components/Browser/BrowserLayout.tsx b/src/renderer/components/Browser/BrowserLayout.tsx index c2ef3ac..8eeee8c 100644 --- a/src/renderer/components/Browser/BrowserLayout.tsx +++ b/src/renderer/components/Browser/BrowserLayout.tsx @@ -6,6 +6,7 @@ import { ChatSidebar } from '../Chat/ChatSidebar'; import { HistorySidebar } from './HistorySidebar'; import { BookmarksSidebar } from './BookmarksSidebar'; import { ModelManager } from '../Models/ModelManager'; +import { DownloadStatusBar } from '../Downloads/DownloadStatusBar'; import { useBrowserStore } from '../../store/browser'; import { useTabsStore } from '../../store/tabs'; import { useModelStore } from '../../store/models'; @@ -180,6 +181,9 @@ export const BrowserLayout: React.FC = () => { {/* Modal Overlays */} + + {/* Download Status Bar */} + ); }; diff --git a/src/renderer/components/Browser/ContextMenu.tsx b/src/renderer/components/Browser/ContextMenu.tsx index 0cbd8d8..ce35ce2 100644 --- a/src/renderer/components/Browser/ContextMenu.tsx +++ b/src/renderer/components/Browser/ContextMenu.tsx @@ -35,7 +35,7 @@ export const ContextMenu: React.FC = ({ items, position, onClo setTimeout(() => { document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscape); - }, 0); + }, 100); return () => { document.removeEventListener('mousedown', handleClickOutside); 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..d63e02b 100644 --- a/src/renderer/components/Browser/NavigationBar.tsx +++ b/src/renderer/components/Browser/NavigationBar.tsx @@ -2,9 +2,12 @@ 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 { SystemPromptSettings } from '../Settings/SystemPromptSettings'; +import { supportsVision } from '../../../shared/modelRegistry'; interface NavigationBarProps { webviewRef: RefObject; @@ -28,7 +31,8 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { toggleBookmarks, } = useBrowserStore(); const { updateTab, activeTabId } = useTabsStore(); - const { setIsModelManagerOpen } = useModelStore(); + const { setIsModelManagerOpen, defaultModel } = useModelStore(); + const { sendChatMessage, setCurrentModel } = useChatStore(); const [inputValue, setInputValue] = useState(''); const [isFocused, setIsFocused] = useState(false); @@ -38,6 +42,14 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [showMenu, setShowMenu] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const [showSystemPromptSettings, setShowSystemPromptSettings] = useState(false); + + // Sync inputValue with currentUrl when not focused (for tab changes) + useEffect(() => { + if (!isFocused) { + setInputValue(''); + } + }, [currentUrl, isFocused]); // Check bookmark status when URL changes useEffect(() => { @@ -51,6 +63,69 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { } }, [currentUrl, setIsBookmarked]); + // Listen for AI context menu actions from webview + useEffect(() => { + const handleAIAsk = async (selectedText: string) => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + setCurrentModel(defaultModel); + if (!isChatOpen) { + toggleChat(); + } + await sendChatMessage(`Can you help me with this: "${selectedText}"`); + }; + + const handleAIExplain = async (selectedText: string) => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + setCurrentModel(defaultModel); + if (!isChatOpen) { + toggleChat(); + } + await sendChatMessage(`Please explain this: "${selectedText}"`); + }; + + const handleAITranslate = async (selectedText: string) => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + setCurrentModel(defaultModel); + if (!isChatOpen) { + toggleChat(); + } + await sendChatMessage(`Please translate this to English: "${selectedText}"`); + }; + + const handleAISummarize = async (selectedText: string) => { + if (!defaultModel) { + alert('Please select a default model first'); + return; + } + setCurrentModel(defaultModel); + if (!isChatOpen) { + toggleChat(); + } + await sendChatMessage(`Please summarize this: "${selectedText}"`); + }; + + const unsubAsk = window.electron.on('ai-ask-about-selection', handleAIAsk); + const unsubExplain = window.electron.on('ai-explain-selection', handleAIExplain); + const unsubTranslate = window.electron.on('ai-translate-selection', handleAITranslate); + const unsubSummarize = window.electron.on('ai-summarize-selection', handleAISummarize); + + return () => { + unsubAsk(); + unsubExplain(); + unsubTranslate(); + unsubSummarize(); + }; + }, [defaultModel, isChatOpen, toggleChat, sendChatMessage, setCurrentModel]); + const handleToggleBookmark = async () => { if (!currentUrl) return; @@ -98,7 +173,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { } }, [inputValue, isFocused]); - const handleNavigate = () => { + const handleNavigate = async () => { if (!inputValue.trim()) return; let url = inputValue.trim(); @@ -109,8 +184,25 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { if (url.includes('.') && !url.includes(' ')) { url = 'https://' + url; } else { - // Treat as search query - url = `https://www.google.com/search?q=${encodeURIComponent(url)}`; + // Treat as AI prompt - send to chat + if (!defaultModel) { + alert('Please select a default model first to use AI chat'); + return; + } + + // Set the current model for the chat + setCurrentModel(defaultModel); + + // Open chat if not already open + if (!isChatOpen) { + toggleChat(); + } + + // Send the query to AI + await sendChatMessage(url); + setInputValue(''); + (document.activeElement as HTMLInputElement)?.blur(); + return; } } @@ -158,6 +250,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(() => { @@ -205,9 +301,112 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { }; const handleMenuClick = (e: React.MouseEvent) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setMenuPosition({ x: rect.right - 200, y: rect.bottom + 4 }); - setShowMenu(true); + e.preventDefault(); + e.stopPropagation(); + + if (showMenu) { + setShowMenu(false); + } else { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setMenuPosition({ x: rect.right - 200, y: rect.bottom + 4 }); + setShowMenu(true); + } + }; + + const handleMenuMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + 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 @@ -215,6 +414,73 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { 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: 'System Prompt Settings', + icon: ( + + + + + ), + onClick: () => setShowSystemPromptSettings(true), + }, + { label: '', separator: true, onClick: () => {} }, { label: 'Zoom In', shortcut: 'Ctrl++', @@ -463,7 +729,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => {
{suggestions.map((suggestion, index) => (
handleSuggestionClick(suggestion.url)} className={`px-4 py-2 cursor-pointer transition-colors ${ index === selectedSuggestionIndex ? 'bg-accent' : 'hover:bg-accent' @@ -592,6 +858,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { {/* Three-dots Menu Button */}
)} + + {/* System Prompt Settings Modal */} + setShowSystemPromptSettings(false)} + />
); }; diff --git a/src/renderer/components/Browser/TabBar.tsx b/src/renderer/components/Browser/TabBar.tsx index 768977a..5d7f418 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) => (
{ {tab.title || tab.url || 'New Tab'} - {/* Close button */} - + {/* Close button - only show if there's more than one tab */} + {tabs.length > 1 && ( + + )}
))}
diff --git a/src/renderer/components/Chat/ChatSidebar.tsx b/src/renderer/components/Chat/ChatSidebar.tsx index 3b58e0b..63cd105 100644 --- a/src/renderer/components/Chat/ChatSidebar.tsx +++ b/src/renderer/components/Chat/ChatSidebar.tsx @@ -2,18 +2,19 @@ import React, { useState, useEffect, useRef } from 'react'; import { useChatStore, Message } from '../../store/chat'; import { useBrowserStore } from '../../store/browser'; import { useModelStore } from '../../store/models'; +import { supportsVision, supportsToolCalling } from '../../../shared/modelRegistry'; export const ChatSidebar: React.FC = () => { const { messages, isStreaming, currentModel, - addMessage, - appendToLastMessage, - setIsStreaming, + error, + planningMode, setCurrentModel, setError, - startNewMessage, + setPlanningMode, + sendChatMessage, } = useChatStore(); const { models, @@ -25,11 +26,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(true); // User toggle for page context - enabled by default + 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 +74,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 +208,32 @@ export const ChatSidebar: React.FC = () => { {currentModelInfo && (
-
- {supportsVision ? ( - Vision +
+ {hasVisionSupport ? ( + <> + + + + + + Vision + + ) : ( Text-Only )} @@ -231,10 +264,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 */}
-