diff --git a/.gitignore b/.gitignore index 3129a4f..6a672c1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ node_modules !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* +/target + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..51d705d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3773 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accelerate-src" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415ed64958754dbe991900f3940677e6a7eefb4d7367afd70d642677b0c7d19d" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "candle-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecb245093b0f791b89d3420c3df9c6d49c60ab63ba54db896bf8a3baf486706" +dependencies = [ + "accelerate-src", + "byteorder", + "candle-metal-kernels", + "candle-ug", + "float8", + "gemm 0.19.0", + "half", + "libc", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "objc2-foundation", + "objc2-metal", + "rand", + "rand_distr", + "rayon", + "safetensors 0.8.0", + "thiserror 2.0.21", + "tokenizers", + "yoke 0.8.3", + "zerocopy", + "zip", +] + +[[package]] +name = "candle-metal-kernels" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "242e83c6acf639bb273c929d73c67a882bb4dd08a140f121096e19ba2f213d3e" +dependencies = [ + "block2", + "half", + "objc2", + "objc2-foundation", + "objc2-metal", + "once_cell", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "candle-nn" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa10b6ccc365b33210ce404fbf45e60d3e0bdac1004463cf1052e6ee1c1739a" +dependencies = [ + "accelerate-src", + "candle-core", + "candle-metal-kernels", + "half", + "libc", + "num-traits", + "objc2-metal", + "rayon", + "safetensors 0.8.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "candle-transformers" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcbbf7ff00ff6fe2af22b93600195917fe90e90ff48424a140d1a926c44b1c1" +dependencies = [ + "accelerate-src", + "byteorder", + "candle-core", + "candle-nn", + "fancy-regex", + "num-traits", + "rand", + "rayon", + "serde", + "serde_json", + "serde_plain", + "tracing", +] + +[[package]] +name = "candle-ug" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257411c33abf7d898a31ac20d80813dfe96ff09e23a73039e22ab293aea9b871" +dependencies = [ + "ug", + "ug-metal", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "float8" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" +dependencies = [ + "half", + "num-traits", + "rand", + "rand_distr", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gemm" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +dependencies = [ + "dyn-stack", + "gemm-c32 0.18.2", + "gemm-c64 0.18.2", + "gemm-common 0.18.2", + "gemm-f16 0.18.2", + "gemm-f32 0.18.2", + "gemm-f64 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32 0.19.0", + "gemm-c64 0.19.0", + "gemm-common 0.19.0", + "gemm-f16 0.19.0", + "gemm-f32 0.19.0", + "gemm-f64 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.21.5", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.22.3", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "gemm-f32 0.18.2", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "gemm-f32 0.19.0", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "thiserror 2.0.21", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "rand", + "rand_distr", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke 0.8.3", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke 0.8.3", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", + "stable_deref_trait", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation", + "objc2-image-io", + "objc2-metal", +] + +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + +[[package]] +name = "objc2-core-ml" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201b055e6acfa0f9f15568255d3f03ce2b54bc86d1814442dc69138e36813e18" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation", + "objc2-image-io", + "objc2-metal", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-image-io" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-vision" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc194758a2d5d7540b1ad283bfb9ca318ec608991892326e95b428230b2689b" +dependencies = [ + "block2", + "objc2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-media", + "objc2-core-ml", + "objc2-core-video", + "objc2-foundation", + "objc2-image-io", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.2", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pulp" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44560c11236a6130a46ce36c836a62936dc81ebf8c36a37947423571be0e55b6" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.21", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttree-ai" +version = "0.2.0" +dependencies = [ + "anyhow", + "candle-core", + "candle-transformers", + "futures-util", + "reqwest", + "serde", + "serde_json", + "tokio", + "ttree-core", + "url", +] + +[[package]] +name = "ttree-app" +version = "0.2.0" +dependencies = [ + "anyhow", + "global-hotkey", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "tokio", + "ttree-ai", + "ttree-core", + "ttree-ocr", + "ttree-platform-macos", + "ttree-storage", + "ttree-translate", +] + +[[package]] +name = "ttree-core" +version = "0.2.0" +dependencies = [ + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "ttree-ocr" +version = "0.2.0" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-ml", + "objc2-foundation", + "objc2-image-io", + "objc2-vision", + "serde", + "tempfile", +] + +[[package]] +name = "ttree-platform-macos" +version = "0.2.0" +dependencies = [ + "anyhow", + "auto-launch", + "core-foundation 0.10.1", + "global-hotkey", + "objc2", + "objc2-app-kit", + "objc2-core-graphics", + "objc2-foundation", + "security-framework", + "serde", + "serde_json", + "ttree-core", +] + +[[package]] +name = "ttree-storage" +version = "0.2.0" +dependencies = [ + "dirs 6.0.0", + "serde", + "serde_json", + "tempfile", + "ttree-core", + "ttree-platform-macos", +] + +[[package]] +name = "ttree-translate" +version = "0.2.0" +dependencies = [ + "futures-util", + "reqwest", + "serde", + "serde_json", + "tokio", + "ttree-core", + "urlencoding", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "ug" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b761acf8af3494640d826a8609e2265e19778fb43306c7f15379c78c9b05b0" +dependencies = [ + "gemm 0.18.2", + "half", + "libloading", + "memmap2", + "num", + "num-traits", + "num_cpus", + "rayon", + "safetensors 0.4.5", + "serde", + "thiserror 1.0.69", + "tracing", + "yoke 0.7.5", +] + +[[package]] +name = "ug-metal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7adf545a99a086d362efc739e7cf4317c18cbeda22706000fd434d70ea3d95" +dependencies = [ + "half", + "metal", + "objc", + "serde", + "thiserror 1.0.69", + "ug", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive 0.8.4", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke 0.8.3", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke 0.8.3", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa4bf66 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,117 @@ +[workspace] +resolver = "2" +members = [ + "crates/ttree-core", + "crates/ttree-storage", + "crates/ttree-platform-macos", + "crates/ttree-ocr", + "crates/ttree-translate", + "crates/ttree-ai", + "crates/ttree-app", +] + +[workspace.package] +version = "0.2.0" +edition = "2021" +authors = ["ZASENJC"] +license = "MIT" + +[workspace.dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json", "stream", "cookies"] } +futures-util = "0.3" +url = "2" +urlencoding = "2" +tempfile = "3" +dirs = "6" +auto-launch = "0.5" +global-hotkey = "0.8" +core-foundation = "0.10" +security-framework = "3" +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = [ + "NSArray", + "NSAttributedString", + "NSBundle", + "NSData", + "NSDictionary", + "NSError", + "NSGeometry", + "NSObject", + "NSProcessInfo", + "NSRange", + "NSRunLoop", + "NSString", + "NSThread", + "NSTimer", + "NSURL", + "NSValue", +] } +objc2-core-foundation = "0.3" +objc2-core-graphics = { version = "0.3", features = [ + "CGBitmapContext", + "CGColorSpace", + "CGContext", + "CGEvent", + "CGEventTypes", + "CGImage", + "CGRemoteOperation", +] } +objc2-core-image = { version = "0.3", features = ["CIContext"] } +objc2-core-ml = { version = "0.3", features = ["MLCPUComputeDevice", "MLComputeDeviceProtocol"] } +objc2-image-io = { version = "0.3", features = ["CGImageSource"] } +objc2-vision = { version = "0.3", features = [ + "objc2-core-graphics", + "objc2-core-ml", + "VNRequest", + "VNRecognizeTextRequest", + "VNObservation", + "VNRequestHandler", + "VNTypes", +] } +objc2-app-kit = { version = "0.3", features = [ + "NSApplication", + "NSBezierPath", + "NSBox", + "NSButton", + "NSButtonCell", + "NSCell", + "NSColor", + "NSControl", + "NSEvent", + "NSFont", + "NSGraphics", + "NSImage", + "NSMenu", + "NSMenuItem", + "NSOpenPanel", + "NSPanel", + "NSPasteboard", + "NSPasteboardItem", + "NSPopUpButton", + "NSResponder", + "NSRunningApplication", + "NSSavePanel", + "NSScreen", + "NSScrollView", + "NSSecureTextField", + "NSSegmentedControl", + "NSSlider", + "NSStatusBar", + "NSStatusBarButton", + "NSStatusItem", + "NSText", + "NSTextContainer", + "NSTextField", + "NSTextFieldCell", + "NSTextStorage", + "NSTextView", + "NSView", + "NSVisualEffectView", + "NSWindow", + "objc2-core-foundation", +] } +objc2-quartz-core = { version = "0.3", features = ["CALayer"] } diff --git a/crates/ttree-ai/Cargo.toml b/crates/ttree-ai/Cargo.toml new file mode 100644 index 0000000..64c1423 --- /dev/null +++ b/crates/ttree-ai/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ttree-ai" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +ttree-core = { path = "../ttree-core" } +anyhow.workspace = true +futures-util.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +candle-core = { version = "0.11.0", features = ["metal", "accelerate"] } +candle-transformers = { version = "0.11.0", features = ["metal", "accelerate"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +candle-core = { version = "0.11.0" } +candle-transformers = { version = "0.11.0" } diff --git a/crates/ttree-ai/src/gguf.rs b/crates/ttree-ai/src/gguf.rs new file mode 100644 index 0000000..ac0cdec --- /dev/null +++ b/crates/ttree-ai/src/gguf.rs @@ -0,0 +1,396 @@ +//! 纯 Rust `Candle` 本地 `.gguf` 量化模型直接加载与流式推理(支持 macOS Metal GPU 加速)。 +//! +//! 核心亮点: +//! 1. 直接从 `.gguf` 文件内置的 `tokenizer.ggml.tokens` 元数据构建分词器与解码器,无需额外准备 `tokenizer.json`; +//! 2. 自动识别 `qwen2`、`qwen3`、`llama`(含 Llama3 / Mistral / DeepSeek 等)主流 GGUF 架构; +//! 3. 优先启用 Apple Silicon `Device::new_metal(0)` GPU 加速,并在内存中常驻缓存已加载的模型权重,二次翻译 0 加载延迟; +//! 4. 每生成一个 Token 即刻回调 UI 并检查 `CancelToken`,支持随时毫秒级打断。 + +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +use candle_core::quantized::gguf_file::{self, Value as GgufValue}; +use candle_core::{Device, Tensor}; +use candle_transformers::models::{quantized_llama, quantized_qwen2, quantized_qwen3}; +use ttree_core::{CancelToken, ChatMessage}; + +const MAX_NEW_TOKENS: usize = 512; + +enum QuantizedArch { + Qwen2(quantized_qwen2::ModelWeights), + Qwen3(quantized_qwen3::ModelWeights), + Llama(quantized_llama::ModelWeights), +} + +impl QuantizedArch { + fn clear_kv_cache(&mut self) { + match self { + Self::Qwen2(m) => m.clear_kv_cache(), + Self::Qwen3(m) => m.clear_kv_cache(), + Self::Llama(_) => {} + } + } + + fn forward(&mut self, input: &Tensor, index_pos: usize) -> candle_core::Result { + match self { + Self::Qwen2(m) => m.forward(input, index_pos), + Self::Qwen3(m) => m.forward(input, index_pos), + Self::Llama(m) => m.forward(input, index_pos), + } + } +} + +/// 从 GGUF 内嵌的 `tokenizer.ggml.tokens` 词表构建的轻量分词/解码器。 +pub struct GgufEmbeddedTokenizer { + id_to_token: Vec, + token_to_id: HashMap, + eos_tokens: Vec, + bos_token: Option, +} + +impl GgufEmbeddedTokenizer { + pub fn from_gguf_metadata(md: &HashMap) -> Result { + let raw_tokens = md + .get("tokenizer.ggml.tokens") + .and_then(|v| v.to_vec().ok()) + .ok_or_else(|| "GGUF 文件缺少内置词表 (tokenizer.ggml.tokens)".to_string())?; + + let mut id_to_token = Vec::with_capacity(raw_tokens.len()); + let mut token_to_id = HashMap::with_capacity(raw_tokens.len()); + + for (idx, val) in raw_tokens.iter().enumerate() { + let s = val.to_string().map(|s| s.clone()).unwrap_or_default(); + token_to_id.insert(s.clone(), idx as u32); + id_to_token.push(s); + } + + let mut eos_tokens = Vec::new(); + if let Some(eos_id) = md + .get("tokenizer.ggml.eos_token_id") + .and_then(|v| v.to_u32().ok()) + { + eos_tokens.push(eos_id); + } + for special in ["<|im_end|>", "<|endoftext|>", "", "<|eot_id|>", ""] { + if let Some(&id) = token_to_id.get(special) { + if !eos_tokens.contains(&id) { + eos_tokens.push(id); + } + } + } + + let bos_token = md + .get("tokenizer.ggml.bos_token_id") + .and_then(|v| v.to_u32().ok()); + + Ok(Self { + id_to_token, + token_to_id, + eos_tokens, + bos_token, + }) + } + + pub fn is_eos(&self, token_id: u32) -> bool { + self.eos_tokens.contains(&token_id) + } + + /// 最长前缀贪心匹配 + GPT2/SentencePiece 字节回退分词。 + pub fn encode(&self, text: &str) -> Vec { + let mut ids = Vec::new(); + if let Some(bos) = self.bos_token { + ids.push(bos); + } + // 将空格映射为 SentencePiece `▁` 或 GPT-2 `Ġ` 候选以匹配主流 GGUF 词表 + let uses_sp_space = self.token_to_id.contains_key("▁the") + || self.token_to_id.contains_key("▁") + || self.token_to_id.contains_key("▁a"); + let normalized = if uses_sp_space { + text.replace(' ', "▁") + } else { + text.replace(' ', "Ġ").replace('\n', "Ċ") + }; + + let mut rest = normalized.as_str(); + while !rest.is_empty() { + // 先匹配特殊控制标记(如 `<|im_start|>`、`<|im_end|>`) + if rest.starts_with("<|") { + if let Some(end_idx) = rest.find("|>") { + let special = &rest[..end_idx + 2]; + if let Some(&id) = self.token_to_id.get(special) { + ids.push(id); + rest = &rest[end_idx + 2..]; + continue; + } + } + } + + let mut matched = None; + let max_bytes = rest.len().min(32); + let mut end = max_bytes; + while end > 0 { + if rest.is_char_boundary(end) { + let candidate = &rest[..end]; + if let Some(&id) = self.token_to_id.get(candidate) { + matched = Some((id, end)); + break; + } + } + end -= 1; + } + + if let Some((id, len)) = matched { + ids.push(id); + rest = &rest[len..]; + } else { + // 单字节 `<0xXX>` 回退 + let ch = rest.chars().next().unwrap(); + let ch_len = ch.len_utf8(); + for &b in &rest.as_bytes()[..ch_len] { + let hex_tok = format!("<0x{b:02X}>"); + if let Some(&id) = self.token_to_id.get(&hex_tok) { + ids.push(id); + } + } + rest = &rest[ch_len..]; + } + } + ids + } + + pub fn decode_token_bytes(&self, token_id: u32) -> Vec { + let Some(piece) = self.id_to_token.get(token_id as usize) else { + return Vec::new(); + }; + if piece.starts_with("<|") && piece.ends_with("|>") { + return Vec::new(); + } + if piece.len() == 6 && piece.starts_with("<0x") && piece.ends_with('>') { + if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) { + return vec![b]; + } + } + let decoded = piece + .replace('▁', " ") + .replace("Ġ", " ") + .replace("Ċ", "\n"); + decoded.into_bytes() + } +} + +struct CachedSession { + model_path: String, + device: Device, + arch: QuantizedArch, + tokenizer: GgufEmbeddedTokenizer, +} + +static GGUF_SESSION: OnceLock>> = OnceLock::new(); + +fn select_device() -> Device { + #[cfg(target_os = "macos")] + if let Ok(metal) = Device::new_metal(0) { + return metal; + } + Device::Cpu +} + +fn load_session(model_path: &str) -> Result { + let path = Path::new(model_path); + if !path.is_file() { + return Err(format!("找不到本地 GGUF 模型文件: {model_path}")); + } + let file = File::open(path).map_err(|e| format!("打开 GGUF 文件失败: {e}"))?; + let mut reader = BufReader::new(file); + let content = gguf_file::Content::read(&mut reader) + .map_err(|e| format!("解析 GGUF 文件头失败: {e}"))?; + + let arch_name = content + .metadata + .get("general.architecture") + .and_then(|v| v.to_string().ok()) + .cloned() + .unwrap_or_else(|| "llama".to_string()); + + let tokenizer = GgufEmbeddedTokenizer::from_gguf_metadata(&content.metadata)?; + let device = select_device(); + + let arch = match arch_name.as_str() { + "qwen2" => { + let weights = quantized_qwen2::ModelWeights::from_gguf(content, &mut reader, &device) + .map_err(|e| format!("加载 Qwen2 GGUF 权重失败: {e}"))?; + QuantizedArch::Qwen2(weights) + } + "qwen3" => { + let weights = quantized_qwen3::ModelWeights::from_gguf(content, &mut reader, &device) + .map_err(|e| format!("加载 Qwen3 GGUF 权重失败: {e}"))?; + QuantizedArch::Qwen3(weights) + } + _ => { + let weights = quantized_llama::ModelWeights::from_gguf(content, &mut reader, &device) + .map_err(|e| format!("加载 {arch_name} GGUF 权重失败: {e}"))?; + QuantizedArch::Llama(weights) + } + }; + + Ok(CachedSession { + model_path: model_path.to_string(), + device, + arch, + tokenizer, + }) +} + +/// 将对话消息格式化为通用 ChatML 提示词模板(兼容 Qwen / Llama / OpenChat 等 GGUF 模型)。 +pub fn format_chatml_prompt(messages: &[ChatMessage]) -> String { + let mut prompt = String::new(); + for msg in messages { + prompt.push_str("<|im_start|>"); + prompt.push_str(&msg.role); + prompt.push('\n'); + prompt.push_str(&msg.content); + prompt.push_str("<|im_end|>\n"); + } + prompt.push_str("<|im_start|>assistant\n"); + prompt +} + +pub async fn stream_gguf_inference( + gguf_model_path: &str, + _gguf_tokenizer_path: &str, + messages: &[ChatMessage], + cancel: &CancelToken, + on_delta: &mut F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + let model_path = gguf_model_path.trim().to_string(); + if model_path.is_empty() { + return Err("未选择本地 .gguf 模型文件路径,请在设置中指定文件位置".to_string()); + } + + let prompt = format_chatml_prompt(messages); + let cancel_clone = cancel.clone(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::>(); + + tokio::task::spawn_blocking(move || { + let slot = GGUF_SESSION.get_or_init(|| Mutex::new(None)); + let mut guard = match slot.lock() { + Ok(g) => g, + Err(e) => { + let _ = tx.send(Err(format!("GGUF 引擎锁异常: {e}"))); + return; + } + }; + + let needs_reload = guard + .as_ref() + .is_none_or(|s| s.model_path != model_path); + if needs_reload { + match load_session(&model_path) { + Ok(session) => *guard = Some(session), + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + } + } + + let session = guard.as_mut().expect("session loaded"); + session.arch.clear_kv_cache(); + + let prompt_tokens = session.tokenizer.encode(&prompt); + if prompt_tokens.is_empty() { + let _ = tx.send(Err("提示词分词为空".to_string())); + return; + } + + let mut next_token = match run_step(&mut session.arch, &session.device, &prompt_tokens, 0) { + Ok(tok) => tok, + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + + let mut utf8_pending = Vec::new(); + let mut pos = prompt_tokens.len(); + + for _ in 0..MAX_NEW_TOKENS { + if cancel_clone.is_cancelled() { + return; + } + if session.tokenizer.is_eos(next_token) { + break; + } + utf8_pending.extend_from_slice(&session.tokenizer.decode_token_bytes(next_token)); + if let Ok(valid_str) = std::str::from_utf8(&utf8_pending) { + if !valid_str.is_empty() { + let _ = tx.send(Ok(valid_str.to_string())); + } + utf8_pending.clear(); + } + + next_token = match run_step(&mut session.arch, &session.device, &[next_token], pos) { + Ok(tok) => tok, + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + pos += 1; + } + }); + + while let Some(item) = rx.recv().await { + if cancel.is_cancelled() { + return Err("请求已取消".to_string()); + } + let delta = item?; + on_delta(&delta); + } + Ok(()) +} + +/// 执行一步前向推理并采用 Argmax 贪心采样(翻译任务使用 temperature=0 最准确稳定)。 +fn run_step( + arch: &mut QuantizedArch, + device: &Device, + tokens: &[u32], + index_pos: usize, +) -> Result { + let input = Tensor::new(tokens, device) + .and_then(|t| t.unsqueeze(0)) + .map_err(|e| format!("构造输入张量失败: {e}"))?; + let logits = arch + .forward(&input, index_pos) + .map_err(|e| format!("GGUF 模型前向推理失败: {e}"))?; + let logits = logits + .squeeze(0) + .map_err(|e| format!("张量维度压缩失败: {e}"))?; + let next_id = logits + .argmax(0) + .and_then(|t| t.to_scalar::()) + .map_err(|e| format!("采样下一个 Token 失败: {e}"))?; + Ok(next_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_chatml_prompt_correctly() { + let msgs = vec![ + ChatMessage::system("Translate to Chinese."), + ChatMessage::user("Hello"), + ]; + let p = format_chatml_prompt(&msgs); + assert!(p.contains("<|im_start|>system\nTranslate to Chinese.<|im_end|>")); + assert!(p.ends_with("<|im_start|>assistant\n")); + } +} diff --git a/crates/ttree-ai/src/http_provider.rs b/crates/ttree-ai/src/http_provider.rs new file mode 100644 index 0000000..bec4c65 --- /dev/null +++ b/crates/ttree-ai/src/http_provider.rs @@ -0,0 +1,246 @@ +//! 云端 OpenAI 兼容接口与本地服务(Ollama / LM Studio / MLX Server)HTTP 流式客户端 + 本地模型自动探测。 + +use std::sync::OnceLock; +use std::time::Duration; + +use futures_util::StreamExt; +use reqwest::Client; +use serde_json::{json, Value}; +use ttree_core::{AiBackendKind, CancelToken, ChatMessage}; + +use crate::sse::{LineStreamDecoder, StreamDelta}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const STREAM_TIMEOUT: Duration = Duration::from_secs(90); + +fn http_client() -> &'static Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(STREAM_TIMEOUT) + .tcp_keepalive(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build AI HTTP client") + }) +} + +/// 自动探测本机运行中的 Ollama (`/api/tags`) 或 LM Studio / MLX (`/v1/models`) 模型列表。 +pub async fn discover_local_models( + backend: AiBackendKind, + base_url: &str, +) -> Result, String> { + let base = if base_url.trim().is_empty() { + backend.default_base_url() + } else { + base_url.trim().trim_end_matches('/') + }; + + match backend { + AiBackendKind::Ollama => { + let root = base.trim_end_matches("/v1"); + let url = format!("{root}/api/tags"); + let resp = http_client() + .get(&url) + .timeout(Duration::from_secs(3)) + .send() + .await + .map_err(|e| format!("无法连接本机 Ollama 服务 ({url}): {e}"))?; + if !resp.status().is_success() { + return Err(format!("Ollama 返回状态 {}", resp.status())); + } + let body: Value = resp + .json() + .await + .map_err(|e| format!("解析 Ollama 模型列表失败: {e}"))?; + let models = body + .get("models") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|item| item.get("name").and_then(Value::as_str).map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + Ok(models) + } + AiBackendKind::LocalServer | AiBackendKind::OpenAiCompatible => { + let root = if base.ends_with("/v1") { + base.to_string() + } else { + format!("{base}/v1") + }; + let url = format!("{root}/models"); + let resp = http_client() + .get(&url) + .timeout(Duration::from_secs(3)) + .send() + .await + .map_err(|e| format!("无法连接本地模型服务 ({url}): {e}"))?; + if !resp.status().is_success() { + return Err(format!("模型服务返回状态 {}", resp.status())); + } + let body: Value = resp + .json() + .await + .map_err(|e| format!("解析模型列表失败: {e}"))?; + let models = body + .get("data") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|item| item.get("id").and_then(Value::as_str).map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + Ok(models) + } + AiBackendKind::CandleGguf => Ok(Vec::new()), + } +} + +/// 调用 OpenAI 兼容端点(云端或 LM Studio / MLX / llama.cpp)执行 SSE 流式请求。 +pub async fn stream_openai_compatible( + base_url: &str, + api_key: Option<&str>, + model: &str, + messages: &[ChatMessage], + cancel: &CancelToken, + on_delta: &mut F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + let base = base_url.trim().trim_end_matches('/'); + let url = if base.ends_with("/chat/completions") { + base.to_string() + } else { + format!("{base}/chat/completions") + }; + + let payload = json!({ + "model": model, + "stream": true, + "temperature": 0.2, + "messages": messages, + }); + + let mut req = http_client().post(&url).json(&payload); + if let Some(key) = api_key.filter(|k| !k.is_empty()) { + req = req.bearer_auth(key); + } + + let resp = tokio::select! { + biased; + _ = cancel.cancelled() => return Err("请求已取消".to_string()), + r = req.send() => r.map_err(|e| format!("AI 请求失败: {e}"))?, + }; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let snippet: String = text.chars().take(300).collect(); + return Err(format!("AI 接口返回状态 {status}: {snippet}")); + } + + let mut stream = resp.bytes_stream(); + let mut decoder = LineStreamDecoder::new_sse(); + + loop { + let next_chunk = tokio::select! { + biased; + _ = cancel.cancelled() => return Err("请求已取消".to_string()), + chunk = stream.next() => chunk, + }; + let Some(chunk) = next_chunk else { + break; + }; + let bytes = chunk.map_err(|e| format!("读取 AI 响应流失败: {e}"))?; + for delta in decoder.push(&bytes)? { + match delta { + StreamDelta::Content(text) => on_delta(&text), + StreamDelta::Done => return Ok(()), + } + } + } + + for delta in decoder.finish()? { + if let StreamDelta::Content(text) = delta { + on_delta(&text); + } + } + Ok(()) +} + +/// 调用本机 Ollama 原生 `/api/chat` NDJSON 流式接口(自动保持模型热加载 `keep_alive: "15m"`)。 +pub async fn stream_ollama( + base_url: &str, + model: &str, + messages: &[ChatMessage], + cancel: &CancelToken, + on_delta: &mut F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + let base = if base_url.trim().is_empty() { + "http://127.0.0.1:11434" + } else { + base_url.trim().trim_end_matches('/').trim_end_matches("/v1") + }; + let url = format!("{base}/api/chat"); + + let payload = json!({ + "model": model, + "stream": true, + "keep_alive": "15m", + "options": { + "temperature": 0.2 + }, + "messages": messages, + }); + + let resp = tokio::select! { + biased; + _ = cancel.cancelled() => return Err("请求已取消".to_string()), + r = http_client().post(&url).json(&payload).send() => { + r.map_err(|e| format!("连接本机 Ollama 失败 ({url}): {e}"))? + } + }; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let snippet: String = text.chars().take(300).collect(); + return Err(format!("Ollama 返回状态 {status}: {snippet}")); + } + + let mut stream = resp.bytes_stream(); + let mut decoder = LineStreamDecoder::new_ollama_ndjson(); + + loop { + let next_chunk = tokio::select! { + biased; + _ = cancel.cancelled() => return Err("请求已取消".to_string()), + chunk = stream.next() => chunk, + }; + let Some(chunk) = next_chunk else { + break; + }; + let bytes = chunk.map_err(|e| format!("读取 Ollama 流失败: {e}"))?; + for delta in decoder.push(&bytes)? { + match delta { + StreamDelta::Content(text) => on_delta(&text), + StreamDelta::Done => return Ok(()), + } + } + } + + for delta in decoder.finish()? { + if let StreamDelta::Content(text) = delta { + on_delta(&text); + } + } + Ok(()) +} diff --git a/crates/ttree-ai/src/lib.rs b/crates/ttree-ai/src/lib.rs new file mode 100644 index 0000000..48db043 --- /dev/null +++ b/crates/ttree-ai/src/lib.rs @@ -0,0 +1,196 @@ +//! `ttree-ai` —— 独立重写的 AI 翻译与对话模块。 +//! +//! 核心特性: +//! 1. 与 GUI 框架完全解耦,通过回调流式输出增量文本(`on_delta`)并支持 `CancelToken` 毫秒级中断; +//! 2. 消除旧版重复发送两遍 Prompt 的缺陷,大幅降低首字延迟(TTFT); +//! 3. 支持 4 类模型后端: +//! - `OpenAiCompatible`:云端 OpenAI / DeepSeek / OpenRouter 兼容接口 +//! - `Ollama`:本机 Ollama 服务(免 Key,支持 `/api/tags` 自动探测本地已装模型) +//! - `LocalServer`:本机 LM Studio / MLX Server / llama.cpp 服务(免 Key,支持 `/v1/models` 探测) +//! - `CandleGguf`:基于纯 Rust `candle` (Metal GPU 加速) 直接加载本地 `.gguf` 量化模型,常驻内存复用 + +pub mod gguf; +pub mod http_provider; +pub mod sse; + +use ttree_core::language::language_display_name; +use ttree_core::{ + AiBackendKind, AiTranslationConfig, CancelToken, ChatAiConfig, ChatMessage, + validate_language_pair, validate_messages, +}; + +pub use http_provider::discover_local_models; + +/// 渲染精简高效的翻译 System Prompt(不再在 User 消息中重复拼接第二遍 Prompt)。 +pub fn render_translate_prompt(template: &str, source: &str, target: &str) -> String { + let src_name = language_display_name(source); + let tgt_name = language_display_name(target); + template + .replace("{source}", src_name) + .replace("{target}", tgt_name) +} + +/// 组装翻译消息:System 放指令,User 仅放待译原文(省去 50% 冗余 Prompt Token)。 +pub fn build_translate_messages( + cfg: &AiTranslationConfig, + text: &str, + source: &str, + target: &str, +) -> Vec { + let prompt = render_translate_prompt(&cfg.translate_prompt, source, target); + vec![ + ChatMessage::system(prompt), + ChatMessage::user(text.trim()), + ] +} + +/// 组装 AI 对话消息。 +pub fn build_chat_messages(cfg: &ChatAiConfig, history: &[ChatMessage]) -> Vec { + let mut out = Vec::with_capacity(history.len() + 1); + if !cfg.chat_prompt.trim().is_empty() { + out.push(ChatMessage::system(cfg.chat_prompt.trim())); + } + out.extend_from_slice(history); + out +} + +/// 执行流式 AI 翻译。 +pub async fn stream_translate( + cfg: &AiTranslationConfig, + text: &str, + source: &str, + target: &str, + cancel: &CancelToken, + mut on_delta: F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + if !cfg.enabled { + return Ok(()); + } + let trimmed = text.trim(); + if trimmed.is_empty() { + return Ok(()); + } + validate_language_pair(source, target)?; + let messages = build_translate_messages(cfg, trimmed, source, target); + + dispatch_stream( + cfg.backend, + &cfg.base_url, + &cfg.api_key, + &cfg.model, + &cfg.gguf_model_path, + &cfg.gguf_tokenizer_path, + &messages, + cancel, + &mut on_delta, + ) + .await +} + +/// 执行流式 AI 对话。 +pub async fn stream_chat( + cfg: &ChatAiConfig, + history: &[ChatMessage], + cancel: &CancelToken, + mut on_delta: F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + if history.is_empty() { + return Ok(()); + } + validate_messages(history)?; + let messages = build_chat_messages(cfg, history); + + dispatch_stream( + cfg.backend, + &cfg.base_url, + &cfg.api_key, + &cfg.model, + &cfg.gguf_model_path, + &cfg.gguf_tokenizer_path, + &messages, + cancel, + &mut on_delta, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn dispatch_stream( + backend: AiBackendKind, + base_url: &str, + api_key: &str, + model: &str, + gguf_model_path: &str, + gguf_tokenizer_path: &str, + messages: &[ChatMessage], + cancel: &CancelToken, + on_delta: &mut F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + match backend { + AiBackendKind::OpenAiCompatible => { + if api_key.trim().is_empty() { + return Err("未配置云端 API Key(可在设置中填入,或切换为本地 Ollama / LM Studio / GGUF 模型)".to_string()); + } + http_provider::stream_openai_compatible( + base_url, + Some(api_key.trim()), + model, + messages, + cancel, + on_delta, + ) + .await + } + AiBackendKind::Ollama => { + http_provider::stream_ollama(base_url, model, messages, cancel, on_delta).await + } + AiBackendKind::LocalServer => { + let opt_key = (!api_key.trim().is_empty()).then_some(api_key.trim()); + http_provider::stream_openai_compatible( + base_url, + opt_key, + model, + messages, + cancel, + on_delta, + ) + .await + } + AiBackendKind::CandleGguf => { + gguf::stream_gguf_inference( + gguf_model_path, + gguf_tokenizer_path, + messages, + cancel, + on_delta, + ) + .await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_compact_translate_messages_without_duplicate_prompt() { + let cfg = AiTranslationConfig::default(); + let msgs = build_translate_messages(&cfg, "Hello world", "en", "zh-CN"); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].role, "system"); + assert!(msgs[0].content.contains("简体中文")); + assert_eq!(msgs[1].role, "user"); + // User 消息仅包含待译原文,不再重复拼接 system prompt + assert_eq!(msgs[1].content, "Hello world"); + } +} diff --git a/crates/ttree-ai/src/sse.rs b/crates/ttree-ai/src/sse.rs new file mode 100644 index 0000000..3a23507 --- /dev/null +++ b/crates/ttree-ai/src/sse.rs @@ -0,0 +1,182 @@ +//! SSE (Server-Sent Events) 与 NDJSON (Ollama) 流式帧解码器。 + +use serde_json::Value; + +pub const MAX_SSE_FRAME_BYTES: usize = 256 * 1024; +pub const MAX_STREAM_OUTPUT_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamDelta { + Content(String), + Done, +} + +pub fn parse_sse_line(line: &str) -> Result, String> { + let Some(data) = line.strip_prefix("data:") else { + return Ok(None); + }; + let data = data.trim(); + if data.is_empty() { + return Ok(None); + } + if data == "[DONE]" { + return Ok(Some(StreamDelta::Done)); + } + let v: Value = + serde_json::from_str(data).map_err(|e| format!("SSE JSON 解析失败: {e}"))?; + let content = v + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")) + .and_then(|d| d.get("content")) + .and_then(Value::as_str); + Ok(content.map(|s| StreamDelta::Content(s.to_string()))) +} + +pub fn parse_ollama_ndjson_line(line: &str) -> Result, String> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let v: Value = + serde_json::from_str(trimmed).map_err(|e| format!("Ollama JSON 解析失败: {e}"))?; + if let Some(err) = v.get("error").and_then(Value::as_str) { + return Err(format!("Ollama 返回错误: {err}")); + } + let done = v.get("done").and_then(Value::as_bool).unwrap_or(false); + let content = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_str) + .unwrap_or(""); + if !content.is_empty() { + return Ok(Some(StreamDelta::Content(content.to_string()))); + } + if done { + return Ok(Some(StreamDelta::Done)); + } + Ok(None) +} + +pub struct LineStreamDecoder { + buffer: Vec, + frame_limit: usize, + output_limit: usize, + output_bytes: usize, + saw_done: bool, + ndjson_mode: bool, +} + +impl LineStreamDecoder { + pub fn new_sse() -> Self { + Self { + buffer: Vec::new(), + frame_limit: MAX_SSE_FRAME_BYTES, + output_limit: MAX_STREAM_OUTPUT_BYTES, + output_bytes: 0, + saw_done: false, + ndjson_mode: false, + } + } + + pub fn new_ollama_ndjson() -> Self { + Self { + buffer: Vec::new(), + frame_limit: MAX_SSE_FRAME_BYTES, + output_limit: MAX_STREAM_OUTPUT_BYTES, + output_bytes: 0, + saw_done: false, + ndjson_mode: true, + } + } + + pub fn push(&mut self, bytes: &[u8]) -> Result, String> { + let mut deltas = Vec::new(); + for &byte in bytes { + if byte == b'\n' { + if let Some(delta) = self.take_line()? { + let is_done = delta == StreamDelta::Done; + deltas.push(delta); + if is_done { + break; + } + } + continue; + } + if self.buffer.len() >= self.frame_limit { + return Err(format!("流式单帧超出 {} 字节上限", self.frame_limit)); + } + self.buffer.push(byte); + } + Ok(deltas) + } + + pub fn finish(mut self) -> Result, String> { + let mut deltas = Vec::new(); + if !self.buffer.is_empty() { + if let Some(delta) = self.take_line()? { + deltas.push(delta); + } + } + if !self.saw_done && self.output_bytes == 0 { + return Err("AI 流提前结束且未返回有效内容".to_string()); + } + Ok(deltas) + } + + fn take_line(&mut self) -> Result, String> { + let line_bytes = std::mem::take(&mut self.buffer); + let line = std::str::from_utf8(&line_bytes) + .map_err(|e| format!("流式响应包含非法 UTF-8: {e}"))? + .trim(); + let parsed = if self.ndjson_mode { + parse_ollama_ndjson_line(line)? + } else { + parse_sse_line(line)? + }; + let Some(delta) = parsed else { + return Ok(None); + }; + match &delta { + StreamDelta::Content(c) => { + self.output_bytes = self + .output_bytes + .checked_add(c.len()) + .ok_or_else(|| "流式输出大小溢出".to_string())?; + if self.output_bytes > self.output_limit { + return Err(format!("流式输出超出 {} 字节上限", self.output_limit)); + } + } + StreamDelta::Done => { + self.saw_done = true; + } + } + Ok(Some(delta)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_sse_and_ollama_ndjson_streams() { + let mut sse = LineStreamDecoder::new_sse(); + let out = sse + .push("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: [DONE]\n".as_bytes()) + .unwrap(); + assert_eq!( + out, + vec![StreamDelta::Content("你好".to_string()), StreamDelta::Done] + ); + + let mut ndjson = LineStreamDecoder::new_ollama_ndjson(); + let out2 = ndjson + .push("{\"message\":{\"content\":\"世界\"},\"done\":false}\n{\"message\":{\"content\":\"\"},\"done\":true}\n".as_bytes()) + .unwrap(); + assert_eq!( + out2, + vec![StreamDelta::Content("世界".to_string()), StreamDelta::Done] + ); + } +} diff --git a/crates/ttree-app/Cargo.toml b/crates/ttree-app/Cargo.toml new file mode 100644 index 0000000..17e1a87 --- /dev/null +++ b/crates/ttree-app/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ttree-app" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "ttree" +path = "src/main.rs" + +[dependencies] +ttree-core = { path = "../ttree-core" } +ttree-storage = { path = "../ttree-storage" } +ttree-platform-macos = { path = "../ttree-platform-macos" } +ttree-ocr = { path = "../ttree-ocr" } +ttree-translate = { path = "../ttree-translate" } +ttree-ai = { path = "../ttree-ai" } +anyhow.workspace = true +global-hotkey.workspace = true +tokio.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2.workspace = true +objc2-app-kit.workspace = true +objc2-core-foundation.workspace = true +objc2-foundation.workspace = true +objc2-quartz-core.workspace = true diff --git a/crates/ttree-app/src/main.rs b/crates/ttree-app/src/main.rs new file mode 100644 index 0000000..7a23188 --- /dev/null +++ b/crates/ttree-app/src/main.rs @@ -0,0 +1,351 @@ +//! `ttree` —— 100% 纯 Rust 原生 macOS AI 翻译与 OCR 桌面端入口。 + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +#[cfg(target_os = "macos")] +mod state; +#[cfg(target_os = "macos")] +mod ui_helpers; + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("TTREE 纯 Rust 原生 AppKit 客户端仅支持 macOS 运行。"); +} + +#[cfg(target_os = "macos")] +fn main() { + macos_app::run(); +} + +#[cfg(target_os = "macos")] +mod macos_app { + use std::cell::RefCell; + use std::sync::Arc; + + use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; + use objc2::rc::Retained; + use objc2::runtime::{AnyObject, Bool, Sel}; + use objc2::{define_class, msg_send, sel, MainThreadMarker, MainThreadOnly}; + use objc2_app_kit::{ + NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSColor, NSEvent, + NSEventModifierFlags, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSTextView, + NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, + NSVisualEffectView, NSWindow, NSWindowStyleMask, NSWindowTitleVisibility, + }; + use objc2_foundation::{ + NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoop, NSRunLoopCommonModes, NSSize, + NSString, NSTimer, + }; + use tokio::sync::mpsc::{self, UnboundedReceiver}; + + use ttree_core::{WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH}; + use ttree_storage::{HistoryStore, SettingsStore}; + + use crate::state::{ActionTag, AppController, TabMode, UiEvent}; + + struct GlobalUiContext { + ctrl: AppController, + ui_rx: UnboundedReceiver, + _status_item: Retained, + } + + thread_local! { + static UI_CTX: RefCell> = const { RefCell::new(None) }; + } + + fn with_ctx(f: F) { + UI_CTX.with(|cell| { + if let Ok(mut borrow) = cell.try_borrow_mut() { + if let Some(ctx) = borrow.as_mut() { + f(ctx); + } + } + }); + } + + define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "TTreeActionDispatcher"] + pub struct ActionDispatcher; + + unsafe impl NSObjectProtocol for ActionDispatcher {} + + impl ActionDispatcher { + #[unsafe(method(handleControl:))] + fn handle_control(&self, sender: &AnyObject) { + let raw_tag: isize = unsafe { msg_send![sender, tag] }; + if let Some(tag) = ActionTag::from_raw(raw_tag) { + with_ctx(|ctx| ctx.ctrl.on_action(tag)); + } + } + + #[unsafe(method(handleTimer:))] + fn handle_timer(&self, _timer: &AnyObject) { + with_ctx(|ctx| { + while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() { + if hk_ev.state == HotKeyState::Pressed { + if let Some(action) = ctx.ctrl.parsed_shortcuts.action_for_id(hk_ev.id) + { + ctx.ctrl.handle_hotkey_action(action); + } + } + } + while let Ok(ui_ev) = ctx.ui_rx.try_recv() { + ctx.ctrl.handle_ui_event(ui_ev); + } + ctx.ctrl.poll_tick(); + }); + } + + #[unsafe(method(textView:doCommandBySelector:))] + fn text_view_do_command(&self, _text_view: &NSTextView, command: Sel) -> Bool { + if command == sel!(cancelOperation:) { + with_ctx(|ctx| ctx.ctrl.on_action(ActionTag::HideOrEscape)); + return Bool::YES; + } + if command == sel!(insertNewline:) { + let flags = NSEvent::modifierFlags_class(); + if flags.contains(NSEventModifierFlags::Shift) { + return Bool::NO; + } + with_ctx(|ctx| match ctx.ctrl.active_tab { + TabMode::Translate => ctx.ctrl.start_translation(), + TabMode::Chat => ctx.ctrl.send_chat_from_input(), + TabMode::Settings => {} + }); + return Bool::YES; + } + Bool::NO + } + + #[unsafe(method(windowDidResignKey:))] + fn window_did_resign_key(&self, _notification: &AnyObject) { + with_ctx(|ctx| ctx.ctrl.on_window_did_resign_key()); + } + + #[unsafe(method(windowDidResize:))] + fn window_did_resize(&self, _notification: &AnyObject) { + with_ctx(|ctx| ctx.ctrl.on_window_did_resize()); + } + + #[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))] + fn app_should_handle_reopen(&self, _app: &AnyObject, _has_visible: Bool) -> Bool { + with_ctx(|ctx| { + ctx.ctrl.switch_tab(TabMode::Translate); + ctx.ctrl.show_and_focus_window(); + }); + Bool::YES + } + } + ); + + impl ActionDispatcher { + fn new(mtm: MainThreadMarker) -> Retained { + unsafe { msg_send![mtm.alloc::(), init] } + } + } + + pub fn run() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("初始化 Tokio 异步运行时失败"); + + let mtm = MainThreadMarker::new().expect("必须在 macOS 主线程启动 AppKit UI"); + let app = NSApplication::sharedApplication(mtm); + app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + + let dispatcher = ActionDispatcher::new(mtm); + unsafe { + let _: () = msg_send![&*app, setDelegate: Some(&*dispatcher)]; + } + + install_standard_main_menu(mtm, &app); + + let settings = Arc::new(SettingsStore::open_default()); + let history = Arc::new(HistoryStore::open_default()); + let hotkey_mgr = GlobalHotKeyManager::new().expect("初始化全局快捷键管理器失败"); + + let (win_w, win_h) = settings.window_size(); + let appearance = settings.appearance(); + let (window, effect_view) = + build_main_window(mtm, win_w, win_h, appearance.panel_opacity, &dispatcher); + + let status_item = build_status_bar_item(mtm, &dispatcher); + let (ui_tx, ui_rx) = mpsc::unbounded_channel(); + + let mut ctrl = AppController::build( + mtm, + rt.handle().clone(), + ui_tx, + settings, + history, + hotkey_mgr, + window, + effect_view, + &dispatcher, + ); + + if std::env::args().any(|a| a == "--show") { + ctrl.show_and_focus_window(); + } + + UI_CTX.with(|cell| { + *cell.borrow_mut() = Some(GlobalUiContext { + ctrl, + ui_rx, + _status_item: status_item, + }); + }); + + unsafe { + let timer = NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats( + 0.025, + &dispatcher, + sel!(handleTimer:), + None, + true, + ); + let run_loop = NSRunLoop::mainRunLoop(); + run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes); + } + + app.run(); + } + + fn build_main_window( + mtm: MainThreadMarker, + width: f64, + height: f64, + opacity: f64, + dispatcher: &ActionDispatcher, + ) -> (Retained, Retained) { + unsafe { + let frame = NSRect::new(NSPoint::new(240.0, 240.0), NSSize::new(width, height)); + let style = NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Resizable + | NSWindowStyleMask::FullSizeContentView; + let window = NSWindow::initWithContentRect_styleMask_backing_defer( + mtm.alloc(), + frame, + style, + NSBackingStoreType::Buffered, + false, + ); + window.setTitle(&NSString::from_str("TTREE")); + window.setTitleVisibility(NSWindowTitleVisibility::Hidden); + window.setTitlebarAppearsTransparent(true); + window.setMovableByWindowBackground(true); + window.setReleasedWhenClosed(false); + window.setOpaque(false); + window.setBackgroundColor(Some(&NSColor::clearColor())); + window.setAlphaValue(opacity); + window.setMinSize(NSSize::new(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)); + + let _: () = msg_send![&*window, setDelegate: Some(dispatcher)]; + + let effect_view = NSVisualEffectView::initWithFrame(mtm.alloc(), frame); + effect_view.setMaterial(NSVisualEffectMaterial::Popover); + effect_view.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow); + effect_view.setState(NSVisualEffectState::Active); + + window.setContentView(Some(&effect_view)); + (window, effect_view) + } + } + + fn build_status_bar_item( + mtm: MainThreadMarker, + dispatcher: &ActionDispatcher, + ) -> Retained { + unsafe { + let status_bar = NSStatusBar::systemStatusBar(); + let item = status_bar.statusItemWithLength(-1.0); + if let Some(btn) = item.button(mtm) { + btn.setTitle(&NSString::from_str("译")); + } + + let menu = NSMenu::new(mtm); + let action_sel = sel!(handleControl:); + + let add_item = |title: &str, tag: ActionTag, key: &str| { + let mi = NSMenuItem::initWithTitle_action_keyEquivalent( + mtm.alloc(), + &NSString::from_str(title), + Some(action_sel), + &NSString::from_str(key), + ); + mi.setTag(tag as isize); + mi.setTarget(Some(dispatcher)); + menu.addItem(&mi); + }; + + add_item("翻译窗口", ActionTag::TabTranslate, ""); + add_item("截图 OCR 翻译", ActionTag::TriggerOcr, ""); + add_item("AI 对话", ActionTag::TabChat, ""); + menu.addItem(&NSMenuItem::separatorItem(mtm)); + add_item("偏好设置…", ActionTag::TabSettings, ","); + menu.addItem(&NSMenuItem::separatorItem(mtm)); + add_item("退出 TTREE", ActionTag::TrayQuit, "q"); + + // 点击菜单项时如果窗口尚未显示则自动唤起(除了截图 OCR 与退出) + item.setMenu(Some(&menu)); + item + } + } + + /// 为 Accessory 模式应用安装标准 Edit 菜单,使 NSTextView / NSTextField 原生支持 Cmd+C / V / X / A / Z。 + fn install_standard_main_menu(mtm: MainThreadMarker, app: &NSApplication) { + unsafe { + let main_menu = NSMenu::new(mtm); + + // 1. App 子菜单 + let app_item = NSMenuItem::new(mtm); + let app_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("TTREE")); + let close_item = NSMenuItem::initWithTitle_action_keyEquivalent( + mtm.alloc(), + &NSString::from_str("隐藏窗口"), + Some(sel!(orderOut:)), + &NSString::from_str("w"), + ); + let quit_item = NSMenuItem::initWithTitle_action_keyEquivalent( + mtm.alloc(), + &NSString::from_str("退出 TTREE"), + Some(sel!(terminate:)), + &NSString::from_str("q"), + ); + app_menu.addItem(&close_item); + app_menu.addItem(&NSMenuItem::separatorItem(mtm)); + app_menu.addItem(&quit_item); + app_item.setSubmenu(Some(&app_menu)); + main_menu.addItem(&app_item); + + // 2. Edit 子菜单 (支持 Cmd+Z / Cmd+X / Cmd+C / Cmd+V / Cmd+A) + let edit_item = NSMenuItem::new(mtm); + let edit_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("编辑")); + + let edit_entries: [(&str, Sel, &str); 5] = [ + ("撤销", sel!(undo:), "z"), + ("剪切", sel!(cut:), "x"), + ("复制", sel!(copy:), "c"), + ("粘贴", sel!(paste:), "v"), + ("全选", sel!(selectAll:), "a"), + ]; + for (title, action, key) in edit_entries { + let mi = NSMenuItem::initWithTitle_action_keyEquivalent( + mtm.alloc(), + &NSString::from_str(title), + Some(action), + &NSString::from_str(key), + ); + edit_menu.addItem(&mi); + } + edit_item.setSubmenu(Some(&edit_menu)); + main_menu.addItem(&edit_item); + + app.setMainMenu(Some(&main_menu)); + } + } +} diff --git a/crates/ttree-app/src/state.rs b/crates/ttree-app/src/state.rs new file mode 100644 index 0000000..76824da --- /dev/null +++ b/crates/ttree-app/src/state.rs @@ -0,0 +1,2415 @@ +//! `ttree-app` 应用核心控制器、事件总线与三大视图(翻译 / AI 对话 / 设置)状态机。 + +#![cfg(target_os = "macos")] + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use global_hotkey::hotkey::HotKey; +use global_hotkey::GlobalHotKeyManager; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, Sel}; +use objc2::{sel, MainThreadMarker}; +use objc2_app_kit::{ + NSApplication, NSBox, NSButton, NSControlStateValueOn, NSFloatingWindowLevel, + NSModalResponseOK, NSNormalWindowLevel, NSOpenPanel, NSPopUpButton, NSScreen, NSScrollView, + NSSecureTextField, NSSlider, NSTextField, NSTextView, NSView, NSVisualEffectView, NSWindow, +}; +use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize, NSString}; +use tokio::runtime::Handle as TokioHandle; +use tokio::sync::mpsc::UnboundedSender; + +use ttree_core::{ + recent_chat_context, resolve_language_pair, AiBackendKind, AiTranslationConfig, + AppearanceConfig, CancelSlot, ChatAiConfig, ChatMessage, Conversation, ShortcutConfig, + ADAPTIVE_TARGET, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, SOURCE_LANGUAGES, TARGET_LANGUAGES, + WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, +}; +use ttree_ocr::OcrOutcome; +use ttree_platform_macos::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction}; +use ttree_storage::{HistoryStore, SettingsStore}; + +use crate::ui_helpers::{ + add_subview, append_text_view_string, copy_to_pasteboard, get_field_string, + get_text_view_string, make_button, make_card_box, make_checkbox, make_label, make_popup, + make_scrollable_text_view, make_secure_field, make_slider, make_text_field, set_field_string, + set_text_view_string, top_rect, +}; + +const AUTO_TRANSLATE_DEBOUNCE: Duration = Duration::from_millis(420); +const RESIZE_SAVE_DEBOUNCE: Duration = Duration::from_millis(500); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabMode { + Translate, + Chat, + Settings, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(isize)] +pub enum ActionTag { + TabTranslate = 1, + TabChat = 2, + TabSettings = 3, + TriggerOcr = 4, + TogglePin = 5, + TrayQuit = 6, + HideOrEscape = 7, + + TranslateNow = 10, + ClearTranslate = 11, + SwapLanguages = 12, + SourceLangChanged = 13, + TargetLangChanged = 14, + CopyWebResult = 15, + CopyAiResult = 16, + + SendChat = 20, + NewConversation = 21, + ClearChatHistory = 22, + StopChatStream = 23, + SelectConversation = 24, + CopyLastReply = 25, + + SaveSettings = 30, + AiTransBackendChanged = 31, + AiTransFillDefaultUrl = 32, + AiTransDiscoverModels = 33, + AiTransModelPopupSelected = 34, + AiTransBrowseGguf = 35, + AiTransBrowseTokenizer = 36, + AiTransClearKey = 37, + + ChatBackendChanged = 41, + ChatFillDefaultUrl = 42, + ChatDiscoverModels = 43, + ChatModelPopupSelected = 44, + ChatBrowseGguf = 45, + ChatCopyFromTrans = 46, + ChatClearKey = 47, + + ResetDefaultShortcuts = 50, + OpacitySliderChanged = 51, +} + +impl ActionTag { + pub fn from_raw(tag: isize) -> Option { + match tag { + 1 => Some(Self::TabTranslate), + 2 => Some(Self::TabChat), + 3 => Some(Self::TabSettings), + 4 => Some(Self::TriggerOcr), + 5 => Some(Self::TogglePin), + 6 => Some(Self::TrayQuit), + 7 => Some(Self::HideOrEscape), + 10 => Some(Self::TranslateNow), + 11 => Some(Self::ClearTranslate), + 12 => Some(Self::SwapLanguages), + 13 => Some(Self::SourceLangChanged), + 14 => Some(Self::TargetLangChanged), + 15 => Some(Self::CopyWebResult), + 16 => Some(Self::CopyAiResult), + 20 => Some(Self::SendChat), + 21 => Some(Self::NewConversation), + 22 => Some(Self::ClearChatHistory), + 23 => Some(Self::StopChatStream), + 24 => Some(Self::SelectConversation), + 25 => Some(Self::CopyLastReply), + 30 => Some(Self::SaveSettings), + 31 => Some(Self::AiTransBackendChanged), + 32 => Some(Self::AiTransFillDefaultUrl), + 33 => Some(Self::AiTransDiscoverModels), + 34 => Some(Self::AiTransModelPopupSelected), + 35 => Some(Self::AiTransBrowseGguf), + 36 => Some(Self::AiTransBrowseTokenizer), + 37 => Some(Self::AiTransClearKey), + 41 => Some(Self::ChatBackendChanged), + 42 => Some(Self::ChatFillDefaultUrl), + 43 => Some(Self::ChatDiscoverModels), + 44 => Some(Self::ChatModelPopupSelected), + 45 => Some(Self::ChatBrowseGguf), + 46 => Some(Self::ChatCopyFromTrans), + 47 => Some(Self::ChatClearKey), + 50 => Some(Self::ResetDefaultShortcuts), + 51 => Some(Self::OpacitySliderChanged), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum UiEvent { + WebTranslateResult { + gen: u64, + result: Result, + }, + AiTranslateDelta { + gen: u64, + delta: String, + }, + AiTranslateFinished { + gen: u64, + error: Option, + }, + OcrCompleted { + outcome: Result, + }, + SelectionCapturedForTranslate { + text: Option, + fallback_toggle: bool, + }, + SelectionCapturedForChat { + text: Option, + fallback_toggle: bool, + }, + ChatDelta { + gen: u64, + delta: String, + }, + ChatFinished { + gen: u64, + error: Option, + }, + ModelsDiscovered { + for_chat: bool, + result: Result, String>, + }, +} + +pub struct TranslateViewWidgets { + pub root: Retained, + pub source_popup: Retained, + pub swap_btn: Retained, + pub target_popup: Retained, + pub translate_btn: Retained, + pub clear_btn: Retained, + pub input_scroll: Retained, + pub input_tv: Retained, + pub web_card: Retained, + pub web_header_label: Retained, + pub web_copy_btn: Retained, + pub web_scroll: Retained, + pub web_tv: Retained, + pub ai_card: Retained, + pub ai_header_label: Retained, + pub ai_copy_btn: Retained, + pub ai_scroll: Retained, + pub ai_tv: Retained, +} + +pub struct ChatViewWidgets { + pub root: Retained, + pub conv_popup: Retained, + pub new_conv_btn: Retained, + pub clear_hist_btn: Retained, + pub stop_btn: Retained, + pub copy_reply_btn: Retained, + pub transcript_scroll: Retained, + pub transcript_tv: Retained, + pub input_scroll: Retained, + pub input_tv: Retained, + pub send_btn: Retained, +} + +pub struct SettingsViewWidgets { + pub root: Retained, + pub ai_enabled_chk: Retained, + pub ai_backend_popup: Retained, + pub ai_base_url_field: Retained, + pub ai_key_field: Retained, + pub ai_key_status_label: Retained, + pub ai_model_field: Retained, + pub ai_discovered_popup: Retained, + pub ai_gguf_field: Retained, + pub ai_tokenizer_field: Retained, + pub ai_prompt_field: Retained, + + pub chat_backend_popup: Retained, + pub chat_base_url_field: Retained, + pub chat_key_field: Retained, + pub chat_key_status_label: Retained, + pub chat_model_field: Retained, + pub chat_discovered_popup: Retained, + pub chat_gguf_field: Retained, + pub chat_prompt_field: Retained, + + pub sc_toggle_field: Retained, + pub sc_ocr_field: Retained, + pub sc_ai_field: Retained, + pub sc_sel_trans_field: Retained, + pub sc_sel_ai_field: Retained, + + pub opacity_slider: Retained, + pub opacity_val_label: Retained, + pub autostart_chk: Retained, +} + +pub struct AppController { + pub mtm: MainThreadMarker, + pub rt: TokioHandle, + pub ui_tx: UnboundedSender, + pub settings: Arc, + pub history: Arc, + + pub hotkey_mgr: GlobalHotKeyManager, + pub parsed_shortcuts: ParsedShortcuts, + pub registered_hotkeys: Vec, + + pub window: Retained, + pub effect_view: Retained, + pub tab_translate_btn: Retained, + pub tab_chat_btn: Retained, + pub tab_settings_btn: Retained, + pub status_label: Retained, + pub ocr_btn: Retained, + pub pin_btn: Retained, + + pub translate_ui: TranslateViewWidgets, + pub chat_ui: ChatViewWidgets, + pub settings_ui: SettingsViewWidgets, + + pub active_tab: TabMode, + pub is_pinned: bool, + pub pin_before_settings: bool, + pub is_modal_open: bool, + + pub translate_cancel: CancelSlot, + pub translate_gen: u64, + pub last_observed_source: String, + pub last_translated_source: String, + pub pending_debounce_since: Option, + + pub chat_cancel: CancelSlot, + pub chat_gen: u64, + pub chat_streaming: bool, + pub conversations: Vec, + pub active_conversation_id: Option, + pub current_messages: Vec, + pub streaming_assistant_buffer: String, + + pub pending_clear_ai_key: bool, + pub pending_clear_chat_key: bool, + + pub last_known_size: (f64, f64), + pub pending_resize_save: Option, +} + +impl AppController { + #[allow(clippy::too_many_arguments)] + pub fn build( + mtm: MainThreadMarker, + rt: TokioHandle, + ui_tx: UnboundedSender, + settings: Arc, + history: Arc, + hotkey_mgr: GlobalHotKeyManager, + window: Retained, + effect_view: Retained, + dispatcher: &AnyObject, + ) -> Self { + let action_sel: Sel = sel!(handleControl:); + let (win_w, win_h) = settings.window_size(); + + // Top Navigation Bar + let tab_translate_btn = make_button( + mtm, + top_rect(win_h, 14.0, 10.0, 74.0, 28.0), + "● 翻译", + ActionTag::TabTranslate as isize, + dispatcher, + action_sel, + ); + let tab_chat_btn = make_button( + mtm, + top_rect(win_h, 92.0, 10.0, 86.0, 28.0), + "AI 对话", + ActionTag::TabChat as isize, + dispatcher, + action_sel, + ); + let tab_settings_btn = make_button( + mtm, + top_rect(win_h, 182.0, 10.0, 72.0, 28.0), + "设置", + ActionTag::TabSettings as isize, + dispatcher, + action_sel, + ); + let status_label = make_label( + mtm, + top_rect(win_h, 264.0, 14.0, (win_w - 445.0).max(120.0), 20.0), + "纯 Rust 原生极速引擎就绪", + 11.5, + false, + true, + ); + let ocr_btn = make_button( + mtm, + top_rect(win_h, win_w - 172.0, 10.0, 86.0, 28.0), + "截图 OCR", + ActionTag::TriggerOcr as isize, + dispatcher, + action_sel, + ); + let pin_btn = make_button( + mtm, + top_rect(win_h, win_w - 82.0, 10.0, 68.0, 28.0), + "置顶", + ActionTag::TogglePin as isize, + dispatcher, + action_sel, + ); + + add_subview(&effect_view, &tab_translate_btn); + add_subview(&effect_view, &tab_chat_btn); + add_subview(&effect_view, &tab_settings_btn); + add_subview(&effect_view, &status_label); + add_subview(&effect_view, &ocr_btn); + add_subview(&effect_view, &pin_btn); + + let body_h = (win_h - 46.0).max(320.0); + let body_frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(win_w, body_h)); + + let translate_ui = Self::build_translate_view(mtm, body_frame, dispatcher, action_sel); + let chat_ui = Self::build_chat_view(mtm, body_frame, dispatcher, action_sel); + let settings_ui = + Self::build_settings_view(mtm, body_frame, &settings, dispatcher, action_sel); + + chat_ui.root.setHidden(true); + settings_ui.root.setHidden(true); + + add_subview(&effect_view, &translate_ui.root); + add_subview(&effect_view, &chat_ui.root); + add_subview(&effect_view, &settings_ui.root); + + let sc_cfg = settings.shortcuts(); + let parsed_shortcuts = parse_shortcut_config(&sc_cfg).unwrap_or_default(); + let mut registered_hotkeys = Vec::new(); + for hk in parsed_shortcuts.unique_hotkeys() { + if hotkey_mgr.register(hk).is_ok() { + registered_hotkeys.push(hk); + } + } + + let conversations = history.read_conversations().unwrap_or_default(); + let (active_conversation_id, current_messages) = match conversations.last() { + Some(last) => (Some(last.id), last.messages.clone()), + None => (None, Vec::new()), + }; + + let mut ctrl = Self { + mtm, + rt, + ui_tx, + settings, + history, + hotkey_mgr, + parsed_shortcuts, + registered_hotkeys, + window, + effect_view, + tab_translate_btn, + tab_chat_btn, + tab_settings_btn, + status_label, + ocr_btn, + pin_btn, + translate_ui, + chat_ui, + settings_ui, + active_tab: TabMode::Translate, + is_pinned: false, + pin_before_settings: false, + is_modal_open: false, + translate_cancel: CancelSlot::new(), + translate_gen: 0, + last_observed_source: String::new(), + last_translated_source: String::new(), + pending_debounce_since: None, + chat_cancel: CancelSlot::new(), + chat_gen: 0, + chat_streaming: false, + conversations, + active_conversation_id, + current_messages, + streaming_assistant_buffer: String::new(), + pending_clear_ai_key: false, + pending_clear_chat_key: false, + last_known_size: (win_w, win_h), + pending_resize_save: None, + }; + + ctrl.refresh_ai_header_badge(); + ctrl.refresh_conversations_popup(); + ctrl.render_chat_transcript(); + ctrl + } + + fn build_translate_view( + mtm: MainThreadMarker, + frame: NSRect, + dispatcher: &AnyObject, + action_sel: Sel, + ) -> TranslateViewWidgets { + let w = frame.size.width; + let h = frame.size.height; + let root = NSView::initWithFrame(mtm.alloc(), frame); + + let src_labels: Vec<&str> = SOURCE_LANGUAGES.iter().map(|l| l.label).collect(); + let tgt_labels: Vec<&str> = TARGET_LANGUAGES.iter().map(|l| l.label).collect(); + + let source_popup = make_popup( + mtm, + top_rect(h, 14.0, 6.0, 145.0, 26.0), + &src_labels, + 0, + ActionTag::SourceLangChanged as isize, + dispatcher, + action_sel, + ); + let swap_btn = make_button( + mtm, + top_rect(h, 163.0, 6.0, 38.0, 26.0), + "⇄", + ActionTag::SwapLanguages as isize, + dispatcher, + action_sel, + ); + let target_popup = make_popup( + mtm, + top_rect(h, 205.0, 6.0, 165.0, 26.0), + &tgt_labels, + 0, + ActionTag::TargetLangChanged as isize, + dispatcher, + action_sel, + ); + let translate_btn = make_button( + mtm, + top_rect(h, w - 166.0, 6.0, 92.0, 26.0), + "立即翻译", + ActionTag::TranslateNow as isize, + dispatcher, + action_sel, + ); + let clear_btn = make_button( + mtm, + top_rect(h, w - 70.0, 6.0, 56.0, 26.0), + "清空", + ActionTag::ClearTranslate as isize, + dispatcher, + action_sel, + ); + + let input_h = 118.0; + let (input_scroll, input_tv) = make_scrollable_text_view( + mtm, + top_rect(h, 14.0, 38.0, w - 28.0, input_h), + true, + 14.0, + Some(dispatcher), + ); + + let cards_top = 38.0 + input_h + 10.0; + let cards_h = (h - cards_top - 14.0).max(160.0); + let col_w = ((w - 38.0) / 2.0).max(200.0); + + let web_card = make_card_box(mtm, top_rect(h, 14.0, cards_top, col_w, cards_h)); + let web_header_label = make_label( + mtm, + top_rect(cards_h, 10.0, 8.0, col_w - 76.0, 18.0), + "网页极速翻译 (Google / Bing 竞速)", + 12.0, + true, + false, + ); + let web_copy_btn = make_button( + mtm, + top_rect(cards_h, col_w - 64.0, 5.0, 54.0, 24.0), + "复制", + ActionTag::CopyWebResult as isize, + dispatcher, + action_sel, + ); + let (web_scroll, web_tv) = make_scrollable_text_view( + mtm, + top_rect(cards_h, 4.0, 32.0, col_w - 8.0, cards_h - 36.0), + false, + 13.5, + None, + ); + add_subview(&web_card, &web_header_label); + add_subview(&web_card, &web_copy_btn); + add_subview(&web_card, &web_scroll); + + let ai_card = make_card_box( + mtm, + top_rect(h, 14.0 + col_w + 10.0, cards_top, col_w, cards_h), + ); + let ai_header_label = make_label( + mtm, + top_rect(cards_h, 10.0, 8.0, col_w - 76.0, 18.0), + "AI 翻译", + 12.0, + true, + false, + ); + let ai_copy_btn = make_button( + mtm, + top_rect(cards_h, col_w - 64.0, 5.0, 54.0, 24.0), + "复制", + ActionTag::CopyAiResult as isize, + dispatcher, + action_sel, + ); + let (ai_scroll, ai_tv) = make_scrollable_text_view( + mtm, + top_rect(cards_h, 4.0, 32.0, col_w - 8.0, cards_h - 36.0), + false, + 13.5, + None, + ); + add_subview(&ai_card, &ai_header_label); + add_subview(&ai_card, &ai_copy_btn); + add_subview(&ai_card, &ai_scroll); + + add_subview(&root, &source_popup); + add_subview(&root, &swap_btn); + add_subview(&root, &target_popup); + add_subview(&root, &translate_btn); + add_subview(&root, &clear_btn); + add_subview(&root, &input_scroll); + add_subview(&root, &web_card); + add_subview(&root, &ai_card); + + TranslateViewWidgets { + root, + source_popup, + swap_btn, + target_popup, + translate_btn, + clear_btn, + input_scroll, + input_tv, + web_card, + web_header_label, + web_copy_btn, + web_scroll, + web_tv, + ai_card, + ai_header_label, + ai_copy_btn, + ai_scroll, + ai_tv, + } + } + + fn build_chat_view( + mtm: MainThreadMarker, + frame: NSRect, + dispatcher: &AnyObject, + action_sel: Sel, + ) -> ChatViewWidgets { + let w = frame.size.width; + let h = frame.size.height; + let root = NSView::initWithFrame(mtm.alloc(), frame); + + let conv_popup = make_popup( + mtm, + top_rect(h, 14.0, 6.0, 260.0, 26.0), + &["当前对话"], + 0, + ActionTag::SelectConversation as isize, + dispatcher, + action_sel, + ); + let new_conv_btn = make_button( + mtm, + top_rect(h, 280.0, 6.0, 82.0, 26.0), + "+ 新对话", + ActionTag::NewConversation as isize, + dispatcher, + action_sel, + ); + let clear_hist_btn = make_button( + mtm, + top_rect(h, 366.0, 6.0, 82.0, 26.0), + "清空历史", + ActionTag::ClearChatHistory as isize, + dispatcher, + action_sel, + ); + let stop_btn = make_button( + mtm, + top_rect(h, w - 166.0, 6.0, 74.0, 26.0), + "停止", + ActionTag::StopChatStream as isize, + dispatcher, + action_sel, + ); + let copy_reply_btn = make_button( + mtm, + top_rect(h, w - 88.0, 6.0, 74.0, 26.0), + "复制回复", + ActionTag::CopyLastReply as isize, + dispatcher, + action_sel, + ); + + let input_h = 72.0; + let transcript_h = (h - 38.0 - input_h - 22.0).max(180.0); + let (transcript_scroll, transcript_tv) = make_scrollable_text_view( + mtm, + top_rect(h, 14.0, 38.0, w - 28.0, transcript_h), + false, + 13.5, + None, + ); + let input_top = 38.0 + transcript_h + 8.0; + let (input_scroll, input_tv) = make_scrollable_text_view( + mtm, + top_rect(h, 14.0, input_top, w - 104.0, input_h), + true, + 13.5, + Some(dispatcher), + ); + let send_btn = make_button( + mtm, + top_rect(h, w - 84.0, input_top + 18.0, 70.0, 36.0), + "发送", + ActionTag::SendChat as isize, + dispatcher, + action_sel, + ); + + add_subview(&root, &conv_popup); + add_subview(&root, &new_conv_btn); + add_subview(&root, &clear_hist_btn); + add_subview(&root, &stop_btn); + add_subview(&root, ©_reply_btn); + add_subview(&root, &transcript_scroll); + add_subview(&root, &input_scroll); + add_subview(&root, &send_btn); + + ChatViewWidgets { + root, + conv_popup, + new_conv_btn, + clear_hist_btn, + stop_btn, + copy_reply_btn, + transcript_scroll, + transcript_tv, + input_scroll, + input_tv, + send_btn, + } + } + + fn build_settings_view( + mtm: MainThreadMarker, + frame: NSRect, + settings: &SettingsStore, + dispatcher: &AnyObject, + action_sel: Sel, + ) -> SettingsViewWidgets { + let w = frame.size.width; + let h = frame.size.height; + let root = NSView::initWithFrame(mtm.alloc(), frame); + + let ai_cfg = settings.ai_translation_for_request(); + let chat_cfg = settings.chat_ai_for_request(); + let sc_cfg = settings.shortcuts(); + let app_cfg = settings.appearance(); + + let backend_labels: Vec<&str> = AiBackendKind::ALL.iter().map(|b| b.label()).collect(); + let ai_backend_idx = AiBackendKind::ALL + .iter() + .position(|&b| b == ai_cfg.backend) + .unwrap_or(0); + let chat_backend_idx = AiBackendKind::ALL + .iter() + .position(|&b| b == chat_cfg.backend) + .unwrap_or(0); + + // Left Column: AI 翻译引擎 & AI 对话引擎 + let sec1_title = make_label( + mtm, + top_rect(h, 14.0, 4.0, 200.0, 18.0), + "一、AI 翻译引擎(独立模块)", + 12.5, + true, + false, + ); + let ai_enabled_chk = make_checkbox( + mtm, + top_rect(h, 210.0, 3.0, 120.0, 20.0), + "启用 AI 翻译", + ai_cfg.enabled, + 0, + dispatcher, + action_sel, + ); + add_subview(&root, &sec1_title); + add_subview(&root, &ai_enabled_chk); + + let lbl_ai_backend = make_label(mtm, top_rect(h, 14.0, 28.0, 62.0, 18.0), "接入方式", 11.5, false, true); + let ai_backend_popup = make_popup( + mtm, + top_rect(h, 78.0, 25.0, 195.0, 24.0), + &backend_labels, + ai_backend_idx, + ActionTag::AiTransBackendChanged as isize, + dispatcher, + action_sel, + ); + let btn_ai_def_url = make_button( + mtm, + top_rect(h, 276.0, 25.0, 62.0, 24.0), + "默认地址", + ActionTag::AiTransFillDefaultUrl as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_ai_backend); + add_subview(&root, &ai_backend_popup); + add_subview(&root, &btn_ai_def_url); + + let lbl_ai_url = make_label(mtm, top_rect(h, 14.0, 55.0, 62.0, 18.0), "服务地址", 11.5, false, true); + let ai_base_url_field = make_text_field( + mtm, + top_rect(h, 78.0, 53.0, 258.0, 22.0), + &ai_cfg.base_url, + "http://127.0.0.1:11434 或 https://api.openai.com/v1", + ); + add_subview(&root, &lbl_ai_url); + add_subview(&root, &ai_base_url_field); + + let lbl_ai_key = make_label(mtm, top_rect(h, 14.0, 81.0, 62.0, 18.0), "API Key", 11.5, false, true); + let ai_key_field = make_secure_field( + mtm, + top_rect(h, 78.0, 79.0, 148.0, 22.0), + if ai_cfg.has_api_key { "已存钥匙串 (留空不改)" } else { "本地服务/GGUF 免填" }, + ); + let ai_key_status_label = make_label( + mtm, + top_rect(h, 230.0, 81.0, 52.0, 18.0), + if ai_cfg.has_api_key { "已保存" } else { "未设置" }, + 11.0, + false, + true, + ); + let btn_ai_clr_key = make_button( + mtm, + top_rect(h, 282.0, 78.0, 56.0, 22.0), + "清除", + ActionTag::AiTransClearKey as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_ai_key); + add_subview(&root, &ai_key_field); + add_subview(&root, &ai_key_status_label); + add_subview(&root, &btn_ai_clr_key); + + let lbl_ai_model = make_label(mtm, top_rect(h, 14.0, 107.0, 62.0, 18.0), "模型名称", 11.5, false, true); + let ai_model_field = make_text_field( + mtm, + top_rect(h, 78.0, 105.0, 122.0, 22.0), + &ai_cfg.model, + "如 qwen2.5:7b / gpt-4o-mini", + ); + let btn_ai_discover = make_button( + mtm, + top_rect(h, 202.0, 104.0, 64.0, 24.0), + "探测模型", + ActionTag::AiTransDiscoverModels as isize, + dispatcher, + action_sel, + ); + let ai_discovered_popup = make_popup( + mtm, + top_rect(h, 266.0, 104.0, 72.0, 24.0), + &["选择…"], + 0, + ActionTag::AiTransModelPopupSelected as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_ai_model); + add_subview(&root, &ai_model_field); + add_subview(&root, &btn_ai_discover); + add_subview(&root, &ai_discovered_popup); + + let lbl_ai_gguf = make_label(mtm, top_rect(h, 14.0, 133.0, 62.0, 18.0), "GGUF路径", 11.5, false, true); + let ai_gguf_field = make_text_field( + mtm, + top_rect(h, 78.0, 131.0, 198.0, 22.0), + &ai_cfg.gguf_model_path, + "直接加载 .gguf 量化模型路径", + ); + let btn_ai_gguf = make_button( + mtm, + top_rect(h, 278.0, 130.0, 60.0, 24.0), + "浏览…", + ActionTag::AiTransBrowseGguf as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_ai_gguf); + add_subview(&root, &ai_gguf_field); + add_subview(&root, &btn_ai_gguf); + + let lbl_ai_tok = make_label(mtm, top_rect(h, 14.0, 159.0, 62.0, 18.0), "分词器", 11.5, false, true); + let ai_tokenizer_field = make_text_field( + mtm, + top_rect(h, 78.0, 157.0, 198.0, 22.0), + &ai_cfg.gguf_tokenizer_path, + "可选 tokenizer.json (留空读内嵌词表)", + ); + let btn_ai_tok = make_button( + mtm, + top_rect(h, 278.0, 156.0, 60.0, 24.0), + "浏览…", + ActionTag::AiTransBrowseTokenizer as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_ai_tok); + add_subview(&root, &ai_tokenizer_field); + add_subview(&root, &btn_ai_tok); + + let lbl_ai_prompt = make_label(mtm, top_rect(h, 14.0, 185.0, 62.0, 18.0), "翻译指令", 11.5, false, true); + let ai_prompt_field = make_text_field( + mtm, + top_rect(h, 78.0, 183.0, 258.0, 22.0), + &ai_cfg.translate_prompt, + "包含 {source} 与 {target} 占位符", + ); + add_subview(&root, &lbl_ai_prompt); + add_subview(&root, &ai_prompt_field); + + // Section 2: AI 对话引擎(左下) + let sec2_title = make_label( + mtm, + top_rect(h, 14.0, 216.0, 165.0, 18.0), + "二、AI 对话引擎", + 12.5, + true, + false, + ); + let btn_sync_from_trans = make_button( + mtm, + top_rect(h, 185.0, 212.0, 152.0, 24.0), + "复用上方翻译模型配置", + ActionTag::ChatCopyFromTrans as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &sec2_title); + add_subview(&root, &btn_sync_from_trans); + + let lbl_chat_backend = make_label(mtm, top_rect(h, 14.0, 242.0, 62.0, 18.0), "接入方式", 11.5, false, true); + let chat_backend_popup = make_popup( + mtm, + top_rect(h, 78.0, 239.0, 195.0, 24.0), + &backend_labels, + chat_backend_idx, + ActionTag::ChatBackendChanged as isize, + dispatcher, + action_sel, + ); + let btn_chat_def_url = make_button( + mtm, + top_rect(h, 276.0, 239.0, 62.0, 24.0), + "默认地址", + ActionTag::ChatFillDefaultUrl as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_chat_backend); + add_subview(&root, &chat_backend_popup); + add_subview(&root, &btn_chat_def_url); + + let lbl_chat_url = make_label(mtm, top_rect(h, 14.0, 269.0, 62.0, 18.0), "服务地址", 11.5, false, true); + let chat_base_url_field = make_text_field( + mtm, + top_rect(h, 78.0, 267.0, 258.0, 22.0), + &chat_cfg.base_url, + "http://127.0.0.1:11434 或 https://api.openai.com/v1", + ); + add_subview(&root, &lbl_chat_url); + add_subview(&root, &chat_base_url_field); + + let lbl_chat_key = make_label(mtm, top_rect(h, 14.0, 295.0, 62.0, 18.0), "API Key", 11.5, false, true); + let chat_key_field = make_secure_field( + mtm, + top_rect(h, 78.0, 293.0, 148.0, 22.0), + if chat_cfg.has_api_key { "已存钥匙串 (留空不改)" } else { "本地服务/GGUF 免填" }, + ); + let chat_key_status_label = make_label( + mtm, + top_rect(h, 230.0, 295.0, 52.0, 18.0), + if chat_cfg.has_api_key { "已保存" } else { "未设置" }, + 11.0, + false, + true, + ); + let btn_chat_clr_key = make_button( + mtm, + top_rect(h, 282.0, 292.0, 56.0, 22.0), + "清除", + ActionTag::ChatClearKey as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_chat_key); + add_subview(&root, &chat_key_field); + add_subview(&root, &chat_key_status_label); + add_subview(&root, &btn_chat_clr_key); + + let lbl_chat_model = make_label(mtm, top_rect(h, 14.0, 321.0, 62.0, 18.0), "模型名称", 11.5, false, true); + let chat_model_field = make_text_field( + mtm, + top_rect(h, 78.0, 319.0, 122.0, 22.0), + &chat_cfg.model, + "如 qwen2.5:7b / gpt-4o-mini", + ); + let btn_chat_discover = make_button( + mtm, + top_rect(h, 202.0, 318.0, 64.0, 24.0), + "探测模型", + ActionTag::ChatDiscoverModels as isize, + dispatcher, + action_sel, + ); + let chat_discovered_popup = make_popup( + mtm, + top_rect(h, 266.0, 318.0, 72.0, 24.0), + &["选择…"], + 0, + ActionTag::ChatModelPopupSelected as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_chat_model); + add_subview(&root, &chat_model_field); + add_subview(&root, &btn_chat_discover); + add_subview(&root, &chat_discovered_popup); + + let lbl_chat_gguf = make_label(mtm, top_rect(h, 14.0, 347.0, 62.0, 18.0), "GGUF路径", 11.5, false, true); + let chat_gguf_field = make_text_field( + mtm, + top_rect(h, 78.0, 345.0, 198.0, 22.0), + &chat_cfg.gguf_model_path, + "直接加载 .gguf 量化模型路径", + ); + let btn_chat_gguf = make_button( + mtm, + top_rect(h, 278.0, 344.0, 60.0, 24.0), + "浏览…", + ActionTag::ChatBrowseGguf as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &lbl_chat_gguf); + add_subview(&root, &chat_gguf_field); + add_subview(&root, &btn_chat_gguf); + + let lbl_chat_prompt = make_label(mtm, top_rect(h, 14.0, 373.0, 62.0, 18.0), "系统提示", 11.5, false, true); + let chat_prompt_field = make_text_field( + mtm, + top_rect(h, 78.0, 371.0, 258.0, 22.0), + &chat_cfg.chat_prompt, + "可选 System Prompt", + ); + add_subview(&root, &lbl_chat_prompt); + add_subview(&root, &chat_prompt_field); + + // Right Column: 全局快捷键 & 外观通用 + let rx = 354.0; + let rw = (w - rx - 14.0).max(290.0); + + let sec3_title = make_label( + mtm, + top_rect(h, rx, 4.0, 180.0, 18.0), + "三、全局快捷键", + 12.5, + true, + false, + ); + let btn_reset_sc = make_button( + mtm, + top_rect(h, rx + rw - 92.0, 2.0, 92.0, 24.0), + "恢复默认热键", + ActionTag::ResetDefaultShortcuts as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &sec3_title); + add_subview(&root, &btn_reset_sc); + + let lbl_sc1 = make_label(mtm, top_rect(h, rx, 32.0, 88.0, 18.0), "呼出翻译窗口", 11.5, false, true); + let sc_toggle_field = make_text_field( + mtm, + top_rect(h, rx + 90.0, 30.0, rw - 90.0, 22.0), + &sc_cfg.toggle, + "如 CmdOrCtrl+Shift+Space", + ); + let lbl_sc2 = make_label(mtm, top_rect(h, rx, 60.0, 88.0, 18.0), "截图 OCR 翻译", 11.5, false, true); + let sc_ocr_field = make_text_field( + mtm, + top_rect(h, rx + 90.0, 58.0, rw - 90.0, 22.0), + &sc_cfg.ocr, + "如 CmdOrCtrl+Shift+S", + ); + let lbl_sc3 = make_label(mtm, top_rect(h, rx, 88.0, 88.0, 18.0), "呼出 AI 对话", 11.5, false, true); + let sc_ai_field = make_text_field( + mtm, + top_rect(h, rx + 90.0, 86.0, rw - 90.0, 22.0), + &sc_cfg.ai_dialog, + "如 Option+Space", + ); + let lbl_sc4 = make_label(mtm, top_rect(h, rx, 116.0, 88.0, 18.0), "划词即时翻译", 11.5, false, true); + let sc_sel_trans_field = make_text_field( + mtm, + top_rect(h, rx + 90.0, 114.0, rw - 90.0, 22.0), + &sc_cfg.selection_translate, + "可与呼出翻译共用同一热键", + ); + let lbl_sc5 = make_label(mtm, top_rect(h, rx, 144.0, 88.0, 18.0), "划词发送 AI", 11.5, false, true); + let sc_sel_ai_field = make_text_field( + mtm, + top_rect(h, rx + 90.0, 142.0, rw - 90.0, 22.0), + &sc_cfg.selection_ai_dialog, + "可与呼出 AI 对话共用同一热键", + ); + + add_subview(&root, &lbl_sc1); + add_subview(&root, &sc_toggle_field); + add_subview(&root, &lbl_sc2); + add_subview(&root, &sc_ocr_field); + add_subview(&root, &lbl_sc3); + add_subview(&root, &sc_ai_field); + add_subview(&root, &lbl_sc4); + add_subview(&root, &sc_sel_trans_field); + add_subview(&root, &lbl_sc5); + add_subview(&root, &sc_sel_ai_field); + + let sec4_title = make_label( + mtm, + top_rect(h, rx, 182.0, 200.0, 18.0), + "四、外观与系统集成", + 12.5, + true, + false, + ); + add_subview(&root, &sec4_title); + + let lbl_opacity = make_label(mtm, top_rect(h, rx, 210.0, 82.0, 18.0), "面板不透明度", 11.5, false, true); + let opacity_slider = make_slider( + mtm, + top_rect(h, rx + 86.0, 208.0, rw - 136.0, 22.0), + PANEL_OPACITY_MIN, + PANEL_OPACITY_MAX, + app_cfg.panel_opacity, + ActionTag::OpacitySliderChanged as isize, + dispatcher, + action_sel, + ); + let opacity_val_label = make_label( + mtm, + top_rect(h, rx + rw - 46.0, 210.0, 46.0, 18.0), + &format!("{:.0}%", app_cfg.panel_opacity * 100.0), + 11.5, + false, + false, + ); + add_subview(&root, &lbl_opacity); + add_subview(&root, &opacity_slider); + add_subview(&root, &opacity_val_label); + + let autostart_chk = make_checkbox( + mtm, + top_rect(h, rx, 240.0, 220.0, 22.0), + "登录 macOS 时开机自动静默启动", + ttree_platform_macos::autostart::is_enabled(), + 0, + dispatcher, + action_sel, + ); + add_subview(&root, &autostart_chk); + + let notes_label = make_label( + mtm, + top_rect(h, rx, 272.0, rw, 84.0), + "提示:\n• Ollama 默认端口 11434,LM Studio 默认 1234,MLX 默认 8080\n• 选择「直接加载本地 GGUF 模型」可在 Apple Silicon Metal GPU 上原生运行量化模型\n• 修改后点击下方「保存全部配置」立即生效", + 11.0, + false, + true, + ); + add_subview(&root, ¬es_label); + + let save_btn = make_button( + mtm, + top_rect(h, rx, 364.0, rw, 34.0), + "💾 保存全部配置 (立即生效)", + ActionTag::SaveSettings as isize, + dispatcher, + action_sel, + ); + add_subview(&root, &save_btn); + + SettingsViewWidgets { + root, + ai_enabled_chk, + ai_backend_popup, + ai_base_url_field, + ai_key_field, + ai_key_status_label, + ai_model_field, + ai_discovered_popup, + ai_gguf_field, + ai_tokenizer_field, + ai_prompt_field, + chat_backend_popup, + chat_base_url_field, + chat_key_field, + chat_key_status_label, + chat_model_field, + chat_discovered_popup, + chat_gguf_field, + chat_prompt_field, + sc_toggle_field, + sc_ocr_field, + sc_ai_field, + sc_sel_trans_field, + sc_sel_ai_field, + opacity_slider, + opacity_val_label, + autostart_chk, + } + } + + pub fn set_status(&self, msg: &str) { + set_field_string(&self.status_label, msg); + } + + pub fn switch_tab(&mut self, tab: TabMode) { + if self.active_tab == TabMode::Settings && tab != TabMode::Settings { + self.set_pinned(self.pin_before_settings); + } else if self.active_tab != TabMode::Settings && tab == TabMode::Settings { + self.pin_before_settings = self.is_pinned; + self.set_pinned(true); + } + + self.active_tab = tab; + self.translate_ui.root.setHidden(tab != TabMode::Translate); + self.chat_ui.root.setHidden(tab != TabMode::Chat); + self.settings_ui.root.setHidden(tab != TabMode::Settings); + + let t_title = if tab == TabMode::Translate { "● 翻译" } else { "翻译" }; + let c_title = if tab == TabMode::Chat { "● AI 对话" } else { "AI 对话" }; + let s_title = if tab == TabMode::Settings { "● 设置" } else { "设置" }; + self.tab_translate_btn.setTitle(&NSString::from_str(t_title)); + self.tab_chat_btn.setTitle(&NSString::from_str(c_title)); + self.tab_settings_btn.setTitle(&NSString::from_str(s_title)); + + match tab { + TabMode::Translate => { + let _ = self.window.makeFirstResponder(Some(&self.translate_ui.input_tv)); + } + TabMode::Chat => { + let _ = self.window.makeFirstResponder(Some(&self.chat_ui.input_tv)); + } + TabMode::Settings => {} + } + } + + pub fn set_pinned(&mut self, pinned: bool) { + self.is_pinned = pinned; + let level = if pinned { + NSFloatingWindowLevel + } else { + NSNormalWindowLevel + }; + self.window.setLevel(level); + let title = if pinned { "📌 已置顶" } else { "置顶" }; + self.pin_btn.setTitle(&NSString::from_str(title)); + } + + pub fn show_and_focus_window(&mut self) { + let app = NSApplication::sharedApplication(self.mtm); + if !self.window.isVisible() { + if let Some(screen) = NSScreen::mainScreen(self.mtm) { + let vf = screen.visibleFrame(); + let wf = self.window.frame(); + let x = vf.origin.x + (vf.size.width - wf.size.width) * 0.5; + let y = vf.origin.y + (vf.size.height - wf.size.height) * 0.62; + self.window.setFrameOrigin(NSPoint::new(x, y)); + } + } + #[allow(deprecated)] + app.activateIgnoringOtherApps(true); + self.window.makeKeyAndOrderFront(None); + match self.active_tab { + TabMode::Translate => { + let _ = self.window.makeFirstResponder(Some(&self.translate_ui.input_tv)); + } + TabMode::Chat => { + let _ = self.window.makeFirstResponder(Some(&self.chat_ui.input_tv)); + } + TabMode::Settings => {} + } + } + + pub fn hide_window(&mut self) { + self.window.orderOut(None); + } + + pub fn refresh_ai_header_badge(&self) { + let cfg = self.settings.ai_translation_for_request(); + let label = if !cfg.enabled { + "AI 翻译 (已在设置中关闭)".to_string() + } else { + match cfg.backend { + AiBackendKind::OpenAiCompatible => format!("AI 翻译 (云端 · {})", cfg.model), + AiBackendKind::Ollama => format!("AI 翻译 (Ollama · {})", cfg.model), + AiBackendKind::LocalServer => format!("AI 翻译 (本地服务 · {})", cfg.model), + AiBackendKind::CandleGguf => { + let name = std::path::Path::new(&cfg.gguf_model_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("未选 .gguf"); + format!("AI 翻译 (GGUF · {name})") + } + } + }; + set_field_string(&self.translate_ui.ai_header_label, &label); + } + + pub fn selected_source_code(&self) -> &'static str { + let idx = self.translate_ui.source_popup.indexOfSelectedItem(); + SOURCE_LANGUAGES + .get(idx.max(0) as usize) + .map(|l| l.code) + .unwrap_or("auto") + } + + pub fn selected_target_code(&self) -> &'static str { + let idx = self.translate_ui.target_popup.indexOfSelectedItem(); + TARGET_LANGUAGES + .get(idx.max(0) as usize) + .map(|l| l.code) + .unwrap_or(ADAPTIVE_TARGET) + } + + pub fn start_translation(&mut self) { + let raw_text = get_text_view_string(&self.translate_ui.input_tv); + let trimmed = raw_text.trim().to_string(); + self.pending_debounce_since = None; + + if trimmed.is_empty() { + self.translate_cancel.cancel_current(); + set_text_view_string(&self.translate_ui.web_tv, ""); + set_text_view_string(&self.translate_ui.ai_tv, ""); + self.last_translated_source.clear(); + self.set_status("就绪"); + return; + } + + let cancel_token = self.translate_cancel.next(); + self.translate_gen = self.translate_gen.wrapping_add(1); + let gen = self.translate_gen; + self.last_translated_source = trimmed.clone(); + + let src_ui = self.selected_source_code(); + let tgt_ui = self.selected_target_code(); + let (resolved_src, resolved_tgt) = resolve_language_pair(&trimmed, src_ui, tgt_ui); + + set_text_view_string(&self.translate_ui.web_tv, "正在极速翻译…"); + let ai_cfg = self.settings.ai_translation_for_request(); + self.refresh_ai_header_badge(); + if ai_cfg.enabled { + set_text_view_string(&self.translate_ui.ai_tv, "正在请求 AI 流式译文…"); + } else { + set_text_view_string(&self.translate_ui.ai_tv, "(AI 翻译未启用,可在「设置」中开启或接入本地 Ollama / GGUF 模型)"); + } + self.set_status(&format!("正在翻译 ({resolved_src} → {resolved_tgt})…")); + + // 1. 并发启动极速网页翻译(Google POST + Bing 内存缓存 Token 竞速) + let web_tx = self.ui_tx.clone(); + let web_text = trimmed.clone(); + let web_src = resolved_src.clone(); + let web_tgt = resolved_tgt.clone(); + let web_cancel = cancel_token.clone(); + self.rt.spawn(async move { + let res = + ttree_translate::translate_web(&web_text, &web_src, &web_tgt, &web_cancel).await; + if !web_cancel.is_cancelled() { + let _ = web_tx.send(UiEvent::WebTranslateResult { gen, result: res }); + } + }); + + // 2. 并发启动 AI 流式翻译(云端 / Ollama / LM Studio / 本地 GGUF) + if ai_cfg.enabled { + let ai_tx = self.ui_tx.clone(); + let ai_text = trimmed; + let ai_cancel = cancel_token; + self.rt.spawn(async move { + let delta_tx = ai_tx.clone(); + let res = ttree_ai::stream_translate( + &ai_cfg, + &ai_text, + &resolved_src, + &resolved_tgt, + &ai_cancel, + move |delta| { + let _ = delta_tx.send(UiEvent::AiTranslateDelta { + gen, + delta: delta.to_string(), + }); + }, + ) + .await; + if !ai_cancel.is_cancelled() { + let _ = ai_tx.send(UiEvent::AiTranslateFinished { + gen, + error: res.err(), + }); + } + }); + } + } + + pub fn trigger_ocr(&mut self) { + self.hide_window(); + self.set_status("正在框选截图区域…"); + let tx = self.ui_tx.clone(); + self.rt.spawn(async move { + let res = tokio::task::spawn_blocking(ttree_ocr::capture_and_recognize) + .await + .unwrap_or_else(|e| Err(format!("OCR 线程异常: {e}"))); + let _ = tx.send(UiEvent::OcrCompleted { outcome: res }); + }); + } + + pub fn handle_hotkey_action(&mut self, action: ShortcutAction) { + let sc = self.settings.shortcuts(); + match action { + ShortcutAction::Toggle => { + if self.window.isVisible() + && self.window.isKeyWindow() + && self.active_tab == TabMode::Translate + { + self.hide_window(); + } else { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + } + } + ShortcutAction::Ocr => { + self.trigger_ocr(); + } + ShortcutAction::AiDialog => { + if self.window.isVisible() + && self.window.isKeyWindow() + && self.active_tab == TabMode::Chat + { + self.hide_window(); + } else { + self.switch_tab(TabMode::Chat); + self.show_and_focus_window(); + } + } + ShortcutAction::SelectionTranslate => { + let shared_with_toggle = sc.toggle.trim() == sc.selection_translate.trim(); + if shared_with_toggle + && self.window.isVisible() + && self.window.isKeyWindow() + && self.active_tab == TabMode::Translate + { + self.hide_window(); + return; + } + let tx = self.ui_tx.clone(); + self.rt.spawn(async move { + let captured = tokio::task::spawn_blocking( + ttree_platform_macos::selection::capture_selected_text, + ) + .await + .ok() + .and_then(Result::ok) + .flatten(); + let _ = tx.send(UiEvent::SelectionCapturedForTranslate { + text: captured, + fallback_toggle: shared_with_toggle, + }); + }); + } + ShortcutAction::SelectionAiDialog => { + let shared_with_ai = sc.ai_dialog.trim() == sc.selection_ai_dialog.trim(); + if shared_with_ai + && self.window.isVisible() + && self.window.isKeyWindow() + && self.active_tab == TabMode::Chat + { + self.hide_window(); + return; + } + let tx = self.ui_tx.clone(); + self.rt.spawn(async move { + let captured = tokio::task::spawn_blocking( + ttree_platform_macos::selection::capture_selected_text, + ) + .await + .ok() + .and_then(Result::ok) + .flatten(); + let _ = tx.send(UiEvent::SelectionCapturedForChat { + text: captured, + fallback_toggle: shared_with_ai, + }); + }); + } + } + } + + pub fn on_action(&mut self, tag: ActionTag) { + match tag { + ActionTag::TabTranslate => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + } + ActionTag::TabChat => { + self.switch_tab(TabMode::Chat); + self.show_and_focus_window(); + } + ActionTag::TabSettings => { + self.switch_tab(TabMode::Settings); + self.show_and_focus_window(); + } + ActionTag::TriggerOcr => self.trigger_ocr(), + ActionTag::TogglePin => self.set_pinned(!self.is_pinned), + ActionTag::TrayQuit => { + let app = NSApplication::sharedApplication(self.mtm); + app.terminate(None); + } + ActionTag::HideOrEscape => { + if self.active_tab == TabMode::Settings { + self.switch_tab(TabMode::Translate); + } else { + self.hide_window(); + } + } + ActionTag::TranslateNow => self.start_translation(), + ActionTag::ClearTranslate => { + self.translate_cancel.cancel_current(); + set_text_view_string(&self.translate_ui.input_tv, ""); + set_text_view_string(&self.translate_ui.web_tv, ""); + set_text_view_string(&self.translate_ui.ai_tv, ""); + self.last_observed_source.clear(); + self.last_translated_source.clear(); + self.pending_debounce_since = None; + self.set_status("已清空"); + } + ActionTag::SwapLanguages => { + let src_code = self.selected_source_code(); + let tgt_code = self.selected_target_code(); + if src_code != "auto" && tgt_code != ADAPTIVE_TARGET { + let new_src_code = if tgt_code.starts_with("zh") { + "zh-CN" + } else { + tgt_code + }; + if let Some(new_src_idx) = + SOURCE_LANGUAGES.iter().position(|l| l.code == new_src_code) + { + if let Some(new_tgt_idx) = + TARGET_LANGUAGES.iter().position(|l| l.code == src_code) + { + self.translate_ui + .source_popup + .selectItemAtIndex(new_src_idx as isize); + self.translate_ui + .target_popup + .selectItemAtIndex(new_tgt_idx as isize); + self.start_translation(); + } + } + } + } + ActionTag::SourceLangChanged | ActionTag::TargetLangChanged => { + if !get_text_view_string(&self.translate_ui.input_tv) + .trim() + .is_empty() + { + self.start_translation(); + } + } + ActionTag::CopyWebResult => { + let text = get_text_view_string(&self.translate_ui.web_tv); + if copy_to_pasteboard(&text) { + self.set_status("✓ 已复制网页翻译结果"); + } + } + ActionTag::CopyAiResult => { + let text = get_text_view_string(&self.translate_ui.ai_tv); + if copy_to_pasteboard(&text) { + self.set_status("✓ 已复制 AI 翻译结果"); + } + } + ActionTag::SendChat => self.send_chat_from_input(), + ActionTag::NewConversation => { + self.chat_cancel.cancel_current(); + self.chat_streaming = false; + let _ = self.history.start_new_conversation(); + self.active_conversation_id = None; + self.current_messages.clear(); + self.streaming_assistant_buffer.clear(); + self.refresh_conversations_popup(); + self.render_chat_transcript(); + self.set_status("已开启新对话"); + } + ActionTag::ClearChatHistory => { + self.chat_cancel.cancel_current(); + self.chat_streaming = false; + let _ = self.history.clear(); + self.conversations.clear(); + self.active_conversation_id = None; + self.current_messages.clear(); + self.streaming_assistant_buffer.clear(); + self.refresh_conversations_popup(); + self.render_chat_transcript(); + self.set_status("✓ 对话历史已清空"); + } + ActionTag::StopChatStream => { + self.chat_cancel.cancel_current(); + self.chat_streaming = false; + self.set_status("已停止生成"); + } + ActionTag::SelectConversation => { + let idx = self.chat_ui.conv_popup.indexOfSelectedItem(); + if idx >= 0 && (idx as usize) < self.conversations.len() { + let conv = &self.conversations[idx as usize]; + self.active_conversation_id = Some(conv.id); + self.current_messages = conv.messages.clone(); + self.streaming_assistant_buffer.clear(); + self.render_chat_transcript(); + } + } + ActionTag::CopyLastReply => { + if let Some(last) = self + .current_messages + .iter() + .rev() + .find(|m| m.role == "assistant") + { + if copy_to_pasteboard(&last.content) { + self.set_status("✓ 已复制最后一条 AI 回复"); + } + } + } + ActionTag::AiTransBackendChanged => { + let backend = self.selected_ai_trans_backend(); + let cur_url = get_field_string(&self.settings_ui.ai_base_url_field); + if cur_url.trim().is_empty() + || cur_url == "https://api.openai.com/v1" + || cur_url == "http://127.0.0.1:11434" + || cur_url == "http://127.0.0.1:1234/v1" + { + set_field_string( + &self.settings_ui.ai_base_url_field, + backend.default_base_url(), + ); + } + } + ActionTag::AiTransFillDefaultUrl => { + let backend = self.selected_ai_trans_backend(); + set_field_string( + &self.settings_ui.ai_base_url_field, + backend.default_base_url(), + ); + } + ActionTag::AiTransDiscoverModels => self.discover_models(false), + ActionTag::AiTransModelPopupSelected => { + if let Some(title) = self.settings_ui.ai_discovered_popup.titleOfSelectedItem() { + let s = title.to_string(); + if s != "选择…" { + set_field_string(&self.settings_ui.ai_model_field, &s); + } + } + } + ActionTag::AiTransBrowseGguf => { + if let Some(path) = self.pick_file(&["gguf"]) { + set_field_string(&self.settings_ui.ai_gguf_field, &path); + self.settings_ui.ai_backend_popup.selectItemAtIndex(3); + } + } + ActionTag::AiTransBrowseTokenizer => { + if let Some(path) = self.pick_file(&["json"]) { + set_field_string(&self.settings_ui.ai_tokenizer_field, &path); + } + } + ActionTag::AiTransClearKey => { + self.pending_clear_ai_key = true; + set_field_string(&self.settings_ui.ai_key_field, ""); + set_field_string(&self.settings_ui.ai_key_status_label, "待清除"); + } + ActionTag::ChatBackendChanged => { + let backend = self.selected_chat_backend(); + let cur_url = get_field_string(&self.settings_ui.chat_base_url_field); + if cur_url.trim().is_empty() + || cur_url == "https://api.openai.com/v1" + || cur_url == "http://127.0.0.1:11434" + || cur_url == "http://127.0.0.1:1234/v1" + { + set_field_string( + &self.settings_ui.chat_base_url_field, + backend.default_base_url(), + ); + } + } + ActionTag::ChatFillDefaultUrl => { + let backend = self.selected_chat_backend(); + set_field_string( + &self.settings_ui.chat_base_url_field, + backend.default_base_url(), + ); + } + ActionTag::ChatDiscoverModels => self.discover_models(true), + ActionTag::ChatModelPopupSelected => { + if let Some(title) = self.settings_ui.chat_discovered_popup.titleOfSelectedItem() { + let s = title.to_string(); + if s != "选择…" { + set_field_string(&self.settings_ui.chat_model_field, &s); + } + } + } + ActionTag::ChatBrowseGguf => { + if let Some(path) = self.pick_file(&["gguf"]) { + set_field_string(&self.settings_ui.chat_gguf_field, &path); + self.settings_ui.chat_backend_popup.selectItemAtIndex(3); + } + } + ActionTag::ChatCopyFromTrans => { + let idx = self.settings_ui.ai_backend_popup.indexOfSelectedItem(); + self.settings_ui.chat_backend_popup.selectItemAtIndex(idx); + set_field_string( + &self.settings_ui.chat_base_url_field, + &get_field_string(&self.settings_ui.ai_base_url_field), + ); + set_field_string( + &self.settings_ui.chat_model_field, + &get_field_string(&self.settings_ui.ai_model_field), + ); + set_field_string( + &self.settings_ui.chat_gguf_field, + &get_field_string(&self.settings_ui.ai_gguf_field), + ); + self.set_status("已将翻译模型配置同步至对话引擎表单"); + } + ActionTag::ChatClearKey => { + self.pending_clear_chat_key = true; + set_field_string(&self.settings_ui.chat_key_field, ""); + set_field_string(&self.settings_ui.chat_key_status_label, "待清除"); + } + ActionTag::ResetDefaultShortcuts => { + let def = ShortcutConfig::default(); + set_field_string(&self.settings_ui.sc_toggle_field, &def.toggle); + set_field_string(&self.settings_ui.sc_ocr_field, &def.ocr); + set_field_string(&self.settings_ui.sc_ai_field, &def.ai_dialog); + set_field_string( + &self.settings_ui.sc_sel_trans_field, + &def.selection_translate, + ); + set_field_string(&self.settings_ui.sc_sel_ai_field, &def.selection_ai_dialog); + self.set_status("已填入默认快捷键,点击「保存全部配置」生效"); + } + ActionTag::OpacitySliderChanged => { + let val = AppearanceConfig::clamp_opacity( + self.settings_ui.opacity_slider.doubleValue(), + ); + self.window.setAlphaValue(val); + set_field_string( + &self.settings_ui.opacity_val_label, + &format!("{:.0}%", val * 100.0), + ); + } + ActionTag::SaveSettings => self.save_all_settings(), + } + } + + fn selected_ai_trans_backend(&self) -> AiBackendKind { + let idx = self.settings_ui.ai_backend_popup.indexOfSelectedItem(); + AiBackendKind::ALL + .get(idx.max(0) as usize) + .copied() + .unwrap_or(AiBackendKind::OpenAiCompatible) + } + + fn selected_chat_backend(&self) -> AiBackendKind { + let idx = self.settings_ui.chat_backend_popup.indexOfSelectedItem(); + AiBackendKind::ALL + .get(idx.max(0) as usize) + .copied() + .unwrap_or(AiBackendKind::OpenAiCompatible) + } + + fn discover_models(&mut self, for_chat: bool) { + let (backend, base_url) = if for_chat { + ( + self.selected_chat_backend(), + get_field_string(&self.settings_ui.chat_base_url_field), + ) + } else { + ( + self.selected_ai_trans_backend(), + get_field_string(&self.settings_ui.ai_base_url_field), + ) + }; + self.set_status("正在探测本机已加载模型…"); + let tx = self.ui_tx.clone(); + self.rt.spawn(async move { + let res = ttree_ai::discover_local_models(backend, &base_url).await; + let _ = tx.send(UiEvent::ModelsDiscovered { + for_chat, + result: res, + }); + }); + } + + fn pick_file(&mut self, allowed_exts: &[&str]) -> Option { + self.is_modal_open = true; + let panel = NSOpenPanel::openPanel(self.mtm); + panel.setCanChooseFiles(true); + panel.setCanChooseDirectories(false); + panel.setAllowsMultipleSelection(false); + let response = panel.runModal(); + self.is_modal_open = false; + if response != NSModalResponseOK { + return None; + } + let urls = panel.URLs(); + let url = urls.firstObject()?; + let path = url.path()?.to_string(); + if !allowed_exts.is_empty() { + let ext_ok = std::path::Path::new(&path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| allowed_exts.iter().any(|a| a.eq_ignore_ascii_case(ext))); + if !ext_ok { + self.set_status(&format!( + "⚠️ 请选择 .{} 格式的文件", + allowed_exts.join("/") + )); + return None; + } + } + Some(path) + } + + fn save_all_settings(&mut self) { + let ai_input = AiTranslationConfig { + enabled: self.settings_ui.ai_enabled_chk.state() == NSControlStateValueOn, + backend: self.selected_ai_trans_backend(), + base_url: get_field_string(&self.settings_ui.ai_base_url_field), + has_api_key: false, + api_key: get_field_string(&self.settings_ui.ai_key_field), + clear_api_key: self.pending_clear_ai_key, + model: get_field_string(&self.settings_ui.ai_model_field), + gguf_model_path: get_field_string(&self.settings_ui.ai_gguf_field), + gguf_tokenizer_path: get_field_string(&self.settings_ui.ai_tokenizer_field), + translate_prompt: get_field_string(&self.settings_ui.ai_prompt_field), + }; + if let Err(e) = self.settings.save_ai_translation(&ai_input) { + self.set_status(&format!("❌ AI 翻译配置错误: {e}")); + return; + } + self.pending_clear_ai_key = false; + + let chat_input = ChatAiConfig { + backend: self.selected_chat_backend(), + base_url: get_field_string(&self.settings_ui.chat_base_url_field), + has_api_key: false, + api_key: get_field_string(&self.settings_ui.chat_key_field), + clear_api_key: self.pending_clear_chat_key, + model: get_field_string(&self.settings_ui.chat_model_field), + gguf_model_path: get_field_string(&self.settings_ui.chat_gguf_field), + gguf_tokenizer_path: get_field_string(&self.settings_ui.ai_tokenizer_field), + chat_prompt: get_field_string(&self.settings_ui.chat_prompt_field), + }; + if let Err(e) = self.settings.save_chat_ai(&chat_input) { + self.set_status(&format!("❌ AI 对话配置错误: {e}")); + return; + } + self.pending_clear_chat_key = false; + + let sc_input = ShortcutConfig { + toggle: get_field_string(&self.settings_ui.sc_toggle_field), + ocr: get_field_string(&self.settings_ui.sc_ocr_field), + ai_dialog: get_field_string(&self.settings_ui.sc_ai_field), + selection_translate: get_field_string(&self.settings_ui.sc_sel_trans_field), + selection_ai_dialog: get_field_string(&self.settings_ui.sc_sel_ai_field), + }; + match parse_shortcut_config(&sc_input) { + Ok(parsed) => { + for old_hk in self.registered_hotkeys.drain(..) { + let _ = self.hotkey_mgr.unregister(old_hk); + } + for hk in parsed.unique_hotkeys() { + if self.hotkey_mgr.register(hk).is_ok() { + self.registered_hotkeys.push(hk); + } + } + self.parsed_shortcuts = parsed; + let _ = self.settings.save_shortcuts(&sc_input); + } + Err(e) => { + self.set_status(&format!("❌ 快捷键无效: {e}")); + return; + } + } + + let opacity = + AppearanceConfig::clamp_opacity(self.settings_ui.opacity_slider.doubleValue()); + let _ = self.settings.save_appearance(AppearanceConfig { + panel_opacity: opacity, + }); + self.window.setAlphaValue(opacity); + + let want_autostart = self.settings_ui.autostart_chk.state() == NSControlStateValueOn; + if want_autostart != ttree_platform_macos::autostart::is_enabled() { + let _ = ttree_platform_macos::autostart::set_enabled(want_autostart); + } + + let saved_ai = self.settings.ai_translation_for_request(); + let saved_chat = self.settings.chat_ai_for_request(); + set_field_string(&self.settings_ui.ai_key_field, ""); + set_field_string( + &self.settings_ui.ai_key_status_label, + if saved_ai.has_api_key { "已保存" } else { "未设置" }, + ); + set_field_string(&self.settings_ui.chat_key_field, ""); + set_field_string( + &self.settings_ui.chat_key_status_label, + if saved_chat.has_api_key { "已保存" } else { "未设置" }, + ); + self.refresh_ai_header_badge(); + self.set_status("✓ 全部配置已保存并立即生效"); + } + + pub fn send_chat_from_input(&mut self) { + let raw = get_text_view_string(&self.chat_ui.input_tv); + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + return; + } + set_text_view_string(&self.chat_ui.input_tv, ""); + self.send_chat_text(trimmed); + } + + pub fn send_chat_text(&mut self, text: String) { + let cancel_token = self.chat_cancel.next(); + self.chat_gen = self.chat_gen.wrapping_add(1); + let gen = self.chat_gen; + + self.current_messages.push(ChatMessage::user(text)); + self.streaming_assistant_buffer.clear(); + self.chat_streaming = true; + self.render_chat_transcript(); + self.set_status("AI 正在思考并流式回复…"); + + let cfg = self.settings.chat_ai_for_request(); + let context = recent_chat_context(&self.current_messages); + let tx = self.ui_tx.clone(); + + self.rt.spawn(async move { + let delta_tx = tx.clone(); + let res = ttree_ai::stream_chat(&cfg, &context, &cancel_token, move |delta| { + let _ = delta_tx.send(UiEvent::ChatDelta { + gen, + delta: delta.to_string(), + }); + }) + .await; + if !cancel_token.is_cancelled() { + let _ = tx.send(UiEvent::ChatFinished { + gen, + error: res.err(), + }); + } + }); + } + + pub fn refresh_conversations_popup(&mut self) { + self.conversations = self.history.read_conversations().unwrap_or_default(); + self.chat_ui.conv_popup.removeAllItems(); + if self.conversations.is_empty() { + self.chat_ui + .conv_popup + .addItemWithTitle(&NSString::from_str("当前新对话")); + return; + } + for (i, conv) in self.conversations.iter().enumerate() { + let title = if conv.summary.is_empty() { + format!("对话 #{}", i + 1) + } else { + format!("{}. {}", i + 1, conv.summary) + }; + self.chat_ui + .conv_popup + .addItemWithTitle(&NSString::from_str(&title)); + } + if let Some(active_id) = self.active_conversation_id { + if let Some(pos) = self.conversations.iter().position(|c| c.id == active_id) { + self.chat_ui.conv_popup.selectItemAtIndex(pos as isize); + } + } else { + self.chat_ui + .conv_popup + .addItemWithTitle(&NSString::from_str("● 当前新对话")); + let count = self.chat_ui.conv_popup.numberOfItems(); + self.chat_ui.conv_popup.selectItemAtIndex(count - 1); + } + } + + pub fn render_chat_transcript(&self) { + let mut out = String::new(); + if self.current_messages.is_empty() && self.streaming_assistant_buffer.is_empty() { + out.push_str("欢迎使用 TTREE AI 对话(支持云端 OpenAI / 本机 Ollama / LM Studio / 直接加载 .gguf 模型)。\n在下方输入框按 Enter 发送,Shift+Enter 换行。"); + } else { + for msg in &self.current_messages { + let prefix = if msg.role == "user" { + "【你】" + } else { + "【AI】" + }; + out.push_str(prefix); + out.push('\n'); + out.push_str(&msg.content); + out.push_str("\n\n"); + } + if self.chat_streaming { + out.push_str("【AI】\n"); + out.push_str(&self.streaming_assistant_buffer); + out.push_str(" ▍"); + } + } + let s = NSString::from_str(&out); + self.chat_ui.transcript_tv.setString(&s); + let len = s.length(); + self.chat_ui + .transcript_tv + .scrollRangeToVisible(NSRange::new(len, 0)); + } + + pub fn handle_ui_event(&mut self, event: UiEvent) { + match event { + UiEvent::WebTranslateResult { gen, result } => { + if gen != self.translate_gen { + return; + } + match result { + Ok(translated) => { + set_text_view_string(&self.translate_ui.web_tv, &translated); + self.set_status("✓ 网页翻译完成"); + } + Err(err) => { + set_text_view_string( + &self.translate_ui.web_tv, + &format!("⚠️ 网页翻译失败: {err}"), + ); + self.set_status("⚠️ 网页翻译失败"); + } + } + } + UiEvent::AiTranslateDelta { gen, delta } => { + if gen != self.translate_gen { + return; + } + let current = get_text_view_string(&self.translate_ui.ai_tv); + if current == "正在请求 AI 流式译文…" { + set_text_view_string(&self.translate_ui.ai_tv, &delta); + } else { + append_text_view_string(&self.translate_ui.ai_tv, &delta, true); + } + } + UiEvent::AiTranslateFinished { gen, error } => { + if gen != self.translate_gen { + return; + } + if let Some(err) = error { + set_text_view_string(&self.translate_ui.ai_tv, &format!("⚠️ {err}")); + } else { + self.set_status("✓ 网页与 AI 翻译全部完成"); + } + } + UiEvent::OcrCompleted { outcome } => match outcome { + Ok(OcrOutcome::Text(text)) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + set_text_view_string(&self.translate_ui.input_tv, &text); + self.last_observed_source = text; + self.start_translation(); + self.set_status("✓ 截图 OCR 识别成功,正在翻译…"); + } + Ok(OcrOutcome::Cancelled) => { + self.set_status("已取消截图"); + } + Ok(OcrOutcome::EmptyText) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + set_text_view_string( + &self.translate_ui.web_tv, + "⚠️ 所选截图区域未识别到任何文字,请重新框选包含清晰文本的区域。", + ); + self.set_status("⚠️ 截图未识别出文字"); + } + Ok(OcrOutcome::PermissionDenied(msg)) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + set_text_view_string(&self.translate_ui.web_tv, &format!("🔒 {msg}")); + self.set_status("🔒 缺少 macOS 录屏权限"); + } + Ok(OcrOutcome::Busy) => { + self.set_status("正在进行截图框选…"); + } + Err(err) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + set_text_view_string( + &self.translate_ui.web_tv, + &format!("❌ OCR 错误: {err}"), + ); + self.set_status("❌ OCR 失败"); + } + }, + UiEvent::SelectionCapturedForTranslate { + text, + fallback_toggle, + } => match text { + Some(selected) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + set_text_view_string(&self.translate_ui.input_tv, &selected); + self.last_observed_source = selected; + self.start_translation(); + } + None if fallback_toggle => { + self.switch_tab(TabMode::Translate); + self.show_and_focus_window(); + } + None => {} + }, + UiEvent::SelectionCapturedForChat { + text, + fallback_toggle, + } => match text { + Some(selected) => { + self.switch_tab(TabMode::Chat); + self.show_and_focus_window(); + let _ = self.history.start_new_conversation(); + self.active_conversation_id = None; + self.current_messages.clear(); + self.send_chat_text(selected); + } + None if fallback_toggle => { + self.switch_tab(TabMode::Chat); + self.show_and_focus_window(); + } + None => {} + }, + UiEvent::ChatDelta { gen, delta } => { + if gen != self.chat_gen { + return; + } + self.streaming_assistant_buffer.push_str(&delta); + self.render_chat_transcript(); + } + UiEvent::ChatFinished { gen, error } => { + if gen != self.chat_gen { + return; + } + self.chat_streaming = false; + if let Some(err) = error { + self.current_messages + .push(ChatMessage::assistant(format!("⚠️ {err}"))); + self.set_status("⚠️ AI 回复出错"); + } else { + let reply = std::mem::take(&mut self.streaming_assistant_buffer); + if !reply.trim().is_empty() { + let user_msg = self + .current_messages + .iter() + .rev() + .find(|m| m.role == "user") + .cloned(); + let assistant_msg = ChatMessage::assistant(reply); + self.current_messages.push(assistant_msg.clone()); + if let Some(u) = user_msg { + let _ = self + .history + .append_round(self.active_conversation_id, &[u, assistant_msg]); + self.refresh_conversations_popup(); + if self.active_conversation_id.is_none() { + self.active_conversation_id = + self.conversations.last().map(|c| c.id); + } + } + } + self.set_status("✓ AI 回复完成"); + } + self.render_chat_transcript(); + } + UiEvent::ModelsDiscovered { for_chat, result } => match result { + Ok(models) => { + let popup = if for_chat { + &self.settings_ui.chat_discovered_popup + } else { + &self.settings_ui.ai_discovered_popup + }; + let field = if for_chat { + &self.settings_ui.chat_model_field + } else { + &self.settings_ui.ai_model_field + }; + popup.removeAllItems(); + for m in &models { + popup.addItemWithTitle(&NSString::from_str(m)); + } + if let Some(first) = models.first() { + set_field_string(field, first); + } + self.set_status(&format!("✓ 成功探测到 {} 个本地模型", models.len())); + } + Err(err) => { + self.set_status(&format!("⚠️ {err}")); + } + }, + } + } + + pub fn poll_tick(&mut self) { + // 1. 监控输入框文本变化并执行 420ms 防抖自动翻译 + if self.window.isVisible() && self.active_tab == TabMode::Translate { + let current_src = get_text_view_string(&self.translate_ui.input_tv); + if current_src != self.last_observed_source { + self.last_observed_source = current_src; + self.pending_debounce_since = Some(Instant::now()); + } + if let Some(since) = self.pending_debounce_since { + if since.elapsed() >= AUTO_TRANSLATE_DEBOUNCE { + self.pending_debounce_since = None; + let trimmed = self.last_observed_source.trim().to_string(); + if trimmed != self.last_translated_source { + self.start_translation(); + } + } + } + } + + // 2. 窗口尺寸变更防抖持久化 + if let Some(since) = self.pending_resize_save { + if since.elapsed() >= RESIZE_SAVE_DEBOUNCE { + self.pending_resize_save = None; + let (w, h) = self.last_known_size; + self.settings.save_window_size(w, h); + } + } + } + + pub fn on_window_did_resign_key(&mut self) { + if !self.is_pinned && self.active_tab != TabMode::Settings && !self.is_modal_open { + self.hide_window(); + } + } + + pub fn on_window_did_resize(&mut self) { + let Some(content_view) = self.window.contentView() else { + return; + }; + let bounds = content_view.bounds(); + let w = bounds.size.width.max(WINDOW_MIN_WIDTH); + let h = bounds.size.height.max(WINDOW_MIN_HEIGHT); + if (w - self.last_known_size.0).abs() < 0.5 && (h - self.last_known_size.1).abs() < 0.5 { + return; + } + self.last_known_size = (w, h); + self.pending_resize_save = Some(Instant::now()); + + self.effect_view.setFrame(bounds); + self.tab_translate_btn + .setFrame(top_rect(h, 14.0, 10.0, 74.0, 28.0)); + self.tab_chat_btn + .setFrame(top_rect(h, 92.0, 10.0, 86.0, 28.0)); + self.tab_settings_btn + .setFrame(top_rect(h, 182.0, 10.0, 72.0, 28.0)); + self.status_label + .setFrame(top_rect(h, 264.0, 14.0, (w - 445.0).max(120.0), 20.0)); + self.ocr_btn + .setFrame(top_rect(h, w - 172.0, 10.0, 86.0, 28.0)); + self.pin_btn + .setFrame(top_rect(h, w - 82.0, 10.0, 68.0, 28.0)); + + let body_h = (h - 46.0).max(320.0); + let body_frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(w, body_h)); + + // Relayout Translate view + self.translate_ui.root.setFrame(body_frame); + self.translate_ui + .source_popup + .setFrame(top_rect(body_h, 14.0, 6.0, 145.0, 26.0)); + self.translate_ui + .swap_btn + .setFrame(top_rect(body_h, 163.0, 6.0, 38.0, 26.0)); + self.translate_ui + .target_popup + .setFrame(top_rect(body_h, 205.0, 6.0, 165.0, 26.0)); + self.translate_ui + .translate_btn + .setFrame(top_rect(body_h, w - 166.0, 6.0, 92.0, 26.0)); + self.translate_ui + .clear_btn + .setFrame(top_rect(body_h, w - 70.0, 6.0, 56.0, 26.0)); + + let input_h = 118.0; + self.translate_ui + .input_scroll + .setFrame(top_rect(body_h, 14.0, 38.0, w - 28.0, input_h)); + let cards_top = 38.0 + input_h + 10.0; + let cards_h = (body_h - cards_top - 14.0).max(160.0); + let col_w = ((w - 38.0) / 2.0).max(200.0); + + self.translate_ui + .web_card + .setFrame(top_rect(body_h, 14.0, cards_top, col_w, cards_h)); + self.translate_ui + .web_header_label + .setFrame(top_rect(cards_h, 10.0, 8.0, col_w - 76.0, 18.0)); + self.translate_ui + .web_copy_btn + .setFrame(top_rect(cards_h, col_w - 64.0, 5.0, 54.0, 24.0)); + self.translate_ui + .web_scroll + .setFrame(top_rect(cards_h, 4.0, 32.0, col_w - 8.0, cards_h - 36.0)); + + self.translate_ui.ai_card.setFrame(top_rect( + body_h, + 14.0 + col_w + 10.0, + cards_top, + col_w, + cards_h, + )); + self.translate_ui + .ai_header_label + .setFrame(top_rect(cards_h, 10.0, 8.0, col_w - 76.0, 18.0)); + self.translate_ui + .ai_copy_btn + .setFrame(top_rect(cards_h, col_w - 64.0, 5.0, 54.0, 24.0)); + self.translate_ui + .ai_scroll + .setFrame(top_rect(cards_h, 4.0, 32.0, col_w - 8.0, cards_h - 36.0)); + + // Relayout Chat view + self.chat_ui.root.setFrame(body_frame); + self.chat_ui + .conv_popup + .setFrame(top_rect(body_h, 14.0, 6.0, 260.0, 26.0)); + self.chat_ui + .new_conv_btn + .setFrame(top_rect(body_h, 280.0, 6.0, 82.0, 26.0)); + self.chat_ui + .clear_hist_btn + .setFrame(top_rect(body_h, 366.0, 6.0, 82.0, 26.0)); + self.chat_ui + .stop_btn + .setFrame(top_rect(body_h, w - 166.0, 6.0, 74.0, 26.0)); + self.chat_ui + .copy_reply_btn + .setFrame(top_rect(body_h, w - 88.0, 6.0, 74.0, 26.0)); + let chat_input_h = 72.0; + let transcript_h = (body_h - 38.0 - chat_input_h - 22.0).max(180.0); + self.chat_ui + .transcript_scroll + .setFrame(top_rect(body_h, 14.0, 38.0, w - 28.0, transcript_h)); + let chat_input_top = 38.0 + transcript_h + 8.0; + self.chat_ui.input_scroll.setFrame(top_rect( + body_h, + 14.0, + chat_input_top, + w - 104.0, + chat_input_h, + )); + self.chat_ui.send_btn.setFrame(top_rect( + body_h, + w - 84.0, + chat_input_top + 18.0, + 70.0, + 36.0, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_tag_round_trips_all_known_tags() { + let tags = [ + ActionTag::TabTranslate, + ActionTag::TabChat, + ActionTag::TabSettings, + ActionTag::TriggerOcr, + ActionTag::TogglePin, + ActionTag::TrayQuit, + ActionTag::HideOrEscape, + ActionTag::TranslateNow, + ActionTag::ClearTranslate, + ActionTag::SwapLanguages, + ActionTag::SourceLangChanged, + ActionTag::TargetLangChanged, + ActionTag::CopyWebResult, + ActionTag::CopyAiResult, + ActionTag::SendChat, + ActionTag::NewConversation, + ActionTag::ClearChatHistory, + ActionTag::StopChatStream, + ActionTag::SelectConversation, + ActionTag::CopyLastReply, + ActionTag::SaveSettings, + ActionTag::AiTransBackendChanged, + ActionTag::AiTransFillDefaultUrl, + ActionTag::AiTransDiscoverModels, + ActionTag::AiTransModelPopupSelected, + ActionTag::AiTransBrowseGguf, + ActionTag::AiTransBrowseTokenizer, + ActionTag::AiTransClearKey, + ActionTag::ChatBackendChanged, + ActionTag::ChatFillDefaultUrl, + ActionTag::ChatDiscoverModels, + ActionTag::ChatModelPopupSelected, + ActionTag::ChatBrowseGguf, + ActionTag::ChatCopyFromTrans, + ActionTag::ChatClearKey, + ActionTag::ResetDefaultShortcuts, + ActionTag::OpacitySliderChanged, + ]; + for tag in tags { + assert_eq!(ActionTag::from_raw(tag as isize), Some(tag)); + } + assert_eq!(ActionTag::from_raw(9999), None); + } + + #[test] + fn converts_top_origin_rect_to_appkit_bottom_origin() { + let r = top_rect(500.0, 14.0, 10.0, 80.0, 28.0); + assert_eq!(r.origin.x, 14.0); + assert_eq!(r.origin.y, 462.0); + assert_eq!(r.size.width, 80.0); + assert_eq!(r.size.height, 28.0); + } +} + diff --git a/crates/ttree-app/src/ui_helpers.rs b/crates/ttree-app/src/ui_helpers.rs new file mode 100644 index 0000000..2b69603 --- /dev/null +++ b/crates/ttree-app/src/ui_helpers.rs @@ -0,0 +1,302 @@ +//! macOS 原生 AppKit (`objc2-app-kit`) 控件构建与读写辅助函数。 + +#![cfg(target_os = "macos")] + +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, Sel}; +use objc2::{msg_send, MainThreadMarker}; +use objc2_app_kit::{ + NSBezelStyle, NSBox, NSBoxType, NSButton, NSButtonType, NSColor, NSControlStateValueOff, + NSControlStateValueOn, NSFont, NSPasteboard, NSPasteboardTypeString, NSPopUpButton, + NSScrollView, NSSecureTextField, NSSlider, NSTextField, NSTextView, NSView, +}; +use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize}; +use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize, NSString}; + +/// 将以顶部为原点的 `(x, top_y, width, height)` 转换为 AppKit 默认左下角原点的 `NSRect`。 +pub fn top_rect(parent_height: f64, x: f64, top_y: f64, width: f64, height: f64) -> NSRect { + let y = (parent_height - top_y - height).max(0.0); + NSRect::new(NSPoint::new(x, y), NSSize::new(width, height)) +} + +/// 创建静态文本标签 (`NSTextField` 设为只读无边框)。 +pub fn make_label( + mtm: MainThreadMarker, + frame: NSRect, + text: &str, + font_size: f64, + bold: bool, + secondary: bool, +) -> Retained { + let label = NSTextField::initWithFrame(mtm.alloc(), frame); + let ns_text = NSString::from_str(text); + label.setStringValue(&ns_text); + label.setBezeled(false); + label.setDrawsBackground(false); + label.setEditable(false); + label.setSelectable(false); + let font = if bold { + NSFont::boldSystemFontOfSize(font_size as CGFloat) + } else { + NSFont::systemFontOfSize(font_size as CGFloat) + }; + label.setFont(Some(&font)); + let color = if secondary { + NSColor::secondaryLabelColor() + } else { + NSColor::labelColor() + }; + label.setTextColor(Some(&color)); + label +} + +/// 创建圆角操作按钮 (`NSButton`) 并绑定 `target` / `action` / `tag`。 +pub fn make_button( + mtm: MainThreadMarker, + frame: NSRect, + title: &str, + tag: isize, + target: &AnyObject, + action: Sel, +) -> Retained { + unsafe { + let btn = NSButton::initWithFrame(mtm.alloc(), frame); + let ns_title = NSString::from_str(title); + btn.setTitle(&ns_title); + #[allow(deprecated)] + btn.setBezelStyle(NSBezelStyle::Rounded); + btn.setFont(Some(&NSFont::systemFontOfSize(12.5))); + btn.setTag(tag); + btn.setTarget(Some(target)); + btn.setAction(Some(action)); + btn + } +} + +/// 创建复选框按钮 (`NSButtonType::Switch`)。 +pub fn make_checkbox( + mtm: MainThreadMarker, + frame: NSRect, + title: &str, + checked: bool, + tag: isize, + target: &AnyObject, + action: Sel, +) -> Retained { + unsafe { + let btn = NSButton::initWithFrame(mtm.alloc(), frame); + btn.setButtonType(NSButtonType::Switch); + let ns_title = NSString::from_str(title); + btn.setTitle(&ns_title); + btn.setFont(Some(&NSFont::systemFontOfSize(12.5))); + btn.setState(if checked { + NSControlStateValueOn + } else { + NSControlStateValueOff + }); + btn.setTag(tag); + btn.setTarget(Some(target)); + btn.setAction(Some(action)); + btn + } +} + +/// 创建下拉选择框 (`NSPopUpButton`)。 +pub fn make_popup( + mtm: MainThreadMarker, + frame: NSRect, + items: &[&str], + selected_idx: usize, + tag: isize, + target: &AnyObject, + action: Sel, +) -> Retained { + unsafe { + let popup = NSPopUpButton::initWithFrame_pullsDown(mtm.alloc(), frame, false); + popup.setFont(Some(&NSFont::systemFontOfSize(12.5))); + for item in items { + let s = NSString::from_str(item); + popup.addItemWithTitle(&s); + } + if selected_idx < items.len() { + popup.selectItemAtIndex(selected_idx as isize); + } + popup.setTag(tag); + popup.setTarget(Some(target)); + popup.setAction(Some(action)); + popup + } +} + +/// 创建单行可编辑输入框 (`NSTextField`)。 +pub fn make_text_field( + mtm: MainThreadMarker, + frame: NSRect, + value: &str, + placeholder: &str, +) -> Retained { + let field = NSTextField::initWithFrame(mtm.alloc(), frame); + let ns_val = NSString::from_str(value); + field.setStringValue(&ns_val); + let ns_ph = NSString::from_str(placeholder); + field.setPlaceholderString(Some(&ns_ph)); + field.setEditable(true); + field.setSelectable(true); + field.setBezeled(true); + field.setDrawsBackground(true); + field.setFont(Some(&NSFont::systemFontOfSize(12.5))); + field +} + +/// 创建单行密码/密钥输入框 (`NSSecureTextField`)。 +pub fn make_secure_field( + mtm: MainThreadMarker, + frame: NSRect, + placeholder: &str, +) -> Retained { + let field = NSSecureTextField::initWithFrame(mtm.alloc(), frame); + let ns_ph = NSString::from_str(placeholder); + field.setPlaceholderString(Some(&ns_ph)); + field.setEditable(true); + field.setSelectable(true); + field.setBezeled(true); + field.setDrawsBackground(true); + field.setFont(Some(&NSFont::systemFontOfSize(12.5))); + field +} + +/// 创建滑块控件 (`NSSlider`)。 +pub fn make_slider( + mtm: MainThreadMarker, + frame: NSRect, + min: f64, + max: f64, + value: f64, + tag: isize, + target: &AnyObject, + action: Sel, +) -> Retained { + unsafe { + let slider = NSSlider::initWithFrame(mtm.alloc(), frame); + slider.setMinValue(min); + slider.setMaxValue(max); + slider.setDoubleValue(value); + slider.setContinuous(true); + slider.setTag(tag); + slider.setTarget(Some(target)); + slider.setAction(Some(action)); + slider + } +} + +/// 创建圆角卡片容器 (`NSBox`)。 +pub fn make_card_box(mtm: MainThreadMarker, frame: NSRect) -> Retained { + let card = NSBox::initWithFrame(mtm.alloc(), frame); + card.setBoxType(NSBoxType::Custom); + card.setCornerRadius(10.0); + card.setBorderWidth(1.0); + card.setBorderColor(&NSColor::separatorColor()); + card.setFillColor(&NSColor::controlBackgroundColor().colorWithAlphaComponent(0.55)); + let zero_margins = CGSize::new(0.0, 0.0); + card.setContentViewMargins(zero_margins); + card +} + +/// 创建带垂直滚动条的多行 `NSTextView`。 +pub fn make_scrollable_text_view( + mtm: MainThreadMarker, + frame: NSRect, + editable: bool, + font_size: f64, + delegate: Option<&AnyObject>, +) -> (Retained, Retained) { + unsafe { + let scroll = NSScrollView::initWithFrame(mtm.alloc(), frame); + scroll.setHasVerticalScroller(true); + scroll.setHasHorizontalScroller(false); + scroll.setDrawsBackground(editable); + if editable { + scroll.setBackgroundColor(&NSColor::textBackgroundColor().colorWithAlphaComponent(0.65)); + } + + let content_size = scroll.contentSize(); + let text_frame = CGRect::new(CGPoint::new(0.0, 0.0), content_size); + let text_view = NSTextView::initWithFrame(mtm.alloc(), text_frame); + text_view.setEditable(editable); + text_view.setSelectable(true); + text_view.setRichText(false); + text_view.setDrawsBackground(false); + text_view.setFont(Some(&NSFont::systemFontOfSize(font_size as CGFloat))); + text_view.setTextColor(Some(&NSColor::labelColor())); + text_view.setVerticallyResizable(true); + text_view.setHorizontallyResizable(false); + let inset = NSSize::new(8.0, 8.0); + text_view.setTextContainerInset(inset); + if let Some(container) = text_view.textContainer() { + container.setWidthTracksTextView(true); + container.setContainerSize(NSSize::new(content_size.width, 10_000_000.0)); + } + if editable { + let _: () = msg_send![&*text_view, setAutomaticQuoteSubstitutionEnabled: false]; + let _: () = msg_send![&*text_view, setAutomaticDashSubstitutionEnabled: false]; + let _: () = msg_send![&*text_view, setAutomaticSpellingCorrectionEnabled: false]; + } + if let Some(del) = delegate { + let _: () = msg_send![&*text_view, setDelegate: Some(del)]; + } + + scroll.setDocumentView(Some(&text_view)); + (scroll, text_view) + } +} + +/// 读取 `NSTextView` 内的全部纯文本。 +pub fn get_text_view_string(tv: &NSTextView) -> String { + tv.string().to_string() +} + +/// 设置 `NSTextView` 的纯文本内容。 +pub fn set_text_view_string(tv: &NSTextView, text: &str) { + let s = NSString::from_str(text); + tv.setString(&s); +} + +/// 向 `NSTextView` 末尾追加流式增量文本并自动滚动到底部。 +pub fn append_text_view_string(tv: &NSTextView, delta: &str, scroll_to_end: bool) { + let mut current = tv.string().to_string(); + current.push_str(delta); + let s = NSString::from_str(¤t); + tv.setString(&s); + if scroll_to_end { + let len = s.length(); + tv.scrollRangeToVisible(NSRange::new(len, 0)); + } +} + +/// 读取 `NSTextField` 或 `NSSecureTextField` 文本。 +pub fn get_field_string(field: &NSTextField) -> String { + field.stringValue().to_string() +} + +/// 设置 `NSTextField` 文本。 +pub fn set_field_string(field: &NSTextField, value: &str) { + let s = NSString::from_str(value); + field.setStringValue(&s); +} + +/// 将字符串写入 macOS 系统剪贴板。 +pub fn copy_to_pasteboard(text: &str) -> bool { + if text.trim().is_empty() { + return false; + } + let pb = NSPasteboard::generalPasteboard(); + pb.clearContents(); + let ns_str = NSString::from_str(text); + let pb_type = unsafe { NSPasteboardTypeString }; + pb.setString_forType(&ns_str, pb_type) +} + +/// 将子视图添加到父视图。 +pub fn add_subview(parent: &NSView, child: &NSView) { + parent.addSubview(child); +} diff --git a/crates/ttree-core/Cargo.toml b/crates/ttree-core/Cargo.toml new file mode 100644 index 0000000..d2ed758 --- /dev/null +++ b/crates/ttree-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ttree-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true diff --git a/crates/ttree-core/src/cancel.rs b/crates/ttree-core/src/cancel.rs new file mode 100644 index 0000000..6088f5c --- /dev/null +++ b/crates/ttree-core/src/cancel.rs @@ -0,0 +1,119 @@ +//! 轻量任务取消令牌(`CancelToken`)与单槽抢占器(`CancelSlot`)。 +//! +//! 解决痛点:当用户快速修改输入或连续触发划词/OCR 时,立即取消正在进行的旧请求 +//! (尤其是耗时较长的 AI 流式生成),绝不让上一条慢请求排队阻塞新翻译。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Notify; + +#[derive(Default)] +struct Inner { + cancelled: AtomicBool, + notify: Notify, +} + +/// 可克隆的异步取消令牌。 +#[derive(Clone, Default)] +pub struct CancelToken { + request_id: u64, + inner: Arc, +} + +impl CancelToken { + pub fn new(request_id: u64) -> Self { + Self { + request_id, + inner: Arc::new(Inner::default()), + } + } + + /// 当前令牌对应的单调递增请求 ID。 + pub fn request_id(&self) -> u64 { + self.request_id + } + + /// 触发取消并唤醒所有正在等待的异步任务。 + pub fn cancel(&self) { + if !self.inner.cancelled.swap(true, Ordering::SeqCst) { + self.inner.notify.notify_waiters(); + } + } + + /// 是否已被取消。 + pub fn is_cancelled(&self) -> bool { + self.inner.cancelled.load(Ordering::SeqCst) + } + + /// 异步等待直至令牌被取消(配合 `tokio::select!` 中断网络流)。 + pub async fn cancelled(&self) { + if self.is_cancelled() { + return; + } + let notified = self.inner.notify.notified(); + if self.is_cancelled() { + return; + } + notified.await; + } +} + +/// 单槽抢占管理器:每次签发新 `CancelToken` 时自动取消上一个未完成的令牌。 +#[derive(Default)] +pub struct CancelSlot { + state: Mutex<(u64, Option)>, +} + +impl CancelSlot { + pub const fn new() -> Self { + Self { + state: Mutex::new((0, None)), + } + } + + /// 取消旧令牌并签发新一代 `CancelToken`。 + pub fn next(&self) -> CancelToken { + let mut guard = self.state.lock().expect("CancelSlot lock poisoned"); + if let Some(prev) = guard.1.take() { + prev.cancel(); + } + guard.0 = guard.0.wrapping_add(1); + let token = CancelToken::new(guard.0); + guard.1 = Some(token.clone()); + token + } + + /// 显式取消当前槽位中的活跃任务。 + pub fn cancel_current(&self) { + let mut guard = self.state.lock().expect("CancelSlot lock poisoned"); + if let Some(prev) = guard.1.take() { + prev.cancel(); + } + } + + /// 当前最新请求 ID。 + pub fn current_id(&self) -> u64 { + self.state.lock().map(|g| g.0).unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn issuing_next_token_cancels_previous_token() { + let slot = CancelSlot::new(); + let first = slot.next(); + assert_eq!(first.request_id(), 1); + assert!(!first.is_cancelled()); + + let second = slot.next(); + assert_eq!(second.request_id(), 2); + assert!(first.is_cancelled()); + assert!(!second.is_cancelled()); + + // cancelled() future on first resolves immediately + first.cancelled().await; + } +} diff --git a/crates/ttree-core/src/chat.rs b/crates/ttree-core/src/chat.rs new file mode 100644 index 0000000..8ae0b87 --- /dev/null +++ b/crates/ttree-core/src/chat.rs @@ -0,0 +1,109 @@ +//! 对话消息与会话上下文裁剪。 + +use serde::{Deserialize, Serialize}; + +const ALLOWED_ROLES: &[&str] = &["user", "assistant", "system"]; +pub const MAX_CONTENT_BYTES: usize = 64 * 1024; +pub const MAX_MESSAGES: usize = 200; +pub const MAX_CONTEXT_MESSAGES: usize = 24; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +impl ChatMessage { + pub fn user(content: impl Into) -> Self { + Self { + role: "user".to_string(), + content: content.into(), + } + } + + pub fn assistant(content: impl Into) -> Self { + Self { + role: "assistant".to_string(), + content: content.into(), + } + } + + pub fn system(content: impl Into) -> Self { + Self { + role: "system".to_string(), + content: content.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Conversation { + pub id: usize, + pub messages: Vec, + pub summary: String, +} + +/// 校验消息角色白名单、单条长度与总条数。 +pub fn validate_messages(msgs: &[ChatMessage]) -> Result<(), String> { + if msgs.len() > MAX_MESSAGES { + return Err(format!("消息数超出上限 {MAX_MESSAGES} 条")); + } + for m in msgs { + if !ALLOWED_ROLES.contains(&m.role.as_str()) { + return Err(format!("非法消息角色: {}", m.role)); + } + if m.content.len() > MAX_CONTENT_BYTES { + return Err(format!("单条消息超出 {MAX_CONTENT_BYTES} 字节上限")); + } + } + Ok(()) +} + +/// 截取最近 24 条非空消息,并丢弃前导孤立的 `assistant` 消息,确保上下文以 `user` 开头。 +pub fn recent_chat_context(messages: &[ChatMessage]) -> Vec { + let filtered: Vec = messages + .iter() + .filter(|m| !m.content.trim().is_empty()) + .cloned() + .collect(); + let start = filtered.len().saturating_sub(MAX_CONTEXT_MESSAGES); + let mut slice = &filtered[start..]; + while let Some(first) = slice.first() { + if first.role == "assistant" { + slice = &slice[1..]; + } else { + break; + } + } + slice.to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trims_context_and_drops_leading_orphan_assistant() { + let mut msgs = Vec::new(); + for i in 0..30 { + let role = if i % 2 == 0 { "user" } else { "assistant" }; + msgs.push(ChatMessage { + role: role.to_string(), + content: format!("msg-{i}"), + }); + } + let ctx = recent_chat_context(&msgs); + assert!(ctx.len() <= MAX_CONTEXT_MESSAGES); + assert_eq!(ctx.first().unwrap().role, "user"); + } + + #[test] + fn validates_roles_and_lengths() { + assert!(validate_messages(&[ChatMessage::user("hi")]).is_ok()); + assert!(validate_messages(&[ChatMessage { + role: "tool".to_string(), + content: "x".to_string() + }]) + .is_err()); + } +} diff --git a/crates/ttree-core/src/config.rs b/crates/ttree-core/src/config.rs new file mode 100644 index 0000000..73877df --- /dev/null +++ b/crates/ttree-core/src/config.rs @@ -0,0 +1,306 @@ +//! 核心配置数据结构与校验规则。 + +use serde::{Deserialize, Serialize}; + +pub const MAX_BASE_URL_BYTES: usize = 2 * 1024; +pub const MAX_MODEL_BYTES: usize = 256; +pub const MAX_PATH_BYTES: usize = 4 * 1024; +pub const MAX_PROMPT_BYTES: usize = 64 * 1024; +pub const MAX_API_KEY_BYTES: usize = 16 * 1024; + +pub const PANEL_OPACITY_MIN: f64 = 0.35; +pub const PANEL_OPACITY_MAX: f64 = 1.0; + +pub const WINDOW_MIN_WIDTH: f64 = 520.0; +pub const WINDOW_MIN_HEIGHT: f64 = 340.0; + +/// AI 引擎后端类型:支持云端 OpenAI 兼容、本机 Ollama/LM Studio/MLX 服务、以及直接加载 GGUF。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum AiBackendKind { + /// 云端 OpenAI 兼容接口(OpenAI / DeepSeek / OpenRouter 等,需 Keychain API Key) + #[default] + OpenAiCompatible, + /// 本机 Ollama 服务(默认 `http://127.0.0.1:11434`,免 API Key,支持 `/api/tags` 枚举模型) + Ollama, + /// 本机 LM Studio / MLX Server / llama.cpp 服务(默认 `http://127.0.0.1:1234/v1`,免或可选 Key) + LocalServer, + /// 纯 Rust Candle 框架直接加载本地 `.gguf` 量化模型文件(Metal GPU 加速) + CandleGguf, +} + +impl AiBackendKind { + pub const ALL: [AiBackendKind; 4] = [ + AiBackendKind::OpenAiCompatible, + AiBackendKind::Ollama, + AiBackendKind::LocalServer, + AiBackendKind::CandleGguf, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::OpenAiCompatible => "云端 API (OpenAI 兼容)", + Self::Ollama => "本地服务: Ollama", + Self::LocalServer => "本地服务: LM Studio / MLX", + Self::CandleGguf => "本地文件: 直接加载 .gguf (Candle Metal)", + } + } + + pub fn default_base_url(self) -> &'static str { + match self { + Self::OpenAiCompatible => "https://api.openai.com/v1", + Self::Ollama => "http://127.0.0.1:11434", + Self::LocalServer => "http://127.0.0.1:1234/v1", + Self::CandleGguf => "", + } + } + + pub fn requires_api_key(self) -> bool { + matches!(self.into(), Self::OpenAiCompatible) + } +} + +fn serialize_empty_api_key(_value: &String, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str("") +} + +fn default_true() -> bool { + true +} + +/// AI 翻译独立配置(支持开关、切换云端/Ollama/LM Studio/MLX/GGUF)。 +#[derive(Clone, Serialize, Deserialize)] +pub struct AiTranslationConfig { + /// 是否在翻译面板右侧启用 AI 翻译对照列。 + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub backend: AiBackendKind, + pub base_url: String, + #[serde(default, serialize_with = "serialize_empty_api_key")] + pub api_key: String, + #[serde(default)] + pub has_api_key: bool, + #[serde(default)] + pub clear_api_key: bool, + pub model: String, + /// 当 `backend == CandleGguf` 时使用的 `.gguf` 权重文件路径。 + #[serde(default)] + pub gguf_model_path: String, + /// 当 `backend == CandleGguf` 时使用的 `tokenizer.json` 路径(留空则自动在同目录查找)。 + #[serde(default)] + pub gguf_tokenizer_path: String, + pub translate_prompt: String, +} + +impl std::fmt::Debug for AiTranslationConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AiTranslationConfig") + .field("enabled", &self.enabled) + .field("backend", &self.backend) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("model", &self.model) + .field("gguf_model_path", &self.gguf_model_path) + .finish_non_exhaustive() + } +} + +impl Default for AiTranslationConfig { + fn default() -> Self { + Self { + enabled: true, + backend: AiBackendKind::OpenAiCompatible, + base_url: "https://api.openai.com/v1".to_string(), + api_key: String::new(), + has_api_key: false, + clear_api_key: false, + model: "gpt-4o-mini".to_string(), + gguf_model_path: String::new(), + gguf_tokenizer_path: String::new(), + translate_prompt: "你是一个专业、地道的翻译引擎。将用户输入从 {source} 翻译成 {target}。只输出译文,不要解释、不要引号、不要附加任何说明。".to_string(), + } + } +} + +/// AI 对话配置(与翻译独立)。 +#[derive(Clone, Serialize, Deserialize)] +pub struct ChatAiConfig { + #[serde(default)] + pub backend: AiBackendKind, + pub base_url: String, + #[serde(default, serialize_with = "serialize_empty_api_key")] + pub api_key: String, + #[serde(default)] + pub has_api_key: bool, + #[serde(default)] + pub clear_api_key: bool, + pub model: String, + #[serde(default)] + pub gguf_model_path: String, + #[serde(default)] + pub gguf_tokenizer_path: String, + #[serde(default)] + pub chat_prompt: String, +} + +impl std::fmt::Debug for ChatAiConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChatAiConfig") + .field("backend", &self.backend) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("model", &self.model) + .field("gguf_model_path", &self.gguf_model_path) + .finish_non_exhaustive() + } +} + +impl Default for ChatAiConfig { + fn default() -> Self { + Self { + backend: AiBackendKind::OpenAiCompatible, + base_url: "https://api.openai.com/v1".to_string(), + api_key: String::new(), + has_api_key: false, + clear_api_key: false, + model: "gpt-4o-mini".to_string(), + gguf_model_path: String::new(), + gguf_tokenizer_path: String::new(), + chat_prompt: String::new(), + } + } +} + +/// 全局快捷键配置。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShortcutConfig { + pub toggle: String, + pub ocr: String, + #[serde(default)] + pub ai_dialog: String, + #[serde(default)] + pub selection_translate: String, + #[serde(default)] + pub selection_ai_dialog: String, +} + +impl Default for ShortcutConfig { + fn default() -> Self { + Self { + toggle: "CmdOrCtrl+Shift+Space".to_string(), + ocr: "CmdOrCtrl+Shift+S".to_string(), + ai_dialog: String::new(), + selection_translate: String::new(), + selection_ai_dialog: String::new(), + } + } +} + +/// 外观配置。 +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct AppearanceConfig { + #[serde(default = "default_panel_opacity")] + pub panel_opacity: f64, +} + +fn default_panel_opacity() -> f64 { + 0.96 +} + +impl Default for AppearanceConfig { + fn default() -> Self { + Self { + panel_opacity: default_panel_opacity(), + } + } +} + +impl AppearanceConfig { + pub fn clamp_opacity(value: f64) -> f64 { + if value.is_nan() { + return default_panel_opacity(); + } + value.clamp(PANEL_OPACITY_MIN, PANEL_OPACITY_MAX) + } +} + +pub fn validate_field_len(label: &str, value: &str, max_bytes: usize) -> Result<(), String> { + if value.len() > max_bytes { + return Err(format!("{label}超出 {max_bytes} 字节上限")); + } + Ok(()) +} + +/// 校验并规范化接口地址: +/// - 远程地址要求 HTTPS +/// - 本机回环地址(`localhost` / `127.0.0.1` / `::1`)或局域网本地模型服务允许 HTTP +/// - 拒绝包含用户名/密码(userinfo)或非 HTTP(S) 协议 +pub fn normalize_base_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("base_url 不能为空".to_string()); + } + validate_field_len("base_url", trimmed, MAX_BASE_URL_BYTES)?; + let url = url::Url::parse(trimmed).map_err(|e| format!("base_url 非法: {e}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err("base_url 不允许包含内嵌用户名或密码".to_string()); + } + match url.scheme() { + "https" => {} + "http" if is_loopback_url(&url) => {} + "http" => return Err("远程 base_url 必须使用 HTTPS;HTTP 仅允许本机回环地址".to_string()), + other => return Err(format!("base_url 必须为 HTTP(S),不支持 {other}")), + } + if url.host().is_none() { + return Err("base_url 必须包含主机名或 IP 地址".to_string()); + } + let out = trimmed.trim_end_matches('/'); + Ok(if out.is_empty() { + trimmed.to_string() + } else { + out.to_string() + }) +} + +fn is_loopback_url(url: &url::Url) -> bool { + match url.host() { + Some(url::Host::Domain(host)) => { + host.trim_end_matches('.').eq_ignore_ascii_case("localhost") + } + Some(url::Host::Ipv4(host)) => host.is_loopback(), + Some(url::Host::Ipv6(host)) => host.is_loopback(), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_https_and_loopback_http_urls() { + assert_eq!( + normalize_base_url("https://api.openai.com/v1/").unwrap(), + "https://api.openai.com/v1" + ); + assert_eq!( + normalize_base_url("http://127.0.0.1:11434/").unwrap(), + "http://127.0.0.1:11434" + ); + assert_eq!( + normalize_base_url("http://localhost:1234/v1").unwrap(), + "http://localhost:1234/v1" + ); + } + + #[test] + fn rejects_remote_http_and_file_urls() { + assert!(normalize_base_url("http://example.com/v1").is_err()); + assert!(normalize_base_url("file:///etc/passwd").is_err()); + assert!(normalize_base_url("https://user:pass@example.com/v1").is_err()); + } +} diff --git a/crates/ttree-core/src/language.rs b/crates/ttree-core/src/language.rs new file mode 100644 index 0000000..93975ac --- /dev/null +++ b/crates/ttree-core/src/language.rs @@ -0,0 +1,195 @@ +//! 语言列表与自适应目标方向判定(中 ⇄ 英)。 + +pub const ADAPTIVE_TARGET: &str = "adaptive"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LanguageOption { + pub code: &'static str, + pub label: &'static str, +} + +pub const SOURCE_LANGUAGES: &[LanguageOption] = &[ + LanguageOption { + code: "auto", + label: "自动检测", + }, + LanguageOption { + code: "zh-CN", + label: "简体中文", + }, + LanguageOption { + code: "zh-TW", + label: "繁体中文", + }, + LanguageOption { + code: "en", + label: "英语", + }, + LanguageOption { + code: "ja", + label: "日语", + }, + LanguageOption { + code: "ko", + label: "韩语", + }, + LanguageOption { + code: "fr", + label: "法语", + }, + LanguageOption { + code: "de", + label: "德语", + }, + LanguageOption { + code: "es", + label: "西班牙语", + }, + LanguageOption { + code: "ru", + label: "俄语", + }, +]; + +pub const TARGET_LANGUAGES: &[LanguageOption] = &[ + LanguageOption { + code: ADAPTIVE_TARGET, + label: "中 ⇄ 英(自适应)", + }, + LanguageOption { + code: "zh-CN", + label: "简体中文", + }, + LanguageOption { + code: "zh-TW", + label: "繁体中文", + }, + LanguageOption { + code: "en", + label: "英语", + }, + LanguageOption { + code: "ja", + label: "日语", + }, + LanguageOption { + code: "ko", + label: "韩语", + }, + LanguageOption { + code: "fr", + label: "法语", + }, + LanguageOption { + code: "de", + label: "德语", + }, + LanguageOption { + code: "es", + label: "西班牙语", + }, + LanguageOption { + code: "ru", + label: "俄语", + }, +]; + +const SUPPORTED_SOURCE_CODES: &[&str] = &[ + "auto", "zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru", +]; +const SUPPORTED_TARGET_CODES: &[&str] = + &["zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru"]; + +/// 判断字符是否属于 CJK 统一表意文字(含扩展区)。 +fn is_han_char(ch: char) -> bool { + matches!( + ch as u32, + 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xF900..=0xFAFF + | 0x20000..=0x2A6DF + | 0x2A700..=0x2B73F + | 0x2B740..=0x2B81F + | 0x2B820..=0x2CEAF + | 0x2CEB0..=0x2EBEF + | 0x30000..=0x3134F + ) +} + +/// 根据输入文本自动选择目标语言:包含汉字则译为英语 (`en`),否则译为简体中文 (`zh-CN`)。 +pub fn resolve_adaptive_target(text: &str) -> &'static str { + if text.chars().any(is_han_char) { + "en" + } else { + "zh-CN" + } +} + +/// 将用户选择的 `(source, target)`(可能含 `adaptive`)解析为实际请求的语言对。 +pub fn resolve_language_pair(source: &str, target: &str, text: &str) -> (String, String) { + if target == ADAPTIVE_TARGET { + ("auto".to_string(), resolve_adaptive_target(text).to_string()) + } else { + (source.to_string(), target.to_string()) + } +} + +/// 校验实际发送给引擎的语言对是否合法。 +pub fn validate_language_pair(source: &str, target: &str) -> Result<(), String> { + if !SUPPORTED_SOURCE_CODES.contains(&source) { + return Err(format!("不支持的源语言代码: {source}")); + } + if !SUPPORTED_TARGET_CODES.contains(&target) { + return Err(format!("不支持的目标语言代码: {target}")); + } + Ok(()) +} + +/// 根据目标语言代码返回中文可读名称(供 AI 提示词渲染使用)。 +pub fn language_display_name(code: &str) -> &str { + match code { + "auto" => "自动检测的源语言", + "zh-CN" => "简体中文", + "zh-TW" => "繁体中文", + "en" => "英语 (English)", + "ja" => "日语 (Japanese)", + "ko" => "韩语 (Korean)", + "fr" => "法语 (French)", + "de" => "德语 (German)", + "es" => "西班牙语 (Spanish)", + "ru" => "俄语 (Russian)", + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adaptive_target_detects_chinese_and_non_chinese() { + assert_eq!(resolve_adaptive_target("你好,世界"), "en"); + assert_eq!(resolve_adaptive_target("Hello, world!"), "zh-CN"); + assert_eq!(resolve_adaptive_target("Rust 性能优化"), "en"); + assert_eq!(resolve_adaptive_target("12345 !@#"), "zh-CN"); + } + + #[test] + fn resolves_adaptive_pair_to_concrete_codes() { + let (src, tgt) = resolve_language_pair("en", ADAPTIVE_TARGET, "今天天气不错"); + assert_eq!(src, "auto"); + assert_eq!(tgt, "en"); + + let (src, tgt) = resolve_language_pair("zh-CN", "ja", "今天天气不错"); + assert_eq!(src, "zh-CN"); + assert_eq!(tgt, "ja"); + } + + #[test] + fn validates_known_language_codes() { + assert!(validate_language_pair("auto", "zh-CN").is_ok()); + assert!(validate_language_pair("en", "zh-TW").is_ok()); + assert!(validate_language_pair("en", "auto").is_err()); + assert!(validate_language_pair("unknown", "en").is_err()); + } +} diff --git a/crates/ttree-core/src/lib.rs b/crates/ttree-core/src/lib.rs new file mode 100644 index 0000000..eb8483c --- /dev/null +++ b/crates/ttree-core/src/lib.rs @@ -0,0 +1,17 @@ +//! `ttree-core` —— 核心领域模型、语言自适应解析、取消令牌、配置模型与对话上下文处理。 + +pub mod cancel; +pub mod chat; +pub mod config; +pub mod language; + +pub use cancel::{CancelSlot, CancelToken}; +pub use chat::{recent_chat_context, validate_messages, ChatMessage, Conversation}; +pub use config::{ + normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig, + ShortcutConfig, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, +}; +pub use language::{ + resolve_adaptive_target, resolve_language_pair, validate_language_pair, LanguageOption, + ADAPTIVE_TARGET, SOURCE_LANGUAGES, TARGET_LANGUAGES, +}; diff --git a/crates/ttree-ocr/Cargo.toml b/crates/ttree-ocr/Cargo.toml new file mode 100644 index 0000000..d1ab8b4 --- /dev/null +++ b/crates/ttree-ocr/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ttree-ocr" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +tempfile.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2.workspace = true +objc2-core-foundation.workspace = true +objc2-core-graphics.workspace = true +objc2-core-image.workspace = true +objc2-core-ml.workspace = true +objc2-foundation.workspace = true +objc2-image-io.workspace = true +objc2-vision.workspace = true diff --git a/crates/ttree-ocr/src/image.rs b/crates/ttree-ocr/src/image.rs new file mode 100644 index 0000000..f89d3d9 --- /dev/null +++ b/crates/ttree-ocr/src/image.rs @@ -0,0 +1,174 @@ +//! 图像预处理:解码 PNG 并以截图边缘自适应背景色填充边距,生成 8-bit sRGB `CGImage`。 +//! +//! 关键改进: +//! 旧版在 <128px 的小截图四周填充纯白 `(1.0, 1.0, 1.0)`,导致深色模式(白字黑底)单行截图 +//! 产生刺眼的白框伪影,干扰 Vision 行检测。新版自动采样原图四周边缘像素的平均色进行无缝填充。 + +#![cfg(target_os = "macos")] + +use objc2_core_foundation::{CFData, CFRetained, CGPoint, CGRect, CGSize}; +use objc2_core_graphics::{ + kCGColorSpaceSRGB, CGBitmapContextCreate, CGBitmapContextCreateImage, CGColorSpace, CGContext, + CGImage, CGImageAlphaInfo, CGImageByteOrderInfo, +}; +use objc2_image_io::CGImageSource; + +const MIN_DIMENSION: usize = 128; +const BORDER: usize = 16; +const MAX_PIXELS: usize = 32_000_000; + +pub fn prepare_png(bytes: &[u8]) -> Result, String> { + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 24 { + return Err("截图不是有效的 PNG 图片,请重新框选。".to_string()); + } + if &bytes[12..16] != b"IHDR" { + return Err("截图 PNG 缺少图片尺寸信息,请重新框选。".to_string()); + } + let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as usize; + let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize; + canvas_size(width, height)?; + + let data = CFData::from_bytes(bytes); + let source = unsafe { CGImageSource::with_data(&data, None) } + .ok_or_else(|| "系统无法读取截图 PNG,请重新框选。".to_string())?; + let image = unsafe { source.image_at_index(0, None) } + .ok_or_else(|| "截图 PNG 解码失败,请重新框选。".to_string())?; + let width = CGImage::width(Some(&image)); + let height = CGImage::height(Some(&image)); + let (canvas_width, canvas_height) = canvas_size(width, height)?; + + let color_space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) + .ok_or_else(|| "创建 OCR 色彩空间失败".to_string())?; + let bitmap_info = CGImageAlphaInfo::NoneSkipLast.0 | CGImageByteOrderInfo::Order32Big.0; + + // 先将原图解码到一块小缓冲区以采样四周边缘背景色,确保深色/浅色截图都能平滑延展背景 + let (bg_r, bg_g, bg_b) = sample_border_rgb(&image, width, height, &color_space, bitmap_info); + + let context = unsafe { + CGBitmapContextCreate( + std::ptr::null_mut(), + canvas_width, + canvas_height, + 8, + canvas_width * 4, + Some(&color_space), + bitmap_info, + ) + } + .ok_or_else(|| "创建 OCR 位图失败".to_string())?; + + CGContext::set_rgb_fill_color(Some(&context), bg_r, bg_g, bg_b, 1.0); + CGContext::fill_rect(Some(&context), rect(0, 0, canvas_width, canvas_height)); + CGContext::draw_image( + Some(&context), + rect( + (canvas_width - width) / 2, + (canvas_height - height) / 2, + width, + height, + ), + Some(&image), + ); + CGBitmapContextCreateImage(Some(&context)).ok_or_else(|| "生成 OCR 位图失败".to_string()) +} + +/// 采样原图外圈像素的平均 RGB 值(0.0..=1.0),失败时回退白色。 +fn sample_border_rgb( + image: &CGImage, + width: usize, + height: usize, + color_space: &CGColorSpace, + bitmap_info: u32, +) -> (f64, f64, f64) { + let mut buf = vec![255u8; width * height * 4]; + let Some(ctx) = (unsafe { + CGBitmapContextCreate( + buf.as_mut_ptr().cast(), + width, + height, + 8, + width * 4, + Some(color_space), + bitmap_info, + ) + }) else { + return (1.0, 1.0, 1.0); + }; + CGContext::set_rgb_fill_color(Some(&ctx), 1.0, 1.0, 1.0, 1.0); + CGContext::fill_rect(Some(&ctx), rect(0, 0, width, height)); + CGContext::draw_image(Some(&ctx), rect(0, 0, width, height), Some(image)); + + let mut r_sum: u64 = 0; + let mut g_sum: u64 = 0; + let mut b_sum: u64 = 0; + let mut count: u64 = 0; + + let mut add_pixel = |x: usize, y: usize| { + let idx = (y * width + x) * 4; + r_sum += buf[idx] as u64; + g_sum += buf[idx + 1] as u64; + b_sum += buf[idx + 2] as u64; + count += 1; + }; + + for x in 0..width { + add_pixel(x, 0); + if height > 1 { + add_pixel(x, height - 1); + } + } + if height > 2 { + for y in 1..(height - 1) { + add_pixel(0, y); + if width > 1 { + add_pixel(width - 1, y); + } + } + } + + if count == 0 { + return (1.0, 1.0, 1.0); + } + ( + (r_sum as f64 / count as f64) / 255.0, + (g_sum as f64 / count as f64) / 255.0, + (b_sum as f64 / count as f64) / 255.0, + ) +} + +pub fn canvas_size(width: usize, height: usize) -> Result<(usize, usize), String> { + if width == 0 || height == 0 { + return Err("截图尺寸为空,请重新框选。".to_string()); + } + let padded = width + .checked_add(BORDER * 2) + .zip(height.checked_add(BORDER * 2)) + .map(|(w, h)| (w.max(MIN_DIMENSION), h.max(MIN_DIMENSION))) + .filter(|(w, h)| w.checked_mul(*h).is_some_and(|pixels| pixels <= MAX_PIXELS)); + padded.ok_or_else(|| "截图区域过大,请缩小框选范围后重试。".to_string()) +} + +fn rect(x: usize, y: usize, width: usize, height: usize) -> CGRect { + CGRect { + origin: CGPoint { + x: x as _, + y: y as _, + }, + size: CGSize { + width: width as _, + height: height as _, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pads_small_captures_to_minimum_dimension() { + assert_eq!(canvas_size(320, 24).unwrap(), (352, 128)); + assert_eq!(canvas_size(8, 8).unwrap(), (128, 128)); + assert!(canvas_size(0, 10).is_err()); + } +} diff --git a/crates/ttree-ocr/src/lib.rs b/crates/ttree-ocr/src/lib.rs new file mode 100644 index 0000000..d3701a7 --- /dev/null +++ b/crates/ttree-ocr/src/lib.rs @@ -0,0 +1,76 @@ +//! `ttree-ocr` —— macOS 原生截图与 Apple Vision OCR 引擎。 +//! +//! 针对旧版“莫名其妙无法使用”的 4 项核心根治: +//! 1. 强类型 `OcrOutcome` 区分「成功」「用户取消」「未识别出文字」「录屏权限未开启」「正在截图中」,杜绝静默吞掉错误; +//! 2. 调用 `CGPreflightScreenCaptureAccess` 预检 macOS 录屏权限,区分无权限导致的空截图与按 Esc 取消; +//! 3. `image.rs` 自动采样截图边缘背景色填充 Padding(替代旧版硬编码纯白底,彻底修复深色模式单行小图识别失败); +//! 4. `vision.rs` 移除主路径上的 `PreferBackgroundProcessing(true)` 后台降级标志,确保菜单栏配件应用极速响应。 + +pub mod screenshot; + +#[cfg(target_os = "macos")] +pub mod image; +#[cfg(target_os = "macos")] +pub mod vision; + +use std::sync::atomic::{AtomicBool, Ordering}; +use serde::{Deserialize, Serialize}; + +static OCR_BUSY: AtomicBool = AtomicBool::new(false); + +struct BusyGuard; + +impl BusyGuard { + fn try_acquire() -> Option { + OCR_BUSY + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .ok() + .map(|_| Self) + } +} + +impl Drop for BusyGuard { + fn drop(&mut self) { + OCR_BUSY.store(false, Ordering::SeqCst); + } +} + +/// 截图 OCR 强类型结果,彻底避免把权限失败或未识别出文字误判为用户取消。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum OcrOutcome { + /// 成功识别出文字。 + Text(String), + /// 用户按 Esc 主动取消了区域框选。 + Cancelled, + /// 截图成功但所选图片区域内未检测到任何文字。 + EmptyText, + /// 上一次截图框选仍在进行中。 + Busy, + /// macOS 屏幕录制权限未授予或已失效。 + PermissionDenied(String), +} + +/// 完整执行一次「macOS 原生交互式框选 (`screencapture -i -x -r`) + Apple Vision OCR」。 +pub fn capture_and_recognize() -> Result { + let Some(_guard) = BusyGuard::try_acquire() else { + return Ok(OcrOutcome::Busy); + }; + + match screenshot::capture_interactive()? { + screenshot::CaptureResult::Captured(temp_path) => { + recognize_file(&temp_path.to_string_lossy()) + } + screenshot::CaptureResult::Cancelled => Ok(OcrOutcome::Cancelled), + screenshot::CaptureResult::PermissionDenied(msg) => Ok(OcrOutcome::PermissionDenied(msg)), + } +} + +#[cfg(target_os = "macos")] +pub fn recognize_file(path: &str) -> Result { + vision::recognize_file(path) +} + +#[cfg(not(target_os = "macos"))] +pub fn recognize_file(_path: &str) -> Result { + Err("当前平台暂不支持 OCR".to_string()) +} diff --git a/crates/ttree-ocr/src/screenshot.rs b/crates/ttree-ocr/src/screenshot.rs new file mode 100644 index 0000000..93516fc --- /dev/null +++ b/crates/ttree-ocr/src/screenshot.rs @@ -0,0 +1,127 @@ +//! macOS 原生屏幕区域框选(`/usr/sbin/screencapture -i -x -r`)与 TCC 录屏权限预检。 + +use std::path::Path; +use std::process::Command; +use tempfile::{NamedTempFile, TempPath}; + +#[cfg(target_os = "macos")] +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGPreflightScreenCaptureAccess() -> bool; + fn CGRequestScreenCaptureAccess() -> bool; +} + +#[cfg(target_os = "macos")] +pub fn has_screen_capture_permission() -> bool { + unsafe { CGPreflightScreenCaptureAccess() } +} + +#[cfg(target_os = "macos")] +pub fn request_screen_capture_permission() -> bool { + unsafe { CGRequestScreenCaptureAccess() } +} + +#[cfg(not(target_os = "macos"))] +pub fn has_screen_capture_permission() -> bool { + true +} + +#[cfg(not(target_os = "macos"))] +pub fn request_screen_capture_permission() -> bool { + true +} + +pub fn screencapture_args() -> [&'static str; 3] { + ["-i", "-x", "-r"] +} + +pub enum CaptureResult { + Captured(TempPath), + Cancelled, + PermissionDenied(String), +} + +const PERMISSION_HINT: &str = "未获得 macOS 屏幕录制权限。请打开「系统设置 → 隐私与安全性 → 屏幕与系统音频录制」,开启 TTREE 权限后重试。"; + +/// 调用 Apple 原生 `/usr/sbin/screencapture -i -x -r` 进行自由框选截图。 +pub fn capture_interactive() -> Result { + let had_permission = has_screen_capture_permission(); + if !had_permission { + // 主动请求一次系统权限弹窗,若仍未授权则稍后给出明确提示而非静默返回空 + let _ = request_screen_capture_permission(); + } + + let target = new_capture_target()?; + + let output = Command::new("/usr/sbin/screencapture") + .args(screencapture_args()) + .arg(target.path()) + .output() + .map_err(|e| format!("调用 screencapture 失败: {e}"))?; + + let has_capture = validate_capture_output(target.path())?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr); + if !has_capture + && (detail.trim().is_empty() || detail.to_ascii_lowercase().contains("cancel")) + { + if !had_permission && !has_screen_capture_permission() { + return Ok(CaptureResult::PermissionDenied(PERMISSION_HINT.to_string())); + } + return Ok(CaptureResult::Cancelled); + } + let detail: String = detail.trim().chars().take(300).collect(); + return Err(format!( + "系统截图失败({}): {detail}。{PERMISSION_HINT}", + output.status + )); + } + + if !has_capture { + if !had_permission && !has_screen_capture_permission() { + return Ok(CaptureResult::PermissionDenied(PERMISSION_HINT.to_string())); + } + return Ok(CaptureResult::Cancelled); + } + + Ok(CaptureResult::Captured(target.into_temp_path())) +} + +fn new_capture_target() -> Result { + tempfile::Builder::new() + .prefix("ttree-shot-") + .suffix(".png") + .tempfile() + .map_err(|e| format!("创建截图临时文件失败: {e}")) +} + +fn validate_capture_output(path: &Path) -> Result { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(format!("读取截图临时文件失败: {error}")), + }; + if !metadata.file_type().is_file() { + return Err("截图输出不是普通文件".to_string()); + } + Ok(metadata.len() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_native_screencapture_flags() { + assert_eq!(screencapture_args(), ["-i", "-x", "-r"]); + } + + #[test] + fn empty_or_deleted_temp_file_is_not_captured() { + let target = new_capture_target().unwrap(); + assert!(!validate_capture_output(target.path()).unwrap()); + let path = target.path().to_path_buf(); + drop(target); + assert!(!validate_capture_output(&path).unwrap()); + } +} diff --git a/crates/ttree-ocr/src/vision.rs b/crates/ttree-ocr/src/vision.rs new file mode 100644 index 0000000..3d3cf6d --- /dev/null +++ b/crates/ttree-ocr/src/vision.rs @@ -0,0 +1,202 @@ +//! macOS Apple Vision (`VNRecognizeTextRequest`) 原生文字识别。 + +#![cfg(target_os = "macos")] + +use objc2::rc::{autoreleasepool, Retained}; +use objc2::runtime::AnyObject; +use objc2::{AnyThread, ClassType}; +use objc2_core_graphics::CGImage; +use objc2_core_image::{kCIContextUseSoftwareRenderer, CIContext}; +use objc2_core_ml::MLCPUComputeDevice; +use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSObjectProtocol, NSString}; +use objc2_vision::{ + VNImageOptionCIContext, VNImageRequestHandler, VNRecognizeTextRequest, + VNRequestTextRecognitionLevel, +}; + +use crate::OcrOutcome; + +pub fn recognize_file(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("读取截图失败: {e}"))?; + recognize_bytes(&bytes) +} + +pub fn recognize_bytes(bytes: &[u8]) -> Result { + autoreleasepool(|_| { + let image = super::image::prepare_png(bytes)?; + let first_error = match recognize_once(&image, false) { + Ok(text) if !text.trim().is_empty() => return Ok(OcrOutcome::Text(text)), + Ok(_) => None, + Err(error) => Some(error), + }; + + // 兼容回退:切换软件 CIContext + CPU 计算设备 + revision 2 再试一次 + match recognize_once(&image, true) { + Ok(text) if !text.trim().is_empty() => Ok(OcrOutcome::Text(text)), + Ok(_) => Ok(OcrOutcome::EmptyText), + Err(second_error) => match first_error { + Some(first) => Err(format!("OCR 识别及兼容重试失败: {first}; {second_error}")), + None => Ok(OcrOutcome::EmptyText), + }, + } + }) +} + +pub(crate) fn recognize_once(image: &CGImage, compatibility: bool) -> Result { + unsafe { + let request: Retained = VNRecognizeTextRequest::new(); + configure_request(&request, compatibility)?; + + let options = if compatibility || objc2::available!(macos = 27.0) { + let software = NSNumber::numberWithBool(true); + let values: [&AnyObject; 1] = [&software]; + let context_options = + NSDictionary::from_slices(&[kCIContextUseSoftwareRenderer], &values); + let context = CIContext::contextWithOptions(Some(&context_options)); + let values: [&AnyObject; 1] = [&context]; + NSDictionary::from_slices(&[VNImageOptionCIContext], &values) + } else { + NSDictionary::new() + }; + + let handler = VNImageRequestHandler::initWithCGImage_options( + VNImageRequestHandler::alloc(), + image, + &options, + ); + + let requests = NSArray::from_retained_slice(&[Retained::into_super(Retained::into_super( + request.clone(), + ))]); + handler.performRequests_error(&requests).map_err(|e| { + format!( + "OCR 执行失败: {e}(图像 {}×{},{} bit)", + CGImage::width(Some(image)), + CGImage::height(Some(image)), + CGImage::bits_per_component(Some(image)), + ) + })?; + + Ok(collect_text(&request)) + } +} + +fn configure_request(request: &VNRecognizeTextRequest, compatibility: bool) -> Result<(), String> { + request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate); + request.setUsesLanguageCorrection(!compatibility); + + let revision = if !compatibility && objc2::available!(macos = 13.0) { + 3 + } else { + 2 + }; + unsafe { + request.setRevision(revision); + // 关键修复:不再将 preferBackgroundProcessing 设为 true,保证菜单栏隐藏窗口状态下拥有前台级识别优先级 + request.setPreferBackgroundProcessing(false); + } + if objc2::available!(macos = 13.0) { + request.setAutomaticallyDetectsLanguage(!compatibility); + } + + let supported = unsafe { + if objc2::available!(macos = 12.0) { + request.supportedRecognitionLanguagesAndReturnError() + } else { + #[allow(deprecated)] + VNRecognizeTextRequest::supportedRecognitionLanguagesForTextRecognitionLevel_revision_error( + VNRequestTextRecognitionLevel::Accurate, + revision, + ) + } + } + .map_err(|error| format!("读取 OCR 支持语言失败: {error}"))?; + + let supported_codes: Vec = supported.iter().map(|code| code.to_string()).collect(); + let languages: Vec> = preferred_languages(&supported_codes, revision) + .into_iter() + .map(NSString::from_str) + .collect(); + if !languages.is_empty() { + request.setRecognitionLanguages(&NSArray::from_retained_slice(&languages)); + } + + if compatibility || objc2::available!(macos = 27.0) { + configure_cpu(request)?; + } + Ok(()) +} + +fn configure_cpu(request: &VNRecognizeTextRequest) -> Result<(), String> { + unsafe { + if objc2::available!(macos = 14.0) { + let stages = request + .supportedComputeStageDevicesAndReturnError() + .map_err(|error| format!("读取 OCR 计算设备失败: {error}"))?; + let mut assigned = false; + for stage in stages.allKeys().iter() { + if let Some(devices) = stages.objectForKey(&stage) { + if let Some(cpu) = devices + .iter() + .find(|device| device.isKindOfClass(MLCPUComputeDevice::class())) + { + request.setComputeDevice_forComputeStage(Some(&cpu), &stage); + assigned = true; + } + } + } + if !assigned { + return Err("此 OCR 模型没有可用的 CPU 计算设备".to_string()); + } + } else { + #[allow(deprecated)] + request.setUsesCPUOnly(true); + } + } + Ok(()) +} + +fn preferred_languages(supported: &[String], revision: usize) -> Vec<&str> { + let candidates: &[&str] = if revision >= 3 { + &[ + "zh-Hans", "zh-Hant", "en-US", "ja-JP", "ko-KR", "fr-FR", "de-DE", "es-ES", "ru-RU", + ] + } else { + // revision 2 中文模型仅允许与 en-US 组合 + &["zh-Hans", "zh-Hant", "en-US"] + }; + candidates + .iter() + .copied() + .filter(|code| supported.iter().any(|s| s.as_str() == *code)) + .collect() +} + +unsafe fn collect_text(request: &VNRecognizeTextRequest) -> String { + let Some(observations) = request.results() else { + return String::new(); + }; + let mut lines: Vec = Vec::new(); + for obs in observations.iter() { + let candidates = obs.topCandidates(1); + if let Some(top) = candidates.firstObject() { + lines.push(top.string().to_string()); + } + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configures_foreground_priority_and_auto_language_detection() { + let request = VNRecognizeTextRequest::new(); + configure_request(&request, false).unwrap(); + assert!(unsafe { !request.preferBackgroundProcessing() }); + if objc2::available!(macos = 13.0) { + assert!(request.automaticallyDetectsLanguage()); + } + } +} diff --git a/crates/ttree-platform-macos/Cargo.toml b/crates/ttree-platform-macos/Cargo.toml new file mode 100644 index 0000000..f871ec9 --- /dev/null +++ b/crates/ttree-platform-macos/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ttree-platform-macos" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +ttree-core = { path = "../ttree-core" } +anyhow.workspace = true +auto-launch.workspace = true +global-hotkey.workspace = true +serde.workspace = true +serde_json.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation.workspace = true +security-framework.workspace = true +objc2.workspace = true +objc2-app-kit.workspace = true +objc2-core-graphics.workspace = true +objc2-foundation.workspace = true diff --git a/crates/ttree-platform-macos/src/autostart.rs b/crates/ttree-platform-macos/src/autostart.rs new file mode 100644 index 0000000..22b6655 --- /dev/null +++ b/crates/ttree-platform-macos/src/autostart.rs @@ -0,0 +1,40 @@ +//! macOS LaunchAgent 开机自启管理。 + +use auto_launch::{AutoLaunch, AutoLaunchBuilder}; + +const APP_NAME: &str = "TTREE"; + +fn build_auto_launch() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("获取当前可执行文件路径失败: {e}"))?; + // 若运行在 TTREE.app/Contents/MacOS/ttree 内,优先传入 .app bundle 路径 + let app_path = exe + .parent() + .and_then(|macos| macos.parent()) + .and_then(|contents| contents.parent()) + .filter(|bundle| bundle.extension().is_some_and(|ext| ext == "app")) + .unwrap_or(exe.as_path()); + + AutoLaunchBuilder::new() + .set_app_name(APP_NAME) + .set_app_path(&app_path.to_string_lossy()) + .set_use_launch_agent(true) + .build() + .map_err(|e| format!("初始化开机自启失败: {e}")) +} + +pub fn is_enabled() -> bool { + build_auto_launch() + .and_then(|al| al.is_enabled().map_err(|e| e.to_string())) + .unwrap_or(false) +} + +pub fn set_enabled(enable: bool) -> Result { + let al = build_auto_launch()?; + if enable { + al.enable().map_err(|e| format!("启用开机自启失败: {e}"))?; + } else if al.is_enabled().unwrap_or(false) { + al.disable().map_err(|e| format!("关闭开机自启失败: {e}"))?; + } + al.is_enabled() + .map_err(|e| format!("读取开机自启状态失败: {e}")) +} diff --git a/crates/ttree-platform-macos/src/hotkey.rs b/crates/ttree-platform-macos/src/hotkey.rs new file mode 100644 index 0000000..de99102 --- /dev/null +++ b/crates/ttree-platform-macos/src/hotkey.rs @@ -0,0 +1,180 @@ +//! 全局快捷键解析、冲突校验与内存级 O(1) 分发(不读盘)。 + +use std::str::FromStr; + +use anyhow::anyhow; +use global_hotkey::hotkey::{HotKey, Modifiers}; +use ttree_core::ShortcutConfig; + +const MAX_SHORTCUT_SPEC_BYTES: usize = 128; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutAction { + Toggle, + Ocr, + AiDialog, + SelectionTranslate, + SelectionAiDialog, +} + +#[derive(Debug, Clone, Default)] +pub struct ParsedShortcuts { + pub toggle: Option, + pub ocr: Option, + pub ai_dialog: Option, + pub selection_translate: Option, + pub selection_ai_dialog: Option, +} + +impl ParsedShortcuts { + /// 划词动作排在对应的纯呼出动作之前,共用同一快捷键时优先走划词逻辑。 + pub fn entries(&self) -> [(ShortcutAction, &Option); 5] { + [ + ( + ShortcutAction::SelectionTranslate, + &self.selection_translate, + ), + (ShortcutAction::SelectionAiDialog, &self.selection_ai_dialog), + (ShortcutAction::Toggle, &self.toggle), + (ShortcutAction::Ocr, &self.ocr), + (ShortcutAction::AiDialog, &self.ai_dialog), + ] + } + + /// 去重后的待注册热键列表。 + pub fn unique_hotkeys(&self) -> Vec { + let mut out: Vec = Vec::new(); + for (_, hk) in self.entries() { + if let Some(hk) = hk { + if !out.iter().any(|existing| existing.id() == hk.id()) { + out.push(*hk); + } + } + } + out + } + + /// 根据 `global-hotkey` 的 `id` 在内存中 O(1) 匹配动作。 + pub fn action_for_id(&self, hotkey_id: u32) -> Option { + self.entries() + .into_iter() + .find(|(_, candidate)| candidate.as_ref().is_some_and(|hk| hk.id() == hotkey_id)) + .map(|(action, _)| action) + } + + fn reject_disallowed_duplicates(&self) -> Result<(), anyhow::Error> { + let fields: [(&str, &Option); 5] = [ + ("呼出翻译快捷键", &self.toggle), + ("截图 OCR 快捷键", &self.ocr), + ("呼出 AI 对话快捷键", &self.ai_dialog), + ("划词翻译快捷键", &self.selection_translate), + ("划词 AI 对话快捷键", &self.selection_ai_dialog), + ]; + + for (i, (left_label, left)) in fields.iter().enumerate() { + let Some(left) = left else { continue }; + for (right_label, right) in fields.iter().skip(i + 1) { + let Some(right) = right else { continue }; + if left.id() == right.id() && !is_allowed_shared_pair(left_label, right_label) { + return Err(anyhow!("{left_label}和{right_label}不能相同")); + } + } + } + Ok(()) + } +} + +fn is_allowed_shared_pair(a: &str, b: &str) -> bool { + const ALLOWED: [(&str, &str); 2] = [ + ("呼出翻译快捷键", "划词翻译快捷键"), + ("呼出 AI 对话快捷键", "划词 AI 对话快捷键"), + ]; + ALLOWED + .iter() + .any(|&(x, y)| (a == x && b == y) || (a == y && b == x)) +} + +/// 将 Tauri 风格的 `CmdOrCtrl+Shift+Space` / `Option+Space` 规范化为 `global-hotkey` 支持的格式。 +fn normalize_spec(spec: &str) -> String { + spec.trim() + .split('+') + .map(|part| match part.trim() { + "CmdOrCtrl" | "CommandOrControl" | "Cmd" | "Command" => "Super", + "Option" => "Alt", + other => other, + }) + .collect::>() + .join("+") +} + +pub fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result { + let parsed = ParsedShortcuts { + toggle: parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?, + ocr: parse_optional_shortcut("截图 OCR 快捷键", &cfg.ocr)?, + ai_dialog: parse_optional_shortcut("呼出 AI 对话快捷键", &cfg.ai_dialog)?, + selection_translate: parse_optional_shortcut("划词翻译快捷键", &cfg.selection_translate)?, + selection_ai_dialog: parse_optional_shortcut( + "划词 AI 对话快捷键", + &cfg.selection_ai_dialog, + )?, + }; + parsed.reject_disallowed_duplicates()?; + Ok(parsed) +} + +fn parse_optional_shortcut(label: &str, spec: &str) -> Result, anyhow::Error> { + if spec.len() > MAX_SHORTCUT_SPEC_BYTES { + return Err(anyhow!("{label}超出 {MAX_SHORTCUT_SPEC_BYTES} 字节上限")); + } + if spec.trim().is_empty() { + return Ok(None); + } + let normalized = normalize_spec(spec); + let hotkey = HotKey::from_str(&normalized).map_err(|e| anyhow!("{label}无效: {e}"))?; + if !hotkey + .mods + .intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT) + { + return Err(anyhow!("{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); + } + Ok(Some(hotkey)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_default_and_shared_shortcuts() { + let cfg = ShortcutConfig { + toggle: "CmdOrCtrl+Shift+Space".to_string(), + ocr: "CmdOrCtrl+Shift+S".to_string(), + ai_dialog: "Option+Space".to_string(), + selection_translate: "CmdOrCtrl+Shift+Space".to_string(), + selection_ai_dialog: String::new(), + }; + let parsed = parse_shortcut_config(&cfg).unwrap(); + assert_eq!(parsed.unique_hotkeys().len(), 3); + let toggle_id = parsed.toggle.unwrap().id(); + assert_eq!( + parsed.action_for_id(toggle_id), + Some(ShortcutAction::SelectionTranslate) + ); + } + + #[test] + fn rejects_invalid_or_conflicting_shortcuts() { + let no_mod = ShortcutConfig { + toggle: "Space".to_string(), + ..ShortcutConfig::default() + }; + assert!(parse_shortcut_config(&no_mod).is_err()); + + let conflict = ShortcutConfig { + toggle: "CmdOrCtrl+Shift+S".to_string(), + ocr: "CmdOrCtrl+Shift+S".to_string(), + ..ShortcutConfig::default() + }; + assert!(parse_shortcut_config(&conflict).is_err()); + } +} diff --git a/crates/ttree-platform-macos/src/keychain.rs b/crates/ttree-platform-macos/src/keychain.rs new file mode 100644 index 0000000..d4b2389 --- /dev/null +++ b/crates/ttree-platform-macos/src/keychain.rs @@ -0,0 +1,127 @@ +//! macOS Keychain 封装 —— 兼容 `com.samwstu.ttree` 既有凭据格式。 + +#[cfg(target_os = "macos")] +use security_framework::passwords; +use serde::{Deserialize, Serialize}; + +pub const SERVICE: &str = "com.samwstu.ttree"; +pub const ACCOUNT_OPENAI_KEY: &str = "openai.api_key"; +pub const ACCOUNT_CHAT_AI_KEY: &str = "chat_ai.api_key"; + +const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; +const BOUND_SECRET_PREFIX: &str = "ttree-bound-secret-v1:"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretRecord { + Legacy(String), + Bound { endpoint: String, value: String }, +} + +impl SecretRecord { + pub fn value(&self) -> &str { + match self { + Self::Legacy(v) => v, + Self::Bound { value, .. } => value, + } + } +} + +#[derive(Serialize, Deserialize)] +struct BoundSecretPayload { + endpoint: String, + value: String, +} + +pub fn encode_bound_secret(endpoint: &str, value: &str) -> Result { + if endpoint.is_empty() || value.is_empty() { + return Err("接口地址和密钥均不能为空".to_string()); + } + let payload = serde_json::to_string(&BoundSecretPayload { + endpoint: endpoint.to_string(), + value: value.to_string(), + }) + .map_err(|e| format!("序列化钥匙串记录失败: {e}"))?; + Ok(format!("{BOUND_SECRET_PREFIX}{payload}")) +} + +pub fn decode_secret_record(raw: &str) -> Result { + let Some(payload) = raw.strip_prefix(BOUND_SECRET_PREFIX) else { + return Ok(SecretRecord::Legacy(raw.to_string())); + }; + let record: BoundSecretPayload = + serde_json::from_str(payload).map_err(|e| format!("钥匙串记录损坏: {e}"))?; + if record.endpoint.is_empty() || record.value.is_empty() { + return Err("钥匙串记录损坏: 接口地址或密钥为空".to_string()); + } + Ok(SecretRecord::Bound { + endpoint: record.endpoint, + value: record.value, + }) +} + +pub fn get_secret_record(account: &str) -> Result, String> { + get_secret(account)? + .map(|raw| decode_secret_record(&raw)) + .transpose() +} + +pub fn set_bound_secret(account: &str, endpoint: &str, value: &str) -> Result<(), String> { + let encoded = encode_bound_secret(endpoint, value)?; + set_secret(account, &encoded) +} + +#[cfg(target_os = "macos")] +pub fn get_secret(account: &str) -> Result, String> { + match passwords::get_generic_password(SERVICE, account) { + Ok(bytes) => Ok(Some( + String::from_utf8(bytes).map_err(|e| format!("密钥内容非 UTF-8: {e}"))?, + )), + Err(err) => { + if err.code() == ERR_SEC_ITEM_NOT_FOUND { + Ok(None) + } else { + Err(format!("读取钥匙串失败 (code {}): {err}", err.code())) + } + } + } +} + +#[cfg(target_os = "macos")] +pub fn set_secret(account: &str, value: &str) -> Result<(), String> { + passwords::set_generic_password(SERVICE, account, value.as_bytes()) + .map_err(|e| format!("写入钥匙串失败: {e}")) +} + +#[cfg(target_os = "macos")] +pub fn delete_secret(account: &str) -> Result<(), String> { + match passwords::delete_generic_password(SERVICE, account) { + Ok(()) => Ok(()), + Err(err) if err.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()), + Err(err) => Err(format!("删除钥匙串失败 (code {}): {err}", err.code())), + } +} + +#[cfg(not(target_os = "macos"))] +pub fn get_secret(_account: &str) -> Result, String> { + Ok(None) +} +#[cfg(not(target_os = "macos"))] +pub fn set_secret(_account: &str, _value: &str) -> Result<(), String> { + Ok(()) +} +#[cfg(not(target_os = "macos"))] +pub fn delete_secret(_account: &str) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_bound_secret() { + let encoded = encode_bound_secret("https://api.openai.com/v1", "sk-test").unwrap(); + let decoded = decode_secret_record(&encoded).unwrap(); + assert_eq!(decoded.value(), "sk-test"); + } +} diff --git a/crates/ttree-platform-macos/src/lib.rs b/crates/ttree-platform-macos/src/lib.rs new file mode 100644 index 0000000..f393bbb --- /dev/null +++ b/crates/ttree-platform-macos/src/lib.rs @@ -0,0 +1,10 @@ +//! `ttree-platform-macos` —— macOS 原生系统能力封装: +//! - Keychain 安全凭据存取(兼容既有 `com.samwstu.ttree` 钥匙串记录) +//! - Accessibility + 剪贴板回退的划词捕获 +//! - 全局热键解析、冲突校验与内存级 O(1) 分发 +//! - LaunchAgent 开机自启管理 + +pub mod autostart; +pub mod hotkey; +pub mod keychain; +pub mod selection; diff --git a/crates/ttree-platform-macos/src/selection.rs b/crates/ttree-platform-macos/src/selection.rs new file mode 100644 index 0000000..b66bbf9 --- /dev/null +++ b/crates/ttree-platform-macos/src/selection.rs @@ -0,0 +1,253 @@ +//! macOS 选中文本捕获:优先走 Accessibility API (`AXSelectedText`), +//! 仅当辅助功能未授权或焦点控件不支持 AX 属性时才回退到临时剪贴板复制。 + +use anyhow::Result; + +pub fn normalize_selected_text(text: impl AsRef) -> Option { + let trimmed = text.as_ref().trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[cfg(target_os = "macos")] +mod imp { + use std::ptr; + use std::thread; + use std::time::{Duration, Instant}; + + use anyhow::{anyhow, Result}; + use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType}; + use core_foundation::string::{CFString, CFStringRef}; + use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString}; + use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation}; + use objc2_foundation::{NSData, NSInteger, NSString}; + + use super::normalize_selected_text; + + const AX_ERROR_SUCCESS: i32 = 0; + const AX_ERROR_NO_VALUE: i32 = -25212; + const COPY_KEY_CODE: u16 = 8; + const COPY_TIMEOUT: Duration = Duration::from_millis(320); + const COPY_POLL_INTERVAL: Duration = Duration::from_millis(16); + + #[repr(C)] + struct __AXUIElement { + _private: [u8; 0], + } + + type AXUIElementRef = *const __AXUIElement; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> Boolean; + fn AXUIElementCreateSystemWide() -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> i32; + } + + #[derive(Debug, Clone, PartialEq, Eq)] + enum AxOutcome { + Selected(String), + EmptySelection, + Unavailable, + } + + #[derive(Debug, Clone)] + struct PasteboardSnapshot { + entries: Vec, + } + + #[derive(Debug, Clone)] + struct PasteboardEntry { + type_name: String, + data: Vec, + } + + pub fn is_accessibility_trusted() -> bool { + unsafe { AXIsProcessTrusted() != 0 } + } + + pub fn capture_selected_text() -> Result> { + match capture_accessibility() { + AxOutcome::Selected(text) => Ok(Some(text)), + // Accessibility 已明确告知当前焦点控件为空选区:不合成 Cmd+C,避免误触蜂鸣音与 320ms 轮询等待 + AxOutcome::EmptySelection => Ok(None), + AxOutcome::Unavailable => capture_clipboard_selected_text(), + } + } + + fn capture_accessibility() -> AxOutcome { + unsafe { + if AXIsProcessTrusted() == 0 { + return AxOutcome::Unavailable; + } + + let system_element = AXUIElementCreateSystemWide(); + if system_element.is_null() { + return AxOutcome::Unavailable; + } + + let focused_attr = CFString::new("AXFocusedUIElement"); + let mut focused_value: CFTypeRef = ptr::null(); + let focused_error = AXUIElementCopyAttributeValue( + system_element, + focused_attr.as_concrete_TypeRef(), + &mut focused_value, + ); + CFRelease(system_element as CFTypeRef); + + if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() { + return AxOutcome::Unavailable; + } + + let selected_attr = CFString::new("AXSelectedText"); + let mut selected_value: CFTypeRef = ptr::null(); + let selected_error = AXUIElementCopyAttributeValue( + focused_value as AXUIElementRef, + selected_attr.as_concrete_TypeRef(), + &mut selected_value, + ); + CFRelease(focused_value); + + if selected_error == AX_ERROR_NO_VALUE { + return AxOutcome::EmptySelection; + } + if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() { + return AxOutcome::Unavailable; + } + + let selected = + CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string(); + match normalize_selected_text(selected) { + Some(text) => AxOutcome::Selected(text), + None => AxOutcome::EmptySelection, + } + } + } + + fn capture_clipboard_selected_text() -> Result> { + let pasteboard = NSPasteboard::generalPasteboard(); + let snapshot = snapshot_pasteboard(&pasteboard); + let before_copy = pasteboard.changeCount(); + + post_copy_keypress()?; + + let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else { + return Ok(None); + }; + + let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text); + + if pasteboard.changeCount() == copy_change_count { + restore_pasteboard(&pasteboard, &snapshot); + } + + Ok(copied_text) + } + + fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot { + let entries = pasteboard + .types() + .map(|types| { + types + .to_vec() + .into_iter() + .filter_map(|type_name| { + pasteboard + .dataForType(&type_name) + .map(|data| PasteboardEntry { + type_name: type_name.to_string(), + data: data.to_vec(), + }) + }) + .collect() + }) + .unwrap_or_default(); + + PasteboardSnapshot { entries } + } + + fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) { + let _ = pasteboard.clearContents(); + for entry in &snapshot.entries { + let type_name = NSString::from_str(&entry.type_name); + let data = NSData::with_bytes(&entry.data); + let _ = pasteboard.setData_forType(Some(&data), &type_name); + } + } + + fn wait_for_pasteboard_change( + pasteboard: &NSPasteboard, + before_copy: NSInteger, + ) -> Option { + let deadline = Instant::now() + COPY_TIMEOUT; + while Instant::now() < deadline { + let current = pasteboard.changeCount(); + if current != before_copy { + return Some(current); + } + thread::sleep(COPY_POLL_INTERVAL); + } + None + } + + fn read_plain_text(pasteboard: &NSPasteboard) -> Option { + let pb_type: &'static NSPasteboardType = unsafe { NSPasteboardTypeString }; + pasteboard + .stringForType(pb_type) + .map(|text| text.to_string()) + } + + fn post_copy_keypress() -> Result<()> { + let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true) + .ok_or_else(|| anyhow!("创建复制按下事件失败"))?; + let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false) + .ok_or_else(|| anyhow!("创建复制释放事件失败"))?; + + CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand); + CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand); + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down)); + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up)); + Ok(()) + } +} + +#[cfg(target_os = "macos")] +pub fn capture_selected_text() -> Result> { + imp::capture_selected_text() +} + +#[cfg(target_os = "macos")] +pub fn is_accessibility_trusted() -> bool { + imp::is_accessibility_trusted() +} + +#[cfg(not(target_os = "macos"))] +pub fn capture_selected_text() -> Result> { + Ok(None) +} + +#[cfg(not(target_os = "macos"))] +pub fn is_accessibility_trusted() -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_whitespace_only_to_none() { + assert_eq!(normalize_selected_text(" \n\t "), None); + assert_eq!( + normalize_selected_text(" hello world \n"), + Some("hello world".to_string()) + ); + } +} diff --git a/crates/ttree-storage/Cargo.toml b/crates/ttree-storage/Cargo.toml new file mode 100644 index 0000000..ffb3687 --- /dev/null +++ b/crates/ttree-storage/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ttree-storage" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +ttree-core = { path = "../ttree-core" } +ttree-platform-macos = { path = "../ttree-platform-macos" } +dirs.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/ttree-storage/src/history.rs b/crates/ttree-storage/src/history.rs new file mode 100644 index 0000000..fc44c5c --- /dev/null +++ b/crates/ttree-storage/src/history.rs @@ -0,0 +1,238 @@ +//! AI 对话历史持久化(JSONL 追加写,100% 兼容既有 `chat-history.jsonl`)。 + +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::Deserialize; +use ttree_core::{validate_messages, ChatMessage, Conversation}; + +use crate::settings::SettingsStore; + +pub const HISTORY_FILE: &str = "chat-history.jsonl"; +const SESSION_START_MARKER: &str = r#"{"type":"session_start"}"#; +const SUMMARY_MAX_CHARS: usize = 40; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum HistoryLine { + SessionResume { + #[serde(rename = "type")] + kind: String, + id: usize, + }, + SessionStart { + #[serde(rename = "type")] + kind: String, + }, + Message(ChatMessage), +} + +pub struct HistoryStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl HistoryStore { + pub fn open_default() -> Self { + let dir = SettingsStore::default_config_dir(); + let _ = fs::create_dir_all(&dir); + Self::open_at(dir.join(HISTORY_FILE)) + } + + pub fn open_at(path: PathBuf) -> Self { + Self { + path, + lock: Mutex::new(()), + } + } + + pub fn start_new_conversation(&self) -> Result<(), String> { + let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?; + let needs_marker = fs::metadata(&self.path) + .map(|m| m.len() > 0) + .unwrap_or(false); + if !needs_marker { + return Ok(()); + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .map_err(|e| format!("打开历史文件失败: {e}"))?; + let mut buf = String::from(SESSION_START_MARKER); + buf.push('\n'); + file.write_all(buf.as_bytes()) + .map_err(|e| format!("写入对话分隔标记失败: {e}")) + } + + pub fn append_round( + &self, + conversation_id: Option, + msgs: &[ChatMessage], + ) -> Result<(), String> { + if msgs.is_empty() { + return Ok(()); + } + validate_messages(msgs)?; + let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?; + let mut buf = String::with_capacity(128 * msgs.len()); + if let Some(id) = conversation_id { + let conversations = read_conversations_from_path(&self.path)?; + if !conversations.iter().any(|c| c.id == id) { + return Err(format!("对话不存在: {id}")); + } + let marker = serde_json::json!({ "type": "session_resume", "id": id }); + buf.push_str( + &serde_json::to_string(&marker) + .map_err(|e| format!("序列化对话恢复标记失败: {e}"))?, + ); + buf.push('\n'); + } + for m in msgs { + let line = serde_json::to_string(m).map_err(|e| format!("序列化消息失败: {e}"))?; + buf.push_str(&line); + buf.push('\n'); + } + if let Some(parent) = self.path.parent() { + let _ = fs::create_dir_all(parent); + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .map_err(|e| format!("打开历史文件失败: {e}"))?; + file.write_all(buf.as_bytes()) + .map_err(|e| format!("写入历史文件失败: {e}")) + } + + pub fn read_conversations(&self) -> Result, String> { + let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?; + read_conversations_from_path(&self.path) + } + + pub fn clear(&self) -> Result<(), String> { + let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?; + match fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("删除历史文件失败: {e}")), + } + } +} + +fn read_conversations_from_path(path: &Path) -> Result, String> { + let file = match File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(format!("打开历史文件失败: {e}")), + }; + let reader = BufReader::new(file); + Ok(parse_conversation_lines( + reader.lines().map_while(Result::ok), + )) +} + +pub fn parse_conversation_lines(lines: I) -> Vec +where + I: IntoIterator, +{ + let mut conversations: Vec = Vec::new(); + let mut current_id: Option = None; + let mut next_id: usize = 0; + + for line in lines { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(parsed) = serde_json::from_str::(trimmed) else { + continue; + }; + match parsed { + HistoryLine::SessionResume { kind, id } if kind == "session_resume" => { + current_id = Some(id); + next_id = next_id.max(id.saturating_add(1)); + if let Some(index) = conversations.iter().position(|c| c.id == id) { + let conv = conversations.remove(index); + conversations.push(conv); + } + } + HistoryLine::SessionStart { kind } if kind == "session_start" => { + current_id = Some(next_id); + next_id = next_id.saturating_add(1); + } + HistoryLine::Message(msg) => { + let id = current_id.unwrap_or_else(|| { + let id = next_id; + next_id = next_id.saturating_add(1); + current_id = Some(id); + id + }); + if !conversations.iter().any(|c| c.id == id) { + conversations.push(Conversation { + id, + messages: Vec::new(), + summary: String::new(), + }); + } + let conv = conversations + .iter_mut() + .find(|c| c.id == id) + .expect("conversation exists"); + if conv.summary.is_empty() && msg.role == "user" { + conv.summary = truncate_summary(&msg.content); + } + conv.messages.push(msg); + } + _ => continue, + } + } + conversations +} + +fn truncate_summary(content: &str) -> String { + let first_line = content.lines().next().unwrap_or("").trim(); + if first_line.chars().count() <= SUMMARY_MAX_CHARS { + return first_line.to_string(); + } + let truncated: String = first_line.chars().take(SUMMARY_MAX_CHARS).collect(); + format!("{truncated}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_conversations_and_session_splits() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let store = HistoryStore::open_at(tmp.path().to_path_buf()); + + store + .append_round( + None, + &[ + ChatMessage::user("第一段问题"), + ChatMessage::assistant("第一段回答"), + ], + ) + .unwrap(); + store.start_new_conversation().unwrap(); + store + .append_round( + None, + &[ + ChatMessage::user("第二段问题"), + ChatMessage::assistant("第二段回答"), + ], + ) + .unwrap(); + + let convs = store.read_conversations().unwrap(); + assert_eq!(convs.len(), 2); + assert_eq!(convs[0].summary, "第一段问题"); + assert_eq!(convs[1].summary, "第二段问题"); + } +} diff --git a/crates/ttree-storage/src/lib.rs b/crates/ttree-storage/src/lib.rs new file mode 100644 index 0000000..b515295 --- /dev/null +++ b/crates/ttree-storage/src/lib.rs @@ -0,0 +1,7 @@ +//! `ttree-storage` —— 内存级配置缓存(兼容既有 `settings.json` 与 Keychain)与 JSONL 对话历史持久化。 + +pub mod history; +pub mod settings; + +pub use history::HistoryStore; +pub use settings::SettingsStore; diff --git a/crates/ttree-storage/src/settings.rs b/crates/ttree-storage/src/settings.rs new file mode 100644 index 0000000..3dbcc7e --- /dev/null +++ b/crates/ttree-storage/src/settings.rs @@ -0,0 +1,442 @@ +//! 内存缓存 + 磁盘持久化的配置管理。 +//! +//! 关键性能优化: +//! - 启动时一次性加载 `settings.json` 与 Keychain 密钥到内存 `RwLock` +//! - 翻译/对话/快捷键分发热路径直接从内存读取(0 磁盘 I/O,0 Keychain IPC) +//! - 100% 兼容旧版 Tauri `tauri-plugin-store` 生成的扁平点号键名(如 `"openai.base_url"`) + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use serde_json::{Map, Value}; +use ttree_core::config::{ + validate_field_len, MAX_API_KEY_BYTES, MAX_MODEL_BYTES, MAX_PATH_BYTES, MAX_PROMPT_BYTES, +}; +use ttree_core::{ + normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig, + ShortcutConfig, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, +}; +use ttree_platform_macos::keychain::{self, ACCOUNT_CHAT_AI_KEY, ACCOUNT_OPENAI_KEY}; + +pub const APP_BUNDLE_ID: &str = "com.samwstu.ttree"; +pub const STORE_FILE: &str = "settings.json"; + +#[derive(Clone)] +struct CachedSettings { + raw_map: Map, + ai_translate: AiTranslationConfig, + chat_ai: ChatAiConfig, + shortcuts: ShortcutConfig, + appearance: AppearanceConfig, + window_size: (f64, f64), +} + +pub struct SettingsStore { + path: PathBuf, + cache: RwLock, +} + +impl SettingsStore { + pub fn default_config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(APP_BUNDLE_ID) + } + + pub fn open_default() -> Self { + let dir = Self::default_config_dir(); + let _ = fs::create_dir_all(&dir); + Self::open_at(dir.join(STORE_FILE)) + } + + pub fn open_at(path: PathBuf) -> Self { + let raw_map = read_json_map(&path); + let ai_translate = load_ai_translation(&raw_map); + let chat_ai = load_chat_ai(&raw_map); + let shortcuts = load_shortcuts(&raw_map); + let appearance = load_appearance(&raw_map); + let window_size = load_window_size(&raw_map).unwrap_or((680.0, 520.0)); + + Self { + path, + cache: RwLock::new(CachedSettings { + raw_map, + ai_translate, + chat_ai, + shortcuts, + appearance, + window_size, + }), + } + } + + /// 供翻译请求直接读取(含内存缓存的真实 API Key,零 I/O)。 + pub fn ai_translation_for_request(&self) -> AiTranslationConfig { + self.cache.read().expect("settings lock poisoned").ai_translate.clone() + } + + /// 供对话请求直接读取(含内存缓存的真实 API Key,零 I/O)。 + pub fn chat_ai_for_request(&self) -> ChatAiConfig { + self.cache.read().expect("settings lock poisoned").chat_ai.clone() + } + + pub fn shortcuts(&self) -> ShortcutConfig { + self.cache.read().expect("settings lock poisoned").shortcuts.clone() + } + + pub fn appearance(&self) -> AppearanceConfig { + self.cache.read().expect("settings lock poisoned").appearance + } + + pub fn window_size(&self) -> (f64, f64) { + self.cache.read().expect("settings lock poisoned").window_size + } + + pub fn save_ai_translation(&self, input: &AiTranslationConfig) -> Result<(), String> { + validate_field_len("模型", &input.model, MAX_MODEL_BYTES)?; + validate_field_len("GGUF 模型路径", &input.gguf_model_path, MAX_PATH_BYTES)?; + validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?; + validate_field_len("翻译提示词", &input.translate_prompt, MAX_PROMPT_BYTES)?; + + let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() { + String::new() + } else { + normalize_base_url(&input.base_url)? + }; + + let mut guard = self.cache.write().expect("settings lock poisoned"); + let resolved_key = update_secret_if_needed( + ACCOUNT_OPENAI_KEY, + &base_url, + &guard.ai_translate.api_key, + &input.api_key, + input.clear_api_key, + )?; + + let updated = AiTranslationConfig { + enabled: input.enabled, + backend: input.backend, + base_url: base_url.clone(), + has_api_key: !resolved_key.is_empty(), + api_key: resolved_key, + clear_api_key: false, + model: input.model.trim().to_string(), + gguf_model_path: input.gguf_model_path.trim().to_string(), + gguf_tokenizer_path: input.gguf_tokenizer_path.trim().to_string(), + translate_prompt: input.translate_prompt.clone(), + }; + + guard.raw_map.insert("openai.enabled".into(), Value::Bool(updated.enabled)); + guard.raw_map.insert( + "openai.backend".into(), + serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())), + ); + guard.raw_map.insert("openai.base_url".into(), Value::String(updated.base_url.clone())); + guard.raw_map.insert("openai.model".into(), Value::String(updated.model.clone())); + guard.raw_map.insert( + "openai.gguf_model_path".into(), + Value::String(updated.gguf_model_path.clone()), + ); + guard.raw_map.insert( + "openai.gguf_tokenizer_path".into(), + Value::String(updated.gguf_tokenizer_path.clone()), + ); + guard.raw_map.insert( + "openai.translate_prompt".into(), + Value::String(updated.translate_prompt.clone()), + ); + guard.raw_map.remove("openai.api_key"); + + write_json_map(&self.path, &guard.raw_map)?; + guard.ai_translate = updated; + Ok(()) + } + + pub fn save_chat_ai(&self, input: &ChatAiConfig) -> Result<(), String> { + validate_field_len("模型", &input.model, MAX_MODEL_BYTES)?; + validate_field_len("GGUF 模型路径", &input.gguf_model_path, MAX_PATH_BYTES)?; + validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?; + validate_field_len("AI 对话提示词", &input.chat_prompt, MAX_PROMPT_BYTES)?; + + let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() { + String::new() + } else { + normalize_base_url(&input.base_url)? + }; + + let mut guard = self.cache.write().expect("settings lock poisoned"); + let resolved_key = update_secret_if_needed( + ACCOUNT_CHAT_AI_KEY, + &base_url, + &guard.chat_ai.api_key, + &input.api_key, + input.clear_api_key, + )?; + + let updated = ChatAiConfig { + backend: input.backend, + base_url: base_url.clone(), + has_api_key: !resolved_key.is_empty(), + api_key: resolved_key, + clear_api_key: false, + model: input.model.trim().to_string(), + gguf_model_path: input.gguf_model_path.trim().to_string(), + gguf_tokenizer_path: input.gguf_tokenizer_path.trim().to_string(), + chat_prompt: input.chat_prompt.clone(), + }; + + guard.raw_map.insert( + "chat_ai.backend".into(), + serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())), + ); + guard.raw_map.insert("chat_ai.base_url".into(), Value::String(updated.base_url.clone())); + guard.raw_map.insert("chat_ai.model".into(), Value::String(updated.model.clone())); + guard.raw_map.insert( + "chat_ai.gguf_model_path".into(), + Value::String(updated.gguf_model_path.clone()), + ); + guard.raw_map.insert( + "chat_ai.gguf_tokenizer_path".into(), + Value::String(updated.gguf_tokenizer_path.clone()), + ); + guard.raw_map.insert( + "chat_ai.chat_prompt".into(), + Value::String(updated.chat_prompt.clone()), + ); + guard.raw_map.remove("chat_ai.api_key"); + + write_json_map(&self.path, &guard.raw_map)?; + guard.chat_ai = updated; + Ok(()) + } + + pub fn save_shortcuts(&self, cfg: &ShortcutConfig) -> Result<(), String> { + let mut guard = self.cache.write().expect("settings lock poisoned"); + guard.raw_map.insert("shortcut.toggle".into(), Value::String(cfg.toggle.clone())); + guard.raw_map.insert("shortcut.ocr".into(), Value::String(cfg.ocr.clone())); + guard.raw_map.insert("shortcut.ai_dialog".into(), Value::String(cfg.ai_dialog.clone())); + guard.raw_map.insert( + "shortcut.selection_translate".into(), + Value::String(cfg.selection_translate.clone()), + ); + guard.raw_map.insert( + "shortcut.selection_ai_dialog".into(), + Value::String(cfg.selection_ai_dialog.clone()), + ); + write_json_map(&self.path, &guard.raw_map)?; + guard.shortcuts = cfg.clone(); + Ok(()) + } + + pub fn save_appearance(&self, cfg: AppearanceConfig) -> Result<(), String> { + let clamped = AppearanceConfig { + panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity), + }; + let mut guard = self.cache.write().expect("settings lock poisoned"); + guard + .raw_map + .insert("appearance.panel_opacity".into(), Value::from(clamped.panel_opacity)); + write_json_map(&self.path, &guard.raw_map)?; + guard.appearance = clamped; + Ok(()) + } + + pub fn save_window_size(&self, width: f64, height: f64) { + let w = width.max(WINDOW_MIN_WIDTH); + let h = height.max(WINDOW_MIN_HEIGHT); + if let Ok(mut guard) = self.cache.write() { + if (guard.window_size.0 - w).abs() < 1.0 && (guard.window_size.1 - h).abs() < 1.0 { + return; + } + guard.window_size = (w, h); + guard.raw_map.insert("window.width".into(), Value::from(w)); + guard.raw_map.insert("window.height".into(), Value::from(h)); + let _ = write_json_map(&self.path, &guard.raw_map); + } + } +} + +fn update_secret_if_needed( + account: &str, + endpoint: &str, + existing: &str, + new_key: &str, + clear: bool, +) -> Result { + let trimmed = new_key.trim(); + validate_field_len("API Key", trimmed, MAX_API_KEY_BYTES)?; + if clear { + let _ = keychain::delete_secret(account); + return Ok(String::new()); + } + if !trimmed.is_empty() { + let bind_url = if endpoint.is_empty() { + "local://gguf" + } else { + endpoint + }; + let _ = keychain::set_bound_secret(account, bind_url, trimmed); + return Ok(trimmed.to_string()); + } + Ok(existing.to_string()) +} + +fn read_json_map(path: &Path) -> Map { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +fn write_json_map(path: &Path, map: &Map) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?; + } + let bytes = serde_json::to_vec_pretty(map).map_err(|e| format!("序列化配置失败: {e}"))?; + fs::write(path, bytes).map_err(|e| format!("写入配置文件失败: {e}")) +} + +fn load_ai_translation(map: &Map) -> AiTranslationConfig { + let mut cfg = AiTranslationConfig::default(); + if let Some(enabled) = map.get("openai.enabled").and_then(Value::as_bool) { + cfg.enabled = enabled; + } + if let Some(backend) = map + .get("openai.backend") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + cfg.backend = backend; + } + if let Some(url) = map.get("openai.base_url").and_then(Value::as_str) { + if let Ok(norm) = normalize_base_url(url) { + cfg.base_url = norm; + } + } + if let Some(m) = map.get("openai.model").and_then(Value::as_str) { + if !m.trim().is_empty() { + cfg.model = m.trim().to_string(); + } + } + if let Some(p) = map.get("openai.gguf_model_path").and_then(Value::as_str) { + cfg.gguf_model_path = p.trim().to_string(); + } + if let Some(p) = map.get("openai.gguf_tokenizer_path").and_then(Value::as_str) { + cfg.gguf_tokenizer_path = p.trim().to_string(); + } + if let Some(prompt) = map.get("openai.translate_prompt").and_then(Value::as_str) { + if !prompt.trim().is_empty() { + cfg.translate_prompt = prompt.to_string(); + } + } + if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_OPENAI_KEY) { + cfg.api_key = record.value().to_string(); + cfg.has_api_key = !cfg.api_key.is_empty(); + } + cfg +} + +fn load_chat_ai(map: &Map) -> ChatAiConfig { + let mut cfg = ChatAiConfig::default(); + if let Some(backend) = map + .get("chat_ai.backend") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + cfg.backend = backend; + } + if let Some(url) = map.get("chat_ai.base_url").and_then(Value::as_str) { + if let Ok(norm) = normalize_base_url(url) { + cfg.base_url = norm; + } + } + if let Some(m) = map.get("chat_ai.model").and_then(Value::as_str) { + if !m.trim().is_empty() { + cfg.model = m.trim().to_string(); + } + } + if let Some(p) = map.get("chat_ai.gguf_model_path").and_then(Value::as_str) { + cfg.gguf_model_path = p.trim().to_string(); + } + if let Some(p) = map.get("chat_ai.gguf_tokenizer_path").and_then(Value::as_str) { + cfg.gguf_tokenizer_path = p.trim().to_string(); + } + if let Some(prompt) = map.get("chat_ai.chat_prompt").and_then(Value::as_str) { + cfg.chat_prompt = prompt.to_string(); + } + if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_CHAT_AI_KEY) { + cfg.api_key = record.value().to_string(); + cfg.has_api_key = !cfg.api_key.is_empty(); + } + cfg +} + +fn load_shortcuts(map: &Map) -> ShortcutConfig { + let defaults = ShortcutConfig::default(); + ShortcutConfig { + toggle: map + .get("shortcut.toggle") + .and_then(Value::as_str) + .unwrap_or(&defaults.toggle) + .to_string(), + ocr: map + .get("shortcut.ocr") + .and_then(Value::as_str) + .unwrap_or(&defaults.ocr) + .to_string(), + ai_dialog: map + .get("shortcut.ai_dialog") + .and_then(Value::as_str) + .unwrap_or(&defaults.ai_dialog) + .to_string(), + selection_translate: map + .get("shortcut.selection_translate") + .and_then(Value::as_str) + .unwrap_or(&defaults.selection_translate) + .to_string(), + selection_ai_dialog: map + .get("shortcut.selection_ai_dialog") + .and_then(Value::as_str) + .unwrap_or(&defaults.selection_ai_dialog) + .to_string(), + } +} + +fn load_appearance(map: &Map) -> AppearanceConfig { + let mut cfg = AppearanceConfig::default(); + if let Some(v) = map.get("appearance.panel_opacity").and_then(Value::as_f64) { + cfg.panel_opacity = AppearanceConfig::clamp_opacity(v); + } + cfg +} + +fn load_window_size(map: &Map) -> Option<(f64, f64)> { + let w = map.get("window.width").and_then(Value::as_f64)?; + let h = map.get("window.height").and_then(Value::as_f64)?; + if w < WINDOW_MIN_WIDTH || h < WINDOW_MIN_HEIGHT { + return None; + } + Some((w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persists_and_reloads_settings_via_memory_cache() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let store = SettingsStore::open_at(tmp.path().to_path_buf()); + + let mut cfg = store.ai_translation_for_request(); + cfg.backend = AiBackendKind::Ollama; + cfg.base_url = "http://127.0.0.1:11434".to_string(); + cfg.model = "qwen2.5:3b".to_string(); + store.save_ai_translation(&cfg).unwrap(); + + let reloaded = SettingsStore::open_at(tmp.path().to_path_buf()); + let loaded_cfg = reloaded.ai_translation_for_request(); + assert_eq!(loaded_cfg.backend, AiBackendKind::Ollama); + assert_eq!(loaded_cfg.base_url, "http://127.0.0.1:11434"); + assert_eq!(loaded_cfg.model, "qwen2.5:3b"); + } +} diff --git a/crates/ttree-translate/Cargo.toml b/crates/ttree-translate/Cargo.toml new file mode 100644 index 0000000..ba38acd --- /dev/null +++ b/crates/ttree-translate/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ttree-translate" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +ttree-core = { path = "../ttree-core" } +futures-util.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +urlencoding.workspace = true diff --git a/crates/ttree-translate/src/auto.rs b/crates/ttree-translate/src/auto.rs new file mode 100644 index 0000000..90a73af --- /dev/null +++ b/crates/ttree-translate/src/auto.rs @@ -0,0 +1,253 @@ +//! 免费网页翻译智能竞速路由(Hedged Request Racing + 延迟加权主备优选 + 并发分块)。 +//! +//! 关键提速: +//! 1. 根据最近一次响应延迟选出最快的服务作为 Primary,另一方作为 Secondary; +//! 2. Primary 发出后若在 `HEDGE_DELAY (550ms)` 内未返回,立即并发启动 Secondary 竞速(谁先成功用谁); +//! 3. 长文本分块后支持并发请求,而非旧版的串行逐块阻塞等待。 + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use futures_util::future::join_all; + +use crate::{bing, google}; + +const HEDGE_DELAY: Duration = Duration::from_millis(550); +const BASE_COOLDOWN: Duration = Duration::from_secs(45); +const MAX_COOLDOWN: Duration = Duration::from_secs(300); +const CHUNK_UTF16_LIMIT: usize = 1_200; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Provider { + Google, + Bing, +} + +impl Provider { + fn index(self) -> usize { + match self { + Self::Google => 0, + Self::Bing => 1, + } + } + + fn other(self) -> Self { + match self { + Self::Google => Self::Bing, + Self::Bing => Self::Google, + } + } +} + +#[derive(Clone, Copy)] +struct ProviderStats { + ewma_ms: u64, + failures: u32, + retry_after: Option, +} + +impl Default for ProviderStats { + fn default() -> Self { + Self { + ewma_ms: 400, + failures: 0, + retry_after: None, + } + } +} + +#[derive(Default)] +struct RouterState { + stats: [ProviderStats; 2], +} + +impl RouterState { + fn is_available(&self, p: Provider, now: Instant) -> bool { + self.stats[p.index()] + .retry_after + .is_none_or(|t| now >= t) + } + + /// 优先返回当前可用且历史延迟更低的服务。 + fn ordered_candidates(&self, now: Instant) -> Vec { + let mut list: Vec = [Provider::Google, Provider::Bing] + .into_iter() + .filter(|&p| self.is_available(p, now)) + .collect(); + if list.is_empty() { + // 若两个都在冷却期,允许全部重探 + list.push(Provider::Bing); + list.push(Provider::Google); + } else if list.len() == 2 { + list.sort_by_key(|&p| self.stats[p.index()].ewma_ms); + } + list + } + + fn record_success(&mut self, p: Provider, elapsed: Duration) { + let st = &mut self.stats[p.index()]; + st.failures = 0; + st.retry_after = None; + let sample = elapsed.as_millis() as u64; + st.ewma_ms = (st.ewma_ms * 3 + sample) / 4; + } + + fn record_failure(&mut self, p: Provider, now: Instant) { + let st = &mut self.stats[p.index()]; + st.failures = st.failures.saturating_add(1); + let mult = 1 << st.failures.saturating_sub(1).min(3); + let cooldown = (BASE_COOLDOWN * mult).min(MAX_COOLDOWN); + st.retry_after = Some(now + cooldown); + st.ewma_ms = st.ewma_ms.saturating_add(1500); + } +} + +static ROUTER: OnceLock> = OnceLock::new(); + +fn router() -> &'static Mutex { + ROUTER.get_or_init(|| Mutex::new(RouterState::default())) +} + +pub async fn translate(text: &str, source: &str, target: &str) -> Result { + let chunks = split_text(text); + if chunks.len() == 1 { + return translate_chunk_hedged(chunks[0].trim(), source, target).await; + } + + // 多段长文本并发分块翻译,保留段间空白与换行 + let futs = chunks.iter().map(|&chunk| async move { + let content = chunk.trim(); + if content.is_empty() { + return Ok::(chunk.to_string()); + } + let translated = translate_chunk_hedged(content, source, target).await?; + let mut piece = String::new(); + piece.push_str(&chunk[..chunk.len() - chunk.trim_start().len()]); + piece.push_str(translated.trim()); + piece.push_str(&chunk[chunk.trim_end().len()..]); + Ok(piece) + }); + + let results = join_all(futs).await; + let mut output = String::new(); + for res in results { + output.push_str(&res?); + } + Ok(output) +} + +async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Result { + let candidates = router() + .lock() + .map(|g| g.ordered_candidates(Instant::now())) + .unwrap_or_else(|_| vec![Provider::Bing, Provider::Google]); + + let primary = candidates[0]; + let secondary = candidates.get(1).copied().unwrap_or_else(|| primary.other()); + + let primary_fut = call_provider(primary, text, source, target); + tokio::pin!(primary_fut); + + // 先给主服务 HEDGE_DELAY (550ms) 单独响应窗口 + tokio::select! { + res = &mut primary_fut => { + match res { + Ok(out) => return Ok(out), + Err(_) => { + // 主服务立即报错,直接等待备用服务 + return call_provider(secondary, text, source, target).await; + } + } + } + _ = tokio::time::sleep(HEDGE_DELAY) => {} + } + + // 主服务超过 550ms 未返回:并发拉起备用服务竞速! + let secondary_fut = call_provider(secondary, text, source, target); + tokio::pin!(secondary_fut); + + tokio::select! { + res1 = &mut primary_fut => { + match res1 { + Ok(out) => Ok(out), + Err(_) => secondary_fut.await, + } + } + res2 = &mut secondary_fut => { + match res2 { + Ok(out) => Ok(out), + Err(_) => primary_fut.await, + } + } + } +} + +async fn call_provider( + provider: Provider, + text: &str, + source: &str, + target: &str, +) -> Result { + let start = Instant::now(); + let result = match provider { + Provider::Google => google::translate(text, source, target).await, + Provider::Bing => bing::translate(text, source, target).await, + }; + if let Ok(mut guard) = router().lock() { + match &result { + Ok(_) => guard.record_success(provider, start.elapsed()), + Err(_) => guard.record_failure(provider, Instant::now()), + } + } + result +} + +pub fn split_text(mut text: &str) -> Vec<&str> { + let mut chunks = Vec::new(); + while !text.is_empty() { + let mut units = 0; + let mut end = text.len(); + let mut boundary = None; + for (index, ch) in text.char_indices() { + units += ch.len_utf16(); + if units > CHUNK_UTF16_LIMIT { + end = boundary.unwrap_or(index); + break; + } + if units >= CHUNK_UTF16_LIMIT / 2 + && (ch.is_whitespace() || matches!(ch, '.' | '!' | '?' | '。' | '!' | '?')) + { + boundary = Some(index + ch.len_utf8()); + } + } + let (chunk, remaining) = text.split_at(end); + chunks.push(chunk); + text = remaining; + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn router_prefers_lower_latency_healthy_provider() { + let mut state = RouterState::default(); + let now = Instant::now(); + state.record_success(Provider::Bing, Duration::from_millis(120)); + state.record_success(Provider::Google, Duration::from_millis(900)); + assert_eq!(state.ordered_candidates(now)[0], Provider::Bing); + + state.record_failure(Provider::Bing, now); + assert_eq!(state.ordered_candidates(now), vec![Provider::Google]); + } + + #[test] + fn splits_long_text_without_losing_characters() { + let input = format!("{}\n{}", "测试文本 ".repeat(400), "hello world. ".repeat(300)); + let chunks = split_text(&input); + assert!(chunks.len() > 1); + assert_eq!(chunks.concat(), input); + } +} diff --git a/crates/ttree-translate/src/bing.rs b/crates/ttree-translate/src/bing.rs new file mode 100644 index 0000000..620e877 --- /dev/null +++ b/crates/ttree-translate/src/bing.rs @@ -0,0 +1,229 @@ +//! 必应翻译 —— 带有内存级 `BingToken` 缓存(默认 45 分钟复用,失效自动刷新)。 +//! +//! 关键提速: +//! 旧版每次翻译都先请求并下载数 MB 的 `https://www.bing.com/translator` 网页解析 Token, +//! 导致必应翻译耗时翻倍且分块时重复抓网页。新版将 Token 缓存在内存中,后续请求全部单跳直达。 + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use crate::{ + read_error_body, read_limited_response, shared_client, MAX_BING_PAGE_BYTES, + MAX_TRANSLATION_RESPONSE_BYTES, +}; + +const TRANSLATOR_URL: &str = "https://www.bing.com/translator"; +const TRANSLATE_URL: &str = "https://www.bing.com/ttranslatev3"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +const TOKEN_TTL: Duration = Duration::from_secs(45 * 60); +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BingToken { + pub ig: String, + pub iid: String, + pub key: String, + pub token: String, +} + +#[derive(Clone)] +struct CachedToken { + token: BingToken, + expires_at: Instant, +} + +static TOKEN_CACHE: OnceLock>> = OnceLock::new(); + +fn get_cached_token(now: Instant) -> Option { + let cache = TOKEN_CACHE.get_or_init(|| Mutex::new(None)); + let mut guard = cache.lock().ok()?; + if let Some(entry) = guard.as_ref() { + if now < entry.expires_at { + return Some(entry.token.clone()); + } + } + *guard = None; + None +} + +fn store_cached_token(token: BingToken, now: Instant) { + let cache = TOKEN_CACHE.get_or_init(|| Mutex::new(None)); + if let Ok(mut guard) = cache.lock() { + *guard = Some(CachedToken { + token, + expires_at: now + TOKEN_TTL, + }); + } +} + +fn invalidate_cached_token() { + if let Some(cache) = TOKEN_CACHE.get() { + if let Ok(mut guard) = cache.lock() { + *guard = None; + } + } +} + +fn map_lang(code: &str) -> &str { + match code { + "auto" => "auto-detect", + "zh-CN" => "zh-Hans", + "zh-TW" => "zh-Hant", + other => other, + } +} + +pub async fn translate(text: &str, source: &str, target: &str) -> Result { + let client = shared_client(REQUEST_TIMEOUT, true); + + let (token, from_cache) = match get_cached_token(Instant::now()) { + Some(t) => (t, true), + None => { + let fresh = fetch_token(&client).await?; + store_cached_token(fresh.clone(), Instant::now()); + (fresh, false) + } + }; + + match translate_with_token(&client, &token, text, source, target).await { + Ok(out) => Ok(out), + Err(_first_err) if from_cache => { + // 缓存令牌可能提前失效,立即淘汰并刷新重试一次 + invalidate_cached_token(); + let fresh = fetch_token(&client).await?; + store_cached_token(fresh.clone(), Instant::now()); + translate_with_token(&client, &fresh, text, source, target).await + } + Err(err) => Err(err), + } +} + +async fn translate_with_token( + client: &reqwest::Client, + token: &BingToken, + text: &str, + source: &str, + target: &str, +) -> Result { + let url = format!( + "{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}", + ig = urlencoding::encode(&token.ig), + iid = urlencoding::encode(&token.iid), + ); + + let resp = client + .post(&url) + .header("User-Agent", USER_AGENT) + .header("Referer", TRANSLATOR_URL) + .form(&[ + ("fromLang", map_lang(source)), + ("to", map_lang(target)), + ("text", text), + ("token", &token.token), + ("key", &token.key), + ]) + .send() + .await + .map_err(|e| format!("必应翻译请求失败: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = read_error_body(resp).await; + return Err(format!("必应翻译返回状态 {status}: {body}")); + } + + let body = + read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "必应翻译响应").await?; + let body: Value = + serde_json::from_slice(&body).map_err(|e| format!("必应翻译响应解析失败: {e}"))?; + + parse_response(&body) + .filter(|t| !t.trim().is_empty()) + .ok_or_else(|| "必应翻译未返回译文".to_string()) +} + +async fn fetch_token(client: &reqwest::Client) -> Result { + let response = client + .get(TRANSLATOR_URL) + .header("User-Agent", USER_AGENT) + .send() + .await + .map_err(|e| format!("必应翻译页请求失败: {e}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = read_error_body(response).await; + return Err(format!("必应翻译页返回状态 {status}: {body}")); + } + let html = read_limited_response(response, MAX_BING_PAGE_BYTES, "必应翻译页").await?; + let html = std::str::from_utf8(&html).map_err(|e| format!("必应翻译页不是 UTF-8: {e}"))?; + + parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string()) +} + +pub fn parse_token(html: &str) -> Option { + let ig = extract_between(html, "IG:\"", "\"")?; + let iid = format!( + "translator.{}", + extract_between(html, "data-iid=\"translator.", "\"")? + ); + + let assignment = html + .split_once("params_AbusePreventionHelper")? + .1 + .trim_start(); + let assignment = assignment.strip_prefix('=')?.trim_start(); + let helper = assignment.strip_prefix('[')?.split_once(']')?.0; + let parts: Vec = serde_json::from_str(&format!("[{helper}]")).ok()?; + let key = parts.first()?.as_u64()?.to_string(); + let token = parts.get(1)?.as_str()?.to_string(); + + if key.is_empty() || token.is_empty() { + return None; + } + + Some(BingToken { + ig, + iid, + key, + token, + }) +} + +fn extract_between(haystack: &str, start: &str, end: &str) -> Option { + let begin = haystack.find(start)? + start.len(); + let rest = &haystack[begin..]; + let stop = rest.find(end)?; + Some(rest[..stop].to_string()) +} + +pub fn parse_response(body: &Value) -> Option { + body.get(0)? + .get("translations")? + .get(0)? + .get("text")? + .as_str() + .map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bing_html_token_and_caches_with_ttl() { + let html = r#"IG:"FF00";
+ params_AbusePreventionHelper = [167060,"abcdef",3600000];"#; + let tok = parse_token(html).unwrap(); + assert_eq!(tok.ig, "FF00"); + assert_eq!(tok.iid, "translator.5028"); + assert_eq!(tok.token, "abcdef"); + + let now = Instant::now(); + store_cached_token(tok.clone(), now); + assert_eq!(get_cached_token(now + Duration::from_secs(60)), Some(tok)); + assert_eq!(get_cached_token(now + TOKEN_TTL + Duration::from_secs(1)), None); + } +} diff --git a/crates/ttree-translate/src/google.rs b/crates/ttree-translate/src/google.rs new file mode 100644 index 0000000..30a01f6 --- /dev/null +++ b/crates/ttree-translate/src/google.rs @@ -0,0 +1,66 @@ +//! 谷歌翻译 —— 改用 POST 表单请求避免长文本 URL 膨胀,并配合 1.8s 快速连接超时。 + +use std::time::Duration; +use serde_json::Value; + +use crate::{read_error_body, read_limited_response, shared_client, MAX_TRANSLATION_RESPONSE_BYTES}; + +const ENDPOINT: &str = "https://translate.googleapis.com/translate_a/single"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); + +pub async fn translate(text: &str, source: &str, target: &str) -> Result { + let url = format!( + "{ENDPOINT}?client=gtx&sl={sl}&tl={tl}&dt=t", + sl = urlencoding::encode(source), + tl = urlencoding::encode(target), + ); + + let resp = shared_client(REQUEST_TIMEOUT, false) + .post(&url) + .header("User-Agent", "Mozilla/5.0") + .form(&[("q", text)]) + .send() + .await + .map_err(|e| format!("谷歌翻译请求失败: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = read_error_body(resp).await; + return Err(format!("谷歌翻译返回状态 {status}: {body}")); + } + + let body = + read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "谷歌翻译响应").await?; + let body: Value = + serde_json::from_slice(&body).map_err(|e| format!("谷歌翻译响应解析失败: {e}"))?; + + let out = parse_response(&body); + if out.trim().is_empty() { + return Err("谷歌翻译未返回有效译文".to_string()); + } + Ok(out) +} + +pub fn parse_response(body: &Value) -> String { + let mut out = String::new(); + if let Some(segments) = body.get(0).and_then(|v| v.as_array()) { + for seg in segments { + if let Some(t) = seg.get(0).and_then(|v| v.as_str()) { + out.push_str(t); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_multi_segment_google_response() { + let body = json!([[["你好", "hello"], ["世界", "world"]], null, "en"]); + assert_eq!(parse_response(&body), "你好世界"); + } +} diff --git a/crates/ttree-translate/src/lib.rs b/crates/ttree-translate/src/lib.rs new file mode 100644 index 0000000..f96ca55 --- /dev/null +++ b/crates/ttree-translate/src/lib.rs @@ -0,0 +1,119 @@ +//! `ttree-translate` —— 极速网页翻译模块(Google POST + Bing 内存 Token 缓存 + Hedged 主备竞速路由)。 + +pub mod auto; +pub mod bing; +pub mod google; + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use futures_util::StreamExt; +use reqwest::{Client, Response}; +use ttree_core::{validate_language_pair, CancelToken}; + +pub const MAX_TRANSLATION_TEXT_BYTES: usize = 64 * 1024; +pub const MAX_TRANSLATION_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_BING_PAGE_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_ERROR_BODY_BYTES: usize = 16 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_millis(1800); + +/// 共享连接池客户端:内置 1.8s 快速连接超时与 TCP Keepalive,彻底消除不可达节点卡死 15s 的问题。 +pub(crate) fn shared_client(timeout: Duration, cookie_store: bool) -> Client { + static CLIENTS: OnceLock>> = OnceLock::new(); + + let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new())); + let mut clients = clients.lock().expect("HTTP client cache lock poisoned"); + clients + .entry((timeout, cookie_store)) + .or_insert_with(|| { + Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(timeout) + .tcp_keepalive(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(90)) + .cookie_store(cookie_store) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build reqwest client") + }) + .clone() +} + +pub fn validate_translation_text(text: &str) -> Result<(), String> { + if text.len() > MAX_TRANSLATION_TEXT_BYTES { + return Err(format!( + "待翻译文本超出 {MAX_TRANSLATION_TEXT_BYTES} 字节上限" + )); + } + Ok(()) +} + +/// 顶层网页翻译入口(支持 `CancelToken` 即时中断与主备竞速)。 +pub async fn translate_web( + text: &str, + source: &str, + target: &str, + cancel: &CancelToken, +) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Ok(String::new()); + } + validate_translation_text(trimmed)?; + validate_language_pair(source, target)?; + + tokio::select! { + biased; + _ = cancel.cancelled() => Err("翻译已取消".to_string()), + res = auto::translate(trimmed, source, target) => res, + } +} + +pub(crate) async fn read_limited_response( + response: Response, + limit: usize, + label: &str, +) -> Result, String> { + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(format!("{label}超出 {limit} 字节上限")); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("读取{label}失败: {e}"))?; + if bytes.len().saturating_add(chunk.len()) > limit { + return Err(format!("{label}超出 {limit} 字节上限")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +pub(crate) async fn read_error_body(response: Response) -> String { + let mut bytes = Vec::new(); + let mut truncated = false; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => { + let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(bytes.len()); + if chunk.len() > remaining { + bytes.extend_from_slice(&chunk[..remaining]); + truncated = true; + break; + } + bytes.extend_from_slice(&chunk); + } + Err(e) => return format!("<读取错误响应失败: {e}>"), + } + } + let mut out = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + out.push_str("…[truncated]"); + } + out +} diff --git a/scripts/package-pure-rust-macos.sh b/scripts/package-pure-rust-macos.sh new file mode 100755 index 0000000..3814038 --- /dev/null +++ b/scripts/package-pure-rust-macos.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -euo pipefail + +export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.cargo/bin:$PATH" + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +APP_NAME="TTREE" +BUNDLE_IDENTIFIER="com.samwstu.ttree" +PINNED_SIGNING_IDENTITY="TTREE Open Source Code Signing" +PINNED_CERT_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08" +VERSION="0.2.0" +BUILD_NUMBER="${1:-1}" + +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/target}" +BUNDLE_DIR="$TARGET_DIR/release/bundle/macos" +APP_PATH="$BUNDLE_DIR/$APP_NAME.app" +CONTENTS_DIR="$APP_PATH/Contents" +MACOS_BIN_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" + +echo "[1/4] Compiling 100% pure-Rust TTREE release binary..." +cd "$ROOT_DIR" +cargo build --release -p ttree-app + +echo "[2/4] Assembling macOS App Bundle at $APP_PATH..." +rm -rf "$APP_PATH" +mkdir -p "$MACOS_BIN_DIR" "$RESOURCES_DIR" + +cp "$TARGET_DIR/release/ttree" "$MACOS_BIN_DIR/ttree" +chmod +x "$MACOS_BIN_DIR/ttree" + +if [[ -f "$ROOT_DIR/src-tauri/icons/icon.icns" ]]; then + cp "$ROOT_DIR/src-tauri/icons/icon.icns" "$RESOURCES_DIR/icon.icns" +fi + +cat > "$CONTENTS_DIR/Info.plist" < + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + ${APP_NAME} + CFBundleExecutable + ttree + CFBundleIconFile + icon.icns + CFBundleIdentifier + ${BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${APP_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${BUILD_NUMBER} + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSScreenCaptureUsageDescription + TTREE 需要屏幕录制权限以完成自由框选截图 OCR 翻译。 + + +EOF + +echo "[3/4] Signing $APP_NAME.app with pinned community certificate ($PINNED_CERT_SHA1)..." +TEMP_KEYCHAIN="" +cleanup() { + if [[ -n "$TEMP_KEYCHAIN" && -f "$TEMP_KEYCHAIN" ]]; then + security delete-keychain "$TEMP_KEYCHAIN" >/dev/null 2>&1 || rm -f "$TEMP_KEYCHAIN" + fi +} +trap cleanup EXIT + +SIGNING_KEYCHAIN="${MACOS_COMMUNITY_SIGNING_KEYCHAIN:-}" +if [[ -z "$SIGNING_KEYCHAIN" ]]; then + P12_PATH="$HOME/.config/ttree-code-signing/community-signing.p12" + if [[ -f "$P12_PATH" ]]; then + P12_PASS="$(security find-generic-password -s "com.samwstu.ttree" -a "community-code-signing-password" -w)" + TEMP_KEYCHAIN="$(mktemp -u /tmp/ttree-signing.XXXXXX).keychain-db" + KC_PASS="$(uuidgen)" + security create-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN" + security set-keychain-settings -lut 300 "$TEMP_KEYCHAIN" + security unlock-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN" + security import "$P12_PATH" -k "$TEMP_KEYCHAIN" -P "$P12_PASS" -T /usr/bin/codesign -T /usr/bin/security >/dev/null + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$TEMP_KEYCHAIN" >/dev/null + SIGNING_KEYCHAIN="$TEMP_KEYCHAIN" + fi +fi + +REQUIREMENT="=designated => identifier \"$BUNDLE_IDENTIFIER\" and certificate leaf = H\"$PINNED_CERT_SHA1\"" +CODESIGN_ARGS=( + --force + --options runtime + --timestamp=none + --sign "$PINNED_CERT_SHA1" + --identifier "$BUNDLE_IDENTIFIER" + --requirements "$REQUIREMENT" +) +if [[ -n "$SIGNING_KEYCHAIN" ]]; then + CODESIGN_ARGS+=(--keychain "$SIGNING_KEYCHAIN") +fi + +/usr/bin/codesign "${CODESIGN_ARGS[@]}" "$APP_PATH" + +echo "[4/4] Verifying macOS community signature & TCC designated requirement..." +"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$APP_PATH" + +printf '\nPure-Rust TTREE.app packaged and verified at: %s\n' "$APP_PATH"