From 88fae02d3b6e5288f3beddcdfa9f0fd6a25acd32 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sun, 22 Mar 2026 20:29:17 +0800 Subject: [PATCH 1/2] feat: add MiniMax as alternative LLM provider for chat widget Add multi-provider LLM support to the chat component, enabling users to choose between OpenAI and MiniMax as their LLM backend. MiniMax uses an OpenAI-compatible API, making integration seamless. Changes: - Create frontend/src/services/llm.js with provider registry, per-provider API key storage, model selection, and temperature clamping - Update ChatWidget.jsx with provider/model selector dropdowns in settings - Refactor openai.js to re-export from llm.js for backward compatibility - Add 26 unit tests and 3 integration tests (vitest) - Update README with multi-provider chat feature mention --- README.md | 1 + frontend/package-lock.json | 1183 ++++++++++++++++- frontend/package.json | 10 +- .../src/components/widgets/ChatWidget.jsx | 114 +- .../__tests__/llm.integration.test.js | 184 +++ frontend/src/services/__tests__/llm.test.js | 309 +++++ frontend/src/services/llm.js | 159 +++ frontend/src/services/openai.js | 44 +- 8 files changed, 1933 insertions(+), 71 deletions(-) create mode 100644 frontend/src/services/__tests__/llm.integration.test.js create mode 100644 frontend/src/services/__tests__/llm.test.js create mode 100644 frontend/src/services/llm.js 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/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 }; From 434219be5ef6da8a4ee8a616f51b3cfa4852eb63 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sun, 22 Mar 2026 20:32:37 +0800 Subject: [PATCH 2/2] test: add ChatWidget component tests and top-level test directory Add 9 ChatWidget component tests covering provider selection UI, API key state management, and settings panel rendering. Move test infrastructure (vitest.config.js, vitest.setup.js) to frontend root for proper discovery from both test directories. --- frontend/src/__tests__/ChatWidget.test.jsx | 40 +++++++++++++ .../src/__tests__/llm.integration.test.js | 42 ++++++++++++++ frontend/src/__tests__/llm.test.js | 57 +++++++++++++++++++ frontend/vitest.config.js | 9 +++ frontend/vitest.setup.js | 11 ++++ 5 files changed, 159 insertions(+) create mode 100644 frontend/src/__tests__/ChatWidget.test.jsx create mode 100644 frontend/src/__tests__/llm.integration.test.js create mode 100644 frontend/src/__tests__/llm.test.js create mode 100644 frontend/vitest.config.js create mode 100644 frontend/vitest.setup.js 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/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, +});