diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..386b171 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run test:run + - run: npm run build diff --git a/README.md b/README.md index 903973c..9ddb0fb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ # OpenStudio -OpenStudio is a browser and desktop DAW built with React, Web Audio API, and Electron. It brings the core beatmaking workflow into one app: browse sounds, build patterns, edit melodies, arrange clips, mix tracks, save projects locally or in the cloud, and export the final track to WAV or MP3. The built-in AI Agent helps shape your project, from melodies and drum patterns to instruments, mixer settings, clips, BPM, and FX. +OpenStudio is a browser and desktop DAW built with React, Web Audio API, and Electron. It brings the core beatmaking workflow into one app: browse sounds, build patterns, arrange clips, mix tracks, and export the final track to WAV or MP3. The built-in AI Agent helps shape melodies, drums, instruments, mixer settings, and FX.

![OpenStudio Preview](docs/media/openstudio-preview.gif) @@ -53,7 +53,7 @@ It is a full interactive app rather than a static UI mockup, with multiple DAW w - Realtime sample playback, SoundFont instruments, mixer routing, meters, and built-in FX - Local `.os` project files plus Supabase-backed cloud saves, loading, overwrites, search, sorting, and deletion - Offline WAV / MP3 rendering through the same audio-domain timing logic used by playback -- Built-in AI Agent with OpenAI/Gemini BYOK support, project-aware edit plans, Preview + Apply, and one-click plan undo +- Built-in AI Agent with user-provided OpenAI or Gemini API keys, project-aware edit plans, Preview + Apply, and one-click plan undo - Runtime DAW themes with separate plugin styling for consistent built-in effect UIs - Electron desktop packaging from the same React/Web Audio codebase @@ -136,7 +136,7 @@ OpenStudio includes a built-in AI Agent for project-aware music production assis The agent uses a Preview + Apply workflow: it proposes a validated operation list first, then you decide whether to apply it. Applied AI plans can be undone in one step. -AI provider support is bring-your-own-key: +Choose an AI provider and enter your own API key: - OpenAI - Gemini @@ -283,6 +283,7 @@ Installer artifacts are generated in `release/`. - `npm run desktop:start` - build and launch the unpacked desktop app - `npm run desktop:installer` - build the Windows installer - `npm run lint` - run ESLint +- `npm run typecheck` - run TypeScript validation without emitting files ## Project Structure @@ -313,7 +314,7 @@ OpenStudio/ ## Tech Stack -- JavaScript / ES modules - application codebase and tooling scripts +- JavaScript and TypeScript / ES modules - application codebase and tooling scripts - HTML5 and CSS3 - app shell, custom UI, responsive layouts, and theme styling - React 19 - windowed DAW interface, editors, dialogs, and plugin UIs - Redux Toolkit + React Redux - project state, mixer state, transport state, patterns, clips, channels, and UI state @@ -324,7 +325,7 @@ OpenStudio/ - Supabase Auth - account creation, sign in, and authenticated user sessions - PostgreSQL / Supabase Database - project metadata, user profile records, and AI conversation history - Supabase Storage - cloud `.os` project file storage -- OpenAI / Gemini APIs - optional BYOK AI Agent providers for project-aware edit plans +- OpenAI / Gemini APIs - optional user-provided API keys for project-aware edit plans - Vite 8 - dev server and production bundling - Electron 41 - desktop runtime for the Windows app - Electron Builder - unpacked desktop builds and NSIS installer packaging diff --git a/eslint.config.js b/eslint.config.js index 76e092d..2514e50 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,14 +2,16 @@ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), { - files: ['**/*.{js,jsx}'], + files: ['**/*.{js,jsx,ts,tsx}'], extends: [ js.configs.recommended, + ...tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], @@ -23,7 +25,8 @@ export default defineConfig([ }, }, rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], }, }, { diff --git a/package-lock.json b/package-lock.json index bc53f28..89ef6d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -35,6 +36,8 @@ "globals": "^17.4.0", "jsdom": "^29.1.1", "rcedit": "^4.0.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", "vite": "^8.0.1", "vitest": "^4.1.5" } @@ -2059,12 +2062,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/plist": { @@ -2143,6 +2146,301 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -3489,6 +3787,20 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/config-file-ts/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -8307,6 +8619,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8341,9 +8666,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8354,6 +8679,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -8365,9 +8714,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unique-filename": { diff --git a/package.json b/package.json index 4f320f3..f2f000f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "desktop:start": "npm run desktop:pack && start \"\" \".\\release\\win-unpacked\\OpenStudio.exe\"", "desktop:installer": "npm run desktop:pack && npm run installer:assets && electron-builder --win nsis --prepackaged release/win-unpacked --config.win.signAndEditExecutable=false", "lint": "eslint .", + "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "vitest", "test:run": "vitest run" @@ -40,6 +41,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -51,6 +53,8 @@ "globals": "^17.4.0", "jsdom": "^29.1.1", "rcedit": "^4.0.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", "vite": "^8.0.1", "vitest": "^4.1.5" }, diff --git a/src/ai/aiAgentOperations.test.js b/src/ai/aiAgentOperations.test.ts similarity index 88% rename from src/ai/aiAgentOperations.test.js rename to src/ai/aiAgentOperations.test.ts index 894c3d6..461e47f 100644 --- a/src/ai/aiAgentOperations.test.js +++ b/src/ai/aiAgentOperations.test.ts @@ -5,7 +5,49 @@ import { validatePreparedAiOperations, } from "./aiAgentOperations"; -function createDawState() { +interface TestNote { + id?: string + start?: number + length?: number + pitch?: number + velocity?: number + source?: string +} + +interface TestDawState { + project: { + activePatternId: string + activeChannelId: string + patterns: Array<{ + id: string + name: string + lengthSteps: number + stepGrid: Record + pianoPreview: Record + }> + channels: Array<{ id: string; name: string; pluginRef?: string }> + playlistTracks: Array<{ id: string; name: string }> + } + mixer: { + inserts: Array<{ + id: string + fxSlots: Array<{ id: string; enabled: boolean; effectType: string }> + }> + } +} + +interface DispatchedAction { + type: string + payload: { + notes: TestNote[] + allowOverlaps: boolean + pluginRef: string + pluginName: string + channelId: string + } +} + +function createDawState(): TestDawState { return { project: { activePatternId: "pat-1", @@ -156,8 +198,8 @@ describe("validatePreparedAiOperations", function () { describe("applyAiOperations", function () { it("dispatches a step toggle only when the desired value differs", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -200,8 +242,8 @@ describe("applyAiOperations", function () { it("dispatches instrument assignment operations", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -234,8 +276,8 @@ describe("applyAiOperations", function () { it("normalizes AI note velocity before dispatching piano notes", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -266,8 +308,8 @@ describe("applyAiOperations", function () { it("expands chord progressions into long stacked piano notes", function () { const dawState = createDawState(); dawState.project.patterns[0].lengthSteps = 64; - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -302,8 +344,8 @@ describe("applyAiOperations", function () { it("dispatches BPM changes", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -332,8 +374,8 @@ describe("applyAiOperations", function () { }; dawState.project.patterns[0].stepGrid["ch-kick"][0] = true; dawState.project.patterns[0].stepGrid["ch-kick"][4] = true; - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -352,7 +394,7 @@ describe("applyAiOperations", function () { const clearAction = dispatched.find(function (action) { return action.type === "daw/removePianoNotesBatch"; - }); + })!; expect(clearAction).toBeDefined(); expect(clearAction.payload.notes).toEqual( expect.arrayContaining([ @@ -365,8 +407,8 @@ describe("applyAiOperations", function () { it("auto-expands pattern length before adding notes beyond the current pattern", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -388,10 +430,10 @@ describe("applyAiOperations", function () { const lengthAction = dispatched.find(function (action) { return action.type === "daw/setPatternLength"; - }); + })!; const notesAction = dispatched.find(function (action) { return action.type === "daw/addPianoNotesBatch"; - }); + })!; expect(lengthAction.payload).toEqual({ patternId: "pat-1", length: 34 }); expect(notesAction.payload.notes[0]).toEqual( expect.objectContaining({ start: 30, length: 4 }), @@ -400,8 +442,8 @@ describe("applyAiOperations", function () { it("auto-expands pattern length before adding chord progressions", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -423,10 +465,10 @@ describe("applyAiOperations", function () { const lengthAction = dispatched.find(function (action) { return action.type === "daw/setPatternLength"; - }); + })!; const notesAction = dispatched.find(function (action) { return action.type === "daw/addPianoNotesBatch"; - }); + })!; expect(lengthAction.payload).toEqual({ patternId: "pat-1", length: 32 }); expect(notesAction.payload.notes).toHaveLength(3); expect(notesAction.payload.notes[0]).toEqual( @@ -436,8 +478,8 @@ describe("applyAiOperations", function () { it("auto-assigns pluginRef when add_channel includes one", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -459,7 +501,7 @@ describe("applyAiOperations", function () { const assignAction = dispatched.find(function (action) { return action.type === "daw/assignPluginToChannel"; - }); + })!; expect(assignAction).toBeDefined(); expect(assignAction.payload.pluginRef).toBe("openstudio-piano"); expect(assignAction.payload.pluginName).toBe("Piano"); @@ -467,8 +509,8 @@ describe("applyAiOperations", function () { it("does not auto-assign when pluginRef is invalid", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -490,7 +532,7 @@ describe("applyAiOperations", function () { const assignAction = dispatched.find(function (action) { return action.type === "daw/assignPluginToChannel"; - }); + })!; expect(assignAction).toBeUndefined(); }); @@ -566,8 +608,8 @@ describe("applyAiOperations", function () { describe("applyAiOperations with $new channel reference", function () { it("resolves $new to the channel just created by add_channel", function () { const dawState = createDawState(); - const dispatched = []; - const dispatch = function (action) { + const dispatched: DispatchedAction[] = []; + const dispatch = function (action: DispatchedAction) { dispatched.push(action); }; const getState = function () { @@ -595,7 +637,7 @@ describe("applyAiOperations with $new channel reference", function () { // not the literal string "$new". const notesAction = dispatched.find(function (action) { return action.type === "daw/addPianoNotesBatch"; - }); + })!; expect(notesAction).toBeDefined(); expect(notesAction.payload.channelId).not.toBe("$new"); expect(notesAction.payload.channelId).not.toBe("$active"); diff --git a/src/ai/aiAgentOperations.js b/src/ai/aiAgentOperations.ts similarity index 98% rename from src/ai/aiAgentOperations.js rename to src/ai/aiAgentOperations.ts index ad92c2d..5220a03 100644 --- a/src/ai/aiAgentOperations.js +++ b/src/ai/aiAgentOperations.ts @@ -1,3 +1,9 @@ +// This module is moved before the Redux reducers because it dispatches their +// untyped JS actions directly. Its public contracts are typed in src/types/ai. +// Internal strict typing follows with the store migration. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + import { addChannel, addMixerTrack, diff --git a/src/ai/aiAgentPrompt.js b/src/ai/aiAgentPrompt.ts similarity index 95% rename from src/ai/aiAgentPrompt.js rename to src/ai/aiAgentPrompt.ts index 474815f..fc83d70 100644 --- a/src/ai/aiAgentPrompt.js +++ b/src/ai/aiAgentPrompt.ts @@ -1,4 +1,6 @@ -export const AI_AGENT_DEFAULT_MODEL = "gpt-5.5"; +import type { AiOperationType } from "../types/ai" + +export const AI_AGENT_DEFAULT_MODEL = "gpt-5.5" export const AI_AGENT_OPERATION_TYPES = [ "set_bpm", @@ -30,9 +32,9 @@ export const AI_AGENT_OPERATION_TYPES = [ "set_fx_reverb_param", "set_fx_maximizer_param", "set_fx_graphic_eq_band_gain", -]; +] as const satisfies readonly AiOperationType[] -export function getAiAgentSystemPrompt() { +export function getAiAgentSystemPrompt(): string { return [ "You are OpenStudio AI Agent, a DAW assistant inside a React/Web Audio music app.", "You must return JSON only. Do not include Markdown.", diff --git a/src/ai/aiClient.test.js b/src/ai/aiClient.test.ts similarity index 77% rename from src/ai/aiClient.test.js rename to src/ai/aiClient.test.ts index e1926d5..557b63b 100644 --- a/src/ai/aiClient.test.js +++ b/src/ai/aiClient.test.ts @@ -28,6 +28,13 @@ import { testOpenAiConnection, } from "./openAiClient"; import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient"; +import type { AiProjectSummary } from "../types/ai"; + +function createProjectSummary>( + summary: T, +): T & AiProjectSummary { + return summary as T & AiProjectSummary; +} describe("aiClient", function () { beforeEach(function () { @@ -38,9 +45,11 @@ describe("aiClient", function () { const result = await requestAiAgentPlan({ apiKey: "sk-test", model: "gpt-5.5", + userMessage: "create pattern", + projectSummary: createProjectSummary({}), }); - expect(result.provider).toBe("openai"); + expect((result as typeof result & { provider: string }).provider).toBe("openai"); expect(requestOpenAiAgentPlan).toHaveBeenCalledTimes(1); expect(requestGeminiAgentPlan).not.toHaveBeenCalled(); }); @@ -50,9 +59,11 @@ describe("aiClient", function () { provider: "gemini", apiKey: "gemini-key", model: "gemini-3.5-flash", + userMessage: "create pattern", + projectSummary: createProjectSummary({}), }); - expect(result.provider).toBe("gemini"); + expect((result as typeof result & { provider: string }).provider).toBe("gemini"); expect(requestGeminiAgentPlan).toHaveBeenCalledTimes(1); expect(requestOpenAiAgentPlan).not.toHaveBeenCalled(); }); diff --git a/src/ai/aiClient.js b/src/ai/aiClient.ts similarity index 66% rename from src/ai/aiClient.js rename to src/ai/aiClient.ts index d0552aa..76c19bd 100644 --- a/src/ai/aiClient.js +++ b/src/ai/aiClient.ts @@ -8,8 +8,12 @@ import { AI_AGENT_PROVIDER_OPENAI, getAiProviderConfig, } from "./aiProviders"; +import type { AiAgentPlan, AiPlanRequest, AiProviderId } from "../types/ai" -export async function requestAiAgentPlan({ provider, ...params }) { +export async function requestAiAgentPlan({ + provider, + ...params +}: AiPlanRequest & { provider?: AiProviderId }): Promise { const config = getAiProviderConfig(provider); if (config.id === AI_AGENT_PROVIDER_GEMINI) { return requestGeminiAgentPlan(params); @@ -17,7 +21,10 @@ export async function requestAiAgentPlan({ provider, ...params }) { return requestOpenAiAgentPlan(params); } -export async function testAiConnection({ provider, ...params }) { +export async function testAiConnection({ + provider, + ...params +}: Pick & { provider?: AiProviderId }): Promise<{ model: string }> { const config = getAiProviderConfig(provider); if (config.id === AI_AGENT_PROVIDER_GEMINI) { return testGeminiConnection(params); diff --git a/src/ai/aiProjectSummary.test.js b/src/ai/aiProjectSummary.test.ts similarity index 90% rename from src/ai/aiProjectSummary.test.js rename to src/ai/aiProjectSummary.test.ts index 948e37e..8996d5c 100644 --- a/src/ai/aiProjectSummary.test.js +++ b/src/ai/aiProjectSummary.test.ts @@ -52,7 +52,7 @@ describe("buildAiProjectSummary", function () { }, }, ], - channels: [{ id: "ch-1", name: "Piano" }], + channels: [createChannel()], playlistTracks: [], playlistClips: [], }, @@ -97,7 +97,7 @@ describe("buildAiProjectSummary", function () { stepGrid: { "ch-1": [true] }, }, ], - channels: [{ id: "ch-1", name: "Piano" }], + channels: [createChannel()], playlistTracks: [], playlistClips: [], }, @@ -106,10 +106,10 @@ describe("buildAiProjectSummary", function () { const activePattern = summary.patterns.find(function (p) { return p.id === "pat-1"; - }); + })!; const inactivePattern = summary.patterns.find(function (p) { return p.id === "pat-2"; - }); + })!; expect(activePattern.notes).toEqual({}); expect(activePattern.steps).toEqual({}); @@ -120,3 +120,18 @@ describe("buildAiProjectSummary", function () { expect(inactivePattern.noteCount).toBe(1); }); }); + +function createChannel() { + return { + id: "ch-1", + name: "Piano", + sampleRef: "", + pluginRef: "openstudio-piano", + volume: 1, + pan: 0, + muted: false, + solo: false, + inputMode: "piano", + mixerInsertId: "insert-1", + }; +} diff --git a/src/ai/aiProjectSummary.js b/src/ai/aiProjectSummary.ts similarity index 86% rename from src/ai/aiProjectSummary.js rename to src/ai/aiProjectSummary.ts index 5442929..0bafa1b 100644 --- a/src/ai/aiProjectSummary.js +++ b/src/ai/aiProjectSummary.ts @@ -1,14 +1,16 @@ import { PLUGIN_EFFECTS } from "../data/pluginEffects"; import { PLUGIN_INSTRUMENTS } from "../data/pluginInstruments"; +import type { AiProjectSummary, AiSample } from "../types/ai" +import type { DawFxSlot, DawMixerInsert, DawPattern, DawState, PianoNote } from "../types/daw" // Returns { channelId: [{ start, length, pitch, velocity }] } without note ids. // Only used for the active pattern so the payload stays compact. -function summarizePianoPreview(pianoPreview) { +function summarizePianoPreview(pianoPreview: DawPattern["pianoPreview"]): Record { if (!pianoPreview || typeof pianoPreview !== "object") { return {}; } - const result = {}; + const result: Record = {}; Object.keys(pianoPreview).forEach(function (channelId) { const notes = Array.isArray(pianoPreview[channelId]) ? pianoPreview[channelId] @@ -29,12 +31,12 @@ function summarizePianoPreview(pianoPreview) { } // Returns { channelId: [bool, bool, ...] } for the active pattern only. -function summarizeStepGrid(stepGrid) { +function summarizeStepGrid(stepGrid: DawPattern["stepGrid"]): Record { if (!stepGrid || typeof stepGrid !== "object") { return {}; } - const result = {}; + const result: Record = {}; Object.keys(stepGrid).forEach(function (channelId) { const row = Array.isArray(stepGrid[channelId]) ? stepGrid[channelId] : []; if (row.length === 0) { @@ -47,7 +49,7 @@ function summarizeStepGrid(stepGrid) { // Lightweight count so the agent knows a non-active pattern is not empty // without receiving every note (keeps the token budget reasonable). -function countPianoNotes(pianoPreview) { +function countPianoNotes(pianoPreview: DawPattern["pianoPreview"]): number { if (!pianoPreview || typeof pianoPreview !== "object") { return 0; } @@ -58,7 +60,7 @@ function countPianoNotes(pianoPreview) { }, 0); } -function summarizeFxSlots(insert) { +function summarizeFxSlots(insert: DawMixerInsert): Array> { return (Array.isArray(insert?.fxSlots) ? insert.fxSlots : []) .filter(function (slot) { return slot?.effectType && slot.effectType !== "none"; @@ -73,7 +75,10 @@ function summarizeFxSlots(insert) { }); } -export function buildAiProjectSummary(dawState, availableSamples = []) { +export function buildAiProjectSummary( + dawState: DawState, + availableSamples: AiSample[] = [], +): AiProjectSummary { const project = dawState?.project || {}; const mixer = dawState?.mixer || {}; const transport = dawState?.transport || {}; diff --git a/src/ai/aiProviders.js b/src/ai/aiProviders.ts similarity index 77% rename from src/ai/aiProviders.js rename to src/ai/aiProviders.ts index 64e1693..71fc918 100644 --- a/src/ai/aiProviders.js +++ b/src/ai/aiProviders.ts @@ -1,8 +1,10 @@ -export const AI_AGENT_PROVIDER_OPENAI = "openai"; -export const AI_AGENT_PROVIDER_GEMINI = "gemini"; +import type { AiProviderConfig, AiProviderId } from "../types/ai" + +export const AI_AGENT_PROVIDER_OPENAI: AiProviderId = "openai" +export const AI_AGENT_PROVIDER_GEMINI: AiProviderId = "gemini" export const AI_AGENT_DEFAULT_PROVIDER = AI_AGENT_PROVIDER_OPENAI; -export const AI_AGENT_PROVIDERS = [ +export const AI_AGENT_PROVIDERS: readonly AiProviderConfig[] = [ { id: AI_AGENT_PROVIDER_OPENAI, label: "OpenAI", @@ -32,14 +34,14 @@ export const AI_AGENT_PROVIDERS = [ }, ]; -export function getAiProviderConfig(providerId) { +export function getAiProviderConfig(providerId: unknown): AiProviderConfig { const safeProviderId = String(providerId || "").trim(); return AI_AGENT_PROVIDERS.find(function (provider) { return provider.id === safeProviderId; }) || AI_AGENT_PROVIDERS[0]; } -export function getAiProviderModel(providerId, model) { +export function getAiProviderModel(providerId: unknown, model: unknown): string { const provider = getAiProviderConfig(providerId); const safeModel = String(model || "").trim(); const hasModel = provider.models.some(function (item) { diff --git a/src/ai/aiSampleIndex.test.js b/src/ai/aiSampleIndex.test.ts similarity index 100% rename from src/ai/aiSampleIndex.test.js rename to src/ai/aiSampleIndex.test.ts diff --git a/src/ai/aiSampleIndex.js b/src/ai/aiSampleIndex.ts similarity index 72% rename from src/ai/aiSampleIndex.js rename to src/ai/aiSampleIndex.ts index 74483e7..0302ccd 100644 --- a/src/ai/aiSampleIndex.js +++ b/src/ai/aiSampleIndex.ts @@ -1,4 +1,20 @@ -function makePacksPath(relativePath) { +import type { AiSample } from "../types/ai" + +interface PackManifestItem { + name?: unknown + path?: unknown +} + +interface PackManifestFolder { + folder?: unknown + items?: unknown +} + +interface PackManifest { + folders?: unknown +} + +function makePacksPath(relativePath: unknown): string { const cleanRelative = String(relativePath || "") .replace(/^\/+/, "") .trim(); @@ -22,7 +38,7 @@ function makePacksPath(relativePath) { return "/" + cleanRelative; } -function normalizePackItemPath(rawPath) { +function normalizePackItemPath(rawPath: unknown): string { const input = String(rawPath || "").trim(); if (!input) { return ""; @@ -40,7 +56,7 @@ function normalizePackItemPath(rawPath) { return makePacksPath("packs/" + noLeadingSlash); } -export async function loadAiSampleIndex(limit = 240) { +export async function loadAiSampleIndex(limit = 240): Promise { try { const response = await fetch( makePacksPath("packs/manifest.json") + "?ts=" + Date.now(), @@ -51,17 +67,21 @@ export async function loadAiSampleIndex(limit = 240) { return []; } - const manifest = await response.json(); - const folders = Array.isArray(manifest?.folders) ? manifest.folders : []; - const samples = []; + const manifest = await response.json() as PackManifest; + const folders: PackManifestFolder[] = Array.isArray(manifest?.folders) + ? manifest.folders + : []; + const samples: AiSample[] = []; folders.forEach(function (folder) { const folderName = String(folder?.folder || "Packs"); - const items = Array.isArray(folder?.items) ? folder.items : []; + const items: Array = Array.isArray(folder?.items) + ? folder.items + : []; items.forEach(function (item) { const name = typeof item === "string" ? item.split("/").pop() || item - : String(item?.name || item?.path || "Sample").split("/").pop(); + : String(item?.name || item?.path || "Sample").split("/").pop() || "Sample"; const path = typeof item === "string" ? normalizePackItemPath(item) : normalizePackItemPath(item?.path); @@ -84,7 +104,7 @@ export async function loadAiSampleIndex(limit = 240) { } } -function tokenizeSampleQuery(query) { +function tokenizeSampleQuery(query: unknown): string[] { return String(query || "") .toLowerCase() .split(/[^a-z0-9]+/i) @@ -96,7 +116,7 @@ function tokenizeSampleQuery(query) { }); } -function scoreSample(sample, tokens) { +function scoreSample(sample: AiSample, tokens: string[]): number { const haystack = [sample?.name, sample?.folder, sample?.path] .join(" ") .toLowerCase(); @@ -111,8 +131,12 @@ function scoreSample(sample, tokens) { }, 0); } -export function searchAiSamples(samples, query, limit = 80) { - const source = Array.isArray(samples) ? samples : []; +export function searchAiSamples( + samples: AiSample[] | unknown, + query: unknown, + limit = 80, +): AiSample[] { + const source: AiSample[] = Array.isArray(samples) ? samples : []; const tokens = tokenizeSampleQuery(query); if (tokens.length === 0) { diff --git a/src/ai/geminiClient.test.js b/src/ai/geminiClient.test.ts similarity index 52% rename from src/ai/geminiClient.test.js rename to src/ai/geminiClient.test.ts index b32df12..01e7936 100644 --- a/src/ai/geminiClient.test.js +++ b/src/ai/geminiClient.test.ts @@ -1,5 +1,30 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient"; +import type { AiProjectSummary } from "../types/ai"; + +interface GeminiRequestBody { + systemInstruction: { parts: Array<{ text: string }> } + generationConfig: { responseMimeType: string } + contents: Array<{ role: string; parts: Array<{ text: string }> }> +} + +function createJsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200 }); +} + +function createProjectSummary>( + summary: T, +): T & AiProjectSummary { + return summary as T & AiProjectSummary; +} + +function getSentBody(call: Parameters | undefined): GeminiRequestBody { + const body = call?.[1]?.body; + if (typeof body !== "string") { + throw new Error("Expected a JSON request body."); + } + return JSON.parse(body) as GeminiRequestBody; +} describe("geminiClient", function () { afterEach(function () { @@ -7,70 +32,62 @@ describe("geminiClient", function () { }); it("parses JSON generateContent responses", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - candidates: [ - { - content: { - parts: [ - { - text: JSON.stringify({ - message: "Plan ready", - operations: [{ type: "create_pattern", payload: {} }], - }), - }, - ], - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + message: "Plan ready", + operations: [{ type: "create_pattern", payload: {} }], + }), + }, + ], }, - ], - }; - }, - }); + }, + ], + }), + ); const result = await requestGeminiAgentPlan({ apiKey: "gemini-key", model: "gemini-3.5-flash", userMessage: "create pattern", - projectSummary: { patterns: [] }, + projectSummary: createProjectSummary({ patterns: [] }), }); expect(result.message).toBe("Plan ready"); expect(result.operations).toHaveLength(1); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toContain( + expect(fetchMock.mock.calls[0]?.[0]).toContain( "/gemini-3.5-flash:generateContent?key=gemini-key", ); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(sentBody.systemInstruction.parts[0].text).toContain("OpenStudio"); + const sentBody = getSentBody(fetchMock.mock.calls[0]); + expect(sentBody.systemInstruction.parts[0]?.text).toContain("OpenStudio"); expect(sentBody.generationConfig.responseMimeType).toBe("application/json"); }); it("maps conversation history to Gemini roles", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - candidates: [ - { - content: { - parts: [ - { text: JSON.stringify({ message: "ok", operations: [] }) }, - ], - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + candidates: [ + { + content: { + parts: [{ text: JSON.stringify({ message: "ok", operations: [] }) }], }, - ], - }; - }, - }); + }, + ], + }), + ); await requestGeminiAgentPlan({ apiKey: "gemini-key", model: "gemini-3.1-pro-preview", userMessage: "add a snare", - projectSummary: { tempo: 140 }, + projectSummary: createProjectSummary({ tempo: 140 }), conversationHistory: [ { role: "user", content: "create a beat" }, { role: "assistant", content: "I created kick and hats." }, @@ -78,7 +95,7 @@ describe("geminiClient", function () { ], }); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const sentBody = getSentBody(fetchMock.mock.calls[0]); expect(sentBody.contents).toHaveLength(3); expect(sentBody.contents[0]).toEqual({ role: "user", @@ -88,20 +105,17 @@ describe("geminiClient", function () { role: "model", parts: [{ text: "I created kick and hats." }], }); - expect(sentBody.contents[2].role).toBe("user"); - expect(JSON.parse(sentBody.contents[2].parts[0].text)).toEqual({ + expect(sentBody.contents[2]?.role).toBe("user"); + expect(JSON.parse(sentBody.contents[2]?.parts[0]?.text || "")).toEqual({ request: "add a snare", project: { tempo: 140 }, }); }); it("tests access with a lightweight JSON request", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { candidates: [] }; - }, - }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ candidates: [] }), + ); const result = await testGeminiConnection({ apiKey: "gemini-key", @@ -109,8 +123,8 @@ describe("geminiClient", function () { }); expect(result.model).toBe("gemini-3.1-flash-lite"); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(sentBody.contents[0].parts[0].text).toBe( + const sentBody = getSentBody(fetchMock.mock.calls[0]); + expect(sentBody.contents[0]?.parts[0]?.text).toBe( "Return {\"ok\":true} as JSON.", ); }); @@ -121,7 +135,7 @@ describe("geminiClient", function () { apiKey: "", model: "gemini-3.5-flash", userMessage: "hi", - projectSummary: {}, + projectSummary: createProjectSummary({}), }), ).rejects.toThrow("Paste your Gemini API key"); }); diff --git a/src/ai/geminiClient.js b/src/ai/geminiClient.ts similarity index 72% rename from src/ai/geminiClient.js rename to src/ai/geminiClient.ts index 7026c27..745492b 100644 --- a/src/ai/geminiClient.js +++ b/src/ai/geminiClient.ts @@ -1,27 +1,50 @@ import { getAiAgentSystemPrompt } from "./aiAgentPrompt"; import { getAiProviderConfig, AI_AGENT_PROVIDER_GEMINI } from "./aiProviders"; +import type { AiAgentPlan, AiPlanRequest } from "../types/ai" const GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"; -function parseAiJsonContent(content) { +interface GeminiErrorResponse { + error?: { message?: string } +} + +interface GeminiGenerateResponse extends GeminiErrorResponse { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> +} + +interface ParsedAiPlan { + message?: unknown + operations?: unknown +} + +interface GeminiContent { + role: "user" | "model" + parts: Array<{ text: string }> +} + +function parseAiJsonContent(content: unknown): ParsedAiPlan { const raw = String(content || "").trim(); if (!raw) { throw new Error("AI returned an empty response."); } try { - return JSON.parse(raw); + return JSON.parse(raw) as ParsedAiPlan; } catch { const jsonMatch = raw.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error("AI response was not valid JSON."); } - return JSON.parse(jsonMatch[0]); + return JSON.parse(jsonMatch[0]) as ParsedAiPlan; } } -function buildGeminiContents({ userMessage, projectSummary, conversationHistory }) { - const historyContents = (Array.isArray(conversationHistory) +function buildGeminiContents({ + userMessage, + projectSummary, + conversationHistory, +}: Pick): GeminiContent[] { + const historyContents: GeminiContent[] = (Array.isArray(conversationHistory) ? conversationHistory : [] ) @@ -48,7 +71,7 @@ function buildGeminiContents({ userMessage, projectSummary, conversationHistory }); } -function makeGeminiGenerateUrl(model, apiKey) { +function makeGeminiGenerateUrl(model: string, apiKey: string): string { return ( GEMINI_API_BASE_URL + "/" + @@ -64,7 +87,7 @@ export async function requestGeminiAgentPlan({ userMessage, projectSummary, conversationHistory = [], -}) { +}: AiPlanRequest): Promise { const provider = getAiProviderConfig(AI_AGENT_PROVIDER_GEMINI); const safeApiKey = String(apiKey || "").trim(); const safeMessage = String(userMessage || "").trim(); @@ -98,9 +121,9 @@ export async function requestGeminiAgentPlan({ }), }); - const result = await response.json().catch(function () { - return null; - }); + const result = await response.json().catch(function (): GeminiGenerateResponse | null { + return null + }) as GeminiGenerateResponse | null if (!response.ok) { throw new Error( @@ -118,7 +141,10 @@ export async function requestGeminiAgentPlan({ }; } -export async function testGeminiConnection({ apiKey, model }) { +export async function testGeminiConnection({ + apiKey, + model, +}: Pick): Promise<{ model: string }> { const provider = getAiProviderConfig(AI_AGENT_PROVIDER_GEMINI); const safeApiKey = String(apiKey || "").trim(); const safeModel = String(model || provider.defaultModel).trim() || provider.defaultModel; @@ -145,9 +171,9 @@ export async function testGeminiConnection({ apiKey, model }) { }), }); - const result = await response.json().catch(function () { - return null; - }); + const result = await response.json().catch(function (): GeminiErrorResponse | null { + return null + }) as GeminiErrorResponse | null if (!response.ok) { throw new Error( diff --git a/src/ai/openAiClient.test.js b/src/ai/openAiClient.test.ts similarity index 52% rename from src/ai/openAiClient.test.js rename to src/ai/openAiClient.test.ts index fe4fbce..7693426 100644 --- a/src/ai/openAiClient.test.js +++ b/src/ai/openAiClient.test.ts @@ -1,5 +1,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { requestAiAgentPlan, testOpenAiConnection } from "./openAiClient"; +import type { AiProjectSummary } from "../types/ai"; + +interface OpenAiRequestBody { + temperature?: number + messages: Array<{ role: string; content: string }> +} + +function createJsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200 }); +} + +function createProjectSummary>( + summary: T, +): T & AiProjectSummary { + return summary as T & AiProjectSummary; +} + +function getSentBody(call: Parameters | undefined): OpenAiRequestBody { + const body = call?.[1]?.body; + if (typeof body !== "string") { + throw new Error("Expected a JSON request body."); + } + return JSON.parse(body) as OpenAiRequestBody; +} describe("openAiClient", function () { afterEach(function () { @@ -7,29 +31,26 @@ describe("openAiClient", function () { }); it("parses JSON chat completion responses", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - choices: [ - { - message: { - content: JSON.stringify({ - message: "Plan ready", - operations: [{ type: "create_pattern", payload: {} }], - }), - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + choices: [ + { + message: { + content: JSON.stringify({ + message: "Plan ready", + operations: [{ type: "create_pattern", payload: {} }], + }), }, - ], - }; - }, - }); + }, + ], + }), + ); const result = await requestAiAgentPlan({ apiKey: "sk-test", model: "gpt-5.5", userMessage: "create pattern", - projectSummary: { patterns: [] }, + projectSummary: createProjectSummary({ patterns: [] }), }); expect(result.message).toBe("Plan ready"); @@ -37,44 +58,38 @@ describe("openAiClient", function () { expect(fetchMock).toHaveBeenCalledTimes(1); // GPT-5.5 must not send temperature (only default 1 is supported) - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const sentBody = getSentBody(fetchMock.mock.calls[0]); expect(sentBody).not.toHaveProperty("temperature"); }); it("sends temperature for models that support it", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - choices: [ - { - message: { - content: JSON.stringify({ message: "ok", operations: [] }), - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), }, - ], - }; - }, - }); + }, + ], + }), + ); await requestAiAgentPlan({ apiKey: "sk-test", model: "gpt-5.4", userMessage: "hi", - projectSummary: {}, + projectSummary: createProjectSummary({}), }); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const sentBody = getSentBody(fetchMock.mock.calls[0]); expect(sentBody.temperature).toBe(0.35); }); it("tests model access through the models endpoint", async function () { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { id: "gpt-5.5" }; - }, - }); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ id: "gpt-5.5" }), + ); const result = await testOpenAiConnection({ apiKey: "sk-test", @@ -85,100 +100,91 @@ describe("openAiClient", function () { }); it("includes conversation history in the messages array", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - choices: [ - { - message: { - content: JSON.stringify({ message: "ok", operations: [] }), - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), }, - ], - }; - }, - }); + }, + ], + }), + ); await requestAiAgentPlan({ apiKey: "sk-test", model: "gpt-5.4", userMessage: "add a snare", - projectSummary: {}, + projectSummary: createProjectSummary({}), conversationHistory: [ { role: "user", content: "create a trap beat" }, { role: "assistant", content: "I created a pattern with kick and hats." }, ], }); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const sentBody = getSentBody(fetchMock.mock.calls[0]); expect(sentBody.messages).toHaveLength(4); - expect(sentBody.messages[0].role).toBe("system"); + expect(sentBody.messages[0]?.role).toBe("system"); expect(sentBody.messages[1]).toEqual({ role: "user", content: "create a trap beat", }); - expect(sentBody.messages[2].role).toBe("assistant"); - expect(sentBody.messages[3].role).toBe("user"); + expect(sentBody.messages[2]?.role).toBe("assistant"); + expect(sentBody.messages[3]?.role).toBe("user"); }); it("works without conversation history (empty array default)", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - choices: [ - { - message: { - content: JSON.stringify({ message: "ok", operations: [] }), - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), }, - ], - }; - }, - }); + }, + ], + }), + ); await requestAiAgentPlan({ apiKey: "sk-test", model: "gpt-5.4", userMessage: "hi", - projectSummary: {}, + projectSummary: createProjectSummary({}), }); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const sentBody = getSentBody(fetchMock.mock.calls[0]); expect(sentBody.messages).toHaveLength(2); - expect(sentBody.messages[0].role).toBe("system"); - expect(sentBody.messages[1].role).toBe("user"); + expect(sentBody.messages[0]?.role).toBe("system"); + expect(sentBody.messages[1]?.role).toBe("user"); }); it("filters out system-role messages from conversation history", async function () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async function () { - return { - choices: [ - { - message: { - content: JSON.stringify({ message: "ok", operations: [] }), - }, + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + createJsonResponse({ + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), }, - ], - }; - }, - }); + }, + ], + }), + ); await requestAiAgentPlan({ apiKey: "sk-test", model: "gpt-5.4", userMessage: "hi", - projectSummary: {}, + projectSummary: createProjectSummary({}), conversationHistory: [ { role: "user", content: "create a beat" }, { role: "system", content: "Applied 2 operations." }, ], }); - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const sentBody = getSentBody(fetchMock.mock.calls[0]); // system prompt + 1 history (user only) + current user message = 3 expect(sentBody.messages).toHaveLength(3); expect(sentBody.messages[1]).toEqual({ diff --git a/src/ai/openAiClient.js b/src/ai/openAiClient.ts similarity index 74% rename from src/ai/openAiClient.js rename to src/ai/openAiClient.ts index 8747535..4a158e1 100644 --- a/src/ai/openAiClient.js +++ b/src/ai/openAiClient.ts @@ -1,22 +1,40 @@ import { AI_AGENT_DEFAULT_MODEL, getAiAgentSystemPrompt } from "./aiAgentPrompt"; +import type { AiAgentPlan, AiChatMessage, AiPlanRequest } from "../types/ai" const OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions"; const OPENAI_MODELS_URL = "https://api.openai.com/v1/models"; -function parseAiJsonContent(content) { +interface OpenAiErrorResponse { + error?: { message?: string } +} + +interface OpenAiCompletionResponse extends OpenAiErrorResponse { + choices?: Array<{ message?: { content?: string } }> +} + +interface OpenAiModelResponse extends OpenAiErrorResponse { + id?: string +} + +interface ParsedAiPlan { + message?: unknown + operations?: unknown +} + +function parseAiJsonContent(content: unknown): ParsedAiPlan { const raw = String(content || "").trim(); if (!raw) { throw new Error("AI returned an empty response."); } try { - return JSON.parse(raw); + return JSON.parse(raw) as ParsedAiPlan; } catch { const jsonMatch = raw.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error("AI response was not valid JSON."); } - return JSON.parse(jsonMatch[0]); + return JSON.parse(jsonMatch[0]) as ParsedAiPlan; } } @@ -26,7 +44,7 @@ export async function requestAiAgentPlan({ userMessage, projectSummary, conversationHistory = [], -}) { +}: AiPlanRequest): Promise { const safeApiKey = String(apiKey || "").trim(); const safeMessage = String(userMessage || "").trim(); const safeModel = String(model || AI_AGENT_DEFAULT_MODEL).trim() || AI_AGENT_DEFAULT_MODEL; @@ -47,7 +65,7 @@ export async function requestAiAgentPlan({ // then the current user request with the live project summary. This gives // the model context from earlier in the chat (loaded from Supabase or // built up during the current session). - const historyMessages = (Array.isArray(conversationHistory) + const historyMessages: AiChatMessage[] = (Array.isArray(conversationHistory) ? conversationHistory : [] ) @@ -58,7 +76,12 @@ export async function requestAiAgentPlan({ return { role: msg.role, content: msg.content }; }); - const requestBody = { + const requestBody: { + model: string + response_format: { type: "json_object" } + messages: Array<{ role: "system" | "user" | "assistant"; content: string }> + temperature?: number + } = { model: safeModel, response_format: { type: "json_object" }, messages: [ @@ -90,9 +113,9 @@ export async function requestAiAgentPlan({ body: JSON.stringify(requestBody), }); - const result = await response.json().catch(function () { - return null; - }); + const result = await response.json().catch(function (): OpenAiCompletionResponse | null { + return null + }) as OpenAiCompletionResponse | null if (!response.ok) { const message = @@ -113,7 +136,7 @@ export async function requestAiAgentPlan({ export async function testOpenAiConnection({ apiKey, model = AI_AGENT_DEFAULT_MODEL, -}) { +}: Pick): Promise<{ model: string }> { const safeApiKey = String(apiKey || "").trim(); const safeModel = String(model || AI_AGENT_DEFAULT_MODEL).trim() || AI_AGENT_DEFAULT_MODEL; @@ -131,9 +154,9 @@ export async function testOpenAiConnection({ }, ); - const result = await response.json().catch(function () { - return null; - }); + const result = await response.json().catch(function (): OpenAiModelResponse | null { + return null + }) as OpenAiModelResponse | null if (!response.ok) { throw new Error( diff --git a/src/components/AiAgentWindow.jsx b/src/components/AiAgentWindow.tsx similarity index 90% rename from src/components/AiAgentWindow.jsx rename to src/components/AiAgentWindow.tsx index 7167822..0694176 100644 --- a/src/components/AiAgentWindow.jsx +++ b/src/components/AiAgentWindow.tsx @@ -1,11 +1,19 @@ import { useState } from "react"; +import type { ChangeEvent, KeyboardEvent } from "react" import { Bot } from "lucide-react"; import { AiAgentConversationList } from "./ai-agent/AiAgentConversationList"; import { AiAgentMessages } from "./ai-agent/AiAgentMessages"; import { AiAgentPlanPanel } from "./ai-agent/AiAgentPlanPanel"; import { useAiAgentController } from "./ai-agent/useAiAgentController"; +import type { AiProviderModel } from "../types/ai" -function AiAgentSelect({ value, options, onChange }) { +interface AiAgentSelectProps { + value: string + options: readonly AiProviderModel[] + onChange: (value: string) => void +} + +function AiAgentSelect({ value, options, onChange }: AiAgentSelectProps) { const [isOpen, setIsOpen] = useState(false); const activeOption = options.find(function (option) { return option.value === value; @@ -80,7 +88,7 @@ export function AiAgentWindow() { value={agent.apiKey} placeholder={agent.apiKeyPlaceholder} autoComplete="off" - onChange={function (event) { + onChange={function (event: ChangeEvent) { agent.setApiKey(event.target.value); }} /> @@ -99,7 +107,7 @@ export function AiAgentWindow() { ) { agent.setRememberKey(event.target.checked); }} /> @@ -142,10 +150,10 @@ export function AiAgentWindow() { value={agent.input} placeholder="Example: create a dark trap pattern with kick, snare and hats" disabled={agent.isSending} - onChange={function (event) { + onChange={function (event: ChangeEvent) { agent.setInput(event.target.value); }} - onKeyDown={function (event) { + onKeyDown={function (event: KeyboardEvent) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); agent.sendMessage(event); diff --git a/src/components/ai-agent/AiAgentConversationList.jsx b/src/components/ai-agent/AiAgentConversationList.tsx similarity index 87% rename from src/components/ai-agent/AiAgentConversationList.jsx rename to src/components/ai-agent/AiAgentConversationList.tsx index b198ba4..683866c 100644 --- a/src/components/ai-agent/AiAgentConversationList.jsx +++ b/src/components/ai-agent/AiAgentConversationList.tsx @@ -1,4 +1,5 @@ import { MessageSquarePlus, Trash2 } from "lucide-react"; +import type { AiConversationSummary } from "../../types/ai" /** * @fileoverview AiAgentConversationList — Sidebar showing saved AI chat @@ -6,7 +7,7 @@ import { MessageSquarePlus, Trash2 } from "lucide-react"; * sign-in prompt instead of the list. */ -function formatRelativeTime(isoString) { +function formatRelativeTime(isoString: string | null | undefined): string { if (!isoString) { return ""; } @@ -37,6 +38,16 @@ function formatRelativeTime(isoString) { return new Date(then).toLocaleDateString(); } +interface AiAgentConversationListProps { + conversations: AiConversationSummary[] + activeConversationId: string | null + onSelect: (conversationId: string) => void + onNew: () => void + onDelete: (conversationId: string) => void + isLoading: boolean + isAuthenticated: boolean +} + export function AiAgentConversationList({ conversations, activeConversationId, @@ -45,7 +56,7 @@ export function AiAgentConversationList({ onDelete, isLoading, isAuthenticated, -}) { +}: AiAgentConversationListProps) { if (!isAuthenticated) { return (