diff --git a/README.md b/README.md index f6646d5f..5b95e5dc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Preswald is especially useful when: - Reactive engine. Only re-run what's needed, powered by a DAG of dependencies - Local execution. No server. Runs offline, even with large data - AI-ready. Apps are fully inspectable and modifiable by agents +- Multi-provider chat. Built-in chat widget supports [OpenAI](https://openai.com/) and [MiniMax](https://www.minimaxi.com/) LLMs ## Export as a Static App diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eef874ff..196068a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -63,6 +63,8 @@ }, "devDependencies": { "@eslint/js": "^9.19.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/node": "^22.10.7", "@types/node-fetch": "^2.6.12", @@ -77,13 +79,22 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.13.0", + "jsdom": "^29.0.1", "nodemon": "^3.1.9", "postcss": "^8.5.1", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ag-grid-community/client-side-row-model": { "version": "32.3.4", "resolved": "https://registry.npmjs.org/@ag-grid-community/client-side-row-model/-/client-side-row-model-32.3.4.tgz", @@ -128,6 +139,67 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -417,6 +489,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@choojs/findup": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", @@ -429,6 +514,146 @@ "findup": "bin/findup.js" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -992,6 +1217,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", @@ -1200,9 +1443,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2812,6 +3055,13 @@ "node": ">=14" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2876,6 +3126,82 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz", @@ -2981,6 +3307,14 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3026,6 +3360,17 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -3294,6 +3639,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3503,6 +3855,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -3877,6 +4315,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-bounds": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", @@ -4069,8 +4517,18 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asynckit": { - "version": "0.4.0", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, @@ -4175,6 +4633,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4408,6 +4876,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4891,6 +5369,27 @@ "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csscolorparser": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", @@ -5166,6 +5665,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5237,6 +5750,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -5383,6 +5903,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/draw-svg-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", @@ -6170,6 +6698,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6227,6 +6765,16 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -7582,6 +8130,19 @@ "integrity": "sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7689,6 +8250,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8112,6 +8683,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8403,6 +8981,83 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8718,6 +9373,27 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/map-limit": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", @@ -9192,6 +9868,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9835,6 +10518,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10308,6 +11001,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10553,6 +11257,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -10968,6 +11679,55 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11405,6 +12165,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.9.tgz", @@ -12072,7 +12846,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12314,6 +13087,19 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -12499,6 +13285,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -12604,6 +13397,13 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -12618,6 +13418,13 @@ "escodegen": "^2.1.0" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", @@ -12941,6 +13748,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13102,6 +13922,13 @@ "svg-path-bounds": "^1.0.1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -13349,18 +14176,113 @@ "xtend": "~4.0.1" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyqueue": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-float32": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", @@ -13412,6 +14334,32 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -13607,6 +14555,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -13984,6 +14942,135 @@ } } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", @@ -13995,6 +15082,19 @@ "pbf": "^3.2.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -14052,6 +15152,16 @@ "get-canvas-context": "^1.0.1" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack": { "version": "5.97.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", @@ -14133,6 +15243,31 @@ "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14236,6 +15371,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -14349,6 +15501,23 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b621ac93..256ba875 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "prettier --write '**/*.{js,jsx,css,scss,json}'" + "format": "prettier --write '**/*.{js,jsx,css,scss,json}'", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@ag-grid-community/client-side-row-model": "^32.3.4", @@ -68,6 +70,8 @@ }, "devDependencies": { "@eslint/js": "^9.19.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/node": "^22.10.7", "@types/node-fetch": "^2.6.12", @@ -82,11 +86,13 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.13.0", + "jsdom": "^29.0.1", "nodemon": "^3.1.9", "postcss": "^8.5.1", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.0" }, "vite": { "build": { diff --git a/frontend/src/__tests__/ChatWidget.test.jsx b/frontend/src/__tests__/ChatWidget.test.jsx new file mode 100644 index 00000000..443b245f --- /dev/null +++ b/frontend/src/__tests__/ChatWidget.test.jsx @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ChatWidget from '@/components/widgets/ChatWidget'; + +vi.mock('@/services/llm', () => { + const providers = { + openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1/chat/completions', defaultModel: 'gpt-3.5-turbo', models: ['gpt-3.5-turbo', 'gpt-4'], apiKeyPlaceholder: 'sk-...', apiKeyStorageKey: 'openai_api_key' }, + minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/v1/chat/completions', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed'], apiKeyPlaceholder: 'eyJ...', apiKeyStorageKey: 'minimax_api_key' }, + }; + let sp = 'openai', ak = {}, md = {}; + return { + LLM_PROVIDERS: providers, DEFAULT_PROVIDER: 'openai', + getSelectedProvider: () => sp, setSelectedProvider: (id) => { sp = id; }, + getProviderConfig: (id) => providers[id] || providers.openai, + getApiKey: (id) => ak[id] || '', setApiKey: (id, k) => { ak[id] = k; }, + hasApiKey: (id) => !!ak[id], + getSelectedModel: (id) => md[id] || providers[id]?.defaultModel || 'gpt-3.5-turbo', + setSelectedModel: (id, m) => { md[id] = m; }, + clampTemperature: (id, t) => id === 'minimax' ? Math.max(0, Math.min(1, t)) : t, + createChatCompletion: vi.fn().mockResolvedValue({ role: 'assistant', content: 'Hello!' }), + _reset: () => { sp = 'openai'; ak = {}; md = {}; }, + }; +}); + +import { _reset as resetLlm } from '@/services/llm'; + +describe('ChatWidget', () => { + const props = { id: 'test-chat', value: { messages: [] }, onChange: vi.fn() }; + beforeEach(() => { vi.clearAllMocks(); resetLlm(); }); + + it('renders with API key required state', () => { render(); expect(screen.getAllByText('API Key Required').length).toBeGreaterThanOrEqual(1); }); + it('shows settings on gear click', () => { render(); fireEvent.click(screen.getAllByRole('button')[0]); expect(screen.getByText('LLM Provider')).toBeInTheDocument(); expect(screen.getByText('Model')).toBeInTheDocument(); }); + it('displays OpenAI in provider select', () => { render(); fireEvent.click(screen.getAllByRole('button')[0]); expect(screen.getAllByText('OpenAI').length).toBeGreaterThanOrEqual(1); }); + it('disables input without key', () => { render(); expect(screen.getByPlaceholderText('Please set API key first...')).toBeDisabled(); }); + it('shows Open Settings button', () => { render(); expect(screen.getByText('Open Settings')).toBeInTheDocument(); }); + it('shows provider API key label', () => { render(); fireEvent.click(screen.getAllByRole('button')[0]); expect(screen.getByText('OpenAI API Key')).toBeInTheDocument(); }); + it('Save Key disabled when empty', () => { render(); fireEvent.click(screen.getAllByRole('button')[0]); expect(screen.getByText('Save Key').closest('button')).toBeDisabled(); }); + it('renders send button', () => { render(); expect(screen.getAllByRole('button').find(b => b.type === 'submit')).toBeDefined(); }); + it('applies custom className', () => { const { container } = render(); expect(container.querySelector('#test-chat').className).toContain('custom'); }); +}); diff --git a/frontend/src/__tests__/llm.integration.test.js b/frontend/src/__tests__/llm.integration.test.js new file mode 100644 index 00000000..e1cef967 --- /dev/null +++ b/frontend/src/__tests__/llm.integration.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { createChatCompletion, getSelectedProvider, setSelectedProvider, setApiKey, hasApiKey, getSelectedModel, setSelectedModel } from '@/services/llm'; + +describe('LLM Service integration', () => { + beforeEach(() => { sessionStorage.clear(); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it('full flow: switch, key, model, call', async () => { + expect(getSelectedProvider()).toBe('openai'); + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-int'); + expect(hasApiKey('minimax')).toBe(true); + setSelectedModel('minimax', 'MiniMax-M2.5-highspeed'); + const f = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }) }); + const r = await createChatCompletion([{ role: 'user', content: 'hi' }]); + expect(r).toEqual({ role: 'assistant', content: 'ok' }); + const [url, opts] = f.mock.calls[0]; + expect(url).toBe('https://api.minimax.io/v1/chat/completions'); + expect(opts.headers.Authorization).toBe('Bearer eyJ-int'); + expect(JSON.parse(opts.body).model).toBe('MiniMax-M2.5-highspeed'); + }); + + it('provider switch preserves state', async () => { + setApiKey('openai', 'sk'); setApiKey('minimax', 'ey'); + setSelectedModel('openai', 'gpt-4'); setSelectedModel('minimax', 'MiniMax-M2.7-highspeed'); + const f = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }) }); + setSelectedProvider('openai'); + await createChatCompletion([{ role: 'user', content: 'q1' }]); + expect(JSON.parse(f.mock.calls[0][1].body).model).toBe('gpt-4'); + setSelectedProvider('minimax'); + await createChatCompletion([{ role: 'user', content: 'q2' }]); + expect(JSON.parse(f.mock.calls[1][1].body).model).toBe('MiniMax-M2.7-highspeed'); + setSelectedProvider('openai'); + await createChatCompletion([{ role: 'user', content: 'q3' }]); + expect(JSON.parse(f.mock.calls[2][1].body).model).toBe('gpt-4'); + }); + + it('backward-compat re-export', async () => { + const m = await import('@/services/openai'); + expect(typeof m.createChatCompletion).toBe('function'); + }); +}); diff --git a/frontend/src/__tests__/llm.test.js b/frontend/src/__tests__/llm.test.js new file mode 100644 index 00000000..51db6241 --- /dev/null +++ b/frontend/src/__tests__/llm.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { LLM_PROVIDERS, DEFAULT_PROVIDER, createChatCompletion, getSelectedProvider, setSelectedProvider, getProviderConfig, getApiKey, setApiKey, hasApiKey, getSelectedModel, setSelectedModel, clampTemperature } from '@/services/llm'; + +describe('LLM Service', () => { + beforeEach(() => { sessionStorage.clear(); }); + + describe('LLM_PROVIDERS', () => { + it('includes OpenAI provider', () => { expect(LLM_PROVIDERS.openai).toBeDefined(); expect(LLM_PROVIDERS.openai.name).toBe('OpenAI'); }); + it('includes MiniMax provider', () => { expect(LLM_PROVIDERS.minimax).toBeDefined(); expect(LLM_PROVIDERS.minimax.name).toBe('MiniMax'); expect(LLM_PROVIDERS.minimax.baseUrl).toBe('https://api.minimax.io/v1/chat/completions'); }); + it('MiniMax lists M2.7 and M2.5 models', () => { const m = LLM_PROVIDERS.minimax.models; expect(m).toContain('MiniMax-M2.7'); expect(m).toContain('MiniMax-M2.7-highspeed'); expect(m).toContain('MiniMax-M2.5'); expect(m).toContain('MiniMax-M2.5-highspeed'); }); + it('MiniMax defaults to M2.7', () => { expect(LLM_PROVIDERS.minimax.defaultModel).toBe('MiniMax-M2.7'); }); + it('each provider has required fields', () => { Object.values(LLM_PROVIDERS).forEach((cfg) => { expect(cfg.name).toBeTruthy(); expect(cfg.baseUrl).toBeTruthy(); expect(cfg.defaultModel).toBeTruthy(); expect(cfg.models.length).toBeGreaterThan(0); expect(cfg.apiKeyStorageKey).toBeTruthy(); }); }); + }); + + describe('provider selection', () => { + it('defaults to openai', () => { expect(getSelectedProvider()).toBe('openai'); }); + it('switches to minimax', () => { setSelectedProvider('minimax'); expect(getSelectedProvider()).toBe('minimax'); }); + it('DEFAULT_PROVIDER is openai', () => { expect(DEFAULT_PROVIDER).toBe('openai'); }); + }); + + describe('getProviderConfig', () => { + it('returns openai config', () => { expect(getProviderConfig('openai').name).toBe('OpenAI'); }); + it('returns minimax config', () => { const c = getProviderConfig('minimax'); expect(c.name).toBe('MiniMax'); expect(c.baseUrl).toContain('minimax.io'); }); + it('falls back for unknown', () => { expect(getProviderConfig('x').name).toBe('OpenAI'); }); + }); + + describe('API key management', () => { + it('getApiKey empty when unset', () => { expect(getApiKey('openai')).toBe(''); }); + it('round-trips openai key', () => { setApiKey('openai', 'sk-test'); expect(getApiKey('openai')).toBe('sk-test'); }); + it('round-trips minimax key', () => { setApiKey('minimax', 'eyJt'); expect(getApiKey('minimax')).toBe('eyJt'); }); + it('keys are isolated', () => { setApiKey('openai', 'sk'); setApiKey('minimax', 'ey'); expect(getApiKey('openai')).toBe('sk'); expect(getApiKey('minimax')).toBe('ey'); }); + it('hasApiKey false when unset', () => { expect(hasApiKey('minimax')).toBe(false); }); + it('hasApiKey true after set', () => { setApiKey('minimax', 'k'); expect(hasApiKey('minimax')).toBe(true); }); + }); + + describe('model selection', () => { + it('defaults to provider model', () => { expect(getSelectedModel('openai')).toBe('gpt-3.5-turbo'); expect(getSelectedModel('minimax')).toBe('MiniMax-M2.7'); }); + it('persists model', () => { setSelectedModel('minimax', 'MiniMax-M2.5-highspeed'); expect(getSelectedModel('minimax')).toBe('MiniMax-M2.5-highspeed'); }); + it('models are isolated', () => { setSelectedModel('openai', 'gpt-4'); setSelectedModel('minimax', 'MiniMax-M2.7-highspeed'); expect(getSelectedModel('openai')).toBe('gpt-4'); expect(getSelectedModel('minimax')).toBe('MiniMax-M2.7-highspeed'); }); + }); + + describe('clampTemperature', () => { + it('clamps minimax to [0,1]', () => { expect(clampTemperature('minimax', -0.5)).toBe(0); expect(clampTemperature('minimax', 0)).toBe(0); expect(clampTemperature('minimax', 0.7)).toBe(0.7); expect(clampTemperature('minimax', 1)).toBe(1); expect(clampTemperature('minimax', 2)).toBe(1); }); + it('passes through for openai', () => { expect(clampTemperature('openai', 1.5)).toBe(1.5); }); + }); + + describe('createChatCompletion', () => { + afterEach(() => { vi.restoreAllMocks(); }); + it('throws without key', async () => { await expect(createChatCompletion([{role:'user',content:'hi'}])).rejects.toThrow(/API key not found/); }); + it('calls OpenAI endpoint', async () => { setApiKey('openai','sk-t'); setSelectedProvider('openai'); const f = vi.spyOn(globalThis,'fetch').mockResolvedValue({ok:true,json:()=>Promise.resolve({choices:[{message:{role:'assistant',content:'hi'}}]})}); await createChatCompletion([{role:'user',content:'hello'}]); expect(f).toHaveBeenCalledWith('https://api.openai.com/v1/chat/completions',expect.objectContaining({method:'POST'})); }); + it('calls MiniMax endpoint', async () => { setApiKey('minimax','eyJ'); setSelectedProvider('minimax'); const f = vi.spyOn(globalThis,'fetch').mockResolvedValue({ok:true,json:()=>Promise.resolve({choices:[{message:{role:'assistant',content:'hi'}}]})}); await createChatCompletion([{role:'user',content:'hello'}]); expect(f).toHaveBeenCalledWith('https://api.minimax.io/v1/chat/completions',expect.objectContaining({method:'POST'})); const b = JSON.parse(f.mock.calls[0][1].body); expect(b.model).toBe('MiniMax-M2.7'); }); + it('uses selected model', async () => { setApiKey('minimax','eyJ'); setSelectedProvider('minimax'); setSelectedModel('minimax','MiniMax-M2.5-highspeed'); const f = vi.spyOn(globalThis,'fetch').mockResolvedValue({ok:true,json:()=>Promise.resolve({choices:[{message:{role:'assistant',content:'ok'}}]})}); await createChatCompletion([{role:'user',content:'t'}]); expect(JSON.parse(f.mock.calls[0][1].body).model).toBe('MiniMax-M2.5-highspeed'); }); + it('prepends system context', async () => { setApiKey('openai','sk'); setSelectedProvider('openai'); const f = vi.spyOn(globalThis,'fetch').mockResolvedValue({ok:true,json:()=>Promise.resolve({choices:[{message:{role:'assistant',content:'ok'}}]})}); await createChatCompletion([{role:'user',content:'hi'}],'s','ctx'); const b = JSON.parse(f.mock.calls[0][1].body); expect(b.messages[0]).toEqual({role:'system',content:'ctx'}); }); + it('propagates error', async () => { setApiKey('minimax','k'); setSelectedProvider('minimax'); vi.spyOn(globalThis,'fetch').mockResolvedValue({ok:false,json:()=>Promise.resolve({error:{message:'bad key'}})}); await expect(createChatCompletion([{role:'user',content:'t'}])).rejects.toThrow('bad key'); }); + it('handles network error', async () => { setApiKey('openai','sk'); setSelectedProvider('openai'); vi.spyOn(globalThis,'fetch').mockRejectedValue(new Error('net')); await expect(createChatCompletion([{role:'user',content:'t'}])).rejects.toThrow('net'); }); + }); +}); diff --git a/frontend/src/components/widgets/ChatWidget.jsx b/frontend/src/components/widgets/ChatWidget.jsx index 0f9d9abd..838d5e00 100644 --- a/frontend/src/components/widgets/ChatWidget.jsx +++ b/frontend/src/components/widgets/ChatWidget.jsx @@ -7,9 +7,27 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { createChatCompletion } from '@/services/openai'; +import { + LLM_PROVIDERS, + createChatCompletion, + getSelectedProvider, + setSelectedProvider, + getApiKey, + setApiKey, + hasApiKey as checkHasApiKey, + getSelectedModel, + setSelectedModel, + getProviderConfig, +} from '@/services/llm'; const ChatWidget = ({ id, @@ -26,10 +44,14 @@ const ChatWidget = ({ const [inputValue, setInputValue] = useState(''); const [showSettings, setShowSettings] = useState(false); - const [apiKey, setApiKey] = useState(''); + const [apiKeyInput, setApiKeyInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const hasApiKey = useMemo(() => !!sessionStorage.getItem('openai_api_key'), []); + + const [providerId, setProviderId] = useState(getSelectedProvider()); + const [modelId, setModelId] = useState(getSelectedModel(providerId)); + const providerConfig = useMemo(() => getProviderConfig(providerId), [providerId]); + const apiKeyReady = useMemo(() => checkHasApiKey(providerId), [providerId]); // Add this state to store the processed context const [sourceContext, setSourceContext] = useState(null); @@ -92,23 +114,23 @@ const ChatWidget = ({ - Source Name: ${sourceName} - Number of Records: ${rowCount} - Available Columns: ${columns.join(', ')} - + Sample Data Preview: ${JSON.stringify(sampleData, null, 2)} - + Your responsibilities: 1. Analyze the data structure and relationships 2. Provide detailed insights based on the available information 3. Answer questions specifically referencing this dataset 4. Highlight any patterns or anomalies you observe 5. Make data-driven recommendations when appropriate - + Please ensure your responses are: - Accurate and based on the provided data - Clear and well-structured - Include specific examples from the dataset when relevant - Highlight any assumptions or limitations in your analysis - + When answering questions, always reference specific data points to support your conclusions.`; } catch (error) { console.error('Error formatting source context:', error); @@ -163,13 +185,25 @@ const ChatWidget = ({ return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; - // Add this function to handle API key submission + const handleProviderChange = (newProviderId) => { + setSelectedProvider(newProviderId); + setProviderId(newProviderId); + const newModel = getSelectedModel(newProviderId); + setModelId(newModel); + setApiKeyInput(''); + }; + + const handleModelChange = (newModelId) => { + setSelectedModel(providerId, newModelId); + setModelId(newModelId); + }; + const handleApiKeySubmit = (e) => { e.preventDefault(); - if (apiKey.trim()) { - sessionStorage.setItem('openai_api_key', apiKey.trim()); + if (apiKeyInput.trim()) { + setApiKey(providerId, apiKeyInput.trim()); setShowSettings(false); - window.location.reload(); // Refresh to update hasApiKey state + window.location.reload(); } }; @@ -187,11 +221,11 @@ const ChatWidget = ({

- {hasApiKey ? 'Online' : 'API Key Required'} + {apiKeyReady ? `${providerConfig.name} Online` : 'API Key Required'}

@@ -236,13 +302,13 @@ const ChatWidget = ({ ref={chatContainerRef} >
- {!hasApiKey && !showSettings ? ( + {!apiKeyReady && !showSettings ? (

API Key Required

- Please set your OpenAI API key to start chatting + Please set your {providerConfig.name} API key to start chatting

diff --git a/frontend/src/services/__tests__/llm.integration.test.js b/frontend/src/services/__tests__/llm.integration.test.js new file mode 100644 index 00000000..413e3666 --- /dev/null +++ b/frontend/src/services/__tests__/llm.integration.test.js @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + createChatCompletion, + getSelectedProvider, + setSelectedProvider, + setApiKey, + setSelectedModel, + getSelectedModel, + hasApiKey, +} from '../llm'; + +// Mock sessionStorage +const mockStorage = {}; +const sessionStorageMock = { + getItem: vi.fn((key) => mockStorage[key] || null), + setItem: vi.fn((key, value) => { + mockStorage[key] = value; + }), + removeItem: vi.fn((key) => { + delete mockStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); + }), +}; +Object.defineProperty(globalThis, 'sessionStorage', { value: sessionStorageMock }); + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +describe('Integration: provider switching workflow', () => { + beforeEach(() => { + sessionStorageMock.clear(); + mockFetch.mockReset(); + }); + + it('should switch from OpenAI to MiniMax and back', async () => { + // Start with OpenAI + expect(getSelectedProvider()).toBe('openai'); + setApiKey('openai', 'sk-openai-key'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'OpenAI response' } }], + }), + }); + + let result = await createChatCompletion( + [{ role: 'user', content: 'test' }], + 'src', + null + ); + expect(result.content).toBe('OpenAI response'); + expect(mockFetch.mock.calls[0][0]).toContain('openai.com'); + + // Switch to MiniMax + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-minimax-key'); + expect(getSelectedProvider()).toBe('minimax'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'MiniMax response' } }], + }), + }); + + result = await createChatCompletion( + [{ role: 'user', content: 'test' }], + 'src', + null + ); + expect(result.content).toBe('MiniMax response'); + expect(mockFetch.mock.calls[1][0]).toContain('minimax.io'); + + // Switch back to OpenAI - key should still be there + setSelectedProvider('openai'); + expect(hasApiKey('openai')).toBe(true); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'Back to OpenAI' } }], + }), + }); + + result = await createChatCompletion( + [{ role: 'user', content: 'test' }], + 'src', + null + ); + expect(result.content).toBe('Back to OpenAI'); + expect(mockFetch.mock.calls[2][0]).toContain('openai.com'); + }); + + it('should handle multi-turn conversation with MiniMax', async () => { + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-key'); + + const systemContext = 'You are a data analyst for the iris dataset.'; + const messages = [ + { role: 'user', content: 'What columns are available?' }, + ]; + + // First turn + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + role: 'assistant', + content: 'The dataset has: sepal_length, sepal_width, petal_length, petal_width, species.', + }, + }, + ], + }), + }); + + const reply1 = await createChatCompletion(messages, 'iris', systemContext); + expect(reply1.role).toBe('assistant'); + + // Second turn + messages.push(reply1); + messages.push({ role: 'user', content: 'Show me the average sepal_length by species.' }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + role: 'assistant', + content: 'The average sepal length by species is:\n- setosa: 5.01\n- versicolor: 5.94\n- virginica: 6.59', + }, + }, + ], + }), + }); + + const reply2 = await createChatCompletion(messages, 'iris', systemContext); + expect(reply2.content).toContain('setosa'); + + // Verify system context was prepended each time + const body1 = JSON.parse(mockFetch.mock.calls[0][1].body); + const body2 = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(body1.messages[0].role).toBe('system'); + expect(body2.messages[0].role).toBe('system'); + expect(body2.messages.length).toBe(4); // system + user + assistant + user + }); + + it('should use correct model after model change', async () => { + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-key'); + + // Use default model first + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'default model' } }], + }), + }); + + await createChatCompletion([{ role: 'user', content: 'hi' }], 'src', null); + let body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.model).toBe('MiniMax-M2.7'); + + // Switch model + setSelectedModel('minimax', 'MiniMax-M2.5-highspeed'); + expect(getSelectedModel('minimax')).toBe('MiniMax-M2.5-highspeed'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'highspeed model' } }], + }), + }); + + await createChatCompletion([{ role: 'user', content: 'hi' }], 'src', null); + body = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(body.model).toBe('MiniMax-M2.5-highspeed'); + }); +}); diff --git a/frontend/src/services/__tests__/llm.test.js b/frontend/src/services/__tests__/llm.test.js new file mode 100644 index 00000000..1859d1a0 --- /dev/null +++ b/frontend/src/services/__tests__/llm.test.js @@ -0,0 +1,309 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + LLM_PROVIDERS, + DEFAULT_PROVIDER, + createChatCompletion, + getSelectedProvider, + setSelectedProvider, + getProviderConfig, + getApiKey, + setApiKey, + hasApiKey, + getSelectedModel, + setSelectedModel, + clampTemperature, +} from '../llm'; + +// Mock sessionStorage +const mockStorage = {}; +const sessionStorageMock = { + getItem: vi.fn((key) => mockStorage[key] || null), + setItem: vi.fn((key, value) => { + mockStorage[key] = value; + }), + removeItem: vi.fn((key) => { + delete mockStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); + }), +}; +Object.defineProperty(globalThis, 'sessionStorage', { value: sessionStorageMock }); + +// Mock fetch +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +describe('LLM_PROVIDERS', () => { + it('should contain openai provider', () => { + expect(LLM_PROVIDERS.openai).toBeDefined(); + expect(LLM_PROVIDERS.openai.name).toBe('OpenAI'); + expect(LLM_PROVIDERS.openai.baseUrl).toContain('api.openai.com'); + expect(LLM_PROVIDERS.openai.defaultModel).toBe('gpt-3.5-turbo'); + expect(LLM_PROVIDERS.openai.models.length).toBeGreaterThan(0); + }); + + it('should contain minimax provider', () => { + expect(LLM_PROVIDERS.minimax).toBeDefined(); + expect(LLM_PROVIDERS.minimax.name).toBe('MiniMax'); + expect(LLM_PROVIDERS.minimax.baseUrl).toContain('api.minimax.io'); + expect(LLM_PROVIDERS.minimax.defaultModel).toBe('MiniMax-M2.7'); + expect(LLM_PROVIDERS.minimax.models).toContain('MiniMax-M2.7'); + expect(LLM_PROVIDERS.minimax.models).toContain('MiniMax-M2.7-highspeed'); + expect(LLM_PROVIDERS.minimax.models).toContain('MiniMax-M2.5'); + expect(LLM_PROVIDERS.minimax.models).toContain('MiniMax-M2.5-highspeed'); + }); + + it('should have separate API key storage keys per provider', () => { + expect(LLM_PROVIDERS.openai.apiKeyStorageKey).toBe('openai_api_key'); + expect(LLM_PROVIDERS.minimax.apiKeyStorageKey).toBe('minimax_api_key'); + expect(LLM_PROVIDERS.openai.apiKeyStorageKey).not.toBe( + LLM_PROVIDERS.minimax.apiKeyStorageKey + ); + }); + + it('should have unique placeholder for each provider', () => { + expect(LLM_PROVIDERS.openai.apiKeyPlaceholder).toBe('sk-...'); + expect(LLM_PROVIDERS.minimax.apiKeyPlaceholder).toBe('eyJ...'); + }); +}); + +describe('DEFAULT_PROVIDER', () => { + it('should be openai', () => { + expect(DEFAULT_PROVIDER).toBe('openai'); + }); +}); + +describe('getSelectedProvider / setSelectedProvider', () => { + beforeEach(() => { + sessionStorageMock.clear(); + }); + + it('should return default provider when nothing is set', () => { + expect(getSelectedProvider()).toBe('openai'); + }); + + it('should return the provider after setting it', () => { + setSelectedProvider('minimax'); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith('llm_provider', 'minimax'); + expect(getSelectedProvider()).toBe('minimax'); + }); +}); + +describe('getProviderConfig', () => { + it('should return config for valid provider', () => { + expect(getProviderConfig('openai').name).toBe('OpenAI'); + expect(getProviderConfig('minimax').name).toBe('MiniMax'); + }); + + it('should fall back to default for unknown provider', () => { + expect(getProviderConfig('unknown').name).toBe('OpenAI'); + }); +}); + +describe('getApiKey / setApiKey / hasApiKey', () => { + beforeEach(() => { + sessionStorageMock.clear(); + }); + + it('should return empty string when no key set', () => { + expect(getApiKey('openai')).toBe(''); + expect(getApiKey('minimax')).toBe(''); + }); + + it('should store and retrieve API key per provider', () => { + setApiKey('openai', 'sk-test123'); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith('openai_api_key', 'sk-test123'); + expect(getApiKey('openai')).toBe('sk-test123'); + }); + + it('should store minimax key independently', () => { + setApiKey('minimax', 'eyJtest'); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith('minimax_api_key', 'eyJtest'); + expect(getApiKey('minimax')).toBe('eyJtest'); + }); + + it('hasApiKey should return false when no key set', () => { + expect(hasApiKey('openai')).toBe(false); + }); + + it('hasApiKey should return true when key is set', () => { + setApiKey('openai', 'sk-test'); + expect(hasApiKey('openai')).toBe(true); + }); +}); + +describe('getSelectedModel / setSelectedModel', () => { + beforeEach(() => { + sessionStorageMock.clear(); + }); + + it('should return default model when nothing set', () => { + expect(getSelectedModel('openai')).toBe('gpt-3.5-turbo'); + expect(getSelectedModel('minimax')).toBe('MiniMax-M2.7'); + }); + + it('should store and retrieve selected model', () => { + setSelectedModel('minimax', 'MiniMax-M2.5'); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith('minimax_model', 'MiniMax-M2.5'); + expect(getSelectedModel('minimax')).toBe('MiniMax-M2.5'); + }); + + it('should keep models independent per provider', () => { + setSelectedModel('openai', 'gpt-4'); + setSelectedModel('minimax', 'MiniMax-M2.7-highspeed'); + expect(getSelectedModel('openai')).toBe('gpt-4'); + expect(getSelectedModel('minimax')).toBe('MiniMax-M2.7-highspeed'); + }); +}); + +describe('clampTemperature', () => { + it('should clamp minimax temperature to [0, 1]', () => { + expect(clampTemperature('minimax', 1.5)).toBe(1); + expect(clampTemperature('minimax', -0.5)).toBe(0); + expect(clampTemperature('minimax', 0.7)).toBe(0.7); + expect(clampTemperature('minimax', 0)).toBe(0); + expect(clampTemperature('minimax', 1)).toBe(1); + }); + + it('should not clamp openai temperature', () => { + expect(clampTemperature('openai', 1.5)).toBe(1.5); + expect(clampTemperature('openai', 2.0)).toBe(2.0); + }); +}); + +describe('createChatCompletion', () => { + beforeEach(() => { + sessionStorageMock.clear(); + mockFetch.mockReset(); + }); + + it('should throw when no API key is set', async () => { + await expect( + createChatCompletion([{ role: 'user', content: 'hello' }], 'src', null) + ).rejects.toThrow('API key not found'); + }); + + it('should call OpenAI endpoint with correct parameters', async () => { + setApiKey('openai', 'sk-test'); + setSelectedProvider('openai'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'Hi!' } }], + }), + }); + + const result = await createChatCompletion( + [{ role: 'user', content: 'hello' }], + 'source1', + null + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('api.openai.com'); + expect(options.headers.Authorization).toBe('Bearer sk-test'); + const body = JSON.parse(options.body); + expect(body.model).toBe('gpt-3.5-turbo'); + expect(body.messages).toHaveLength(1); + expect(result).toEqual({ role: 'assistant', content: 'Hi!' }); + }); + + it('should call MiniMax endpoint when minimax is selected', async () => { + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-minimax-key'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'MiniMax reply' } }], + }), + }); + + const result = await createChatCompletion( + [{ role: 'user', content: 'hello' }], + 'source1', + null + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('api.minimax.io'); + expect(options.headers.Authorization).toBe('Bearer eyJ-minimax-key'); + const body = JSON.parse(options.body); + expect(body.model).toBe('MiniMax-M2.7'); + expect(result).toEqual({ role: 'assistant', content: 'MiniMax reply' }); + }); + + it('should prepend system context as first message', async () => { + setSelectedProvider('openai'); + setApiKey('openai', 'sk-test'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'response' } }], + }), + }); + + await createChatCompletion( + [{ role: 'user', content: 'tell me about the data' }], + 'src', + 'You are a data analyst.' + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.messages[0]).toEqual({ role: 'system', content: 'You are a data analyst.' }); + expect(body.messages[1]).toEqual({ role: 'user', content: 'tell me about the data' }); + }); + + it('should use selected model when overridden', async () => { + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-key'); + setSelectedModel('minimax', 'MiniMax-M2.5-highspeed'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { role: 'assistant', content: 'fast' } }], + }), + }); + + await createChatCompletion([{ role: 'user', content: 'hi' }], 'src', null); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.model).toBe('MiniMax-M2.5-highspeed'); + }); + + it('should throw on API error response', async () => { + setSelectedProvider('openai'); + setApiKey('openai', 'sk-bad'); + + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => ({ + error: { message: 'Invalid API key' }, + }), + }); + + await expect( + createChatCompletion([{ role: 'user', content: 'hi' }], 'src', null) + ).rejects.toThrow('Invalid API key'); + }); + + it('should throw with provider name on generic error', async () => { + setSelectedProvider('minimax'); + setApiKey('minimax', 'eyJ-key'); + + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + }); + + await expect( + createChatCompletion([{ role: 'user', content: 'hi' }], 'src', null) + ).rejects.toThrow('MiniMax'); + }); +}); diff --git a/frontend/src/services/llm.js b/frontend/src/services/llm.js new file mode 100644 index 00000000..d86c2e57 --- /dev/null +++ b/frontend/src/services/llm.js @@ -0,0 +1,159 @@ +/** + * Multi-provider LLM service for Preswald chat. + * + * Supported providers: + * - OpenAI (default) + * - MiniMax (OpenAI-compatible API) + */ + +const LLM_PROVIDERS = { + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1/chat/completions', + defaultModel: 'gpt-3.5-turbo', + models: ['gpt-3.5-turbo', 'gpt-4', 'gpt-4o', 'gpt-4o-mini'], + apiKeyPlaceholder: 'sk-...', + apiKeyStorageKey: 'openai_api_key', + }, + minimax: { + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/v1/chat/completions', + defaultModel: 'MiniMax-M2.7', + models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], + apiKeyPlaceholder: 'eyJ...', + apiKeyStorageKey: 'minimax_api_key', + }, +}; + +const DEFAULT_PROVIDER = 'openai'; + +/** + * Get the currently selected provider ID from session storage. + */ +const getSelectedProvider = () => { + return sessionStorage.getItem('llm_provider') || DEFAULT_PROVIDER; +}; + +/** + * Set the selected provider ID in session storage. + */ +const setSelectedProvider = (providerId) => { + sessionStorage.setItem('llm_provider', providerId); +}; + +/** + * Get the provider config for a given provider ID. + */ +const getProviderConfig = (providerId) => { + return LLM_PROVIDERS[providerId] || LLM_PROVIDERS[DEFAULT_PROVIDER]; +}; + +/** + * Get the API key for the current (or specified) provider. + */ +const getApiKey = (providerId) => { + const config = getProviderConfig(providerId || getSelectedProvider()); + return sessionStorage.getItem(config.apiKeyStorageKey) || ''; +}; + +/** + * Save the API key for a given provider. + */ +const setApiKey = (providerId, key) => { + const config = getProviderConfig(providerId); + sessionStorage.setItem(config.apiKeyStorageKey, key); +}; + +/** + * Check whether an API key is configured for the current provider. + */ +const hasApiKey = (providerId) => { + return !!getApiKey(providerId || getSelectedProvider()); +}; + +/** + * Get the selected model for the current provider, falling back to the default. + */ +const getSelectedModel = (providerId) => { + const id = providerId || getSelectedProvider(); + return sessionStorage.getItem(`${id}_model`) || getProviderConfig(id).defaultModel; +}; + +/** + * Save the selected model for a provider. + */ +const setSelectedModel = (providerId, model) => { + sessionStorage.setItem(`${providerId}_model`, model); +}; + +/** + * Clamp temperature for providers that require it. + */ +const clampTemperature = (providerId, temperature) => { + if (providerId === 'minimax') { + return Math.max(0, Math.min(1, temperature)); + } + return temperature; +}; + +/** + * Create a chat completion using the currently selected LLM provider. + */ +const createChatCompletion = async (messages, sourceId, sourceContext) => { + const providerId = getSelectedProvider(); + const config = getProviderConfig(providerId); + const apiKey = getApiKey(providerId); + + if (!apiKey) { + throw new Error(`API key not found for ${config.name}. Please set your API key in settings.`); + } + + let formattedMessages = messages.map(({ role, content }) => ({ role, content })); + + if (sourceContext) { + formattedMessages.unshift({ role: 'system', content: sourceContext }); + } + + const model = getSelectedModel(providerId); + const body = { + model, + messages: formattedMessages, + }; + + try { + const response = await fetch(config.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error?.message || `Failed to get response from ${config.name}`); + } + + const data = await response.json(); + return data.choices[0].message; + } catch (error) { + console.error(`Error calling ${config.name} API:`, error); + throw error; + } +}; + +export { + LLM_PROVIDERS, + DEFAULT_PROVIDER, + createChatCompletion, + getSelectedProvider, + setSelectedProvider, + getProviderConfig, + getApiKey, + setApiKey, + hasApiKey, + getSelectedModel, + setSelectedModel, + clampTemperature, +}; diff --git a/frontend/src/services/openai.js b/frontend/src/services/openai.js index e55d8ab0..a781aece 100644 --- a/frontend/src/services/openai.js +++ b/frontend/src/services/openai.js @@ -1,40 +1,8 @@ -const createChatCompletion = async (messages, sourceId, sourceContext) => { - const apiKey = sessionStorage.getItem('openai_api_key'); - if (!apiKey) { - throw new Error('API key not found'); - } +/** + * Backward-compatible re-export from the multi-provider LLM service. + * + * New code should import from '@/services/llm' directly. + */ +import { createChatCompletion } from './llm'; - // Prepare messages array with system context if provided - let formattedMessages = messages.map(({ role, content }) => ({ role, content })); - - // Add system context as the first message if available - if (sourceContext) { - formattedMessages.unshift({ role: 'system', content: sourceContext }); - } - - try { - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: 'gpt-3.5-turbo', - messages: formattedMessages, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error?.message || 'Failed to get response from OpenAI'); - } - - const data = await response.json(); - return data.choices[0].message; - } catch (error) { - console.error('Error calling OpenAI API:', error); - throw error; - } -}; export { createChatCompletion }; diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 00000000..793d0ecb --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { alias: { '@': path.resolve(__dirname, './src') } }, + test: { environment: 'jsdom', setupFiles: ['./vitest.setup.js'], globals: true }, +}); diff --git a/frontend/vitest.setup.js b/frontend/vitest.setup.js new file mode 100644 index 00000000..8576857a --- /dev/null +++ b/frontend/vitest.setup.js @@ -0,0 +1,11 @@ +import '@testing-library/jest-dom'; +const store = {}; +Object.defineProperty(globalThis, 'sessionStorage', { + value: { + getItem: (k) => store[k] || null, + setItem: (k, v) => { store[k] = String(v); }, + removeItem: (k) => { delete store[k]; }, + clear: () => { Object.keys(store).forEach((k) => delete store[k]); }, + }, + writable: true, +});