fix: desktop provider kepp TUI
Browse files- desktop/src-tauri/Cargo.lock +106 -7
- desktop/src-tauri/Cargo.toml +1 -0
- desktop/src-tauri/src/lib.rs +75 -1
- desktop/src-tauri/tauri.conf.json +1 -1
- desktop/src/api/config.ts +91 -0
- desktop/src/api/providerModels.ts +228 -0
- desktop/src/lib/modelOptions.ts +208 -0
- desktop/src/lib/tuiConversation.ts +434 -0
- desktop/src/stores/chatStore.ts +215 -26
- desktop/src/stores/cliAuthStore.ts +83 -29
- desktop/src/stores/providerStore.ts +55 -2
- desktop/src/stores/settingsStore.ts +58 -13
- src/components/Messages.tsx +1 -1
- src/hooks/useAutoTTS.ts +27 -7
desktop/src-tauri/Cargo.lock
CHANGED
|
@@ -485,6 +485,7 @@ name = "claude-code-desktop"
|
|
| 485 |
version = "0.2.9"
|
| 486 |
dependencies = [
|
| 487 |
"anyhow",
|
|
|
|
| 488 |
"portable-pty",
|
| 489 |
"reqwest",
|
| 490 |
"serde",
|
|
@@ -784,13 +785,34 @@ dependencies = [
|
|
| 784 |
"crypto-common",
|
| 785 |
]
|
| 786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
[[package]]
|
| 788 |
name = "dirs"
|
| 789 |
version = "6.0.0"
|
| 790 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 791 |
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
| 792 |
dependencies = [
|
| 793 |
-
"dirs-sys",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
]
|
| 795 |
|
| 796 |
[[package]]
|
|
@@ -801,7 +823,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|
| 801 |
dependencies = [
|
| 802 |
"libc",
|
| 803 |
"option-ext",
|
| 804 |
-
"redox_users",
|
| 805 |
"windows-sys 0.61.2",
|
| 806 |
]
|
| 807 |
|
|
@@ -3199,6 +3221,17 @@ dependencies = [
|
|
| 3199 |
"bitflags 2.11.1",
|
| 3200 |
]
|
| 3201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3202 |
[[package]]
|
| 3203 |
name = "redox_users"
|
| 3204 |
version = "0.5.2"
|
|
@@ -4175,7 +4208,7 @@ dependencies = [
|
|
| 4175 |
"anyhow",
|
| 4176 |
"bytes",
|
| 4177 |
"cookie",
|
| 4178 |
-
"dirs",
|
| 4179 |
"dunce",
|
| 4180 |
"embed_plist",
|
| 4181 |
"getrandom 0.3.4",
|
|
@@ -4225,7 +4258,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
|
| 4225 |
dependencies = [
|
| 4226 |
"anyhow",
|
| 4227 |
"cargo_toml",
|
| 4228 |
-
"dirs",
|
| 4229 |
"glob",
|
| 4230 |
"heck 0.5.0",
|
| 4231 |
"json-patch",
|
|
@@ -4410,7 +4443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
| 4410 |
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
| 4411 |
dependencies = [
|
| 4412 |
"base64 0.22.1",
|
| 4413 |
-
"dirs",
|
| 4414 |
"flate2",
|
| 4415 |
"futures-util",
|
| 4416 |
"http",
|
|
@@ -4920,7 +4953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
| 4920 |
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
| 4921 |
dependencies = [
|
| 4922 |
"crossbeam-channel",
|
| 4923 |
-
"dirs",
|
| 4924 |
"libappindicator",
|
| 4925 |
"muda",
|
| 4926 |
"objc2",
|
|
@@ -5577,6 +5610,15 @@ dependencies = [
|
|
| 5577 |
"windows-targets 0.42.2",
|
| 5578 |
]
|
| 5579 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5580 |
[[package]]
|
| 5581 |
name = "windows-sys"
|
| 5582 |
version = "0.52.0"
|
|
@@ -5628,6 +5670,21 @@ dependencies = [
|
|
| 5628 |
"windows_x86_64_msvc 0.42.2",
|
| 5629 |
]
|
| 5630 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5631 |
[[package]]
|
| 5632 |
name = "windows-targets"
|
| 5633 |
version = "0.52.6"
|
|
@@ -5685,6 +5742,12 @@ version = "0.42.2"
|
|
| 5685 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5686 |
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
| 5687 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5688 |
[[package]]
|
| 5689 |
name = "windows_aarch64_gnullvm"
|
| 5690 |
version = "0.52.6"
|
|
@@ -5703,6 +5766,12 @@ version = "0.42.2"
|
|
| 5703 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5704 |
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
| 5705 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5706 |
[[package]]
|
| 5707 |
name = "windows_aarch64_msvc"
|
| 5708 |
version = "0.52.6"
|
|
@@ -5721,6 +5790,12 @@ version = "0.42.2"
|
|
| 5721 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5722 |
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
| 5723 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5724 |
[[package]]
|
| 5725 |
name = "windows_i686_gnu"
|
| 5726 |
version = "0.52.6"
|
|
@@ -5751,6 +5826,12 @@ version = "0.42.2"
|
|
| 5751 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5752 |
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
| 5753 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5754 |
[[package]]
|
| 5755 |
name = "windows_i686_msvc"
|
| 5756 |
version = "0.52.6"
|
|
@@ -5769,6 +5850,12 @@ version = "0.42.2"
|
|
| 5769 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5770 |
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
| 5771 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5772 |
[[package]]
|
| 5773 |
name = "windows_x86_64_gnu"
|
| 5774 |
version = "0.52.6"
|
|
@@ -5787,6 +5874,12 @@ version = "0.42.2"
|
|
| 5787 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5788 |
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
| 5789 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5790 |
[[package]]
|
| 5791 |
name = "windows_x86_64_gnullvm"
|
| 5792 |
version = "0.52.6"
|
|
@@ -5805,6 +5898,12 @@ version = "0.42.2"
|
|
| 5805 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5806 |
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
| 5807 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5808 |
[[package]]
|
| 5809 |
name = "windows_x86_64_msvc"
|
| 5810 |
version = "0.52.6"
|
|
@@ -5970,7 +6069,7 @@ dependencies = [
|
|
| 5970 |
"block2",
|
| 5971 |
"cookie",
|
| 5972 |
"crossbeam-channel",
|
| 5973 |
-
"dirs",
|
| 5974 |
"dom_query",
|
| 5975 |
"dpi",
|
| 5976 |
"dunce",
|
|
|
|
| 485 |
version = "0.2.9"
|
| 486 |
dependencies = [
|
| 487 |
"anyhow",
|
| 488 |
+
"dirs 5.0.1",
|
| 489 |
"portable-pty",
|
| 490 |
"reqwest",
|
| 491 |
"serde",
|
|
|
|
| 785 |
"crypto-common",
|
| 786 |
]
|
| 787 |
|
| 788 |
+
[[package]]
|
| 789 |
+
name = "dirs"
|
| 790 |
+
version = "5.0.1"
|
| 791 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 792 |
+
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
| 793 |
+
dependencies = [
|
| 794 |
+
"dirs-sys 0.4.1",
|
| 795 |
+
]
|
| 796 |
+
|
| 797 |
[[package]]
|
| 798 |
name = "dirs"
|
| 799 |
version = "6.0.0"
|
| 800 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 801 |
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
| 802 |
dependencies = [
|
| 803 |
+
"dirs-sys 0.5.0",
|
| 804 |
+
]
|
| 805 |
+
|
| 806 |
+
[[package]]
|
| 807 |
+
name = "dirs-sys"
|
| 808 |
+
version = "0.4.1"
|
| 809 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 810 |
+
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
| 811 |
+
dependencies = [
|
| 812 |
+
"libc",
|
| 813 |
+
"option-ext",
|
| 814 |
+
"redox_users 0.4.6",
|
| 815 |
+
"windows-sys 0.48.0",
|
| 816 |
]
|
| 817 |
|
| 818 |
[[package]]
|
|
|
|
| 823 |
dependencies = [
|
| 824 |
"libc",
|
| 825 |
"option-ext",
|
| 826 |
+
"redox_users 0.5.2",
|
| 827 |
"windows-sys 0.61.2",
|
| 828 |
]
|
| 829 |
|
|
|
|
| 3221 |
"bitflags 2.11.1",
|
| 3222 |
]
|
| 3223 |
|
| 3224 |
+
[[package]]
|
| 3225 |
+
name = "redox_users"
|
| 3226 |
+
version = "0.4.6"
|
| 3227 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 3228 |
+
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
| 3229 |
+
dependencies = [
|
| 3230 |
+
"getrandom 0.2.17",
|
| 3231 |
+
"libredox",
|
| 3232 |
+
"thiserror 1.0.69",
|
| 3233 |
+
]
|
| 3234 |
+
|
| 3235 |
[[package]]
|
| 3236 |
name = "redox_users"
|
| 3237 |
version = "0.5.2"
|
|
|
|
| 4208 |
"anyhow",
|
| 4209 |
"bytes",
|
| 4210 |
"cookie",
|
| 4211 |
+
"dirs 6.0.0",
|
| 4212 |
"dunce",
|
| 4213 |
"embed_plist",
|
| 4214 |
"getrandom 0.3.4",
|
|
|
|
| 4258 |
dependencies = [
|
| 4259 |
"anyhow",
|
| 4260 |
"cargo_toml",
|
| 4261 |
+
"dirs 6.0.0",
|
| 4262 |
"glob",
|
| 4263 |
"heck 0.5.0",
|
| 4264 |
"json-patch",
|
|
|
|
| 4443 |
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
| 4444 |
dependencies = [
|
| 4445 |
"base64 0.22.1",
|
| 4446 |
+
"dirs 6.0.0",
|
| 4447 |
"flate2",
|
| 4448 |
"futures-util",
|
| 4449 |
"http",
|
|
|
|
| 4953 |
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
| 4954 |
dependencies = [
|
| 4955 |
"crossbeam-channel",
|
| 4956 |
+
"dirs 6.0.0",
|
| 4957 |
"libappindicator",
|
| 4958 |
"muda",
|
| 4959 |
"objc2",
|
|
|
|
| 5610 |
"windows-targets 0.42.2",
|
| 5611 |
]
|
| 5612 |
|
| 5613 |
+
[[package]]
|
| 5614 |
+
name = "windows-sys"
|
| 5615 |
+
version = "0.48.0"
|
| 5616 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5617 |
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
| 5618 |
+
dependencies = [
|
| 5619 |
+
"windows-targets 0.48.5",
|
| 5620 |
+
]
|
| 5621 |
+
|
| 5622 |
[[package]]
|
| 5623 |
name = "windows-sys"
|
| 5624 |
version = "0.52.0"
|
|
|
|
| 5670 |
"windows_x86_64_msvc 0.42.2",
|
| 5671 |
]
|
| 5672 |
|
| 5673 |
+
[[package]]
|
| 5674 |
+
name = "windows-targets"
|
| 5675 |
+
version = "0.48.5"
|
| 5676 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5677 |
+
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
| 5678 |
+
dependencies = [
|
| 5679 |
+
"windows_aarch64_gnullvm 0.48.5",
|
| 5680 |
+
"windows_aarch64_msvc 0.48.5",
|
| 5681 |
+
"windows_i686_gnu 0.48.5",
|
| 5682 |
+
"windows_i686_msvc 0.48.5",
|
| 5683 |
+
"windows_x86_64_gnu 0.48.5",
|
| 5684 |
+
"windows_x86_64_gnullvm 0.48.5",
|
| 5685 |
+
"windows_x86_64_msvc 0.48.5",
|
| 5686 |
+
]
|
| 5687 |
+
|
| 5688 |
[[package]]
|
| 5689 |
name = "windows-targets"
|
| 5690 |
version = "0.52.6"
|
|
|
|
| 5742 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5743 |
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
| 5744 |
|
| 5745 |
+
[[package]]
|
| 5746 |
+
name = "windows_aarch64_gnullvm"
|
| 5747 |
+
version = "0.48.5"
|
| 5748 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5749 |
+
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
| 5750 |
+
|
| 5751 |
[[package]]
|
| 5752 |
name = "windows_aarch64_gnullvm"
|
| 5753 |
version = "0.52.6"
|
|
|
|
| 5766 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5767 |
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
| 5768 |
|
| 5769 |
+
[[package]]
|
| 5770 |
+
name = "windows_aarch64_msvc"
|
| 5771 |
+
version = "0.48.5"
|
| 5772 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5773 |
+
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
| 5774 |
+
|
| 5775 |
[[package]]
|
| 5776 |
name = "windows_aarch64_msvc"
|
| 5777 |
version = "0.52.6"
|
|
|
|
| 5790 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5791 |
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
| 5792 |
|
| 5793 |
+
[[package]]
|
| 5794 |
+
name = "windows_i686_gnu"
|
| 5795 |
+
version = "0.48.5"
|
| 5796 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5797 |
+
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
| 5798 |
+
|
| 5799 |
[[package]]
|
| 5800 |
name = "windows_i686_gnu"
|
| 5801 |
version = "0.52.6"
|
|
|
|
| 5826 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5827 |
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
| 5828 |
|
| 5829 |
+
[[package]]
|
| 5830 |
+
name = "windows_i686_msvc"
|
| 5831 |
+
version = "0.48.5"
|
| 5832 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5833 |
+
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
| 5834 |
+
|
| 5835 |
[[package]]
|
| 5836 |
name = "windows_i686_msvc"
|
| 5837 |
version = "0.52.6"
|
|
|
|
| 5850 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5851 |
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
| 5852 |
|
| 5853 |
+
[[package]]
|
| 5854 |
+
name = "windows_x86_64_gnu"
|
| 5855 |
+
version = "0.48.5"
|
| 5856 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5857 |
+
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
| 5858 |
+
|
| 5859 |
[[package]]
|
| 5860 |
name = "windows_x86_64_gnu"
|
| 5861 |
version = "0.52.6"
|
|
|
|
| 5874 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5875 |
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
| 5876 |
|
| 5877 |
+
[[package]]
|
| 5878 |
+
name = "windows_x86_64_gnullvm"
|
| 5879 |
+
version = "0.48.5"
|
| 5880 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5881 |
+
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
| 5882 |
+
|
| 5883 |
[[package]]
|
| 5884 |
name = "windows_x86_64_gnullvm"
|
| 5885 |
version = "0.52.6"
|
|
|
|
| 5898 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5899 |
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
| 5900 |
|
| 5901 |
+
[[package]]
|
| 5902 |
+
name = "windows_x86_64_msvc"
|
| 5903 |
+
version = "0.48.5"
|
| 5904 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5905 |
+
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
| 5906 |
+
|
| 5907 |
[[package]]
|
| 5908 |
name = "windows_x86_64_msvc"
|
| 5909 |
version = "0.52.6"
|
|
|
|
| 6069 |
"block2",
|
| 6070 |
"cookie",
|
| 6071 |
"crossbeam-channel",
|
| 6072 |
+
"dirs 6.0.0",
|
| 6073 |
"dom_query",
|
| 6074 |
"dpi",
|
| 6075 |
"dunce",
|
desktop/src-tauri/Cargo.toml
CHANGED
|
@@ -25,3 +25,4 @@ portable-pty = "0.9.0"
|
|
| 25 |
tauri-plugin-notification = "2"
|
| 26 |
tauri-plugin-single-instance = "2"
|
| 27 |
reqwest = { version = "0.13", default-features = false, features = ["system-proxy"] }
|
|
|
|
|
|
| 25 |
tauri-plugin-notification = "2"
|
| 26 |
tauri-plugin-single-instance = "2"
|
| 27 |
reqwest = { version = "0.13", default-features = false, features = ["system-proxy"] }
|
| 28 |
+
dirs = "5"
|
desktop/src-tauri/src/lib.rs
CHANGED
|
@@ -1151,6 +1151,78 @@ fn set_app_zoom(window: tauri::WebviewWindow, zoom_factor: f64) -> Result<(), St
|
|
| 1151 |
.map_err(|err| format!("set app zoom: {err}"))
|
| 1152 |
}
|
| 1153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
#[cfg(target_os = "windows")]
|
| 1155 |
fn open_windows_notification_settings_impl() -> Result<bool, String> {
|
| 1156 |
StdCommand::new("explorer.exe")
|
|
@@ -2129,7 +2201,9 @@ pub fn run() {
|
|
| 2129 |
get_app_mode,
|
| 2130 |
set_app_mode,
|
| 2131 |
detect_portable_dir,
|
| 2132 |
-
set_app_zoom
|
|
|
|
|
|
|
| 2133 |
]);
|
| 2134 |
|
| 2135 |
// macOS: native menu bar (traffic-light overlay style)
|
|
|
|
| 1151 |
.map_err(|err| format!("set app zoom: {err}"))
|
| 1152 |
}
|
| 1153 |
|
| 1154 |
+
/// Get the path to ~/.claude.json (mirrors TUI's getGlobalClaudeFile)
|
| 1155 |
+
fn get_global_claude_file_path() -> std::path::PathBuf {
|
| 1156 |
+
// Same logic as TUI's getGlobalClaudeFile(): follow CLAUDE_CONFIG_DIR, then homedir
|
| 1157 |
+
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
|
| 1158 |
+
std::path::PathBuf::from(config_dir).join(".claude.json")
|
| 1159 |
+
} else {
|
| 1160 |
+
dirs::home_dir()
|
| 1161 |
+
.map(|h| h.join(".claude.json"))
|
| 1162 |
+
.unwrap_or_else(|| std::path::PathBuf::from(".claude.json"))
|
| 1163 |
+
}
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
/// Read the global Claude config file (~/.claude.json).
|
| 1167 |
+
/// Returns the parsed JSON or null if the file doesn't exist.
|
| 1168 |
+
#[tauri::command]
|
| 1169 |
+
fn get_claude_config() -> Result<serde_json::Value, String> {
|
| 1170 |
+
let path = get_global_claude_file_path();
|
| 1171 |
+
match fs::read_to_string(&path) {
|
| 1172 |
+
Ok(content) => serde_json::from_str(&content)
|
| 1173 |
+
.map_err(|e| format!("parse {}: {e}", path.display())),
|
| 1174 |
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Value::Null),
|
| 1175 |
+
Err(e) => Err(format!("read {}: {e}", path.display())),
|
| 1176 |
+
}
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
/// Write the global Claude config file (~/.claude.json).
|
| 1180 |
+
/// The content is merged with the existing file (deep merge).
|
| 1181 |
+
#[tauri::command]
|
| 1182 |
+
fn save_claude_config(patch: serde_json::Value) -> Result<(), String> {
|
| 1183 |
+
let path = get_global_claude_file_path();
|
| 1184 |
+
// Ensure parent directory exists
|
| 1185 |
+
if let Some(parent) = path.parent() {
|
| 1186 |
+
fs::create_dir_all(parent)
|
| 1187 |
+
.map_err(|e| format!("create config dir {}: {e}", parent.display()))?;
|
| 1188 |
+
}
|
| 1189 |
+
// Read existing content
|
| 1190 |
+
let existing: serde_json::Value = if path.exists() {
|
| 1191 |
+
let content = fs::read_to_string(&path)
|
| 1192 |
+
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
| 1193 |
+
serde_json::from_str(&content)
|
| 1194 |
+
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()))
|
| 1195 |
+
} else {
|
| 1196 |
+
serde_json::Value::Object(serde_json::Map::new())
|
| 1197 |
+
};
|
| 1198 |
+
// Deep merge: patch overrides existing
|
| 1199 |
+
let merged = deep_merge_json(&existing, &patch);
|
| 1200 |
+
let json = serde_json::to_string_pretty(&merged)
|
| 1201 |
+
.map_err(|e| format!("serialize config: {e}"))?;
|
| 1202 |
+
fs::write(&path, json)
|
| 1203 |
+
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
| 1204 |
+
Ok(())
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
/// Deep-merge two JSON objects. Patch values override existing ones.
|
| 1208 |
+
/// Arrays are replaced, not concatenated.
|
| 1209 |
+
fn deep_merge_json(base: &serde_json::Value, patch: &serde_json::Value) -> serde_json::Value {
|
| 1210 |
+
match (base, patch) {
|
| 1211 |
+
(serde_json::Value::Object(base_map), serde_json::Value::Object(patch_map)) => {
|
| 1212 |
+
let mut result = base_map.clone();
|
| 1213 |
+
for (key, patch_val) in patch_map {
|
| 1214 |
+
let merged = match result.get(key) {
|
| 1215 |
+
Some(existing_val) => deep_merge_json(existing_val, patch_val),
|
| 1216 |
+
None => patch_val.clone(),
|
| 1217 |
+
};
|
| 1218 |
+
result.insert(key.clone(), merged);
|
| 1219 |
+
}
|
| 1220 |
+
serde_json::Value::Object(result)
|
| 1221 |
+
}
|
| 1222 |
+
_ => patch.clone(),
|
| 1223 |
+
}
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
#[cfg(target_os = "windows")]
|
| 1227 |
fn open_windows_notification_settings_impl() -> Result<bool, String> {
|
| 1228 |
StdCommand::new("explorer.exe")
|
|
|
|
| 2201 |
get_app_mode,
|
| 2202 |
set_app_mode,
|
| 2203 |
detect_portable_dir,
|
| 2204 |
+
set_app_zoom,
|
| 2205 |
+
get_claude_config,
|
| 2206 |
+
save_claude_config
|
| 2207 |
]);
|
| 2208 |
|
| 2209 |
// macOS: native menu bar (traffic-light overlay style)
|
desktop/src-tauri/tauri.conf.json
CHANGED
|
@@ -24,7 +24,7 @@
|
|
| 24 |
],
|
| 25 |
"security": {
|
| 26 |
"dangerousDisableAssetCspModification": ["style-src"],
|
| 27 |
-
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost http://127.0.0.1:* http://localhost:*; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
|
| 28 |
}
|
| 29 |
},
|
| 30 |
"plugins": {
|
|
|
|
| 24 |
],
|
| 25 |
"security": {
|
| 26 |
"dangerousDisableAssetCspModification": ["style-src"],
|
| 27 |
+
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost http://127.0.0.1:* http://localhost:*; font-src 'self' data:; connect-src 'self' https: ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
|
| 28 |
}
|
| 29 |
},
|
| 30 |
"plugins": {
|
desktop/src/api/config.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* TUI-style configuration reading layer for desktop.
|
| 3 |
+
*
|
| 4 |
+
* Mirrors TUI's src/utils/config.ts and src/utils/env.ts reading patterns,
|
| 5 |
+
* using Rust Tauri commands to read ~/.claude.json directly instead of going
|
| 6 |
+
* through the cc-haha sidecar HTTP API.
|
| 7 |
+
*
|
| 8 |
+
* This allows the desktop app to use the same configuration files as the TUI,
|
| 9 |
+
* making cc-haha's /api/cli-auth endpoint unnecessary for config reads.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { invoke } from '@tauri-apps/api/core'
|
| 13 |
+
|
| 14 |
+
// Re-export types that mirror TUI's GlobalConfig shape
|
| 15 |
+
export type TuiGlobalConfig = {
|
| 16 |
+
// Auth provider
|
| 17 |
+
authProvider?: 'anthropic' | 'openrouter' | 'openai' | 'local' | 'opencode' | 'nvidia' | null
|
| 18 |
+
|
| 19 |
+
// API Keys
|
| 20 |
+
primaryApiKey?: string
|
| 21 |
+
openAiApiKey?: string
|
| 22 |
+
openAiAccessToken?: string
|
| 23 |
+
openRouterApiKey?: string
|
| 24 |
+
nvidiaApiKey?: string
|
| 25 |
+
openCodeApiKey?: string
|
| 26 |
+
openCodeModelName?: string
|
| 27 |
+
localBaseUrl?: string
|
| 28 |
+
localModelName?: string
|
| 29 |
+
|
| 30 |
+
// OAuth
|
| 31 |
+
oauthAccount?: {
|
| 32 |
+
emailAddress?: string
|
| 33 |
+
organizationName?: string
|
| 34 |
+
uuid?: string
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Model selection
|
| 38 |
+
model?: string
|
| 39 |
+
effortLevel?: string
|
| 40 |
+
|
| 41 |
+
// Other fields we don't actively use but preserve
|
| 42 |
+
[key: string]: unknown
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
let configCache: TuiGlobalConfig | null = null
|
| 46 |
+
let configCacheTime = 0
|
| 47 |
+
const CONFIG_CACHE_TTL = 2000 // 2 seconds — balance freshness vs perf
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Get the global Claude config (~/.claude.json) from the Rust layer.
|
| 51 |
+
* Results are cached for CONFIG_CACHE_TTL ms to avoid excessive Rust IPC calls.
|
| 52 |
+
*/
|
| 53 |
+
export async function getTuiConfig(): Promise<TuiGlobalConfig> {
|
| 54 |
+
const now = Date.now()
|
| 55 |
+
if (configCache !== null && now - configCacheTime < CONFIG_CACHE_TTL) {
|
| 56 |
+
return configCache
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
try {
|
| 60 |
+
const result = await invoke<unknown>('get_claude_config')
|
| 61 |
+
if (result === null) {
|
| 62 |
+
configCache = {}
|
| 63 |
+
} else {
|
| 64 |
+
configCache = result as TuiGlobalConfig
|
| 65 |
+
}
|
| 66 |
+
configCacheTime = now
|
| 67 |
+
return configCache!
|
| 68 |
+
} catch (err) {
|
| 69 |
+
console.error('[config] failed to read ~/.claude.json:', err)
|
| 70 |
+
return {}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Save a partial patch to ~/.claude.json (deep-merged with existing content).
|
| 76 |
+
*/
|
| 77 |
+
export async function saveTuiConfigPatch(patch: Partial<TuiGlobalConfig>): Promise<void> {
|
| 78 |
+
await invoke('save_claude_config', { patch })
|
| 79 |
+
// Invalidate cache so next read picks up the change
|
| 80 |
+
configCache = null
|
| 81 |
+
configCacheTime = 0
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Clear the in-memory config cache.
|
| 86 |
+
* Call this after making config changes to force a fresh read.
|
| 87 |
+
*/
|
| 88 |
+
export function clearConfigCache(): void {
|
| 89 |
+
configCache = null
|
| 90 |
+
configCacheTime = 0
|
| 91 |
+
}
|
desktop/src/api/providerModels.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Direct provider model fetcher.
|
| 3 |
+
*
|
| 4 |
+
* Fetches available models directly from provider APIs, mirroring the logic
|
| 5 |
+
* in TUI's openRouterModels.ts / nvidiaClient.ts / opencodeClient.ts.
|
| 6 |
+
*
|
| 7 |
+
* This bypasses the cc-haha sidecar's /api/models endpoint entirely,
|
| 8 |
+
* reusing TUI's model acquisition approach in the desktop WebView.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { getTuiConfig } from './config'
|
| 12 |
+
import type { ModelInfo } from '../types/settings'
|
| 13 |
+
|
| 14 |
+
// ─── Type ────────────────────────────────────────────────────────────────────
|
| 15 |
+
|
| 16 |
+
type FetchResult = {
|
| 17 |
+
models: ModelInfo[]
|
| 18 |
+
provider: { id: string; name: string } | null
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// ─── Cache ───────────────────────────────────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
let modelCache: { key: string; models: ModelInfo[] } | null = null
|
| 24 |
+
let cacheTime = 0
|
| 25 |
+
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
| 26 |
+
|
| 27 |
+
export function clearProviderModelCache(): void {
|
| 28 |
+
modelCache = null
|
| 29 |
+
cacheTime = 0
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// ─── Main entry ──────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
export async function fetchProviderModels(): Promise<FetchResult> {
|
| 35 |
+
const config = await getTuiConfig()
|
| 36 |
+
const authProvider = (config.authProvider as string | undefined) || null
|
| 37 |
+
|
| 38 |
+
const cacheKey = authProvider ?? '__default__'
|
| 39 |
+
if (modelCache && modelCache.key === cacheKey && Date.now() - cacheTime < CACHE_TTL) {
|
| 40 |
+
return { models: modelCache.models, provider: null }
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
let models: ModelInfo[] = []
|
| 44 |
+
let providerInfo: { id: string; name: string } | null = null
|
| 45 |
+
|
| 46 |
+
switch (authProvider) {
|
| 47 |
+
case 'openrouter': {
|
| 48 |
+
const apiKey = config.openRouterApiKey as string | undefined
|
| 49 |
+
models = await fetchOpenRouterModels(apiKey)
|
| 50 |
+
providerInfo = models.length > 0
|
| 51 |
+
? { id: 'cli-openrouter', name: 'OpenRouter' }
|
| 52 |
+
: null
|
| 53 |
+
break
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
case 'nvidia': {
|
| 57 |
+
const apiKey = config.nvidiaApiKey as string | undefined
|
| 58 |
+
const baseUrl = config.nvidiaBaseUrl as string | undefined
|
| 59 |
+
models = await fetchNvidiaModels(apiKey, baseUrl || 'https://integrate.api.nvidia.com/v1')
|
| 60 |
+
providerInfo = models.length > 0
|
| 61 |
+
? { id: 'cli-nvidia', name: 'NVIDIA' }
|
| 62 |
+
: null
|
| 63 |
+
break
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
case 'opencode': {
|
| 67 |
+
models = await fetchOpencodeModels()
|
| 68 |
+
providerInfo = models.length > 0
|
| 69 |
+
? { id: 'cli-opencode', name: 'OpenCode Zen' }
|
| 70 |
+
: null
|
| 71 |
+
break
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
case 'openai':
|
| 75 |
+
// OpenAI models are best fetched via the official provider API.
|
| 76 |
+
// For now, the static catalog from modelCatalog.ts is used.
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
case 'local': {
|
| 80 |
+
const modelName = config.localModelName as string | undefined
|
| 81 |
+
if (modelName) {
|
| 82 |
+
models = [{ id: modelName, name: modelName, description: 'Local model', context: '' }]
|
| 83 |
+
}
|
| 84 |
+
providerInfo = { id: 'cli-local', name: 'Local' }
|
| 85 |
+
break
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
default:
|
| 89 |
+
// firstParty / anthropic — no external fetch, use static defaults
|
| 90 |
+
break
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
modelCache = { key: cacheKey, models }
|
| 94 |
+
cacheTime = Date.now()
|
| 95 |
+
|
| 96 |
+
return { models, provider: providerInfo }
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// ─── OpenRouter ──────────────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
let orCache: ModelInfo[] | null = null
|
| 102 |
+
let orCacheTime = 0
|
| 103 |
+
const OR_CACHE_TTL = 5 * 60 * 1000
|
| 104 |
+
|
| 105 |
+
async function fetchOpenRouterModels(apiKey?: string | null): Promise<ModelInfo[]> {
|
| 106 |
+
if (orCache && Date.now() - orCacheTime < OR_CACHE_TTL) {
|
| 107 |
+
return orCache
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (!apiKey) {
|
| 111 |
+
orCache = []
|
| 112 |
+
orCacheTime = Date.now()
|
| 113 |
+
return []
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
try {
|
| 117 |
+
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
| 118 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 119 |
+
signal: AbortSignal.timeout(20_000),
|
| 120 |
+
})
|
| 121 |
+
if (!res.ok) {
|
| 122 |
+
orCache = []
|
| 123 |
+
return []
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const data = (await res.json()) as { data?: Array<{ id: string; name: string; description?: string; context_length?: number }> }
|
| 127 |
+
const models: ModelInfo[] = (data.data || [])
|
| 128 |
+
.filter((m) => {
|
| 129 |
+
const id = m.id.toLowerCase()
|
| 130 |
+
return id.includes('/') && !id.startsWith('router') && !id.startsWith('free') && !id.startsWith('aggregat')
|
| 131 |
+
})
|
| 132 |
+
.map((m) => ({
|
| 133 |
+
id: m.id,
|
| 134 |
+
name: m.name || m.id,
|
| 135 |
+
description: (m.description || '').length > 100 ? m.description!.slice(0, 97) + '...' : (m.description || ''),
|
| 136 |
+
context: String(m.context_length || ''),
|
| 137 |
+
}))
|
| 138 |
+
|
| 139 |
+
orCache = models
|
| 140 |
+
orCacheTime = Date.now()
|
| 141 |
+
return models
|
| 142 |
+
} catch {
|
| 143 |
+
orCache = orCache || []
|
| 144 |
+
return orCache
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ─── NVIDIA NIM ──────────────────────────────────────────────────────────────
|
| 149 |
+
|
| 150 |
+
let nvCache: ModelInfo[] | null = null
|
| 151 |
+
let nvCacheTime = 0
|
| 152 |
+
|
| 153 |
+
async function fetchNvidiaModels(apiKey?: string | null, baseUrl?: string): Promise<ModelInfo[]> {
|
| 154 |
+
if (nvCache && Date.now() - nvCacheTime < OR_CACHE_TTL) {
|
| 155 |
+
return nvCache
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if (!apiKey) {
|
| 159 |
+
nvCache = []
|
| 160 |
+
nvCacheTime = Date.now()
|
| 161 |
+
return []
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
const normalized = (baseUrl || 'https://integrate.api.nvidia.com/v1').replace(/\/$/, '')
|
| 165 |
+
const modelsUrl = normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`
|
| 166 |
+
|
| 167 |
+
try {
|
| 168 |
+
const res = await fetch(modelsUrl, {
|
| 169 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 170 |
+
signal: AbortSignal.timeout(20_000),
|
| 171 |
+
})
|
| 172 |
+
if (!res.ok) {
|
| 173 |
+
nvCache = []
|
| 174 |
+
return []
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const json = (await res.json()) as { data?: Array<{ id: string }> }
|
| 178 |
+
if (json.data && Array.isArray(json.data)) {
|
| 179 |
+
const models: ModelInfo[] = json.data.map((m) => ({
|
| 180 |
+
id: m.id,
|
| 181 |
+
name: m.id,
|
| 182 |
+
description: 'NVIDIA NIM model',
|
| 183 |
+
context: '',
|
| 184 |
+
}))
|
| 185 |
+
nvCache = models
|
| 186 |
+
nvCacheTime = Date.now()
|
| 187 |
+
return models
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
nvCache = []
|
| 191 |
+
return []
|
| 192 |
+
} catch {
|
| 193 |
+
nvCache = nvCache || []
|
| 194 |
+
return nvCache
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// ─── OpenCode Zen ────────────────────────────────────────────────────────────
|
| 199 |
+
|
| 200 |
+
export async function fetchOpencodeModels(): Promise<ModelInfo[]> {
|
| 201 |
+
try {
|
| 202 |
+
const res = await fetch('https://models.dev/api.json', {
|
| 203 |
+
signal: AbortSignal.timeout(15_000),
|
| 204 |
+
})
|
| 205 |
+
if (!res.ok) return []
|
| 206 |
+
|
| 207 |
+
const data = (await res.json()) as {
|
| 208 |
+
opencode?: { models?: Record<string, { name?: string; status?: string; cost?: { input?: number; output?: number } }> }
|
| 209 |
+
}
|
| 210 |
+
const opencodeModels = data?.opencode?.models || {}
|
| 211 |
+
const models: ModelInfo[] = []
|
| 212 |
+
|
| 213 |
+
for (const [modelId, cfg] of Object.entries(opencodeModels)) {
|
| 214 |
+
if (cfg.status === 'deprecated') continue
|
| 215 |
+
const isFree = cfg.cost?.input === 0 && cfg.cost?.output === 0
|
| 216 |
+
models.push({
|
| 217 |
+
id: modelId,
|
| 218 |
+
name: cfg.name || modelId,
|
| 219 |
+
description: isFree ? 'Free model' : 'Paid model',
|
| 220 |
+
context: '',
|
| 221 |
+
})
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return models
|
| 225 |
+
} catch {
|
| 226 |
+
return []
|
| 227 |
+
}
|
| 228 |
+
}
|
desktop/src/lib/modelOptions.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Desktop model options utility.
|
| 3 |
+
*
|
| 4 |
+
* Mirrors key logic from TUI's src/utils/model/modelOptions.ts
|
| 5 |
+
* but adapted for the desktop React context.
|
| 6 |
+
*
|
| 7 |
+
* Core value: provider-tier-aware model filtering (Max gets Opus,
|
| 8 |
+
* Pro gets Sonnet, etc.) and environment-variable overrides that
|
| 9 |
+
* TUI supports but the cc-haha /api/models endpoint doesn't expose.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import type { ModelInfo } from '../types/settings'
|
| 13 |
+
|
| 14 |
+
export type ModelOption = {
|
| 15 |
+
value: string | null // null = default
|
| 16 |
+
label: string
|
| 17 |
+
description: string
|
| 18 |
+
descriptionForModel?: string
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** Determine the effective auth provider from ~/.claude.json config. */
|
| 22 |
+
export async function getConfigAuthProvider(): Promise<string | null> {
|
| 23 |
+
const { invoke } = await import('@tauri-apps/api/core')
|
| 24 |
+
try {
|
| 25 |
+
const config = await invoke<Record<string, unknown>>('get_claude_config')
|
| 26 |
+
return (config?.authProvider as string | null) ?? null
|
| 27 |
+
} catch {
|
| 28 |
+
return null
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Provider-specific model strings (simplified from TUI's modelStrings.ts)
|
| 33 |
+
// Desktop uses these for display labels when the full TUI module isn't loaded.
|
| 34 |
+
export const DESKTOP_MODEL_STRINGS = {
|
| 35 |
+
opus46: 'claude-opus-4-6-20250514',
|
| 36 |
+
sonnet46: 'claude-sonnet-4-6-20250608',
|
| 37 |
+
haiku45: 'claude-haiku-4-5-20250522',
|
| 38 |
+
opus45: 'claude-opus-4-5-20241022',
|
| 39 |
+
sonnet45: 'claude-sonnet-4-5-20241022',
|
| 40 |
+
haiku35: 'claude-haiku-3-5-20241022',
|
| 41 |
+
} as const
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Build ModelOption[] for a given provider and subscription tier.
|
| 45 |
+
* This mirrors the logic in TUI's getModelOptions() but without the
|
| 46 |
+
* full TUI bootstrap/graphbook dependency tree.
|
| 47 |
+
*
|
| 48 |
+
* @param provider - The active API provider from config
|
| 49 |
+
* @param isSubscriber - Whether the user has a claude.ai subscription
|
| 50 |
+
* @param subscriptionType - 'max' | 'pro' | 'team' | null
|
| 51 |
+
* @param fastMode - Whether fast mode is enabled
|
| 52 |
+
* @param availableModels - Existing model list from cc-haha (supplemental)
|
| 53 |
+
*/
|
| 54 |
+
export function buildModelOptions(
|
| 55 |
+
provider: string | null,
|
| 56 |
+
_isSubscriber: boolean,
|
| 57 |
+
subscriptionType: string | null,
|
| 58 |
+
fastMode: boolean,
|
| 59 |
+
availableModels: ModelInfo[],
|
| 60 |
+
): ModelOption[] {
|
| 61 |
+
const opts: ModelOption[] = []
|
| 62 |
+
|
| 63 |
+
// Default always first
|
| 64 |
+
opts.push({
|
| 65 |
+
value: null,
|
| 66 |
+
label: 'Default (recommended)',
|
| 67 |
+
description: getDefaultDescription(subscriptionType),
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
if (!provider) {
|
| 71 |
+
return opts
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const is3P = provider !== 'firstParty'
|
| 75 |
+
|
| 76 |
+
switch (provider) {
|
| 77 |
+
case 'firstParty': {
|
| 78 |
+
// Sonnet 4.6 is default for PAYG / non-subscriber
|
| 79 |
+
opts.push(makeSonnet46(is3P))
|
| 80 |
+
// Sonnet 1M if available
|
| 81 |
+
opts.push(makeSonnet46_1M(is3P))
|
| 82 |
+
// Opus 4.6 alternatives
|
| 83 |
+
if (isOpus1mMergeEnabled(_isSubscriber, subscriptionType)) {
|
| 84 |
+
opts.push(makeOpus46_1M(fastMode, is3P))
|
| 85 |
+
} else {
|
| 86 |
+
opts.push(makeOpus46(fastMode, is3P))
|
| 87 |
+
}
|
| 88 |
+
// Haiku
|
| 89 |
+
opts.push(makeHaiku45(is3P))
|
| 90 |
+
break
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
case 'openai': {
|
| 94 |
+
// Map to GPT models for display
|
| 95 |
+
opts.push({ value: 'gpt-5.4', label: 'GPT-5.4', description: 'GPT-5.4 · Recommended for most coding tasks' })
|
| 96 |
+
opts.push({ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'GPT-5.4 Mini · Fastest OpenAI option' })
|
| 97 |
+
break
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
case 'opencode': {
|
| 101 |
+
// OpenCode models - use available list or fall back to defaults
|
| 102 |
+
if (availableModels.length > 0) {
|
| 103 |
+
for (const m of availableModels) {
|
| 104 |
+
opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'OpenCode model' })
|
| 105 |
+
}
|
| 106 |
+
} else {
|
| 107 |
+
// Fallback defaults from DESKTOP_MODEL_STRINGS
|
| 108 |
+
opts.push({ value: 'big-pickle', label: 'Big Pickle', description: '旗舰模型,限时免费,适合复杂任务' })
|
| 109 |
+
opts.push({ value: 'gpt-5-nano', label: 'GPT 5 Nano', description: '永久免费,轻量快速,隐私安全' })
|
| 110 |
+
}
|
| 111 |
+
break
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
case 'nvidia': {
|
| 115 |
+
if (availableModels.length > 0) {
|
| 116 |
+
for (const m of availableModels) {
|
| 117 |
+
opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'NVIDIA NIM model' })
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
break
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
case 'local': {
|
| 124 |
+
// Local models come from availableModels
|
| 125 |
+
for (const m of availableModels) {
|
| 126 |
+
opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'Local model' })
|
| 127 |
+
}
|
| 128 |
+
break
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
case 'openrouter': {
|
| 132 |
+
if (availableModels.length > 0) {
|
| 133 |
+
for (const m of availableModels) {
|
| 134 |
+
opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'OpenRouter model' })
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
break
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
default:
|
| 141 |
+
// For unknown providers, supplement with availableModels
|
| 142 |
+
for (const m of availableModels) {
|
| 143 |
+
opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'Model' })
|
| 144 |
+
}
|
| 145 |
+
break
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
return opts
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function getDefaultDescription(subscriptionType: string | null): string {
|
| 152 |
+
if (subscriptionType === 'max') return 'Opus 4.6 · Most capable for complex work'
|
| 153 |
+
if (subscriptionType === 'pro') return 'Sonnet 4.6 · Best for everyday tasks'
|
| 154 |
+
return 'Sonnet 4.6 · Best for everyday tasks'
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function isOpus1mMergeEnabled(_isSubscriber: boolean, subscriptionType: string | null): boolean {
|
| 158 |
+
// Only enable 1M for Max/Team Premium subscribers on firstParty
|
| 159 |
+
if (subscriptionType === 'max' || subscriptionType === 'team') return true
|
| 160 |
+
return false
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function makeSonnet46(is3P: boolean): ModelOption {
|
| 164 |
+
return {
|
| 165 |
+
value: 'sonnet',
|
| 166 |
+
label: 'Sonnet',
|
| 167 |
+
description: `Sonnet 4.6 · Best for everyday tasks${is3P ? '' : ' · $3 / M input, $15 / M output'}`,
|
| 168 |
+
descriptionForModel: 'Sonnet 4.6 - best for everyday tasks',
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function makeSonnet46_1M(is3P: boolean): ModelOption {
|
| 173 |
+
return {
|
| 174 |
+
value: 'sonnet[1m]',
|
| 175 |
+
label: 'Sonnet (1M context)',
|
| 176 |
+
description: `Sonnet 4.6 with 1M context window${is3P ? '' : ' · $3 / M input, $15 / M output'}`,
|
| 177 |
+
descriptionForModel: 'Sonnet 4.6 with 1M context window - for long sessions',
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function makeOpus46(fastMode: boolean, is3P: boolean): ModelOption {
|
| 182 |
+
const suffix = fastMode && !is3P ? ' · ⚡ $7.50 / M input, $37.50 / M output' : ''
|
| 183 |
+
return {
|
| 184 |
+
value: 'opus',
|
| 185 |
+
label: 'Opus',
|
| 186 |
+
description: `Opus 4.6 · Most capable for complex work${suffix}`,
|
| 187 |
+
descriptionForModel: 'Opus 4.6 - most capable for complex work',
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function makeOpus46_1M(fastMode: boolean, is3P: boolean): ModelOption {
|
| 192 |
+
const suffix = fastMode && !is3P ? ' · ⚡ $7.50 / M input, $37.50 / M output' : ''
|
| 193 |
+
return {
|
| 194 |
+
value: 'opus[1m]',
|
| 195 |
+
label: 'Opus (1M context)',
|
| 196 |
+
description: `Opus 4.6 with 1M context${suffix}`,
|
| 197 |
+
descriptionForModel: 'Opus 4.6 with 1M context - most capable for complex work',
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function makeHaiku45(is3P: boolean): ModelOption {
|
| 202 |
+
return {
|
| 203 |
+
value: 'haiku',
|
| 204 |
+
label: 'Haiku',
|
| 205 |
+
description: `Haiku 4.5 · Fastest for quick answers${is3P ? '' : ' · $0.08 / M input, $0.40 / M output'}`,
|
| 206 |
+
descriptionForModel: 'Haiku 4.5 - fastest for quick answers',
|
| 207 |
+
}
|
| 208 |
+
}
|
desktop/src/lib/tuiConversation.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* TUI-style conversation module for desktop.
|
| 3 |
+
*
|
| 4 |
+
* Reuses TUI's approach of directly calling provider APIs via HTTP,
|
| 5 |
+
* without going through the cc-haha sidecar or CLI subprocess.
|
| 6 |
+
*
|
| 7 |
+
* Supports all provider types:
|
| 8 |
+
* - Anthropic protocol: firstParty, openrouter, openai, local
|
| 9 |
+
* - OpenAI protocol: nvidia (NIM), opencode
|
| 10 |
+
*
|
| 11 |
+
* Config is read from ~/.claude.json via getTuiConfig().
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { getTuiConfig } from '../api/config'
|
| 15 |
+
|
| 16 |
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
export type ConversationMessage = {
|
| 19 |
+
role: 'user' | 'assistant'
|
| 20 |
+
content: string
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export type ConversationStreamEvent =
|
| 24 |
+
| { type: 'text_delta'; text: string }
|
| 25 |
+
| { type: 'thinking_delta'; thinking: string }
|
| 26 |
+
| { type: 'content_block_start'; index: number; blockType: string }
|
| 27 |
+
| { type: 'content_block_stop'; index: number }
|
| 28 |
+
| { type: 'message_stop'; usage?: { input_tokens?: number; output_tokens?: number } }
|
| 29 |
+
| { type: 'error'; message: string }
|
| 30 |
+
| { type: 'done' }
|
| 31 |
+
|
| 32 |
+
export type ConversationResult = {
|
| 33 |
+
content: string
|
| 34 |
+
usage?: { input_tokens: number; output_tokens: number }
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// ─── Provider client factory ────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
type ProviderClientConfig = {
|
| 40 |
+
baseUrl: string
|
| 41 |
+
apiKey: string
|
| 42 |
+
model: string
|
| 43 |
+
/** Anthropic Messages API vs OpenAI Chat Completions */
|
| 44 |
+
protocol: 'anthropic' | 'openai'
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function resolveProviderConfig(model?: string): Promise<ProviderClientConfig> {
|
| 48 |
+
const config = await getTuiConfig()
|
| 49 |
+
const authProvider = (config.authProvider as string) || 'anthropic'
|
| 50 |
+
|
| 51 |
+
// Resolve API key and base URL based on provider
|
| 52 |
+
switch (authProvider) {
|
| 53 |
+
case 'openrouter': {
|
| 54 |
+
const apiKey = config.openRouterApiKey as string
|
| 55 |
+
if (!apiKey) throw new Error('OpenRouter API key not configured. Run /login first.')
|
| 56 |
+
return {
|
| 57 |
+
baseUrl: (process.env as Record<string, string>).OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
|
| 58 |
+
apiKey,
|
| 59 |
+
model: model || 'anthropic/claude-sonnet-4-6',
|
| 60 |
+
protocol: 'anthropic',
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
case 'nvidia': {
|
| 65 |
+
const apiKey = config.nvidiaApiKey as string
|
| 66 |
+
if (!apiKey) throw new Error('NVIDIA NIM API key not configured. Run /login first.')
|
| 67 |
+
const nvidiaBaseUrl = (config.nvidiaBaseUrl as string) || 'https://integrate.api.nvidia.com/v1'
|
| 68 |
+
return {
|
| 69 |
+
baseUrl: nvidiaBaseUrl,
|
| 70 |
+
apiKey,
|
| 71 |
+
model: model || (config.nvidiaModel as string) || 'nvidia/llama-3.1-nemotron-70b-instruct',
|
| 72 |
+
protocol: 'openai',
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
case 'opencode': {
|
| 77 |
+
const apiKey = config.openCodeApiKey as string
|
| 78 |
+
return {
|
| 79 |
+
baseUrl: 'https://opencode.ai/zen/v1',
|
| 80 |
+
apiKey: apiKey || 'public',
|
| 81 |
+
model: model || (config.openCodeModelName as string) || 'big-pickle',
|
| 82 |
+
protocol: 'openai',
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
case 'openai': {
|
| 87 |
+
const apiKey = (config.openAiApiKey as string) || (config.openAiAccessToken as string)
|
| 88 |
+
if (!apiKey) throw new Error('OpenAI API key not configured.')
|
| 89 |
+
return {
|
| 90 |
+
baseUrl: (process.env as Record<string, string>).OPENAI_BASE_URL || 'https://api.openai.com/v1',
|
| 91 |
+
apiKey,
|
| 92 |
+
model: model || 'gpt-5.4-codex',
|
| 93 |
+
protocol: 'openai',
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
case 'local': {
|
| 98 |
+
const localBaseUrl = config.localBaseUrl as string
|
| 99 |
+
if (!localBaseUrl) throw new Error('Local provider base URL not configured.')
|
| 100 |
+
return {
|
| 101 |
+
baseUrl: localBaseUrl,
|
| 102 |
+
apiKey: 'local-model',
|
| 103 |
+
model: model || (config.localModelName as string) || 'local-model',
|
| 104 |
+
protocol: 'openai', // Most local providers use OpenAI-compatible API
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
default: {
|
| 109 |
+
// First-party Anthropic
|
| 110 |
+
const apiKey = config.anthropicApiKey as string
|
| 111 |
+
if (!apiKey) throw new Error('Anthropic API key not configured.')
|
| 112 |
+
return {
|
| 113 |
+
baseUrl: (process.env as Record<string, string>).ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1',
|
| 114 |
+
apiKey,
|
| 115 |
+
model: model || 'claude-sonnet-4-6',
|
| 116 |
+
protocol: 'anthropic',
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// ─── HTTP helpers ────────────────────────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
function anthropicHeaders(apiKey: string): Record<string, string> {
|
| 125 |
+
return {
|
| 126 |
+
'Content-Type': 'application/json',
|
| 127 |
+
'x-api-key': apiKey,
|
| 128 |
+
'anthropic-version': '2023-06-01',
|
| 129 |
+
'User-Agent': 'versperclaw-desktop/1.0',
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function openaiHeaders(apiKey: string): Record<string, string> {
|
| 134 |
+
return {
|
| 135 |
+
'Content-Type': 'application/json',
|
| 136 |
+
Authorization: `Bearer ${apiKey}`,
|
| 137 |
+
'User-Agent': 'versperclaw-desktop/1.0',
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// ─── OpenAI protocol streaming ───────────────────────────────────────────────
|
| 142 |
+
|
| 143 |
+
async function* streamOpenAI(
|
| 144 |
+
config: ProviderClientConfig,
|
| 145 |
+
messages: ConversationMessage[],
|
| 146 |
+
signal?: AbortSignal,
|
| 147 |
+
): AsyncGenerator<ConversationStreamEvent> {
|
| 148 |
+
const body = {
|
| 149 |
+
model: config.model,
|
| 150 |
+
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
| 151 |
+
stream: true,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
let res: Response
|
| 155 |
+
try {
|
| 156 |
+
res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
| 157 |
+
method: 'POST',
|
| 158 |
+
headers: openaiHeaders(config.apiKey),
|
| 159 |
+
body: JSON.stringify(body),
|
| 160 |
+
signal,
|
| 161 |
+
})
|
| 162 |
+
} catch (err) {
|
| 163 |
+
const name = err instanceof TypeError ? err.name : ''
|
| 164 |
+
const msg = err instanceof Error ? err.message : String(err)
|
| 165 |
+
console.error('[tuiConversation] OpenAI fetch failed:', { name, message: msg, url: config.baseUrl, model: config.model })
|
| 166 |
+
yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
|
| 167 |
+
yield { type: 'done' }
|
| 168 |
+
return
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
if (!res.ok) {
|
| 172 |
+
const errBody = await res.text().catch(() => '')
|
| 173 |
+
yield { type: 'error', message: `API error ${res.status}: ${errBody}` }
|
| 174 |
+
yield { type: 'done' }
|
| 175 |
+
return
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
if (!res.body) {
|
| 179 |
+
yield { type: 'error', message: 'No response body' }
|
| 180 |
+
yield { type: 'done' }
|
| 181 |
+
return
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const decoder = new TextDecoder()
|
| 185 |
+
const reader = res.body.getReader()
|
| 186 |
+
let buffer = ''
|
| 187 |
+
|
| 188 |
+
yield { type: 'content_block_start', index: 0, blockType: 'text' }
|
| 189 |
+
|
| 190 |
+
while (true) {
|
| 191 |
+
if (signal?.aborted) break
|
| 192 |
+
const { done, value } = await reader.read()
|
| 193 |
+
if (done) break
|
| 194 |
+
|
| 195 |
+
buffer += decoder.decode(value, { stream: true })
|
| 196 |
+
const lines = buffer.split('\n')
|
| 197 |
+
buffer = lines.pop() || ''
|
| 198 |
+
|
| 199 |
+
for (const line of lines) {
|
| 200 |
+
if (!line.startsWith('data: ')) continue
|
| 201 |
+
const raw = line.slice(6).trim()
|
| 202 |
+
if (raw === '[DONE]') {
|
| 203 |
+
yield { type: 'content_block_stop', index: 0 }
|
| 204 |
+
yield { type: 'done' }
|
| 205 |
+
return
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
try {
|
| 209 |
+
const chunk = JSON.parse(raw)
|
| 210 |
+
const delta = chunk.choices?.[0]?.delta
|
| 211 |
+
if (!delta) continue
|
| 212 |
+
|
| 213 |
+
if (delta.content) {
|
| 214 |
+
yield { type: 'text_delta', text: delta.content }
|
| 215 |
+
}
|
| 216 |
+
if (delta.reasoning_content) {
|
| 217 |
+
yield { type: 'thinking_delta', thinking: delta.reasoning_content }
|
| 218 |
+
}
|
| 219 |
+
} catch {
|
| 220 |
+
// Skip malformed JSON
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
yield { type: 'content_block_stop', index: 0 }
|
| 226 |
+
yield { type: 'message_stop' }
|
| 227 |
+
yield { type: 'done' }
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
/**
|
| 233 |
+
* Send a conversation message and stream the response.
|
| 234 |
+
* Works for all provider types, bypassing the cc-haha sidecar.
|
| 235 |
+
*
|
| 236 |
+
* Usage:
|
| 237 |
+
* ```ts
|
| 238 |
+
* for await (const event of sendMessage([...messages])) {
|
| 239 |
+
* if (event.type === 'text_delta') { updateUI(event.text) }
|
| 240 |
+
* if (event.type === 'done') { break }
|
| 241 |
+
* }
|
| 242 |
+
* ```
|
| 243 |
+
*/
|
| 244 |
+
export async function* sendMessage(
|
| 245 |
+
messages: ConversationMessage[],
|
| 246 |
+
options?: {
|
| 247 |
+
model?: string
|
| 248 |
+
system?: string
|
| 249 |
+
signal?: AbortSignal
|
| 250 |
+
},
|
| 251 |
+
): AsyncGenerator<ConversationStreamEvent> {
|
| 252 |
+
const config = await resolveProviderConfig(options?.model)
|
| 253 |
+
|
| 254 |
+
if (config.protocol === 'anthropic') {
|
| 255 |
+
// Re-implement Anthropic streaming inline with proper yield support
|
| 256 |
+
const body: Record<string, unknown> = {
|
| 257 |
+
model: config.model,
|
| 258 |
+
messages,
|
| 259 |
+
max_tokens: 4096,
|
| 260 |
+
stream: true,
|
| 261 |
+
}
|
| 262 |
+
if (options?.system) body.system = options.system
|
| 263 |
+
|
| 264 |
+
let res: Response
|
| 265 |
+
try {
|
| 266 |
+
res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/messages`, {
|
| 267 |
+
method: 'POST',
|
| 268 |
+
headers: anthropicHeaders(config.apiKey),
|
| 269 |
+
body: JSON.stringify(body),
|
| 270 |
+
signal: options?.signal,
|
| 271 |
+
})
|
| 272 |
+
} catch (err) {
|
| 273 |
+
const name = err instanceof TypeError ? err.name : ''
|
| 274 |
+
const msg = err instanceof Error ? err.message : String(err)
|
| 275 |
+
console.error('[tuiConversation] Anthropic fetch failed:', { name, message: msg, url: config.baseUrl, model: config.model })
|
| 276 |
+
yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
|
| 277 |
+
yield { type: 'done' }
|
| 278 |
+
return
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
if (!res.ok) {
|
| 282 |
+
const errBody = await res.text().catch(() => '')
|
| 283 |
+
yield { type: 'error', message: `API error ${res.status}: ${errBody}` }
|
| 284 |
+
yield { type: 'done' }
|
| 285 |
+
return
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
if (!res.body) {
|
| 289 |
+
yield { type: 'error', message: 'No response body' }
|
| 290 |
+
yield { type: 'done' }
|
| 291 |
+
return
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const decoder = new TextDecoder()
|
| 295 |
+
const reader = res.body.getReader()
|
| 296 |
+
let buffer = ''
|
| 297 |
+
let usage: { input_tokens?: number; output_tokens?: number } | undefined
|
| 298 |
+
|
| 299 |
+
try {
|
| 300 |
+
while (true) {
|
| 301 |
+
if (options?.signal?.aborted) break
|
| 302 |
+
const { done, value } = await reader.read()
|
| 303 |
+
if (done) break
|
| 304 |
+
|
| 305 |
+
buffer += decoder.decode(value, { stream: true })
|
| 306 |
+
const lines = buffer.split('\n')
|
| 307 |
+
buffer = lines.pop() || ''
|
| 308 |
+
|
| 309 |
+
for (const line of lines) {
|
| 310 |
+
if (line.startsWith('data: ')) {
|
| 311 |
+
const raw = line.slice(6).trim()
|
| 312 |
+
if (!raw) continue
|
| 313 |
+
|
| 314 |
+
try {
|
| 315 |
+
const data = JSON.parse(raw)
|
| 316 |
+
|
| 317 |
+
if (data.type === 'content_block_start') {
|
| 318 |
+
yield {
|
| 319 |
+
type: 'content_block_start',
|
| 320 |
+
index: data.index ?? 0,
|
| 321 |
+
blockType: data.content_block?.type ?? 'text',
|
| 322 |
+
}
|
| 323 |
+
} else if (data.type === 'content_block_delta') {
|
| 324 |
+
const delta = data.delta
|
| 325 |
+
if (delta?.type === 'text_delta' && delta.text) {
|
| 326 |
+
yield { type: 'text_delta', text: delta.text }
|
| 327 |
+
} else if (delta?.type === 'thinking_delta' && delta.thinking) {
|
| 328 |
+
yield { type: 'thinking_delta', thinking: delta.thinking }
|
| 329 |
+
}
|
| 330 |
+
} else if (data.type === 'content_block_stop') {
|
| 331 |
+
yield { type: 'content_block_stop', index: data.index ?? 0 }
|
| 332 |
+
} else if (data.type === 'message_start' && data.message?.usage) {
|
| 333 |
+
usage = data.message.usage
|
| 334 |
+
} else if (data.type === 'message_delta' && data.delta?.stop_reason) {
|
| 335 |
+
if (data.usage) usage = data.usage
|
| 336 |
+
} else if (data.type === 'message_stop') {
|
| 337 |
+
yield { type: 'message_stop', usage }
|
| 338 |
+
yield { type: 'done' }
|
| 339 |
+
return
|
| 340 |
+
}
|
| 341 |
+
} catch {
|
| 342 |
+
// Skip malformed JSON
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
} finally {
|
| 348 |
+
reader.releaseLock()
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
if (!options?.signal?.aborted) {
|
| 352 |
+
yield { type: 'message_stop', usage }
|
| 353 |
+
yield { type: 'done' }
|
| 354 |
+
}
|
| 355 |
+
} else {
|
| 356 |
+
// OpenAI protocol
|
| 357 |
+
yield* streamOpenAI(config, messages, options?.signal)
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
/**
|
| 362 |
+
* Non-streaming send - returns the complete result.
|
| 363 |
+
*/
|
| 364 |
+
export async function sendMessageSync(
|
| 365 |
+
messages: ConversationMessage[],
|
| 366 |
+
options?: {
|
| 367 |
+
model?: string
|
| 368 |
+
system?: string
|
| 369 |
+
signal?: AbortSignal
|
| 370 |
+
},
|
| 371 |
+
): Promise<ConversationResult> {
|
| 372 |
+
const config = await resolveProviderConfig(options?.model)
|
| 373 |
+
|
| 374 |
+
if (config.protocol === 'anthropic') {
|
| 375 |
+
const body: Record<string, unknown> = {
|
| 376 |
+
model: config.model,
|
| 377 |
+
messages,
|
| 378 |
+
max_tokens: 4096,
|
| 379 |
+
}
|
| 380 |
+
if (options?.system) body.system = options.system
|
| 381 |
+
|
| 382 |
+
const res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/messages`, {
|
| 383 |
+
method: 'POST',
|
| 384 |
+
headers: anthropicHeaders(config.apiKey),
|
| 385 |
+
body: JSON.stringify(body),
|
| 386 |
+
signal: options?.signal,
|
| 387 |
+
})
|
| 388 |
+
|
| 389 |
+
if (!res.ok) {
|
| 390 |
+
const errBody = await res.text().catch(() => '')
|
| 391 |
+
throw new Error(`API error ${res.status}: ${errBody}`)
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
const data = (await res.json()) as {
|
| 395 |
+
content: Array<{ type: string; text?: string }>
|
| 396 |
+
usage?: { input_tokens: number; output_tokens: number }
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
const text = data.content
|
| 400 |
+
?.filter((b) => b.type === 'text')
|
| 401 |
+
.map((b) => b.text)
|
| 402 |
+
.join('\n') ?? ''
|
| 403 |
+
|
| 404 |
+
return { content: text, usage: data.usage }
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// OpenAI protocol
|
| 408 |
+
const body = {
|
| 409 |
+
model: config.model,
|
| 410 |
+
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
const res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
| 414 |
+
method: 'POST',
|
| 415 |
+
headers: openaiHeaders(config.apiKey),
|
| 416 |
+
body: JSON.stringify(body),
|
| 417 |
+
signal: options?.signal,
|
| 418 |
+
})
|
| 419 |
+
|
| 420 |
+
if (!res.ok) {
|
| 421 |
+
const errBody = await res.text().catch(() => '')
|
| 422 |
+
throw new Error(`API error ${res.status}: ${errBody}`)
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
const data = (await res.json()) as {
|
| 426 |
+
choices: Array<{ message: { content: string | null } }>
|
| 427 |
+
usage?: { prompt_tokens: number; completion_tokens: number }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
return {
|
| 431 |
+
content: data.choices?.[0]?.message?.content ?? '',
|
| 432 |
+
usage: data.usage ? { input_tokens: data.usage.prompt_tokens, output_tokens: data.usage.completion_tokens } : undefined,
|
| 433 |
+
}
|
| 434 |
+
}
|
desktop/src/stores/chatStore.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { useTabStore } from './tabStore'
|
|
| 9 |
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
| 10 |
import { notifyDesktop } from '../lib/desktopNotifications'
|
| 11 |
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
|
|
|
|
|
|
| 12 |
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
|
| 13 |
import type { ComposerAttachment } from '../lib/composerAttachments'
|
| 14 |
import type { MessageEntry } from '../types/session'
|
|
@@ -145,6 +147,7 @@ type ChatStore = {
|
|
| 145 |
clearComposerDraft: (sessionId: string) => void
|
| 146 |
clearMessages: (sessionId: string) => void
|
| 147 |
handleServerMessage: (sessionId: string, msg: ServerMessage) => void
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
|
|
@@ -612,6 +615,21 @@ function mergeSlashCommandUpdates(
|
|
| 612 |
return [...merged.values()]
|
| 613 |
}
|
| 614 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
async function fetchAndMapSessionHistory(sessionId: string) {
|
| 616 |
const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId)
|
| 617 |
const uiMessages = mapHistoryMessagesToUiMessages(messages)
|
|
@@ -663,13 +681,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|
| 663 |
get().handleServerMessage(sessionId, msg)
|
| 664 |
})
|
| 665 |
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
}
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
|
|
|
|
|
|
| 673 |
|
| 674 |
get().loadHistory(sessionId)
|
| 675 |
sessionsApi.getSlashCommands(sessionId)
|
|
@@ -811,7 +831,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|
| 811 |
return
|
| 812 |
}
|
| 813 |
|
| 814 |
-
|
|
|
|
| 815 |
},
|
| 816 |
|
| 817 |
respondToPermission: (sessionId, requestId, allowed, options) => {
|
|
@@ -852,29 +873,197 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|
| 852 |
},
|
| 853 |
|
| 854 |
stopGeneration: (sessionId) => {
|
| 855 |
-
|
|
|
|
|
|
|
| 856 |
if (pendingDeltaBySession.has(sessionId)) {
|
| 857 |
const text = consumePendingDelta(sessionId)
|
| 858 |
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
|
| 859 |
}
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
}
|
| 877 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
},
|
| 879 |
|
| 880 |
loadHistory: async (sessionId) => {
|
|
|
|
| 9 |
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
| 10 |
import { notifyDesktop } from '../lib/desktopNotifications'
|
| 11 |
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
| 12 |
+
import * as tuiConversation from '../lib/tuiConversation'
|
| 13 |
+
import type { ConversationMessage } from '../lib/tuiConversation'
|
| 14 |
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
|
| 15 |
import type { ComposerAttachment } from '../lib/composerAttachments'
|
| 16 |
import type { MessageEntry } from '../types/session'
|
|
|
|
| 147 |
clearComposerDraft: (sessionId: string) => void
|
| 148 |
clearMessages: (sessionId: string) => void
|
| 149 |
handleServerMessage: (sessionId: string, msg: ServerMessage) => void
|
| 150 |
+
startDirectConversation: (sessionId: string, content: string) => Promise<void>
|
| 151 |
}
|
| 152 |
|
| 153 |
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
|
|
|
|
| 615 |
return [...merged.values()]
|
| 616 |
}
|
| 617 |
|
| 618 |
+
function buildConversationHistory(messages: UIMessage[]): ConversationMessage[] {
|
| 619 |
+
const history: ConversationMessage[] = []
|
| 620 |
+
for (const msg of messages) {
|
| 621 |
+
if (msg.type === 'user_text') {
|
| 622 |
+
const record = msg as Extract<UIMessage, { type: 'user_text' }>
|
| 623 |
+
const text = (record as { modelContent?: string }).modelContent || record.content
|
| 624 |
+
history.push({ role: 'user', content: text })
|
| 625 |
+
} else if (msg.type === 'assistant_text') {
|
| 626 |
+
const text = (msg as Extract<UIMessage, { type: 'assistant_text' }>).content
|
| 627 |
+
history.push({ role: 'assistant', content: text })
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
return history
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
async function fetchAndMapSessionHistory(sessionId: string) {
|
| 634 |
const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId)
|
| 635 |
const uiMessages = mapHistoryMessagesToUiMessages(messages)
|
|
|
|
| 681 |
get().handleServerMessage(sessionId, msg)
|
| 682 |
})
|
| 683 |
|
| 684 |
+
// Direct API mode: no sidecar runtime config to send
|
| 685 |
+
// const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId]
|
| 686 |
+
// if (runtimeSelection) {
|
| 687 |
+
// wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection })
|
| 688 |
+
// }
|
| 689 |
+
// Direct API mode: no CLI subprocess to prewarm
|
| 690 |
+
// if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) {
|
| 691 |
+
// wsManager.send(sessionId, { type: 'prewarm_session' })
|
| 692 |
+
// }
|
| 693 |
|
| 694 |
get().loadHistory(sessionId)
|
| 695 |
sessionsApi.getSlashCommands(sessionId)
|
|
|
|
| 831 |
return
|
| 832 |
}
|
| 833 |
|
| 834 |
+
// Use direct provider API instead of sidecar CLI subprocess
|
| 835 |
+
get().startDirectConversation(sessionId, modelFacingContent)
|
| 836 |
},
|
| 837 |
|
| 838 |
respondToPermission: (sessionId, requestId, allowed, options) => {
|
|
|
|
| 873 |
},
|
| 874 |
|
| 875 |
stopGeneration: (sessionId) => {
|
| 876 |
+
// Direct transport: no WebSocket to send stop signal to.
|
| 877 |
+
// For now, aborts are not supported in direct mode.
|
| 878 |
+
// The session will complete the current response naturally.
|
| 879 |
if (pendingDeltaBySession.has(sessionId)) {
|
| 880 |
const text = consumePendingDelta(sessionId)
|
| 881 |
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
|
| 882 |
}
|
| 883 |
+
clearPendingTaskToolUseIds(sessionId)
|
| 884 |
+
clearPendingToolParentUseIds(sessionId)
|
| 885 |
+
},
|
| 886 |
+
|
| 887 |
+
startDirectConversation: async (sessionId, _content) => {
|
| 888 |
+
const session = get().sessions[sessionId]
|
| 889 |
+
if (!session) return
|
| 890 |
+
|
| 891 |
+
// Build conversation history from existing messages.
|
| 892 |
+
// The user's new message is already in session.messages (added by sendMessage's
|
| 893 |
+
// synchronous set()), so no need to push it again.
|
| 894 |
+
const history = buildConversationHistory(session.messages)
|
| 895 |
+
|
| 896 |
+
// Use runtime-selected model if available (set via desktop session settings)
|
| 897 |
+
const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId]
|
| 898 |
+
const sendOptions: { model?: string } = {}
|
| 899 |
+
if (runtimeSelection?.modelId) {
|
| 900 |
+
sendOptions.model = runtimeSelection.modelId
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
try {
|
| 904 |
+
const stream = tuiConversation.sendMessage(history, sendOptions)
|
| 905 |
+
for await (const event of stream) {
|
| 906 |
+
switch (event.type) {
|
| 907 |
+
case 'content_block_start': {
|
| 908 |
+
if (event.blockType === 'text') {
|
| 909 |
+
set((s) => ({
|
| 910 |
+
sessions: updateSessionIn(s.sessions, sessionId, () => ({
|
| 911 |
+
chatState: 'streaming',
|
| 912 |
+
apiRetry: null,
|
| 913 |
+
})),
|
| 914 |
+
}))
|
| 915 |
+
}
|
| 916 |
+
break
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
case 'text_delta': {
|
| 920 |
+
appendPendingDelta(sessionId, event.text)
|
| 921 |
+
if (!flushTimerBySession.has(sessionId)) {
|
| 922 |
+
const timer = setTimeout(() => {
|
| 923 |
+
const text = pendingDeltaBySession.get(sessionId) ?? ''
|
| 924 |
+
pendingDeltaBySession.delete(sessionId)
|
| 925 |
+
flushTimerBySession.delete(sessionId)
|
| 926 |
+
set((s) => ({
|
| 927 |
+
sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
|
| 928 |
+
streamingText: sess.streamingText + text,
|
| 929 |
+
})),
|
| 930 |
+
}))
|
| 931 |
+
}, 50)
|
| 932 |
+
flushTimerBySession.set(sessionId, timer)
|
| 933 |
+
}
|
| 934 |
+
break
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
case 'thinking_delta': {
|
| 938 |
+
set((s) => {
|
| 939 |
+
const sess = s.sessions[sessionId] ?? createDefaultSessionState()
|
| 940 |
+
const pendingText = `${sess.streamingText}${consumePendingDelta(sessionId)}`
|
| 941 |
+
let base = sess.messages
|
| 942 |
+
if (pendingText.trim()) {
|
| 943 |
+
base = appendAssistantTextMessage(base, pendingText, Date.now())
|
| 944 |
+
}
|
| 945 |
+
const last = base[base.length - 1]
|
| 946 |
+
if (last && last.type === 'thinking') {
|
| 947 |
+
const updated = [...base]
|
| 948 |
+
updated[updated.length - 1] = {
|
| 949 |
+
...last,
|
| 950 |
+
content: last.content + event.thinking,
|
| 951 |
+
}
|
| 952 |
+
return {
|
| 953 |
+
sessions: updateSessionIn(s.sessions, sessionId, () => ({
|
| 954 |
+
messages: updated,
|
| 955 |
+
chatState: 'thinking',
|
| 956 |
+
activeThinkingId: last.id,
|
| 957 |
+
streamingText: '',
|
| 958 |
+
})),
|
| 959 |
+
}
|
| 960 |
+
}
|
| 961 |
+
const id = nextId()
|
| 962 |
+
return {
|
| 963 |
+
sessions: updateSessionIn(s.sessions, sessionId, () => ({
|
| 964 |
+
messages: [
|
| 965 |
+
...base,
|
| 966 |
+
{ id, type: 'thinking', content: event.thinking, timestamp: Date.now() },
|
| 967 |
+
],
|
| 968 |
+
chatState: 'thinking',
|
| 969 |
+
activeThinkingId: id,
|
| 970 |
+
streamingText: '',
|
| 971 |
+
})),
|
| 972 |
+
}
|
| 973 |
+
})
|
| 974 |
+
break
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
case 'content_block_stop': {
|
| 978 |
+
const pendingText = consumePendingDelta(sessionId)
|
| 979 |
+
set((s) => ({
|
| 980 |
+
sessions: updateSessionIn(s.sessions, sessionId, (sess) => {
|
| 981 |
+
const accumulatedText = `${sess.streamingText}${pendingText}`
|
| 982 |
+
if (!accumulatedText.trim()) return {}
|
| 983 |
+
return {
|
| 984 |
+
messages: appendAssistantTextMessage(sess.messages, accumulatedText, Date.now()),
|
| 985 |
+
streamingText: '',
|
| 986 |
+
}
|
| 987 |
+
}),
|
| 988 |
+
}))
|
| 989 |
+
break
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
case 'message_stop': {
|
| 993 |
+
// Flush any remaining pending delta
|
| 994 |
+
const remainingText = consumePendingDelta(sessionId)
|
| 995 |
+
if (remainingText.trim()) {
|
| 996 |
+
set((s) => ({
|
| 997 |
+
sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
|
| 998 |
+
messages: appendAssistantTextMessage(
|
| 999 |
+
sess.messages,
|
| 1000 |
+
`${sess.streamingText}${remainingText}`,
|
| 1001 |
+
Date.now(),
|
| 1002 |
+
),
|
| 1003 |
+
streamingText: '',
|
| 1004 |
+
})),
|
| 1005 |
+
}))
|
| 1006 |
+
}
|
| 1007 |
+
const sess = get().sessions[sessionId]
|
| 1008 |
+
if (sess?.elapsedTimer) {
|
| 1009 |
+
clearInterval(sess.elapsedTimer)
|
| 1010 |
+
}
|
| 1011 |
+
set((s) => ({
|
| 1012 |
+
sessions: updateSessionIn(s.sessions, sessionId, () => ({
|
| 1013 |
+
chatState: 'idle',
|
| 1014 |
+
elapsedTimer: null,
|
| 1015 |
+
statusVerb: '',
|
| 1016 |
+
})),
|
| 1017 |
+
}))
|
| 1018 |
+
break
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
case 'done':
|
| 1022 |
+
break
|
| 1023 |
+
|
| 1024 |
+
case 'error': {
|
| 1025 |
+
set((s) => ({
|
| 1026 |
+
sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
|
| 1027 |
+
chatState: 'idle',
|
| 1028 |
+
messages: [
|
| 1029 |
+
...sess.messages,
|
| 1030 |
+
{
|
| 1031 |
+
id: nextId(),
|
| 1032 |
+
type: 'error',
|
| 1033 |
+
message: event.message,
|
| 1034 |
+
code: 'CLI_ERROR',
|
| 1035 |
+
timestamp: Date.now(),
|
| 1036 |
+
} as UIMessage,
|
| 1037 |
+
],
|
| 1038 |
+
})),
|
| 1039 |
+
}))
|
| 1040 |
+
break
|
| 1041 |
+
}
|
| 1042 |
+
}
|
| 1043 |
}
|
| 1044 |
+
} catch (err) {
|
| 1045 |
+
const message = err instanceof Error ? err.message : String(err)
|
| 1046 |
+
const sess = get().sessions[sessionId]
|
| 1047 |
+
if (sess?.elapsedTimer) {
|
| 1048 |
+
clearInterval(sess.elapsedTimer)
|
| 1049 |
+
}
|
| 1050 |
+
set((s) => ({
|
| 1051 |
+
sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
|
| 1052 |
+
chatState: 'idle',
|
| 1053 |
+
elapsedTimer: null,
|
| 1054 |
+
messages: [
|
| 1055 |
+
...sess.messages,
|
| 1056 |
+
{
|
| 1057 |
+
id: nextId(),
|
| 1058 |
+
type: 'error',
|
| 1059 |
+
message,
|
| 1060 |
+
code: 'CLI_ERROR',
|
| 1061 |
+
timestamp: Date.now(),
|
| 1062 |
+
} as UIMessage,
|
| 1063 |
+
],
|
| 1064 |
+
})),
|
| 1065 |
+
}))
|
| 1066 |
+
}
|
| 1067 |
},
|
| 1068 |
|
| 1069 |
loadHistory: async (sessionId) => {
|
desktop/src/stores/cliAuthStore.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { create } from 'zustand'
|
| 2 |
-
import {
|
|
|
|
|
|
|
| 3 |
|
| 4 |
type CliAuthStore = {
|
| 5 |
authProvider: AuthProvider | null
|
|
@@ -38,16 +46,19 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 38 |
fetchAuth: async () => {
|
| 39 |
set({ isLoading: true, error: null })
|
| 40 |
try {
|
| 41 |
-
const config = await
|
|
|
|
|
|
|
|
|
|
| 42 |
set({
|
| 43 |
-
authProvider
|
| 44 |
-
nvidiaApiKey: config.nvidiaApiKey
|
| 45 |
-
openRouterApiKey: config.openRouterApiKey
|
| 46 |
-
openAiApiKey: config.openAiApiKey || config.openAiAccessToken
|
| 47 |
-
openCodeApiKey: config.openCodeApiKey
|
| 48 |
-
openCodeModelName: config.openCodeModelName
|
| 49 |
-
localBaseUrl: config.localBaseUrl
|
| 50 |
-
localModelName: config.localModelName
|
| 51 |
isLoading: false,
|
| 52 |
})
|
| 53 |
} catch (err) {
|
|
@@ -58,8 +69,8 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 58 |
setAuthProvider: async (provider: AuthProvider) => {
|
| 59 |
set({ isLoading: true, error: null })
|
| 60 |
try {
|
| 61 |
-
await
|
| 62 |
-
|
| 63 |
set({ authProvider: provider, isLoading: false })
|
| 64 |
} catch (err) {
|
| 65 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
@@ -69,8 +80,11 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 69 |
saveNvidiaApiKey: async (apiKey: string) => {
|
| 70 |
set({ isLoading: true, error: null })
|
| 71 |
try {
|
| 72 |
-
await
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
set({ authProvider: 'nvidia', nvidiaApiKey: apiKey, isLoading: false })
|
| 75 |
} catch (err) {
|
| 76 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
@@ -80,8 +94,11 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 80 |
saveOpenRouterApiKey: async (apiKey: string) => {
|
| 81 |
set({ isLoading: true, error: null })
|
| 82 |
try {
|
| 83 |
-
await
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
| 85 |
set({ authProvider: 'openrouter', openRouterApiKey: apiKey, isLoading: false })
|
| 86 |
} catch (err) {
|
| 87 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
@@ -91,8 +108,12 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 91 |
saveOpenAIApiKey: async (apiKey: string) => {
|
| 92 |
set({ isLoading: true, error: null })
|
| 93 |
try {
|
| 94 |
-
await
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
set({ authProvider: 'openai', openAiApiKey: apiKey, isLoading: false })
|
| 97 |
} catch (err) {
|
| 98 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
@@ -102,10 +123,12 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 102 |
saveOpenCodeApiKey: async (apiKey: string, modelName?: string) => {
|
| 103 |
set({ isLoading: true, error: null })
|
| 104 |
try {
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
set({
|
| 110 |
authProvider: 'opencode',
|
| 111 |
openCodeApiKey: apiKey,
|
|
@@ -120,9 +143,18 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 120 |
saveLocalModelConfig: async (baseUrl: string, modelName: string) => {
|
| 121 |
set({ isLoading: true, error: null })
|
| 122 |
try {
|
| 123 |
-
await
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
} catch (err) {
|
| 127 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
| 128 |
}
|
|
@@ -131,11 +163,33 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
|
|
| 131 |
clearAuth: async () => {
|
| 132 |
set({ isLoading: true, error: null })
|
| 133 |
try {
|
| 134 |
-
|
| 135 |
-
await
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
} catch (err) {
|
| 138 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
| 139 |
}
|
| 140 |
},
|
| 141 |
-
}))
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Desktop auth store that reads/writes ~/.claude.json directly via Rust Tauri commands.
|
| 3 |
+
* Mirrors TUI's src/utils/auth.ts save* functions.
|
| 4 |
+
*
|
| 5 |
+
* This removes the dependency on cc-haha's /api/cli-auth HTTP endpoint.
|
| 6 |
+
*/
|
| 7 |
import { create } from 'zustand'
|
| 8 |
+
import { getTuiConfig, saveTuiConfigPatch, clearConfigCache } from '../api/config'
|
| 9 |
+
|
| 10 |
+
type AuthProvider = 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
|
| 11 |
|
| 12 |
type CliAuthStore = {
|
| 13 |
authProvider: AuthProvider | null
|
|
|
|
| 46 |
fetchAuth: async () => {
|
| 47 |
set({ isLoading: true, error: null })
|
| 48 |
try {
|
| 49 |
+
const config = await getTuiConfig()
|
| 50 |
+
// Map TUI's authProvider names to our store's names
|
| 51 |
+
// TUI uses 'anthropic' → our 'anthropic' means firstParty
|
| 52 |
+
const authProvider = config.authProvider ?? null
|
| 53 |
set({
|
| 54 |
+
authProvider,
|
| 55 |
+
nvidiaApiKey: config.nvidiaApiKey ?? null,
|
| 56 |
+
openRouterApiKey: config.openRouterApiKey ?? null,
|
| 57 |
+
openAiApiKey: (config.openAiApiKey || config.openAiAccessToken) ?? null,
|
| 58 |
+
openCodeApiKey: config.openCodeApiKey ?? null,
|
| 59 |
+
openCodeModelName: config.openCodeModelName ?? null,
|
| 60 |
+
localBaseUrl: config.localBaseUrl ?? null,
|
| 61 |
+
localModelName: config.localModelName ?? null,
|
| 62 |
isLoading: false,
|
| 63 |
})
|
| 64 |
} catch (err) {
|
|
|
|
| 69 |
setAuthProvider: async (provider: AuthProvider) => {
|
| 70 |
set({ isLoading: true, error: null })
|
| 71 |
try {
|
| 72 |
+
await saveTuiConfigPatch({ authProvider: provider })
|
| 73 |
+
clearConfigCache()
|
| 74 |
set({ authProvider: provider, isLoading: false })
|
| 75 |
} catch (err) {
|
| 76 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
|
|
| 80 |
saveNvidiaApiKey: async (apiKey: string) => {
|
| 81 |
set({ isLoading: true, error: null })
|
| 82 |
try {
|
| 83 |
+
await saveTuiConfigPatch({
|
| 84 |
+
authProvider: 'nvidia',
|
| 85 |
+
nvidiaApiKey: apiKey,
|
| 86 |
+
})
|
| 87 |
+
clearConfigCache()
|
| 88 |
set({ authProvider: 'nvidia', nvidiaApiKey: apiKey, isLoading: false })
|
| 89 |
} catch (err) {
|
| 90 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
|
|
| 94 |
saveOpenRouterApiKey: async (apiKey: string) => {
|
| 95 |
set({ isLoading: true, error: null })
|
| 96 |
try {
|
| 97 |
+
await saveTuiConfigPatch({
|
| 98 |
+
authProvider: 'openrouter',
|
| 99 |
+
openRouterApiKey: apiKey,
|
| 100 |
+
})
|
| 101 |
+
clearConfigCache()
|
| 102 |
set({ authProvider: 'openrouter', openRouterApiKey: apiKey, isLoading: false })
|
| 103 |
} catch (err) {
|
| 104 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
|
|
| 108 |
saveOpenAIApiKey: async (apiKey: string) => {
|
| 109 |
set({ isLoading: true, error: null })
|
| 110 |
try {
|
| 111 |
+
await saveTuiConfigPatch({
|
| 112 |
+
authProvider: 'openai',
|
| 113 |
+
openAiApiKey: apiKey,
|
| 114 |
+
openAiAccessToken: undefined, // Clear OAuth token when switching to API key
|
| 115 |
+
})
|
| 116 |
+
clearConfigCache()
|
| 117 |
set({ authProvider: 'openai', openAiApiKey: apiKey, isLoading: false })
|
| 118 |
} catch (err) {
|
| 119 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
|
|
|
| 123 |
saveOpenCodeApiKey: async (apiKey: string, modelName?: string) => {
|
| 124 |
set({ isLoading: true, error: null })
|
| 125 |
try {
|
| 126 |
+
await saveTuiConfigPatch({
|
| 127 |
+
authProvider: 'opencode',
|
| 128 |
+
openCodeApiKey: apiKey || undefined,
|
| 129 |
+
openCodeModelName: modelName || undefined,
|
| 130 |
+
})
|
| 131 |
+
clearConfigCache()
|
| 132 |
set({
|
| 133 |
authProvider: 'opencode',
|
| 134 |
openCodeApiKey: apiKey,
|
|
|
|
| 143 |
saveLocalModelConfig: async (baseUrl: string, modelName: string) => {
|
| 144 |
set({ isLoading: true, error: null })
|
| 145 |
try {
|
| 146 |
+
await saveTuiConfigPatch({
|
| 147 |
+
authProvider: 'local',
|
| 148 |
+
localBaseUrl: baseUrl,
|
| 149 |
+
localModelName: modelName,
|
| 150 |
+
})
|
| 151 |
+
clearConfigCache()
|
| 152 |
+
set({
|
| 153 |
+
authProvider: 'local',
|
| 154 |
+
localBaseUrl: baseUrl,
|
| 155 |
+
localModelName: modelName,
|
| 156 |
+
isLoading: false,
|
| 157 |
+
})
|
| 158 |
} catch (err) {
|
| 159 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
| 160 |
}
|
|
|
|
| 163 |
clearAuth: async () => {
|
| 164 |
set({ isLoading: true, error: null })
|
| 165 |
try {
|
| 166 |
+
// Clear by writing null values for all auth fields
|
| 167 |
+
await saveTuiConfigPatch({
|
| 168 |
+
authProvider: null,
|
| 169 |
+
primaryApiKey: undefined,
|
| 170 |
+
openAiApiKey: undefined,
|
| 171 |
+
openAiAccessToken: undefined,
|
| 172 |
+
openRouterApiKey: undefined,
|
| 173 |
+
nvidiaApiKey: undefined,
|
| 174 |
+
openCodeApiKey: undefined,
|
| 175 |
+
openCodeModelName: undefined,
|
| 176 |
+
localBaseUrl: undefined,
|
| 177 |
+
localModelName: undefined,
|
| 178 |
+
})
|
| 179 |
+
clearConfigCache()
|
| 180 |
+
set({
|
| 181 |
+
authProvider: null,
|
| 182 |
+
nvidiaApiKey: null,
|
| 183 |
+
openRouterApiKey: null,
|
| 184 |
+
openAiApiKey: null,
|
| 185 |
+
openCodeApiKey: null,
|
| 186 |
+
openCodeModelName: null,
|
| 187 |
+
localBaseUrl: null,
|
| 188 |
+
localModelName: null,
|
| 189 |
+
isLoading: false,
|
| 190 |
+
})
|
| 191 |
} catch (err) {
|
| 192 |
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
| 193 |
}
|
| 194 |
},
|
| 195 |
+
}))
|
desktop/src/stores/providerStore.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
| 11 |
OPENAI_OFFICIAL_PROVIDER_ID,
|
| 12 |
} from '../constants/openaiOfficialProvider'
|
|
|
|
| 13 |
import type {
|
| 14 |
SavedProvider,
|
| 15 |
CreateProviderInput,
|
|
@@ -48,6 +49,34 @@ function providerModelIds(provider: SavedProvider): Set<string> {
|
|
| 48 |
)
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
function resolveRuntimeRefreshSelection(
|
| 52 |
provider: SavedProvider,
|
| 53 |
activeId: string | null,
|
|
@@ -147,10 +176,11 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
|
| 147 |
activateProvider: async (id) => {
|
| 148 |
await providersApi.activate(id)
|
| 149 |
await get().fetchProviders()
|
| 150 |
-
//
|
| 151 |
-
//
|
| 152 |
const settings = useSettingsStore.getState()
|
| 153 |
if (id === OPENAI_OFFICIAL_PROVIDER_ID) {
|
|
|
|
| 154 |
await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID)
|
| 155 |
await settings.fetchAll()
|
| 156 |
return
|
|
@@ -158,10 +188,33 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
|
| 158 |
|
| 159 |
const provider = get().providers.find((p) => p.id === id)
|
| 160 |
if (!provider) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
await settings.setModel(provider.models.main)
|
| 162 |
await settings.fetchAll()
|
| 163 |
},
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
activateOfficial: async () => {
|
| 166 |
await providersApi.activateOfficial()
|
| 167 |
await get().fetchProviders()
|
|
|
|
| 10 |
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
| 11 |
OPENAI_OFFICIAL_PROVIDER_ID,
|
| 12 |
} from '../constants/openaiOfficialProvider'
|
| 13 |
+
import { saveTuiConfigPatch, clearConfigCache } from '../api/config'
|
| 14 |
import type {
|
| 15 |
SavedProvider,
|
| 16 |
CreateProviderInput,
|
|
|
|
| 49 |
)
|
| 50 |
}
|
| 51 |
|
| 52 |
+
/** Map a sidecar SavedProvider to TUI ~/.claude.json authProvider fields */
|
| 53 |
+
type TuiProviderMapping = { authProvider: string; apiKeyField: string; apiKey?: string } | null
|
| 54 |
+
function mapSidecarToTuiProvider(provider: SavedProvider): TuiProviderMapping {
|
| 55 |
+
const apiFormat = provider.apiFormat || 'anthropic'
|
| 56 |
+
switch (apiFormat) {
|
| 57 |
+
case 'anthropic':
|
| 58 |
+
return { authProvider: 'anthropic', apiKeyField: 'anthropicApiKey', apiKey: provider.apiKey }
|
| 59 |
+
case 'openai': {
|
| 60 |
+
// Try to infer specific provider from base URL or name
|
| 61 |
+
const baseUrl = (provider.baseUrl || '').toLowerCase()
|
| 62 |
+
const name = (provider.name || '').toLowerCase()
|
| 63 |
+
if (baseUrl.includes('nvidia') || name.includes('nvidia')) {
|
| 64 |
+
return { authProvider: 'nvidia', apiKeyField: 'nvidiaApiKey', apiKey: provider.apiKey }
|
| 65 |
+
}
|
| 66 |
+
if (baseUrl.includes('openrouter') || name.includes('openrouter')) {
|
| 67 |
+
return { authProvider: 'openrouter', apiKeyField: 'openRouterApiKey', apiKey: provider.apiKey }
|
| 68 |
+
}
|
| 69 |
+
if (baseUrl.includes('opencode') || name.includes('opencode')) {
|
| 70 |
+
return { authProvider: 'opencode', apiKeyField: 'openCodeApiKey', apiKey: provider.apiKey }
|
| 71 |
+
}
|
| 72 |
+
// Generic OpenAI-compatible
|
| 73 |
+
return { authProvider: 'openai', apiKeyField: 'openAiApiKey', apiKey: provider.apiKey }
|
| 74 |
+
}
|
| 75 |
+
default:
|
| 76 |
+
return null
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
function resolveRuntimeRefreshSelection(
|
| 81 |
provider: SavedProvider,
|
| 82 |
activeId: string | null,
|
|
|
|
| 176 |
activateProvider: async (id) => {
|
| 177 |
await providersApi.activate(id)
|
| 178 |
await get().fetchProviders()
|
| 179 |
+
// 同步 provider 信息到 ~/.claude.json,这样 fetchProviderModels() / tuiConversation
|
| 180 |
+
// 可以直接通过 TUI 方式来读取配置,无需依赖 sidecar。
|
| 181 |
const settings = useSettingsStore.getState()
|
| 182 |
if (id === OPENAI_OFFICIAL_PROVIDER_ID) {
|
| 183 |
+
await saveTuiConfigPatch({ authProvider: 'anthropic' })
|
| 184 |
await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID)
|
| 185 |
await settings.fetchAll()
|
| 186 |
return
|
|
|
|
| 188 |
|
| 189 |
const provider = get().providers.find((p) => p.id === id)
|
| 190 |
if (!provider) return
|
| 191 |
+
|
| 192 |
+
// Map sidecar apiFormat to TUI authProvider
|
| 193 |
+
const tuiProvider = mapSidecarToTuiProvider(provider)
|
| 194 |
+
const patch: Record<string, unknown> = {}
|
| 195 |
+
if (tuiProvider) {
|
| 196 |
+
patch.authProvider = tuiProvider.authProvider
|
| 197 |
+
if (tuiProvider.apiKey) patch[tuiProvider.apiKeyField] = tuiProvider.apiKey
|
| 198 |
+
}
|
| 199 |
+
patch.model = provider.models.main
|
| 200 |
+
await saveTuiConfigPatch(patch)
|
| 201 |
+
clearConfigCache()
|
| 202 |
+
|
| 203 |
await settings.setModel(provider.models.main)
|
| 204 |
await settings.fetchAll()
|
| 205 |
},
|
| 206 |
|
| 207 |
+
activateOfficial: async () => {
|
| 208 |
+
await providersApi.activateOfficial()
|
| 209 |
+
await get().fetchProviders()
|
| 210 |
+
// 同步回 Anthropic first-party
|
| 211 |
+
await saveTuiConfigPatch({ authProvider: 'anthropic' })
|
| 212 |
+
clearConfigCache()
|
| 213 |
+
const settings = useSettingsStore.getState()
|
| 214 |
+
await settings.setModel(OFFICIAL_DEFAULT_MODEL_ID)
|
| 215 |
+
await settings.fetchAll()
|
| 216 |
+
},
|
| 217 |
+
|
| 218 |
activateOfficial: async () => {
|
| 219 |
await providersApi.activateOfficial()
|
| 220 |
await get().fetchProviders()
|
desktop/src/stores/settingsStore.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { ApiError } from '../api/client'
|
|
| 3 |
import { settingsApi } from '../api/settings'
|
| 4 |
import { modelsApi } from '../api/models'
|
| 5 |
import { h5AccessApi } from '../api/h5Access'
|
|
|
|
|
|
|
| 6 |
import {
|
| 7 |
isThemeMode,
|
| 8 |
type AppMode,
|
|
@@ -170,23 +172,60 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 170 |
set({ isLoading: true, error: null })
|
| 171 |
try {
|
| 172 |
const previousH5Access = get().h5Access
|
| 173 |
-
const [
|
|
|
|
|
|
|
| 174 |
settingsApi.getPermissionMode(),
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
modelsApi.getEffort(),
|
| 178 |
settingsApi.getUser(),
|
| 179 |
loadH5AccessSettings(previousH5Access),
|
| 180 |
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
|
| 182 |
useUIStore.getState().setTheme(theme)
|
| 183 |
set({
|
| 184 |
permissionMode: mode,
|
| 185 |
-
availableModels
|
| 186 |
-
activeProviderId
|
| 187 |
-
activeProviderName
|
| 188 |
-
currentModel
|
| 189 |
-
effortLevel
|
| 190 |
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
|
| 191 |
theme,
|
| 192 |
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
|
@@ -225,16 +264,22 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 225 |
},
|
| 226 |
|
| 227 |
setModel: async (modelId) => {
|
| 228 |
-
await
|
| 229 |
-
const
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
},
|
| 232 |
|
| 233 |
setEffort: async (level) => {
|
| 234 |
const prev = get().effortLevel
|
| 235 |
set({ effortLevel: level })
|
| 236 |
try {
|
| 237 |
-
await
|
| 238 |
} catch {
|
| 239 |
set({ effortLevel: prev })
|
| 240 |
}
|
|
|
|
| 3 |
import { settingsApi } from '../api/settings'
|
| 4 |
import { modelsApi } from '../api/models'
|
| 5 |
import { h5AccessApi } from '../api/h5Access'
|
| 6 |
+
import { fetchProviderModels } from '../api/providerModels'
|
| 7 |
+
import { getTuiConfig, saveTuiConfigPatch } from '../api/config'
|
| 8 |
import {
|
| 9 |
isThemeMode,
|
| 10 |
type AppMode,
|
|
|
|
| 172 |
set({ isLoading: true, error: null })
|
| 173 |
try {
|
| 174 |
const previousH5Access = get().h5Access
|
| 175 |
+
const [directModels, { mode }, config, userSettings, h5AccessResult] = await Promise.all([
|
| 176 |
+
// Fetch models directly from provider APIs (TUI-style)
|
| 177 |
+
fetchProviderModels().catch(() => ({ models: [] as ModelInfo[], provider: null as { id: string; name: string } | null })),
|
| 178 |
settingsApi.getPermissionMode(),
|
| 179 |
+
// Read model + effort from ~/.claude.json (TUI-style), bypassing sidecar
|
| 180 |
+
getTuiConfig(),
|
|
|
|
| 181 |
settingsApi.getUser(),
|
| 182 |
loadH5AccessSettings(previousH5Access),
|
| 183 |
])
|
| 184 |
+
|
| 185 |
+
// Use direct models if available (TUI-style), otherwise fall back to sidecar
|
| 186 |
+
let availableModels: ModelInfo[]
|
| 187 |
+
let activeProviderId: string | null
|
| 188 |
+
let activeProviderName: string | null
|
| 189 |
+
|
| 190 |
+
if (directModels.models.length > 0) {
|
| 191 |
+
availableModels = directModels.models
|
| 192 |
+
activeProviderId = directModels.provider?.id ?? null
|
| 193 |
+
activeProviderName = directModels.provider?.name ?? null
|
| 194 |
+
} else {
|
| 195 |
+
const sidecarRes = await modelsApi.list().catch(() => null)
|
| 196 |
+
if (sidecarRes && sidecarRes.models.length > 0) {
|
| 197 |
+
availableModels = sidecarRes.models
|
| 198 |
+
activeProviderId = sidecarRes.provider?.id ?? null
|
| 199 |
+
activeProviderName = sidecarRes.provider?.name ?? null
|
| 200 |
+
} else {
|
| 201 |
+
availableModels = []
|
| 202 |
+
activeProviderId = null
|
| 203 |
+
activeProviderName = null
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// Derive currentModel from config's model field, matched against availableModels
|
| 208 |
+
const configModelId = config.model ?? null
|
| 209 |
+
const currentModel: ModelInfo | null = configModelId
|
| 210 |
+
? availableModels.find((m) => m.id === configModelId) ?? {
|
| 211 |
+
id: configModelId,
|
| 212 |
+
name: configModelId,
|
| 213 |
+
description: '',
|
| 214 |
+
context: '',
|
| 215 |
+
}
|
| 216 |
+
: null
|
| 217 |
+
const effortLevel: EffortLevel =
|
| 218 |
+
(config.effortLevel as EffortLevel) ?? 'medium'
|
| 219 |
+
|
| 220 |
const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
|
| 221 |
useUIStore.getState().setTheme(theme)
|
| 222 |
set({
|
| 223 |
permissionMode: mode,
|
| 224 |
+
availableModels,
|
| 225 |
+
activeProviderId,
|
| 226 |
+
activeProviderName,
|
| 227 |
+
currentModel,
|
| 228 |
+
effortLevel,
|
| 229 |
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
|
| 230 |
theme,
|
| 231 |
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
|
|
|
| 264 |
},
|
| 265 |
|
| 266 |
setModel: async (modelId) => {
|
| 267 |
+
await saveTuiConfigPatch({ model: modelId })
|
| 268 |
+
const availableModels = get().availableModels
|
| 269 |
+
const currentModel: ModelInfo | null = availableModels.find((m) => m.id === modelId) ?? {
|
| 270 |
+
id: modelId,
|
| 271 |
+
name: modelId,
|
| 272 |
+
description: '',
|
| 273 |
+
context: '',
|
| 274 |
+
}
|
| 275 |
+
set({ currentModel })
|
| 276 |
},
|
| 277 |
|
| 278 |
setEffort: async (level) => {
|
| 279 |
const prev = get().effortLevel
|
| 280 |
set({ effortLevel: level })
|
| 281 |
try {
|
| 282 |
+
await saveTuiConfigPatch({ effortLevel: level })
|
| 283 |
} catch {
|
| 284 |
set({ effortLevel: prev })
|
| 285 |
}
|
src/components/Messages.tsx
CHANGED
|
@@ -544,7 +544,7 @@ const MessagesImpl = ({
|
|
| 544 |
}, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]);
|
| 545 |
const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]);
|
| 546 |
|
| 547 |
-
useAutoTTS(renderableMessages);
|
| 548 |
|
| 549 |
// Divider insertion point: first renderableMessage whose uuid shares the
|
| 550 |
// 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
|
|
|
|
| 544 |
}, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]);
|
| 545 |
const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]);
|
| 546 |
|
| 547 |
+
useAutoTTS(renderableMessages, isLoading);
|
| 548 |
|
| 549 |
// Divider insertion point: first renderableMessage whose uuid shares the
|
| 550 |
// 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
|
src/hooks/useAutoTTS.ts
CHANGED
|
@@ -24,27 +24,47 @@ function resolveEdgeTTSVoice(language: string | undefined, explicitVoice: string
|
|
| 24 |
return 'en-US-JennyNeural'
|
| 25 |
}
|
| 26 |
|
| 27 |
-
export function useAutoTTS(messages: RenderableMessage[]): void {
|
| 28 |
const triggeredIdsRef = useRef<Set<string>>(new Set())
|
| 29 |
const pendingPlayRef = useRef<Promise<void> | null>(null)
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
useEffect(() => {
|
| 32 |
const settings = getInitialSettings()
|
| 33 |
if (!settings.voiceAutoTTS || !settings.voiceEnabled) return
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
for (const msg of messages) {
|
| 36 |
-
if (msg.type !== 'assistant') continue
|
| 37 |
if (triggeredIdsRef.current.has(msg.uuid)) continue
|
|
|
|
|
|
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
const content = msg.message.content[0]
|
| 40 |
if (content?.type !== 'text') continue
|
| 41 |
if (!content.text.trim()) continue
|
| 42 |
|
| 43 |
-
triggeredIdsRef.current.add(msg.uuid)
|
| 44 |
-
|
| 45 |
const text = content.text
|
| 46 |
-
const language = settings.voiceLanguage || settings.language
|
| 47 |
-
const voice = resolveEdgeTTSVoice(language, settings.voiceTTSVoice)
|
| 48 |
|
| 49 |
const run = async () => {
|
| 50 |
const result = await speakWithEdgeTTS(text, {
|
|
@@ -62,5 +82,5 @@ export function useAutoTTS(messages: RenderableMessage[]): void {
|
|
| 62 |
pendingPlayRef.current = run().catch(() => {})
|
| 63 |
}
|
| 64 |
}
|
| 65 |
-
}, [messages])
|
| 66 |
}
|
|
|
|
| 24 |
return 'en-US-JennyNeural'
|
| 25 |
}
|
| 26 |
|
| 27 |
+
export function useAutoTTS(messages: RenderableMessage[], isLoading?: boolean): void {
|
| 28 |
const triggeredIdsRef = useRef<Set<string>>(new Set())
|
| 29 |
const pendingPlayRef = useRef<Promise<void> | null>(null)
|
| 30 |
|
| 31 |
+
// Snapshot of message IDs present when the conversation first becomes "active".
|
| 32 |
+
// isLoading goes true on the first user submit and stays false on resume.
|
| 33 |
+
// By waiting for isLoading=true we ensure the snapshot captures the correct
|
| 34 |
+
// boundary: everything before it is history (skip), everything after is new.
|
| 35 |
+
const historyIdsRef = useRef<Set<string> | null>(null)
|
| 36 |
+
const wasLoadingRef = useRef<boolean>(false)
|
| 37 |
+
|
| 38 |
useEffect(() => {
|
| 39 |
const settings = getInitialSettings()
|
| 40 |
if (!settings.voiceAutoTTS || !settings.voiceEnabled) return
|
| 41 |
|
| 42 |
+
// Capture history snapshot on the transition from idle → active.
|
| 43 |
+
// This fires once: when the user first submits a message in a fresh REPL,
|
| 44 |
+
// or (importantly) never during a resume where isLoading stays false.
|
| 45 |
+
if (!historyIdsRef.current && !wasLoadingRef.current && isLoading) {
|
| 46 |
+
historyIdsRef.current = new Set(messages.map(m => m.uuid))
|
| 47 |
+
}
|
| 48 |
+
wasLoadingRef.current = isLoading ?? false
|
| 49 |
+
|
| 50 |
+
const voice = resolveEdgeTTSVoice(
|
| 51 |
+
settings.voiceLanguage || settings.language,
|
| 52 |
+
settings.voiceTTSVoice,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
for (const msg of messages) {
|
|
|
|
| 56 |
if (triggeredIdsRef.current.has(msg.uuid)) continue
|
| 57 |
+
// Skip messages that were present before the conversation became active.
|
| 58 |
+
if (historyIdsRef.current?.has(msg.uuid)) continue
|
| 59 |
|
| 60 |
+
triggeredIdsRef.current.add(msg.uuid)
|
| 61 |
+
|
| 62 |
+
if (msg.type !== 'assistant') continue
|
| 63 |
const content = msg.message.content[0]
|
| 64 |
if (content?.type !== 'text') continue
|
| 65 |
if (!content.text.trim()) continue
|
| 66 |
|
|
|
|
|
|
|
| 67 |
const text = content.text
|
|
|
|
|
|
|
| 68 |
|
| 69 |
const run = async () => {
|
| 70 |
const result = await speakWithEdgeTTS(text, {
|
|
|
|
| 82 |
pendingPlayRef.current = run().catch(() => {})
|
| 83 |
}
|
| 84 |
}
|
| 85 |
+
}, [messages, isLoading])
|
| 86 |
}
|