diff --git a/package.json b/package.json index aba3b06..32cda65 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ ], "main": "./.erb/dll/main.bundle.dev.js", "scripts": { - "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", + "build": "concurrently \"pnpm run build:main\" \"pnpm run build:renderer\"", "build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", @@ -76,11 +76,15 @@ ], "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", - "\\.(css|less|sass|scss)$": "identity-obj-proxy" + "\\.(css|less|sass|scss)$": "identity-obj-proxy", + "renderer/(.*)": "/src/renderer/$1" }, "setupFiles": [ "./.erb/scripts/check-build-exists.ts" ], + "setupFilesAfterEnv": [ + "./src/__tests__/jest.setup.js" + ], "testEnvironment": "jsdom", "testEnvironmentOptions": { "url": "http://localhost/" @@ -123,6 +127,7 @@ "lunr": "^2.3.9", "luxon": "^3.3.0", "openai": "^4.44.0", + "prop-types": "^15.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^9.0.1", @@ -139,7 +144,7 @@ "@svgr/webpack": "^8.1.0", "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", "@testing-library/jest-dom": "^6.1.3", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.3.0", "@types/jest": "^29.5.5", "@types/node": "20.6.2", "@types/react": "^18.2.21", @@ -177,13 +182,14 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "mini-css-extract-plugin": "^2.7.6", "patch-package": "^8.0.0", "postcss": "^8.4.31", "postinstall-postinstall": "^2.1.0", "prettier": "^3.0.3", "react-refresh": "^0.14.0", - "react-test-renderer": "^18.2.0", + "react-test-renderer": "^19.1.0", "rimraf": "^5.0.1", "sass": "^1.67.0", "sass-loader": "^16.0.3", @@ -278,7 +284,7 @@ } ] }, - "devEngines": { + "engines": { "node": ">=18.x", "npm": ">=7.x" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dcc33..7c607f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: openai: specifier: ^4.44.0 version: 4.73.0(encoding@0.1.13) + prop-types: + specifier: ^15.8.1 + version: 15.8.1 react: specifier: ^19.0.0 version: 19.0.0 @@ -139,8 +142,8 @@ importers: specifier: ^6.1.3 version: 6.6.3 '@testing-library/react': - specifier: ^14.0.0 - version: 14.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@9.3.4)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/jest': specifier: ^29.5.5 version: 29.5.14 @@ -252,6 +255,9 @@ importers: jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 + jest-fetch-mock: + specifier: ^3.0.3 + version: 3.0.3(encoding@0.1.13) mini-css-extract-plugin: specifier: ^2.7.6 version: 2.9.2(webpack@5.96.1(webpack-cli@5.1.4)) @@ -271,8 +277,8 @@ importers: specifier: ^0.14.0 version: 0.14.2 react-test-renderer: - specifier: ^18.2.0 - version: 18.3.1(react@19.0.0) + specifier: ^19.1.0 + version: 19.1.0(react@19.0.0) rimraf: specifier: ^5.0.1 version: 5.0.10 @@ -1801,12 +1807,20 @@ packages: resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/react@14.3.1': - resolution: {integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==} - engines: {node: '>=14'} + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true '@tiptap/core@2.10.2': resolution: {integrity: sha512-jYLXbYHTi1stLla/74J8NJizDtcJ/uokhG+1gN4DMWHDujaZOrRZhW98o9gN5BYAp4zv//TVX8H+afLZwKGCKQ==} @@ -3028,6 +3042,9 @@ packages: engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} hasBin: true + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4659,6 +4676,9 @@ packages: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-fetch-mock@3.0.3: + resolution: {integrity: sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==} + jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5818,6 +5838,9 @@ packages: bluebird: optional: true + promise-polyfill@8.3.0: + resolution: {integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==} + promise-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} @@ -5953,6 +5976,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.1.0: + resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==} + react-markdown@9.0.1: resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: @@ -5996,11 +6022,6 @@ packages: peerDependencies: react: '>=16.8' - react-shallow-renderer@16.15.0: - resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - react-style-singleton@2.2.1: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -6011,10 +6032,10 @@ packages: '@types/react': optional: true - react-test-renderer@18.3.1: - resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} + react-test-renderer@19.1.0: + resolution: {integrity: sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==} peerDependencies: - react: ^18.3.1 + react: ^19.1.0 react-textarea-autosize@8.5.5: resolution: {integrity: sha512-CVA94zmfp8m4bSHtWwmANaBR8EPsKy2aZ7KwqhoS4Ftib87F9Kvi7XQhOixypPLMc6kVYgOXvKFuuzZDpHGRPg==} @@ -6263,12 +6284,12 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + schema-utils@2.7.1: resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==} engines: {node: '>= 8.9.0'} @@ -9076,13 +9097,15 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@14.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@testing-library/react@16.3.0(@testing-library/dom@9.3.4)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.26.0 '@testing-library/dom': 9.3.4 - '@types/react-dom': 18.3.1 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 '@tiptap/core@2.10.2(@tiptap/pm@2.10.4)': dependencies: @@ -10603,6 +10626,12 @@ snapshots: dependencies: cross-spawn: 7.0.6 + cross-fetch@3.2.0(encoding@0.1.13): + dependencies: + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -12619,6 +12648,13 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + jest-fetch-mock@3.0.3(encoding@0.1.13): + dependencies: + cross-fetch: 3.2.0(encoding@0.1.13) + promise-polyfill: 8.3.0 + transitivePeerDependencies: + - encoding + jest-get-type@29.6.3: {} jest-haste-map@29.7.0: @@ -14006,6 +14042,8 @@ snapshots: promise-inflight@1.0.1: {} + promise-polyfill@8.3.0: {} + promise-retry@2.0.1: dependencies: err-code: 2.0.3 @@ -14183,6 +14221,8 @@ snapshots: react-is@18.3.1: {} + react-is@19.1.0: {} + react-markdown@9.0.1(@types/react@18.3.12)(react@19.0.0): dependencies: '@types/hast': 3.0.4 @@ -14233,12 +14273,6 @@ snapshots: '@remix-run/router': 1.21.0 react: 19.0.0 - react-shallow-renderer@16.15.0(react@19.0.0): - dependencies: - object-assign: 4.1.1 - react: 19.0.0 - react-is: 18.3.1 - react-style-singleton@2.2.1(@types/react@18.3.12)(react@19.0.0): dependencies: get-nonce: 1.0.1 @@ -14248,12 +14282,11 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - react-test-renderer@18.3.1(react@19.0.0): + react-test-renderer@19.1.0(react@19.0.0): dependencies: react: 19.0.0 - react-is: 18.3.1 - react-shallow-renderer: 16.15.0(react@19.0.0) - scheduler: 0.23.2 + react-is: 19.1.0 + scheduler: 0.26.0 react-textarea-autosize@8.5.5(@types/react@18.3.12)(react@19.0.0): dependencies: @@ -14521,12 +14554,10 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - scheduler@0.25.0: {} + scheduler@0.26.0: {} + schema-utils@2.7.1: dependencies: '@types/json-schema': 7.0.15 diff --git a/release/app/package-lock.json b/release/app/package-lock.json index 1fc9fc3..03a7954 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -1,719 +1,14 @@ { "name": "pile", - "version": "0.9.8", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pile", - "version": "0.9.8", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "keytar": "^7.9.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/fs-constants": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/keytar": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", "hasInstallScript": true, - "dependencies": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, - "node_modules/node-abi": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", - "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==" - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - } - }, - "dependencies": { - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "requires": { - "mimic-response": "^3.1.0" - } - }, - "deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" - }, - "detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==" - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "requires": { - "once": "^1.4.0" - } - }, - "expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" - }, - "fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "keytar": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", - "requires": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" - }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - }, - "mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, - "node-abi": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", - "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", - "requires": { - "semver": "^7.3.5" - } - }, - "node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "requires": { - "wrappy": "1" - } - }, - "prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "requires": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - } - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - } - }, - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "requires": { - "lru-cache": "^6.0.0" - } - }, - "simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" - }, - "simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "requires": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" - }, - "tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "requires": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - } - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "license": "MIT" } } } diff --git a/src/__tests__/AIContext.test.js b/src/__tests__/AIContext.test.js new file mode 100644 index 0000000..af8713b --- /dev/null +++ b/src/__tests__/AIContext.test.js @@ -0,0 +1,363 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { AIContextProvider, useAIContext } from '../renderer/context/AIContext'; +import { usePilesContext } from '../renderer/context/PilesContext'; +import { useElectronStore } from '../renderer/hooks/useElectronStore'; + +jest.mock('../renderer/context/PilesContext'); +jest.mock('../renderer/hooks/useElectronStore'); + +const mockPilesContext = { + currentPile: { id: '1', name: 'Test Pile' }, + updateCurrentPile: jest.fn(), +}; + +const mockElectronStore = + (store = {}) => + (key, initialValue) => { + const [state, setState] = React.useState(store[key] || initialValue); + const setStore = (value) => { + store[key] = value; + setState(value); + }; + return [state, setStore]; + }; + +describe('AIContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + window.electron = { + ipc: { + invoke: jest.fn().mockResolvedValue('test-key'), + on: jest.fn().mockReturnValue(() => {}), + }, + }; + usePilesContext.mockReturnValue(mockPilesContext); + }); + + it('initializes with default OpenAI settings', async () => { + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'openai', + model: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + }), + ); + + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(context.pileAIProvider).toBe('openai'); + expect(context.model).toBe('gpt-4o'); + expect(context.baseUrl).toBe('https://api.openai.com/v1'); + expect(context.ai.type).toBe('openai'); + }); + + it('initializes with gemini provider and correct settings', async () => { + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'gemini', + model: 'gemini-2.0-flash', // Include the expected model in the store + baseUrl: 'https://api.openai.com/v1', + }), + ); + + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(context.pileAIProvider).toBe('gemini'); + expect(context.model).toBe('gemini-2.0-flash'); + // baseUrl should remain as the stored value since getProviderBaseUrl returns null for gemini + expect(context.baseUrl).toBe('https://api.openai.com/v1'); + expect(context.ai.type).toBe('gemini'); + }); + + it('switches to gemini provider and updates settings', async () => { + const store = { pileAIProvider: 'openai' }; + useElectronStore.mockImplementation(mockElectronStore(store)); + + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(context.pileAIProvider).toBe('openai'); + + await act(async () => { + context.setPileAIProvider('gemini'); + }); + + expect(context.pileAIProvider).toBe('gemini'); + expect(context.model).toBe('gemini-2.0-flash'); + }); + + it('provides available models for gemini', async () => { + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'gemini', + }), + ); + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + await act(async () => { + render( + + + , + ); + }); + expect(context.getAvailableModels()).toEqual([ + 'gemini-2.5-flash-preview-05-20', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + 'gemini-1.5-flash', + ]); + }); + + it('provides empty array for openai models', async () => { + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'openai', + }), + ); + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + await act(async () => { + render( + + + , + ); + }); + expect(context.getAvailableModels()).toEqual([]); + }); + + it('provides empty array for ollama models', async () => { + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'ollama', + }), + ); + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + await act(async () => { + render( + + + , + ); + }); + expect(context.getAvailableModels()).toEqual([]); + }); + + it('getAvailableModels is memoized correctly', async () => { + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'gemini', + }), + ); + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + await act(async () => { + render( + + + , + ); + }); + + const firstCall = context.getAvailableModels; + + // Force a re-render + await act(async () => { + context.setPrompt('new prompt'); + }); + + const secondCall = context.getAvailableModels; + // Function should be the same reference since pileAIProvider didn't change + expect(firstCall).toBe(secondCall); + }); + + it('checkApiKeyValidity returns true when key exists', async () => { + window.electron.ipc.invoke.mockResolvedValue('test-key'); + + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'openai', + }), + ); + + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + const isValid = await context.validKey(); + expect(isValid).toBe(true); + }); + + it('checkApiKeyValidity returns false when key is null', async () => { + window.electron.ipc.invoke.mockResolvedValue(null); + + useElectronStore.mockImplementation( + mockElectronStore({ + pileAIProvider: 'openai', + }), + ); + + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + const isValid = await context.validKey(); + expect(isValid).toBe(false); + }); + + it('loads AI prompt from current pile', async () => { + const mockPilesContextWithPrompt = { + currentPile: { id: '1', name: 'Test Pile', AIPrompt: 'Custom AI prompt' }, + updateCurrentPile: jest.fn(), + }; + + usePilesContext.mockReturnValue(mockPilesContextWithPrompt); + useElectronStore.mockImplementation(mockElectronStore({})); + + let context; + function TestComponent() { + context = useAIContext(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(context.prompt).toBe('Custom AI prompt'); + }); + + it('memoizes context value when dependencies do not change', async () => { + const store = { + pileAIProvider: 'openai', + model: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + embeddingModel: 'mxbai-embed-large', + }; + useElectronStore.mockImplementation(mockElectronStore(store)); + + let renderCount = 0; + const contexts = []; + + function TestComponent() { + const ctx = useAIContext(); + contexts[renderCount] = ctx; + renderCount += 1; + return null; + } + + // Initial render and wait for async effects to complete + const { rerender } = await act(async () => { + const result = render( + + + , + ); + // Wait a bit for setupAi async function to complete + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + return result; + }); + + // Second render without changing any dependencies + await act(async () => { + rerender( + + + , + ); + // Wait again for any async effects + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + // Since the ai state changes asynchronously, we'll test that the functions are memoized + expect(contexts[0]).toBeDefined(); + expect(contexts[1]).toBeDefined(); + + // Test that specific memoized functions are the same reference + // Only test functions that don't depend on changing state (ai, model) + expect(contexts[0].getAvailableModels).toBe(contexts[1].getAvailableModels); + expect(contexts[0].validKey).toBe(contexts[1].validKey); + expect(contexts[0].prepareCompletionContext).toBe( + contexts[1].prepareCompletionContext, + ); + // generateCompletion depends on ai/model state that changes, so we don't test it + }); +}); diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 6a1de2a..2bd0115 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1,9 +1,172 @@ import '@testing-library/jest-dom'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import App from '../renderer/App'; +import { usePilesContext } from '../renderer/context/PilesContext'; +import { useElectronStore } from '../renderer/hooks/useElectronStore'; +import React from 'react'; + +// Mock all required contexts and hooks +jest.mock('../renderer/context/PilesContext'); +jest.mock('../renderer/hooks/useElectronStore'); +jest.mock('../renderer/context/AIContext', () => ({ + AIContextProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + useAIContext: () => ({ + ai: null, + prompt: 'Default prompt', + model: 'gpt-4o', + pileAIProvider: 'openai', + }), +})); +jest.mock('../renderer/context/ToastsContext', () => ({ + ToastsContextProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + useToastsContext: () => ({ + notifications: [], + addNotification: jest.fn(), + removeNotification: jest.fn(), + }), +})); + +const mockUsePilesContext = usePilesContext as jest.MockedFunction; +const mockUseElectronStore = useElectronStore as jest.MockedFunction; describe('App', () => { - it('should render', () => { - expect(render()).toBeTruthy(); + beforeEach(() => { + // Mock window.electron + Object.defineProperty(window, 'electron', { + value: { + isMac: true, + ipc: { + invoke: jest.fn().mockResolvedValue(null), + on: jest.fn().mockReturnValue(() => {}), + removeListener: jest.fn(), + }, + getConfigPath: jest.fn().mockReturnValue('/mock/config/path'), + existsSync: jest.fn().mockReturnValue(true), + }, + writable: true, + }); + + // Mock PilesContext + mockUsePilesContext.mockReturnValue({ + currentPile: { + id: '1', + name: 'Test Pile', + path: '/test/pile/path', + AIPrompt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + updateCurrentPile: jest.fn(), + piles: [], + loadPiles: jest.fn(), + createPile: jest.fn(), + deletePile: jest.fn(), + isLoading: false, + error: null, + }); + + // Mock useElectronStore + mockUseElectronStore.mockImplementation((key: string, defaultValue: any) => { + const mockStore: Record = { + pileAIProvider: 'openai', + model: 'gpt-4o', + embeddingModel: 'mxbai-embed-large', + baseUrl: 'https://api.openai.com/v1', + }; + + const [state, setState] = React.useState(mockStore[key] || defaultValue); + return [state, setState]; + }); + + // Clear all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders without crashing', () => { + const { container } = render( + + + + ); + + expect(container).toBeInTheDocument(); + }); + + it('renders with different routes', () => { + const { container } = render( + + + + ); + + expect(container).toBeInTheDocument(); + }); + + it('handles missing currentPile gracefully', () => { + mockUsePilesContext.mockReturnValue({ + currentPile: null, + updateCurrentPile: jest.fn(), + piles: [], + loadPiles: jest.fn(), + createPile: jest.fn(), + deletePile: jest.fn(), + isLoading: false, + error: null, + }); + + const { container } = render( + + + + ); + + expect(container).toBeInTheDocument(); + }); + + it('handles error state in PilesContext', () => { + mockUsePilesContext.mockReturnValue({ + currentPile: null, + updateCurrentPile: jest.fn(), + piles: [], + loadPiles: jest.fn(), + createPile: jest.fn(), + deletePile: jest.fn(), + isLoading: false, + error: 'Failed to load piles', + }); + + const { container } = render( + + + + ); + + expect(container).toBeInTheDocument(); + }); + + it('handles loading state in PilesContext', () => { + mockUsePilesContext.mockReturnValue({ + currentPile: null, + updateCurrentPile: jest.fn(), + piles: [], + loadPiles: jest.fn(), + createPile: jest.fn(), + deletePile: jest.fn(), + isLoading: true, + error: null, + }); + + const { container } = render( + + + + ); + + expect(container).toBeInTheDocument(); }); -}); +}); \ No newline at end of file diff --git a/src/__tests__/Editor.test.js b/src/__tests__/Editor.test.js new file mode 100644 index 0000000..bb8fc35 --- /dev/null +++ b/src/__tests__/Editor.test.js @@ -0,0 +1,154 @@ +// Test helper to extract and test the highlightTerms function +// This mirrors the actual implementation in the Editor component +const escapeRegExp = (string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + +const highlightTerms = (text, term) => { + if (!term.trim()) return text; + const regex = new RegExp(`(${escapeRegExp(term)})`, 'gi'); + // This tests the fixed version - before the fix it was: `$1` + return text.replace(regex, `$1`); +}; + +describe('highlightTerms function', () => { + it('returns original text when search term is empty', () => { + const text = '

