feat: LLM based post-processing (#222)
* Add LLM post-processing feature with settings management and UI integration * Refactor LLM post-processing settings and prompt selection logic * Rename CLAUDE.md to more standard AGENTS.md * Replace Open Router with OpenAI Compatible endpoints for post-processing * Fix post processing settings api styles * refactor or something * simplify * cleanup a bit? * remove useless code * minor improvements * fix laggy textarea * Store post-processing results in db * fix text color * new default prompt --------- Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
parent
b8af208ae4
commit
f7380fa69a
39 changed files with 2238 additions and 299 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# CLAUDE.md
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
|
|
@ -103,4 +103,4 @@ Settings are stored using Tauri's store plugin with reactive updates:
|
|||
|
||||
### Single Instance Architecture
|
||||
|
||||
The app enforces single instance behavior - launching when already running brings the settings window to front rather than creating a new process.
|
||||
The app enforces single instance behavior - launching when already running brings the settings window to front rather than creating a new process.
|
||||
|
|
@ -74,7 +74,7 @@ Handy follows a clean architecture pattern:
|
|||
- `hooks/` - Reusable React hooks
|
||||
- `lib/types.ts` - Shared TypeScript types
|
||||
|
||||
For more details, see the Architecture section in [README.md](README.md) or [CLAUDE.md](CLAUDE.md).
|
||||
For more details, see the Architecture section in [README.md](README.md) or [AGENTS.md](AGENTS.md).
|
||||
|
||||
## 🐛 Reporting Bugs
|
||||
|
||||
|
|
|
|||
104
bun.lock
104
bun.lock
|
|
@ -19,6 +19,7 @@
|
|||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-select": "^5.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tauri-plugin-macos-permissions-api": "2.3.0",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-select": "^5.0.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.4.1",
|
||||
|
|
@ -69,12 +71,36 @@
|
|||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||
|
||||
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
|
||||
|
||||
"@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
|
||||
|
||||
"@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
|
||||
|
||||
"@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
|
||||
|
||||
"@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="],
|
||||
|
||||
"@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="],
|
||||
|
||||
"@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="],
|
||||
|
||||
"@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
|
||||
|
||||
"@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="],
|
||||
|
||||
"@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="],
|
||||
|
||||
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
|
||||
|
|
@ -127,6 +153,12 @@
|
|||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
|
@ -271,50 +303,84 @@
|
|||
|
||||
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
|
||||
|
||||
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@types/react-select": ["@types/react-select@5.0.1", "", { "dependencies": { "react-select": "*" } }, "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA=="],
|
||||
|
||||
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.240", "", {}, "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
|
@ -341,6 +407,8 @@
|
|||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
|
@ -349,24 +417,48 @@
|
|||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-select": ["react-select@5.10.2", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
|
@ -375,8 +467,14 @@
|
|||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
|
@ -391,14 +489,20 @@
|
|||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
||||
|
||||
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
|
||||
|
||||
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="],
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"@tauri-apps/plugin-sql": "~2.3.1",
|
||||
"@tauri-apps/plugin-store": "~2.4.1",
|
||||
"@tauri-apps/plugin-updater": "~2.9.0",
|
||||
"react-select": "^5.8.0",
|
||||
"tauri-plugin-macos-permissions-api": "2.3.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-select": "^5.0.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.4.1"
|
||||
|
|
|
|||
273
src-tauri/Cargo.lock
generated
273
src-tauri/Cargo.lock
generated
|
|
@ -254,7 +254,33 @@ dependencies = [
|
|||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand 0.9.2",
|
||||
"reqwest 0.12.24",
|
||||
"reqwest",
|
||||
"reqwest-eventsource",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-openai"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bf39a15c8d613eb61892dc9a287c02277639ebead41ee611ad23aaa613f1a82"
|
||||
dependencies = [
|
||||
"async-openai-macros",
|
||||
"backoff",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"derive_builder",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand 0.9.2",
|
||||
"reqwest",
|
||||
"reqwest-eventsource",
|
||||
"secrecy",
|
||||
"serde",
|
||||
|
|
@ -2244,16 +2270,16 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
|
||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"http",
|
||||
"indexmap 2.12.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
|
|
@ -2277,6 +2303,7 @@ name = "handy"
|
|||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-openai 0.30.1",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"cpvc",
|
||||
|
|
@ -2289,7 +2316,7 @@ dependencies = [
|
|||
"natural",
|
||||
"once_cell",
|
||||
"rdev",
|
||||
"reqwest 0.11.27",
|
||||
"reqwest",
|
||||
"rodio",
|
||||
"rubato",
|
||||
"rusqlite",
|
||||
|
|
@ -2436,17 +2463,6 @@ dependencies = [
|
|||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.3.1"
|
||||
|
|
@ -2458,17 +2474,6 @@ dependencies = [
|
|||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
|
|
@ -2476,7 +2481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2487,8 +2492,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
|||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
|
|
@ -2504,42 +2509,12 @@ version = "1.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.7.0"
|
||||
|
|
@ -2550,8 +2525,9 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
|
|
@ -2567,8 +2543,8 @@ version = "0.27.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http 1.3.1",
|
||||
"hyper 1.7.0",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
|
|
@ -2581,15 +2557,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper 0.14.32",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2603,17 +2582,19 @@ dependencies = [
|
|||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.7.0",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4596,7 +4577,7 @@ dependencies = [
|
|||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2 0.6.1",
|
||||
"socket2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -4633,7 +4614,7 @@ dependencies = [
|
|||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
|
@ -4888,48 +4869,6 @@ version = "0.8.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.11.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper-tls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"winreg 0.50.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.24"
|
||||
|
|
@ -4938,17 +4877,22 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper 1.7.0",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
@ -4958,8 +4902,9 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 1.0.2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
|
|
@ -4985,7 +4930,7 @@ dependencies = [
|
|||
"mime",
|
||||
"nom 7.1.3",
|
||||
"pin-project-lite",
|
||||
"reqwest 0.12.24",
|
||||
"reqwest",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
|
|
@ -5163,15 +5108,6 @@ dependencies = [
|
|||
"security-framework 3.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
|
|
@ -5619,16 +5555,6 @@ version = "2.0.0-alpha.10"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
|
|
@ -6174,12 +6100,6 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
|
|
@ -6211,20 +6131,20 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
|
|
@ -6327,7 +6247,7 @@ dependencies = [
|
|||
"glob",
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni",
|
||||
|
|
@ -6343,7 +6263,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.12.24",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
|
|
@ -6635,13 +6555,13 @@ dependencies = [
|
|||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.24",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -6666,7 +6586,7 @@ dependencies = [
|
|||
"cookie",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"jni",
|
||||
"objc2 0.6.3",
|
||||
"objc2-ui-kit",
|
||||
|
|
@ -6689,7 +6609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"jni",
|
||||
"log",
|
||||
"objc2 0.6.3",
|
||||
|
|
@ -6722,7 +6642,7 @@ dependencies = [
|
|||
"dunce",
|
||||
"glob",
|
||||
"html5ever",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"infer",
|
||||
"json-patch",
|
||||
"kuchikiki",
|
||||
|
|
@ -6910,7 +6830,7 @@ dependencies = [
|
|||
"libc",
|
||||
"mio 1.1.0",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
|
@ -7075,7 +6995,7 @@ dependencies = [
|
|||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper 1.0.2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -7090,8 +7010,8 @@ dependencies = [
|
|||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
|
|
@ -7149,7 +7069,7 @@ version = "0.1.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "856646076d3d9739998ba693ebfff3afe95c0cdb71d4fead32e761b11496094f"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-openai 0.29.6",
|
||||
"async-trait",
|
||||
"derive_builder",
|
||||
"env_logger 0.10.2",
|
||||
|
|
@ -7334,7 +7254,7 @@ dependencies = [
|
|||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"socks",
|
||||
"ureq-proto",
|
||||
|
|
@ -7349,7 +7269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
|
@ -8000,6 +7920,17 @@ dependencies = [
|
|||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
|
@ -8387,16 +8318,6 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
|
@ -8454,7 +8375,7 @@ dependencies = [
|
|||
"gdkx11",
|
||||
"gtk",
|
||||
"html5ever",
|
||||
"http 1.3.1",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"kuchikiki",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ tokio = "1.43.0"
|
|||
vad-rs = { git = "https://github.com/cjpais/vad-rs", default-features = false }
|
||||
enigo = "0.6.1"
|
||||
rodio = { git = "https://github.com/cjpais/rodio.git" }
|
||||
reqwest = { version = "0.11.27", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
async-openai = "0.30.1"
|
||||
futures-util = "0.3"
|
||||
rustfft = "6.4.0"
|
||||
strsim = "0.11.0"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
use crate::audio_feedback::{SoundType, play_feedback_sound};
|
||||
use crate::audio_feedback::{play_feedback_sound, SoundType};
|
||||
use crate::managers::audio::AudioRecordingManager;
|
||||
use crate::managers::history::HistoryManager;
|
||||
use crate::managers::transcription::TranscriptionManager;
|
||||
use crate::overlay::{show_recording_overlay, show_transcribing_overlay};
|
||||
use crate::settings::get_settings;
|
||||
use crate::settings::{get_settings, AppSettings};
|
||||
use crate::tray::{change_tray_icon, TrayIconState};
|
||||
use crate::utils;
|
||||
use async_openai::types::{
|
||||
ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs,
|
||||
CreateChatCompletionRequestArgs,
|
||||
};
|
||||
use log::{debug, error};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -23,6 +27,139 @@ pub trait ShortcutAction: Send + Sync {
|
|||
// Transcribe Action
|
||||
struct TranscribeAction;
|
||||
|
||||
async fn maybe_post_process_transcription(
|
||||
settings: &AppSettings,
|
||||
transcription: &str,
|
||||
) -> Option<String> {
|
||||
if !settings.post_process_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider = match settings.active_post_process_provider().cloned() {
|
||||
Some(provider) => provider,
|
||||
None => {
|
||||
debug!("Post-processing enabled but no provider is selected");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let model = settings
|
||||
.post_process_models
|
||||
.get(&provider.id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if model.trim().is_empty() {
|
||||
debug!(
|
||||
"Post-processing skipped because provider '{}' has no model configured",
|
||||
provider.id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let selected_prompt_id = match &settings.post_process_selected_prompt_id {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
debug!("Post-processing skipped because no prompt is selected");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let prompt = match settings
|
||||
.post_process_prompts
|
||||
.iter()
|
||||
.find(|prompt| prompt.id == selected_prompt_id)
|
||||
{
|
||||
Some(prompt) => prompt.prompt.clone(),
|
||||
None => {
|
||||
debug!(
|
||||
"Post-processing skipped because prompt '{}' was not found",
|
||||
selected_prompt_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if prompt.trim().is_empty() {
|
||||
debug!("Post-processing skipped because the selected prompt is empty");
|
||||
return None;
|
||||
}
|
||||
|
||||
let api_key = settings
|
||||
.post_process_api_keys
|
||||
.get(&provider.id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
debug!(
|
||||
"Starting LLM post-processing with provider '{}' (model: {})",
|
||||
provider.id, model
|
||||
);
|
||||
|
||||
// Replace ${output} variable in the prompt with the actual text
|
||||
let processed_prompt = prompt.replace("${output}", transcription);
|
||||
debug!("Processed prompt length: {} chars", processed_prompt.len());
|
||||
|
||||
// Create OpenAI-compatible client
|
||||
let client = match crate::llm_client::create_client(&provider, api_key) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
error!("Failed to create LLM client: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Build the chat completion request
|
||||
let message = match ChatCompletionRequestUserMessageArgs::default()
|
||||
.content(processed_prompt)
|
||||
.build()
|
||||
{
|
||||
Ok(msg) => ChatCompletionRequestMessage::User(msg),
|
||||
Err(e) => {
|
||||
error!("Failed to build chat message: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let request = match CreateChatCompletionRequestArgs::default()
|
||||
.model(&model)
|
||||
.messages(vec![message])
|
||||
.build()
|
||||
{
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
error!("Failed to build chat completion request: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Send the request
|
||||
match client.chat().create(request).await {
|
||||
Ok(response) => {
|
||||
if let Some(choice) = response.choices.first() {
|
||||
if let Some(content) = &choice.message.content {
|
||||
debug!(
|
||||
"LLM post-processing succeeded for provider '{}'. Output length: {} chars",
|
||||
provider.id,
|
||||
content.len()
|
||||
);
|
||||
return Some(content.clone());
|
||||
}
|
||||
}
|
||||
error!("LLM API response has no content");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"LLM post-processing failed for provider '{}': {}. Falling back to original transcription.",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShortcutAction for TranscribeAction {
|
||||
fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {
|
||||
let start_time = Instant::now();
|
||||
|
|
@ -116,22 +253,51 @@ impl ShortcutAction for TranscribeAction {
|
|||
transcription
|
||||
);
|
||||
if !transcription.is_empty() {
|
||||
// Save to history
|
||||
let settings = get_settings(&ah);
|
||||
let mut final_text = transcription.clone();
|
||||
let mut post_processed_text: Option<String> = None;
|
||||
let mut post_process_prompt: Option<String> = None;
|
||||
|
||||
if let Some(processed_text) =
|
||||
maybe_post_process_transcription(&settings, &transcription).await
|
||||
{
|
||||
final_text = processed_text.clone();
|
||||
post_processed_text = Some(processed_text);
|
||||
|
||||
// Get the prompt that was used
|
||||
if let Some(prompt_id) = &settings.post_process_selected_prompt_id {
|
||||
if let Some(prompt) = settings
|
||||
.post_process_prompts
|
||||
.iter()
|
||||
.find(|p| &p.id == prompt_id)
|
||||
{
|
||||
post_process_prompt = Some(prompt.prompt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save to history with post-processed text and prompt
|
||||
let hm_clone = Arc::clone(&hm);
|
||||
let transcription_for_history = transcription.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = hm_clone
|
||||
.save_transcription(samples_clone, transcription_for_history)
|
||||
.save_transcription(
|
||||
samples_clone,
|
||||
transcription_for_history,
|
||||
post_processed_text,
|
||||
post_process_prompt,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to save transcription to history: {}", e);
|
||||
}
|
||||
});
|
||||
let transcription_clone = transcription.clone();
|
||||
|
||||
// Paste the final text (either processed or original)
|
||||
let ah_clone = ah.clone();
|
||||
let paste_time = Instant::now();
|
||||
ah.run_on_main_thread(move || {
|
||||
match utils::paste(transcription_clone, ah_clone.clone()) {
|
||||
match utils::paste(final_text, ah_clone.clone()) {
|
||||
Ok(()) => debug!(
|
||||
"Text pasted successfully in {:?}",
|
||||
paste_time.elapsed()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod audio_feedback;
|
|||
pub mod audio_toolkit;
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod llm_client;
|
||||
mod managers;
|
||||
mod overlay;
|
||||
mod settings;
|
||||
|
|
@ -234,6 +235,16 @@ pub fn run() {
|
|||
shortcut::change_word_correction_threshold_setting,
|
||||
shortcut::change_paste_method_setting,
|
||||
shortcut::change_clipboard_handling_setting,
|
||||
shortcut::change_post_process_enabled_setting,
|
||||
shortcut::change_post_process_base_url_setting,
|
||||
shortcut::change_post_process_api_key_setting,
|
||||
shortcut::change_post_process_model_setting,
|
||||
shortcut::set_post_process_provider,
|
||||
shortcut::fetch_post_process_models,
|
||||
shortcut::add_post_process_prompt,
|
||||
shortcut::update_post_process_prompt,
|
||||
shortcut::delete_post_process_prompt,
|
||||
shortcut::set_post_process_selected_prompt,
|
||||
shortcut::update_custom_words,
|
||||
shortcut::suspend_binding,
|
||||
shortcut::resume_binding,
|
||||
|
|
|
|||
30
src-tauri/src/llm_client.rs
Normal file
30
src-tauri/src/llm_client.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use async_openai::{config::OpenAIConfig, Client};
|
||||
use crate::settings::PostProcessProvider;
|
||||
|
||||
/// Create an OpenAI-compatible client configured for the given provider
|
||||
pub fn create_client(provider: &PostProcessProvider, api_key: String) -> Result<Client<OpenAIConfig>, String> {
|
||||
let base_url = provider.base_url.trim_end_matches('/');
|
||||
let config = OpenAIConfig::new()
|
||||
.with_api_base(base_url)
|
||||
.with_api_key(api_key);
|
||||
|
||||
// Create client with Anthropic-specific header if needed
|
||||
let client = if provider.id == "anthropic" {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
reqwest::header::HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
Client::with_config(config).with_http_client(http_client)
|
||||
} else {
|
||||
Client::with_config(config)
|
||||
};
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ pub struct HistoryEntry {
|
|||
pub saved: bool,
|
||||
pub title: String,
|
||||
pub transcription_text: String,
|
||||
pub post_processed_text: Option<String>,
|
||||
pub post_process_prompt: Option<String>,
|
||||
}
|
||||
|
||||
pub struct HistoryManager {
|
||||
|
|
@ -52,35 +54,39 @@ impl HistoryManager {
|
|||
}
|
||||
|
||||
pub fn get_migrations() -> Vec<Migration> {
|
||||
vec![Migration {
|
||||
version: 1,
|
||||
description: "create_transcription_history_table",
|
||||
sql: "CREATE TABLE IF NOT EXISTS transcription_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_name TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
saved BOOLEAN NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
transcription_text TEXT NOT NULL
|
||||
);",
|
||||
kind: MigrationKind::Up,
|
||||
}]
|
||||
vec![
|
||||
Migration {
|
||||
version: 1,
|
||||
description: "create_transcription_history_table",
|
||||
sql: "CREATE TABLE IF NOT EXISTS transcription_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_name TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
saved BOOLEAN NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
transcription_text TEXT NOT NULL
|
||||
);",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
description: "add_post_processed_text_column",
|
||||
sql: "ALTER TABLE transcription_history ADD COLUMN post_processed_text TEXT;",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
description: "add_post_process_prompt_column",
|
||||
sql: "ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn init_database(&self) -> Result<()> {
|
||||
let conn = Connection::open(&self.db_path)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS transcription_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_name TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
saved BOOLEAN NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
transcription_text TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
debug!("Database initialized at: {:?}", self.db_path);
|
||||
// Database initialization and migrations are handled by tauri-plugin-sql
|
||||
// via the preload configuration in tauri.conf.json
|
||||
debug!("Database path: {:?}", self.db_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +99,8 @@ impl HistoryManager {
|
|||
&self,
|
||||
audio_samples: Vec<f32>,
|
||||
transcription_text: String,
|
||||
post_processed_text: Option<String>,
|
||||
post_process_prompt: Option<String>,
|
||||
) -> Result<()> {
|
||||
// If history limit is 0, do not save at all.
|
||||
if crate::settings::get_history_limit(&self.app_handle) == 0 {
|
||||
|
|
@ -108,7 +116,14 @@ impl HistoryManager {
|
|||
save_wav_file(file_path, &audio_samples).await?;
|
||||
|
||||
// Save to database
|
||||
self.save_to_database(file_name, timestamp, title, transcription_text)?;
|
||||
self.save_to_database(
|
||||
file_name,
|
||||
timestamp,
|
||||
title,
|
||||
transcription_text,
|
||||
post_processed_text,
|
||||
post_process_prompt,
|
||||
)?;
|
||||
|
||||
// Clean up old entries
|
||||
self.cleanup_old_entries()?;
|
||||
|
|
@ -127,11 +142,13 @@ impl HistoryManager {
|
|||
timestamp: i64,
|
||||
title: String,
|
||||
transcription_text: String,
|
||||
post_processed_text: Option<String>,
|
||||
post_process_prompt: Option<String>,
|
||||
) -> Result<()> {
|
||||
let conn = self.get_connection()?;
|
||||
conn.execute(
|
||||
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![file_name, timestamp, false, title, transcription_text],
|
||||
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
|
||||
)?;
|
||||
|
||||
debug!("Saved transcription to database");
|
||||
|
|
@ -186,7 +203,7 @@ impl HistoryManager {
|
|||
pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {
|
||||
let conn = self.get_connection()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text FROM transcription_history ORDER BY timestamp DESC"
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt FROM transcription_history ORDER BY timestamp DESC"
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
|
@ -197,6 +214,8 @@ impl HistoryManager {
|
|||
saved: row.get("saved")?,
|
||||
title: row.get("title")?,
|
||||
transcription_text: row.get("transcription_text")?,
|
||||
post_processed_text: row.get("post_processed_text")?,
|
||||
post_process_prompt: row.get("post_process_prompt")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
|
@ -242,7 +261,7 @@ impl HistoryManager {
|
|||
pub async fn get_entry_by_id(&self, id: i64) -> Result<Option<HistoryEntry>> {
|
||||
let conn = self.get_connection()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||
FROM transcription_history WHERE id = ?1",
|
||||
)?;
|
||||
|
||||
|
|
@ -255,6 +274,8 @@ impl HistoryManager {
|
|||
saved: row.get("saved")?,
|
||||
title: row.get("title")?,
|
||||
transcription_text: row.get("transcription_text")?,
|
||||
post_processed_text: row.get("post_processed_text")?,
|
||||
post_process_prompt: row.get("post_process_prompt")?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::settings;
|
||||
use crate::settings::OverlayPosition;
|
||||
use log::debug;
|
||||
use enigo::{Enigo, Mouse};
|
||||
use log::debug;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewWindowBuilder};
|
||||
|
||||
const OVERLAY_WIDTH: f64 = 172.0;
|
||||
|
|
@ -24,7 +24,8 @@ fn get_monitor_with_cursor(app_handle: &AppHandle) -> Option<tauri::Monitor> {
|
|||
if let Ok(mouse_location) = enigo.location() {
|
||||
if let Ok(monitors) = app_handle.available_monitors() {
|
||||
for monitor in monitors {
|
||||
let is_within = is_mouse_within_monitor(mouse_location, monitor.position(), monitor.size());
|
||||
let is_within =
|
||||
is_mouse_within_monitor(mouse_location, monitor.position(), monitor.size());
|
||||
if is_within {
|
||||
return Some(monitor);
|
||||
}
|
||||
|
|
@ -42,8 +43,14 @@ fn is_mouse_within_monitor(
|
|||
monitor_size: &PhysicalSize<u32>,
|
||||
) -> bool {
|
||||
let (mouse_x, mouse_y) = mouse_pos;
|
||||
let PhysicalPosition { x: monitor_x, y: monitor_y } = *monitor_pos;
|
||||
let PhysicalSize { width: monitor_width, height: monitor_height } = *monitor_size;
|
||||
let PhysicalPosition {
|
||||
x: monitor_x,
|
||||
y: monitor_y,
|
||||
} = *monitor_pos;
|
||||
let PhysicalSize {
|
||||
width: monitor_width,
|
||||
height: monitor_height,
|
||||
} = *monitor_size;
|
||||
|
||||
mouse_x >= monitor_x
|
||||
&& mouse_x < (monitor_x + monitor_width as i32)
|
||||
|
|
@ -53,26 +60,26 @@ fn is_mouse_within_monitor(
|
|||
|
||||
fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> {
|
||||
if let Some(monitor) = get_monitor_with_cursor(app_handle) {
|
||||
let work_area = monitor.work_area();
|
||||
let scale = monitor.scale_factor();
|
||||
let work_area_width = work_area.size.width as f64 / scale;
|
||||
let work_area_height = work_area.size.height as f64 / scale;
|
||||
let work_area_x = work_area.position.x as f64 / scale;
|
||||
let work_area_y = work_area.position.y as f64 / scale;
|
||||
let work_area = monitor.work_area();
|
||||
let scale = monitor.scale_factor();
|
||||
let work_area_width = work_area.size.width as f64 / scale;
|
||||
let work_area_height = work_area.size.height as f64 / scale;
|
||||
let work_area_x = work_area.position.x as f64 / scale;
|
||||
let work_area_y = work_area.position.y as f64 / scale;
|
||||
|
||||
let settings = settings::get_settings(app_handle);
|
||||
let settings = settings::get_settings(app_handle);
|
||||
|
||||
let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0;
|
||||
let y = match settings.overlay_position {
|
||||
OverlayPosition::Top => work_area_y + OVERLAY_TOP_OFFSET,
|
||||
OverlayPosition::Bottom | OverlayPosition::None => {
|
||||
// don't subtract the overlay height it puts it too far up
|
||||
work_area_y + work_area_height - OVERLAY_BOTTOM_OFFSET
|
||||
}
|
||||
};
|
||||
let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0;
|
||||
let y = match settings.overlay_position {
|
||||
OverlayPosition::Top => work_area_y + OVERLAY_TOP_OFFSET,
|
||||
OverlayPosition::Bottom | OverlayPosition::None => {
|
||||
// don't subtract the overlay height it puts it too far up
|
||||
work_area_y + work_area_height - OVERLAY_BOTTOM_OFFSET
|
||||
}
|
||||
};
|
||||
|
||||
return Some((x, y));
|
||||
}
|
||||
return Some((x, y));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,24 @@ pub struct ShortcutBinding {
|
|||
pub current_binding: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LLMPrompt {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PostProcessProvider {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub allow_base_url_edit: bool,
|
||||
#[serde(default)]
|
||||
pub models_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OverlayPosition {
|
||||
|
|
@ -163,6 +181,20 @@ pub struct AppSettings {
|
|||
pub paste_method: PasteMethod,
|
||||
#[serde(default)]
|
||||
pub clipboard_handling: ClipboardHandling,
|
||||
#[serde(default = "default_post_process_enabled")]
|
||||
pub post_process_enabled: bool,
|
||||
#[serde(default = "default_post_process_provider_id")]
|
||||
pub post_process_provider_id: String,
|
||||
#[serde(default = "default_post_process_providers")]
|
||||
pub post_process_providers: Vec<PostProcessProvider>,
|
||||
#[serde(default = "default_post_process_api_keys")]
|
||||
pub post_process_api_keys: HashMap<String, String>,
|
||||
#[serde(default = "default_post_process_models")]
|
||||
pub post_process_models: HashMap<String, String>,
|
||||
#[serde(default = "default_post_process_prompts")]
|
||||
pub post_process_prompts: Vec<LLMPrompt>,
|
||||
#[serde(default)]
|
||||
pub post_process_selected_prompt_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mute_while_recording: bool,
|
||||
}
|
||||
|
|
@ -218,6 +250,71 @@ fn default_sound_theme() -> SoundTheme {
|
|||
SoundTheme::Marimba
|
||||
}
|
||||
|
||||
fn default_post_process_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_post_process_provider_id() -> String {
|
||||
"openai".to_string()
|
||||
}
|
||||
|
||||
fn default_post_process_providers() -> Vec<PostProcessProvider> {
|
||||
vec![
|
||||
PostProcessProvider {
|
||||
id: "openai".to_string(),
|
||||
label: "OpenAI".to_string(),
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
allow_base_url_edit: false,
|
||||
models_endpoint: Some("/models".to_string()),
|
||||
},
|
||||
PostProcessProvider {
|
||||
id: "openrouter".to_string(),
|
||||
label: "OpenRouter".to_string(),
|
||||
base_url: "https://openrouter.ai/api/v1".to_string(),
|
||||
allow_base_url_edit: false,
|
||||
models_endpoint: Some("/models".to_string()),
|
||||
},
|
||||
PostProcessProvider {
|
||||
id: "anthropic".to_string(),
|
||||
label: "Anthropic".to_string(),
|
||||
base_url: "https://api.anthropic.com/v1".to_string(),
|
||||
allow_base_url_edit: false,
|
||||
models_endpoint: Some("/models".to_string()),
|
||||
},
|
||||
PostProcessProvider {
|
||||
id: "custom".to_string(),
|
||||
label: "Custom".to_string(),
|
||||
base_url: "http://localhost:11434/v1".to_string(),
|
||||
allow_base_url_edit: true,
|
||||
models_endpoint: Some("/models".to_string()),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn default_post_process_api_keys() -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
for provider in default_post_process_providers() {
|
||||
map.insert(provider.id, String::new());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn default_post_process_models() -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
for provider in default_post_process_providers() {
|
||||
map.insert(provider.id, String::new());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn default_post_process_prompts() -> Vec<LLMPrompt> {
|
||||
vec![LLMPrompt {
|
||||
id: "default_improve_transcriptions".to_string(),
|
||||
name: "Improve Transcriptions".to_string(),
|
||||
prompt: "Clean this transcript:\n1. Fix spelling, capitalization, and punctuation errors\n2. Convert number words to digits (twenty-five → 25, ten percent → 10%, five dollars → $5)\n3. Replace spoken punctuation with symbols (period → ., comma → ,, question mark → ?)\n4. Remove filler words (um, uh, like as filler)\n\nPreserve exact meaning and word order. Do not paraphrase or reorder content.\n\nReturn only the cleaned transcript.\n\nTranscript:\n${output}".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
||||
|
||||
pub fn get_default_settings() -> AppSettings {
|
||||
|
|
@ -264,10 +361,40 @@ pub fn get_default_settings() -> AppSettings {
|
|||
history_limit: default_history_limit(),
|
||||
paste_method: PasteMethod::default(),
|
||||
clipboard_handling: ClipboardHandling::default(),
|
||||
post_process_enabled: default_post_process_enabled(),
|
||||
post_process_provider_id: default_post_process_provider_id(),
|
||||
post_process_providers: default_post_process_providers(),
|
||||
post_process_api_keys: default_post_process_api_keys(),
|
||||
post_process_models: default_post_process_models(),
|
||||
post_process_prompts: default_post_process_prompts(),
|
||||
post_process_selected_prompt_id: None,
|
||||
mute_while_recording: false,
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
pub fn active_post_process_provider(&self) -> Option<&PostProcessProvider> {
|
||||
self.post_process_providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == self.post_process_provider_id)
|
||||
}
|
||||
|
||||
pub fn post_process_provider(&self, provider_id: &str) -> Option<&PostProcessProvider> {
|
||||
self.post_process_providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
}
|
||||
|
||||
pub fn post_process_provider_mut(
|
||||
&mut self,
|
||||
provider_id: &str,
|
||||
) -> Option<&mut PostProcessProvider> {
|
||||
self.post_process_providers
|
||||
.iter_mut()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings {
|
||||
// Initialize store
|
||||
let store = app
|
||||
|
|
@ -279,27 +406,19 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings {
|
|||
match serde_json::from_value::<AppSettings>(settings_value) {
|
||||
Ok(settings) => {
|
||||
println!("Found existing settings: {:?}", settings);
|
||||
|
||||
settings
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to parse settings: {}", e);
|
||||
// Fall back to default settings if parsing fails
|
||||
let default_settings = get_default_settings();
|
||||
|
||||
// Store the default settings
|
||||
store.set("settings", serde_json::to_value(&default_settings).unwrap());
|
||||
|
||||
default_settings
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create default settings
|
||||
let default_settings = get_default_settings();
|
||||
|
||||
// Store the settings
|
||||
store.set("settings", serde_json::to_value(&default_settings).unwrap());
|
||||
|
||||
default_settings
|
||||
};
|
||||
|
||||
|
|
@ -312,10 +431,15 @@ pub fn get_settings(app: &AppHandle) -> AppSettings {
|
|||
.expect("Failed to initialize store");
|
||||
|
||||
if let Some(settings_value) = store.get("settings") {
|
||||
serde_json::from_value::<AppSettings>(settings_value)
|
||||
.unwrap_or_else(|_| get_default_settings())
|
||||
serde_json::from_value::<AppSettings>(settings_value).unwrap_or_else(|_| {
|
||||
let default_settings = get_default_settings();
|
||||
store.set("settings", serde_json::to_value(&default_settings).unwrap());
|
||||
default_settings
|
||||
})
|
||||
} else {
|
||||
get_default_settings()
|
||||
let default_settings = get_default_settings();
|
||||
store.set("settings", serde_json::to_value(&default_settings).unwrap());
|
||||
default_settings
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
|||
|
||||
use crate::actions::ACTION_MAP;
|
||||
use crate::settings::ShortcutBinding;
|
||||
use crate::settings::{self, get_settings, ClipboardHandling, OverlayPosition, PasteMethod, SoundTheme};
|
||||
use crate::settings::{
|
||||
self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme,
|
||||
};
|
||||
use crate::ManagedToggleState;
|
||||
|
||||
pub fn init_shortcuts(app: &AppHandle) {
|
||||
|
|
@ -285,7 +287,10 @@ pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Re
|
|||
"dont_modify" => ClipboardHandling::DontModify,
|
||||
"copy_to_clipboard" => ClipboardHandling::CopyToClipboard,
|
||||
other => {
|
||||
eprintln!("Invalid clipboard handling '{}', defaulting to dont_modify", other);
|
||||
eprintln!(
|
||||
"Invalid clipboard handling '{}', defaulting to dont_modify",
|
||||
other
|
||||
);
|
||||
ClipboardHandling::DontModify
|
||||
}
|
||||
};
|
||||
|
|
@ -294,6 +299,318 @@ pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Re
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
settings.post_process_enabled = enabled;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_post_process_base_url_setting(
|
||||
app: AppHandle,
|
||||
provider_id: String,
|
||||
base_url: String,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
let label = settings
|
||||
.post_process_provider(&provider_id)
|
||||
.map(|provider| provider.label.clone())
|
||||
.ok_or_else(|| format!("Provider '{}' not found", provider_id))?;
|
||||
|
||||
let provider = settings
|
||||
.post_process_provider_mut(&provider_id)
|
||||
.expect("Provider looked up above must exist");
|
||||
|
||||
if !provider.allow_base_url_edit {
|
||||
return Err(format!(
|
||||
"Provider '{}' does not allow editing the base URL",
|
||||
label
|
||||
));
|
||||
}
|
||||
|
||||
provider.base_url = base_url;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generic helper to validate provider exists
|
||||
fn validate_provider_exists(
|
||||
settings: &settings::AppSettings,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if !settings
|
||||
.post_process_providers
|
||||
.iter()
|
||||
.any(|provider| provider.id == provider_id)
|
||||
{
|
||||
return Err(format!("Provider '{}' not found", provider_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_post_process_api_key_setting(
|
||||
app: AppHandle,
|
||||
provider_id: String,
|
||||
api_key: String,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
validate_provider_exists(&settings, &provider_id)?;
|
||||
settings.post_process_api_keys.insert(provider_id, api_key);
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_post_process_model_setting(
|
||||
app: AppHandle,
|
||||
provider_id: String,
|
||||
model: String,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
validate_provider_exists(&settings, &provider_id)?;
|
||||
settings.post_process_models.insert(provider_id, model);
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
validate_provider_exists(&settings, &provider_id)?;
|
||||
settings.post_process_provider_id = provider_id;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_post_process_prompt(
|
||||
app: AppHandle,
|
||||
name: String,
|
||||
prompt: String,
|
||||
) -> Result<LLMPrompt, String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
||||
// Generate unique ID using timestamp and random component
|
||||
let id = format!("prompt_{}", chrono::Utc::now().timestamp_millis());
|
||||
|
||||
let new_prompt = LLMPrompt {
|
||||
id: id.clone(),
|
||||
name,
|
||||
prompt,
|
||||
};
|
||||
|
||||
settings.post_process_prompts.push(new_prompt.clone());
|
||||
settings::write_settings(&app, settings);
|
||||
|
||||
Ok(new_prompt)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_post_process_prompt(
|
||||
app: AppHandle,
|
||||
id: String,
|
||||
name: String,
|
||||
prompt: String,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
||||
if let Some(existing_prompt) = settings
|
||||
.post_process_prompts
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id)
|
||||
{
|
||||
existing_prompt.name = name;
|
||||
existing_prompt.prompt = prompt;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Prompt with id '{}' not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
||||
// Don't allow deleting the last prompt
|
||||
if settings.post_process_prompts.len() <= 1 {
|
||||
return Err("Cannot delete the last prompt".to_string());
|
||||
}
|
||||
|
||||
// Find and remove the prompt
|
||||
let original_len = settings.post_process_prompts.len();
|
||||
settings.post_process_prompts.retain(|p| p.id != id);
|
||||
|
||||
if settings.post_process_prompts.len() == original_len {
|
||||
return Err(format!("Prompt with id '{}' not found", id));
|
||||
}
|
||||
|
||||
// If the deleted prompt was selected, select the first one or None
|
||||
if settings.post_process_selected_prompt_id.as_ref() == Some(&id) {
|
||||
settings.post_process_selected_prompt_id =
|
||||
settings.post_process_prompts.first().map(|p| p.id.clone());
|
||||
}
|
||||
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_post_process_models(
|
||||
app: AppHandle,
|
||||
provider_id: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let settings = settings::get_settings(&app);
|
||||
|
||||
// Find the provider
|
||||
let provider = settings
|
||||
.post_process_providers
|
||||
.iter()
|
||||
.find(|p| p.id == provider_id)
|
||||
.ok_or_else(|| format!("Provider '{}' not found", provider_id))?;
|
||||
|
||||
// Get API key
|
||||
let api_key = settings
|
||||
.post_process_api_keys
|
||||
.get(&provider_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Skip fetching if no API key for providers that typically need one
|
||||
if api_key.trim().is_empty() && provider.id != "custom" {
|
||||
return Err(format!(
|
||||
"API key is required for {}. Please add an API key to list available models.",
|
||||
provider.label
|
||||
));
|
||||
}
|
||||
|
||||
// TODO: In the future, we can use async-openai's models API:
|
||||
// let client = crate::llm_client::create_client(provider, api_key)?;
|
||||
// let response = client.models().list().await?;
|
||||
// return Ok(response.data.iter().map(|m| m.id.clone()).collect());
|
||||
|
||||
// For now, use manual HTTP request to have more control over the endpoint
|
||||
fetch_models_manual(provider, api_key).await
|
||||
}
|
||||
|
||||
/// Fetch models using manual HTTP request
|
||||
/// This gives us more control and avoids issues with non-standard endpoints
|
||||
async fn fetch_models_manual(
|
||||
provider: &crate::settings::PostProcessProvider,
|
||||
api_key: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
// Build the endpoint URL
|
||||
let base_url = provider.base_url.trim_end_matches('/');
|
||||
let models_endpoint = provider
|
||||
.models_endpoint
|
||||
.as_ref()
|
||||
.map(|s| s.trim_start_matches('/'))
|
||||
.unwrap_or("models");
|
||||
let endpoint = format!("{}/{}", base_url, models_endpoint);
|
||||
|
||||
// Create HTTP client with headers
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
"HTTP-Referer",
|
||||
reqwest::header::HeaderValue::from_static("https://github.com/cjpais/Handy"),
|
||||
);
|
||||
headers.insert(
|
||||
"X-Title",
|
||||
reqwest::header::HeaderValue::from_static("Handy"),
|
||||
);
|
||||
|
||||
// Add provider-specific headers
|
||||
if provider.id == "anthropic" {
|
||||
if !api_key.is_empty() {
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
reqwest::header::HeaderValue::from_str(&api_key)
|
||||
.map_err(|e| format!("Invalid API key: {}", e))?,
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
reqwest::header::HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
} else if !api_key.is_empty() {
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
|
||||
.map_err(|e| format!("Invalid API key: {}", e))?,
|
||||
);
|
||||
}
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
// Make the request
|
||||
let response = http_client
|
||||
.get(&endpoint)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch models: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!(
|
||||
"Model list request failed ({}): {}",
|
||||
status, error_text
|
||||
));
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
let parsed: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let mut models = Vec::new();
|
||||
|
||||
// Handle OpenAI format: { data: [ { id: "..." }, ... ] }
|
||||
if let Some(data) = parsed.get("data").and_then(|d| d.as_array()) {
|
||||
for entry in data {
|
||||
if let Some(id) = entry.get("id").and_then(|i| i.as_str()) {
|
||||
models.push(id.to_string());
|
||||
} else if let Some(name) = entry.get("name").and_then(|n| n.as_str()) {
|
||||
models.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle array format: [ "model1", "model2", ... ]
|
||||
else if let Some(array) = parsed.as_array() {
|
||||
for entry in array {
|
||||
if let Some(model) = entry.as_str() {
|
||||
models.push(model.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
||||
// Verify the prompt exists
|
||||
if !settings.post_process_prompts.iter().any(|p| p.id == id) {
|
||||
return Err(format!("Prompt with id '{}' not found", id));
|
||||
}
|
||||
|
||||
settings.post_process_selected_prompt_id = Some(id);
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@
|
|||
"endpoints": [
|
||||
"https://github.com/cjpais/Handy/releases/latest/download/latest.json"
|
||||
]
|
||||
},
|
||||
"sql": {
|
||||
"preload": ["sqlite:history.db"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Cog, FlaskConical, History, Info } from "lucide-react";
|
||||
import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react";
|
||||
import HandyTextLogo from "./icons/HandyTextLogo";
|
||||
import HandyHand from "./icons/HandyHand";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
HistorySettings,
|
||||
DebugSettings,
|
||||
AboutSettings,
|
||||
PostProcessingSettings,
|
||||
} from "./settings";
|
||||
|
||||
export type SidebarSection = keyof typeof SECTIONS_CONFIG;
|
||||
|
|
@ -41,6 +42,12 @@ export const SECTIONS_CONFIG = {
|
|||
component: AdvancedSettings,
|
||||
enabled: () => true,
|
||||
},
|
||||
postprocessing: {
|
||||
label: "Post Process",
|
||||
icon: Sparkles,
|
||||
component: PostProcessingSettings,
|
||||
enabled: (settings) => settings?.post_process_enabled ?? false,
|
||||
},
|
||||
history: {
|
||||
label: "History",
|
||||
icon: History,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import React, { useState } from "react";
|
||||
import { Input } from "../../ui/Input";
|
||||
|
||||
interface ApiKeyFieldProps {
|
||||
value: string;
|
||||
onBlur: (value: string) => void;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ApiKeyField: React.FC<ApiKeyFieldProps> = React.memo(
|
||||
({ value, onBlur, disabled, placeholder, className = "" }) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
// Sync with prop changes
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="password"
|
||||
value={localValue}
|
||||
onChange={(event) => setLocalValue(event.target.value)}
|
||||
onBlur={() => onBlur(localValue)}
|
||||
placeholder={placeholder}
|
||||
variant="compact"
|
||||
disabled={disabled}
|
||||
className={`flex-1 min-w-[320px] ${className}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ApiKeyField.displayName = "ApiKeyField";
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import React, { useState } from "react";
|
||||
import { Input } from "../../ui/Input";
|
||||
|
||||
interface BaseUrlFieldProps {
|
||||
value: string;
|
||||
onBlur: (value: string) => void;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BaseUrlField: React.FC<BaseUrlFieldProps> = React.memo(
|
||||
({ value, onBlur, disabled, placeholder, className = "" }) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
// Sync with prop changes
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const disabledMessage = disabled
|
||||
? "Base URL is managed by the selected provider."
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(event) => setLocalValue(event.target.value)}
|
||||
onBlur={() => onBlur(localValue)}
|
||||
placeholder={placeholder}
|
||||
variant="compact"
|
||||
disabled={disabled}
|
||||
className={`flex-1 min-w-[360px] ${className}`}
|
||||
title={disabledMessage}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BaseUrlField.displayName = "BaseUrlField";
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import type { ModelOption } from "./types";
|
||||
import { Select } from "../../ui/Select";
|
||||
|
||||
type ModelSelectProps = {
|
||||
value: string;
|
||||
options: ModelOption[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
isLoading?: boolean;
|
||||
onSelect: (value: string) => void;
|
||||
onCreate: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ModelSelect: React.FC<ModelSelectProps> = React.memo(
|
||||
({
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
placeholder,
|
||||
isLoading,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onBlur,
|
||||
className = "flex-1 min-w-[360px]",
|
||||
}) => {
|
||||
const handleCreate = (inputValue: string) => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) return;
|
||||
onCreate(trimmed);
|
||||
};
|
||||
|
||||
const computedClassName = `text-sm ${className}`;
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={computedClassName}
|
||||
value={value || null}
|
||||
options={options}
|
||||
onChange={(selected) => onSelect(selected ?? "")}
|
||||
onCreateOption={handleCreate}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
isLoading={isLoading}
|
||||
isCreatable
|
||||
formatCreateLabel={(input) => `Use "${input}"`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ModelSelect.displayName = "ModelSelect";
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from "react";
|
||||
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
|
||||
|
||||
interface ProviderSelectProps {
|
||||
options: DropdownOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ProviderSelect: React.FC<ProviderSelectProps> = React.memo(
|
||||
({ options, value, onChange, disabled }) => {
|
||||
return (
|
||||
<Dropdown
|
||||
options={options}
|
||||
selectedValue={value}
|
||||
onSelect={onChange}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProviderSelect.displayName = "ProviderSelect";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { PostProcessingSettingsApi } from "../post-processing/PostProcessingSettings";
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export type ModelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSettings } from "../../../hooks/useSettings";
|
||||
import { useSettingsStore } from "../../../stores/settingsStore";
|
||||
import type { PostProcessProvider } from "../../../lib/types";
|
||||
import type { ModelOption } from "./types";
|
||||
import type { DropdownOption } from "../../ui/Dropdown";
|
||||
|
||||
type PostProcessProviderState = {
|
||||
enabled: boolean;
|
||||
providerOptions: DropdownOption[];
|
||||
selectedProviderId: string;
|
||||
selectedProvider: PostProcessProvider | undefined;
|
||||
isCustomProvider: boolean;
|
||||
baseUrl: string;
|
||||
handleBaseUrlChange: (value: string) => void;
|
||||
isBaseUrlUpdating: boolean;
|
||||
apiKey: string;
|
||||
handleApiKeyChange: (value: string) => void;
|
||||
isApiKeyUpdating: boolean;
|
||||
model: string;
|
||||
handleModelChange: (value: string) => void;
|
||||
modelOptions: ModelOption[];
|
||||
isModelUpdating: boolean;
|
||||
isFetchingModels: boolean;
|
||||
handleProviderSelect: (providerId: string) => void;
|
||||
handleModelSelect: (value: string) => void;
|
||||
handleModelCreate: (value: string) => void;
|
||||
handleRefreshModels: () => void;
|
||||
};
|
||||
|
||||
export const usePostProcessProviderState = (): PostProcessProviderState => {
|
||||
const {
|
||||
settings,
|
||||
isUpdating,
|
||||
setPostProcessProvider,
|
||||
updatePostProcessBaseUrl,
|
||||
updatePostProcessApiKey,
|
||||
updatePostProcessModel,
|
||||
fetchPostProcessModels,
|
||||
postProcessModelOptions,
|
||||
} = useSettings();
|
||||
|
||||
const enabled = settings?.post_process_enabled || false;
|
||||
|
||||
// Settings are guaranteed to have providers after migration
|
||||
const providers = settings?.post_process_providers || [];
|
||||
|
||||
const selectedProviderId = useMemo(() => {
|
||||
return settings?.post_process_provider_id || providers[0]?.id || "openai";
|
||||
}, [providers, settings?.post_process_provider_id]);
|
||||
|
||||
const selectedProvider = useMemo(() => {
|
||||
return (
|
||||
providers.find((provider) => provider.id === selectedProviderId) ||
|
||||
providers[0]
|
||||
);
|
||||
}, [providers, selectedProviderId]);
|
||||
|
||||
// Use settings directly as single source of truth
|
||||
const baseUrl = selectedProvider?.base_url ?? "";
|
||||
const apiKey = settings?.post_process_api_keys?.[selectedProviderId] ?? "";
|
||||
const model = settings?.post_process_models?.[selectedProviderId] ?? "";
|
||||
|
||||
const providerOptions = useMemo<DropdownOption[]>(() => {
|
||||
return providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
}));
|
||||
}, [providers]);
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(providerId: string) => {
|
||||
if (providerId !== selectedProviderId) {
|
||||
void setPostProcessProvider(providerId);
|
||||
}
|
||||
},
|
||||
[selectedProviderId, setPostProcessProvider],
|
||||
);
|
||||
|
||||
const handleBaseUrlChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!selectedProvider || !selectedProvider.allow_base_url_edit) {
|
||||
return;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed !== baseUrl) {
|
||||
void updatePostProcessBaseUrl(selectedProvider.id, trimmed);
|
||||
}
|
||||
},
|
||||
[selectedProvider, baseUrl, updatePostProcessBaseUrl],
|
||||
);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed !== apiKey) {
|
||||
void updatePostProcessApiKey(selectedProviderId, trimmed);
|
||||
}
|
||||
},
|
||||
[apiKey, selectedProviderId, updatePostProcessApiKey],
|
||||
);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed !== model) {
|
||||
void updatePostProcessModel(selectedProviderId, trimmed);
|
||||
}
|
||||
},
|
||||
[model, selectedProviderId, updatePostProcessModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(value: string) => {
|
||||
void updatePostProcessModel(selectedProviderId, value.trim());
|
||||
},
|
||||
[selectedProviderId, updatePostProcessModel],
|
||||
);
|
||||
|
||||
const handleModelCreate = useCallback(
|
||||
(value: string) => {
|
||||
void updatePostProcessModel(selectedProviderId, value);
|
||||
},
|
||||
[selectedProviderId, updatePostProcessModel],
|
||||
);
|
||||
|
||||
const handleRefreshModels = useCallback(() => {
|
||||
void fetchPostProcessModels(selectedProviderId);
|
||||
}, [fetchPostProcessModels, selectedProviderId]);
|
||||
|
||||
const availableModelsRaw = postProcessModelOptions[selectedProviderId] || [];
|
||||
|
||||
const modelOptions = useMemo<ModelOption[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const options: ModelOption[] = [];
|
||||
|
||||
const upsert = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
options.push({ value: trimmed, label: trimmed });
|
||||
};
|
||||
|
||||
// Add available models from API
|
||||
for (const candidate of availableModelsRaw) {
|
||||
upsert(candidate);
|
||||
}
|
||||
|
||||
// Ensure current model is in the list
|
||||
upsert(model);
|
||||
|
||||
return options;
|
||||
}, [availableModelsRaw, model]);
|
||||
|
||||
const isBaseUrlUpdating = isUpdating(
|
||||
`post_process_base_url:${selectedProviderId}`,
|
||||
);
|
||||
const isApiKeyUpdating = isUpdating(
|
||||
`post_process_api_key:${selectedProviderId}`,
|
||||
);
|
||||
const isModelUpdating = isUpdating(
|
||||
`post_process_model:${selectedProviderId}`,
|
||||
);
|
||||
const isFetchingModels = isUpdating(
|
||||
`post_process_models_fetch:${selectedProviderId}`,
|
||||
);
|
||||
|
||||
const isCustomProvider = selectedProvider?.id === "custom";
|
||||
|
||||
// No automatic fetching - user must click refresh button
|
||||
|
||||
return {
|
||||
enabled,
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
selectedProvider,
|
||||
isCustomProvider,
|
||||
baseUrl,
|
||||
handleBaseUrlChange,
|
||||
isBaseUrlUpdating,
|
||||
apiKey,
|
||||
handleApiKeyChange,
|
||||
isApiKeyUpdating,
|
||||
model,
|
||||
handleModelChange,
|
||||
modelOptions,
|
||||
isModelUpdating,
|
||||
isFetchingModels,
|
||||
handleProviderSelect,
|
||||
handleModelSelect,
|
||||
handleModelCreate,
|
||||
handleRefreshModels,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { PostProcessingSettingsPrompts } from "./post-processing/PostProcessingSettings";
|
||||
28
src/components/settings/PostProcessingToggle.tsx
Normal file
28
src/components/settings/PostProcessingToggle.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
interface PostProcessingToggleProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const PostProcessingToggle: React.FC<PostProcessingToggleProps> = React.memo(
|
||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||
|
||||
const enabled = getSetting("post_process_enabled") || false;
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
checked={enabled}
|
||||
onChange={(enabled) => updateSetting("post_process_enabled", enabled)}
|
||||
isUpdating={isUpdating("post_process_enabled")}
|
||||
label="Post Process"
|
||||
description="Enable post-processing of transcribed text using language models via OpenAI Compatible API."
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import { Button } from "../ui/Button";
|
||||
import { AppDataDirectory } from "./AppDataDirectory";
|
||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||
import { SettingContainer } from "../../ui/SettingContainer";
|
||||
import { Button } from "../../ui/Button";
|
||||
import { AppDataDirectory } from "../AppDataDirectory";
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
const [version, setVersion] = useState("");
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import React from "react";
|
||||
import { ShowOverlay } from "./ShowOverlay";
|
||||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
|
||||
import { CustomWords } from "./CustomWords";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { StartHidden } from "./StartHidden";
|
||||
import { AutostartToggle } from "./AutostartToggle";
|
||||
import { PasteMethodSetting } from "./PasteMethod";
|
||||
import { ClipboardHandlingSetting } from "./ClipboardHandling";
|
||||
import { ShowOverlay } from "../ShowOverlay";
|
||||
import { TranslateToEnglish } from "../TranslateToEnglish";
|
||||
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
|
||||
import { CustomWords } from "../CustomWords";
|
||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||
import { StartHidden } from "../StartHidden";
|
||||
import { AutostartToggle } from "../AutostartToggle";
|
||||
import { PasteMethodSetting } from "../PasteMethod";
|
||||
import { ClipboardHandlingSetting } from "../ClipboardHandling";
|
||||
|
||||
export const AdvancedSettings: React.FC = () => {
|
||||
return (
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React from "react";
|
||||
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { HistoryLimit } from "./HistoryLimit";
|
||||
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
import { SoundPicker } from "./SoundPicker";
|
||||
import { MuteWhileRecording } from "./MuteWhileRecording";
|
||||
import { WordCorrectionThreshold } from "./WordCorrectionThreshold";
|
||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||
import { HistoryLimit } from "../HistoryLimit";
|
||||
import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone";
|
||||
import { SoundPicker } from "../SoundPicker";
|
||||
import { PostProcessingToggle } from "../PostProcessingToggle";
|
||||
import { MuteWhileRecording } from "../MuteWhileRecording";
|
||||
|
||||
export const DebugSettings: React.FC = () => {
|
||||
return (
|
||||
|
|
@ -17,6 +18,7 @@ export const DebugSettings: React.FC = () => {
|
|||
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
|
||||
<HistoryLimit descriptionMode="tooltip" grouped={true} />
|
||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||
<PostProcessingToggle descriptionMode="tooltip" grouped={true} />
|
||||
<MuteWhileRecording descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import React from "react";
|
||||
import { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { HandyShortcut } from "./HandyShortcut";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
import { PushToTalk } from "./PushToTalk";
|
||||
import { AudioFeedback } from "./AudioFeedback";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { VolumeSlider } from "./VolumeSlider";
|
||||
import { MicrophoneSelector } from "../MicrophoneSelector";
|
||||
import { LanguageSelector } from "../LanguageSelector";
|
||||
import { HandyShortcut } from "../HandyShortcut";
|
||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||
import { OutputDeviceSelector } from "../OutputDeviceSelector";
|
||||
import { PushToTalk } from "../PushToTalk";
|
||||
import { AudioFeedback } from "../AudioFeedback";
|
||||
import { useSettings } from "../../../hooks/useSettings";
|
||||
import { VolumeSlider } from "../VolumeSlider";
|
||||
|
||||
export const GeneralSettings: React.FC = () => {
|
||||
const { audioFeedbackEnabled } = useSettings();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { AudioPlayer } from "../ui/AudioPlayer";
|
||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||
import { AudioPlayer } from "../../ui/AudioPlayer";
|
||||
import { Copy, Star, Check, Trash2 } from "lucide-react";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
// Settings section components
|
||||
export { GeneralSettings } from "./GeneralSettings";
|
||||
export { AdvancedSettings } from "./AdvancedSettings";
|
||||
export { DebugSettings } from "./DebugSettings";
|
||||
export { HistorySettings } from "./HistorySettings";
|
||||
export { AboutSettings } from "./AboutSettings";
|
||||
export { GeneralSettings } from "./general/GeneralSettings";
|
||||
export { AdvancedSettings } from "./advanced/AdvancedSettings";
|
||||
export { DebugSettings } from "./debug/DebugSettings";
|
||||
export { HistorySettings } from "./history/HistorySettings";
|
||||
export { AboutSettings } from "./about/AboutSettings";
|
||||
export { PostProcessingSettings } from "./post-processing/PostProcessingSettings";
|
||||
|
||||
// Individual setting components
|
||||
export { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
|
|
@ -15,6 +16,9 @@ export { ShowOverlay } from "./ShowOverlay";
|
|||
export { HandyShortcut } from "./HandyShortcut";
|
||||
export { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
export { CustomWords } from "./CustomWords";
|
||||
export { PostProcessingToggle } from "./PostProcessingToggle";
|
||||
export { PostProcessingSettingsApi } from "./PostProcessingSettingsApi";
|
||||
export { PostProcessingSettingsPrompts } from "./PostProcessingSettingsPrompts";
|
||||
export { AppDataDirectory } from "./AppDataDirectory";
|
||||
export { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
|
||||
export { StartHidden } from "./StartHidden";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||
import { SettingContainer } from "../../ui/SettingContainer";
|
||||
import { Button } from "../../ui/Button";
|
||||
import { ResetButton } from "../../ui/ResetButton";
|
||||
import { Input } from "../../ui/Input";
|
||||
import { Dropdown } from "../../ui/Dropdown";
|
||||
import { Textarea } from "../../ui/Textarea";
|
||||
|
||||
import { ProviderSelect } from "../PostProcessingSettingsApi/ProviderSelect";
|
||||
import { BaseUrlField } from "../PostProcessingSettingsApi/BaseUrlField";
|
||||
import { ApiKeyField } from "../PostProcessingSettingsApi/ApiKeyField";
|
||||
import { ModelSelect } from "../PostProcessingSettingsApi/ModelSelect";
|
||||
import { usePostProcessProviderState } from "../PostProcessingSettingsApi/usePostProcessProviderState";
|
||||
import { useSettings } from "../../../hooks/useSettings";
|
||||
import type { LLMPrompt } from "../../../lib/types";
|
||||
|
||||
const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => (
|
||||
<div className="p-4 bg-mid-gray/5 rounded-lg border border-mid-gray/20 text-center">
|
||||
<p className="text-sm text-mid-gray">{children}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||
const state = usePostProcessProviderState();
|
||||
|
||||
if (!state.enabled) {
|
||||
return (
|
||||
<DisabledNotice>
|
||||
Post processing is currently disabled. Enable it in Debug settings to
|
||||
configure.
|
||||
</DisabledNotice>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingContainer
|
||||
title="Provider"
|
||||
description="Select an OpenAI-compatible provider."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderSelect
|
||||
options={state.providerOptions}
|
||||
value={state.selectedProviderId}
|
||||
onChange={state.handleProviderSelect}
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer
|
||||
title="Base URL"
|
||||
description="API base URL for the selected provider. Only the custom provider can be edited."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BaseUrlField
|
||||
value={state.baseUrl}
|
||||
onBlur={state.handleBaseUrlChange}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={
|
||||
!state.selectedProvider?.allow_base_url_edit ||
|
||||
state.isBaseUrlUpdating
|
||||
}
|
||||
className="min-w-[380px]"
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer
|
||||
title="API Key"
|
||||
description="API key for the selected provider."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ApiKeyField
|
||||
value={state.apiKey}
|
||||
onBlur={state.handleApiKeyChange}
|
||||
placeholder="sk-..."
|
||||
disabled={state.isApiKeyUpdating}
|
||||
className="min-w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer
|
||||
title="Model"
|
||||
description={
|
||||
state.isCustomProvider
|
||||
? "Provide the model identifier expected by your custom endpoint."
|
||||
: "Choose a model exposed by the selected provider."
|
||||
}
|
||||
descriptionMode="tooltip"
|
||||
layout="stacked"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelect
|
||||
value={state.model}
|
||||
options={state.modelOptions}
|
||||
disabled={state.isModelUpdating}
|
||||
isLoading={state.isFetchingModels}
|
||||
placeholder={
|
||||
state.modelOptions.length > 0
|
||||
? "Search or select a model"
|
||||
: "Type a model name"
|
||||
}
|
||||
onSelect={state.handleModelSelect}
|
||||
onCreate={state.handleModelCreate}
|
||||
onBlur={() => {}}
|
||||
className="flex-1 min-w-[380px]"
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={state.handleRefreshModels}
|
||||
disabled={state.isFetchingModels}
|
||||
ariaLabel="Refresh models"
|
||||
className="flex h-10 w-10 items-center justify-center"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${state.isFetchingModels ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</ResetButton>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||
const { getSetting, updateSetting, isUpdating, refreshSettings } =
|
||||
useSettings();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [draftText, setDraftText] = useState("");
|
||||
|
||||
const enabled = getSetting("post_process_enabled") || false;
|
||||
const prompts = getSetting("post_process_prompts") || [];
|
||||
const selectedPromptId = getSetting("post_process_selected_prompt_id") || "";
|
||||
const selectedPrompt =
|
||||
prompts.find((prompt) => prompt.id === selectedPromptId) || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating) return;
|
||||
|
||||
if (selectedPrompt) {
|
||||
setDraftName(selectedPrompt.name);
|
||||
setDraftText(selectedPrompt.prompt);
|
||||
} else {
|
||||
setDraftName("");
|
||||
setDraftText("");
|
||||
}
|
||||
}, [
|
||||
isCreating,
|
||||
selectedPromptId,
|
||||
selectedPrompt?.name,
|
||||
selectedPrompt?.prompt,
|
||||
]);
|
||||
|
||||
const handlePromptSelect = (promptId: string | null) => {
|
||||
if (!promptId) return;
|
||||
updateSetting("post_process_selected_prompt_id", promptId);
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const handleCreatePrompt = async () => {
|
||||
if (!draftName.trim() || !draftText.trim()) return;
|
||||
|
||||
try {
|
||||
const newPrompt = await invoke<LLMPrompt>("add_post_process_prompt", {
|
||||
name: draftName.trim(),
|
||||
prompt: draftText.trim(),
|
||||
});
|
||||
await refreshSettings();
|
||||
updateSetting("post_process_selected_prompt_id", newPrompt.id);
|
||||
setIsCreating(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to create prompt:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePrompt = async () => {
|
||||
if (!selectedPromptId || !draftName.trim() || !draftText.trim()) return;
|
||||
|
||||
try {
|
||||
await invoke("update_post_process_prompt", {
|
||||
id: selectedPromptId,
|
||||
name: draftName.trim(),
|
||||
prompt: draftText.trim(),
|
||||
});
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
console.error("Failed to update prompt:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrompt = async (promptId: string) => {
|
||||
if (!promptId) return;
|
||||
|
||||
try {
|
||||
await invoke("delete_post_process_prompt", { id: promptId });
|
||||
await refreshSettings();
|
||||
setIsCreating(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete prompt:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
setIsCreating(false);
|
||||
if (selectedPrompt) {
|
||||
setDraftName(selectedPrompt.name);
|
||||
setDraftText(selectedPrompt.prompt);
|
||||
} else {
|
||||
setDraftName("");
|
||||
setDraftText("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setIsCreating(true);
|
||||
setDraftName("");
|
||||
setDraftText("");
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<DisabledNotice>
|
||||
Post processing is currently disabled. Enable it in Debug settings to
|
||||
configure.
|
||||
</DisabledNotice>
|
||||
);
|
||||
}
|
||||
|
||||
const hasPrompts = prompts.length > 0;
|
||||
const isDirty =
|
||||
!!selectedPrompt &&
|
||||
(draftName.trim() !== selectedPrompt.name ||
|
||||
draftText.trim() !== selectedPrompt.prompt.trim());
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Selected Prompt"
|
||||
description="Select a template for refining transcriptions or create a new one. Use ${output} inside the prompt text to reference the captured transcript."
|
||||
descriptionMode="tooltip"
|
||||
layout="stacked"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Dropdown
|
||||
selectedValue={selectedPromptId || null}
|
||||
options={prompts.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}))}
|
||||
onSelect={(value) => handlePromptSelect(value)}
|
||||
placeholder={
|
||||
prompts.length === 0 ? "No prompts available" : "Select a prompt"
|
||||
}
|
||||
disabled={
|
||||
isUpdating("post_process_selected_prompt_id") || isCreating
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleStartCreate}
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={isCreating}
|
||||
>
|
||||
Create New Prompt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isCreating && hasPrompts && selectedPrompt && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-semibold">Prompt Label</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="Enter prompt name"
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-semibold">
|
||||
Prompt Instructions
|
||||
</label>
|
||||
<Textarea
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
placeholder="Write the instructions to run after transcription. Example: Improve grammar and clarity for the following text: ${output}"
|
||||
/>
|
||||
<p className="text-xs text-mid-gray/70">
|
||||
Tip: Use{" "}
|
||||
<code className="px-1 py-0.5 bg-mid-gray/20 rounded text-xs">
|
||||
${output}
|
||||
</code>{" "}
|
||||
to insert the transcribed text in your prompt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleUpdatePrompt}
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={!draftName.trim() || !draftText.trim() || !isDirty}
|
||||
>
|
||||
Update Prompt
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeletePrompt(selectedPromptId)}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
disabled={!selectedPromptId || prompts.length <= 1}
|
||||
>
|
||||
Delete Prompt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCreating && !selectedPrompt && (
|
||||
<div className="p-3 bg-mid-gray/5 rounded border border-mid-gray/20">
|
||||
<p className="text-sm text-mid-gray">
|
||||
{hasPrompts
|
||||
? "Select a prompt above to view and edit its details."
|
||||
: "Click 'Create New Prompt' above to create your first post-processing prompt."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreating && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 block flex flex-col">
|
||||
<label className="text-sm font-semibold text-text">
|
||||
Prompt Label
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="Enter prompt name"
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<label className="text-sm font-semibold">
|
||||
Prompt Instructions
|
||||
</label>
|
||||
<Textarea
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
placeholder="Write the instructions to run after transcription. Example: Improve grammar and clarity for the following text: ${output}"
|
||||
/>
|
||||
<p className="text-xs text-mid-gray/70">
|
||||
Tip: Use{" "}
|
||||
<code className="px-1 py-0.5 bg-mid-gray/20 rounded text-xs">
|
||||
${output}
|
||||
</code>{" "}
|
||||
to insert the transcribed text in your prompt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleCreatePrompt}
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={!draftName.trim() || !draftText.trim()}
|
||||
>
|
||||
Create Prompt
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelCreate}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const PostProcessingSettingsApi = React.memo(
|
||||
PostProcessingSettingsApiComponent,
|
||||
);
|
||||
PostProcessingSettingsApi.displayName = "PostProcessingSettingsApi";
|
||||
|
||||
export const PostProcessingSettingsPrompts = React.memo(
|
||||
PostProcessingSettingsPromptsComponent,
|
||||
);
|
||||
PostProcessingSettingsPrompts.displayName = "PostProcessingSettingsPrompts";
|
||||
|
||||
export const PostProcessingSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="API (OpenAI Compatible)">
|
||||
<PostProcessingSettingsApi />
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Prompt">
|
||||
<PostProcessingSettingsPrompts />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,19 +7,26 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||
export const Input: React.FC<InputProps> = ({
|
||||
className = "",
|
||||
variant = "default",
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-left flex items-center justify-between transition-all duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/20 focus:border-logo-primary";
|
||||
|
||||
const baseClasses =
|
||||
"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-left transition-all duration-150";
|
||||
|
||||
const interactiveClasses = disabled
|
||||
? "opacity-60 cursor-not-allowed bg-mid-gray/10 border-mid-gray/40"
|
||||
: "hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/20 focus:border-logo-primary";
|
||||
|
||||
const variantClasses = {
|
||||
default: "px-3 py-2",
|
||||
compact: "px-2 py-1"
|
||||
};
|
||||
compact: "px-2 py-1",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<input
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,12 +5,22 @@ interface ResetButtonProps {
|
|||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ResetButton: React.FC<ResetButtonProps> = React.memo(
|
||||
({ onClick, disabled = false, className = "" }) => (
|
||||
({
|
||||
onClick,
|
||||
disabled = false,
|
||||
className = "",
|
||||
ariaLabel,
|
||||
children,
|
||||
}) => (
|
||||
<button
|
||||
className={`p-1 rounded fill-text border border-transparent transition-all duration-150 ${
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
className={`p-1 rounded border border-transparent transition-all duration-150 ${
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed text-text/40"
|
||||
: "hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] hover:cursor-pointer hover:border-logo-primary text-text/80"
|
||||
|
|
@ -18,7 +28,7 @@ export const ResetButton: React.FC<ResetButtonProps> = React.memo(
|
|||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ResetIcon />
|
||||
{children ?? <ResetIcon />}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
|
|
|||
174
src/components/ui/Select.tsx
Normal file
174
src/components/ui/Select.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import React from "react";
|
||||
import SelectComponent from "react-select";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import type {
|
||||
ActionMeta,
|
||||
Props as ReactSelectProps,
|
||||
SingleValue,
|
||||
StylesConfig,
|
||||
} from "react-select";
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
type BaseProps = {
|
||||
value: string | null;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
isClearable?: boolean;
|
||||
onChange: (value: string | null, action: ActionMeta<SelectOption>) => void;
|
||||
onBlur?: () => void;
|
||||
className?: string;
|
||||
formatCreateLabel?: (input: string) => string;
|
||||
};
|
||||
|
||||
type CreatableProps = {
|
||||
isCreatable: true;
|
||||
onCreateOption: (value: string) => void;
|
||||
};
|
||||
|
||||
type NonCreatableProps = {
|
||||
isCreatable?: false;
|
||||
onCreateOption?: never;
|
||||
};
|
||||
|
||||
export type SelectProps = BaseProps & (CreatableProps | NonCreatableProps);
|
||||
|
||||
const baseBackground = "color-mix(in srgb, var(--color-mid-gray) 10%, transparent)";
|
||||
const hoverBackground = "color-mix(in srgb, var(--color-logo-primary) 12%, transparent)";
|
||||
const focusBackground = "color-mix(in srgb, var(--color-logo-primary) 20%, transparent)";
|
||||
const neutralBorder = "color-mix(in srgb, var(--color-mid-gray) 80%, transparent)";
|
||||
|
||||
const selectStyles: StylesConfig<SelectOption, false> = {
|
||||
control: (base, state) => ({
|
||||
...base,
|
||||
minHeight: 40,
|
||||
borderRadius: 6,
|
||||
borderColor: state.isFocused ? "var(--color-logo-primary)" : neutralBorder,
|
||||
boxShadow: state.isFocused ? "0 0 0 1px var(--color-logo-primary)" : "none",
|
||||
backgroundColor: state.isFocused ? focusBackground : baseBackground,
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--color-text)",
|
||||
transition: "all 150ms ease",
|
||||
":hover": {
|
||||
borderColor: "var(--color-logo-primary)",
|
||||
backgroundColor: hoverBackground,
|
||||
},
|
||||
}),
|
||||
valueContainer: (base) => ({
|
||||
...base,
|
||||
paddingInline: 10,
|
||||
paddingBlock: 6,
|
||||
}),
|
||||
input: (base) => ({
|
||||
...base,
|
||||
color: "var(--color-text)",
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
color: "var(--color-text)",
|
||||
}),
|
||||
dropdownIndicator: (base, state) => ({
|
||||
...base,
|
||||
color: state.isFocused
|
||||
? "var(--color-logo-primary)"
|
||||
: "color-mix(in srgb, var(--color-mid-gray) 80%, transparent)",
|
||||
":hover": {
|
||||
color: "var(--color-logo-primary)",
|
||||
},
|
||||
}),
|
||||
clearIndicator: (base) => ({
|
||||
...base,
|
||||
color: "color-mix(in srgb, var(--color-mid-gray) 80%, transparent)",
|
||||
":hover": {
|
||||
color: "var(--color-logo-primary)",
|
||||
},
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
zIndex: 30,
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-mid-gray) 30%, transparent)",
|
||||
boxShadow: "0 10px 30px rgba(15, 15, 15, 0.2)",
|
||||
}),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
backgroundColor: state.isSelected
|
||||
? focusBackground
|
||||
: state.isFocused
|
||||
? hoverBackground
|
||||
: "transparent",
|
||||
color: "var(--color-text)",
|
||||
cursor: state.isDisabled ? "not-allowed" : base.cursor,
|
||||
opacity: state.isDisabled ? 0.5 : 1,
|
||||
}),
|
||||
placeholder: (base) => ({
|
||||
...base,
|
||||
color: "color-mix(in srgb, var(--color-mid-gray) 65%, transparent)",
|
||||
}),
|
||||
};
|
||||
|
||||
export const Select: React.FC<SelectProps> = React.memo(
|
||||
({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
isLoading,
|
||||
isClearable = true,
|
||||
onChange,
|
||||
onBlur,
|
||||
className = "",
|
||||
isCreatable,
|
||||
formatCreateLabel,
|
||||
onCreateOption,
|
||||
}) => {
|
||||
const selectValue = React.useMemo(() => {
|
||||
if (!value) return null;
|
||||
const existing = options.find((option) => option.value === value);
|
||||
if (existing) return existing;
|
||||
return { value, label: value, isDisabled: false };
|
||||
}, [value, options]);
|
||||
|
||||
const handleChange = (
|
||||
option: SingleValue<SelectOption>,
|
||||
action: ActionMeta<SelectOption>,
|
||||
) => {
|
||||
onChange(option?.value ?? null, action);
|
||||
};
|
||||
|
||||
const sharedProps: Partial<ReactSelectProps<SelectOption, false>> = {
|
||||
className,
|
||||
classNamePrefix: "app-select",
|
||||
value: selectValue,
|
||||
options,
|
||||
onChange: handleChange,
|
||||
placeholder,
|
||||
isDisabled: disabled,
|
||||
isLoading,
|
||||
onBlur,
|
||||
isClearable,
|
||||
styles: selectStyles,
|
||||
};
|
||||
|
||||
if (isCreatable) {
|
||||
return (
|
||||
<CreatableSelect<SelectOption, false>
|
||||
{...sharedProps}
|
||||
onCreateOption={onCreateOption}
|
||||
formatCreateLabel={formatCreateLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SelectComponent<SelectOption, false> {...sharedProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
Select.displayName = "Select";
|
||||
27
src/components/ui/Textarea.tsx
Normal file
27
src/components/ui/Textarea.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from "react";
|
||||
|
||||
interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
variant?: "default" | "compact";
|
||||
}
|
||||
|
||||
export const Textarea: React.FC<TextareaProps> = ({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses =
|
||||
"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-left transition-[background-color,border-color] duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/10 focus:border-logo-primary resize-y";
|
||||
|
||||
const variantClasses = {
|
||||
default: "px-3 py-2 min-h-[100px]",
|
||||
compact: "px-2 py-1 min-h-[80px]",
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,3 +4,4 @@ export { ToggleSwitch } from "./ToggleSwitch";
|
|||
export { SettingContainer } from "./SettingContainer";
|
||||
export { SettingsGroup } from "./SettingsGroup";
|
||||
export { TextDisplay } from "./TextDisplay";
|
||||
export { Textarea } from "./Textarea";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface UseSettingsReturn {
|
|||
audioDevices: AudioDevice[];
|
||||
outputDevices: AudioDevice[];
|
||||
audioFeedbackEnabled: boolean;
|
||||
postProcessModelOptions: Record<string, string[]>;
|
||||
|
||||
// Actions
|
||||
updateSetting: <K extends keyof Settings>(
|
||||
|
|
@ -20,13 +21,20 @@ interface UseSettingsReturn {
|
|||
refreshSettings: () => Promise<void>;
|
||||
refreshAudioDevices: () => Promise<void>;
|
||||
refreshOutputDevices: () => Promise<void>;
|
||||
|
||||
|
||||
// Binding-specific actions
|
||||
updateBinding: (id: string, binding: string) => Promise<void>;
|
||||
resetBinding: (id: string) => Promise<void>;
|
||||
|
||||
// Convenience getters
|
||||
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
|
||||
|
||||
// Post-processing helpers
|
||||
setPostProcessProvider: (providerId: string) => Promise<void>;
|
||||
updatePostProcessBaseUrl: (providerId: string, baseUrl: string) => Promise<void>;
|
||||
updatePostProcessApiKey: (providerId: string, apiKey: string) => Promise<void>;
|
||||
updatePostProcessModel: (providerId: string, model: string) => Promise<void>;
|
||||
fetchPostProcessModels: (providerId: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export const useSettings = (): UseSettingsReturn => {
|
||||
|
|
@ -46,6 +54,7 @@ export const useSettings = (): UseSettingsReturn => {
|
|||
audioDevices: store.audioDevices,
|
||||
outputDevices: store.outputDevices,
|
||||
audioFeedbackEnabled: store.settings?.audio_feedback || false,
|
||||
postProcessModelOptions: store.postProcessModelOptions,
|
||||
updateSetting: store.updateSetting,
|
||||
resetSetting: store.resetSetting,
|
||||
refreshSettings: store.refreshSettings,
|
||||
|
|
@ -54,5 +63,10 @@ export const useSettings = (): UseSettingsReturn => {
|
|||
updateBinding: store.updateBinding,
|
||||
resetBinding: store.resetBinding,
|
||||
getSetting: store.getSetting,
|
||||
setPostProcessProvider: store.setPostProcessProvider,
|
||||
updatePostProcessBaseUrl: store.updatePostProcessBaseUrl,
|
||||
updatePostProcessApiKey: store.updatePostProcessApiKey,
|
||||
updatePostProcessModel: store.updatePostProcessModel,
|
||||
fetchPostProcessModels: store.fetchPostProcessModels,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,6 +40,28 @@ export type PasteMethod = z.infer<typeof PasteMethodSchema>;
|
|||
export const ClipboardHandlingSchema = z.enum(["dont_modify", "copy_to_clipboard"]);
|
||||
export type ClipboardHandling = z.infer<typeof ClipboardHandlingSchema>;
|
||||
|
||||
export const LLMPromptSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
prompt: z.string(),
|
||||
});
|
||||
|
||||
export type LLMPrompt = z.infer<typeof LLMPromptSchema>;
|
||||
|
||||
export const PostProcessProviderSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
base_url: z.string(),
|
||||
allow_base_url_edit: z.boolean().optional().default(false),
|
||||
models_endpoint: z.string().nullable().optional(),
|
||||
kind: z
|
||||
.enum(["openai_compatible", "anthropic"])
|
||||
.optional()
|
||||
.default("openai_compatible"),
|
||||
});
|
||||
|
||||
export type PostProcessProvider = z.infer<typeof PostProcessProviderSchema>;
|
||||
|
||||
export const SettingsSchema = z.object({
|
||||
bindings: ShortcutBindingsMapSchema,
|
||||
push_to_talk: z.boolean(),
|
||||
|
|
@ -65,6 +87,22 @@ export const SettingsSchema = z.object({
|
|||
history_limit: z.number().optional().default(5),
|
||||
paste_method: PasteMethodSchema.optional().default("ctrl_v"),
|
||||
clipboard_handling: ClipboardHandlingSchema.optional().default("dont_modify"),
|
||||
post_process_enabled: z.boolean().optional().default(false),
|
||||
post_process_provider_id: z.string().optional().default("openai"),
|
||||
post_process_providers: z
|
||||
.array(PostProcessProviderSchema)
|
||||
.optional()
|
||||
.default([]),
|
||||
post_process_api_keys: z
|
||||
.record(z.string())
|
||||
.optional()
|
||||
.default({}),
|
||||
post_process_models: z
|
||||
.record(z.string())
|
||||
.optional()
|
||||
.default({}),
|
||||
post_process_prompts: z.array(LLMPromptSchema).optional().default([]),
|
||||
post_process_selected_prompt_id: z.string().nullable().optional(),
|
||||
mute_while_recording: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface SettingsStore {
|
|||
audioDevices: AudioDevice[];
|
||||
outputDevices: AudioDevice[];
|
||||
customSounds: { start: boolean; stop: boolean };
|
||||
postProcessModelOptions: Record<string, string[]>;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
|
|
@ -27,6 +28,13 @@ interface SettingsStore {
|
|||
isUpdatingKey: (key: string) => boolean;
|
||||
playTestSound: (soundType: "start" | "stop") => Promise<void>;
|
||||
checkCustomSounds: () => Promise<void>;
|
||||
setPostProcessProvider: (providerId: string) => Promise<void>;
|
||||
updatePostProcessSetting: (settingType: 'base_url' | 'api_key' | 'model', providerId: string, value: string) => Promise<void>;
|
||||
updatePostProcessBaseUrl: (providerId: string, baseUrl: string) => Promise<void>;
|
||||
updatePostProcessApiKey: (providerId: string, apiKey: string) => Promise<void>;
|
||||
updatePostProcessModel: (providerId: string, model: string) => Promise<void>;
|
||||
fetchPostProcessModels: (providerId: string) => Promise<string[]>;
|
||||
setPostProcessModelOptions: (providerId: string, models: string[]) => void;
|
||||
|
||||
// Internal state setters
|
||||
setSettings: (settings: Settings | null) => void;
|
||||
|
|
@ -37,6 +45,8 @@ interface SettingsStore {
|
|||
setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void;
|
||||
}
|
||||
|
||||
// Note: Default post-processing settings are now managed in Rust
|
||||
// Settings will always have providers after migration
|
||||
const DEFAULT_SETTINGS: Partial<Settings> = {
|
||||
always_on_microphone: false,
|
||||
audio_feedback: true,
|
||||
|
|
@ -102,6 +112,10 @@ const settingUpdaters: {
|
|||
clipboard_handling: (value) =>
|
||||
invoke("change_clipboard_handling_setting", { handling: value }),
|
||||
history_limit: (value) => invoke("update_history_limit", { limit: value }),
|
||||
post_process_enabled: (value) =>
|
||||
invoke("change_post_process_enabled_setting", { enabled: value }),
|
||||
post_process_selected_prompt_id: (value) =>
|
||||
invoke("set_post_process_selected_prompt", { id: value }),
|
||||
mute_while_recording: (value) =>
|
||||
invoke("change_mute_while_recording_setting", { enabled: value }),
|
||||
};
|
||||
|
|
@ -114,6 +128,7 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
audioDevices: [],
|
||||
outputDevices: [],
|
||||
customSounds: { start: false, stop: false },
|
||||
postProcessModelOptions: {},
|
||||
|
||||
// Internal setters
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
|
@ -134,9 +149,9 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
refreshSettings: async () => {
|
||||
try {
|
||||
const { load } = await import("@tauri-apps/plugin-store");
|
||||
const store = await load("settings_store.json", {
|
||||
const store = await load("settings_store.json", {
|
||||
defaults: DEFAULT_SETTINGS,
|
||||
autoSave: false
|
||||
autoSave: false
|
||||
});
|
||||
const settings = (await store.get("settings")) as Settings;
|
||||
|
||||
|
|
@ -337,6 +352,126 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
}
|
||||
},
|
||||
|
||||
setPostProcessProvider: async (providerId) => {
|
||||
const { settings, setUpdating, refreshSettings } = get();
|
||||
const updateKey = "post_process_provider_id";
|
||||
const previousId = settings?.post_process_provider_id ?? null;
|
||||
|
||||
setUpdating(updateKey, true);
|
||||
|
||||
if (settings) {
|
||||
set((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, post_process_provider_id: providerId }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke("set_post_process_provider", { providerId });
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
console.error("Failed to set post-process provider:", error);
|
||||
if (previousId !== null) {
|
||||
set((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, post_process_provider_id: previousId }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
},
|
||||
|
||||
// Generic updater for post-processing provider settings
|
||||
updatePostProcessSetting: async (
|
||||
settingType: 'base_url' | 'api_key' | 'model',
|
||||
providerId: string,
|
||||
value: string
|
||||
) => {
|
||||
const { setUpdating, refreshSettings } = get();
|
||||
const updateKey = `post_process_${settingType}:${providerId}`;
|
||||
|
||||
// Map setting types to command names
|
||||
const commandMap = {
|
||||
base_url: 'change_post_process_base_url_setting',
|
||||
api_key: 'change_post_process_api_key_setting',
|
||||
model: 'change_post_process_model_setting',
|
||||
};
|
||||
|
||||
// Map setting types to param names
|
||||
const paramMap = {
|
||||
base_url: 'baseUrl',
|
||||
api_key: 'apiKey',
|
||||
model: 'model',
|
||||
};
|
||||
|
||||
setUpdating(updateKey, true);
|
||||
|
||||
try {
|
||||
await invoke(commandMap[settingType], {
|
||||
providerId,
|
||||
[paramMap[settingType]]: value,
|
||||
});
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
console.error(`Failed to update post-process ${settingType.replace('_', ' ')}:`, error);
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
},
|
||||
|
||||
updatePostProcessBaseUrl: async (providerId, baseUrl) => {
|
||||
return get().updatePostProcessSetting('base_url', providerId, baseUrl);
|
||||
},
|
||||
|
||||
updatePostProcessApiKey: async (providerId, apiKey) => {
|
||||
// Clear cached models when API key changes - user should click refresh after
|
||||
set((state) => ({
|
||||
postProcessModelOptions: {
|
||||
...state.postProcessModelOptions,
|
||||
[providerId]: [],
|
||||
},
|
||||
}));
|
||||
return get().updatePostProcessSetting('api_key', providerId, apiKey);
|
||||
},
|
||||
|
||||
updatePostProcessModel: async (providerId, model) => {
|
||||
return get().updatePostProcessSetting('model', providerId, model);
|
||||
},
|
||||
|
||||
fetchPostProcessModels: async (providerId) => {
|
||||
const updateKey = `post_process_models_fetch:${providerId}`;
|
||||
const { setUpdating, setPostProcessModelOptions } = get();
|
||||
|
||||
setUpdating(updateKey, true);
|
||||
|
||||
try {
|
||||
// Call Tauri backend command instead of fetch
|
||||
const models: string[] = await invoke("fetch_post_process_models", {
|
||||
providerId,
|
||||
});
|
||||
|
||||
setPostProcessModelOptions(providerId, models);
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch models:", error);
|
||||
// Don't cache empty array on error - let user retry
|
||||
return [];
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
},
|
||||
|
||||
setPostProcessModelOptions: (providerId, models) =>
|
||||
set((state) => ({
|
||||
postProcessModelOptions: {
|
||||
...state.postProcessModelOptions,
|
||||
[providerId]: models,
|
||||
},
|
||||
})),
|
||||
|
||||
// Initialize everything
|
||||
initialize: async () => {
|
||||
const {
|
||||
|
|
|
|||
Loading…
Reference in a new issue