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:
Luis Eduardo 2025-11-05 20:29:03 -06:00 committed by GitHub
parent b8af208ae4
commit f7380fa69a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2238 additions and 299 deletions

View file

@ -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.

View file

@ -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
View file

@ -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=="],

View file

@ -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
View file

@ -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",

View file

@ -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"

View file

@ -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()

View file

@ -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,

View 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)
}

View file

@ -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()?;

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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);

View file

@ -76,6 +76,9 @@
"endpoints": [
"https://github.com/cjpais/Handy/releases/latest/download/latest.json"
]
},
"sql": {
"preload": ["sqlite:history.db"]
}
}
}

View file

@ -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,

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -0,0 +1 @@
export { PostProcessingSettingsApi } from "../post-processing/PostProcessingSettings";

View file

@ -0,0 +1,4 @@
export type ModelOption = {
value: string;
label: string;
};

View file

@ -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,
};
};

View file

@ -0,0 +1 @@
export { PostProcessingSettingsPrompts } from "./post-processing/PostProcessingSettings";

View 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}
/>
);
},
);

View file

@ -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("");

View file

@ -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 (

View file

@ -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>

View file

@ -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();

View file

@ -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";

View file

@ -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";

View file

@ -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">
$&#123;output&#125;
</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">
$&#123;output&#125;
</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>
);
};

View file

@ -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}
/>
);
};
};

View file

@ -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>
),
);

View 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";

View 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}
/>
);
};

View file

@ -4,3 +4,4 @@ export { ToggleSwitch } from "./ToggleSwitch";
export { SettingContainer } from "./SettingContainer";
export { SettingsGroup } from "./SettingsGroup";
export { TextDisplay } from "./TextDisplay";
export { Textarea } from "./Textarea";

View file

@ -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,
};
};

View file

@ -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),
});

View file

@ -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 {