Hello world

'; + expect(highlightTerms(text, '')).toBe(text); + expect(highlightTerms(text, ' ')).toBe(text); + }); + + it('highlights single word matches', () => { + const text = '

Hello world

'; + const result = highlightTerms(text, 'Hello'); + expect(result).toBe('

Hello world

'); + }); + + it('highlights multiple matches case-insensitively', () => { + const text = '

Hello world, hello universe

'; + const result = highlightTerms(text, 'hello'); + expect(result).toBe( + '

Hello world, hello universe

', + ); + }); + + it('highlights partial word matches', () => { + const text = '

JavaScript and Java are different

'; + const result = highlightTerms(text, 'Java'); + expect(result).toBe( + '

JavaScript and Java are different

', + ); + }); + + it('escapes special regex characters in search term', () => { + const text = '

Price is $10.99 (special offer)

'; + const result = highlightTerms(text, '$10.99'); + expect(result).toBe( + '

Price is $10.99 (special offer)

', + ); + }); + + it('handles complex HTML with nested tags', () => { + const text = '

Hello world

'; + const result = highlightTerms(text, 'world'); + expect(result).toBe( + '

Hello world

', + ); + }); + + it('preserves existing HTML structure', () => { + const text = '

This is emphasized text

'; + const result = highlightTerms(text, 'emphasized'); + expect(result).toBe( + '

