diff --git a/CLAUDE.md b/AGENTS.md similarity index 99% rename from CLAUDE.md rename to AGENTS.md index 9388408..2806520 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -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. \ No newline at end of file +The app enforces single instance behavior - launching when already running brings the settings window to front rather than creating a new process. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66d191d..8fee6e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/bun.lock b/bun.lock index 8c1d4bc..4050238 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-select": "^5.8.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "tauri-plugin-macos-permissions-api": "2.3.0", @@ -30,6 +31,7 @@ "@types/node": "^24.9.1", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", + "@types/react-select": "^5.0.1", "@vitejs/plugin-react": "^4.7.0", "typescript": "~5.6.3", "vite": "^6.4.1", @@ -69,12 +71,36 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], @@ -127,6 +153,12 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -271,50 +303,84 @@ "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="], "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/react-select": ["@types/react-select@5.0.1", "", { "dependencies": { "react-select": "*" } }, "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA=="], + + "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="], "browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.240", "", {}, "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ=="], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -341,6 +407,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -349,24 +417,48 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-select": ["react-select@5.10.2", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], @@ -375,8 +467,14 @@ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -391,14 +489,20 @@ "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], diff --git a/package.json b/package.json index 65676d7..fa9b38b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7dc40b2..8f64fb6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c68e89..0263f2d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index db6bc70..c2aff8a 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -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 { + 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 = None; + let mut post_process_prompt: Option = 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() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62dfb4d..fa202fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs new file mode 100644 index 0000000..a6d579d --- /dev/null +++ b/src-tauri/src/llm_client.rs @@ -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, 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) +} diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index 55e62fd..e1a06f0 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -18,6 +18,8 @@ pub struct HistoryEntry { pub saved: bool, pub title: String, pub transcription_text: String, + pub post_processed_text: Option, + pub post_process_prompt: Option, } pub struct HistoryManager { @@ -52,35 +54,39 @@ impl HistoryManager { } pub fn get_migrations() -> Vec { - 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, transcription_text: String, + post_processed_text: Option, + post_process_prompt: Option, ) -> 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, + post_process_prompt: Option, ) -> 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> { 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> { 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()?; diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 7361fc1..bcd2008 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -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 { 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, ) -> 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 } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2ebeca8..3beafe9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, +} + #[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, + #[serde(default = "default_post_process_api_keys")] + pub post_process_api_keys: HashMap, + #[serde(default = "default_post_process_models")] + pub post_process_models: HashMap, + #[serde(default = "default_post_process_prompts")] + pub post_process_prompts: Vec, + #[serde(default)] + pub post_process_selected_prompt_id: Option, #[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 { + 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 { + 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 { + 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 { + 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::(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::(settings_value) - .unwrap_or_else(|_| get_default_settings()) + serde_json::from_value::(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 } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 1302803..0f72663 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -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 { + 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, 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, 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); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0eb0312..46ccf82 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -76,6 +76,9 @@ "endpoints": [ "https://github.com/cjpais/Handy/releases/latest/download/latest.json" ] + }, + "sql": { + "preload": ["sqlite:history.db"] } } } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ef4e139..48065fc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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, diff --git a/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx b/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx new file mode 100644 index 0000000..804c1f2 --- /dev/null +++ b/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx @@ -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 = React.memo( + ({ value, onBlur, disabled, placeholder, className = "" }) => { + const [localValue, setLocalValue] = useState(value); + + // Sync with prop changes + React.useEffect(() => { + setLocalValue(value); + }, [value]); + + return ( + setLocalValue(event.target.value)} + onBlur={() => onBlur(localValue)} + placeholder={placeholder} + variant="compact" + disabled={disabled} + className={`flex-1 min-w-[320px] ${className}`} + /> + ); + }, +); + +ApiKeyField.displayName = "ApiKeyField"; diff --git a/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx b/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx new file mode 100644 index 0000000..1c1cf13 --- /dev/null +++ b/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx @@ -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 = 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 ( + 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"; diff --git a/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx b/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx new file mode 100644 index 0000000..8a05ed0 --- /dev/null +++ b/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx @@ -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 = 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 ( + setDraftName(e.target.value)} + placeholder="Enter prompt name" + variant="compact" + /> + + +
+ +