This is emphasized text

', + ); + }); + + it('handles special characters in search term', () => { + const text = '

Search for C++ programming

'; + const result = highlightTerms(text, 'C++'); + expect(result).toBe( + '

Search for C++ programming

', + ); + }); + + it('handles parentheses and brackets in search term', () => { + const text = '

Function call: myFunction(param)

'; + const result = highlightTerms(text, 'myFunction(param)'); + expect(result).toBe( + '

Function call: myFunction(param)

', + ); + }); + + it('does not break on multiple consecutive spaces in search term', () => { + const text = '

Hello world with multiple spaces

'; + const result = highlightTerms(text, 'Hello world'); + expect(result).toBe( + '

Hello world with multiple spaces

', + ); + }); + + it('handles unicode characters', () => { + const text = '

Café and naïve are French words

'; + const result = highlightTerms(text, 'Café'); + expect(result).toBe( + '

Café and naïve are French words

', + ); + }); + + it('verifies the CSS class bug fix', () => { + const text = '

Test content

'; + const result = highlightTerms(text, 'Test'); + + // The bug was: `$1` + // Fixed to: `$1` (which becomes `$1` in our test) + expect(result).toBe('

Test content

'); + + // Ensure the buggy string concatenation is not present + expect(result).not.toContain("' + styles.highlight + '"); + expect(result).not.toContain("' +"); + expect(result).not.toContain("+ '"); + }); + + it('handles edge case with empty matches', () => { + const text = '

Content without matches

'; + const result = highlightTerms(text, 'nonexistent'); + expect(result).toBe(text); + }); + + it('handles search term that appears at start and end', () => { + const text = '

test some content test

'; + const result = highlightTerms(text, 'test'); + expect(result).toBe( + '

test some content test

', + ); + }); + + it('handles overlapping HTML tags correctly', () => { + const text = '

Hello beautiful world

'; + const result = highlightTerms(text, 'beautiful'); + expect(result).toBe( + '

Hello beautiful world

', + ); + }); +}); + +describe('escapeRegExp function', () => { + it('escapes all special regex characters', () => { + const specialChars = '.*+?^${}()|[]\\'; + const result = escapeRegExp(specialChars); + expect(result).toBe('\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'); + }); + + it('does not affect normal characters', () => { + const normalText = 'abcABC123'; + const result = escapeRegExp(normalText); + expect(result).toBe(normalText); + }); + + it('handles mixed normal and special characters', () => { + const mixedText = 'hello.world+test'; + const result = escapeRegExp(mixedText); + expect(result).toBe('hello\\.world\\+test'); + }); +}); diff --git a/src/__tests__/jest.setup.js b/src/__tests__/jest.setup.js new file mode 100644 index 0000000..9727dab --- /dev/null +++ b/src/__tests__/jest.setup.js @@ -0,0 +1,112 @@ +/* eslint-disable no-console */ +/* eslint-disable no-underscore-dangle */ +import 'jest-fetch-mock'; +import '@testing-library/jest-dom'; + +// Mock window.electron globally for all tests +Object.defineProperty(window, 'electron', { + value: { + isMac: true, + ipc: { + invoke: jest.fn().mockResolvedValue(null), + on: jest.fn().mockReturnValue(() => {}), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + }, + getConfigPath: jest.fn().mockReturnValue('/mock/config/path'), + existsSync: jest.fn().mockReturnValue(true), + }, + writable: true, +}); + +// Mock document.createElement for tests that use it (like htmlToText in AIContext) +const originalCreateElement = document.createElement; +document.createElement = jest.fn().mockImplementation((tagName) => { + const element = originalCreateElement.call(document, tagName); + if (tagName === 'div') { + Object.defineProperty(element, 'innerHTML', { + get() { + return this._innerHTML || ''; + }, + set(value) { + this._innerHTML = value; + // Simple text extraction for testing + let sanitized = value; + // Remove HTML tags including malformed ones with nested < characters + while (/<[^>]*>/g.test(sanitized)) { + sanitized = sanitized.replace(/<[^>]*>/g, ''); + } + this.textContent = sanitized; + this.innerText = this.textContent; + }, + }); + } + return element; +}); + +// Mock React.useState for consistent testing +const originalUseState = require('react').useState; +jest.spyOn(require('react'), 'useState').mockImplementation((initial) => { + return originalUseState(initial); +}); + +// Global test utilities +global.testUtils = { + // Helper to create mock pile objects + createMockPile: (overrides = {}) => ({ + id: '1', + name: 'Test Pile', + path: '/test/pile/path', + AIPrompt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }), + + // Helper to create mock post objects + createMockPost: (overrides = {}) => ({ + id: '1', + content: '

Test content

', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + data: { + attachments: [], + }, + ...overrides, + }), +}; + +// Suppress console warnings for tests unless explicitly needed +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +console.warn = jest.fn(); +console.error = jest.fn(); + +// Restore console methods for specific tests that need them +global.restoreConsole = () => { + console.warn = originalConsoleWarn; + console.error = originalConsoleError; +}; + +// Basic functionality test +describe('Jest Setup', () => { + it('should have properly configured testing environment', () => { + expect(window.electron).toBeDefined(); + expect(typeof window.electron.ipc.invoke).toBe('function'); + expect(global.testUtils).toBeDefined(); + expect(typeof global.testUtils.createMockPile).toBe('function'); + expect(typeof global.testUtils.createMockPost).toBe('function'); + }); + + it('should have document.createElement properly mocked', () => { + const div = document.createElement('div'); + div.innerHTML = 'Hello World'; + expect(div.textContent).toBe('Hello World'); + }); + + it('should have fetch mocked', () => { + expect(fetch).toBeDefined(); + expect(typeof fetch).toBe('function'); + }); +}); diff --git a/src/main/handlers/ai.ts b/src/main/handlers/ai.ts new file mode 100644 index 0000000..9a7a865 --- /dev/null +++ b/src/main/handlers/ai.ts @@ -0,0 +1,346 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +import { ipcMain, BrowserWindow } from 'electron'; +import OpenAI from 'openai'; +import { getKey } from '../utils/store'; + +const OLLAMA_URL = 'http://localhost:11434/api'; +const OPENAI_URL = 'https://api.openai.com/v1'; +const GEMINI_OPENAI_URL = + 'https://generativelanguage.googleapis.com/v1beta/openai/'; + +const getProviderBaseUrl = (provider: string) => { + if (provider === 'openai') return OPENAI_URL; + if (provider === 'gemini') return GEMINI_OPENAI_URL; + return null; +}; + +const isGeminiModel = (model: string): boolean => { + const isGemini = model.startsWith('gemini-'); + return isGemini; +}; + +ipcMain.handle( + 'ai-generate-completion', + async (_, { provider, model, messages }) => { + try { + if (provider === 'ollama') { + const response = await fetch(`${OLLAMA_URL}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, stream: false }), + }); + + if (!response.ok) { + const errorText = await response + .text() + .catch(() => 'No error details'); + throw new Error( + `HTTP error! status: ${response.status} - ${errorText}`, + ); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Failed to get response reader'); + } + + const decoder = new TextDecoder(); + const chunks: string[] = []; + + let done = false; + while (!done) { + const { value, done: readDone } = await reader.read(); + done = readDone; + if (value) { + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + lines.forEach((line) => { + const trimmedLine = line.trim(); + if (trimmedLine !== '') { + try { + const jsonResponse = JSON.parse(trimmedLine); + if (!jsonResponse.done && jsonResponse.message?.content) { + chunks.push(jsonResponse.message.content); + } + } catch (parseError) { + // It's okay for Ollama to return non-JSON lines, so we just ignore parse errors + // console.warn('Failed to parse JSON line:', line); + } + } + }); + } + } + + return { success: true, content: chunks.join('') }; + } + if (provider === 'gemini' || provider === 'openai') { + const apiKey = await getKey(); + if (!apiKey) { + throw new Error('API key not found'); + } + + const baseURL = getProviderBaseUrl(provider); + if (!baseURL) { + throw new Error(`Unknown provider: ${provider}`); + } + + const openai = new OpenAI({ + apiKey, + baseURL, + }); + + let stream; + try { + const completionParams: any = { + model, + stream: true, + messages, + max_tokens: 500, + }; + + // Add reasoning_effort: none for Gemini models to disable thinking + if (isGeminiModel(model)) { + // console.warn(`Disabling thinking for Gemini model: ${model}`); + completionParams.reasoning_effort = 'none'; + } + + stream = await openai.chat.completions.create(completionParams); + } catch (apiError: any) { + throw new Error( + `API Error (${apiError.status}): ${apiError.message || 'Unknown error'}`, + ); + } + + const chunks: string[] = []; + for await (const part of stream) { + if (part.choices[0]?.delta?.content) { + chunks.push(part.choices[0].delta.content); + } + } + + return { success: true, content: chunks.join('') }; + } + throw new Error(`Unsupported provider: ${provider}`); + } catch (error) { + // eslint-disable-next-line no-console + console.error('AI request failed:', error); + // Check if it's a network error that might indicate Ollama is not running + if (error instanceof TypeError && error.message.includes('fetch')) { + return { + success: false, + error: + 'Unable to connect to AI service. Please ensure Ollama is running if using Ollama provider.', + }; + } + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, +); + +ipcMain.handle( + 'ai-generate-completion-stream', + async (_, { provider, model, messages }) => { + // For streaming, we'll return a unique request ID and use a separate channel for chunks + const requestId = Date.now().toString(); + + try { + if (provider === 'ollama') { + const response = await fetch(`${OLLAMA_URL}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, stream: true }), + }); + + if (!response.ok) { + const errorText = await response + .text() + .catch(() => 'No error details'); + throw new Error( + `HTTP error! status: ${response.status} - ${errorText}`, + ); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Failed to get response reader'); + } + + const decoder = new TextDecoder(); + + // Process stream in background + (async () => { + try { + let done = false; + while (!done) { + const { value, done: readDone } = await reader.read(); + done = readDone; + + if (value) { + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + lines.forEach((line) => { + if (line.trim() !== '') { + try { + const jsonResponse = JSON.parse(line); + if (!jsonResponse.done && jsonResponse.message?.content) { + // Send chunk to renderer + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send( + `ai-stream-chunk-${requestId}`, + jsonResponse.message.content, + ); + } + } + } catch (parseError) { + // It's okay for Ollama to return non-JSON lines, so we just ignore parse errors + // console.warn('Failed to parse JSON line:', line); + } + } + }); + } + } + + // Send completion signal + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send(`ai-stream-complete-${requestId}`); + } + } catch (error) { + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send( + `ai-stream-error-${requestId}`, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } + })(); + + return { success: true, requestId }; + } + if (provider === 'gemini' || provider === 'openai') { + const apiKey = await getKey(); + if (!apiKey) { + throw new Error('API key not found'); + } + + const baseURL = getProviderBaseUrl(provider); + if (!baseURL) { + throw new Error(`Unknown provider: ${provider}`); + } + + const openai = new OpenAI({ + apiKey, + baseURL, + }); + + // Process stream in background + (async () => { + try { + let stream; + try { + const completionParams: any = { + model, + stream: true, + messages, + max_tokens: 500, + }; + + // Add reasoning_effort: none for Gemini models to disable thinking + if (isGeminiModel(model)) { + // Disabling thinking for Gemini streaming model + completionParams.reasoning_effort = 'none'; + } + + stream = await openai.chat.completions.create(completionParams); + } catch (apiError: any) { + // eslint-disable-next-line no-console + console.error('OpenAI/Gemini streaming API error:', apiError); + let errorMessage = 'AI request failed'; + if (apiError.status === 404) { + errorMessage = `Model "${model}" not found. Please check if the model name is correct.`; + } else if (apiError.status === 401) { + errorMessage = + 'Invalid API key. Please check your API key configuration.'; + } else if (apiError.status === 429) { + errorMessage = 'Rate limit exceeded. Please try again later.'; + } else if (apiError.status) { + errorMessage = `API Error (${apiError.status}): ${apiError.message || 'Unknown error'}`; + } else { + errorMessage = apiError.message || 'Unknown error'; + } + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send( + `ai-stream-error-${requestId}`, + errorMessage, + ); + } + return; + } + + for await (const part of stream) { + if (part.choices[0]?.delta?.content) { + // Send chunk to renderer + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send( + `ai-stream-chunk-${requestId}`, + part.choices[0].delta.content, + ); + } + } + } + + // Send completion signal + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send(`ai-stream-complete-${requestId}`); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Streaming error:', error); + const mainWindow = + BrowserWindow.getFocusedWindow() || + BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send( + `ai-stream-error-${requestId}`, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } + })(); + + return { success: true, requestId }; + } + throw new Error(`Unsupported provider: ${provider}`); + } catch (error) { + // eslint-disable-next-line no-console + console.error('AI stream request failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, +); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index dfbea1e..513c213 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,3 +6,4 @@ import './handlers/highlights'; import './handlers/index'; import './handlers/links'; import './handlers/store'; +import './handlers/ai'; diff --git a/src/main/preload.ts b/src/main/preload.ts index 0863626..f811324 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -4,7 +4,7 @@ import { contextBridge, ipcRenderer, IpcRendererEvent, shell } from 'electron'; import fs from 'fs'; import path from 'path'; -export type Channels = 'ipc-example'; +export type Channels = 'ipc-example' | string; const electronHandler = { ipc: { diff --git a/src/main/utils/pileEmbeddings.js b/src/main/utils/pileEmbeddings.js index 5f5584d..99b7866 100644 --- a/src/main/utils/pileEmbeddings.js +++ b/src/main/utils/pileEmbeddings.js @@ -5,6 +5,7 @@ const { walk } = require('../util'); const matter = require('gray-matter'); const settings = require('electron-settings'); const {getKey} = require('../utils/store'); +const OpenAI = require('openai'); // Todo: Cache the norms alongside embeddings at some point // to avoid recomputing them for every query @@ -136,41 +137,53 @@ class PileEmbeddings { } } - // todo: based on which ai is configured this should either - // use ollama or openai + // Generate embeddings based on configured AI provider async generateEmbedding(document) { const pileAIProvider = await settings.get('pileAIProvider'); const embeddingModel = await settings.get('embeddingModel'); - const isOllama = pileAIProvider === 'ollama'; - if (isOllama) { + if (pileAIProvider === 'ollama') { const url = 'http://127.0.0.1:11434/api/embed'; const data = { - model: 'mxbai-embed-large', + model: embeddingModel || 'mxbai-embed-large', input: document, }; try { const response = await axios.post(url, data); - const embeddings = response.data.embeddings; return response.data.embeddings[0]; } catch (error) { console.error('Error generating embedding with Ollama:', error); return null; } + } else if (pileAIProvider === 'gemini') { + const openai = new OpenAI({ + apiKey: this.apiKey, + baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/' + }); + + try { + const response = await openai.embeddings.create({ + model: 'text-embedding-004', + input: document, + }); + return response.data[0].embedding; + } catch (error) { + console.error('Error generating embedding with Gemini:', error); + return null; + } } else { - const url = 'https://api.openai.com/v1/embeddings'; - const headers = { - Authorization: `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - }; - const data = { - model: 'text-embedding-3-small', - input: document, - }; + // Default to OpenAI + const openai = new OpenAI({ + apiKey: this.apiKey, + baseURL: 'https://api.openai.com/v1' + }); try { - const response = await axios.post(url, data, { headers }); - return response.data.data[0].embedding; + const response = await openai.embeddings.create({ + model: embeddingModel || 'text-embedding-3-small', + input: document, + }); + return response.data[0].embedding; } catch (error) { console.error('Error generating embedding with OpenAI:', error); return null; diff --git a/src/renderer/context/AIContext.js b/src/renderer/context/AIContext.js index 45edcbc..bd4e732 100644 --- a/src/renderer/context/AIContext.js +++ b/src/renderer/context/AIContext.js @@ -1,164 +1,292 @@ +/* eslint-disable no-use-before-define */ import { useState, createContext, useContext, useEffect, useCallback, + useRef, + useMemo, } from 'react'; -import OpenAI from 'openai'; +import PropTypes from 'prop-types'; import { usePilesContext } from './PilesContext'; -import { useElectronStore } from 'renderer/hooks/useElectronStore'; +import { useElectronStore } from '../hooks/useElectronStore'; + +// Helper function to convert HTML to plain text +const htmlToText = (html) => { + if (!html) return ''; + + // Create a temporary div element to parse HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // Extract text content and clean up whitespace + return tempDiv.textContent || tempDiv.innerText || ''; +}; -const OLLAMA_URL = 'http://localhost:11434/api'; const OPENAI_URL = 'https://api.openai.com/v1'; + const DEFAULT_PROMPT = - 'You are an AI within a journaling app. Your job is to help the user reflect on their thoughts in a thoughtful and kind manner. The user can never directly address you or directly respond to you. Try not to repeat what the user said, instead try to seed new ideas, encourage or debate. Keep your responses concise, but meaningful.'; + 'You are an AI within a journaling app. Your job is to help the user reflect on their thoughts in a thoughtful and kind manner. The user can never directly address you or directly respond to you. Try not to repeat what the user said, instead try to seed new ideas, encourage or debate. Keep your responses concise, but meaningful. You can only respond in plaintext, do NOT use HTML.'; + +const getProviderBaseUrl = (provider) => { + if (provider === 'openai') return OPENAI_URL; + if (provider === 'gemini') + return 'https://generativelanguage.googleapis.com/v1beta/openai/'; + return null; +}; + +const getDefaultModel = (provider) => { + if (provider === 'openai') return 'gpt-4o'; + if (provider === 'gemini') return 'gemini-2.0-flash'; + if (provider === 'ollama') return 'llama3'; // Or any default + return 'gpt-4o'; +}; export const AIContext = createContext(); -export const AIContextProvider = ({ children }) => { +export function AIContextProvider({ children }) { const { currentPile, updateCurrentPile } = usePilesContext(); const [ai, setAi] = useState(null); const [prompt, setPrompt] = useState(DEFAULT_PROMPT); const [pileAIProvider, setPileAIProvider] = useElectronStore( 'pileAIProvider', - 'openai' + 'openai', + ); + const [model, setModel] = useElectronStore( + 'model', + getDefaultModel('openai'), ); - const [model, setModel] = useElectronStore('model', 'gpt-4o'); const [embeddingModel, setEmbeddingModel] = useElectronStore( 'embeddingModel', - 'mxbai-embed-large' + 'mxbai-embed-large', ); const [baseUrl, setBaseUrl] = useElectronStore('baseUrl', OPENAI_URL); + const previousProviderRef = useRef(null); + const [isInitialized, setIsInitialized] = useState(false); // Add isInitialized state + + const getAvailableModels = useCallback(() => { + if (pileAIProvider === 'gemini') { + return [ + 'gemini-2.5-flash-preview-05-20', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + 'gemini-1.5-flash', + ]; + } + // Add other providers if they have a fixed list of models + return []; // Return empty for openai and ollama where models can be custom + }, [pileAIProvider]); + + const isModelMatchingProvider = useCallback((modelToCheck, provider) => { + return ( + (provider === 'gemini' && modelToCheck.startsWith('gemini-')) || + (provider === 'openai' && + (modelToCheck.startsWith('gpt-') || modelToCheck.startsWith('o1-'))) || + (provider === 'ollama' && + !modelToCheck.startsWith('gemini-') && + !modelToCheck.startsWith('gpt-') && + !modelToCheck.startsWith('o1-')) + ); + }, []); + + // Initialize after both provider and model are loaded + useEffect(() => { + if (!isInitialized && pileAIProvider && model) { + setIsInitialized(true); + previousProviderRef.current = pileAIProvider; + } + }, [pileAIProvider, model, isInitialized]); + + useEffect(() => { + const newBaseUrl = getProviderBaseUrl(pileAIProvider); + if (newBaseUrl) { + setBaseUrl(newBaseUrl); + } + + // Only change model when provider changes after initialization + if (isInitialized && previousProviderRef.current !== pileAIProvider) { + // If the current model doesn't match the new provider, set the default model + if (!isModelMatchingProvider(model, pileAIProvider)) { + setModel(getDefaultModel(pileAIProvider)); + } + previousProviderRef.current = pileAIProvider; + } + }, [ + pileAIProvider, + model, + isInitialized, + setBaseUrl, + setModel, + isModelMatchingProvider, + ]); const setupAi = useCallback(async () => { const key = await window.electron.ipc.invoke('get-ai-key'); if (!key && pileAIProvider !== 'ollama') return; - if (pileAIProvider === 'ollama') { - setAi({ type: 'ollama' }); - } else { - const openaiInstance = new OpenAI({ - baseURL: baseUrl, - apiKey: key, - dangerouslyAllowBrowser: true, - }); - setAi({ type: 'openai', instance: openaiInstance }); - } - }, [pileAIProvider, baseUrl]); + // AI instances are now handled in the main process + setAi({ type: pileAIProvider }); + }, [pileAIProvider]); useEffect(() => { if (currentPile) { - console.log('🧠 Syncing current pile'); - if (currentPile.AIPrompt) setPrompt(currentPile.AIPrompt); + // console.log('🧠 Syncing current pile'); + if (currentPile.AIPrompt) { + setPrompt(currentPile.AIPrompt); + } setupAi(); } - }, [currentPile, baseUrl, setupAi]); + }, [currentPile, setupAi]); const generateCompletion = useCallback( async (context, callback) => { if (!ai) return; try { - if (ai.type === 'ollama') { - const response = await fetch(`${OLLAMA_URL}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, messages: context }), - }); - - if (!response.ok) - throw new Error(`HTTP error! status: ${response.status}`); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.trim() !== '') { - const jsonResponse = JSON.parse(line); - if (!jsonResponse.done) { - callback(jsonResponse.message.content); - } - } - } - } - } else { - const stream = await ai.instance.chat.completions.create({ + // Use streaming API for real-time responses + const result = await window.electron.ipc.invoke( + 'ai-generate-completion-stream', + { + provider: ai.type, model, - stream: true, - max_tokens: 500, messages: context, - }); + maxTokens: 500, + }, + ); - for await (const part of stream) { - callback(part.choices[0].delta.content); - } + if (!result.success) { + throw new Error(result.error || 'AI request failed'); } + + const { requestId } = result; + + // Listen for streaming chunks + const handleChunk = (chunk) => { + callback(chunk); + }; + + const handleComplete = () => { + chunkUnsubscribe(); + completeUnsubscribe(); + errorUnsubscribe(); + }; + + const handleError = (error) => { + chunkUnsubscribe(); + completeUnsubscribe(); + errorUnsubscribe(); + throw new Error(error); + }; + + // Set up event listeners + const chunkUnsubscribe = window.electron.ipc.on( + `ai-stream-chunk-${requestId}`, + (chunk) => handleChunk(chunk), + ); + const completeUnsubscribe = window.electron.ipc.on( + `ai-stream-complete-${requestId}`, + handleComplete, + ); + const errorUnsubscribe = window.electron.ipc.on( + `ai-stream-error-${requestId}`, + (error) => handleError(error), + ); } catch (error) { + // eslint-disable-next-line no-console console.error('AI request failed:', error); throw error; } }, - [ai, model] + [ai, model], ); const prepareCompletionContext = useCallback( (thread) => { - return [ + // console.log( + // '📝 Preparing completion context with prompt:', + // prompt.substring(0, 100) + '...', + // ); + // console.log('🧵 Thread data:', thread); + + const context = [ { role: 'system', content: prompt }, - { - role: 'system', - content: 'You can only respond in plaintext, do NOT use HTML.', - }, - ...thread.map((post) => ({ role: 'user', content: post.content })), + ...thread.map((post) => { + const plainTextContent = htmlToText(post.content); + return { role: 'user', content: plainTextContent }; + }), ]; + + // console.log('📨 Final context being sent to AI:', context); + return context; }, - [prompt] + [prompt], ); - const checkApiKeyValidity = async () => { + const checkApiKeyValidity = useCallback(async () => { // TODO: Add regex for OpenAPI and Ollama API keys const key = await window.electron.ipc.invoke('get-ai-key'); - + if (key !== null) { return true; } return false; - } + }, []); - const AIContextValue = { - ai, - baseUrl, - setBaseUrl, - prompt, - setPrompt, - setKey: (secretKey) => window.electron.ipc.invoke('set-ai-key', secretKey), - getKey: () => window.electron.ipc.invoke('get-ai-key'), - validKey: checkApiKeyValidity, - deleteKey: () => window.electron.ipc.invoke('delete-ai-key'), - updateSettings: (newPrompt) => - updateCurrentPile({ ...currentPile, AIPrompt: newPrompt }), - model, - setModel, - embeddingModel, - setEmbeddingModel, - generateCompletion, - prepareCompletionContext, - pileAIProvider, - setPileAIProvider, - }; + const AIContextValue = useMemo( + () => ({ + ai, + baseUrl, + setBaseUrl, + prompt, + setPrompt, + getAvailableModels, + setKey: (secretKey) => + window.electron.ipc.invoke('set-ai-key', secretKey), + getKey: () => window.electron.ipc.invoke('get-ai-key'), + validKey: checkApiKeyValidity, + deleteKey: () => window.electron.ipc.invoke('delete-ai-key'), + updateSettings: (newPrompt) => { + return updateCurrentPile({ ...currentPile, AIPrompt: newPrompt }); + }, + model, + setModel, + embeddingModel, + setEmbeddingModel, + generateCompletion, + prepareCompletionContext, + pileAIProvider, + setPileAIProvider, + }), + [ + ai, + baseUrl, + setBaseUrl, + prompt, + setPrompt, + getAvailableModels, + checkApiKeyValidity, + updateCurrentPile, + currentPile, + model, + setModel, + embeddingModel, + setEmbeddingModel, + generateCompletion, + prepareCompletionContext, + pileAIProvider, + setPileAIProvider, + ], + ); return ( {children} ); +} + +AIContextProvider.propTypes = { + children: PropTypes.node.isRequired, }; export const useAIContext = () => useContext(AIContext); diff --git a/src/renderer/icons/img/BoxOpenIcon.js b/src/renderer/icons/img/BoxOpenIcon.js index 010e761..3261673 100644 --- a/src/renderer/icons/img/BoxOpenIcon.js +++ b/src/renderer/icons/img/BoxOpenIcon.js @@ -1,18 +1,20 @@ -export const BoxOpenIcon = (props) => { +/* eslint-disable import/prefer-default-export */ +export function BoxOpenIcon(props) { return ( @@ -23,4 +25,4 @@ export const BoxOpenIcon = (props) => { ); -}; +} diff --git a/src/renderer/icons/img/CardIcon.js b/src/renderer/icons/img/CardIcon.js index 945e28b..d16a2e3 100644 --- a/src/renderer/icons/img/CardIcon.js +++ b/src/renderer/icons/img/CardIcon.js @@ -1,21 +1,23 @@ -export const CardIcon = (props) => { +/* eslint-disable import/prefer-default-export */ +export function CardIcon(props) { return ( - + ); -}; +} diff --git a/src/renderer/pages/CreatePile/index.tsx b/src/renderer/pages/CreatePile/index.tsx index 1721a7c..5d60211 100644 --- a/src/renderer/pages/CreatePile/index.tsx +++ b/src/renderer/pages/CreatePile/index.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import styles from './CreatePile.module.scss'; -import { TrashIcon } from 'renderer/icons'; +import { TrashIcon } from '../../icons'; import { Link } from 'react-router-dom'; -import { usePilesContext } from 'renderer/context/PilesContext'; +import { usePilesContext } from '../../context/PilesContext'; import { useNavigate } from 'react-router-dom'; import icon from '../../../../assets/logo.png'; import { motion } from 'framer-motion'; @@ -86,7 +86,10 @@ export default function CreatePile() { Pick a place to store this pile - diff --git a/src/renderer/pages/License/index.tsx b/src/renderer/pages/License/index.tsx index ab0058b..04ad5f2 100644 --- a/src/renderer/pages/License/index.tsx +++ b/src/renderer/pages/License/index.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import styles from './License.module.scss'; -import { TrashIcon } from 'renderer/icons'; import { Link } from 'react-router-dom'; import { usePilesContext } from '../../context/PilesContext'; diff --git a/src/renderer/pages/Pile/Chat/Message/index.js b/src/renderer/pages/Pile/Chat/Message/index.js index c15f135..1346b26 100644 --- a/src/renderer/pages/Pile/Chat/Message/index.js +++ b/src/renderer/pages/Pile/Chat/Message/index.js @@ -2,7 +2,6 @@ import styles from './Message.module.scss'; import { AnimatePresence, motion } from 'framer-motion'; import { useState, useEffect, useCallback, memo } from 'react'; import { AIIcon, PersonIcon } from 'renderer/icons'; -import Markdown from 'react-markdown'; const Message = ({ index, message, scrollToBottom }) => { const isUser = message.role === 'user'; diff --git a/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx b/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx index b400a48..eee0a3c 100644 --- a/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx +++ b/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx @@ -1,14 +1,9 @@ -import styles from './LinkPreview.module.scss'; -import { useCallback, useState, useEffect } from 'react'; -import { - DiscIcon, - PhotoIcon, - TrashIcon, - TagIcon, - ChainIcon, -} from 'renderer/icons'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion } from 'framer-motion'; +import { useEffect, useState } from 'react'; import { useLinksContext } from 'renderer/context/LinksContext'; +import { ChainIcon } from 'renderer/icons'; +import PropTypes from 'prop-types'; +import styles from './LinkPreview.module.scss'; const isUrlYouTubeVideo = (url) => { // Regular expression to check for various forms of YouTube URLs @@ -24,26 +19,21 @@ export default function LinkPreview({ url }) { const toggleExpand = () => setExpanded(!expanded); - const getPreview = async (url) => { - const data = await getLink(url); - setPreview(data); - }; - - const updateSummary = (e) => { - const summary = e.target.value; - const _preview = { ...preview, aiCard: { ...preview.aiCard, summary } }; - }; - useEffect(() => { + const getPreview = async (theUrl) => { + const data = await getLink(theUrl); + setPreview(data); + }; + getPreview(url); - }, [url]); + }, [url, getLink]); - if (!preview) return
; + if (!preview) return
; - const createYouTubeEmbed = (url) => { + const createYouTubeEmbed = (videoUrl) => { // Extract the video ID from the YouTube URL - const regExp = /^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#\&\?]*).*/; - const match = url.match(regExp); + const regExp = /^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#?&]*).*/; + const match = videoUrl.match(regExp); if (match && match[2].length === 11) { return ( @@ -59,19 +49,19 @@ export default function LinkPreview({ url }) { />
); - } else { - return null; } + return null; }; const renderImage = () => { - if (!preview?.images || preview.images.length == 0) return; + if (!preview?.images || preview.images.length === 0) return null; const image = preview.images[0]; + if (!image || image.trim() === '') return null; return (
- + Preview
); }; @@ -82,9 +72,9 @@ export default function LinkPreview({ url }) { const renderAICard = () => { // check if AI card is reliable and has enough content. - if (!preview.aiCard) return; + if (!preview.aiCard) return null; - const highlights = () => {}; + // const highlights = null; // Removed unused variable return (
@@ -93,22 +83,22 @@ export default function LinkPreview({ url }) { {/* Highlights */} {preview?.aiCard?.highlights?.length > 0 && (
    - {preview.aiCard.highlights.map((highlight, i) => ( -
  • {highlight}
  • + {preview.aiCard.highlights.map((highlight) => ( +
  • {highlight}
  • ))}
    + />
)} {/* Buttons */} {preview?.aiCard?.buttons?.length > 0 && (
- {preview.aiCard.buttons.map((btn, i) => ( + {preview.aiCard.buttons.map((btn) => ( -
+
{ + if (e.key === 'Enter' || e.key === ' ') { + toggleExpand(); + } + }} + > {renderImage()}
{renderAICard()}
- {' '} + {preview.favicon && preview.favicon.trim() !== '' && ( + Favicon + )}{' '} {preview?.aiCard?.category && ( {preview?.aiCard?.category} )} @@ -156,3 +162,7 @@ export default function LinkPreview({ url }) { ); } + +LinkPreview.propTypes = { + url: PropTypes.string.isRequired, +}; diff --git a/src/renderer/pages/Pile/Editor/index.jsx b/src/renderer/pages/Pile/Editor/index.jsx index 52345b9..f19522b 100644 --- a/src/renderer/pages/Pile/Editor/index.jsx +++ b/src/renderer/pages/Pile/Editor/index.jsx @@ -1,26 +1,22 @@ -import './ProseMirror.scss'; -import styles from './Editor.module.scss'; -import { useCallback, useState, useEffect, useRef, memo } from 'react'; +/* eslint-disable no-use-before-define */ import { Extension } from '@tiptap/core'; -import { useEditor, EditorContent } from '@tiptap/react'; +import CharacterCount from '@tiptap/extension-character-count'; import Link from '@tiptap/extension-link'; -import StarterKit from '@tiptap/starter-kit'; -import Typography from '@tiptap/extension-typography'; import Placeholder from '@tiptap/extension-placeholder'; -import CharacterCount from '@tiptap/extension-character-count'; -import { DiscIcon, PhotoIcon, TrashIcon, TagIcon } from 'renderer/icons'; -import { motion, AnimatePresence } from 'framer-motion'; -import { postFormat } from 'renderer/utils/fileOperations'; -import { useParams } from 'react-router-dom'; -import TagButton from './TagButton'; -import TagList from './TagList'; -import Attachments from './Attachments'; -import usePost from 'renderer/hooks/usePost'; -import ProseMirrorStyles from './ProseMirror.scss'; +import Typography from '@tiptap/extension-typography'; +import { EditorContent, useEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useAIContext } from 'renderer/context/AIContext'; +import { useToastsContext } from 'renderer/context/ToastsContext'; +import usePost from 'renderer/hooks/usePost'; import useThread from 'renderer/hooks/useThread'; +import { PhotoIcon } from 'renderer/icons'; +import PropTypes from 'prop-types'; +import Attachments from './Attachments'; +import styles from './Editor.module.scss'; import LinkPreviews from './LinkPreviews'; -import { useToastsContext } from 'renderer/context/ToastsContext'; +import './ProseMirror.scss'; // Escape special characters const escapeRegExp = (string) => { @@ -30,10 +26,7 @@ const escapeRegExp = (string) => { const highlightTerms = (text, term) => { if (!term.trim()) return text; const regex = new RegExp(`(${escapeRegExp(term)})`, 'gi'); - return text.replace( - regex, - '$1' - ); + return text.replace(regex, `$1`); }; const Editor = memo( @@ -51,8 +44,6 @@ const Editor = memo( const { post, savePost, - addTag, - removeTag, attachToPost, detachFromPost, setContent, @@ -60,8 +51,7 @@ const Editor = memo( deletePost, } = usePost(postPath, { isReply, parentPostPath, reloadParentPost, isAI }); const { getThread } = useThread(); - const { ai, prompt, model, generateCompletion, prepareCompletionContext } = - useAIContext(); + const { generateCompletion, prepareCompletionContext } = useAIContext(); const { addNotification, removeNotification } = useToastsContext(); const isNew = !postPath; @@ -70,13 +60,11 @@ const Editor = memo( name: 'EnterSubmitExtension', addCommands() { return { - triggerSubmit: - () => - ({ state, dispatch }) => { - const event = new CustomEvent('submit'); - document.dispatchEvent(event); - return true; - }, + triggerSubmit: () => () => { + const event = new CustomEvent('submit'); + document.dispatchEvent(event); + return true; + }, }; }, @@ -125,7 +113,7 @@ const Editor = memo( EnterSubmitExtension, ], editorProps: { - handlePaste: function (view, event, slice) { + handlePaste(event) { const items = Array.from(event.clipboardData?.items || []); let imageHandled = false; // flag to track if an image was handled @@ -140,8 +128,8 @@ const Editor = memo( } return imageHandled; }, - handleDrop: function (view, event, slice, moved) { - let imageHandled = false; // flag to track if an image was handled + handleDrop(event, moved) { + const imageHandled = false; // flag to track if an image was handled if ( !moved && event.dataTransfer && @@ -157,10 +145,10 @@ const Editor = memo( }, }, autofocus: true, - editable: editable, + editable, content: post?.content || '', - onUpdate: ({ editor }) => { - setContent(editor.getHTML()); + onUpdate: ({ editor: tipTapEditor }) => { + setContent(tipTapEditor.getHTML()); }, }); @@ -190,6 +178,7 @@ const Editor = memo( useEffect(() => { if (!editor) return; generateAiResponse(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor, isAI]); const handleSubmit = useCallback(async () => { @@ -202,7 +191,7 @@ const Editor = memo( closeReply(); setEditable(false); - }, [editor, isNew, post]); + }, [isNew, resetPost, closeReply, setEditable, savePost]); // Listen for the 'submit' event and call handleSubmit when it's triggered useEffect(() => { @@ -223,10 +212,10 @@ const Editor = memo( // on entries added for the AI that are empty. const generateAiResponse = useCallback(async () => { if ( - !editor || - isAIResponding || - !isAI || - !editor.state.doc.textContent.length === 0 + !editor || // no editor + isAIResponding || // already responding + !isAI || // not AI post + editor.state.doc.textContent.length > 0 // not empty ) return; @@ -246,7 +235,9 @@ const Editor = memo( if (context.length === 0) return; await generateCompletion(context, (token) => { - editor.commands.insertContent(token); + if (token) { + editor.commands.insertContent(token); + } }); } catch (error) { addNotification({ @@ -267,12 +258,17 @@ const Editor = memo( prepareCompletionContext, getThread, parentPostPath, + addNotification, + closeReply, + isAIResponding, + removeNotification, + setEditable, ]); useEffect(() => { if (editor) { if (!post) return; - if (post?.content != editor.getHTML()) { + if (post?.content !== editor.getHTML()) { editor.commands.setContent(post.content); } } @@ -285,16 +281,16 @@ const Editor = memo( editor.setEditable(editable); } setDeleteStep(0); - }, [editable]); + }, [editable, editor]); const handleOnDelete = useCallback(async () => { - if (deleteStep == 0) { + if (deleteStep === 0) { setDeleteStep(1); return; } await deletePost(); - }, [deleteStep]); + }, [deleteStep, setDeleteStep, deletePost]); const isBig = useCallback(() => { return editor?.storage.characterCount.characters() < 280; @@ -308,7 +304,7 @@ const Editor = memo( return 'Update'; }; - if (!post) return; + if (!post) return null; let previewContent = post.content; if (searchTerm && !editable) { @@ -319,7 +315,7 @@ const Editor = memo(
{editable ? (
@@ -349,6 +346,7 @@ const Editor = memo( onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} + role="presentation" >
-
{isReply && ( - )} {!isNew && ( )} - )); + const getProviderConfig = () => { + const configs = { + openai: { + name: 'OpenAI', + baseUrlPlaceholder: 'https://api.openai.com/v1', + modelPlaceholder: 'gpt-4o', + keyPlaceholder: 'Paste an OpenAI API key to enable AI reflections', + keyLabel: 'OpenAI API key', + disclaimer: + 'Remember to manage your spend by setting up a budget in the API service you choose to use.', + pitch: + 'Create an API key in your OpenAI account and paste it here to start using GPT AI models in Pile.', + }, + gemini: { + name: 'Gemini', + baseUrlPlaceholder: + 'https://generativelanguage.googleapis.com/v1beta/openai/', + modelPlaceholder: 'gemini-2.0-flash', + keyPlaceholder: 'Paste a Gemini API key to enable AI reflections', + keyLabel: 'Gemini API key', + disclaimer: + 'Remember to manage your spend by setting up a budget in the API service you choose to use.', + pitch: + 'Create an API key in Google AI Studio and paste it here to start using Gemini AI models in Pile.', + availableModels: getAvailableModels(), + }, + }; + return configs[remoteProvider] || configs.openai; }; + const currentConfig = getProviderConfig(); + return ( Subscription @@ -73,19 +181,19 @@ export default function AISettingTabs({ APIkey, setCurrentKey }) { - Ollama API + Ollama - OpenAI API + Remote AI @@ -107,7 +215,7 @@ export default function AISettingTabs({ APIkey, setCurrentKey }) {
AI subscription for Pile is provided separately by{' '} - + UNMS . Subject to availability and capacity limits. Fair-use policy @@ -132,9 +240,8 @@ export default function AISettingTabs({ APIkey, setCurrentKey }) { @@ -145,11 +252,12 @@ export default function AISettingTabs({ APIkey, setCurrentKey }) {
@@ -158,7 +266,11 @@ export default function AISettingTabs({ APIkey, setCurrentKey }) { Ollama is the easiest way to run AI models on your own computer. Remember to pull your models in Ollama before using them in Pile. Learn more and download Ollama from{' '} - + ollama.com . @@ -166,57 +278,124 @@ export default function AISettingTabs({ APIkey, setCurrentKey }) {
- +
-
- Create an API key in your OpenAI account and paste it here to start - using GPT AI models in Pile. -
+
{currentConfig.pitch}
+ +
+ + +
-
-
-
-
- Remember to manage your spend by setting up a budget in the API - service you choose to use. -
+
{currentConfig.disclaimer}
); } + +AISettingTabs.propTypes = { + APIkey: PropTypes.string.isRequired, + setCurrentKey: PropTypes.func.isRequired, +}; diff --git a/src/renderer/pages/Pile/Settings/index.jsx b/src/renderer/pages/Pile/Settings/index.jsx index fd1e12f..1fc0c00 100644 --- a/src/renderer/pages/Pile/Settings/index.jsx +++ b/src/renderer/pages/Pile/Settings/index.jsx @@ -1,31 +1,17 @@ -import styles from './Settings.module.scss'; -import { SettingsIcon, CrossIcon, OllamaIcon } from 'renderer/icons'; -import { useEffect, useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; +import { useEffect, useState } from 'react'; import { useAIContext } from 'renderer/context/AIContext'; import { availableThemes, usePilesContext, } from 'renderer/context/PilesContext'; +import { CrossIcon, SettingsIcon } from 'renderer/icons'; import AISettingTabs from './AISettingsTabs'; -import { useIndexContext } from 'renderer/context/IndexContext'; +import styles from './Settings.module.scss'; export default function Settings() { - const { regenerateEmbeddings } = useIndexContext(); - const { - ai, - prompt, - setPrompt, - updateSettings, - setBaseUrl, - getKey, - setKey, - deleteKey, - model, - setModel, - ollama, - baseUrl, - } = useAIContext(); + const { prompt, setPrompt, updateSettings, getKey, setKey, deleteKey } = + useAIContext(); const [APIkey, setCurrentKey] = useState(''); const { currentTheme, setTheme } = usePilesContext(); @@ -36,19 +22,7 @@ export default function Settings() { useEffect(() => { retrieveKey(); - }, []); - - const handleOnChangeBaseUrl = (e) => { - setBaseUrl(e.target.value); - }; - - const handleOnChangeModel = (e) => { - setModel(e.target.value); - }; - - const handleOnChangeKey = (e) => { - setCurrentKey(e.target.value); - }; + }); const handleOnChangePrompt = (e) => { const p = e.target.value ?? ''; @@ -56,10 +30,11 @@ export default function Settings() { }; const handleSaveChanges = () => { + // eslint-disable-next-line eqeqeq if (!APIkey || APIkey == '') { deleteKey(); } else { - console.log('save key', APIkey); + // console.log('save key', APIkey); setKey(APIkey); } @@ -68,22 +43,24 @@ export default function Settings() { }; const renderThemes = () => { - return Object.keys(availableThemes).map((theme, index) => { + return Object.keys(availableThemes).map((theme) => { const colors = availableThemes[theme]; return ( ); }); @@ -100,21 +77,24 @@ export default function Settings() { Settings
-
-
-