commit d7ee3f5db98aa8fba786e806b6613775c0c7aaff Author: 王性驊 Date: Wed Nov 4 16:51:51 2026 +0800 AnyBot: first commit of the working desktop app Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama, custom), tools and approvals, the bot's Docker computer, computer use on macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based toolset, the encrypted vault, pinned grid and sidebar sections, Markdown with Mermaid diagrams, phone access through the relay, and the Linux build and test scripts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57b3379 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target +relay/target +shots +*.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fc2395 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build output +/target/ +/relay/target/ + +# Test and screenshot output (regenerated by the test scripts) +/linux-shots/ +/shots/ + +# Local secrets (deploy/server/.env.example is the template) +.env +*.env.local + +# macOS +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ec59148 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6497 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf 0.14.0", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" +dependencies = [ + "accesskit", + "hashbrown 0.17.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[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", + "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 = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anybot" +version = "0.1.0" +dependencies = [ + "ashpd", + "async-io", + "base64 0.22.1", + "calamine", + "chrono", + "chrono-tz", + "cpal", + "dirs", + "eframe", + "futures-lite", + "hayro", + "iana-time-zone", + "image", + "memmap2", + "mermaid-rs-renderer", + "pdf-extract", + "pulldown-cmark", + "resvg", + "ring", + "rodio", + "serde", + "serde_json", + "tungstenite", + "unicode-normalization", + "ureq", + "wayland-client", + "wayland-protocols", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "x11rb", + "zbus", + "zhconv", + "zip", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "serde_repr", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.6", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.6", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.6", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[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" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[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 = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calamine" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f431b67cba6122a1f7d26b7adafc71bbdf07026095fe811f5efc025d8748e1d8" +dependencies = [ + "atoi_simd", + "byteorder", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml 0.42.0", + "serde", + "zip", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.6", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.6", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cff-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "codepage" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdff162541cd8b79de82e2edcc7eff3a8c2a6dc3d75152636028f96d93de3b26" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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", + "indexmap", + "log", + "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-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[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", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.5", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "web-sys", + "windows", + "windows-core", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[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", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[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 0.6.5", +] + +[[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 = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "egui_system_font_provider", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "send_wrapper", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools 0.15.0", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" +dependencies = [ + "accesskit_winit", + "arboard", + "block2 0.6.2", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2a2fdfe3c05dd9f691d2b1388b2312d6dce1af502f217c22136d22d9bd76f6" +dependencies = [ + "epaint", + "fontique", + "log", + "poll-promise", + "skrifa 0.44.0", + "sys-locale", + "unicode-script", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa 0.44.0", + "smallvec", + "unicode-general-category", + "unicode-properties", + "unicode-segmentation", + "vello_cpu 0.2.0", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" + +[[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 = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "fearless_simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4beca3cb2444e3304ac30843cc091f44ed58932353cd492ce740067bfce6b12" + +[[package]] +name = "fearless_simd" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629c9d6b6f30b5604089e0980f4d924a013aacb1654a6cdf5d3d852224f68651" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.41.0", + "roxmltree 0.21.2", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[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", +] + +[[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.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-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-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[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-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-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "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", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.2.0", +] + +[[package]] +name = "glifo" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1218dedd5348c1b166ec3b8b3cbca49d2f527bd43b3fa072ea5123c523ed3f" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.3.0", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid 0.22.14", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hayro" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34d3977ae664ab6eea015be3024379c83c6a3580f67d51be6884f1992bf683ba" +dependencies = [ + "ab_glyph_rasterizer", + "bytemuck", + "fearless_simd 1.2.0", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "rustc-hash 2.1.3", + "vello_cpu 0.3.0", +] + +[[package]] +name = "hayro-ccitt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbed896e99325bc312778bb9c9c289d795d51c12775c44dd5e4352f679c7e46" + +[[package]] +name = "hayro-cmap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14afde3344be69225fec4f6f44f6a219a3a492f9d8301eb25ef608fe4b7014c" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-font" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac7f0c78172573e9bd6eeb17f62c63f4d378b2a2d127c3ee9c2c7cc6fbdf78b" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "phf 0.13.1", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.49.0", +] + +[[package]] +name = "hayro-interpret" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166477622df4b770a0fc5889a9e4b48c7ff8595d6ec019bf71b4297b97b55fb6" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-font", + "hayro-syntax", + "kurbo", + "moxcms", + "rustc-hash 2.1.3", + "siphasher", + "smallvec", +] + +[[package]] +name = "hayro-jbig2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb5c7f3a4eb551c2bb7fa16e268f728d4bc2a5c9b090554b8dc0ee2503fb5840" +dependencies = [ + "fearless_simd 1.2.0", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5e5f2b3de970707fc22fd956d8f44211602939d5e4588603afa919cb7b3740" +dependencies = [ + "fearless_simd 1.2.0", +] + +[[package]] +name = "hayro-postscript" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f" + +[[package]] +name = "hayro-syntax" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ced8279487b029813f6cc05399099fa34df6fa23d8f7b0d41d972c49d5b30d" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg", +] + +[[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 = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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 = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[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 = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348cde6df2b507ba3c3753beba226baba8567af588d6568cdb9877f02c75b60" +dependencies = [ + "serde", + "ucd-trie", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid 0.22.14", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[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 = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lopdf" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" +dependencies = [ + "aes", + "bitflags 2.13.2", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap", + "itoa", + "log", + "md-5", + "nom", + "rand", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.21", + "ttf-parser", + "weezl", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mermaid-rs-renderer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb5b469222ce6b9896edf0a969cf68464f5a028ef394fd6643f00bd943df117" +dependencies = [ + "anyhow", + "fontdb 0.23.0", + "json5", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "ttf-parser", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[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-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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-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_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[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 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.5", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.5", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[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 0.6.5", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[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 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.5", +] + +[[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", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[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-core-foundation", +] + +[[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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[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 0.6.2", + "libc", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[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 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[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 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[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", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + +[[package]] +name = "pdf-extract" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5c4820f5811e424ce037d08493ac87752ecc54339e0f1e40c3d17da93278861" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid 0.20.14", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "poll-promise" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6a58fecbf9da8965bcdb20ce4fd29788d1acee68ddbb64f0ba1b81bccdb7df" +dependencies = [ + "document-features", + "static_assertions", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + +[[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 = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pulldown-cmark" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[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.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdaa4fffe9ff3ca2c353fa9729792805b9b3e324695ff5a081f860612e81b330" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "font-types", + "once_cell", + "smallvec", + "yoke", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[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_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[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 = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[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 = "rodio" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e3ee0a5ca418bac59a15d5506e498ab138359edbb67257a6b8a3b93d411f39" +dependencies = [ + "cpal", + "dasp_sample", + "num-rational", + "symphonia", + "thiserror 2.0.21", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "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 = [ + "log", + "once_cell", + "ring", + "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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +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 = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[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_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7459fb60888ad9a5f367dd293fa2fb9df142cb451aa6bfd6cd66a7d6a9b739d" +dependencies = [ + "bytemuck", + "once_cell", + "read-fonts 0.46.0", + "smallvec", + "yoke", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.6", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[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 = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[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 = "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 = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "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 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[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 = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "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 = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "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 = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + +[[package]] +name = "unicode-script" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa04c955db05429d464c74d495c1ee677e1866e783a4c3747f9c0cef7756cabf" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7be76686bdf7198002062423ca0d8cf9b75a27c8e1de804513b97e97b555d9d" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[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 = "usvg" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c" +dependencies = [ + "base64 0.23.1", + "data-url", + "flate2", + "fontdb 0.24.0", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.2", + "simplecss", + "siphasher", + "skrifa 0.44.0", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd 0.4.1", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c2fbe53548bcd25c8ff42f75a93770a8611a16b37bee56d7ef7b44e36027f1" +dependencies = [ + "bytemuck", + "fearless_simd 0.7.0", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo 0.3.0", + "hashbrown 0.17.1", + "png", + "vello_common 0.2.0", +] + +[[package]] +name = "vello_cpu" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1fec424fbba6acf6cbc78bb526447eb77590d95f40419d598870fe37535212" +dependencies = [ + "bytemuck", + "glifo 0.4.0", + "hashbrown 0.17.1", + "png", + "vello_common 0.3.0", +] + +[[package]] +name = "vergen" +version = "8.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566" +dependencies = [ + "anyhow", + "cfg-if", + "rustversion", + "time", +] + +[[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 = "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.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe8d20d0214027baf11b8b0cc307c1da7657d8f4db9aa956f7de27a6fb1acbd5" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[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 = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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 = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.6", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[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", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.6", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + +[[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", +] + +[[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", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zhconv" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb4f247ec21be13451293e760f6246e57300ce80208231ab3e0a437a5d96b27" +dependencies = [ + "console_error_panic_hook", + "daachorse", + "hex-literal", + "itertools 0.14.0", + "once_cell", + "regex", + "ruzstd", + "sha2", + "strum", + "vergen", + "wasm-bindgen", + "zstd", +] + +[[package]] +name = "zip" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "libc", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb02c3132d251ab1606e9fc913a7da6c6ed08cc263df15edf082e1dc497d157" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8ea04668f5de8d8d51051714ba560c9536519cadcdb8829bba4fe2ad159b03" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dac6a81 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "anybot" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "anybot" +path = "src/main.rs" + +[dependencies] +eframe = "0.37" +ureq = { version = "3", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "7" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } +base64 = "0.22" +chrono = "0.4" +chrono-tz = "0.10" +iana-time-zone = "0.1" +ring = "0.17" +tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } +calamine = "0.38.0" +resvg = "0.49.0" +hayro = "0.9" +zip = { version = "9", default-features = false, features = ["deflate"] } +rodio = { version = "0.23", default-features = false, features = ["playback", "flac", "mp3", "mp4", "vorbis", "wav", "symphonia-aiff", "symphonia-alac", "symphonia-caf"] } +pdf-extract = "0.12" +unicode-normalization = "0.1" +cpal = "0.18" +mermaid-rs-renderer = { version = "0.3", default-features = false } +pulldown-cmark = { version = "0.13", default-features = false } +[profile.release] +opt-level = 2 + +[target.'cfg(not(target_os = "macos"))'.dependencies] +zhconv = "0.4" + +[target.'cfg(target_os = "linux")'.dependencies] +x11rb = { version = "0.13", features = ["xtest", "screensaver", "record"] } +# Wayland: wlroots desktops (sway, Hyprland…) through their own protocols; GNOME and KDE through +# the desktop portals (one "allow" dialog, remembered). +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +wayland-protocols-misc = { version = "0.3", features = ["client"] } +memmap2 = "0.9" +ashpd = { version = "0.13", default-features = false, features = ["async-io", "remote_desktop", "screencast", "screenshot"] } +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } +futures-lite = "2" +async-io = "2" diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..22bd751 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,2096 @@ +# AnyBot 功能說明書(完整版) + +AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。最大的不同是:**每個 Bot 都可以換 AI 供應商**,可以是 xAI Grok、OpenAI、Claude、Google Gemini、OpenRouter、Ollama(本機),或任何 OpenAI 相容的服務。 + +這份文件列出 App 目前所有的功能和細節: + +- 「」裡的字是畫面上或 Bot 收到的原文。 +- 數字是程式實際使用的值。 +- 標「估計值」的數字是我自己定的,因為原版裡找不到那個數字。 + +--- + +## 目錄 + +1. 視窗與版面 +2. 左側 Bot 清單(側邊欄) +3. Bot 選單(右鍵/「⋯」) +4. 聊天頂部 +5. 聊天內容 +6. 輸入框 +7. 允許請求(卡片與彈出視窗) +8. 直接操作模式 +9. Bot 設定/建立 Bot +10. 設定視窗(一般/AI 供應商/電腦/自動審核) +11. AI 供應商 +12. 回覆的串流、思考與錯誤 +13. 一個回合怎麼運作(步驟、插話、喚醒、結束) +14. 長對話整理成摘要 +15. 自動審核 +16. 許可關卡的完整順序 +17. 三種電腦 +18. 檔案與指令工具 +19. 安全規則:哪些直接做、先問、拒絕 +20. 背景指令 +21. 網頁工具 +22. AnyBot 自己的瀏覽器與設定檔 +23. 讀取你自己的瀏覽器 +24. 操作螢幕(滑鼠、鍵盤、截圖、接管、示範教學) +25. 例行任務 +26. Bot 可以直接操作 App 的功能 +27. 通知 +28. 版本、更新與重新啟動 +29. 管理員鎖定 +30. 存下來的資料 +31. 第一次啟動的 Bot +32. 外觀:頭像、顏色、主題、字型 +33. Bot 收到的系統指示 +34. 鍵盤快速鍵 +35. 示範/截圖模式 +36. 手機、帳號、雲端主機 +37. 字型、語言、設定分類、右側面板 +38. 還沒做的功能 + +--- + +## 1. 視窗與版面 + +- **視窗** + - 標題:「AnyBot」 + - 開啟時大小:1180 × 780 + - 最小寬度:664(側邊欄 240 加聊天區 424) + - 最小高度:480 +- **版面**:左邊是 Bot 清單(側邊欄),右邊是目前選中 Bot 的聊天。 +- **側邊欄寬度** + - 預設 280 px,拖動時可在 240 到 400 px 之間調整。 + - 拖到不足 210 px,會收成 88 px 寬的「頭像直欄」。 + - 收起後要拖到超過 220 px 才會再展開。中間留這段緩衝,是為了避免在邊界上一直切換。 + - 怎麼拖:滑鼠移到側邊欄右緣左右 3 px 內,游標會變成左右箭頭;滑過或拖動時,右緣會出現 2 px 的分隔線。 +- **聊天內容** + - 最寬 720 px,置中,兩側合計至少留 48 px。 + - 聊天區最小寬度 424 px。 +- **「設定」和「Bot 設定」視窗** + - 視窗上下合計保留 80 px,不會超出畫面(估計值)。 + - 內容太長時可以在視窗裡捲動。 +- **自動儲存** + - 有變更就存檔。 + - Bot 回覆進行中時,最多每 2 秒存一次。 + - 關閉 App 時,會先停掉所有進行中的回覆和背景指令,再存檔。 +- **畫面更新頻率** + - 閒置時每 15 秒更新一次,讓例行任務的時鐘繼續走。 + - 回覆進行中或正在抓模型清單時,每 100 毫秒更新一次。 + +--- + +## 2. 左側 Bot 清單(側邊欄) + +### 展開時 + +- **頂列** + - 「◧」:提示「收合側邊欄」。 + - 粗體「AnyBot」,15 px。 + - 「+」:提示「新 Bot」。 +- **搜尋** + - 灰底圓角框,左邊有「🔍」,提示文字「搜尋 Bot」。 + - 比對 Bot 的名稱和描述,不分大小寫。 + - 沒有符合的 Bot 時顯示「沒有相符的 Bot」。 +- **分組**(小標題 12 px 灰字,空的組不顯示) + - 「主要 Bot」:只有一個。 + - 「已釘選的 Bot」 + - 「Bot」:其餘全部。 +- **每一列 Bot**(高 46 px,圓角 10 px) + - 選中時有底色,滑過時有淡底色。 + - 左邊是 28 px 頭像。 + - 第一行:名稱,14 px。 + - 第二行(12 px)依狀態顯示: + - Bot 工作中且在等你允許:「需要你允許」,強調色。 + - Bot 工作中:「正在輸入…」 + - 閒置時:最後一則訊息的第一行。沒有訊息時顯示「供應商簡稱·模型」,例如「Grok·grok-4」。 + - 名稱或第二行太長時,在右側狀態點前截斷,結尾加「…」。 + - 右側狀態點: + - 等你允許:強調色實心點(半徑 5.5 px)外加一圈淡色光環(半徑 8 px)。 + - 有未讀訊息:強調色小點(半徑 4 px)。 + - 工作中:灰色小點(半徑 4 px)。 + - 點一下會切到這個 Bot,清除未讀,並把聊天捲到最底。 + - 按右鍵會打開 Bot 選單(第 3 節)。 +- **底部** + - 「⚙ 設定」按鈕,高 30 px。 + - 最下面是「版本 YYYY-MM-DD HH:MM」,10.5 px 灰字。版本就是這份程式建好的時間;不知道時顯示「?」。 + - 有新版本時多一顆強調色按鈕「⬆ 重新啟動以更新(新版本時間)」,按下立刻換成新版。 + +### 收合成直欄時 + +- 寬 88 px。 +- 最上面依序是「◨」(提示「展開側邊欄」)和「+」(提示「新 Bot」)。 +- 頭像依「主要 Bot、已釘選、其他」的順序排列: + - 每格 56×48 px,頭像 32 px。 + - 選中或滑過時有底色,圓角 10 px。 + - 右上角小點:有未讀是強調色,工作中是灰色。 + - 滑過會顯示名稱;按右鍵會打開同一份 Bot 選單。 +- 最下面是「⚙」(提示「設定」)。 +- 收合或展開的狀態會被記住。 + +--- + +## 3. Bot 選單(右鍵選單和聊天頂部的「⋯」共用) + +- 「編輯 Bot…」 +- 「重新啟動這個 Bot」 + - 提示文字:「卡住時用:停止它目前的工作和背景指令,聊天記錄和檔案都會保留」。 + - 會停掉這個 Bot 目前的工作、待允許的請求和背景指令,並把做到一半的回覆標成「中斷」,之後可以按「重新產生」重試。 + - 完成後顯示「已重新啟動「名稱」。」。有停掉背景指令時改為「已重新啟動「名稱」,也停止了 N 個背景指令。」 +- 「釘選到側邊欄」或「取消釘選」 +- 「設為主要 Bot」:只有不是主要 Bot 的才會出現。 +- 「清除聊天記錄」:先停止工作再清空,不會跳確認。 +- 分隔線 +- 「刪除 Bot」:紅字,會先跳確認視窗。 + - 標題「刪除 Bot?」,內文「這會刪除「名稱」和它的聊天記錄。此操作無法復原。」 + - 按鈕「取消」和紅底「刪除」。 + - 刪除時會先停掉它的工作。 + - 刪到一個都不剩時,會自動建一個全新的初始 Bot。 + - 刪掉的是主要 Bot 或目前選中的 Bot 時,改成清單第一個 Bot。 + +--- + +## 4. 聊天頂部 + +- **左邊** + - 30 px 頭像,提示「編輯 Bot 頭像」,點了打開 Bot 設定。 + - 名稱,粗體 15 px。 + - 描述,12 px 灰字。沒有描述時顯示「新增描述…」。 +- **右邊**(從右到左) + - 「⋯」:打開 Bot 選單。 + - 「Bot 設定」 + - 「例行任務」:有例行任務時加上數量,例如「例行任務 2」。 + - 依 Bot 狀態多一個標示: + - 在等你允許:強調色膠囊按鈕「● 需要你允許」,提示「打開請求視窗」,按下會重新叫出彈出視窗。 + - 只是在工作:灰字「工作中…」。 +- 標題列底部有 1 px 分隔線。 +- 沒有選任何 Bot 時,畫面中間顯示「未選取 Bot」。 + +--- + +## 5. 聊天內容 + +### 還沒有任何訊息時 + +- 置中顯示: + - 72 px 大頭像。 + - 名稱,22 px 粗體。 + - 描述。 + - 「使用 供應商·模型」,12 px 灰字。 +- 有打開電腦工具時,多一行:「可以在「工作資料夾」裡查看、修改檔案和執行指令,動手前會先問你」。 +- 這家供應商需要金鑰卻還沒設定時,顯示按鈕「設定 (供應商) 的 API 金鑰」,按下直接打開「設定 → AI 供應商」,並選好這家。 + +### 你的訊息 + +- 靠右的氣泡,圓角 18 px,左右內距 14 px,最寬佔可用寬度的 75%。只有一行時約高 36 px。 +- 文字 15 px,行高 22 px。 +- 氣泡顏色: + - 「強調色」選「Bot 顏色」時,用那個 Bot 的顏色配白字。 + - 選「黑色」,或 Bot 本身就是黑色時,用黑色氣泡。 +- 最後一則是你的訊息、而且 Bot 沒有在回覆時(例如 App 在回覆前被關掉),下方會出現小字「還沒有回覆」和小按鈕「重新傳送」。 + +### Bot 的回覆 + +- 左邊 24 px 小頭像,內容在淺灰氣泡裡,圓角 18 px。 +- 支援簡單排版: + - 標題(# / ## / ###):17 px 粗體,行高 24 px。 + - 項目符號(- 或 *):依縮排往內縮。 + - 整行粗體(**…**)。 + - 程式碼區塊(```):灰底,圓角 8 px,等寬字 13 px,行高 18 px。 + - 其他行內的 ** 和 ` 符號會被拿掉。 +- 出錯時用紅字,加很淡的紅底。 + +### 思考過程 + +- 還沒出現第一個字時,顯示斜體灰字「思考中…」。 +- 有思考內容時,變成可以展開的「思考過程」,預設收起,13 px。 +- 正在重試時,改顯示重試的狀態文字(第 12 節)。 + +### 工具步驟(每一步一行) + +- 開頭的狀態圖示: + - 「●」等你允許,強調色。 + - 「…」執行中。 + - 「✓」完成。 + - 「–」被拒絕或已停止。 + - 「×」失敗,紅色。 +- 接著是工具名稱(13 px),然後是等寬灰字的動作說明(12 px,最多 90 字)。 +- 有指定電腦時,說明後面加「 ·電腦名稱」。 + +### 工具結果 + +- 緊接在那一步下面,縮排 32 px。 +- 標題是「結果:第一行(最多 60 字)」,預設收起。 +- 展開後是灰底等寬字,最多顯示 6,000 字。 + +### 摘要 + +- 舊對話被整理過時,會出現有框、可以展開的「📝 較早的對話已整理成摘要」。 + +### 回覆下方的小按鈕 + +- 只出現在 Bot 的最終回覆下面,不會夾在步驟中間。 +- 「複製」:複製回覆文字。 +- 「重新產生」:只有最後一則有。會刪掉最後一則你的訊息之後的內容,再重新回答。 +- 小字「供應商·模型」,11 px,表示這則是哪個模型寫的。 + +### 特殊文字 + +- 「(已停止)」:按停止時,回覆還完全沒有內容。 +- 「(沒有回應內容)」:回覆結束了,但沒有文字也沒有任何步驟。 +- 「(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)」:App 關閉時還在回覆,下次打開時會修成這句。 + +### 卡住偵測 + +- 「模型未能及時開始回應。請重試。」:每一步開始後 120 秒內沒有任何回應(估計值)。 +- 「回應中斷:模型超過 35 秒沒有送出任何內容。」:已經開始輸出後,又安靜超過 35 秒。 +- 等你允許或工具在執行時,不算卡住。 + +### 自動捲動 + +- 送出訊息、切換 Bot、按「前往聊天」時,會捲到最底並停在底部。 + +--- + +## 6. 輸入框 + +### 外觀 + +- 外框圓角 20 px;正在輸入時框線變深。 +- 提示文字「傳訊息給 (Bot 名稱)」。 +- 從一行開始,跟著內容長高,最高 160 px,再多就在框內捲動。 +- 每個 Bot 各自保留打到一半的草稿。 + +### 左下:模型選擇器 + +- 顯示「供應商簡稱·模型 ▾」。供應商簡稱有:Grok、OpenAI、Claude、Gemini、OpenRouter、Ollama、自訂。 +- 打開後: + - 最上面是說明「選擇這個 Bot 用來思考的模型」。 + - 每家供應商一個子選單。目前用的那家前面有「✓ 」;沒有金鑰的標「(未設定金鑰)」。 + - 子選單列出模型:已經抓過清單就用抓到的,沒有就用內建的起始清單。預設模型排第一。 + - 最後是「其他模型…」:換成這家的預設模型,並打開 Bot 設定讓你自己輸入。 + +### 右下:送出/停止按鈕(32 px 圓形) + +- 有字時:「↑」,提示「傳送(Enter)」。 +- Bot 工作中、輸入框是空的:「■」,提示「停止」。 +- Bot 工作中、輸入框有字:「↑」,提示「補充給正在進行的工作(Enter)」。 +- 沒字又沒在工作:變灰,按不下去。 + +### 按鍵 + +- Enter 送出,Shift+Enter 換行。 +- 用注音、拼音等輸入法選字時按 Enter,不會誤送出。 +- 送出後游標留在輸入框。 + +### Bot 工作中繼續打字(插話) + +- 新訊息不會另開一輪,而是插進正在進行的工作,Bot 下一步就會看到。 +- 這一輪剛好結束、來不及插入時,會自動變成下一輪。 +- 按停止時,還沒被讀到的補充會被丟掉。 + +### 輸入框下方的小字(11 px 置中,擇一顯示) + +- 有系統提示時:強調色提示,加「知道了」可以關掉。 +- 閒置時:「Enter 傳送 · Shift+Enter 換行」。 +- 工作中:「工作中也可以繼續打字,Bot 會在下一步看到 · Shift+Enter 換行」。 +- 有排入的補充時:「已排入「最新一則前 30 字」,Bot 會在下一步看到」。多於一則時:「已排入「…」 等 N 則,Bot 會在下一步看到」。 + +--- + +## 7. 允許請求(聊天裡的卡片和彈出視窗內容相同) + +### 標題(依情況) + +- 「允許 (Bot) 使用電腦工具嗎?」:這個 Bot 的電腦工具還沒打開。 +- 「允許 (Bot) 在你的電腦上執行任務嗎?」:「在這台電腦上執行」還沒打開。 +- 「允許 (Bot) (動作)嗎?」或「允許 (Bot) 在 (電腦) 上(動作)嗎?」:一般動作。 + +### 說明文字 + +- 電腦工具還沒打開:「這個 Bot 的電腦工具還沒打開。它想做的事:」 +- 「在這台電腦上執行」還沒打開:「「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:」 +- 接著是灰底等寬字框,顯示細節,例如要執行的指令。 +- 有指定電腦時加一行「在:(電腦名稱)」。 + +### 按鈕 + +- 主要按鈕:「允許這一次」;一般動作則是「允許」。 +- 一律允許的按鈕: + - 電腦工具還沒打開時:「一律允許這個 Bot 使用電腦」,會打開這個 Bot 的電腦工具。 + - 你的電腦還沒允許時:「一律允許(打開「在這台電腦上執行」)」。 + - 一般動作:「一律允許(類別)」,會記到這個 Bot 的「一律允許」清單。類別有: + - 「修改檔案」 + - 「讀取瀏覽器分頁」 + - 「讀取工作資料夾以外」 + - 指令類直接顯示指令,例如「npm install」 + - 沒有可以記住的類別時,不顯示這顆按鈕。 +- 「拒絕」。 +- 「⚡ 全部允許,之後直接操作(像 Grok Bot 一樣)」 + - 只在「直接操作」還沒開、而且管理員沒鎖住時出現。 + - 提示文字:「打開「直接操作」:Bot 可以直接使用這台電腦和瀏覽器,不再一個個問你。危險指令、密碼檔和你的「一律詢問」規則仍會擋下來。可在設定 → 電腦關掉。」 + - 按下會允許這一筆,並打開直接操作(第 8 節)。 + +### 聊天裡的卡片 + +- 縮排 32 px,2 px 強調色外框,圓角 14 px。 + +### 彈出視窗 + +- 會自己跳出來,背後整個畫面變暗。淺色主題暗 70,深色主題暗 120(透明度 0–255)。 +- 視窗寬 440 px,置中稍微偏上 20 px,有 2 px 強調色外框、圓角 16 px 和陰影。 +- 頂部:28 px 頭像加「(Bot) 正在等你決定」。同時有多筆時再加「(還有 N 個請求)」;目前畫面上的 Bot 優先顯示。 +- 底部: + - 「前往聊天」:只有請求來自別的 Bot 時才有,按下會切過去。 + - 「稍後再決定」:提示「先關掉這個視窗;聊天裡的卡片還在,Bot 會一直等你」。 +- 有新請求時,Dock 圖示會跳動。你不在 App 前面而且通知有開時,還會跳 macOS 通知,標題「(Bot) 需要你允許」。 + +--- + +## 8. 直接操作模式 + +一個開關,讓 Bot 像 Grok Bot 那樣直接做事,不再一個個問。 + +### 打開時會一次做完 + +- 打開「在這台電腦上執行」。 +- 打開自動審核。 +- 打開所有 Bot 的電腦工具。 +- 「一律詢問」規則是空的時候,填入三條預設規則: + - 「刪除檔案或資料夾」 + - 「付款、購買或轉帳」 + - 「用我的帳號發文、留言,或傳訊息給別人」 +- 正在進行的工作從下一步開始就不再問。 +- 其他 Bot 正在等的「可以用電腦嗎」類請求會自動允許。一般動作的請求仍然要你按。 + +### 打開後仍然會擋下或詢問 + +- 內建安全檢查擋下的事一律不做,例如危險指令和密碼檔。 +- 符合你的「一律詢問」規則時會停下來問,理由後面加「(符合你的「一律詢問」規則:…)」。 +- 其他自動允許的動作,結果前面會標「(直接操作模式:已自動允許)」。 + +### 提示文字 + +- 打開時:「已打開「直接操作」:Bot 會直接做事,只有危險操作和你的「一律詢問」規則會停下來問你。」 +- 被管理員鎖住時:「「直接操作」已由管理員關閉,沒辦法打開。」 + +--- + +## 9. Bot 設定/建立 Bot + +### 標題與開法 + +- 新 Bot 的標題是「建立 Bot」,既有 Bot 是「Bot 設定」。寬 460 px,置中。 +- 開法: + - 側邊欄「+」 + - ⌘N + - 選單「編輯 Bot…」 + - 頂部「Bot 設定」按鈕 + - 點頂部頭像 + +### 新 Bot 的預設值 + +- 名稱「新 Bot」。 +- 形狀和顏色依編號輪流分配。 +- 供應商和模型跟目前選中的 Bot 一樣;沒有時用 xAI Grok 的建議模型。 +- 電腦工具關閉,模型可讀長度 128K。 + +### 欄位 + +- 左上角 56 px 頭像預覽。 +- 「Bot 名稱」。 +- 「Bot 描述」:提示「它負責做什麼?」。 +- 「頭像」:12 種形狀排成一列,每個 28 px,沒選的顯示灰色,滑過顯示名稱(第 32 節)。 +- 顏色:10 個 24 px 圓點,選中的有藍色外圈。 +- 「AI 供應商與模型」 + - 供應商下拉選單,換供應商時模型會自動換成那家的預設模型。 + - 模型名稱輸入框,提示「模型名稱」。 + - 旁邊的「選擇」下拉選單,可以從清單挑模型。 + - 沒有金鑰時顯示紅字:「⚠ 還沒有設定 (供應商) 的 API 金鑰(在「設定 → AI 供應商」)」。 +- 「電腦操作」 + - 勾選框「讓這個 Bot 查看、修改檔案和執行指令」。勾選後才會出現下面這些欄位。 + - 「預設電腦」下拉選單:Bot 的電腦、你的電腦、各台 SSH 電腦。 + - 你的電腦還沒在設定裡允許時,顯示「你的電腦(要先在設定裡允許)」。 + - 旁邊小字:「聊天時說「在我的 Mac 上」就會換」。 + - 「在你的電腦上從 [資料夾] 開始」:提示「例如 ~/Documents/專案」。空白就是家目錄。 + - 說明文字:「在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。」 + - 「已設定「一律允許」:」:每條規則是一顆「名稱 ×」按鈕,提示「移除這個允許」,按了就移除。 + - 「模型可讀長度」:32K、128K、200K、400K、1000K tokens。旁邊小字:「用到 70% 時會把舊對話整理成摘要」。最小 4,000。 +- 「指示(Bot 應該記得的事、說話風格…)」:3 行的輸入框,提示「例如:用繁體中文回答,簡短一點。」。 + +### 最下面一列 + +- 勾選框「釘選到側邊欄」。 +- 「取消」和主要按鈕(新 Bot 是「建立 Bot」,既有 Bot 是「儲存」)。 +- 名稱或模型是空的時候,主要按鈕按不下去。 +- 儲存時會去掉前後空白。建好後會自動選中新 Bot。編輯期間 Bot 新收到的訊息不會因為儲存而遺失。 + +--- + +## 10. 設定視窗 + +- 標題「設定」,寬 560 px。用 ⌘, 或側邊欄「⚙ 設定」打開。 +- 四個分頁:「一般」「AI 供應商」「電腦」「自動審核」。 +- 每次打開都會重新讀取管理員的鎖定。 + +### 一般 + +- **「主題」**:「為 AnyBot 選擇淺色或深色外觀」。 + - 選項:「淺色」「深色」「跟隨系統」。選跟隨系統時,會即時跟著系統切換。 +- **「強調色」**:「用 Bot 的顏色為你的訊息上色,或保持黑色」。 + - 選項:「Bot 顏色」「黑色」。 +- **「通知」**:「Bot 完成任務或等你處理時通知你」。 + - 勾選框「你不在 AnyBot 前面時,跳出 macOS 通知」,預設開。 + - 說明:「視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。第一次可能要在「系統設定 → 通知」允許「工序指令編寫程式」。」 +- **「時區」**:「設定 Bot 在排程和提醒中使用的時區」。 + - 一個輸入框,加按鈕「用這台電腦的時區」。 + - 看不懂時顯示紅字:「看不懂這個時區,請用像 Asia/Taipei 這樣的名稱」。 +- **「資料」**:「聊天記錄和金鑰只存在這台電腦上」,下面用小字顯示存放位置。 +- **「版本」**:「這個視窗正在用的版本(程式建置的時間)」。 + - 有新版時:「有更新的版本:(時間)。按下面的「重新啟動 AnyBot」換成新版。」 + - 沒有新版時:「已是最新版本」。 + - 勾選框「在你離開時自動更新用戶端」,預設開。 + - 說明:「螢幕鎖定或螢幕保護程式開著、10 分鐘沒動作、而且沒有 Bot 在工作時,才會自己換成新版本。」 +- **「重新啟動」**:「App 不太對勁時用:先儲存,再關掉並重新打開 AnyBot。進行中的工作會停下,聊天記錄都會保留」。 + - 按鈕「重新啟動 AnyBot」,進行中變成灰色「正在重新啟動…」。 + - 失敗時顯示「沒辦法重新啟動:(錯誤)。請手動關掉再打開 AnyBot。」 + +### AI 供應商 + +- 左邊是供應商清單,寬 200 px(估計值)。「● 」表示已經可以用,「○ 」表示還沒設定。 +- 右邊是選中的那家: + - 名稱。 + - 取得金鑰的說明(第 11 節)。 + - 「API 金鑰」:隱藏顯示。提示「貼上金鑰,或設定環境變數 (變數名)」。Ollama 沒有這一欄。 + - 「服務網址」:提示文字是預設網址。 + - 「新 Bot 預設模型」:提示文字是建議模型。 + - 按鈕「測試連線並取得模型清單」: + - 進行中顯示轉圈和「正在取得模型清單…」。 + - 成功:「連線成功,找到 N 個模型」,並記住這份清單。 + - 失敗:顯示錯誤。 + - 以前抓過清單時顯示「已記住 N 個模型」。 + +### 電腦 + +- **「⚡ 直接操作(像 Grok Bot 一樣)」**:強調色淡底框。 + - 說明:「一個開關:讓 Bot 直接使用這台電腦和你的瀏覽器,不再一個個問你」。 + - 勾選框「打開直接操作」。被管理員鎖住時顯示紅字「🔒 由你的管理員設定,無法變更」。 + - 說明:「仍會擋下:危險指令(sudo、格式化…)、密碼和金鑰檔、AnyBot 自己的資料,以及你在「自動審核」寫的「一律詢問」規則(預設:刪除檔案、付款、用你的帳號發文或傳訊息)。macOS 第一次讓 AnyBot 控制瀏覽器時還是會自己問一次,按「好」就行。」 +- **「Bot 的電腦」**:狀態、開啟畫面、啟動/休眠、在瀏覽器開啟;「快照與更新」「磁碟」「終端機與環境密鑰」「在哪裡執行」和「顯示紀錄」。細節見第 17 節。 +- **「你的電腦」**:「這台 Mac。Bot 要用你的檔案、你的 App 或你的瀏覽器時才會用到。」 +- **「在這台電腦上執行」**:「允許 Bot 在這台電腦上執行任務,而不只是在它自己的電腦上」。 + - 勾選框「允許在「你的電腦」上執行」,可能被管理員鎖住。 + - 說明:「打開後,改檔案和會改變東西的指令仍會先問你(或依自動審核規則)。密碼和金鑰資料夾一律碰不到。」 +- **「macOS 權限」**:「螢幕與系統錄音」「輔助使用」各一列,已允許是綠色勾勾,還沒允許有「打開設定」按鈕(直接打開對應的系統設定面板)。下面列出「Bot 可以操作的 App」:「{Bot} 可以看到「{App}」,並在其中點擊和輸入」,每列可以「移除」。 +- **「示範教學」**:見第 24 節。 +- **「其他電腦」**:「透過 SSH 讓 Bot 在你的其他電腦上做事(需要先設好免密碼登入)」。 + - 每台電腦一列:名稱、SSH 位址、「移除」。 + - 新增時填「名稱,例如 工作站」和「me@studio.local」,按「新增」。兩欄都要填,名稱不能跟現有的重複。 +- **「AnyBot 瀏覽器設定檔」**:見第 22 節。 + +### 自動審核 + +- 「自動審核」:「依照自訂規則審核 Bot 的操作」。勾選框「開啟自動審核」。 +- 說明:「為每種操作寫一條簡短的自然語言規則。規則衝突時,「先詢問」優先。這些規則只對你生效。內建的安全檢查一律有效。」 +- 關閉時加一行:「自動審核關閉時不會使用規則」。 +- 「「無需詢問直接允許」規則」:「操作符合這些規則時,自動繼續」。 + - 每條規則顯示為「• 規則」,旁邊有「刪除」。 + - 輸入框提示「例如:在工作資料夾裡建立文字檔」,按鈕「新增規則」。 +- 「「一律詢問」規則」:「操作符合這些規則時,停下來詢問」。輸入框提示「例如:刪除任何檔案」。 + +--- + +## 11. AI 供應商 + +| 顯示名稱 | 預設網址 | 建議模型 | 起始模型清單 | 金鑰 | 金鑰環境變數 | 取得金鑰說明 | +|---|---|---|---|---|---|---| +| 「xAI Grok」 | https://api.x.ai/v1 | grok-4 | grok-4、grok-4-fast、grok-3-mini | 要 | XAI_API_KEY | 「在 console.x.ai 取得 API 金鑰」 | +| 「OpenAI」 | https://api.openai.com/v1 | gpt-5 | gpt-5、gpt-5-mini、gpt-4.1 | 要 | OPENAI_API_KEY | 「在 platform.openai.com 取得 API 金鑰」 | +| 「Claude」 | https://api.anthropic.com/v1 | claude-opus-5-5 | claude-opus-5-5、claude-sonnet-5-5、claude-haiku-5-5、claude-fable-5-1 | 要 | ANTHROPIC_API_KEY | 「在 console.anthropic.com 取得 API 金鑰」 | +| 「Google Gemini」 | https://generativelanguage.googleapis.com/v1beta/openai | gemini-2.5-flash | gemini-2.5-flash、gemini-2.5-pro | 要 | GEMINI_API_KEY | 「在 aistudio.google.com 取得 API 金鑰」 | +| 「OpenRouter」 | https://openrouter.ai/api/v1 | openrouter/auto | openrouter/auto | 要 | OPENROUTER_API_KEY | 「在 openrouter.ai 取得 API 金鑰」 | +| 「Ollama(本機)」 | http://localhost:11434/v1 | llama3.2 | llama3.2、qwen2.5、gemma3 | 不用 | – | 「先安裝並開啟 Ollama,不需要金鑰」 | +| 「自訂(OpenAI 相容)」 | http://localhost:8080/v1 | local-model | (無) | 選填 | – | 「任何 OpenAI 相容的服務(LM Studio、vLLM…)」 | + +- **金鑰**:先用設定裡貼上的金鑰(會去掉前後空白);沒貼的話讀環境變數。 + - 需要金鑰卻沒有時,送訊息會看到:「還沒有設定 {供應商} 的 API 金鑰。請到「設定 → AI 供應商」貼上金鑰。」 +- **網址**:沒填就用預設網址,結尾的「/」會自動拿掉。 +- **每個 Bot 可以選不同的供應商和模型**,每則回覆下面都會標出是誰寫的。 +- **連線格式**:Claude 用自己的格式;其他六家都用 OpenAI 相容格式。OpenRouter 會帶上「AnyBot」當作 App 名稱。 +- **Claude 的設定** + - API 版本 2026-10-21。 + - 每次回覆最多 64,000 tokens。 + - 「自適應思考」(顯示思考摘要)只開給較新的模型:claude-opus-5、claude-sonnet-5、claude-haiku-5、claude-fable-5、claude-mythos-5、claude-opus-4-6/4-7/4-8、claude-sonnet-4-6 開頭的模型。 + - 「伺服器端備援」只給 claude-opus-5-5、claude-opus-5、claude-fable-5-1、claude-sonnet-5-5:請求被拒時,伺服器可以換別的模型重試。 +- **抓模型清單**:會排序、去掉重複,並去掉名稱前面的「models/」,然後存起來。 + - 「請先貼上 API 金鑰」 + - 「供應商沒有回傳任何模型」 + +--- + +## 12. 回覆的串流、思考與錯誤 + +### 串流與思考 + +- 所有供應商都是一段一段傳回,畫面邊收邊顯示。 +- 思考內容會即時顯示: + - OpenAI 相容的供應商:讀取 reasoning_content 或 reasoning 欄位。 + - Claude:讀取思考區塊。思考的簽章原封不動保存,下次照原樣送回去。 +- 模型要用工具時,畫面上立刻出現那一步,等這一步的文字結束才真的執行。沒有編號的工具會自動編成 call_0、call_1。 + +### Claude 的特殊結尾 + +- 模型拒答時加上:「(模型拒絕回答這個要求。)」 +- 回答超過長度上限時加上:「(回答太長,已達長度上限。)」 + +### 重試與逾時 + +| 項目 | 數值 | +|---|---| +| 連線逾時 | 15 秒 | +| 等第一段回應 | 120 秒(估計值) | +| 中途安靜多久算卡住 | 35 秒 | +| 最多嘗試 | 3 次(第 1 次加 2 次重試) | +| 重試前等多久 | 1 秒、2 秒(每次加倍,最多 4 秒) | +| 伺服器要求等待時 | 照伺服器說的等,最多 30 秒(估計值) | +| 等待中多久檢查一次「停止」 | 每 0.1 秒 | + +- **會重試**:錯誤 429、408、529、所有 500 以上的錯誤、連不上、串流中途斷線(「連線中斷:…」)。 +- **不重試**:401、403、404 等其他 400 類錯誤、對話太長、模型不支援工具、串流中途傳回錯誤。 +- **畫面上已經出現文字、思考或工具時,就不再重試**,避免內容重複。 +- 重試前顯示:「{錯誤}\n{N} 秒後重試(第 2 或 3 次,共 3 次)…」。 +- 等待重試時按停止,會安靜結束,不顯示錯誤。 + +### 錯誤訊息 + +- 一般格式:「錯誤 {碼}:{供應商的說明}\n{提示}」。說明讀不出來時,顯示回應的前 300 字。 +- 依錯誤碼附上的提示: + - 401/403:「金鑰無效或沒有權限,請到「設定 → AI 供應商」檢查。」 + - 404:「找不到這個模型或網址,請檢查模型名稱。」 + - 429:「模型供應商已超載或用量已達上限。通常是暫時的,請稍後再試。」 + - 500–599:「模型供應商目前負載過高。通常是暫時的。」 +- 對話太長:「對話太長了,超過這個模型能讀的長度。請清除聊天記錄或換一個模型。」 +- 模型不支援工具:「這個模型不支援工具。請換一個支援工具的模型,或在 Bot 設定裡關掉「電腦操作」。」 +- 連不上 Ollama:「連不上 Ollama:{原因}\n請先開啟 Ollama,並用 `ollama pull` 下載模型。」 +- 連不上其他供應商:「連不上 {供應商}:{原因}」 +- 串流中出錯但沒有說明:「未知錯誤」 + +--- + +## 13. 一個回合怎麼運作 + +- **一個回合**:從你送出一則訊息(或 Bot 被喚醒)開始,到模型用純文字回答、不再要求工具為止。 + - 中間可以來回很多步:模型要用工具,App 執行並交回結果,模型再決定下一步。 +- **步驟上限**:一個回合最多 25 步(估計值)。用完時會顯示:「(這次已經用了 25 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)」 +- **每一步開始前會依序做**: + 1. 從第 2 步起,檢查要不要整理摘要(門檻 75%)。 + 2. 把你中途打的話加進去。 + 3. 開一個新的回覆氣泡。 +- **插話**:Bot 工作時你送出的訊息,會在下一步交給模型。模型本來要結束、但還有沒讀的插話時,回合會繼續,讓它看到你的新訊息。 +- **喚醒**:例行任務時間到,或別的 Bot 傳訊息過來,都會像你傳訊息一樣開始新回合。 +- **回合結束有三種**: + - 成功:模型用文字回完;或用完步驟上限。 + - 取消:你按停止。每一步和每個工具前後都會檢查;正在等你回答的請求一律當作拒絕;不顯示錯誤。 + - 錯誤:模型呼叫失敗,而且重試完了或不能重試。 + - 每次結束都會記下回合編號、結果和結束時間。 + +--- + +## 14. 長對話整理成摘要 + +- **可讀長度**:每個 Bot 可以設,預設 128,000 tokens(估計值)。 +- **怎麼估長度**:英數約 4 個字元算 1 token,中文每個字算 1 token。每則訊息另外加 4,工具參數也算進去。 +- **門檻** + - 回合開始時:用到 70% 就整理。 + - 回合進行中:每一步到 75% 才整理(多 5% 的寬限)。 +- **一定保留的內容** + - 最新 6 則訊息保留原文(估計值)。 + - 只在某一則你的訊息之前切開,不會把工具要求和它的結果拆開。 + - 7 則以下不整理;切點之前全是摘要時也不整理。 +- **怎麼整理** + - 每則舊訊息最多取 2,000 字(估計值),用「使用者:」「助手:」「[使用工具 …]」「工具結果(…):」「【先前的摘要】」標出來。 + - 要求寫下:使用者的目標與偏好、已經做出的決定、重要的事實與數字、提到的檔案路徑與指令結果、還沒完成的事,不能加入對話裡沒有的內容。 +- **整理時畫面顯示**:「對話很長了,正在把較早的內容整理成摘要…」 +- **結果**:舊訊息換成一則摘要,之後會放進給模型的開頭說明,接在「先前對話的摘要:」後面。 + - 摘要是空的或被取消時不替換。 + - 用的是同一個 Bot 的模型,而且不帶任何工具。 + +--- + +## 15. 自動審核 + +- 你可以寫兩組規則:「無需詢問直接允許」和「一律詢問」,用一般的話寫就好。 +- 兩組都符合時,「一律詢問」優先。 +- 由同一個 Bot 的模型來判斷(不帶工具),在內建安全檢查之後才進行。 + - 判斷時只看你寫的規則,不能自己加規則,回答「允許」「詢問」或「都不符合」三種之一。 +- **允許**:直接做,結果前面加「(自動審核:符合「{規則}」,直接允許)」。 +- **詢問**:照常跳出請求,理由後面加「(符合你的「一律詢問」規則:{規則})」,而且不提供「一律允許」。 +- **都不符合,或看不懂回覆**:照一般流程詢問。 +- 兩組規則都是空的時,不會去問模型。 + +--- + +## 16. 許可關卡的完整順序 + +0. **不需要許可的** + - App 操作工具(第 26 節),只有「刪除例行任務」會先問。 + - 讀公開網頁。 + - AnyBot 自己的瀏覽器。 +1. **決定在哪台電腦做** + - 操作螢幕和讀你自己的瀏覽器:一律在「你的電腦」。 + - 其他工具:用 Bot 指定的電腦,沒指定就用預設電腦。 + - 指定的電腦不存在:「沒有「{名稱}」這台電腦。可以用:…」 +2. **直接操作有開時**:第 3 步和第 5 步自動算已開。 +3. **關卡一:電腦工具還沒開** + - 詢問:「讓 {Bot} 使用電腦工具:{動作}\n(在 {電腦} 上)」 + - 允許一次;或一律允許(打開這個 Bot 的電腦工具);或拒絕。 + - 拒絕時 Bot 收到:「使用者沒有允許使用電腦工具。請改用文字回答,或問使用者想怎麼做。」 +4. **管理員鎖住「你的電腦」**:直接拒絕,「你的管理員關閉了在「你的電腦」上執行。請改用「Bot 的電腦」。」 +5. **關卡二:「在這台電腦上執行」還沒開** + - 詢問:「在你的電腦上執行任務,而不只是在它自己的電腦上:{動作}\n(在 你的電腦 上)」 + - 拒絕時 Bot 收到:「使用者沒有允許在「你的電腦」上執行。可以改用「Bot 的電腦」,或問使用者。」 +6. **個別動作** + - 截圖:「看你的螢幕畫面:…」,可以一律允許。 + - 滑鼠和鍵盤:「操作你的滑鼠和鍵盤:…」,可以一律允許。 + - 讀你瀏覽器的分頁:「讀取你瀏覽器目前分頁的內容(包含已登入的頁面):目前的分頁」,可以一律允許。單純打開連結不用問。 + - 檔案和指令:照第 19 節的安全規則決定。被擋下時顯示「被安全規則擋下:{原因}」。 +7. **自動審核或直接操作**:見第 15 節和第 8 節。 +8. **一般請求** + - 允許一次。 + - 一律允許:記到 Bot 的「一律允許」清單,例如「修改檔案」或「git commit」。 + - 拒絕:Bot 收到「使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。」 + - 等你回答時,每 0.2 秒檢查一次有沒有按停止;按停止就等於拒絕。 +9. **工具狀態**:等待中、執行中、完成、被拒、失敗。結果開頭會標電腦名稱,例如「[你的電腦] …」。 + +--- + +## 17. 三種電腦 + +| 名稱 | 是什麼 | +|---|---| +| 「Bot 的電腦」 | 一台真的 Linux 電腦(Debian 桌面、Chromium、終端機),所有 Bot 共用,每個 Bot 有自己的資料夾 `/home/bot/bot-編號`。跑在 Docker 裡,可以是這台 Mac 或你的伺服器。預設電腦。沒有 Docker 時,退回成一個封閉的資料夾(沒有桌面)。 | +| 「你的電腦」 | 這台 Mac,從工作資料夾開始(預設是家目錄)。要先打開「在這台電腦上執行」,或每次允許。讀你的瀏覽器一定用這台。 | +| SSH 電腦 | 你自己加的其他電腦,每台有名稱、位址(例如 me@studio.local)和起始資料夾(沒填就用對方的家目錄)。 | + +- 聊天時說「在我的 Mac 上」,Bot 就會換到「你的電腦」。 +- 清單順序:Bot 的電腦、你的電腦、各台 SSH 電腦。 + +### Bot 的電腦(照原版的雲端電腦做) + +原版每個帳戶有一台雲端電腦(box):Linux 容器裡的 X11 桌面,用 xdotool 操作,透過 noVNC(埠 6080)觀看。AnyBot 用同樣的做法: + +- **狀態**(設定頁和畫面視窗都有狀態圓點): + - 「尚未建立」→ 第一次用到時「建立中」(第一次會先做映像檔,要幾分鐘) + - 「休眠中」→ 用到時「喚醒中」 + - 「連線中」→「執行中」 + - 「正在備份/正在建立/正在搬移資料/正在拆除舊電腦/正在清除資料/正在從快照還原」(更新進行中) + - 「發生問題」:下一次用到時會自動重新啟動 +- **自動喚醒和休眠**:Bot 用到時自動喚醒;閒置 15 分鐘(估計值)而且沒有 Bot 在用、沒有人在看畫面,就先存快照再休眠。 +- **健康檢查**:每 20 秒看一次,單次最多等 3 秒(原版 /health 的逾時)。 +- **Bot 在上面的權限**:這是它自己的電腦,所以指令、改檔案、操作螢幕都不用問你;只有「一律不允許」的指令(例如格式化)還是會擋。可以 `sudo` 安裝套件。 +- **開機時間**:最多等 60 秒(估計值)。 + +### 快照與三種更新(原版的規則) + +- **儲存快照**:把整個 `/home/bot`(略過 .cache)壓縮存到這台 Mac。休眠前和「重建電腦」前會自動存。設定頁顯示「上次快照:月/日 時:分」。 +- **「重建電腦」**:「你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。」 + - 有 Bot 正在用這台電腦時會拒絕:「有 Bot 正在用這台電腦,等它做完再重建」。按下確認時會先停掉正在工作的 Bot,聊天會保留。 + - 步驟:正在備份(存快照)→ 正在建立(更新映像檔、換上新電腦)→ 正在搬移資料(確認原本的檔案還在、新電腦能連線)。 +- **「從快照復原」**:「從你上次儲存的快照重新啟動它。最近的變更可能會遺失,已安裝的 App 會被移除。你的 Bot、檔案和登入資訊都是安全的。」 + - 步驟:正在拆除舊電腦 → 正在建立 → 正在從快照還原;放回檔案後最多等 90 秒、每 2 秒確認一次結果(取自原版)。 + - 還沒有快照時按鈕是灰的。 +- **「重設電腦」**(紅色按鈕):全部清空,換一台全新的電腦。步驟:正在拆除舊電腦 → 正在清除資料 → 正在建立。 +- **同時只能有一個更新**:再按會顯示「已經有一個更新在進行中」。開始後就不能取消。 +- **中斷了會接續**:更新做到一半 App 被關掉,下次打開時,如果紀錄在 10 分鐘內、而且電腦不正常,就自動接續;超過 10 分鐘就清掉紀錄,電腦正常時也不重做(都取自原版)。 +- 進度在設定頁用步驟列和進度條顯示。 +- **新版本與排程更新**(原版:「有新映像檔就在指定的當地時間自動更新,錯過有一小時的補執行時間」): + - AnyBot 裡帶著的電腦版本比正在跑的新時,「快照與更新」最上面出現強調色框「有新版的 Bot 的電腦」和「現在更新」(打開「重建電腦」的確認)。每 30 秒檢查一次(估計值)。 + - 勾選「有新版本時,自動在每天 [03:00] 更新」:到了那個時間、有新版、沒有 Bot 在用,就自動做「重建電腦」;每天最多一次。錯過時間的話一小時內還會補做,超過就等隔天。時間格式錯誤時顯示「請用 時:分,例如 03:00」。 + +### 磁碟 + +- 「檢查用量」:顯示磁碟剩餘空間和最大的幾個資料夾。 +- 「請 Bot 提出清理方案」:把「檢查 Bot 的電腦的磁碟使用情況,並提出安全的清理方案供我核准」放進主要 Bot 的輸入框,由你按送出。 + +### 終端機與環境密鑰 + +- 給指令用的帳密,例如 CRM_API_TOKEN。Bot 執行指令時會變成環境變數。 +- 名稱要大寫字母開頭,只能用 A–Z、0–9 和 _(原版規則);輸入時自動轉成大寫。 +- 值只能寫入、不能讀出:清單只顯示名稱和「••••••••」,可以「取代」或「刪除」。 +- 每個值最多 32 KB、最多 100 個(估計值)。存在這台 Mac 上,只有你的帳號能讀。 +- Bot 的系統指示只會告訴它有哪些名稱,並提醒它不要把值印出來。 + +### 在哪裡執行 + +- 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。 +- 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。 +- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。 +- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。 + +### 看畫面、取得控制權 + +- 設定頁的「開啟畫面」,或聊天頂部的「電腦」按鈕。 +- 視窗:螢幕圖示+「Bot 的電腦」+狀態圓點;右邊是「取得控制權」「實際大小/調整至視窗大小」「全螢幕」和關閉。 +- 畫面大約每 0.45 秒更新一次(估計值)。Bot 正在操作時顯示「● Bot 正在操作」。 +- **取得控制權**:畫面外框變成強調色,你的點擊、右鍵、連點、捲動、打字(包括中文)和快速鍵都會送到 Bot 的電腦(⌘ 會當成 Ctrl)。這時 Bot 的滑鼠鍵盤操作會暫停,它會收到「使用者正在自己操作 Bot 的電腦,請等他交還控制權」。按「交還給 Bot」結束。 +- **僅觀看**:沒有取得控制權時,畫面只能看。 +- **全螢幕**:在瀏覽器用 noVNC 開啟(有控制權時可以操作,沒有時只能看)。 +- 電腦休眠或還沒建立時,畫面中間有「喚醒」按鈕。 + +--- + +## 18. 檔案與指令工具 + +除了「查看背景指令」,每個工具都可以指定在哪台電腦做。 + +在 Bot 的電腦和 SSH 電腦上,這些工具在那台電腦裡執行;寫檔案時內容從標準輸入送過去,所以任何文字都能寫;「修改檔案」也能只換掉一段(先讀出來、確認那段只出現一次,再整個寫回)。 + +### 「查看資料夾」(list_dir) + +- 資料夾排在前面,名稱後面加「/」;檔案排在後面,附上大小(B、KB、MB)。依名稱排序,不分大小寫。 +- 最多列 2,000 項,超過時顯示「…還有 N 項」。 +- 空資料夾顯示「(空資料夾)」。 +- 私密檔案自動隱藏。 + +### 「搜尋檔案」(search) + +- 參數:要找的文字(必填)、資料夾、是否只列檔名。 +- 結果格式:「路徑:行號:內容」。 +- 電腦有裝 ripgrep 時: + - 可以用正規表示式。 + - 每行最多 300 欄。 + - 略過 .git、.env、.pem、.key。 +- 沒裝 ripgrep 時: + - 改用一般比對,不分大小寫。 + - 略過 node_modules、target 和私密檔案。 +- 最多找 25 秒、10,000 行,只回傳前 2,000 行。 +- 可能看到的訊息: + - 「…結果太多,只列出前 2000 行(共 N 行以上)」 + - 「…搜尋超過 25 秒,結果可能不完整」 + - 「沒有找到」 + - 「請給要搜尋的文字」 + +### 「讀取檔案」(read_file) + +- 從第幾行開始(預設 1),讀幾行(預設 2,000,估計值)。每行前面加行號。 +- 檔案最大 8 MB(8,388,608 位元組)。前 8,000 位元組裡出現空字元,就當作不是文字檔。 +- 可能看到的訊息: + - 「…還有 N 行(用 offset=M 繼續讀)」 + - 「(空檔案)」 + - 「X 太大了(大小),最多讀 8 MB」 + - 「X 不是文字檔」 + - 「X 是資料夾,請用 list_dir」 + - 「找不到 X」 + +### 「修改檔案」(edit_file) + +- 把舊文字換成新文字,舊文字必須在檔案裡剛好出現 1 次。 +- 舊文字留空時,建立新檔或整個覆寫,需要時會自動建立上層資料夾。 +- 成功時:「已建立 X(N 行)」「已覆寫 X(N 行)」「已修改 X」。 +- 失敗時: + - 「在 X 裡找不到要替換的文字」 + - 「要替換的文字在 X 出現了 N 次,請多給一些前後文讓它只出現一次」 + +### 「執行指令」(shell) + +- 在工作資料夾裡執行,不接受鍵盤輸入。 +- 輸出:保留開頭和結尾各 6,000 字,中間顯示「…(中間省略 N 個字)…」(估計值)。總共不到 12,000 字時全部保留。 +- 結果: + - 沒有輸出:「(完成,沒有輸出)」 + - 失敗:「結束代碼 N」加上輸出 + - 無法執行:「無法執行:…」 + - 按停止:「已停止(連同它啟動的程式)」 +- 預設等 30 秒(估計值,最多可設 10 分鐘)。還沒跑完時不會中斷,而是移到背景繼續跑:「指令跑了超過 N 秒,已移到背景繼續執行(背景指令 #編號)。用 read_terminal 查看進度。目前的輸出:…」 +- 執行前會從環境裡拿掉 6 種 AI 金鑰,指令拿不到你的金鑰。 + +### 「查看背景指令」(read_terminal) + +- 一律不用問。 +- 顯示: + - 「背景指令 #N:指令」 + - 「狀態:已結束(代碼 C)」或「還在執行:N 個程式,CPU 時間 X.X 秒,記憶體 M MB」 + - 最新的輸出 +- 編號不存在時:「沒有背景指令 #N」。 + +--- + +## 19. 安全規則:哪些直接做、先問、拒絕 + +判斷順序是:拒絕 → 私密檔案 → 允許 → 詢問。用 ; | || & && 或換行串在一起的指令,會先拆開、一段一段判斷。 + +### 私密檔案(Bot 不能讀也不能改) + +- AnyBot 自己的資料資料夾(金鑰存在這裡) +- .git 資料夾 +- .ssh、.gnupg、Library/Keychains +- .env、.env.*、.netrc、credentials +- .pem、.key 檔 +- id_rsa、id_ed25519 開頭的檔案 + +### 在「你的電腦」上 + +- **讀取類工具**(查看資料夾、搜尋、讀取檔案) + - 工作資料夾裡:直接做。 + - 工作資料夾外:先問「讀取工作資料夾以外的地方:路徑」,可以一律允許。 + - 私密檔案:拒絕,「「路徑」是私密或系統檔案,Bot 不能讀取。」 +- **修改檔案** + - 工作資料夾裡:問「修改檔案:路徑」,可以一律允許。 + - 工作資料夾外:問「修改工作資料夾以外的檔案:路徑」,不能一律允許。 + - 私密檔案:拒絕。 +- **指令** + - 一律拒絕:sudo、su、rm -rf /、rm -rf ~、mkfs、dd、shutdown、reboot、diskutil erase、chmod -R 777 /。訊息是「「X」這類指令一律不允許。」 + - 碰到私密檔案:拒絕,「指令碰到私密或系統檔案:X」。 + - 含有看不出內容的子指令($( )、反引號、eval、exec、source、「.」,或引號沒關好):一律問「執行指令(含有隱藏的子指令,無法自動判斷):…」,不能一律允許。 + - 不用問的 24 種唯讀指令:ls、pwd、cat、head、tail、wc、echo、rg、grep、find、file、stat、du、df、date、which、tree、sort、uniq、diff、git status、git diff、git log、git show。每一段都是唯讀或已允許、而且沒有用「>」寫入檔案,才直接執行。 + - 其他指令:問「執行指令:…」。 + - 一律允許會記住指令的第一個字。 + - git、npm、pnpm、yarn、cargo、brew、pip、pip3、python3、docker、go 這 11 種會記住前兩個字,例如「npm install」。 + +### 沙盒(macOS 有提供時) + +- 所有指令都在沙盒裡跑。 +- 指令碰不到這些地方:AnyBot 的資料、.ssh、.gnupg、Library/Keychains、.aws、.config/gcloud。 + +### 在「Bot 的電腦」上 + +- 指令只檢查上面那 10 種一律拒絕的,其他都直接做。沙盒會把它關在自己的資料夾裡,看不到也寫不到家目錄的其他地方。 +- 自己資料夾裡的檔案可以自由讀和改。 +- 資料夾外:拒絕,「{Bot} 看不到這台電腦上的其他檔案(路徑)。要處理你的檔案,請用「你的電腦」。」 + +### 在 SSH 電腦上 + +- 連線不會問密碼,最多等 15 秒。有起始資料夾時會先切過去。 +- 查看資料夾、讀檔、搜尋、查看背景指令:直接做。 +- 修改檔案:問「修改 {電腦} 上的檔案:路徑」,而且只能整個寫入。否則會看到「在 {電腦} 上只能整個寫入檔案(old_text 留空),或用 shell 修改。」 +- 指令:套用跟本機一樣的規則,問「在 {電腦} 上執行指令:…」。 + +--- + +## 20. 背景指令 + +- **「輸入到背景指令」(write_terminal)**:原版可以把輸入傳給執行中的指令。指令在等你回答(例如「name?」「y/n」)時,Bot 用它把文字送進去,`\n` 代表按 Enter;`close` 會結束輸入(像 Ctrl-D)。送出後等 0.4 秒,回傳最新輸出。指令已經結束時會說「背景指令 #N 已經結束了」。 + +- 一個指令連同它啟動的所有程式,最多追蹤 4,096 個。統計程式數量、CPU 時間和記憶體。 +- 停止時從最新的程式開始,依序把整棵程式樹停掉。 +- 關閉 App 時,會停掉所有背景指令。「重新啟動這個 Bot」只停那個 Bot 的背景指令,並回報停了幾個。 + +--- + +## 21. 網頁工具 + +### 「讀取網頁」(web_fetch) + +- 讀公開頁面,不帶登入,不用許可。 +- 網址: + - 沒寫 https:// 會自動補上。 + - 有空白或沒有「.」時不接受:「「X」看起來不是網址」。 + - 空白:「請給網址」。 +- 限制: + - 最多等 20 秒。 + - 最多下載 3,000,000 位元組(估計值)。 + - 最多回傳 20,000 字(估計值),超過時結尾加「…(後面還有,已截斷)」。 +- 會拿掉程式碼、樣式和頁首,把段落、標題、清單、表格列分行,特殊符號轉回一般文字。 +- 輸出:「網址:…」「標題:…」加內文。 +- 可能看到的訊息: + - 網站回應 400 以上:「網站回應 N。如果這個頁面要登入,請改用 browser_open + browser_read。」 + - 文字少於 200 字:「(頁面幾乎沒有文字,可能要登入或靠程式載入。可以改用 browser_open 打開,再用 browser_read 讀。)」 + +### 瀏覽器工具 + +- 「在瀏覽器打開」(browser_open):參數是網址和設定檔。 +- 「讀取瀏覽器分頁」(browser_read):最多讀多少字(預設 20,000,最多 40,000),加上設定檔。 +- 「瀏覽器設定檔」(browser_profiles):列出、新增、切換。 + +### 用哪個瀏覽器 + +- 有裝 Chrome 類瀏覽器時,用 AnyBot 自己的瀏覽器。只有設定檔選「你的瀏覽器」時,才用你平常的瀏覽器。 +- 都沒裝時,一律用你自己的瀏覽器。 + +### 原版「從 Chrome 複製登入」 + +- 原版已經拿掉這個功能,所以 AnyBot 也沒有做,Bot 也被交代不要提。 + +--- + +## 22. AnyBot 自己的瀏覽器與設定檔 + +### 用哪個瀏覽器 + +- 依序找第一個有裝的:Google Chrome、Brave Browser、Microsoft Edge、Chromium。 +- 用 AnyBot 自己的資料夾打開,不會影響你平常的瀏覽器,也不用改任何瀏覽器設定。 +- 略過首次使用畫面和「設為預設瀏覽器」的詢問。 + +### 數字 + +- 「預設」設定檔用本機連接埠 9333(估計值)。其他設定檔由瀏覽器自己挑空的連接埠,再由 AnyBot 讀回來。 +- 等待時間: + - 等瀏覽器開好:最多 20 秒。 + - 讀一頁:最多 20 秒。 + - 打開網址後:等 3 秒再讀。 +- 已經開著時,用新分頁打開。 + +### 設定檔 + +- 「預設」是第一個,登入狀態一直保留。 +- 其他設定檔各自有資料夾和登入,例如「工作」「私人」。 +- 「你的瀏覽器」代表你平常的 Chrome(第 23 節)。也聽得懂「你的 chrome」「我的 chrome」「我的瀏覽器」「my chrome」這些說法。 +- 順序:「預設」排第一,其他依名稱,「你的瀏覽器」排最後。 +- 「使用中」的設定檔會被記住;找不到時回到「預設」。找設定檔不分大小寫。 +- 在某個設定檔打開網址,那個設定檔就會變成使用中。 + +### 名稱規則 + +- 不能空白:「請給設定檔名稱」 +- 最多 30 字(估計值):「設定檔名稱最多 30 個字」 +- 不能用保留名稱:「「X」是留給你自己瀏覽器的名字,請換一個」 +- 不能有 / 或 :,也不能用 . 開頭:「設定檔名稱不能有 / 或 : 也不能用 . 開頭」 + +### 新增、切換、列出、移除 + +- **新增**:名稱已經存在時沿用,不重複建立。新增後自動切過去。 + - 「已新增並切換到設定檔「X」(全新的,還沒登入任何網站)…」 +- **切換**:「已切換到「X」,之後的 browser_open / browser_read 都用它。」 + - 切到「你的瀏覽器」時補一句:「(使用者平常的瀏覽器:請使用者在那裡切到想要的 Chrome 設定檔)」 +- **列出**:「AnyBot 瀏覽器的設定檔:A、B(使用中)…。每個設定檔有自己的登入。」 +- **移除**:只有你能在設定裡移除,Bot 不行。 + - 「預設」和「你的瀏覽器」不能移除:「「X」不能移除」。 + - 視窗還開著:「「X」的視窗還開著,請先關掉它再移除。」 + - 移除的是使用中的設定檔時,會回到「預設」。 +- **設定檔不存在**:「AnyBot 瀏覽器沒有叫「X」的設定檔。現有:…。要新的可以用 browser_profiles 新增(action: add)。」 + +### 設定 → 電腦 → 「AnyBot 瀏覽器設定檔」 + +- 說明:「每個設定檔是一個獨立的瀏覽器視窗,有自己的登入(例如工作和私人帳號)。Bot 會用「使用中」的那個,你也可以在聊天裡叫它換。」 +- 每個設定檔一列: + - 單選按鈕,使用中的那個旁邊標「使用中」。 + - 「開啟視窗」:提示「打開這個設定檔的瀏覽器視窗,在裡面登入網站」。 + - 開啟中:「正在打開「X」…」 + - 成功:「已打開「X」的視窗。在裡面登入一次,之後會記住。」 + - 已經開著時,把它的分頁帶到前面。 + - 「移除」:「預設」沒有這顆。按下後變成紅字「確定移除(登入也會刪掉)」和「取消」,移除後顯示「已移除「X」。」 + - 「你的瀏覽器」那列: + - 說明「你平常的 Chrome,切到哪個設定檔就讀哪個」。 + - 已有權限時顯示「✓ 輔助使用已打開」;沒有時是按鈕「打開輔助使用」。 +- 新增:輸入框提示「新設定檔名稱,例如 工作」,按「新增設定檔」。 + - 成功:「已新增「X」並設為使用中。按「開啟視窗」登入你要的網站。」 +- 沒有任何 Chrome 類瀏覽器時,只顯示:「需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。」 +- 底部說明:「選「你的瀏覽器」:Bot 像 Grok Bot 一樣從畫面讀你平常的 Chrome(macOS 會請你打開一次「輔助使用」),你在 Chrome 切到哪個設定檔,它就看哪個;讀之前會先問你。其他的是 AnyBot 自己的視窗,各自登入一次就好。」 + +### 打開和讀取 + +- 打開成功:「已在 AnyBot 瀏覽器({瀏覽器},設定檔「X」)打開 {網址}。接著用 browser_read 讀內容。如果頁面要登入,請使用者在那個視窗登入一次,之後會記住。」 +- 讀取的是最近用的那個一般分頁。輸出:「瀏覽器:AnyBot 瀏覽器(…,設定檔「X」)」「標題:」「網址:」加內文。 +- 頁面少於 1,500 字,又有 log in、sign in 或「登入」字樣時,加上「(這頁看起來要登入。請使用者在 AnyBot 瀏覽器視窗登入一次,之後就會記住。)」 +- 可能看到的錯誤: + - 「這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。」 + - 「{瀏覽器} 沒有及時打開…請再試一次。」 + - 「AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開網址。」 + - 「讀取頁面太久了」 + - 「頁面拒絕讀取:…」 + +--- + +## 23. 讀取你自己的瀏覽器(「你的瀏覽器」) + +### 選哪個瀏覽器 + +- 候選:Google Chrome、Arc、Microsoft Edge、Brave Browser、Chromium、Safari。 +- 優先選畫面上看得到、而且最前面的瀏覽器視窗。 + - 透明、沒有大小的視窗,以及選單列小工具都不算。 + - 列出視窗最多等 10 秒。 +- 沒有看得到的視窗時,用第一個正在執行的瀏覽器;都沒在執行時,有裝 Chrome 就用 Chrome,否則用 Safari。 + +### 打開網址 + +- Chrome 類瀏覽器:網址交給你平常的視窗,不會跑到 AnyBot 的視窗。 +- 打開後等 4 秒(估計值)。 +- 成功:「已在 {瀏覽器} 打開 {網址}。接著可以用 browser_read 讀這個分頁。」 + +### 讀取:先用「輔助使用」(跟原版小幫手一樣的方法) + +- 不用改任何瀏覽器設定。你在 Chrome 開著哪個設定檔的視窗,就讀哪個。 +- 需要 macOS 的「輔助使用」權限,macOS 會自己問。 + - 沒開時,AnyBot 會打開那個設定頁並說:「macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。」 +- 只會讀你自己的瀏覽器,不會讀到 AnyBot 自己的視窗。 +- Chrome 和 Electron 類 App 的特別處理(規則取自原版): + - 怎麼判斷:App 識別碼是 com.google.Chrome、com.github.Electron,或以 com.google.Chrome. 開頭;或是 Electron 程式;或 App 裡含有 Electron Framework、Chromium Embedded Framework、Google Chrome Framework 其中之一。 + - 這類 App 會先打開兩個輔助使用開關(AXManualAccessibility、AXEnhancedUserInterface),網頁文字才看得到。 +- 讀的是最前面視窗裡的網頁:標題、網址,連續的文字合成一行。 +- 限制(估計值): + - 最多 20,000 個元素、80 層。 + - 試 5 次,每次隔 0.7 秒。 + - 每次最多等 2 秒。 +- 成功:「瀏覽器:{瀏覽器}(你的瀏覽器)」「標題:」「網址:」加內文。 +- 可能看到的錯誤: + - 「你的瀏覽器現在沒有打開的視窗」 + - 「讀不到「標題」的頁面內容」 + - 「你的瀏覽器沒有開著。請先打開它(或叫我用 browser_open 打開網址)。」 + +### 讀不到時改用 AppleScript + +- 讀最前面分頁的標題、網址和文字。最多等 90 秒,讓你有時間回答 macOS 的詢問。 + - 等太久:「等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)」 +- 讀不到文字時,仍會給標題和網址,加上「(讀不到頁面文字)」和說明: + - 被擋下控制權:自動打開「自動化」設定頁,並說「macOS 擋下了 AnyBot 控制 {瀏覽器}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{瀏覽器}」,然後再叫我試一次。」 + - Safari:「Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開…」 + - Chrome 類:「{瀏覽器} 還沒允許讀取網頁內容:請在 {瀏覽器} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。」 +- Bot 被交代絕對不要主動叫你去改這些選單。只有在最後的備用方法也失敗時,才會照實轉告。 + +--- + +## 24. 操作螢幕(滑鼠、鍵盤、截圖) + +- 兩台電腦有螢幕:「Bot 的電腦」(Linux 桌面)和「你的電腦」(這台 Mac)。每個螢幕工具都有 `computer` 參數;沒指定時用 Bot 的預設電腦,預設電腦沒有螢幕時用 Bot 的電腦。 +- 在 Bot 的電腦上:用 xdotool 操作(跟原版雲端電腦一樣),不用問你;英文一個鍵一個鍵打,中文和表情符號用剪貼簿貼上(終端機用 Shift+Insert,其他程式用 Ctrl+V),所以任何程式都打得進去;⌘ 當成 Ctrl。 +- 在你的 Mac 上:只在 macOS。工具名稱和參數跟原版一樣。 +- 需要看得懂圖片的模型。 + +### 原版的九個工具 + +computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)、drag、scroll、typing、press_key、check_permissions。 + +- **「按住或放開滑鼠」(computer_use_mouse_button)**:action 是 press 或 release,可以先移到 x、y。用來長按或自己控制的拖曳。結果「已按住滑鼠鍵(記得之後放開)。」「已放開滑鼠鍵。」 +- **「檢查螢幕權限」(computer_use_check_permissions)**:回報「螢幕錄製:已允許/還沒允許」「輔助使用(滑鼠和鍵盤):…」,不會跳出系統視窗。Bot 的電腦上一律可以。 +- **背景點擊**:click 加上 `background: true`,在你的 Mac 上把點擊直接送給那個視窗的 App,不移動游標、不把視窗拉到前面(原版用私有 API「skylight-no-raise」做這件事)。結果「已在背景點「App」的 (x, y),沒有把它拉到前面。」 + +### 逐個 App 授權(原版:「Grok Bot 可以看到 {app},並在其中點擊和輸入」) + +- 看螢幕(截圖)問一次「看你的螢幕畫面」。 +- 點擊、打字、捲動、拖曳時,AnyBot 先找出會落在哪個 App:點的位置底下的視窗,打字和按鍵則是最前面的 App。第一次碰到某個 App 會問:「讓 {Bot} 看到「{App}」,並在其中點擊和輸入:{動作}」。選「一律允許」就記成這個 Bot 對這個 App 的授權;設定頁可以移除。 +- 直接操作模式下不問。 + +### 接管與停止(你的 Mac) + +- Bot 在你的 Mac 上動滑鼠或鍵盤時,螢幕最上方中間會浮出一條橫幅(永遠在最上層,不會被其他視窗蓋住):紅點「{Bot} 正在操作你的電腦」,右邊「取得控制權」和「停止」。最後一個動作後 8 秒(估計值)自動收起。 +- **取得控制權**:橫幅變成「你正在操作,Bot 已暫停」,按鈕變「交還給 Bot」。這段時間 Bot 的滑鼠鍵盤動作會被擋下,它會收到「使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作」。截圖仍可以。 +- **停止**:直接停掉那個 Bot 這一回合的工作。 + +### 示範教學(原版:「錄下你執行任務的過程。{Bot} 會學習這些步驟,之後可以自行再次執行。」) + +- 在設定 → 電腦 →「示範教學」,替目前選的 Bot 輸入任務名稱(例如「匯出本月報表」),按「開始錄製」。 +- 設定視窗會關掉,螢幕上方出現「{Bot} 正在觀看並學習 分:秒」(紅點閃爍),右邊「完成」「取消」。 +- 照平常的方式操作。AnyBot 只「聽」點擊、打字和捲動,不會擋住或改變你的操作;點在 AnyBot 自己視窗上的不算。 +- 錄製時每 0.5 秒拍一張螢幕畫面(寬 1280,需要「螢幕與系統錄音」權限),完成後用 ffmpeg 合成影片(原版也是用 ffmpeg 錄影);沒有 ffmpeg 時保留每一步的畫面。每一步都留一張「做這一步之前」的畫面。 +- 示範卡片上有「播放錄影」,展開步驟後每一步有「畫面」可以打開。 +- Bot 有新工具「view_demo_step」(示範名稱+第幾步),可以看那一步當時的畫面,照著找按鈕;不用問你。 +- 按「完成」後整理成步驟,例如: + 1. 切到「Safari」,在「Safari」點 (50, 25) + 2. 在「Safari」輸入「hi」(打錯刪掉的字會一起刪掉) + 3. 按 return + 4. 按 cmd+c + 5. 切到「備忘錄」,在「備忘錄」連點 2 下 (150, 100)(0.4 秒內同一個位置算連點,估計值) + 6. 在「備忘錄」往下捲動(1.5 秒內連續的捲動算一次,估計值) +- 座標換算成截圖上的位置,Bot 可以直接用。 +- 聊天上方顯示「已記下「名稱」(N 個步驟)。之後跟 Bot 說「照示範做 名稱」就可以。」 +- 每個 Bot 的示範列在卡片下面,可以展開看步驟或刪除;同名的會被新的取代。 +- Bot 的系統指示會附上它學過的示範,並提醒它每一步先截圖確認、畫面不同就找對應的按鈕。 +- 第一次需要 macOS「輸入監控」權限:沒開時 AnyBot 會打開那個設定頁,並說「macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。」 + +### 權限 + +- 截圖要「螢幕與系統錄音」權限。沒開時,AnyBot 會打開那個設定頁並說:「macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。」 +- 滑鼠和鍵盤要「輔助使用」權限,沒開時的說法類似。 +- 在 AnyBot 裡: + - 第一次看螢幕會問一次(「看你的螢幕畫面」)。 + - 第一次動滑鼠或鍵盤也會問一次(「操作你的滑鼠和鍵盤」)。 + - 兩者都可以一律允許;直接操作模式下不問。 + +### 座標 + +- 用最新截圖上的像素位置。AnyBot 依螢幕大小和截圖大小的比例換算。 + - 例如寬 1,470 點的螢幕送出寬 1,024 的圖,圖上 (512, 333) 就是螢幕上的 (735, 478)。 +- 還沒截圖就要點:「請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。」 +- 你自己的滑鼠和鍵盤不會被凍住。 + +### 「看你的螢幕」(computer_use_screenshot) + +- 只拍主螢幕,沒有快門聲。 +- 長邊縮到 1,024,例如 2940×1912 會變成 1024×666;短邊不小於 8。 +- JPEG 品質依序試 85、70、55,取第一個小於 1 MB(1,048,576 位元組)的。這些都取自原版。 +- 只把最近 3 張截圖給模型看(估計值),較早的改成「(較早的截圖已省略)」。截圖不會存到硬碟。 +- 成功:「你的螢幕截圖(寬×高)。之後的座標請用這張圖上的像素位置。」 + +### 「點一下」(computer_use_click) + +- 參數:x、y、左/右/中鍵、點幾下(1–3)、修飾鍵。 +- 先移過去,按下後停 0.03 秒再放開;連點時每下隔 0.08 秒(取自原版)。 +- 結果:「已點 (x, y)。要確認結果可以再截圖。」 + +### 「打字」(computer_use_typing) + +- 任何語言都可以,包括中文。 +- 每段最多 20 個字元單位,不會把一個字切斷。每段按下後停 0.035 秒再放開,段與段之間隔 0.02 秒(取自原版)。 +- 結果:「已打字(N 個字)。」 + +### 「按鍵」(computer_use_press_key) + +- 例如 return、tab、escape、delete、up、cmd+c、cmd+shift+t、cmd+space。 +- 多個鍵用空白分開,依序按,例如「cmd+a delete」。 +- 每個鍵按住 0.035 秒,鍵與鍵之間隔 0.02 秒(取自原版)。可以指定按多久,最多 5 秒。 +- 可用的鍵: + - return/enter、tab、space、delete/backspace、escape、forwarddelete、home、end、pageup、pagedown、方向鍵、F1–F12。 + - a–z、0–9 和常見符號。 +- 修飾鍵:cmd、shift、option、control(也聽得懂 command、alt、ctrl 等寫法)。 +- 錯誤:「不認得「X」這個鍵」「不認得「X」這個修飾鍵」。 + +### 其他操作 + +- **「捲動」(computer_use_scroll)** + - 往上、下、左、右,預設 3 格,最多 50 格。 + - 結果:「已捲動 {方向} N 格。」 +- **「移動滑鼠」(computer_use_mouse_move)** + - 例如讓滑過才出現的選單顯示出來。 + - 結果:「已移動滑鼠。」 +- **「拖曳」(computer_use_drag)** + - 沿著一串點拖過去,第一點按下、最後一點放開,每步隔 8 毫秒(估計值)。 + - 至少要兩個點。 + - 結果:「已拖曳(N 個點)。」 + +--- + +### Mac 內建 App(原版的裝置工具,名稱一樣) + +| 工具 | 做什麼 | 權限 | +|---|---|---| +| ios_read_reminders | 看提醒事項(可指定清單、關鍵字、是否含已完成) | 每個 App 問一次「讀取你的「提醒事項」」 | +| ios_write_reminders | 新增、修改、完成、刪除提醒事項 | 問「修改你的「提醒事項」」,可一律允許 | +| mobile_read_calendar | 看某段時間的行程 | 讀取:問一次 | +| mobile_write_calendar | 新增、修改、刪除行程(沒給結束時間就排 1 小時) | 修改:可一律允許 | +| mobile_read_contacts | 用名字、電子郵件或電話找人,最多 50 筆(原版一次 50 筆) | 讀取:問一次 | +| mac_read_notes | 搜尋備忘錄,或讀一則(最多 20,000 字,原版的上限) | 讀取:問一次 | +| mac_write_notes | 新增(要有標題)或接在某則後面(要給 id 和目前的標題;標題變了會請 Bot 重讀) | 修改:可一律允許 | +| mac_read_mail | 搜尋郵件(關鍵字、信箱、只看未讀、最近幾天),或讀一封(最多 20,000 字) | 讀取:問一次 | +| mac_draft_mail | 在「郵件」打開草稿(新信要有收件人;也可以回覆某封),由你自己按寄出 | 修改:可一律允許 | +| mac_send_mail | 直接寄出 | **每一封都問**,沒有「一律允許」,直接操作模式也一樣 | +| mac_mail_accounts | 列出郵件帳號和地址 | 讀取:問一次 | +| mac_send_message | 用「訊息」傳 iMessage/簡訊 | **每一則都問** | + +- 透過 macOS 的自動化執行,第一次用到每個 App 時 macOS 會自己問一次。被拒絕時 AnyBot 會打開「系統設定 → 隱私權與安全性 → 自動化」並說明要打開哪個 App。 +- 每次最多等 30 秒(原版 30 秒)。 +- 搜尋郵件時最多看最近 300 封(估計值,郵件 App 的腳本很慢)。 + +--- + +### 附加檔案和圖片 + +- 輸入框左下角「+」:打開 Finder 選檔案(可以一次選很多個)。也可以把檔案拖到視窗上(整個視窗會變淡,顯示「放開以附加檔案」),或在輸入框按 ⌘V 貼上剪貼簿裡的圖片(例如 ⌃⇧⌘4 截的圖)。 +- 等著送出的附件排在輸入框上方:圖片顯示縮圖,其他檔案顯示檔名和大小,每個都有 × 可以拿掉。只有附件、沒有文字也可以送出。 +- 每個附件會複製一份到 AnyBot 的資料夾,原檔搬走也不影響聊天。最大 50 MB(估計值)。資料夾不能直接附加。 +- 送給模型時: + - 圖片:縮成跟截圖一樣(長邊 1024、最大 1 MB),跟你的文字一起送(OpenAI 相容和 Claude 兩種格式都支援)。HEIC、TIFF 等先用 macOS 轉檔。 + - 文字檔、程式碼:直接讀內容;Word、RTF、Pages、HTML 用 macOS 的 textutil 轉成文字;PDF 在有 pdftotext 時讀內容。每個最多 20,000 字。 + - 其他檔案:告訴 Bot 檔案在哪裡,讓它用工具處理。 + - 「Bot 的電腦」正在執行時,附件也會放進那個 Bot 的資料夾 `附件/`,並告訴 Bot 位置。 +- 聊天裡你的訊息上方顯示附件;點一下用預設的 App 打開。 +- Bot 工作中送出附件時,文字內容會補充給正在進行的工作(圖片要等下一回合)。 + +### 語音輸入 + +- 輸入框的麥克風圖示:按一下開始錄音,變成紅色閃爍的「● 分:秒 完成」;再按一下停止,顯示「轉成文字…」,完成後文字放進輸入框(接在原本的文字後面),由你檢查後送出。 +- 錄音用 macOS 內建的錄音功能,不用另外安裝。第一次 macOS 會問麥克風權限;被拒絕時 AnyBot 會打開「系統設定 → 隱私權與安全性 → 麥克風」。 +- 轉文字用「設定 → AI 供應商 → OpenAI」的金鑰(依序試 gpt-4o-mini-transcribe、whisper-1)。沒有金鑰時會說明要去哪裡填。 +- 最長 10 分鐘(估計值),到了自動停止。 + +### 語音聊天(朗讀回答) + +- 輸入框的喇叭圖示:打開後,這個 Bot 每次回答完都會用 macOS 內建語音唸出來(中文用台灣的「美佳」)。標題、粗體、連結會整理成好唸的文字,程式碼只說「程式碼略過」。 +- 正在唸的時候按喇叭會停止這一段;開始語音輸入時也會先停。再按一次(沒在唸時)就關閉朗讀。 +- 搭配語音輸入,就能用說的跟 Bot 來回對話。每個 Bot 各自記住開或關。 + +### 通話模式(即時語音對話) + +- 聊天頂部的「通話」:開始後不用按任何鍵。AnyBot 一直聽,你講完(安靜 1.2 秒,估計值)就停止錄音、轉成文字、送給 Bot;Bot 回答後用語音唸出來,唸完再繼續聽。30 秒沒人說話就繼續等下一句。 +- 頂部紅色按鈕顯示目前在做什麼:「● 正在聽… / 聽懂中… / 想一下… / 正在說… 結束通話」,按一下結束。 +- Bot 要你允許某個操作時,會等你按完再繼續。轉文字用 OpenAI 的金鑰;聲音偵測用 macOS 錄音器的音量(大於 −38 dB 算有說話,估計值)。 +- 原版用 xAI 的即時語音服務;AnyBot 是「聽 → 回答 → 唸」的輪流方式,不能在 Bot 說話時插話(按結束或喇叭可以打斷)。 + +### 分享 Bot(匯出、匯入) + +- Bot 選單(右鍵或「⋯」)→「匯出 Bot(分享給別人)…」:選位置存成 `.anybot` 檔。帶走外觀、供應商和模型、指示、例行任務、示範的步驟;**不會**帶走聊天記錄、API 金鑰、Token(例行任務的 token、apiKey、secret 會清空)、你的「一律允許」授權、示範的錄影和畫面。 +- 「匯入 Bot…」:選 `.anybot` 檔,變成一個新的 Bot。事件觸發的例行任務會先暫停,因為需要新主人自己的 Token;匯入後會提醒。檔案來自較新版本時會請你先更新。 + +### 訊息管道(Slack、Discord、Telegram) + +- 設定 → 「訊息管道」。原版有 Slack、Teams、Discord 和訊息管道;它靠自己的伺服器接收訊息。AnyBot 沒有對外的網址,所以用你自己的 Bot Token 定期去看新訊息: + - Slack:每 3 秒看一次指定頻道(估計值),回覆在原訊息的討論串。提到 Bot 的「@Bot」會被拿掉。 + - Discord:每 3 秒看一次指定頻道,回覆時引用原訊息;超過 2,000 字自動分段。 + - Telegram:長輪詢(等 25 秒),可以只回某個聊天 ID,留空就回所有私訊;超過 4,096 字自動分段。 + - 其他 Bot 的訊息和自己的訊息都不回;第一次連上時從「現在」開始,不會回覆舊訊息。 +- 收到訊息時,指定的 Bot 會收到「💬 Slack「客服群」的 小明:…」並開始工作,就像你傳訊息給它;它在工作中時會插進同一回合。回合成功結束後,它最後的回答會自動傳回去;出錯時傳「抱歉,這次沒有完成,請再試一次。」;你按停止就不回。 +- Bot 的系統指示會告訴它:對方不是使用者本人,要簡潔、不要透露你的私人資料,要動到你的電腦或帳號前先確認。 +- 每個管道一張卡片:種類、名稱、狀態點(綠色「已連上,等待新訊息(收到 N 則)」,紅色顯示錯誤)、「開啟」勾選、「移除」,以及由哪個 Bot 回覆。 +- 新增:選 Telegram/Slack/Discord,填名稱、回覆的 Bot、Token(每種都有怎麼拿 Token 的說明)、頻道或聊天 ID;「測試 Token」會顯示連上的帳號名稱。 +- AnyBot 開著時才會收訊息。 +- **Mac「訊息」(iMessage/簡訊)**:讀這台 Mac 的訊息資料庫看新訊息(要給 AnyBot「完全取用磁碟」權限),用「訊息」回覆;可以只回某個電話或 Apple ID。 +- **郵件**:每 30 秒看「郵件」的未讀信(可以指定信箱),讀完整封後交給 Bot,回信主旨自動加「Re:」,寄回寄件人。 +- **Microsoft Teams**:用你自己的 Microsoft 帳號登入,不需要公開網址。填 Entra 應用程式(用戶端)ID 和租用戶(留空 = 公司或學校帳號),按「登入 Microsoft」:AnyBot 打開微軟的登入頁並顯示代碼,你輸入代碼、登入後就完成(裝置碼登入)。之後每 5 秒看一次聊天(`chat:<聊天 ID>`)或頻道(`<團隊 ID>/<頻道 ID>`),在聊天裡回覆、在頻道裡回在討論串。Bot 以你的身分回覆;微軟換發的新登入權杖會自動保存。 + +--- + +### 連接器(MCP) + +- 設定 →「連接器」。接上 Model Context Protocol 伺服器,Bot 就能用它的工具(原版的 MCP 用戶端)。 +- 本機伺服器填指令(例如 `npx -y @modelcontextprotocol/server-filesystem ~/Documents`)加環境變數;遠端伺服器填網址(Streamable HTTP,JSON 或 SSE 回應都可以)加標頭。 +- 每個連接器一張卡:狀態點(「已連上,N 個工具」/錯誤原因)、開啟、移除、「給 Bot 的使用說明」(原版的 MCP 自訂說明,會放進 Bot 的系統指示)。下面列出所有可以用的工具。 +- 工具名稱給模型看時是 `mcp__<連接器>__<工具>`(最多 64 字;中文名稱的連接器用一段固定代碼)。 +- 權限:每個連接器第一次被用時問「使用連接器「X」的工具:…」,可以一律允許;直接操作模式下不問。 +- 工具回傳的文字、圖片都會交給 Bot;一次最多等 120 秒(估計值)。 + +### 記憶、技能、範本 + +- **記憶**:跟 Bot 說「記住我喜歡靠窗的座位」,它會用 remember 記下來;「忘記…」用 forget。每個 Bot 最多 200 條、每條 500 字(估計值),每次聊天都帶著。Bot 設定裡的「記得的事」可以一條條刪。 +- **技能**:教 Bot 一套做法後請它「存成技能」(save_skill),所有 Bot 共用;系統指示只列名稱和一句說明,Bot 要用時先用 use_skill 讀完整做法。設定 →「連接器」頁的「技能」卡可以刪除。 +- **範本**:側邊欄「+」按右鍵 →「從範本建立」:研究助理、寫作夥伴、電腦助手、客服助理、開發助手,各有角色說明、指示、頭像和顏色。Bot 也能用 create_bot 的 template 參數建立。 + +### 保險庫(照原版重做,2026-11-04) + +- 設定 → 保險庫:「新項目」選單(登入資訊/卡/聯絡資訊/匯入)、分頁(全部/登入資訊/卡片/聯絡資訊,含數量)、搜尋保險庫、每種的空白提示和底部說明。 +- **登入資訊**:網站(最多 10 個,子網域也會填)、使用者名稱、密碼、提示。檢視時密碼一律顯示成 ••••,沒有顯示或複製鈕;編輯時密碼留空就不變。 +- **卡片**:卡號(檢查碼與卡別)、有效期限、安全碼、持卡人、暱稱、帳單地址(可用聯絡資訊的地址)、提示。存好後卡號鎖定,安全碼不能再看。 +- **聯絡資訊**:姓名、地址、多個電話和電子郵件(可設為主要)、標籤、提示。 +- **CSV 匯入**:自動辨識 1Password、Chrome/Edge、Safari、Firefox、Bitwarden、LastPass(含卡片),預覽、略過重複、只收 https、進度條可停止、匯入摘要。 +- **存放**:這台電腦上的加密檔 vault.bin(AES-256-GCM),金鑰放在系統鑰匙圈(「AnyBot vault key」)。畫面和 Bot 只拿得到不含密碼、卡號、安全碼的資料。以前存在鑰匙圈的 AnyBot 登入會自動搬進來。 +- **Bot 的工具**:`vault_list`(只有可看的欄位)、`browser_fill_login`、`browser_fill_card`、`browser_fill_contact`。每次填入都先問;登入用在沒存過的網站會警告「只有在它與已儲存的網站屬於同一服務時才允許。仿冒網站可能會竊取你的密碼。」且不能一律允許;卡片每次都問。付款服務框架(iframe)裡的卡號欄填不進去,會請使用者自己填。 +- 還沒做:1Password 服務帳戶連線(原版的「Shared with Grok Bot」保管庫)、安全表單、刷卡相機。 + +### 檔案預覽、Bot 做的頁面 + +- Bot 用 `open_for_person` 把它做好的網頁、圖表、報告、圖片、文件在你的螢幕上打開(網址也可以)。在 Bot 的電腦上做的檔案會先複製到這台 Mac。 + +### 生活卡片 + +- Bot 用 `show_card` 把機票、餐廳或地點、天氣、商品、活動整理成卡片顯示在聊天裡:圖示、標題、副標、最多 12 列「項目/內容」,以及「查看來源 ↗」。 + +### 聊天:群組、表情回應、引用、討論串、匯出、會議 + +- **群組聊天**:「+」按右鍵 →「群組聊天(好幾個 Bot 一起回答)」。在 Bot 設定的「群組成員」勾選成員(至少一個)。你傳一則訊息後,成員依序回答,每個用自己的模型、指示、記憶和電腦;每則回答顯示那個 Bot 的頭像。後面的成員看得到前面的回答(標成【名字】,但看不到別人的工具步驟和結果),被要求補充不同觀點、不要重複。某個成員出錯或被停止,這一輪就停下來。輸入框左下角顯示「成員:…」取代模型選單。 +- **表情回應**:滑鼠移到 Bot 的回答上,可以按 👍 ❤️ 😂 🎉 🙏;回應顯示在回答下面,按一下可以拿掉;Bot 下一回合會知道你的回應。 +- **引用**:回答下的「引用」:輸入框上方出現「引用:…」(左邊一條強調色),送出時引用的內容會變成 Markdown 引言放在你的訊息前面。 +- **討論串**:Bot 選單 →「開始新對話」:目前的對話收起來(用第一則訊息當標題,最多保留 50 個),聊天重新開始;「過去的對話」可以切回任何一個(目前的會先收起來)。 +- **匯出文字記錄**:Bot 選單 →「匯出文字記錄…」存成 Markdown(你的訊息、附件名稱、Bot 的回答、工具步驟、摘要)。 +- **錄會議**:Bot 選單 →「錄會議…」:用麥克風錄音(最長 2 小時,估計值),頂部顯示紅色「● 正在錄會議 · 結束」。結束後轉成文字記錄,當作附件放進輸入框,並幫你打好「這是剛才會議的文字記錄,請整理:重點、做了哪些決定、待辦事項(誰、做什麼、什麼時候)。」,由你按送出。 + +## 25. 例行任務 + +### 每個例行任務記錄的內容 + +- 編號、標題 +- 每次要做什麼 +- 排程 +- 是否暫停 +- 建立時間、上次執行、下次執行 +- 用哪台電腦 + +### 排程可以怎麼設 + +- 每天/每週/每月/每年,每 N 個單位(預設 1)。 +- 每週要選星期幾。 +- 每月可以選幾號,或「第 N 個星期幾」(1–4,或最後一個)。 +- 每年選月份和日期。 +- 時間用 24 小時制(例如 09:00,也接受 7:30)。 +- 可以設時區;沒設就用你在設定裡選的時區。 + +### 自動修正與檢查 + +- 每 0 個單位會改成 1。 +- 每月沒有指定日期:設成 1 日。 +- 每年沒有指定月份:設成 1 月。 +- 每週沒有指定星期幾:「每週的例行任務要指定星期幾…」 +- 時間看不懂:「時間「X」看不懂,請用 24 小時制的 HH:MM,例如 09:00」 +- 日期超出範圍:「每月的日期要在 1 到 31 之間」 + +### 畫面上的描述(照原版文字) + +| 情況 | 文字 | +|---|---| +| 每天 | 「每天」 | +| 每 N 天 | 「每N 天」 | +| 每週一到週五 | 「每個工作日」 | +| 每週指定幾天 | 「每週的週一、週三」,每 N 週則為「每N 週的…」 | +| 每月幾號 | 「每月D日」,每 N 個月則為「每N 個月的D日」 | +| 每月第幾個星期幾 | 「每月的第N個星期X」或「每月的最後一個星期X」 | +| 每年 | 「每年M月D日」 | + +- 描述後面接時間,例如「每個工作日 09:00」。 +- 時區跟這台電腦不同時,結尾加「(時區)」。 + +### 下次什麼時候跑 + +- 一定是現在之後的時間。 +- 「每 N」從建立那天開始算。 +- 遇到月底: + - 31 日在只有 30 天的月份,改在最後一天跑。 + - 2 月 29 日只在閏年跑。 +- 依例行任務自己的時區計算,最多往後找 5 年。 +- 夏令時間: + - 時間重複時,用比較早的那一次。 + - 時間不存在時,那天跳過。 +- 下次時間顯示成「今天 09:00」「明天 09:00」或「11月5日 09:00」。 + +### 怎麼觸發 + +- 每 1 秒檢查一次。 +- Bot 忙的時候,等它閒下來再跑;每個 Bot 一次只觸發一個。 +- Bot 收到的訊息是「⏰ 例行任務「標題」時間到了:要做的事」,然後像平常一樣工作。 +- 如果那個 Bot 不在目前畫面上,會標成未讀。 + +### 例行任務視窗(頂部「例行任務」按鈕) + +- 標題「(Bot) 的例行任務」,寬 480 px。 +- 沒有任務時顯示: + - 「例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。」 + - 「例如:「每個工作日早上 9 點整理新聞給我」」 +- 每張卡片: + - 標題(暫停中的加「已暫停」)。 + - 排程,接著是「·下次 (時間)」。 + - 要做的事。 + - 按鈕: + - 「暫停」/「繼續」:繼續時會重新算下次時間。 + - 「立即執行」:會切到這個 Bot 並開始做。 + - 「刪除」:紅字。 +- 刪除確認視窗: + - 標題「刪除例行任務?」 + - 內文「這會刪除例行任務並停止其後續執行。此操作無法復原。」 +- 視窗最下面:「要新增或修改,直接在聊天裡告訴 Bot。」 + +--- + +### 更多排程寫法(原版有的) + +- 「每 N 分鐘」(最少 5 分鐘,估計值,從建立時間開始算)、「每小時」「每 N 小時的 MM 分」。 +- 「僅一次」:下一次符合的時間執行一次就停。 +- 「左右」(彈性時間):在指定時間前後 15 分鐘內(估計值)的某個固定時刻執行,畫面上寫「09:00左右」。 +- 都是在聊天裡請 Bot 設定(create_routine 的 frequency 可以是 minutely、hourly,加上 once、flexible)。 + +### 事件觸發的例行任務(原版的觸發種類) + +在聊天裡跟 Bot 說,例如「PR 合併時幫我寫更新紀錄」「CI 失敗就通知我並查原因」「Slack #部署 有人提到『回滾』時處理」「週會開始前 10 分鐘把議程整理好」。Bot 會用 create_routine 的 trigger 設定: + +| 種類 | 怎麼知道發生了 | 可以設定 | +|---|---|---| +| GitHub | 用你的 Token 每 60 秒查存放庫事件和 CI 結果(GitHub 要求的最短間隔) | 存放庫;事件:PR 建立/更新/合併/關閉/留言、CI 通過/失敗、審查核准/要求修改/留言、討論串解決/重新開啟、議題被指派/建立、推送;限定使用者、分支、PR | +| Slack | 每 10 秒看頻道(估計值) | 任何訊息、被 @、提到某個字、有人加上某個表情回應;Token 留空時用「訊息管道」的 Slack Token | +| Linear | 每 60 秒查議題(估計值) | 議題建立、狀態變更;限定團隊 | +| Sentry | 每 60 秒查議題 | 建立、指派、解決、封存、變回未解決、有變更;限定專案 | +| PagerDuty | 每 60 秒查事故 | 觸發、確認、解決、有變更;限定服務 | +| 郵件 | 每 60 秒看「郵件」收件匣 | 寄件人包含、主旨包含 | +| Webhook | AnyBot 在這台 Mac 開一個網址 `POST http://127.0.0.1:8792/hooks/<編號>`,要帶 `Authorization: Bearer <密鑰>`(原版的驗證方式) | 每次呼叫跑一次,呼叫的內容會交給 Bot;密鑰錯誤回 401 | +| 行事曆(跟著行事曆) | 每 30 秒看「行事曆」 | 活動開始時或開始前 N 分鐘;限定行事曆、標題;活動被移到別的時間,會在新時間觸發(原版:「無論行事曆將其移到何時」) | +| 群組 | 裡面任何一個觸發就跑 | 至少兩個觸發條件(原版規則) | + +- 觸發時 Bot 會收到「⚡ 例行任務「名稱」被觸發:(發生了什麼)要做的事:…」,像你傳訊息給它一樣開始工作。 +- 第一次連上時只記住目前狀態,不會把以前的事件當成新的。AnyBot 開著時才會檢查。 +- 「例行任務」視窗顯示「⚡ 觸發條件」和連線狀態(例如「已連上 GitHub,等待事件」或錯誤原因)。暫停的不會檢查。 + +## 26. Bot 可以直接操作 App 的功能 + +你可以跟 Bot 說「每天早上 9 點幫我…」「把你換成 Claude」「切成深色模式」,它會自己操作 App。 + +| 功能 | 做什麼 | 先問嗎 | 完成時說 | +|---|---|---|---| +| 「查看例行任務」 | 列出這個 Bot 的例行任務 | 不用 | 每行「#編號 「標題」:排程 · 下次…/已暫停/沒有下次」;沒有時「還沒有例行任務。」 | +| 「建立例行任務」 | 照標題、內容、排程、電腦建立 | 不用 | 「已建立例行任務 #…」 | +| 「修改例行任務」 | 改標題、內容、排程,或暫停、恢復 | 不用 | 「已更新 #…」 | +| 「刪除例行任務」 | 刪除 | **要先問** | 問:「刪除例行任務:「標題」。這會停止它之後的執行,無法復原。」 | +| 「立即執行例行任務」 | 馬上跑一次 | 不用 | 「已開始執行「標題」」 | +| 「查看 Bot」 | 列出所有 Bot | 不用 | 「「名稱」(主要 Bot)· 供應商 · 模型:描述」 | +| 「建立 Bot」 | 建新的 Bot | 不用 | 「已建立 Bot「名稱」(供應商 · 模型)」 | +| 「修改 Bot」 | 改名稱、描述、指示、供應商、模型、釘選、預設電腦 | 不用 | 「已更新「名稱」的…(現在:供應商 · 模型)」 | +| 「傳訊息給其他 Bot」 | 把工作交給另一個 Bot 並叫醒它 | 不用 | 「已交給「名稱」,它的回覆會出現在它自己的聊天裡。」對方收到「📨 來自「發送者」:內容」 | +| 「查看電腦」 | 列出能用的電腦和狀態 | 不用 | 例如「你的電腦:這台 Mac,已允許」 | +| 「修改設定」 | 改主題、強調色、時區 | 不用 | 「已更新:主題 → …」 | + +- **「在這台電腦上執行」只有你能打開,Bot 不能自己打開。** +- **修改 Bot 怎麼找對象**:先找名稱完全一樣的(不分大小寫),找不到再找名稱裡有那幾個字的。只換供應商時,模型會自動改成那家的建議模型。 +- **可能看到的錯誤**: + - 「找不到叫「X」的 Bot」 + - 「沒有「X」這個供應商」 + - 「不能傳訊息給自己」 + - 「「X」不是有效的時區名稱(例如 Asia/Taipei)」 + - 「沒有要改的項目」 + +--- + +## 27. 通知 + +- **什麼時候通知**:AnyBot 視窗不在最前面,或你 90 秒沒碰鍵盤滑鼠。每 10 秒檢查一次你在不在。 +- **Bot 做完時**:標題「(Bot) 完成了」,內文是回覆的第一行(最多 80 字),有 Glass 提示音。 +- **Bot 等你允許時**:標題「(Bot) 需要你允許」,Dock 圖示也會跳動。 +- 通知內文最多 180 字。 +- 可以在「設定 → 一般 → 通知」關掉。 +- 通知只在 macOS 上有。 + +--- + +## 28. 版本、更新與重新啟動 + +- **版本就是程式建好的時間**,顯示在側邊欄最下面和「設定 → 一般 → 版本」。 +- **偵測新版**:每 10 秒檢查一次程式有沒有換新。有新版時,側邊欄出現「⬆ 重新啟動以更新」。 +- **你離開時自動更新**,要同時符合: + - 「在你離開時自動更新用戶端」有開(預設開)。 + - 有新版。 + - 螢幕鎖定或螢幕保護程式開著。 + - 10 分鐘(600 秒)沒動作。 + - 沒有 Bot 在工作,也沒有在等你允許的請求。 +- **檢查時間**:啟動後 30 秒檢查第一次,之後每 1 小時再加 0–300 秒的隨機延遲。這些數字取自原版。 +- **重新啟動**: + 1. 先停掉所有工作和背景指令,再存檔。 + 2. 小幫手每 0.2 秒看一次舊的 App 關了沒,關了才打開新的,不會同時開兩個。 + 3. 新開的 App 一律用一般模式啟動。 +- **中斷修復**:App 關閉時還在回覆的 Bot,下次打開時會改成「(這次的回應被中斷了…)」,還在執行或等待的步驟改成「–」,讓你可以重試,不會看起來一直卡住。 + +--- + +## 29. 管理員鎖定 + +- 公司管理員可以鎖兩個開關: + - 「直接操作」(AllowDirectMode) + - 「在這台電腦上執行」(AllowLocalExecution) +- **設定位置**: + - macOS:系統偏好設定網域 com.anybot.AnyBot,也可以用 MDM 強制。例如 `defaults write com.anybot.AnyBot AllowDirectMode false`。 + - 其他系統:家目錄的 .anybot/policy.json。 +- **值怎麼解讀**: + - 沒設:你可以自由開關。 + - true、1、yes:允許。 + - false、0、no:鎖成關閉。 + - 其他看不懂的值:也鎖成關閉。 +- **被鎖住時**: + - 設定裡顯示「🔒 由你的管理員設定,無法變更」。 + - Bot 要用你的電腦時直接被拒,不會問你。 +- 每次打開設定都會重新讀取。 + +--- + +## 30. 存下來的資料 + +- **存放位置**:這台電腦的應用程式資料資料夾裡的「AnyBot」資料夾,聊天和金鑰都不會上傳。 +- **怎麼寫入**:先寫暫存檔再換名,避免寫到一半壞掉。 +- **打不開時**:檔案壞掉或一個 Bot 都沒有時,改用第一次啟動的預設資料。 +- **整體設定**(括號內是預設值): + - 主要 Bot(1 號)、選中的 Bot + - 主題(淺色)、強調色(黑色) + - 每家供應商的金鑰、網址、預設模型和抓過的模型清單 + - 側邊欄收合與寬度 + - 在這台電腦上執行(關) + - SSH 電腦 + - 自動審核(關)和兩組規則 + - 時區(這台電腦的時區,讀不到就用 Asia/Taipei) + - 通知(開) + - 直接操作(關) + - 自動更新(開) +- **每個 Bot 記錄**: + - 名稱、描述、指示 + - 形狀、顏色 + - 供應商、模型、釘選 + - 訊息、未讀 + - 電腦工具開關(預設關)、工作資料夾、一律允許清單 + - 可讀長度(預設 128,000) + - 例行任務 + - 上一回合的結果 + - 預設電腦(預設「Bot 的電腦」) +- **每則訊息記錄**: + - 誰說的、文字、思考內容 + - 哪個模型寫的、是不是錯誤 + - 工具步驟 + - 是不是摘要、喚醒或插話 + - 截圖不存。 +- **AnyBot 自己的瀏覽器**:登入資料存在同一個資料夾的 browser 和 browser-profiles 裡,使用中的設定檔記在 browser-profile.txt。 + +--- + +## 31. 第一次啟動的 Bot + +| Bot | 形狀、顏色 | 供應商 | 描述 | 備註 | +|---|---|---|---|---| +| 「Bot」 | 圓角方、黑 | xAI Grok | 「你的主要聯絡窗口,什麼都可以問。」 | 主要 Bot | +| 「研究助理」 | 圓球、綠 | Claude | 「幫你查資料、整理重點。」 | 已釘選 | +| 「寫作夥伴」 | 雲、紫 | OpenAI | 「改稿、翻譯、想標題。」 | 已釘選 | +| 「電腦助手」 | 寶石、藍 | Claude | 「幫你整理檔案、跑指令。動手前會先問你。」 | 已釘選,電腦工具預設開 | +| 「本機模型」 | 六角、棕 | Ollama | 「在你自己的電腦上跑,不用連網。」 | | + +每個 Bot 都用那家供應商的建議模型。 + +--- + +## 32. 外觀:頭像、顏色、主題、字型 + +### 頭像 + +- 12 種形狀:圓角方、圓球、膠囊、蛋、六角、寶石、雲、拱門、星星、愛心、盾牌、石頭。 +- 每個頭像都有兩隻斜斜的橢圓眼睛:深色身體配白眼,很淺的身體配黑眼。 + +### Bot 顏色(10 種) + +| 名稱 | 色碼 | +|---|---| +| 黑色 | #1C1C1C | +| 紅 | #C82532 | +| 橘 | #D2550A | +| 黃 | #C2730B | +| 綠 | #0D9655 | +| 青 | #0D9381 | +| 藍 | #126FD4 | +| 紫 | #7442D1 | +| 洋紅 | #C82779 | +| 棕 | #86592E | + +### 主題配色 + +淺色和深色都取自原版:淺色來自原版的樣式表,深色來自原版畫面程式裡的深色色票(每種半透明顏色是「文字色 × 原版指定的透明度」)。 + +| 用途 | 淺色 | 深色 | +|---|---|---| +| 主畫面 | #FFFFFF | #1F1F1F | +| 頂列 | #FCFCFC | #121212 | +| 側邊欄 | #F5F5F5 | #0E0E0E | +| 浮起面板 | #FFFFFF | #161616 | +| 文字 | #1C1C1C | #F5F5F5 | +| 次要文字 | #1C1C1C 約 68% | #F5F5F5 約 66% | +| 第三層文字 | #1C1C1C 約 56% | #F5F5F5 約 54% | +| 你的對話泡泡 | #1C1C1C(白字) | #F5F5F5 約 18%(淺色字,半透明) | +| Bot 對話泡泡 | #1C1C1C 約 6% | #F5F5F5 約 4% | +| 滑過/選中 | 6%/8% | 6%/8% | +| 邊框 | #1C1C1C 約 12% | #F5F5F5 約 10% | +| 焦點框 | #1E86FC | #1884FC | +| 強調色 | #1680F3 | #1884FC | +| 危險/錯誤 | #CD2834 | #FC2C3E | +| 陰影 | 有 | 深色主題沒有陰影(原版設定為 0) | + +### 字型與字級(取自原版樣式) + +- 原版的字級:11、12、13、14、15、17、22、26 px,對應行高 16、16、18、20、22、24、28、32 px。 +- AnyBot 的用法: + - 一般文字和按鈕:14 + - 小字:12 + - 標題和視窗標題:17 + - 程式碼:13,行高 18 + - 聊天內文:15,行高 22 +- 中文和英文都用**蘋方-繁**,同一套字型,所以基準線和大小對得齊。找不到時依序改用冬青黑體、華文黑體等。 +- 程式碼用 **SF Mono**,沒有就用 Menlo,跟原版一樣。 +- 「·」分隔號前後不加空格,因為這套字型的「·」本身已經有間距。 + +### 其他 + +- 圓角:一般視窗 14 px、選單 10 px、按鈕 8 px。 +- 選取文字的底色是焦點色。連結用強調色。 + +--- + +## 33. Bot 收到的系統指示(摘要) + +每次回覆前,Bot 都會先收到這些說明: + +- **身分**:「你是「名稱」。」,加上它的角色描述。主要 Bot 另外知道自己是你的主要窗口,可以把工作交給其他 Bot。 +- **現在的日期、時間、星期和時區。** +- **可以直接操作 App**: + - 建立、修改、暫停、刪除例行任務(時間照你的時區)。 + - 換模型或供應商、建立 Bot。 + - 改主題、強調色、時區。 + - 把工作交給其他 Bot。 + - 做完要用一句話說結果。 +- **上網和瀏覽器**: + - 公開網頁直接讀。 + - 要登入的網站(Threads、Instagram、Facebook、Gmail、銀行…)用 AnyBot 瀏覽器打開再讀;遇到登入頁就請你登入一次。 + - 絕對不要叫你去改瀏覽器的設定或選單,也不要說可以從 Chrome 複製登入。 + - 你要換 profile/設定檔/另一個帳號時,用設定檔工具;想用你平常的 Chrome,就用「你的瀏覽器」。 +- **操作你的 Mac 畫面**:截圖看畫面,再點、打字、按鍵、捲動、拖曳,每一步都再截圖確認。用在檔案和網頁工具做不到的事,例如操作其他 App、在 Chrome 切換設定檔。 +- **不要說「我沒有瀏覽器或工具」**,要先試。需要權限時 App 會自己跳出來問你。 +- **目前的例行任務清單。** +- **能用的電腦**:Bot 的電腦、你的電腦、SSH 電腦,以及預設是哪一台。 +- **工作規則**: + - 你說在哪台電腦做就換過去。 + - 先看清楚再動手。 + - 需要權限時直接用工具,不要叫你自己去改設定。 + - 被拒絕就換個方式或問你。 + - 跑很久的指令要在背景追蹤,不要重跑。 + - 有工具結果證明做完了才能說做完;失敗要照實說。 +- **最後是這個 Bot 自己的「指示」。** + +--- + +## 34. 鍵盤快速鍵 + +| 按鍵 | 作用 | +|---|---| +| ⌘N | 建立 Bot | +| ⌘, | 打開設定 | +| Enter | 送出(Bot 工作中時是補充給它) | +| Shift+Enter | 換行 | + +用輸入法選字時按 Enter,不會誤送出。 + +--- + +## 35. 示範/截圖模式 + +給檢查用的模式,平常不會用到。 + +- 啟動時加上截圖選項:畫面跑 120 格後存成圖片,然後自動關閉。 +- 這個模式下: + - 用示範資料,不會存檔。 + - 不發通知、不跑例行任務、不檢查更新,也不讀管理員鎖定。 +- 示範內容: + - 台南兩天一夜行程的對話(先用 Grok,再換 Claude)。 + - 研究助理的「AI 新聞早報」例行任務(每個工作日 09:00)。 + - 電腦助手正在等你允許整理照片的指令「mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/」,並排入一則補充「2026-06 的照片先不要動」。 +- 可以指定要拍的畫面:聊天、設定(AI 供應商)、Bot 設定、選單、收合的側邊欄、例行任務、設定(電腦)、設定(自動審核)。也可以把彈出視窗藏起來,單獨檢查每個畫面。 + +--- + +## 36. 手機、帳號、雲端主機 + +原版是「大腦在雲端,手機和電腦是裝置」。AnyBot 的做法如下: + +- Bot 的大腦留在 AnyBot 裡,可以放在 Mac 上、一直開著的 Mac mini,或雲端主機。 +- 另外有一個很小的「帳號伺服器」(薄後端),負責帳號、轉送、裝置清單、加密備份和推播。 +- 架設方式見 `relay/README.md`。 + +### 手機網頁版 + +- 打開方式: + - 用帳號伺服器時,打開帳號伺服器的網址,用帳號登入; + - 在同一個網路或 Tailscale 時,打開 `http://這台電腦:8793`,用手機密碼登入。 +- 可以「加入主畫面」,像 App 一樣全螢幕打開。顏色跟系統的淺色、深色走。 +- Bot 清單: + - 每個 Bot 的頭像顏色、最後一句話; + - 工作中顯示狀態; + - 等你允許時顯示橘色的「等你決定」; + - 有新訊息時顯示藍點。 +- 聊天: + - Bot 的回覆用 Markdown 顯示,包含程式碼、清單、連結; + - 工具步驟每步一行,有狀態符號; + - 生活卡片; + - 群組裡每則回覆標出是誰說的。 +- 允許請求:卡片固定在輸入框上方,有「允許一次」「一律允許」「拒絕」三個按鈕,跟電腦版同一套規則。 +- 輸入: + - Bot 工作中再傳訊息算插話; + - 輸入框空白時,送出鍵會變成停止鍵; + - 可以附加照片或檔案,最大 30 MB; + - 右上角「+」開始新對話。 +- 看電腦畫面: + - 「Bot 的電腦」每秒更新一次畫面;在休眠時按「叫醒」。 + - 「這台 Mac」要先在設定裡打開才會出現。 + - 操作方式:點一下是左鍵;長按是右鍵;點兩下是雙擊;「右鍵:開」之後每次點都是右鍵。 + - 「⌨︎ 打字」可以打中文,會打進目前的視窗;還有 Enter、Esc、Tab、⌫ 和捲動按鈕。 +- 選單(右上角「⋯」): + - 開啟通知(只有用帳號登入時才有;iPhone 要先加入主畫面); + - 切換到別台 AnyBot; + - 登出這支手機。 +- 畫面更新:手機問「有沒有新東西」會等最多 20 秒,有變化就立刻回來,所以不用一直刷新。 + +### 設定 › 手機與帳號 + +- **AnyBot 帳號**: + - 填帳號伺服器網址、帳號、密碼,可以順便建立新帳號(有邀請碼時要填)。 + - 登入後,這台電腦會拿到自己的金鑰,之後保持連線。顯示「已連線」或錯誤原因。 + - 電腦不會保存你的密碼,只保存由密碼算出的備份金鑰。 + - 可以設定這台電腦在手機上顯示的名字。 +- **備份**: + - 「每天自動備份到帳號」(預設開),也可以按「立刻備份」。 + - 備份在這台電腦加密後才上傳,伺服器打不開。 + - 「從帳號備份還原」:把這台電腦的 Bot 換成帳號裡最新的備份,這台電腦自己的手機和帳號設定不會被蓋掉。 +- **同一個網路直接連**: + - 先設定手機密碼(至少 8 個字),再打開「讓手機直接連這台電腦」。 + - 會列出所有可以用的網址,包含 Tailscale 的。 + - 換密碼會登出所有手機。密碼錯 5 次要等 1 分鐘。 +- **手機可以做什麼**(下面兩項預設關閉): + - 從手機看這台電腦的螢幕並操作; + - 裝置連結:讓帳號裡其他的 AnyBot 操作這台電腦。 +- **直接連線的裝置**:列出手機和最近使用時間,可以個別登出。 +- **帳號裡的電腦**:列出每台 AnyBot 在線或離線。 + +### 不開視窗執行(`anybot --serve`) + +- Bot、例行任務、事件觸發、訊息管道和手機網頁照常運作,你用手機操作。 +- 可以跑在 Mac 或 Linux。Linux 版已在 Docker 裡編譯並測試過。 +- 環境變數: + - `ANYBOT_PHONE_PASSWORD`:設定手機密碼並打開直接連線; + - `ANYBOT_ACCOUNT=網址|帳號` 加上 `ANYBOT_ACCOUNT_PASSWORD`:加入帳號,失敗時每 30 秒重試; + - `ANYBOT_HOST_NAME`:在手機上顯示的名字; + - `ANYBOT_RESTORE_FROM_ACCOUNT=1`:第一次啟動時從帳號備份接手; + - `ANYBOT_DATA_DIR`:資料放哪裡; + - `XAI_API_KEY`、`ANTHROPIC_API_KEY` 等:AI 金鑰。 +- 在伺服器上,Bot 的系統指示會說明「你的電腦」指的是伺服器本身。要用你的 Mac,就選帳號裡那台電腦的名字。 +- 沒有視窗,所以需要你允許的事會推播到手機。 +- 部署檔: + - 雲端:`deploy/server/`(帳號伺服器、Caddy、AnyBot 三合一); + - Mac mini:`deploy/mac/com.anybot.serve.plist`。 + +### 裝置連結(一台 AnyBot 操作另一台) + +- 同一個帳號的另一台 AnyBot 打開「裝置連結」後,會出現在這台 Bot 的電腦清單裡,名字就是那台電腦的名字。 +- 可以用的工具:檔案、指令、背景指令、九個螢幕工具,以及 Mac App 工具。 +- 權限: + - 看螢幕和操作螢幕,第一次用時各問一次(可以一律允許); + - 指令和改檔案的規則跟 SSH 電腦一樣。 + - 詢問會出現在發出動作的那台 AnyBot,也就是手機或那台電腦的視窗。原版是在被操作的那台裝置上詢問。 +- 經過帳號伺服器,最多等 35 秒(原版的裝置工具等 30 秒)。 + +### 團隊(分享給其他帳號) + +- 在「設定 › 手機與帳號 › 分享給團隊」輸入對方在帳號伺服器上的帳號,選擇成員或管理員,按「分享」。 +- 對方用自己的帳號登入手機網頁版後,會看到「{電腦名稱}({你的帳號} 的)」。 +- 成員可以做的事: + - 看 Bot 清單和聊天; + - 傳訊息、附加檔案。 +- 成員的限制: + - 看不到電腦畫面的按鈕; + - 不能開始新對話; + - 不能停止; + - 允許請求只會顯示「等擁有者或管理員決定」。 +- 管理員可以做的事:除了裝置連結以外,和擁有者一樣。 +- 成員傳的訊息前面會加上「【帳號】」,Bot 知道是誰在說話。 +- 需要允許時,只通知擁有者和管理員。 +- 權限同時由帳號伺服器和 AnyBot 兩邊檢查:AnyBot 依帳號伺服器告訴它的角色,自己再拒絕一次。 + +### 發布網站 + +- 有登入帳號時,Bot 多一個 `publish_site` 工具,可以把一個 .html 檔,或一個有 index.html 的資料夾,發布成公開網站。 + - 網址是 `帳號伺服器/s/{帳號}-{名稱}/`。 + - 用同一個名稱再發布會取代舊的。 +- 檔案可以在 Bot 的電腦、你的電腦,或 Bot 自己的資料夾。 +- 限制:最多 500 個檔案、25 MB;會略過 `.` 開頭的檔案和 node_modules。 +- 每個網站名稱第一次發布時會問你:「把 {路徑} 發布成公開網站「{名稱}」(任何知道網址的人都看得到)」,可以選一律允許。 +- 安全:發布的網頁在沙盒裡執行(`Content-Security-Policy: sandbox`),讀不到同一個網址下手機版的登入資料。 +- 帳號伺服器設了 `ANYBOT_RELAY_PUBLIC_URL` 時,回給 Bot 的是公開網址。 + +### 通知 + +以下情況會推播到所有打開通知的手機: + +- Bot 需要你允許時; +- 你不在時 Bot 做完事。 + +「不在」的意思是:沒開視窗、視窗不在最前面,或 90 秒沒動作。用 `anybot --serve` 時一律推播。 + +推播內容只有手機解得開(Web Push 加密),點通知會直接打開那個 Bot 的聊天。 + +### 驗證過的 + +- 單元測試:手機 API 的登入和權限、帳號伺服器的註冊/登入/轉送/備份、推播加密(用手機那一端的方法解開)和簽章、密碼和加密。 +- 真實測試,手機用 curl 和 Chrome(手機尺寸)模擬: + - 透過帳號伺服器傳訊息,Bot 要在電腦上執行指令時,手機核准兩次後完成,結果回到手機; + - 自動備份上傳; + - 兩台不開視窗的 AnyBot 用裝置連結,一台叫另一台執行指令並拿回結果; + - 直接連線:密碼錯被拒、看 Bot 的電腦畫面(1280×800 JPEG)、上傳附件、有變化 2 秒內通知; + - Linux 版在 Docker 裡跟帳號伺服器放在同一個 Docker 網路:帳號建立後自動加入,手機看得到 Bot 和 Bot 的電腦畫面。 +- 截圖:`shots/phone.png`、`shots/phone-account.png`、`shots/remote.png`。 +- 還沒在真的 iPhone 上試過推播通知(需要你的網域和 HTTPS)。 + +--- + +### 連接器市集(設定 › 連接器) + +- 8 個常用連接器,按「加入」就能用: + - files:讀寫「文件」資料夾; + - memory:知識圖記憶; + - thinking:循序思考; + - fetch:抓網頁; + - git; + - time; + - playwright:瀏覽器自動化; + - github:官方遠端伺服器。 +- 需要金鑰的(github)會把下面的「新增連接器」表單填好,補上 Token 再按「新增」。 +- 本機的連接器要先安裝 Node.js(npx)或 uv(uvx)。 +- 驗證過的:不用金鑰的 7 個都實際下載、透過 AnyBot 的連接器程式連上。 + - 工具數:files 14、memory 9、thinking 1、fetch 1、git 12、time 2、playwright 25。 + - 用 time 實際查了台北時間。 + - 測試是 `cargo test --release -- --ignored marketplace`。 + - github 要你的 Token,沒有測。 + +### 專屬號碼、打電話、X(設定 › 訊息管道) + +- **簡訊(Twilio)**: + - 填「Account SID:Auth Token」和你的 Twilio 號碼。 + - 傳到這個號碼的簡訊會交給 Bot,Bot 從同一個號碼回覆。 + - 每 3 秒查一次,不需要公開網址;開始之前收到的簡訊不會回。 +- **打電話、傳簡訊**:設好 Twilio 後,Bot 多出兩個工具: + - `place_call`:用你的號碼撥出,以中文語音唸兩次訊息; + - `send_sms`。 +- **X**: + - 填使用者存取權杖。 + - Bot 會每分鐘查一次提及你的貼文,並用回覆的方式回答,長的會接成串。 + - Bot 也可以用 `post_to_x` 發文。 +- 打電話、傳簡訊、發文每次都會問你,不能設成一律允許,直接操作模式也一樣: + - 「打電話給 {號碼},唸:「{內容}」」 + - 「傳簡訊給…」 + - 「用你的帳號在 X 發文…」 +- 驗證過的: + - 用假的 Twilio 和 X 伺服器檢查要求的格式:Basic 驗證、表單編碼、TwiML 跳脫、回覆的貼文編號。 + - 實際跑過一次完整流程:有人傳簡訊進來,Bot 用簡訊回覆;手機核准後 Bot 打出電話。 +- 沒有用真的 Twilio 或 X 帳號試過:需要你申請,而且是付費的。 + +--- + +## 37. 字型、語言、設定分類、右側面板 + +### 字型 + +- 預設用 **粉圓體**(jf open 粉圓 2.1,SIL OFL 1.1 授權,檔案和授權在 `fonts/`),直接包進 App。 +- 粉圓體沒有的字,改用系統字型(蘋方)補上。 +- 設定 › 一般 › 字型可以換回「系統字型」,馬上生效。 + +### 語言(跟原版一樣:「選擇 Grok Bot 選單和訊息使用的語言」) + +- 可選「自動偵測」(跟 Mac 的語言),或原版同一份 37 種語言清單(原版程式裡的語言表)。 +- **選單**: + - 繁體中文:原文。 + - 简体中文:全部由 macOS 內建轉換產生。 + - English、日本語:翻譯了約 370 個常見字串(側邊欄、聊天頂部、輸入框、右側面板、整個設定頁);較少見的訊息仍是中文。 + - 其他語言:選單用英文。 +- **Bot 回答**:在指示最後加上「一律用 {語言} 回答」,37 種都適用。 + +### 設定的分類(照原版) + +- 原版共 7 個分頁:一般、整合、電腦、權限、保險庫、用量與帳單、更新。 +- AnyBot 用「模型」(你自己的 AI 金鑰)取代「用量與帳單」,每個分頁都有圖示: + - **一般**: + - 帳戶(手機、帳號、團隊、備份,按「管理」進去) + - 外觀(主題、強調色、字型、語言) + - 通知 + - Bot(時區) + - 系統(資料位置) + - **模型**:AI 供應商和金鑰。 + - **整合**:訊息管道、連接器、連接器市集、技能。 + - **電腦**: + - Bot 的電腦 + - 在這台電腦上執行 + - macOS 權限 + - 示範教學 + - 其他電腦(SSH) + - 瀏覽器設定檔 + - **權限**:直接操作、自動審核、「無需詢問直接允許」和「一律詢問」規則。 + - **保險庫**:登入資料。 + - **更新**:版本、自動更新、重新啟動。 +- 每一項設定都是同一種排法:左邊是名稱加一行說明,右邊是開關或按鈕。 + +### 聊天頂部和右側面板(照原版實際畫面) + +這部分是打開原版 Grok Bot,用 Chrome 偵錯介面截圖並量了尺寸,照著做的(比對圖:`shots/panel-vs-original.png`)。 + +- **頂部**:沒有標題列。 + - 中間:一顆圓角膠囊,裡面是 Bot 頭像和名字,寬度跟名字一樣,高 40。點它打開或關閉「對話詳細資訊」。 + - 右邊: + - 「分享」膠囊按鈕,36 高、1 px 淡邊框(匯出 Bot 檔案); + - 「⋯」圓鈕(Bot 選單); + - 有請求時多一顆「● 需要你允許」。 +- **輸入框**:右邊有「發起語音聊天」圓鈕,用 Bot 的顏色加聲波圖示,通話中變成紅色停止鈕。 +- **對話詳細資訊**(右側面板,寬 280): + - 從上到下: + - 右上角 36 px 圓形 ✕; + - 96 px Bot 頭像(點了編輯 Bot)和名字; + - 兩個小分頁「動態|資料庫」。 + - 卡片樣式:圓角 12、1 px 淡邊框、內距 4。每列 36 高:圖示 + 文字 + 「›」。分區標題 13 px,淡色。 + - **動態**: + - 進行中:只有工作中或等你允許時才出現,可以停止或打開請求。 + - 建議: + - 新增至 Slack:打開 設定 › 整合; + - 建立例行工作:在輸入框預填「幫我建立一個例行任務:」; + - 加入會議:開始或結束錄會議。 + - 例行任務:有的時候才出現,點了打開管理視窗。 + - 電腦: + - 「Bot 的電腦」和狀態(執行中/休眠中),右邊是**它螢幕的即時縮圖**(`shots/computer-card.png`)。 + - 執行中每 3 秒更新縮圖,綠點表示開著。拍縮圖不會把電腦叫醒。 + - 休眠時顯示最後一張畫面(變暗),每 30 秒檢查一次。 + - 點了打開「Bot 的電腦」視窗,看完整畫面,也可以取得控制權。休眠中會先叫醒。 + - 成員:只有群組才有。 + - 語音聊天: + - 語音:自動,或這台 Mac 上任何一個聲音,選了會試唸一句; + - 速度:0.75x–2x; + - 語言:自動偵測、中文、English 等,用來轉文字; + - 朗讀每個回答。 + - 語音、速度、語言每個 Bot 各自記住,通話和朗讀都會用。 + - **資料庫**: + - 列出這個對話的檔案,包括附件和 Bot 打開給你看的檔案,依時間分成「本週」「更早」。 + - 每組先列 5 個,可以「展開」。 + - 圖示依種類:試算表、影片、圖片、文件。 + - 名字太長時保留副檔名,例如「orders_….xlsx」。 + - 點了打開檔案。 + +### 對話內容(照原版實際畫面) + +比對圖:`shots/chat-vs-original.png`。 + +- **Bot 的回答**: + - 灰色泡泡(深色 #212121、淺色 #F1F1F1),靠左,文字由左往右排。 + - 沒有頭像、沒有模型名稱,也不列出工具步驟。每次回答是一個泡泡。 +- **你的訊息**:Bot 顏色的泡泡,靠右,寬度跟著字數;換行時文字一樣由左往右。 +- **日期**:兩則訊息隔 30 分鐘以上或跨天時,中間顯示「11月2日週一 上午9:50」,今天只顯示時間。 +- **滑過訊息**:泡泡旁出現三個小鈕。 + - Bot 的訊息:表情回應(6 個常用加 24 個更多)、回覆(引用到下一則)、⋯。 + - ⋯ 裡有:複製、重新產生、顯示步驟(N)、模型名稱。 + - 「顯示步驟」會展開這次回答的思考過程和每個工具步驟,滑過步驟會顯示結果。 + - 你的訊息:回覆和 ⋯(複製)。 +- **AI 也會按表情**:Bot 有 `react` 工具,可以在你最新的訊息按 ❤️、👍、😂、🎉、👀 等,顯示在你的泡泡下面。 +- **工作中**: + - 對話最後是一個不停轉的圓環,加上一句有光掃過的狀態,文字取自原版的清單: + - 讀檔:「正在讀取檔案」 + - 跑指令:「正在執行幾個指令」 + - 操作 Bot 的電腦:「正在四處點擊」 + - 操作你的 Mac:「正在你的電腦上工作」 + - 瀏覽網頁:「正在逐頁瀏覽」 + - 等你:「需要你的輸入」 + - 思考時:「正在仔細思考/正在琢磨/正在整理思緒」 + - 其他:「正在推進」等 + - 原版的動畫時間: + - 圓環每 1.2 秒轉一圈; + - 文字上的光每 1.4 秒掃過一次; + - 新訊息 240 ms 淡入並往上浮; + - 表情回應 500 ms 彈出,帶一點回彈。 +- **檔案**: + - Bot 用 `send_file` 把做好的檔案傳進聊天,你附加的檔案也一樣,顯示成檔案卡片:圖示依種類(試算表綠色、Markdown 紫色、網頁橘色、PDF 紅色、圖片、影片)、檔名(太長時保留副檔名)、大小。 + - 點卡片打開預覽(`shots/file-preview.png`): + - 上面是檔名和「22 列」,右上角有「用其他 App 打開」、下載、關閉。 + - Excel(xlsx/xls/ods)和 CSV:表格,有列號和標題列,欄寬跟內容走,太長的格子滑過看全文,多個工作表可以切換。 + - Markdown:排好版顯示。 + - 網頁:「在瀏覽器開啟」,加上網頁的文字。 + - 圖片:直接顯示。 + - PDF、Word:顯示文字。 + - 程式碼、文字:等寬字。 +- 卡片(天氣、機票等)照舊顯示在回答下面。 +- 深色主題改成原版量到的顏色:聊天背景 #131313、側欄 #101010、卡片 #1F1F1F。 + +### 左側欄、輸入框、頭像動畫、啟動動畫(照原版) + +比對圖:`shots/app-vs-original.png`。 + +- **左側欄**: + - 右上兩顆 36 px 圓鈕:搜尋(點了才出現搜尋框)和新 Bot(右鍵可以從範本或建群組)。 + - 主要 Bot 單獨在最上面:大頭像、右下角橘色★、名字。 + - 其他 Bot 照**群組**分: + - + › 新增群組,建立後馬上可以打名字。 + - 把 Bot **拖**到別的群組(拖動時目標群組亮框,`shots/sidebar-drag.png`),或用 Bot 選單 › 移到群組。 + - 群組標題按右鍵可以重新命名、刪除群組(裡面的 Bot 移到未分區);雙擊標題也能改名。 + - 空的群組會留著,顯示「把 Bot 拖進來」;沒分群組的 Bot 在「未分區」。 + - 每列 54 px、圓角 12、40 px 頭像、名字 15 px、最後一句 13 px;未讀是藍點,等你允許是強調色的點。 + - 最下面是你(縮寫頭像和名字,點了打開設定),右邊是連接器市集(打開 設定 › 整合,直接捲到市集)。這一列固定在側欄底部,不會被切到。 + - + 選單:新增 Bot、新增群組、群組聊天、從範本建立。 + - 驗證過的:用模擬點擊確認帳號列、市集鈕、+ 選單、群組右鍵都有反應(`shots/sidebar-clicks.png`),也模擬了拖拉。 +- **輸入框**: + - 一條 44 px 的膠囊:+、文字、麥克風,最右邊是 Bot 顏色的圓鈕。空白時是語音聊天,有字時變成 ↑ 送出,工作中而且沒字時是停止。 + - + 選單:附加檔案、教它一項任務(直接開始錄示範),以及 AnyBot 的模型選擇。 + - 下面不再有提示文字。 +- **選單**(照原版,`shots/menu-vs-original.png`): + - 寬 200、圓角 16、每列 32 高、左邊 16 px 圖示、14 px 字。 + - 分組之間有細線,「刪除」是紅字加垃圾桶,有下一層的列右邊有「›」。 + - Bot 選單:釘選、移至 ›、標示為未讀、設為主要|編輯、開始新對話、過去的對話 ›、錄會議|分享與匯出 ›(匯出 Bot、匯出文字記錄、匯入、複製 ID)|重新啟動、清除、刪除。 + - 群組選單:重新命名、上移、下移|刪除。 + - 訊息 ⋯:複製、回覆、重新產生、顯示步驟。 + - + 選單和模型選擇也是同一種樣式。 +- **在對話裡叫 Bot 分群組**:Bot 有 create_group、move_to_group、rename_group、delete_group 工具。例如「建立文宣組,把文案企劃、網研探子、美術設計放進去」。 +- **頭像動畫**(照原版頭像引擎的參數): + - 側欄、頂部名字膠囊、右側面板的頭像,都會轉向滑鼠,平滑度和原版一樣(每 1/60 秒追 14%)。 + - 每 2.5–6.5 秒眨一次眼(0.18 秒)。 + - 點一下 Bot 會彈一下(0.62 秒,先壓扁再拉長)。 + - 每個狀態用眼睛表達:平常跟著滑鼠;工作中眼睛慢慢左右掃,像在看東西;等你允許時抬頭看你;做完點個頭(0.5 秒)。不再轉圈、不再有繞圈的小點,所以不會閃。 + - 頭像只佔格子的 84%,四周留一點空間。 +- **啟動動畫**(`shots/launch-animation.png`):打開 AnyBot 時,主要 Bot 的頭像在畫面中間長大、往左右看一眼、彈一下,名字淡入;1.6 秒後整個淡出到主畫面。動畫進行中點擊不會誤觸。 +- **滑過訊息的選單**:表情、⋯ 打開後,即使滑鼠移到選單上,按鈕也不會消失,選單點得到了。 + +### 視窗大小 + +- 拉動視窗時,內容會跟著調整: + - 聊天欄寬度跟著視窗變化。 + - 頂部的 Bot 名稱和描述太長時用「…」截掉,按鈕不會被擠掉。 + - 右側面板在空間夠時並排顯示(寬 280–340 px)。不夠時改成浮在聊天上面,聊天欄不會被擠到看不見。 + - 設定和其他對話框不會比視窗寬,左邊的分頁清單也會跟著變窄。 + - 一排放不下的選項(例如新增管道的種類)會自動換行。 +- 驗證過的:在 680×520(最小)、1000×680、1600×1000 截圖檢查。截圖在 `shots/resize.png`、`shots/narrow.png`、`shots/languages.png`、`shots/panel.png`、`shots/settings.png`。 + +--- + +## 37a. 圖示、群組、檔案預覽、跨平台 + +**圖示**:全部改用 Lucide 圖示(ISC 授權,`icons/lucide/*.svg`,用 resvg 畫成貼圖)。介面和手機網頁版都不再用 emoji 當圖示;只有使用者自己按的表情反應保留 emoji。 + +**群組**:左側欄群組可以收合(點標題),可以從「⋯」或右鍵選「重新命名 / 上移 / 下移 / 刪除」。Bot 在聊天裡也能用工具 create_group、move_to_group、rename_group、delete_group 管理群組。 + +**檔案預覽**:和原版一樣分成 11 類(依原版 main-core.cjs 的副檔名表 zc())。 + +| 類型 | 副檔名 | AnyBot 怎麼顯示 | +|---|---|---| +| 圖片 | png jpg gif webp bmp tiff ico svg(avif heic:macOS 用 sips) | 直接顯示 | +| PDF | pdf | hayro 把每一頁畫出來(最多 300 頁);Bot 讀文字用 pdftotext,沒有就用 pdf-extract | +| 表格 | csv tsv xlsx xls xlsm xlsb ods | 有列號的表格、多個工作表 | +| Word | docx | 直接讀 XML 轉成標題、清單、表格、粗體 | +| Markdown | md markdown mdx | 標題、清單、表格、引言、程式碼 | +| 網頁 | html htm | 轉成有結構的內容,加「在瀏覽器開啟」 | +| JSON | json | 排整齊的文字 | +| 文字和程式碼 | 原版那份 80 多種副檔名的清單,再加上 Dockerfile 等 | 等寬字 | +| 音訊 | mp3 m4a aac wav flac ogg aiff caf(opus:先用 ffmpeg 轉檔) | 在 AnyBot 裡播放、暫停、拖進度 | +| 影片 | mp4 mov m4v webm ogv mkv avi | 第一格畫面(用 ffmpeg 擷取)+播放鍵,用電腦的播放器播 | +| 其他 | | 「用其他 App 打開」 | + +用 `ANYBOT_FIXTURES=<資料夾> cargo test --release fixtures -- --ignored --nocapture` 可以一次打開資料夾裡每個檔案,檢查有沒有哪個打不開。截圖模式可以用 `ANYBOT_SHOT_VIEW=fileview ANYBOT_SHOT_FILE=<檔案>` 預覽任何檔案。 + +**跨平台**:AnyBot 現在在 macOS 和 Linux 上都能完整使用,之後還要支援 Windows。 +- 檔案預覽全部用純 Rust 寫,三個平台都能用:hayro、rodio、image、resvg、zip、pdf-extract。 +- 打開檔案或網址統一用 `preview::system_open`;「在 Finder 中顯示」統一用 `preview::reveal`。 +- 介面和 Bot 說明裡的「這台 Mac」「this Mac」,在 Linux 上會統一換成「這台電腦」(`platform::localize`)。 + +| 功能 | macOS | Linux | +|---|---|---| +| 看螢幕、點擊、打字、捲動、拖曳(computer_use) | CoreGraphics | 三種桌面自動判斷(`desk.rs`):
• X11(x11rb:GetImage 截圖、XTEST 模擬輸入;中文是暫時把字放到一個空按鍵上打出來)
• Wayland wlroots 系(sway、Hyprland…,`wl.rs`):wlr-screencopy 截圖、虛擬滑鼠、虛擬鍵盤(自帶鍵盤配置,中文直接打);不用另外允許
• Wayland GNOME / KDE(`portal.rs`):遠端桌面 portal 操作、截圖 portal 看畫面。第一次會跳出「遠端桌面」視窗,要打開「允許遠端操作」再按「分享」;GNOME 另會問一次「允許截圖」。portal 第 2 版以上會記住答案。中文經剪貼簿(wl-copy)+ Ctrl+V 貼上 | +| 錄製示範 | CGEventTap | X11 RECORD(Wayland 不讓 App 看別的 App 的輸入,所以只有 Xorg 工作階段能錄) | +| 語音輸入、通話 | AVAudioRecorder | cpal 錄成 WAV(自己判斷有沒有在說話) | +| 唸出回答 | say | espeak-ng | +| 登入資料保險庫 | 鑰匙圈(security) | 系統鑰匙圈(secret-tool:GNOME Keyring / KWallet) | +| 通知 | display notification | notify-send | +| 閒置、鎖定、螢幕保護 | ioreg、pgrep | X11 螢幕保護擴充;Wayland 用 Mutter IdleMonitor(GNOME)或 ext-idle-notify;loginctl | +| 選檔案、存檔視窗 | Finder 對話框 | zenity / kdialog | +| 貼上剪貼簿圖片 | NSPasteboard | xclip / wl-paste | +| 簡體中文介面 | CFStringTransform | zhconv | +| 系統語言、電腦名稱、網路位址 | defaults、scutil、ifconfig | LANG、hostnamectl、ip | +| AnyBot 自己的瀏覽器 | /Applications 裡的 Chrome 等 | /usr/bin、/opt、Snap、Flatpak 裡的 Chrome、Chromium、Brave、Edge | +| Bot 的電腦(Docker) | OrbStack / Docker Desktop | Docker Engine;代理伺服器只接受本機和 Docker 網路的連線 | + +**Linux 還不能做的**: +- **Wayland 上錄製示範**:系統不允許,要錄請改選 Xorg 工作階段。 +- **Wayland 上「哪個視窗在滑鼠下」**:Wayland 不告訴 App 其他視窗的位置,所以權限提示會寫「目前的 App」。 +- **讀你自己瀏覽器的分頁**:Mac 上是透過輔助使用讀的,Linux 改用 AnyBot 自己的瀏覽器設定檔。 +- **Mac App 工具**:郵件、行事曆、備忘錄、訊息,只有 Mac 有。 +- **在背景點擊、不把視窗拉到前面**:Linux 上會改成一般點擊。 + +**安裝和測試**: +- `deploy/linux/install.sh`:安裝到 ~/.local,包括選單項目和圖示。加上 `--deps` 會順便安裝系統套件,支援 apt、dnf、pacman。 +- `deploy/linux/dev/test.sh`:在 Mac 上用 Docker 裡的 Linux 和虛擬螢幕測試。會跑: + - 全部單元測試; + - 實際操作畫面和錄製示範; + - 鑰匙圈存、讀、刪; + - Wayland(無頭 sway):截圖、點擊、中文打字、快捷鍵、捲動(`wayland_live.sh`); + - 視窗截圖。 +- `deploy/linux/dev/gnome/test.sh`:無頭 GNOME 43(Wayland)測 portal 路線:沒開「允許遠端操作」時的提示、英文+中文打進文字框、精準點到小按鈕。測試腳本 `op.py` 扮演使用者回答系統視窗。 +- 測試用的指令: + - `anybot --tool <工具> …`:直接跑操作電腦的工具; + - `anybot --write-icon out.png 256`:輸出 App 圖示。 + +## 38. 還沒做的功能 + +| 功能 | 為什麼 | +|---|---| +| 原版伺服器端的提示詞和模型路由 | 不在 App 裡,拆不到;AnyBot 用自己的系統指示(第 33 節) | +| 邊說邊插話的即時語音 | 原版用 xAI 即時語音服務;AnyBot 的通話模式是輪流說話 | +| 帳單、方案 | 不需要:AnyBot 用你自己的 AI 金鑰 | +| 代你購物與付款 | 刻意不做:會動到你的錢和卡片 | +| 即時同步(同一個帳號的兩台電腦同時改同一個 Bot) | 帳號只做每日加密備份和還原;要 24 小時運作,請把 AnyBot 放在一直開著的主機(第 36 節) | +| 原生 iPhone/Android App | 用手機網頁版,可加到主畫面、收推播通知 | + + +## 側邊欄:釘選與分區(照原版重做,2026-11-04) + +- 釘選的 Bot 放在最上面的大頭像格子裡(主要 Bot 第一個,有星星)。右鍵「釘選/取消釘選」,或把 Bot 拖進格子(第一次拖時出現「拖曳到此處以釘選」);格子內可拖曳排序;拖到分區會取消釘選並移過去。 +- 分區:Bot 右鍵「移至 ▸」列出所有分區(含未分區,✓ 目前的)和「建立分區」;還沒有分區時是「移至新分區」。新分區放最上面,名字「新增分區」並直接進入改名(Enter/點別處儲存,Esc 取消)。 +- 分區標題右鍵(或 ⋯):重新命名、上移、下移、刪除;未分區只有上移/下移。刪除會先確認「其中的 Bot 會移至 未分區,不會刪除任何 Bot。」只有一個清單時,「Bot」標題右鍵可「建立分區」。 + +## 對話的 Markdown 與流程圖(2026-11-04) + +- 以 pulldown-cmark 解析(表格、刪除線、待辦清單、數學區塊):標題 22/17 px、單一換行照樣換行、粗體(粉圓體只有一種粗細,用疊印做出粗體)、斜體、刪除線、行內程式碼、可點的連結、多層清單、待辦勾選框、引用線、表格(可橫向捲動)、分隔線。 +- 程式碼區塊:圓角邊框、上色(註解、字串、數字、關鍵字)、可橫向捲動、滑過時右上角「複製程式碼」,複製後兩秒顯示勾。 +- ```mermaid:用純 Rust 的 mermaid-rs-renderer 畫成圖(流程圖、時序圖、類別圖、狀態圖、ER、甘特圖、圓餅圖、心智圖、時間軸…),中文用粉圓體,跟著深淺色主題。回覆還在產生時先顯示原始碼,結束後才畫。點圖可放大檢視(縮放、拖曳、下載 PNG 到「下載項目」)。畫不出來時顯示「無法顯示此圖表」並保留原始碼。 + +## 其他(2026-11-04) + +- **OpenCode Go 供應商**:設定 → 模型 選「OpenCode Go」,貼上 opencode.ai Console 的 Go 金鑰。依官方模型清單自動決定每個模型用 OpenAI 格式(/openai/v1)或 Anthropic 格式(/anthropic/v1);每個 Bot 的對話帶固定的 x-opencode-session。 +- **看不到圖片的模型**:模型回「不能收圖片」(例如「At most 0 image(s)」)時,AnyBot 記住這個模型,立刻改成不附圖片重試,截圖的位置換成文字說明,Bot 不會再卡住。 diff --git a/GROK_BOT_FEATURES.md b/GROK_BOT_FEATURES.md new file mode 100644 index 0000000..7a25e50 --- /dev/null +++ b/GROK_BOT_FEATURES.md @@ -0,0 +1,228 @@ +# Grok Bot(內部代號 `sand`)功能分析 + +**分析目標**:`/Applications/Grok Bot.app`(active target `t_a7c7c1454fc1`) +**asar**:`/Applications/Grok Bot.app/Contents/Resources/app.asar`(1027 entries,header 243890 B,integrity verified) +**SHA-256**:`a7c7c1454fc1a9169eca436fe6cf6fdf363e2c359da7312c49c22945170e36fd` +**外殼**:Electron 40.1.0,`package.json` name = `"sand"`,`type: module`,main = `dist/electron-main/main.cjs` +**方法**:純靜態(asar 目錄清單、字串/符號掃描、內建反編譯器、`js_app_map` 應用圖)。**未執行目標程式**,因此本文所有結論都是「程式碼/資源中存在」的證據,不是「執行時發生了」的證據。asar 內無 source map。 + +**標記** +- **觀察**:直接讀到(檔案、符號名、字串位址、設定值)。 +- **推論(derived)**:由觀察算出/歸納出。 +- **推論(inferred)**:機制作最佳解釋,未直接讀到。 +- **推測(estimated)**:best guess,證據薄弱。 + +**引用規則**:每條斷言引 `fnd_*`(已入庫 finding,綁定原始 evidence)。本輪**未取到新的 evidence id**,所以不列 `ev_*`。 + +--- + +## 1. 摘要 + +| # | 結論 | 標記 | 依據 | +|---|---|---|---| +| 1 | Grok Bot 是 Anysphere(Cursor)私有 monorepo 建置出來的 Electron 應用,約 50 個 `@anysphere/*` + `@sand/*` workspace package,包含 `@anysphere/grok-bot`、`agent-*`、`chat-inference`、`mcp-core`、`xai-commerce-prepaid` | 觀察 | `fnd_1` | +| 2 | **文字代理迴圈不在桌面端**。它是一個連到遠端 gateway / "box" 的傳輸客戶端;模型呼叫、prompt 組裝、工具編排都在伺服器 | 推論(derived) | `fnd_5`, `fnd_6` | +| 3 | 桌面端真正的「執行手」是獨立 Node 程序 `dist/local-exec-daemon/main.cjs`:由遠端代理反向驱动的本地工具執行器(搜尋/讀檔/shell/電腦操作/MCP/macOS 內建 App) | 推論(derived) | `fnd_7` | +| 4 | 用戶端有一整套分層權限:讀/寫/shell/MCP 四條 `shouldBlock*` 閘門 + allowlist / denylist / glob + 逐 app 同意 + 持久「always allow」授權 | 推論(derived) | `fnd_8`, `fnd_12` | +| 5 | 用戶端**沒有 token 計數器**;token 記帳與 context 壓縮摘要都來自伺服器串流事件。唯一在客戶端的 token 估計是字元數除以 4(語音逐字稿) | 推論(derived) | `fnd_9` + 本報告 §10 | +| 6 | 唯一在客戶端組裝 prompt 的場景是**語音通話 harness**(`wss://api.x.ai/` 是原檔字面字串):語音模型被設計成薄前端,把工作派給主代理,且不得宣稱任務已完成 | 觀察 | `fnd_10` | +| 7 | 後端是 Anysphere 自有服務(`api2.cursor.sh`,connect-rpc + protobuf;遥測 `api3.cursor.sh`;登入/計費在 cursor.com),**不是**公開 LLM API;金鑰用 Electron `safeStorage` | 觀察 | `fnd_3` | +| 8 | 電腦操作(滑鼠拖曳、鍵盤輸入)的實際輸入合成落在一個原生 macOS helper `Contents/Helpers/CUGrokBotService.app`(Swift module `CUCore`),每次 drag 需自帶一個 permit | 推論(inferred) | `fnd_14` | +| 9 | 模型清單由伺服器提供;程式碼中出現的模型名是 `grok-4.5`(預設壓縮模型)、`grok-4.6`(model picker 實驗) | 觀察 | `fnd_9` + `unk_dd4ee2d6bc4685b5`(已解) | + +--- + +## 2. 程序架構 + +**觀察**(`fnd_2`): +- 主程序拆成兩段:`dist/electron-main/main-core.cjs` 與 `dist/electron-main/main-app.cjs`。 +- preload 有兩支:`preload.cjs`、`preload-avatar-source.cjs`。 +- 兩個獨立 Node 程序: + - `dist/local-exec-daemon/main.cjs` — 沙箱化本地命令執行 + 檔案系統 allow-list(見 §4)。 + - `dist/node-agent-coordinator/main.cjs` — 代理協調器傳輸客戶端(見 §3)。 +- Renderer 與 main 透過 **MessagePort** 連通:main `handle("sand:coordinator-port-request")` → 回傳 `"sand:coordinator-port"`;另有 boot-gate / renderer-alive / network-online 信號。 +- 共 8 處 window 構造,多數 `contextIsolation` + `sandbox`,其中 2 處開了 `webviewTag`。 +- 自訂協議:`sand-mcp-app://`、`sand-visualization://`、`sand-media://`、`sand-html-preview://`、`sand-avatar://`、`sand-favicon://`。 + +**推論(derived)**:這種「main 只當殼 + 兩個独立 Node 程序做重活」的切法,是把「長生命週期的代理/session 狀態」從 Electron 生命週期裡拆出來,避免 renderer 重載或 window 關閉中斷 session。 + +--- + +## 3. 代理迴圈與後端 + +**觀察**(`fnd_5`)— `dist/node-agent-coordinator/main.cjs` 是通往遠端 gateway / box 的傳輸客戶端,符號包括: +`createAgent` / `createAgentWithRetry`、`sendPrompt`(`POST {base}/api/sendPrompt`)、`streamEvents` / `runEventLoop`(`GET {base}/events`)、`postEcho`、`listAgents`、`openAgentTail` / `getAgentTranscriptTail`、`transcribeAudio`、`ensureForeverBox` / `noteBoxAsleep` / `requestBoxIntent`,以及一條 **WebAuthn 同意通道**(`/webauthn/requests`、`/webauthn/responses`、`requestConsent`、`onPinRequest`)。 + +它向 UI 發射的主題:automations、workflows、subagents、skills、secrets、tasks、meetings、library docs、computerActions、`onTurnFailed`。 + +**觀察**(`fnd_6`)— main 端通往 box 的路徑:`probeGateway` → `{gatewayUrl}/health`、`flushBoxStore` → `{gatewayUrl}/api/snapshotBoxStoreNow`、`issueBoxCredential`(POST)。`main-app.cjs` 與 `main-core.cjs` 中**未見**直接 fetch 到任何 LLM inference API。 + +**推論(derived)**:LLM 推理由伺服器端代理發起,桌面端只送 prompt、收事件流。 + +**範圍限制(必讀)**:`main-app.cjs` 有 **18 處動態 fetch 點**,connect-rpc transport 未完全解析 → 「未見直接呼叫」只適用於我解析到的路徑,不等於不exists。 + +**Box 供應**(`fnd_11`,推論(derived)):正式路徑 `.ensureBox` → `client.ensureSandBox`(connect-rpc,帶 rate-limit retry-after、parkMode、access-hold 拒絕分類)。另有一條 **dev-box** 路徑在本地跑 Docker:`imageExists`(`docker image inspect`)、`pullImage`(`docker pull`)、`removeContainer`(`docker rm -f` + `recreate-intent.json`)、`resetBox`、`recreateContainer`、`wipeSandData`。`issueBoxCredential` POST 一枚 `local-exec` 憑證,帶 `x-cursor-team-id` 與 `x-ghost-mode` header → **推測**:讓 local-exec daemon 對 box 認證。 + +--- + +## 4. 本地工具執行器(`dist/local-exec-daemon/main.cjs`) + +**觀察 + 推論(derived)**(`fnd_7`):它**反向撥出**連回 gateway 接指令:`redial`、`streamRequests`、`handleRequest`、`handleExecRequest`、`handleCancel`、`postBatch`、`upload` / `download`。 + +實作的工具族: + +| 工具族 | 觀察到的细节 | 標記 | +|---|---|---| +| 檔案搜尋(ripgrep) | `files` / `count` / `content` 三種模式、timeout、blocked-file 過濾 | 觀察 | +| 目錄樹 | 目錄 tree 輸出 | 觀察 | +| Shell | 執行 + **背景交接**:`requestBackground`、`forceBackgroundByToolCallId`、`timedOut`、`readTerminalFile` | 觀察 | +| 電腦操作 | 經 sidecar helper 走 **unix socket**:`screenshot`、`click`、`scroll`、`move`、`drag`、`typeText`、`pressKey`、`startControl`/`releaseControl`、座標換算 `apiToDisplay`/`displayToApi`、ffmpeg 動作錄製 | 觀察 | +| MCP client | 含 OAuth token 存放 | 觀察 | +| macOS App 工具 | Reminders、Calendar(CRUD)、Contacts(search)、Mail(`composeSend` / `sentCheck`)、Messages(操作),逐 app 同意 + 冪等送出(`rememberSend`、`replayedSend`、`withSendKey`) | 觀察 | +| 上下文載入 | 載入 Cursor rules 與 agent skills | 觀察 | +| 規則解析 | 存在 `tree-sitter-bash.node`(darwin-arm64 prebuild)+ `web-tree-sitter/tree-sitter.wasm` | 觀察 | +| shell 指令解析 | 推測:shell 指令會被解析成子指令後才做 allowlist 檢查 | **推測(estimated)** | + +--- + +## 5. 權限模型 + +**觀察**(`fnd_8`)— 閘門與清單: +`shouldBlockRead`、`shouldBlockWrite`、`shouldBlockShellCommand`、`shouldBlockMcp`、`isShellCommandFullyAllowlisted`、`isMcpFullyAllowlisted`、`isWebFetchFullyAllowlisted`、`addToAllowList` / `addToDenyList`、`shouldEnforceShellInvariantBlocks`,加上 `cursorignore` / `gitignore` / repo-block 的 glob 過濾。 +审批回呼:`onNeedsApproval`、`onApprovalRequested`、`onUserRejected`、`onPermissionDenied`。 + +**觀察**(`fnd_12`)— 客戶端擁有並推送到 box 的設定與授權: +- `recordLocalToolApproval` 是**機器範圍**的;**拒絕** send-mail,除非經 `recordMacMailSendApproval`(從草稿卡片)。 +- `recordLocalToolStandingGrant` 持久化「always allow」。 +- `getLocalToolPermissionCeiling`(權限上限)、`listComputerUseAllowedApps`(從 standing grants 推導)。 +- MCP 自訂指令:`getMcpCustomInstructions`、`applyCustomInstructionsToMcpResult`。 +- auto-review 指令與 `syncHostSettingsToBox` 同步;`pushBoxSecrets`、template skills、user bot memories。 +- 遥測:`reportTurnClientOutcome`、`reportRenderTtfr`。 + +**推論(derived)**:權限分三層 —(1)全域 deny/glob(檔名與指令層面的硬駔)→(2)allowlist(完全放行)→(3)逐次审批 + standing grant(使用者互動)。`shouldBlock*` 是閘門,`is*FullyAllowlisted` 是「整條指令都允許才放行」的那種全量檢查。 + +--- + +## 6. 語音通話代理(客戶端唯一組裝 prompt 的地方) + +**觀察**(`fnd_10`): +- `requestVoiceCallHarness` / `voiceCallHarnessSession` / `voiceCallHarnessTool` 經 `cursorAccount`;realtime endpoint `wss://api.x.ai/` 是原檔第 1116 行的**字面字串**。 +- `e.render`(第 65 行)用 section builder 組出分段 system prompt:`Role & Persona`、`Instruction and tools`、`Conversation Flow`、`While work is in flight`、`When they talk over you`、`Never claim it is done`、`Voice`、`CRITICAL INSTRUCTIONS`;builder 有 `voiceLoopLines`、`noInventedWorkLines`、`rosterPromptSection`(`## Project workers`)、`findBannedPlumbingTerm` regex。 +- 語音代理自己的工具含 `sendTask`、`staySilent`、`endTheCall`;`earnsASpokenTurn` 用 `SPOKEN_KINDS` 決定何時可以說話。 + +**設計取捨(推論 derived)**:語音模型被刻意做成**薄前端** — 只負責接話、派工(`sendTask`)、保持安靜(`staySilent`)、結束通話(`endTheCall`);**不得**在沒有結果時宣稱完成。把「不確定性」與「延遲」交给主代理,避免語音模型自編進度。 + +--- + +## 7. 整合、連線器與後端服務 + +**觀察**(`fnd_3`): +- 後端:`api2.cursor.sh`(`@connectrpc/connect` + protobuf),遥測 `api3.cursor.sh`,登入/計費/onboarding 在 `cursor.com`。 +- 整合:`x.com` / `grok.com` 的 OAuth 連線器;Google Workspace scopes(Gmail / Drive / Calendar / Docs / Sheets / Slides / BigQuery);MCP OAuth;Statsig feature flags;Sentry。 +- 祕密儲存:Electron `safeStorage`。 + +**觀察**(`fnd_4`)— renderer 技術堆疊(來自 deps):React + `@tanstack/react-query`、StyleX、`@base-ui/react`、TipTap(含 mentions)、`react-markdown` + `remark-gfm` + KaTeX、`motion`、`dnd-kit`、Lingui i18n;檔案進件 `pdfjs-dist` / `mammoth`(docx)/ `xlsx`;tree-sitter(bash)做指令解析。原生 macOS addons:`window-capture`、`card-text-recognition`、`devicecheck`、`notification-settings`、`address-lookup`、`proclist`、`jpeg-napi`。 + +**推論(derived)**:Google Workspace scope + Mail/Messages/Calendar 工具 + 「逐 app 同意」顯示這個代理被設計為**直接操作使用者的個人工作環境**,而不只在 repo 內寫程式。 + +--- + +## 8. 設定與功能目錄(UI 層線索) + +**依據 A:專案內既有檔案 `app/ORIGINAL_FEATURES.md`**(5491 B、33 個標題:27 個設定分區、per-bot tabs、routine triggers、SAND‑E 的 9 domains / 108 codes)。這是專案自帶的觀察記錄,**不是**本輪分析產出的 evidence id。 + +**依據 B(`fnd_15`,觀察,名稱層)**:asar 的 renderer bundle 以 per-feature chunk 分割,**檔名本身就列出已出貨的 UI 功能**。`dist/renderer/assets/` 中可見: + +| chunk 檔名 | 對應功能線索 | +|---|---| +| `chunk-agents-*`(十餘個變體,22–52 KB) | agents 主畫面/代理清單 | +| `chunk-agent-avatar-editor` | 代理頭像編輯 | +| `chunk-agent-invite-dialog` | 邀請代理 | +| `chunk-bot-configure-dialog` | Bot 設定對話框 | +| `chunk-bot-manage-screen`(67962 B)、`-manage-knowledge`、`-manage-section` | Bot 管理畫面 + 知識管理 | +| `chunk-bot-share-control`、`-invite-popover`、`-popover`、`-state-line`、`-trigger` | Bot 分享/邀請/狀態列/觸發器 | +| `chunk-automation-detail-panel` | 自動化詳情面板 | +| `chunk-auto-review-rule-notice`、`chunk-auto-review-selector` | auto-review 規則提示與選擇器 | +| `chunk-bonfire-dialog` | bonfire 對話框 | +| `chunk-app-copy` | 整包複製/打包 | + +(來源:asar 目錄清單 offset 360,200/1027 筆,`integrity: verified`。) + +**標記**:以上整章屬**觀察(名稱層)** — 檔名是觀察,功能行為**不是**由檔名證明的。 + +--- + +## 9. 原生 computer-use helper + +**推論(inferred)**(`fnd_14`):輸入合成在原生 macOS helper `Contents/Helpers/CUGrokBotService.app`(Mach-O fat,Swift module `CUCore`,sha256 `1004a8cbd6a03567e2775e3cb1ec69d15d06c66d44afd7f7b627a6fad2cbab13`)。 + +觀察到的符號(內建反編譯器,非 Ghidra): +- `CURemoteToolService.handleDrag(...permit:)` → `CURemoteEventPoster.drag(path:[CGPoint], button:CUMouseButton, steps:Int, holdSeconds:Double, flags:CGEventFlags, permit:CUScrollControlPermit?)`;`drag` 只有 **1 個 caller** → permit 屬**逐請求授權**。 +- 文字輸入走 `CUTypedCharacters.utf16Chunks` + `postChunks(_:source:CGEventSource, prepare:, delivery:_(CGEvent, isCommand:))`。 +- `CUActuator.typeCharacters(pid, text, processIdentity)`、`CUActuator.click(pid, screenPoint, windowBounds, windowID, button, count, holdSeconds, processIdentity)`。 + +**範圍限制**:Ghidra decompiler 無結果,偽碼全來自內建反編譯器;掃描 coverage partial(120/616 函式、80/99 字串);**哪個請求欄位決定 `CUScrollControlPermit` 未解**。 + +--- + +## 10. Token 計數與 context window + +完整展開見同目錄 `GROK_BOT_TOKENS.md`。此處只列結論。 + +**觀察(限定範圍的缺席)**:對 `dist/electron-main/main-app.cjs` 掃 `usage`、`prompt_tokens`、`completion_tokens`、`total_tokens`、`token_count`、`context_window`、`max_tokens`、`tokens_used`、`usage_ratio` → **0 命中**;符號索引含 "token" 的 147 筆全為憑證/認證字串。**→ Grok Bot 用戶端沒有 token 計數器。**(僅該檔、僅這些欄位名。) + +**觀察**(真實在客戶端的东西): +- 壓縮設定:`client_speculative_summarization_config = {enabled:true, thresholdRatio:0.70, toleranceRatio:0.05, modelId:"grok-4.5"}`;閘 `speculativeSummarizationClientGates:{enabled, modelTier:"default", contextRatio:0.70}`。 +- **byte 級**上限(不是 token):`summarizationInputTokenLimit: 360000`(註解 "bytes (ASCII=1 token)")、`summarizationInputByteLimit: 360000`(預設 300000)、`summarizationInputBytes: 120000`、`summarizationInputTokenLimitCap`(0=停用)、`summarizationOutputCap: 6000`、`MAX_SUMMARY_BYTES = 100*1024`、`summarizeMessages`。 +- 每工具輸出上限:`read_file maxBytes` 預設 131072;`readTerminalFile maxBytes` 預設 65536;`ripgrep maxResults` 200;`ripgrep timeoutMs` 20000。 +- 配額預算(**不是** context window):`token_budget = 100000`、`token_budget_per_user = 30000`、`token_budget_per_user_7d = 100000`、`token_budget_per_team`、`usage_warning_threshold = 0.8`、`usage_limit_floored`。 +- Provider 錯誤分類含 `provider_413_context_too_large`。 + +**推論(derived)**: +- 客戶端**唯一**的 token 估計器是 `voiceCallTranscriptCharsPerToken = 4`(預設 4.0),用法 `voiceCallTranscriptTokens = ceil(charLen / charsPerToken)`,對 `voiceCallTranscriptTokenBudget = 12000` 檢查。`estimateMessagesTokenCount` 存在(main-app.cjs 約 @2743065)但算術不可見。 +- `compactionSummary` 來自**伺服器**串流(`compactionSummaryFromEvent`、`applyCompactionSummaryToTimeline`、`applyCompactionSummaryFromEvent`),配 `turnUsageBreakdownFromEvent`、`reportUsage`、`usageFromEvent`、`usageLabel`、`onUsage`、`usageLabelForTurn` → **token 記帳在伺服器端**(與 `fnd_5`/`fnd_6` 一致)。 + +--- + +## 11. 模型與錯誤分類 + +**觀察**(`fnd_9` + `unk_dd4ee2d6bc4685b5` 已解): +- 客戶端不選模型:`SAND_DEFAULT_MODEL_ID` 只有 getter(值不可見);`getComputerUseModel` 是用戶設定;字串群 `Cursor Models` / `Includes Cursor Grok and Composer` / `Other Models`。 +- 程式碼中的模型名:`Wp`(main-core.cjs)含 **`grok-4.5`**;`model_picker_experiments` 含 **`grok-4.6`**。**模型清單由伺服器提供。** +- 沒有可見的 model id 字面值(字串表被截斷)。 +- Provider 錯誤 taxonomy:`provider_400_invalid_request`、`_401_auth_rejected`、`_404_not_found`、`_413_context_too_large`、`_429_quota_exceeded`、`_4xx_rejected`、`_5xx_terminal`、`_image_rejected`、`_user_blocked`、`NonRetryableProviderError`。 +- `main-app.cjs` 內**沒有**文字代理迴圈、聊天 LLM call、context 壓縮客戶端碼(限定範圍缺席)。 + +**推論(derived)**:錯誤 taxonomy 用 HTTP status 分「可重試 / 终止」兩類(`_4xx_rejected` vs `_429_quota_exceeded` vs `_5xx_terminal`),並在客戶端做 retry 決策;`_413_context_too_large` 與 `_image_rejected` 是「把請求重新組裝後重試」的訊號,不是終止訊號。 + +--- + +## 12. 未解問題 + +| id | 問題 | 嚴重度 | 狀態 | +|---|---|---|---| +| `unk_67f6ca825e8ffa3f` | 伺服器端 prompt/模型路由/工具 schema 不在 asar 內;唯一路徑是執行期抓取 box 串流(需使用者核准) | high | blocked | +| `unk_70f57b409ea35f76` | 回合開始/steering/idle 語義已定案(main-app.cjs @3100283 `P3n`;coordinator `wl`/`k6`、`ZF`、`boxHandoff`);開放項是 `api2.cursor.sh` 的 wire schema | high | open | +| `unk_8b8ec93d6245e822` | 側欄 280 px 預設/拖拽 240–400/<210 px 轉 88 px rail/>220 px 展開(index.eager-app @912025 `FT`、@914394 `sKe`、@969523 `QJe`);chat 最小 424 px(`--sand-chat-min-width`);bubble 36/18 px;chat 最大寬 720 px 屬**推測** | medium | open | +| `unk_25d3b20c74d75860` | 深色配色由執行期產生(主執行檔 `Grok Bot`,sha256 `df5b07deaa0b9bd42cf63ca2f2478ac629e4fa257324047cdfe8f3ddce4d6684`,對 `font\|pingfang\|hiragino\|width\|height\|minsize\|title` 0 字串命中);重建用 `mechanics.json` 估計配色 | low | blocked | +| (fnd_14 衍生) | 哪個請求欄位決定 `CUScrollControlPermit` | medium | open | +| (工具層) | application toolset 17 個、projects toolset 27 個工具的確切名稱未逐一確認 | low | open | +| `unk_89a8fde07df81e50` | 功能清單 | — | **resolved**(`app/ORIGINAL_FEATURES.md`) | +| `unk_dd4ee2d6bc4685b5` | 模型清單來源 | — | **resolved**(伺服器提供;程式碼名 `grok-4.5`/`grok-4.6`) | + +--- + +## 13. 方法與信心度說明 + +1. **純靜態**。未執行目標程式任何部分 → 本文無一條「執行過」的斷言。 +2. **缺席結論全部限定範圍**: + - `fnd_9`:字串表被截斷。 + - `fnd_6`:18 處動態 fetch 點未解析;connect-rpc transport 未完全解析。 + - `fnd_14`:Ghidra decompiler 拒絕,偽碼全來自內建反編譯器;coverage partial(120/616 函式、80/99 字串)。 + - §10 的 token 欄位掃描:僅 `dist/electron-main/main.cjs`… 精確說,僅 `dist/electron-main/main-app.cjs`,僅列出的 9 個欄位名。 + - `js_app_map`:受 25000 節點上限(74905 個 node 被丟)與 `paged_modules` 限制(coverage partial 20/697 modules 逐頁取)。 + - `fnd_15`:**僅檔名**,非行為證據。 +3. **asar 目錄清單**只跑到 offset 560/1027,`dist/electron-main/main-app.cjs` 的拼法未在清單直接讀到(前 560 筆被 locales/menubar-tray PNG 與 renderer chunk 吃滿)→ 若要確認,用 `offset=560` 分頁。 +4. **`kind: unpacked` 的檔案不在 asar 內**,實體在 `Contents/Resources/app.asar.unpacked/<相對路徑>`。 +5. **evidence id 會過期**(綁定產生它的查詢週期)。因此本報告一律引 **finding id**(`fnd_1`…`fnd_12`、`fnd_14`、`fnd_15`),其 evidence 連結已入庫綁定。 +6. **重建對應物(`app/src/`)中的每一個 token 數字都是作者設定**,唯一源自 Grok Bot 的引數是 `client_speculative_summarization_config` 的 `0.70` / `0.05` 與一組 byte/char 上限。詳見 `GROK_BOT_TOKENS.md` §11。 diff --git a/GROK_BOT_TOKENS.md b/GROK_BOT_TOKENS.md new file mode 100644 index 0000000..31b9f36 --- /dev/null +++ b/GROK_BOT_TOKENS.md @@ -0,0 +1,210 @@ +# Grok Bot 的 token 計數與 context window 用量,以及重建 harness 的對應做法 + +对象:`/Applications/Grok Bot.app`(package name `sand`,Electron 40.1.0,asar 1027 entries,integrity verified) +分析目標:`dist/electron-main/main-app.cjs`、`dist/local-exec-daemon/main.cjs`、`dist/node-agent-coordinator/main.cjs` +相關 finding:fnd_5、fnd_6、fnd_7、fnd_9、fnd_12、fnd_16 + +--- + +## §0 標記規則(先讀這節) + +本文每條陳述都標明等級: + +- **觀察**:在目標檔案裡直接讀到的欄位名、常數、字面值。**一律附掃描範圍**——「掃了哪個檔、找了哪些關鍵字」。「沒找到」只等於「在該範圍、用該關鍵字沒找到」,不等於「不存在」。 +- **推論**:從觀察算出來/歸納出來的結論(例:記帳在伺服器端)。 +- **推測**:最合理的猜測,沒有直接證據。 +- **作者設定**:**重建 harness(`app/`)裡我自己選的數字**,與 Grok Bot 無關。**不要把這些數字當成 Grok Bot 的事實。** + +引證方式:本文引 **finding id**(`fnd_*`),因為已入庫 finding 的 evidence 連結在入庫時已綁定並驗證。跨輪的 `ev_*` id 不可信任(實測會 `Unknown evidence id`),所以本文只在有**同輪** evidence 的地方列 `ev_*`:本輪的两個 id 是檔案完整性檢查(見 §11 末)。 + +--- + +## §1 核心結論:Grok Bot 客戶端**沒有** token 計數器;記帳在伺服器端 + +**推論**(依據下列觀察 + fnd_5/fnd_6)。 + +**觀察(範圍:`dist/electron-main/main-app.cjs`,找這 9 個欄位名)**:`usage`、`prompt_tokens`、`completion_tokens`、`total_tokens`、`token_count`、`context_window`、`max_tokens`、`tokens_used`、`usage_ratio` → **0 命中**;符號索引中含 `token` 的 147 筆全部是**憑證/密閉字串**(token = credential),不是計數。 + +**觀察**:客戶端有完整的一組 **byte 上限**與**压缩引數**(§2、§5),但**看不到把字串換算成 token 的通用估算器被使用**: +- `estimateMessagesTokenCount` 這個符號存在(約 `main-app.cjs` @2743065 附近),但它的**算術在觀察到的位址不可見**(minified、未展開)。→ 有名字、無實現細節。 + +**觀察(與結論一致的正面證據)**:token 用量是**從伺服器事件流裡取出來的**,不是客戶端算的: +`compactionSummaryFromEvent`、`applyCompactionSummaryToTimeline`、`applyCompactionSummaryFromEvent`、`turnUsageBreakdownFromEvent`、`usageFromEvent`、`onUsage`、`usageLabel`、`usageLabelForTurn`、`reportUsage`、`turnSteer` + `tokenBudgetsForTurn`。 + +**這與 fnd_5/fnd_6 完全對得上**:文字代理迴圈、prompt 組裝、模型路由與 context 管理都在遠端 box/gateway(`api2.cursor.sh` 系),客戶端只是顯示者與工具執行者(fnd_7)。 + +**範圍限制**:只掃了上述欄位名,且 fnd_9 已記錄字串表被截斷。所以嚴格的說法是:**「在 main-app.cjs 的 token 計數欄位掃描中未見客戶端計數器;記帳欄位一律以 event 名出現」**,不是「客戶端絕對沒有 token 數學」。 + +--- + +## §2 客戶端唯一的 token 換算:字元數 / 4(僅限語音逐字稿) + +**推論**:在觀察範圍內,客戶端唯一看得見的 token 除法在語音通話路徑上: + +- `voiceCallTranscriptCharsPerToken = 4.0`(**觀察**) +- 算式形如 `ceil(charLen / charsPerToken)`(**觀察**) +- 預算 `voiceCallTranscriptTokenBudget = 12000`(**觀察**) + +**推論**:這是「ASCII 約 4 字元 = 1 token」的粗估,只用來控制**逐字稿長度**,不是整個對話的 context 管理。 + +**作者設定(重建做法)**:我的 harness 不內嵌 tokenizer(零相依,不帶 `gpt-tokenizer`/`js-tiktoken`)。規則:ASCII byte → 1 token,非 ASCII byte → 0.5 token。這是**我選的**,Grok Bot 只用 4.0 字元/token 於語音逐字稿。 + +--- + +## §3 真正的權威值:讀 provider 回來的 usage + +**觀察**:Grok Bot 的用量數字來自伺服器事件(§1 的 `usageFromEvent` / `turnUsageBreakdownFromEvent` / `onUsage`)。 + +**作者設定(重建做法)**:不自己數,**讀 provider 回傳的 `usage`**。OpenAI-compatible 串流的最後一個 SSE chunk 帶 `usage.prompt_tokens` / `usage.completion_tokens` / `usage.total_tokens`(請求側要發 `stream_options: {"include_usage": true}`)。規則: +1. 每個 LLM 事件流結束時取**最後一個**帶 `usage` 的 chunk 當權威值; +2. 佔用率 = `usage.total_tokens / contextWindow`,寫入事件流(`tokens_used` + `source: "provider"`); +3. provider 沒給 `usage` 時才退回本地估計,並在事件上標 `est.`,UI 顯示 `~`; +4. 估計器與權威值並存,永不互相覆蓋——這樣才知道估計誤差。 + +--- + +## §4 每工具的輸出上限:用 byte 和行列卡,不用 token + +**觀察(範圍:`dist/local-exec-daemon/main.cjs`,fnd_7 的工具實作)**: + +| 工具 | 上限欄位 | 值 | +| --- | --- | --- | +| `read_file` | `maxBytes` | 預設 **131072**(128 KiB) | +| `readTerminalFile`(背景 shell 輸出) | `maxBytes` | **65536**(64 KiB) | +| `ripgrep` | `maxResults` | **200** | +| `ripgrep` | `timeoutMs` | **20000** | + +**推論**:Grok Bot 控制 context 膨脹的主要手段是**在工具邊界卡 byte 與條數**,讓單次工具結果不可能撐爆 context,而不是把工具結果送去數 token。 + +**作者設定(重建做法)**:同樣策略——每個工具都有 `maxBytes` + 行/列上限,回傳時附 `truncated: true` 與「如何取下一页」的提示(offset/cursor)。**我的數值與上表相同**(131072 / 65536 / 200 / 20000 直接採用,因為它們是 Grok Bot 的觀察值,屬於可借鑑的設計常數)。 + +--- + +## §5 壓縮觸發點:Grok Bot 用「比例」,而且是 70 % + +**觀察(範圍:`dist/electron-main/main-app.cjs`)**——本報告**唯一直接來自 Grok Bot 的數字組**: + +``` +client_speculative_summarization_config = { + enabled: true, + thresholdRatio: 0.70, + toleranceRatio: 0.05, + modelId: "grok-4.5" +} +speculativeSummarizationClientGates = { enabled, modelTier: "default", contextRatio: 0.70 } +``` + +**推論**: +- `thresholdRatio: 0.70` = context 用量到 **70 %** 觸發(**推論**:欄位語意由名字與 `contextRatio` 同名值一致支持,非文件證明)。 +- `toleranceRatio: 0.05` = **5 %** 容差 → 觸發後压到 **65 %** 以下(`0.70 - 0.05`)。**0.70 與 0.05 是觀察;「压到 65 %」是把容差讀成遲滯目標的推論。** +- `modelId: "grok-4.5"` = 做摘要的模型由客戶端設定送出去(與 fnd_12 的「客戶端擁有設定、推送到 box」同形)。 +- 「speculative」(投機式)+ 客戶端 gate(`enabled`、`modelTier`)→ **推測**:摘要是在回合進行中/之前**預先算好**,需要時直接採用,避免在關鍵時刻卡一次摘要請求。這是對命名的解讀,無行為證據。 + +**觀察**:压縮結果以 **`compactionSummary`** 的名字從**伺服器事件**送達,並套用到 timeline(§1)→ 摘要在事件流裡是**一等公民**(可重放、可重算 timeline),不是就地改寫訊息歷史。 + +**作者設定(重建做法)**:採用同一對引數——**觸發 70 %,目標 65 %**(遲滯,避免在臨界點反覆壓縮)。這是我從 Grok Bot 借鑑的**唯一**一組數值;其他比例都是我的選擇。 + +--- + +## §6 壓什麼:位元組上限講得很清楚 + +**觀察(範圍:`dist/electron-main/main-app.cjs`)**: + +| 欄位 | 值 | 附註 | +| --- | --- | --- | +| `summarizationInputTokenLimit` | **360000** | 碼內註解寫「bytes (ASCII=1 token)」 | +| `summarizationInputByteLimit` | **360000**(預設 300000) | | +| `summarizationInputBytes` | **120000** | | +| `summarizationInputTokenLimitCap` | 0 = 停用 | 上限的上限 | +| `summarizationOutputCap` | **6000** | 摘要輸出 | +| `MAX_SUMMARY_BYTES` | **100 × 1024** | | +| `summarizeMessages` | 函式名 | | + +**推論**: +1. 全鏈路都以 **byte** 為單位;「token」在客戶端只是「ASCII byte」的別名(註解自己寫了 ASCII=1 token)。 +2. **輸入**有 300 000–360 000 byte 的送件上限,實際一次用 120 000 byte;**輸出**摘要 ≤ 6000(單位未標,**推測**為 token 上限)、單條摘要 ≤ 100 KiB。 +3. 因為摘要由伺服器產生(§1),這些限制更像是**客戶端決定送多少歷史去摘要**。 + +**作者設定(重建做法)**:我的 compress 事件記錄 `beforeTokens` / `afterTokens` / `modelUsed` / `strategy`;摘要本身是 append-only 事件,timeline 由「最近 N 條原文 + 之前所有摘要」重組;保留最後一輪完整不压,避免模型忘記自己剛剛在做什麼。 + +--- + +## §7 context 爆了怎麼辦:413 的分類與一次重試 + +**觀察(fnd_9,範圍 `main-app.cjs`)**:provider 錯誤 taxonomy 含 `provider_413_context_too_large`,以及 `provider_400_invalid_request`、`_401_auth_rejected`、`_404_not_found`、`_429_quota_exceeded`、`_4xx_rejected`、`_5xx_terminal`、`_image_rejected`、`_user_blocked`、`NonRetryableProviderError`。 + +**推論**:`413` 被当成**可處理事件**(有自己的代號),不是笼統的 4xx;`NonRetryableProviderError` 與 `_5xx_terminal` 存在 → 重試策略是**依代號分類**的。 + +**作者設定(重建做法)**:收到 `413` → **強制壓縮一次**(不等到 70 %)→ 重發一次;第二次還失敗就帶著錯誤終止回合,並在事件流記 `provider_413_context_too_large`。`429` 讀 `retry-after`;`4xx` 不重試(對應 fnd_9 的 non-retryable 分類)。 + +--- + +## §8 別把「配額」讀成「context window」 + +**觀察(範圍:`dist/electron-main/main-app.cjs`)**:`token_budget = 100000`、`token_budget_per_user = 30000`、`token_budget_per_user_7d = 100000`、`token_budget_per_team`、`usage_warning_threshold = 0.8`、`usage_limit_floored`。 + +**推論**:`per_user` / `per_user_7d` / `per_team` 這種**按使用者/團隊/7 天視窗**的分桶是**計費與配額**語彙,context window 不會按使用者分桶。`usage_warning_threshold = 0.8` 是「用量到 80 % 要示警」的**配額**示警。 + +**结论**:Grok Bot 的 **context window 大小不在客戶端**(找不到 `context_window`,§1)。客戶端看到的 `token_budget*` 是配額。 + +**作者設定(重建做法)**:兩個數字必須分開。`--context-window`(我預設 **8000**,CLI 可蓋)控制壓縮;配額是另一個概念,我的 harness 不做配額(沒有伺服器)。`usage_warning_threshold = 0.8` 這個「80 % 示警」我沿用為 **CLI 警示條**的閾值(借用其形式,非借用其語意)。 + +--- + +## §9 用量要能對到回合 + +**觀察(範圍:`dist/electron-main/main-app.cjs`)**:`turnUsageBreakdownFromEvent`、`usageLabelForTurn`、`tokenBudgetsForTurn`、`turnSteer`、`reportTurnClientOutcome`、`reportRenderTtfr`。 + +**推論**:token 用量**按回合歸屬**(per-turn breakdown),而且客戶端會**回報自己這端的結果**(`reportTurnClientOutcome`)與**首字延遲**(`reportRenderTtfr`)→ 伺服器是記帳中心,客戶端是觀測點之一。`turnSteer` + `tokenBudgetsForTurn` 一起出現 → **推測**:每回合有 token 預算,steering(中途插入指示)時會重算。 + +**作者設定(重建做法)**:事件流裡每條 `llm.usage` 事件帶 `turnId`;`/usage` 指令印出 per-turn 表格(每回合 prompt/completion/total + 佔 context 百分比 + 是否 `est.`)。我的 harness 不做遠端上報。 + +--- + +## §10 讓它看得見:CLI 與 UI 的可見性 + +**觀察**:客戶端有 `usageLabel` / `usageLabelForTurn` → **推論**:UI 上有專用的用量字串(每回合一條)。 + +**作者設定(重建做法)**: +- CLI 每個回合後印一行 `tokens 1234/8000 (15%)`, provider 值直接顯示,估計值前綴 `~`。 +- 超過 `--context-window` 的 **80 %** 時在同一行尾加警示條 `[████░░]`,並在 compress 事件發生時印 `compacted 4100 → 1500`。 +- `--print` 模式结束時印總計。 + +--- + +## §11 數字來源對照表(最重要的一節) + +| 數字 | 屬於誰 | 出處與標記 | +| --- | --- | --- | +| **0.70**(`thresholdRatio`)、**0.70**(`contextRatio`) | **Grok Bot 用戶端** | **觀察**:`client_speculative_summarization_config` / `speculativeSummarizationClientGates`(`main-app.cjs`)。**這是本報告唯一直接來自 Grok Bot 的比例數字。** | +| **0.05**(`toleranceRatio`) | **Grok Bot 用戶端** | **觀察**:同上欄位。 | +| `modelId: "grok-4.5"`(做摘要的模型) | **Grok Bot 用戶端** | **觀察**:同上欄位(模型清單由伺服器提供,見 fnd_9 / unk_dd4ee2d6bc4685b5 已解)。 | +| 360000 / 300000 / 120000 / 6000 / 100×1024 byte 上限、`MAX_SUMMARY_BYTES` | **Grok Bot 用戶端** | **觀察**:`summarizationInput*`、`summarizationOutputCap`、`MAX_SUMMARY_BYTES`(`main-app.cjs`)。單位是 **byte**(檔內註解:ASCII=1 token)。 | +| 131072 / 65536 / 200 / 20000(每工具上限) | **Grok Bot 用戶端** | **觀察**:`dist/local-exec-daemon/main.cjs`(fnd_7)。 | +| 4.0 chars/token、12000 | **Grok Bot 用戶端(僅語音逐字稿)** | **觀察**:`voiceCallTranscriptCharsPerToken`、`voiceCallTranscriptTokenBudget`。 | +| 100000 / 30000 / 100000 / 0.8 | **Grok Bot 用戶端,但是「配額」不是 context window** | **觀察**:`token_budget*`、`usage_warning_threshold`(§8 推論)。 | +| **65 %**(压縮目標) | **推論 + 作者設定** | 由 `0.70 − 0.05` 得出的**推論**;作為我 harness 的固定目標值则是**作者設定**。Grok Bot 碼內**沒有**「65 %」這個字面值。 | +| **72 000**、**100 000**(重建檔內出現的 context 尺寸範例) | **作者設定** | 我舉的例子,與 Grok Bot 無關。 | +| **8000**(`--context-window` 預設) | **作者設定** | 我的 CLI 預設值。**Grok Bot 客戶端沒有 context window 數字**(§1 掃描 0 命中)。 | +| **80 %**(CLI 警示條閾值) | **作者設定**(形式借鏡 `usage_warning_threshold = 0.8`) | 語意不同:Grok Bot 那個是配額示警。 | +| ASCII 1 token / 非 ASCII 0.5 token 的估計器 | **作者設定** | 我的 harness 規則;Grok Bot 只在語音逐字稿用 4.0 chars/token。 | + +**一句話**:**只有 0.70 / 0.05 這一對壓縮引數,以及一組 byte/char 上限,是從 Grok Bot 用戶端觀察得來的**;其餘任何 token/context 數字都是我自己的設定或範例。 + +**本輪檔案完整性檢查(觀察,同輪 evidence)**:`GROK_BOT_TOKENS.md` 10785 B、`GROK_BOT_FEATURES.md` 20998 B,sha256 `db9f4c11b1ea18855eaac90d0eb15feea6000da92029516b28e02c285772661b` / `fd753cf232f6959e0bb63cd813bc26a8b55753ae16ac1174aa03f247df1f82d3`(`ev_c091620b0c5bc68527e432b2d02d6fe80c4d5ac7d1bd928d49f0b80db8c63174`、`ev_2f4ca1c5d683d7ad93da7316b4fd07eac9100f2bbff0f9ce166269c4de03cf58`;入庫為 fnd_16)。 + +--- + +## §12 方法與限制 + +**方法**:靜態分析。asar 清單(1027 entries,integrity verified)→ `js_app_map` 套裝圖 → 對 `dist/electron-main/main-app.cjs`、`dist/local-exec-daemon/main.cjs`、`dist/node-agent-coordinator/main.cjs` 做符號/欄位名掃描。**沒有執行目標**,因此本文所有陳述都只證明「碼裡寫了什麼」,不證明「跑起來會怎樣」。 + +**限制(逐條)**: +1. 「找不到 token 計數欄位」的範圍是 **main-app.cjs + 列出的 9 個欄位名**;fnd_9 已記錄字串表被截斷,fnd_6 已記錄 **18 個動態 fetch 點**與 connect-rpc transport 未完全解析。 +2. `estimateMessagesTokenCount` 的算術不可見;`summarizationOutputCap` 的單位未標。 +3. `thresholdRatio` / `toleranceRatio` 的**語意**是從命名與 `contextRatio` 一致性推來的(推論),沒有註解或文件直接說明「壓到 65 %」。 +4. 伺服器端的 prompt 組裝、模型路由、真正的 context window 大小與計費邏輯**不在 asar 裡**(unk_67f6ca825e8ffa3f,high,blocked);唯一途徑是執行期擷取 box 事件流(需要使用者核准執行目標)。 +5. `js_app_map` 有 25 000 節點上限(74905 個被丟)與 `paged_modules` 覆盖限制;asar 目錄清單我看過 offset 0–560。 +6. 本輪無檔案內容讀取工具,`core_archive_show` 對單一文字檔只回 size/sha256、不回正文 → 本文是依分析結果重組,非讀回舊檔。 + +**未解(對應 unk id)**:`unk_67f6ca825e8ffa3f`(伺服器端 prompt/模型路由/工具 schema);`estimateMessagesTokenCount` 算術;`CUScrollControlPermit` 由哪個請求欄位決定(fnd_13/fnd_14)。 diff --git a/MECHANICS.md b/MECHANICS.md new file mode 100644 index 0000000..5e0df56 --- /dev/null +++ b/MECHANICS.md @@ -0,0 +1,591 @@ +# How the original works (and what this rebuild copies) + +Original: Grok Bot 0.78.1, an Electron app. + +## The native main program (decompiled) +`Contents/MacOS/Grok Bot` (Mach-O arm64, 53 040 bytes, sha256 df5b07de…). Opened with +core_target_open, and every function read with native_function_pseudocode: + +| Address | Function | What it does | +|---|---|---| +| `0x100000460` | `entry` | Calls `0x1000004c0`, then `electron::fuses::IsRunAsNodeEnabled()`. If the fuse is on and `getenv("ELECTRON_RUN_AS_NODE")` is a non-empty string, it runs `ElectronInitializeICUandStartNode`, otherwise `ElectronMain`. | +| `0x1000004c0` | `sub_1000004c0` | For stdin/stdout/stderr (fd 0, 1, 2): if `fstat` fails with `errno == 9` (EBADF), reopens that stream on `/dev/null`. | +| `0x1000005bc`–`0x100000604` | import stubs | `ElectronInitializeICUandStartNode`, `ElectronMain`, `getenv`, `IsRunAsNodeEnabled`, `freopen`, `__error`, `fstat`. | + +These are all 10 functions in the file (evidence `ev_407aa9c3…`). `native_strings_find` for +`model|grok|provider|chat|bot|theme|api` finds **0 strings** (evidence `ev_1b74b817…`), so none of the +chat, model or settings behaviour is native code. It lives in the JavaScript inside +`Contents/Resources/app.asar`. The sources below therefore give **file + byte offset (@N) + minified +function name**, the JavaScript equivalent of a function address. +Pseudocode evidence: `ev_7c151be5…` (entry), `ev_29ffc0d8…` (sub_1000004c0). +Bundle map evidence: `ev_2bb06b4aef290dafe0a74085416f36482d30fc7ecc476b97f343e324ceb9c9f7` (js_app_map). + +## Errors, retries and timeouts +From `dist/electron-main/main-core.cjs`. + +| Rule | Value | Source | +|---|---|---| +| Retry attempts | 3 | @1082927 `D3={name:"gateway-create-agent-retry",maxAttempts:3,…}` | +| Retry delay | starts at 1000 ms, ×2 each time, at most 4000 ms → 1 s, 2 s, 4 s | same object: `initialDelayMs:1e3,maxDelayMs:4e3,backoffFactor:2` | +| Connect deadline | 15 000 ms | @1082927 `$D=15e3` → `connectDeadline` "gateway-sse-connect" (@1084759 `L3`) | +| Stream silence counts as a stall | 35 000 ms | @1082927 `VD=35e3` → `stallWatchdog` "gateway-sse-stall", `idleMs:VD` (@1084759 `L3`) | +| Provider overloaded / rate limit | retry | @689060 `SAND-E0401 providerOverloaded, retryable:!0` | +| Nothing streamed by the first-token deadline | retry | @689284 `SAND-E0402 firstTokenStall, retryable:!0`. The deadline itself is server-side: **estimated 120 s** | +| Transport reset | retry | @689501 `SAND-E0403 streamReset, retryable:!0` | +| Context window overflow | no retry | @689722 `SAND-E0404 contextWindowOverflow, retryable:!1` | +| Backend rejected | no retry | @689948 `SAND-E0405 backendRejected, retryable:!1` | +| Server-paced retry-after | wait the given delay, then retry | @690623 `SAND-E0408 backendCapacityDeferred` (30 s cap: **estimated**) | +| Silence after output started | cancel; resumed only from a checkpoint | @691348 `SAND-E0415 streamIdleStall`, so the rebuild never auto-retries once text has appeared | +| Backend unreachable (refused / DNS) | retry | @692409 `SAND-E0418 turnBackendUnreachable, retryable:!0` | +| Rate-limit banner | "Rate limited by model provider, retrying…" | `main-app.cjs` `rate_limited_reconnect_message_config` | +| Error texts | provider_overloaded → `wbQwKx`; first_token_stall → `Ly7/5m` "模型未能及時開始回應。請重試。" | `index.eager-app` @1592250, map `YN` | + +How the rebuild applies them: 429, 408, 5xx, 529 and connection failures retry up to 3 tries, waiting +1 s, then 2 s, then 4 s (or the server's Retry-After). The reply shows "…秒後重試(第 n 次,共 3 次)" +meanwhile. A 400 that says the prompt is too long stops with a "對話太長了" message, and 401/403/404 +stop at once. A reply that goes 35 s silent after it started is stopped, and the partial text is kept. + +## Models +| What | Value | Source | +|---|---|---| +| Default model | `grok-4.5` | `main-core.cjs`, `var Wp="grok-4.5"`, exported as `SAND_DEFAULT_MODEL_ID` | +| Model choice shape | `{modelId, maxMode, parameters:[{id,value}]}` | `main-core.cjs`, schema `UEe` / `bh` | +| Model list | fetched from the server, not stored in the app | `model_picker_experiments` in `main-app.cjs` names only `grok-4.5` and `grok-4.6` | +| Setting text | "模型:選擇 Grok Bot 用來思考的模型" | `index.eager-app` settings map `b$.model` → ids `scu3wk` / `2RPCms` | +| Overload message | "模型供應商已超載" / "模型供應商目前負載過高。通常是暫時的。" | zh-TW catalog ids `Rdu4z7`, `wbQwKx` | + +The original only talks to its own cloud service. **The rebuild's change**, as the person asked: +every bot picks its own provider (xAI Grok, OpenAI, Claude, Gemini, OpenRouter, Ollama, or any +OpenAI-compatible service) and model. Keys live in Settings → AI 供應商. + +## Message box +| Rule | Value | Source | +|---|---|---| +| Send | Enter sends; Shift+Enter adds a new line; Enter is ignored while an input method is composing | `index.eager-app` @2002346 `mwt`: `e.key==="Enter"&&!e.shiftKey&&!e.nativeEvent.isComposing&&e.preventDefault()` | +| Placeholder | "傳訊息給 {name}" (fallback "想問什麼都可以,或拖入檔案") | `index.eager-app` function `E6t`, ids `pqr+oY` / `HOCPPm` | + +## Sidebar +From `dist/renderer/assets/index.eager-app-D1juOWZp.js`. + +| Rule | Value | Source | +|---|---|---| +| Default width | 280 px | @912025 `FT={expandedWidth:280,isCollapsed:!1}` | +| Width limits | 240–400 px | @914394 `sKe`: `jT(UT(t.expandedWidth,280),240,400)`; @969523 `QJe`: `Math.max(240,Math.min(400,…))` | +| Folded width | 88 px rail | @969523 `fD=88` | +| Fold when dragged below | 210 px | @969523 `JJe=210` | +| Unfold from the rail when dragged to | 220 px | @969523 `YJe=220` | +| Saved keys | `sand.sidebar.width`, `sand.sidebar.collapsed` | @912025 `BT` | +| Chat minimum width | 424 px | index CSS `--sand-chat-min-width:424px` (class sand-1fiktjy); window minimum = 240 + 424 | +| Sections | 主要 Bot, 已釘選的 Bot, Bot | zh-TW ids `V8a0q9`, `vPVg0Q`, `ixkTse` | +| Search | "搜尋 Bot"; empty result "沒有相符的 Bot" | ids `YjO4Og`, `12i8o8` | + +## Chat +| What | Value | Source | +|---|---|---| +| Bubble corner | 18 px | index CSS `--sand-transcript-prose-bubble-radius:18px` | +| Bubble text | 15 px on a 22 px line | `--sand-transcript-prose-line-height:22px` with `--sand-font-size-lg:15px` | +| One-line bubble height | 36 px (7 px above and below) | `--sand-transcript-prose-one-line-height:36px`, `--sand-transcript-prose-block-padding:7px` | +| UI text sizes | 11/12/13/14/15/17/22/26 px, base 14 px | `--sand-font-size-xxs` … `--sand-font-size-xxxl` | +| Transcript item kinds | user, thinking, assistant-text, tool-call, send-message | `index.eager-app` function `zfn` | +| Thinking label | "思考中" | id `AUV+TY` | +| Message actions | 複製, 重新傳送, 停止 | ids `he3ygx`, `MyjAbr`, `NnvXp5` | + +## Bots +| What | Value | Source | +|---|---|---| +| Bot fields | 名稱, 描述, 頭像 (shape + colour) | ids `RFtRvq`, `NDq8h1`, `fopc9x` | +| Avatar shapes | 26: arch, bean, blob, capsule, cloud, clover, crystal, cylinder, dome, egg, flower, gem, heart, hex, house, leaf, pebble, pumpkin, shield, sparkle, square, squircle, star, tablet, teardrop, wedge | `dist/electron-main/menubar-tray/assets/bot-*.png` | +| Rebuild shapes | 12 of them, drawn as simple shapes (look estimated) | own drawing | + +## Look (light theme) +From `dist/renderer/assets/index-Dh7EWA8t.css`, block `:root,.sand-1vbcnu7`: +pane `#fff`, chrome `#fcfcfc`, sidebar `#f5f5f5`, text `#1c1c1c`, secondary `#1c1c1cad`, +tertiary `#1c1c1c8f`, hover `#1c1c1c0f`, active `#1c1c1c14`, border `#1c1c1c1f`, +user bubble `#1c1c1c` with white text, agent bubble `#1c1c1c0f`, focus `#1e86fc`, accent `#1680f3`. +Bot colours come from `--sand-fill-*-bubble-user`: red `#c82532`, orange `#d2550a`, yellow `#c2730b`, +green `#0d9655`, cyan `#0d9381`, blue `#126fd4`, purple `#7442d1`, magenta `#c82779`, brown `#86592e`. + +Settings → 主題: 淺色 / 深色 / 跟隨系統 (ids `1njn7W`, `pvnfJD`, `Ea4oxV`). +強調色: Bot 顏色 / 黑色 (ids `p7v5PZ`, `THokF3`, description `eeSkqM`). + +Dark theme colours weren't found in the stylesheet or the scripts: **estimated**. +Chat column 720 px: **estimated**. No transcript max-width was found; the 708, 640 and 960 px max-widths +in the CSS belong to other views. + +## Computer tools and the work loop (taken over from the neighbouring deep investigation) +The deep investigation in `investigation.cany` (findings fnd_5–fnd_12) showed that the original's +agent loop runs on a remote "box". The desktop app only hosts a local tool runner +(`dist/local-exec-daemon/main.cjs`) and the permission layer. That session planned its own clean-room +harness (plan step 1 "doing", no code written yet). This version builds that plan here, in Rust, with +the model loop running locally against whichever provider the bot uses. + +| Rule | Value | Source | +|---|---|---| +| Search deadline | 25 000 ms | local-exec-daemon @2983388 `Mu.MAIN_TIMEOUT_MS=25e3` | +| Search hard cap | 10 000 lines | same, `Mu.HARD_MAX_OUTPUT_LINES=1e4` | +| Lines handed to the model | 2 000 | @2966784 `Jut=2e3` (client limit) | +| Largest file read | 8 388 608 bytes (8 MiB) | @2951190 `gut` | +| Long command | not killed: moved to the background, read later | @3010873 `Mye` (stop kind "background", reason TIMEOUT), @3016213 `timeoutBehavior===BACKGROUND` | +| Background read-back | `read_terminal` | @2959283 `readTerminalFile` | +| Command output kept | start + end, middle counted | ShellFailure fields `output_head`, `output_tail`, `elided_chars` (@2186946); 6 000 + 6 000 chars **estimated** | +| Foreground wait before background | 30 s, model may ask up to 600 s | **estimated** (the original gets it per call from the server) | +| Permission order | refuse > hidden files > allowed > ask | fnd_8: `shouldBlockShellCommand`, `isShellCommandFullyAllowlisted`, cursorignore/repo-block globs, approval callbacks | +| Chained commands | split on `&&`, `||`, `;`, `|` and checked one by one; `$(…)`, backticks, `eval` always ask | fnd_8 (tree-sitter-bash parsing), own splitter | +| "Always allow" | remembered per bot (e.g. "修改檔案", "npm install") | fnd_12 `recordLocalToolStandingGrant` | +| Never claim done without a result | in the bot's instructions | fnd_10 design lesson (paraphrased, not copied) | +| Summarize at | 70 % of the context | main-app.cjs `client_speculative_summarization_config.tokenUsageThresholdPercentage:70` | +| Extra room mid-task | +5 % (summarize again at 75 % between steps) | same config, `tolerancePercentage:5` (how it's applied is our reading) | +| Context size | 128K tokens by default, per bot | **estimated** | +| Token estimate | 4 Latin chars or 1 Chinese char ≈ 1 token | **estimated** | +| Steps per turn | 25 | **estimated** | +| Messages kept word for word | newest 6, cut only before a user message | **estimated** | + +### Native helpers behind the tools (decompiled) + +**`cursor_proclist.node`** (Mach-O arm64 bundle, 125 536 bytes, sha256 d5df3fb7…), the original's +process scanner. Strings found with native_strings_find (evidence `ev_3d585c56…`), and functions read with +native_function_pseudocode: + +| Address | Function | Rule taken | +|---|---|---| +| `0x6748` | `getDescendantPids(vector, bool)` | Breadth-first over children: `proc_listpids(6 = PROC_PPID_ONLY, pid, buf, 0x1000)`, de-duplicated in a hash set, and the loop stops once `0xfff` pids are collected, **at most 4096 processes** (evidence `ev_768ea7aa…`) | +| `0x3d00` | `ProclistScanWorker::Execute` | Calls `getDescendantPids(roots, true)` (roots included), then `getProcessInformation` for each and drops any that fail (evidence `ev_6aba01a8…`) | +| `0x5234` | `getProcessInformation(int, ProcessInfo&)` | `proc_pidinfo(pid, 3 = PIDTBSDINFO, 0x88)` for the parent; name via `proc_name`, else ""; arguments via `sysctl` KERN_PROCARGS2 only when under `0x40000` bytes; agent tags read from the environment (`…CONVERSATION_ID=` 0x17 chars, `…REQUEST_ID=` 0x12 chars); `proc_pidinfo(pid, 4 = PIDTASKINFO, 0x60)`: **memoryMB = resident bytes >> 20**, **cpuTimeMs = (user + system) × timebase.numer / timebase.denom / 1 000 000** (magic-number divide `0x431bde82d7b634db >> 18`) (evidence `ev_257a505c…`) | +| `0x1fdc`, `0x6cc0` | `cursorProclistSystemMemory`, `getSystemMemoryInfo` | Reads `kern.memorystatus_vm_pressure_level` (not used here) | + +How the rebuild applies it (app tools.rs `descendants`, `tree_stats`, `kill_tree`): every command is +tagged with `ANYBOT_CONVERSATION_ID` (the bot) and `ANYBOT_REQUEST_ID` (the tool call). These are our +own names for the original's vendor-prefixed tags. Pressing stop, or quitting, ends the command **and +everything it started** (up to 4096 processes, newest first), not just the shell. `read_terminal` reports +process count, CPU seconds and memory MB with the formulas above (via `ps`). + +**`sand-op-launcher`** (universal Mach-O, 136 368 bytes, sha256 1337d620…). Strings in evidence +`ev_7d29f77e…`; `_main` read with native_function_pseudocode (evidence `ev_8006d51c…`): +- It verifies the 1Password CLI's code signature (`SecStaticCodeCheckValidity` against + `identifier "com.1password.op" … OU 2BUA8C4S2C`), and re-checks the file before `execve` ("changed before launch"). +- It accepts only exact argument shapes (`item move`, `item list`, `vault list/create`, `account list`, + `service-account create`, `--version`); anything else is refused ("unsupported invocation"). +- It applies the sandbox profile `(version 1)(allow default)(deny file-read* (with no-report) (subpath (param "ONEPASSWORD_GROUP_CONTAINER")))`. +- It builds a fresh environment (`OP_CACHE=false`, `OP_BIOMETRIC_UNLOCK_ENABLED=true`, `OP_LOAD_DESKTOP_APP_SETTINGS=false`). + +How the rebuild applies it (app tools.rs `build_command`): on macOS every command runs under +`sandbox-exec` with the same profile pattern, extended to deny reading **and writing** the app's own data +folder (where API keys are saved), `~/.ssh`, `~/.gnupg`, `~/Library/Keychains`, `~/.aws` and +`~/.config/gcloud`. Paths are resolved first, because the sandbox matches real paths. The provider API-key +variables are removed from the command's environment, and the app's data folder is also on the +never-read list for file tools. + +Read-only commands that run without asking: ls, pwd, cat, head, tail, wc, echo, rg, grep, find, file, +stat, du, df, date, which, tree, sort, uniq, diff, git status/diff/log/show (own list). Always refused: +sudo, su, rm -rf /, rm -rf ~, mkfs, dd, shutdown, reboot, diskutil erase. Never read or written: +.git internals, .ssh, .gnupg, Keychains, .env files, *.pem, *.key, id_rsa/id_ed25519, .netrc. + +## Turns, permission hand-over, computers and app control + +### What counts as one turn +| Rule | Value | Source | +|---|---|---| +| A turn starts with | a person's message, or a "wake" (a routine or other trigger firing) | main-app.cjs @3100283 `w3n`/`P3n`: turn counter `n+=1` only for kind `human` or `wake` (`SimulatedMsgReason.SUBSCRIPTION` → wake) | +| A message sent while it works | steers the running turn and is **not** a new turn | same, `P3n`: `e.turnSteer===!0 ? null` (steer messages don't count) | +| Turn outcomes | success, cancelled, error | node-agent-coordinator `wl=["success","cancelled","error"]`, read back by `k6` as {turnId, outcome, settledAtMs} | +| Bot is idle only when | not running, no helper tasks running, not waiting for the person, no computer hand-off pending | node-agent-coordinator `ZF`: `!isRunning && hasRunningSubagents!==true && awaiting===undefined && boxHandoff===undefined` | + +Rebuild (app agent.rs, app.rs): a message typed during a turn goes into the turn's steer queue and +reaches the model at its next step. If the model has just finished, the turn keeps going. Anything that +arrives after the turn has settled starts the next turn. Each turn records {turn id, outcome, +settled time}: stop is cancelled, a failure or stall is error, otherwise success. + +### Computers +| Rule | Value | Source | +|---|---|---| +| Default place to work | the bot's own computer ("Bot 的電腦", yGkA3X) | settings map `b$.computers` / `y$.botComputer` | +| This Mac | only after the person turns on "允許 Grok Bot 在這台電腦上執行任務,而不只是在它自己的電腦上" | setting `local-execution`, ids fuUvu2 / Hd1KB+ | +| Approvals name the machine | "允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?", "請在 {targetMachineDisplay} 上回應" | ids eQViQE / ykDLyL | +| Taking over the bot's computer | "接管" (take over), then hand back | id yqwjpd; renderer `handBack`/`handBackForeverBox` @2601598 (not rebuilt; see below) | + +Rebuild: "Bot 的電腦" is a sealed folder per bot (`~/AnyBot 工作區/bot-N`). Its commands run under +the macOS sandbox with nothing else in the home folder readable or writable, like a separate machine, so +it works there without asking. "你的電腦" is this Mac with the layered permission rules, and only after the +setting is on. Other computers are added in Settings → 電腦 and reached over SSH (read-only commands run +directly, others ask). Every tool has a `computer` choice, so "在我的 Mac 上…" picks this Mac. + +### Permission hand-over +| Rule | Value | Source | +|---|---|---| +| Rule choices | 先詢問 / 每次詢問 / 一律允許 / 永不允許 | ids 4YjKLA, 6CTZeX, NoKBgy, wvd4WD | +| Auto-review | "依照自訂規則審核 Bot 的操作", two lists: 「無需詢問直接允許」(continue automatically) and 「一律詢問」(stop and ask) | ids 9yhl+F, geCUKn/JOecNC, 1YRRmk/Pca/iV | +| Conflicts | "規則衝突時,「先詢問」優先"; built-in safety checks always apply | ids NFRGrQ, n9YiIv | +| Standing grants | "always allow" remembered (machine-scoped in the original) | fnd_12 `recordLocalToolStandingGrant` | + +Rebuild: built-in refusals run first. For an action that would ask, auto-review (when on) asks the +model to match it against the two rule lists, and an "always ask" match wins. "Allow" continues without +a card, and the result notes which rule matched. Otherwise the approval card names the computer. + +### Asking for permission when it's needed (pop-up) +The original asks on the spot and names the machine ("允許 Grok Bot 在 {targetMachineDisplay} 上使用 +{app} 嗎?", eQViQE; "請在 {targetMachineDisplay} 上回應", ykDLyL). The rebuild never lets a step fail +quietly for lack of permission: +- Computer tools are always offered to the model. If they're off for this bot, the step asks + "允許 {bot} 使用電腦工具嗎?": 允許這一次 / 一律允許這個 Bot 使用電腦 (turns the bot's tools on) / 拒絕. +- If the step targets 你的電腦 while 在這台電腦上執行 is off, it asks "允許 {bot} 在你的電腦上執行任務嗎?" + (wording from Hd1KB+): 允許這一次 / 一律允許 (turns the setting on) / 拒絕. +- Every request, including single actions, shows as a centred pop-up over a dimmed window, even when + another bot's chat is open. The Dock icon bounces (`RequestUserAttention`) when AnyBot isn't in front. + "稍後再決定" hides the pop-up; the chat card, the header pill "● 需要你允許" (click to reopen) and the + sidebar's highlighted "需要你允許" stay until answered. +- The bot is told to just call the tool and let the app ask, rather than telling the person to change + settings. Tested by `missing_permission_pops_up_and_continues`. + +### Notifications when you're away (decompiled) +**`sand_notification_settings.node`** (Mach-O arm64, 102 368 bytes, sha256 6b2b9fa3…). Strings in evidence +`ev_a3c3cb3d…`, functions read with native_function_pseudocode: + +| Address | Function | Rule taken | +|---|---|---| +| `0x35b4` | `getNotificationSettings(NotificationSettingsInfo&)` | Only on macOS ≥ 10.14 (`__isPlatformVersionAtLeast(1,10,14,0)`) and only inside a real `.app` bundle (bundlePath must end in ".app"). Asks `UNUserNotificationCenter` and waits on a semaphore for at most **3 s** (`dispatch_time(0, 0xb2d05e00)` ns). `authorizationStatus` 0..4 → notDetermined/denied/authorized/provisional/ephemeral, otherwise "unknown"; `alertSetting` 0..2 → notSupported/disabled/enabled, otherwise "unknown" (evidence `ev_7feb06d3…`) | +| `0x3898` | its completion block | Stores authorizationStatus and alertSetting, then signals the semaphore (evidence `ev_32393aac…`) | +| `0x2de8` | `NotificationSettingsWorker::OnOK` | Resolves `{platform:"darwin", setting, alertSetting}` (evidence `ev_ec969729…`) | + +From main-app.cjs: +| Rule | Value | Source | +|---|---|---| +| Permission mapping | authorized/provisional/ephemeral → granted, denied → denied, notDetermined → undetermined | @3183391 `hBt`, `mur` | +| Banners allowed | only when authorized **and** alerts enabled | `fBt` | +| You count as present | signed in, screen not locked, not idle, window alive | `K4` | +| Idle after | **90 s** without input | `Z_e=9e4` (10 min `cBt=10*6e4` while a phone push is being held) | +| Presence re-checked every | **10 s** | `uBt=1e4` | +| Setting text | "Bot 完成任務或等你處理時通知你" | id ttdOeI | + +Rebuild (notify.rs): presence = window in front **and** input within 90 s (idle read from macOS +`HIDIdleTime` every 10 s). When you're not present and a bot needs permission, or finishes a turn, a +macOS banner appears with a sound, on top of the pop-up and the Dock bounce (Settings → 一般 → 通知, +on by default). A build-folder app isn't a `.app` bundle, so, as the original's module shows, it can't +read its own notification permission. Banners go through macOS's `display notification` instead, which +may need allowing once under System Settings → Notifications. The 3 s settings read and the permission +mapping aren't used for that reason. + +### 直接操作 (work like Grok Bot without a stream of questions) +Why the original rarely asks: it does most work on **its own cloud computer and browser** ("Bot 的電腦"), +so nothing on the Mac needs permission. Working on the Mac is one switch (`local-execution`, +Hd1KB+), after which **auto-review** checks actions instead of the person ("讓 Grok Bot 在你的電腦上開啟 +檔案和執行任務。自動審核仍會先檢查所有操作", ENHi8c; hrkSSQ). Per-app grants are remembered +(`recordLocalToolStandingGrant`, `listComputerUseAllowedApps`, fnd_12). + +Rebuild: **Settings → 電腦 → ⚡ 直接操作**, or the "⚡ 全部允許,之後直接操作" link on any request. +- One switch turns on 在這台電腦上執行, every bot's computer tools and auto-review. Running turns stop + asking at once, and other bots' waiting "may I use the computer" requests are answered yes. +- Actions that would ask go ahead with the note "直接操作模式:已自動允許". The only exceptions are + actions that match an "always ask" rule (checked by the model, as in auto-review). The default rules + are own wording: 刪除檔案或資料夾 / 付款、購買或轉帳 / 用我的帳號發文、留言,或傳訊息給別人. +- Still blocked: the built-in refusals (sudo, mkfs, rm -rf /…), secret files and AnyBot's own data + (sandbox), as in the original ("內建的安全檢查一律有效", n9YiIv). +- macOS's own one-time prompt to let AnyBot control a browser can't be skipped by any app. +- Tested by `direct_mode_asks_nothing_but_still_blocks_danger`. + +**Admin locks (decompiled).** `sand_notification_settings.node` `managedPolicyValueSync` at `0x1870` +(evidence `ev_86419910…`) takes `(productName, valueName)`, both of which must be strings (napi type 4), +otherwise it throws "managed_policy_value_sync(productName, valueName)". On macOS it then returns null: +it reads nothing on a Mac. The app side (main-core.cjs @727949, `EZ`/`TZ`/`Ak`/`mZ`/`gZ`) reads policy +from **macOS user defaults** (`systemPreferences.getUserDefault(key, "string")`, which MDM profiles can +force), from `~/.cursor/policy.json` on Linux, and from the native call on Windows. Missing or blank +values mean **unmanaged**, a valid value means **pinned**, and anything else is **invalid** (its URLs must be +plain https origins). + +Rebuild (policy.rs): the same three kinds for two switches, read from macOS defaults domain +`com.anybot.AnyBot` (`AllowDirectMode`, `AllowLocalExecution`; Linux: `~/.anybot/policy.json`): +- unmanaged lets the person decide; pinned true/false fixes it; invalid locks it **off** (fail safe). +- A locked switch is greyed out with "🔒 由你的管理員設定,無法變更". The ⚡ link is hidden. +- A locked "你的電腦" is refused at once instead of asking. Locks are re-read each time Settings opens. +- Example: `defaults write com.anybot.AnyBot AllowDirectMode false`. + +### Websites and the person's browser +The original reaches sites through its own browser (its old Chrome login copy is retired, see below). +The rebuild (web.rs) gives every bot: +- `web_fetch`: public pages as text (20 s timeout, 3 MB read, 20 000 characters to the model: **estimated**). + No permission needed. +- `browser_open`: opens a link in the person's own browser on this Mac (Chrome, Arc, Edge, Brave, + Chromium or Safari, whichever is open), where they're already logged in, and waits 4 s (**estimated**). +- `browser_read`: title, address and visible text of the current tab, via AppleScript. It asks first + (grant 「讀取瀏覽器分頁」). +- Both browser tools count as working on 你的電腦, so the computer pop-ups apply when those are off. + macOS asks itself the first time AnyBot controls a browser. If that's refused (-1743), the app opens + System Settings → 隱私權與安全性 → 自動化 and says what to switch on. If page text is blocked, it says + which menu item to turn on (Chrome: 檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript). +- **Which browser** (decompiled). `sand_window_capture.node` (Mach-O arm64, 128 064 bytes, sha256 + f2b48533…; strings in evidence `ev_6c197ec3…`): + + | Address | Function | Rule taken | + |---|---|---| + | `0x52c0` | `listWindows()` | `CGWindowListCopyWindowInfo(0x11, 0)`: on-screen windows only (`0x1`) without desktop elements (`0x10`), front to back. A window is kept only if its alpha > 0 and its bounds have width > 0 and height > 0. Each entry records window id, owner pid, layer, bounds and owner name (evidence `ev_fbc46bcb…`) | + | `0x5258` | `screenCaptureAccess()` | macOS ≥ 14 (`__isPlatformVersionAtLeast(1,14,0,0)`), else 2 = unsupported; otherwise `CGPreflightScreenCaptureAccess() ^ 1` (0 = granted, 1 = not granted) (evidence `ev_e0e9c7c2…`) | + | `0x651c` | `statusForError(NSError*)` | error code −3801 (the user declined capture) → status 2 "denied"; any other → 5 "failed" (evidence `ev_7c417898…`) | + + Rebuild: `browser_read`/`browser_open` read the same front-to-back window list (through macOS's own + JavaScript bridge) with the same filter, plus layer 0 for ordinary app windows (ours), and use the + browser whose window is **closest to the front**, the tab the person is actually looking at. If no + browser window is visible they fall back to the first open browser, then Chrome, then Safari. + Screen capture itself isn't rebuilt: reading the page text needs no screenshot. +- The bot is told not to say it has no tools, and to try these first. Checked against xAI grok-4.7: + "幫我看一下我的threads 看一下我的帳號" now gives `browser_open(threads.net)` + `browser_read`. + +### AnyBot's own browser (replaces driving the person's browser) +Grok Bot never asks the person to change browser settings because it browses in **its own browser** +(its Chromium-based browser and webviews on the bot's computer). Driving the person's Chrome through +AppleScript, the first approach here, needed Chrome's "允許來自 Apple 事件的 JavaScript" plus a macOS +Automation prompt. + +**Copying logins from Chrome: retired in the original, not rebuilt.** +- Native main program (`Grok Bot` Mach-O, opened with core_target_open): searching its strings for + browser / chrome / cdp / devtools / remote / port / debug finds **0** strings (evidence + `ev_3dc3f004…`), so no browser or cookie logic lives in native code; it's all in the JavaScript. +- The renderer still has the old approval card (chunk-view @3041: 「{botName} 想要使用你現有的登入」, + 「從你電腦上的 Chrome 複製 Cookie…」, 檢視網站 / 允許一次 / 一律允許 / 拒絕, sites grouped by Chrome + profile), but the error catalogue in main-core.cjs and index.eager-app (domain `chrome-import`, + codes **SAND-E0801 … SAND-E0812**: cookieApprovalNoMachine … chromeCookieImportFailed) marks every + one "retired: the agent-prompted cookie sync that raised it was removed" / "retired: the Chrome + cookie import that raised it was removed", and the inject was behind a gate + (`sand_import_chrome_cookies`, SAND-E0811). +- So the shipping original no longer copies Chrome cookies. The rebuild matches that: nothing is + copied, and the bot is told never to offer it. (Also: this Mac's Chrome/Brave data folders refuse + reads without Full Disk Access, "Operation not permitted", so it couldn't work quietly anyway.) + The person logs in once in AnyBot's own browser window instead. + +Rebuild (botbrowser.rs): AnyBot starts a separate Chrome-family profile (Chrome, Brave, Edge or +Chromium, whichever is installed) as **its own browser window**, with logins kept in +`…/AnyBot/browser`. It talks to it over Chrome's DevTools protocol on local port **9333** +(**estimated**, overridable for tests): +- browser_open: starts the window (waits up to 20 s, **estimated**) or adds a tab (`PUT /json/new?url`). +- browser_read: picks the first real page tab (`/json/list`) and reads + `{title, location.href, document.body.innerText}` with `Runtime.evaluate` (20 s limit, **estimated**). +- No browser setting, no Automation prompt, no "your computer" question: it's the bot's browser. + The person logs in to a site in that window once; a short page mentioning 登入 / log in gets a note + asking them to. +- The bot is told never to send the person to browser menus. The AppleScript path remains only as a + fallback when no Chrome-family browser is installed (Safari only). +- Checked live against a hidden Chrome: example.com was read as "標題:Example Domain". + +### Browser profiles (設定檔) +Grok Bot lets the person use any Chrome profile because on "your computer" it drives the real screen +through its Computer Use helper app (local-exec-daemon: `Grok Bot Computer Use`, +`co.anysphere.grok-bot-computer-use`, `CUGrokBotService`): it clicks in their normal Chrome like a +person would. The original has no browser-profile code of its own (no `--profile-directory`, +`Local State` or `info_cache` anywhere; `profileDisplayName` only on the retired login-copy card). +AnyBot doesn't control the screen, and its own browser couldn't see the person's Chrome profiles +(macOS refuses other apps `…/Google/Chrome/Local State`: "Operation not permitted"). So +(rebuild, **estimated** rules): +- AnyBot's browser has its own profiles: 「預設」 (the existing `…/AnyBot/browser` folder, logins kept) + plus any added ones in `…/AnyBot/browser-profiles/`. Each is its own window with its own logins. +- Names: 1–30 characters, no `/` or `:`, not starting with `.`; matched ignoring case. +- 「預設」 keeps port 9333; added profiles start with port **0**, so Chrome picks a free port and writes + it to `DevToolsActivePort` in the profile folder, which AnyBot reads back (checked live: Chrome 156 + wrote 62337 and answered on it). +- The profile in use is remembered in `…/AnyBot/browser-profile.txt`. +- Bot tools: `browser_profiles` (list / add / use, no permission needed: it's the bot's browser), and + an optional `profile` on browser_open / browser_read (opening in a profile makes it the one in use). + The bot is told to use these when the person says profile / 設定檔 / another account. +- Settings → 電腦 → 「AnyBot 瀏覽器設定檔」: pick the one in use, 「開啟視窗」 to log in, 「新增設定檔」. + Removing a profile isn't offered (it would delete its logins). + +### 「你的瀏覽器」: reading the person's own Chrome, any profile (decompiled) +How Grok Bot reads the person's real Chrome in whatever profile is open: its Computer Use helper +(`Helpers/Grok Bot Computer Use.app/Contents/MacOS/CUGrokBotService`, Mach-O fat, sha256 +1004a8cb…) turns on Chrome's accessibility tree and reads the screen's elements. Strings: +`com.google.Chrome`, `com.google.Chrome.`, `Google Chrome Framework.framework` (evidence +`ev_75dff652…`), `AXManualAccessibility` / `AXEnhancedUserInterface` (`ev_bef6c29e…`), the 3 +framework names (`ev_a8cda95a…`). Functions read: + +| Address | Function | Rule taken | +|---|---|---| +| `0x1000a1b6c` | `CURendererApp.isRenderer(bundleIdentifier:executablePath:)` | renderer if bundle id == `com.github.Electron` or == `com.google.Chrome`, or has prefix `com.google.Chrome.`, or the executable path contains `/Electron.app/Contents/MacOS/Electron`, else `isRendererBundle` (evidence `ev_4973f594…`) | +| `0x1000a19ec` | `CURendererApp.isRendererBundle(atPath:)` | `/Contents/Frameworks/` exists for one of 3 names: `Electron Framework.framework`, `Chromium Embedded Framework.framework`, `Google Chrome Framework.framework` (evidence `ev_f0dc9836…`) | +| `0x1000a1cdc` | `CURendererApp.isRenderer(NSRunningApplication)` | takes the running app's bundle id, bundle URL path and executable URL path, then calls the rule above (evidence `ev_179b4f10…`) | +| `0x1000a1554` | `CURendererAccessibility.writeFlags(on:)` | `AXUIElementSetAttributeValue(app, "AXManualAccessibility", on)` then `(app, "AXEnhancedUserInterface", on)`; result from both error codes (evidence `ev_f661d29e…`) | + +Rebuild (axread.rs), used by the profile choice 「你的瀏覽器」 (aliases 我的 Chrome, 你的 Chrome…): +- Same renderer rule (`is_renderer`, constants copied as written) and the same two flags, in order, on + the person's browser before reading. Safari etc. need no flags. +- macOS asks once for 輔助使用 (`AXIsProcessTrustedWithOptions` with the prompt). If it's off, AnyBot + opens 系統設定 → 隱私權與安全性 → 輔助使用 and says to switch on AnyBot. No browser setting is needed. +- Reads the focused window → first `AXWebArea` → text of `AXStaticText` (`AXValue`), one line per + group; title `AXTitle`, address `AXURL`. Up to 20 000 elements, depth 80, 5 tries 0.7 s apart + while Chrome builds the tree, 2 s per answer (all **estimated**). +- The person's browser process is told apart from AnyBot's own window by its arguments (AnyBot's + runs with `--user-data-dir=…/AnyBot/browser`; helper processes carry `--type=`). +- browser_open on 「你的瀏覽器」 starts the browser's own executable with the link, so Chrome hands it + to the person's running window (their normal profile) instead of AnyBot's window. +- It counts as using 你的電腦: the computer pop-ups apply and reading asks first (「讀取你瀏覽器目前 + 分頁」), like before. Switching Chrome profiles is done by the person in Chrome; the bot reads the + window in front. +- Not checked live yet: this test process isn't allowed 輔助使用 (trusted=false). + +### Talking to the bot runs the app +Routines are made in chat ("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。", ZJmAzX), +and the primary bot can coordinate other bots (mQQ/Uq). App tools in the rebuild (appctl.rs): +list_routines, create_routine, update_routine, delete_routine (asks first, wording VAgxC4), +run_routine_now, list_bots, create_bot, update_bot (provider, model, name, pin, default computer), +message_bot (wakes another bot), list_computers, update_settings (theme, accent, time zone). Turning +on "在這台電腦上執行" stays person-only. + +### Routine schedules +Shape from index.eager-app @1508626 (`Prt`, `Irt`): `{frequency: daily|weekly|monthly|yearly, interval, +daysOfWeek: MO..SU (yA), monthDay | weekdayOfMonth {week, day}, month, time "HH:MM", timezone}`. +Descriptions use the original's zh-TW strings: 每天 / 每{n} 天 / 每個工作日 / 每週的{days} / 每月{d}日 / +每月的{week}{day} / 每年{date}, then "{repeats} {time}" and "({zone})" when not the person's time zone. +"Every weekday" means exactly MO–FR (`isEveryWeekday`, `WORKWEEK`). Next-run rules (ours): intervals +count from the creation date; a month day past the month's end runs on the last day; summer-time gaps +take the earliest valid time. A routine that comes due while the bot is busy waits until it is idle. + +### Screen control on 你的電腦 (computer use) +The original's local daemon (local-exec-daemon main.cjs) drives its Computer Use helper with these +tools; the rebuild (computer.rs) uses the same names and arguments: +`computer_use_screenshot` {}, `computer_use_click` {method: coordinate, x, y, button, count, +modifier_keys}, `computer_use_scroll` {x, y, direction, amount, modifier_keys}, +`computer_use_mouse_move` {x, y}, `computer_use_drag` {button, path, modifier_keys}, +`computer_use_typing` {value}, `computer_use_press_key` {key, hold_duration_ms} (main.cjs @2860904). +- Screenshot size, `zct` (main.cjs): long edge fitted to **1024** (`Gct`); if the short edge is under + **8** (`Hs`) nothing is resized; after scaling, an edge under 8 is raised so the short edge is 8. + Largest image **1 048 576** bytes (`mg = 1024*1024`). JPEG is tried at quality **85, 70, 55** (`$3r`) + and the first that fits is used, else the smallest one if it's under the cap (`j3r`). +- Rebuild: `screencapture -x -m` of the main screen, fitted and encoded by those rules, sent to the + model as a picture (Claude: inside the tool result; OpenAI-style: one user message with the + pictures right after the run of tool results). Only the newest **3** screenshots are sent; older + ones say 「較早的截圖已省略」 (**estimated**). Screenshots aren't saved with the chat. +- Coordinates are pixels of the latest screenshot; AnyBot turns them into screen points with the + screen size (`CGDisplayBounds`) ÷ picture size. Shortcuts like `cmd+shift+t` use the US key codes. +- Mouse and keyboard timing, decompiled from `CUGrokBotService` (evidence ids after each): + + | Address | Function | Rule taken | + |---|---|---| + | `0x100010120` | `CUActuator.click(pid:screenPoint:windowBounds:…button:count:holdSeconds:)` | event source `CGEventSourceCreate(1)` (HID system state) with `CGEventSourceSetLocalEventsSuppressionInterval(0)`; press → hold → release where hold = `holdSeconds > 0 ? holdSeconds : 0.03`; `count` clicks with **0.08 s** between them; error "Could not create click event"; background windows get a window-local click (fields 91/92 = window id, "skylight-no-raise") (`ev_f3077201…`) | + | `0x100010a24` | `CUActuator.typeCharacters(pid:text:)` | empty text → "`value` is required and must be a non-empty string"; same event source, then `postChunks` (`ev_77b2182b…`) | + | `0x1000beb00` | `CUTypedCharacters.utf16Chunks` | chunks of at most **20** UTF-16 units (buffer 0x14; a new chunk when the next character would reach 21), never splitting a character (`ev_0dce1c89…`) | + | `0x1000beebc` | `CUTypedCharacters.postChunks` | each chunk: `CGEventCreateKeyboardEvent(src, 0x80 = 128, down/up)` + `CGEventKeyboardSetUnicodeString`, flags 0; down → **0.035 s** → up; **0.02 s** between chunks; error "Could not create character keyboard event" (`ev_714c5253…`) | + | `0x100031764` | `CUCompanionToolService.handlePressKey` | presses a list of keys in order: each down → **0.035 s** → up, **0.02 s** between keys; result "Pressed {keys joined by spaces}."; error "Could not create keyboard event" (`ev_730d67a4…`) | + + | `0x10008c34c` | `CURemoteEventPoster.drag(path:button:steps:holdSeconds:flags:)` | fewer than 2 points → "Drag path requires at least two points."; mouse down at the first point (click state 1), then if hold > 0 sleep it in 10 000 µs slices, then for every segment post `steps` dragged events at `start + delta × i / steps` (i = 1…steps, steps at least 1), then mouse up at the last point (`ev_f0d60f2e…`) | + | `0x10009dee4` | `CURemoteToolService.handleDrag` (closure) | calls drag with **steps 6** and **holdSeconds 0.04**; result "Dragged from (%.0f, %.0f) to (%.0f, %.0f) with left/right/middle button." (`ev_65c07c1e…`) | + | strings | `handleScroll` / `handleDrag` | "`direction` must be `up`, `down`, `left`, or `right`.", "`amount` must be an integer.", "`path` must be an array of at least two {x,y} points in model coordinates.", "Could not create scroll event" (`ev_0b3df00c…`) | + + Drag in the rebuild: same press, 0.04 s hold, 6 interpolated steps per segment, release at the + end, and the reply 「已從 (x, y) 拖曳到 (x, y)(左鍵)」. Scroll refuses a non-integer amount. + + | Address | Function | Rule taken | + |---|---|---| + | `0x10009e200` | `CURemoteToolService.handleScroll` | `direction` is lowercased and must be up/down/left/right; `amount` must be an integer, default **3**, kept to **1…20** (`max(amount,1)`, `min(…,20)`); distance = `(amount + amount) << 3` = **amount × 16** pixels; down and right are negative; `x` and `y` "must be provided together", and without them the current pointer position is used ("Could not read the current cursor position."); needs Accessibility and Screen Recording (`ev_98625ed7…`) | + | `0x10009e998` | handleScroll closure | posts a mouse-moved event (type 5) at the spot, then `CGEventCreateScrollWheelEvent2(src, units 0 = pixel, wheels 2, vertical, horizontal, 0)` at the same spot with the modifier flags; result "Scrolled {direction} x{amount}." (`ev_ee9d8372…`) | + + Rebuild scroll: the same default, range, 16-pixel steps in pixel units, the both-or-neither x/y + rule (no screenshot needed when scrolling where the pointer is), and 「已捲動 down ×3。」. + Rebuild: the same event source, the same holds and gaps, the same chunking on key 128, and a key + press may be several keys separated by spaces (`cmd+a delete`). `hold_duration_ms` replaces the + 0.035 s hold (at most 5 s, **estimated**). Window-local background clicks (clicking without + bringing the window forward) aren't rebuilt: AnyBot clicks where the pointer is, like a person. + Pauses between mouse-move and drag steps are 8 ms (**estimated**). +- Permissions: macOS asks once for 螢幕與系統錄音 (screenshots, `CGPreflightScreenCaptureAccess` like + `screenCaptureAccess()` @0x5258) and once for 輔助使用 (mouse and keyboard); if off, AnyBot opens the + right System Settings pane and says what to switch on. In AnyBot, looking at the screen asks once + (grant 「看你的螢幕畫面」) and using the mouse/keyboard asks once (「操作你的滑鼠和鍵盤」), both with + "always allow"; 直接操作 skips the questions except for "一律詢問" rules. It always counts as 你的電腦, + so the computer pop-ups and admin locks apply. +- Needs a model that can see pictures. +- Not checked live: this test process has neither permission. + +### Still different from the original +- "接管" (take over the bot's computer) is not rebuilt: the bot's computer here is a sealed folder, + not a cloud machine with a screen. Screen control works on 你的電腦 only. +- Routine triggers from events (GitHub, PagerDuty, Sentry: `GITHUB_EVENT_KINDS` etc.) are not rebuilt; + only time schedules. +- The look still uses the estimated layout: the signed-in original's window hasn't been captured. + +## Restarting and recovering + +### From the native main program (decompiled again for this feature) +`Contents/MacOS/Grok Bot`. native_strings_find for `relaunch|restart|ELECTRON|dev/null|crash` finds two +strings (evidence `ev_6af348eb…`), and both lead to these functions: + +| Address | Function | Rule taken | +|---|---|---| +| `0x1000004c0` | `sub_1000004c0` | Before anything else, for fd 0/1/2: if `fstat` fails with `errno == 9` (EBADF), `freopen("/dev/null", …)`, so a copy started without terminal streams still has valid ones (evidence `ev_29ffc0d8…`) | +| `0x100000460` | `entry` | Calls `0x1000004c0`, then a set `ELECTRON_RUN_AS_NODE` (fuse on, non-empty) changes how the program starts (`ElectronInitializeICUandStartNode` instead of `ElectronMain`) (evidence `ev_7c151be5…`) | + +### From the app code +| Rule | Value | Source | +|---|---|---| +| Relaunch helper | detached shell, all output ignored: `spawn(shell, ["-c", script, "grok-bot-relaunch", pid, app, …args], {detached:true, stdio:"ignore"})` | main-app.cjs @3195964 `Pur` | +| Wait before starting | `while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done; exec "$app" "$@"`: the new copy starts only after the old one exited, polled every **0.2 s** | same, `PBt` | +| Inherited files | closes fds > 2 first (`rEe`, Linux /proc only) | same | +| Button while it happens | "正在重新啟動…" (Restarting…) | renderer `ljn` → id fvg2KT | +| Bot's computer stuck | "Grok Bot 的電腦卡住時,從你上次儲存的快照重新啟動它"; recovering keeps files and logins, removes installed apps and packages | ids O+wR5I, ixKoax | + +### How the rebuild applies them +- **重新啟動 AnyBot** (Settings → 一般): stops running turns as cancelled and background commands, saves, + then starts a detached `/bin/sh` helper. The helper waits for this copy's pid to disappear, checking + every 0.2 s, and then starts the app, so the two copies never overlap and the last save can't be + overwritten. Its input and output go to `/dev/null`. Start-up switches (`ANYBOT_SHOT_VIEW`, the + per-command tags, `ELECTRON_RUN_AS_NODE`) are removed so the new copy opens normally. The button reads + "正在重新啟動…" while it happens. Tested by `relaunch_waits_for_the_old_copy`. +- **重新啟動這個 Bot** (⋯ menu): ends the bot's turn as cancelled, stops its background commands and + everything they started (by its conversation tag), and marks half-finished replies as interrupted. + Chats and the files on its computer are kept. The snapshot "recover" step isn't rebuilt: the bot's + computer here is a folder, not a machine image. +- **Interrupted turns on load**: a reply that was still streaming when the app closed is saved empty and + can't continue. On load it becomes "(這次的回應被中斷了…)" with an error settlement, so "重新產生" + appears. A message with no reply shows "重新傳送" (MyjAbr). + +## Versions and updating + +**Native main program:** native_strings_find for `version|update|0.78|x.y.z` in `Contents/MacOS/Grok Bot` +finds **0 strings** (evidence `ev_df0e0f59…`). The version (0.78.1) lives only in Info.plist +(`CFBundleShortVersionString`), and all update logic is in main-app.cjs. + +| Rule | Value | Source | +|---|---|---| +| First check after launch | 30 s | main-app.cjs @3372407 `bhr=3e4` (`initialCheckDelayMs`) | +| Then every | 1 h + random 0–5 min | `khr=3600*1e3`, `Ihr=300*1e3`, `nextPeriodicDelayMs = interval + floor(random·jitter)` | +| Apply by itself only when | auto-update on **and** update ready **and** (screen locked **or** screensaver running) **and** idle ≥ **600 s** **and** no agent work | @3365216 `uQt`, `phr`, `pQt=600`, `uhr` (`confirmed-idle`) | +| Auto-update default | on | `getAutoUpdateWhenIdle?.() ?? true` | +| Screen locked | `powerMonitor.getSystemIdleState(1) === "locked"` | `Aj`/`Cle` | +| Screensaver running | `pgrep -x ScreenSaverEngine` (2 s timeout) | `NRn`, `DRn` | +| Labels | "重新啟動以更新" (FzpXvM), "正在重新啟動…" (fvg2KT), "在你離開時自動更新用戶端" (4vprnG) | catalogs | + +Rebuild: the version shown is the program file's build time (sidebar footer, Settings → 一般 → 版本). +A newer build on disk shows the sidebar button "⬆ 重新啟動以更新(time)". Noticing it is a file check +every 10 s (**estimated**; it's local and cheap). Switching by itself follows the original's schedule: +first try 30 s after start, then hourly plus jitter, and only when the screen is locked or the +screensaver is running (`ioreg` session flag / `pgrep -x ScreenSaverEngine`), idle ≥ 600 s, and no +bot is working or waiting for you. It uses the same wait-then-start relaunch helper. Setting: 在你離開時 +自動更新用戶端 (on by default). Tested by `update_applies_only_when_truly_away`. + +## Left out of this version +Done since this list was first written: routines (例行任務), screen control on 你的電腦, AnyBot's own +browser with profiles, reading the person's own Chrome, removing an added browser profile. +Still left out: Slack/Telegram/WhatsApp/email messaging, team bots and sharing, payments (these need the +original's cloud service and accounts), routines started by events (GitHub, PagerDuty, Sentry need +webhooks into that cloud), "接管" of a cloud bot computer, voice input, file attachments, and the exact +signed-in window layout and dark-theme colours (never captured). + +## Look check (full pass over every screen) +- One font for everything: PingFang TC Regular (macOS system asset, found under + `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/…/PingFang.ttc`, face 2), falling back to + Hiragino Sans GB. Before, English came from egui's own font and Chinese from a fallback, on + different baselines and sizes. egui's fonts stay behind it only for missing symbols. +- That font's 「·」 already has space on both sides, so separators are written without extra spaces. +- Sidebar names and previews end in "…" before the status dot instead of being cut mid-letter. +- A step's copy/model line only shows under the bot's final words, so a tool's result sits right + under the step that asked for it. +- Settings and Bot 設定 stop 80 px short of the window height (**estimated**) and scroll inside; + the provider list is 200 px wide (**estimated**) so 「自訂(OpenAI 相容)」 fits on one line; + all avatar shapes fit on one row; the model list button says 「選擇」. +- Decompile note: the native main program (`Grok Bot`, Mach-O 53 040 bytes, sha256 df5b07de…) is + only Electron's launcher: a search for font / width / height / title finds **0** strings + (evidence `ev_7cf8e974…`). The look lives in the renderer styles (index-Dh7EWA8t.css), read here: + - Type scale `--sand-font-size-*`: xxs 11, xs 12, sm 13, base 14, lg 15, xl 17, xxl 22, xxxl 26 px; + `--sand-line-height-*`: 16, 16, 18, 20, 22, 24, 28, 32 px. + - `--cursor-font-family-sans`: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`, which on + a zh-TW Mac means SF for Latin and PingFang for Chinese. `--cursor-font-family-mono`: + `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, …`. + - Rebuild: body/buttons 14 (base), small 12 (xs), headings and window titles 17 (xl), code 13 (sm) + on 18 px lines, chat text 15 (lg) on 22 px lines, Markdown headings 17 on 24 px lines; code in SF + Mono (`/System/Library/Fonts/SFNSMono.ttf`, then Menlo). Text stays in PingFang TC for both + scripts, which matches -apple-system's Chinese and is close to SF for Latin (egui can't line up + two fonts' baselines the way the browser does). diff --git a/ORIGINAL_FEATURES.md b/ORIGINAL_FEATURES.md new file mode 100644 index 0000000..3f86b01 --- /dev/null +++ b/ORIGINAL_FEATURES.md @@ -0,0 +1,1036 @@ +# Grok Bot(原版)功能分析:完整清單 + +這份文件整理 **原本的 Grok Bot** 能做的所有事情,是從它安裝在這台 Mac 上的檔案分析出來的。來源有四種: + +1. **介面文字目錄**:App 裡全部 4,838 條繁體中文文字,也就是畫面上會出現的每一句話。 +2. **程式結構**:設定頁的分區、例行任務觸發條件的種類、錯誤代碼分類。 +3. **拆解原生程式**:操作螢幕的小幫手程式,取得點擊、打字、按鍵的時間,以及哪些 App 算 Chrome 類。 +4. **前面各輪的分析**:記在 MECHANICS.md,例如重試、更新、權限、例行任務的規則。 + +閱讀時請注意: + +- 「」裡是原版畫面上的文字。`{名稱}` 是之後會填入的內容。 +- 很多功能要靠原版的雲端服務才能運作,例如 Bot 的雲端電腦、付款、電話、團隊。這裡只描述它「能做什麼」,雲端內部怎麼運作,從檔案裡看不到。 +- 最後一節對照了 AnyBot 已經做了哪些、還沒做哪些。 + +--- + +## 目錄 + +1. 整體樣貌與設定頁分區 +2. 帳號、登入、多帳號 +3. 方案、試用、用量、帳單 +4. Bot:建立、外觀、整理、主要 Bot +5. 團隊 Bot、分享、成員、角色 +6. 聊天 +7. 任務、討論串、背景工作、Bot 提問 +8. 附件、語音、語音聊天、聽寫 +9. Bot 自己的雲端電腦 +10. 你的電腦(Mac)與其他裝置 +11. 操作螢幕(電腦使用)、接管、示範教學 +12. 權限、核准、自動審核、規則 +13. 例行任務、排程、事件觸發 +14. 外掛、市集、連接器、MCP +15. Slack、Microsoft Teams、Discord +16. 程式碼與雲端代理(Cursor、GitHub) +17. 訊息管道:Mac「訊息」、郵件、專屬號碼、專屬信箱、X +18. 打電話 +19. 聯絡人與 Apple App(備忘錄、行事曆、提醒事項、地圖) +20. 會議、錄音、文字記錄 +21. 檔案預覽、圖表、Bot 做的頁面與 App +22. 記憶、技能、範本 +23. 瀏覽器、網站登入、保險庫、1Password、匯入密碼 +24. 代你購物與付款(卡片、Link、密碼金鑰) +25. 生活卡片:機票、餐廳、購物、天氣、地圖 +26. 設定:外觀、通知、更新、隱私、系統 +27. 錯誤、狀態、意見回饋 +28. 原版內部規則(從程式拆出來的數字) +29. 和 AnyBot 的對照 + +--- + +## 1. 整體樣貌與設定頁分區 + +- **標語**:「你的全天候 Bot 團隊,把工作真正做完」「Grok Bot 有自己的電腦,工作方式和你一樣」。 +- **基本概念**: + - 你有很多個 Bot,每個 Bot 有自己的工作。 + - 其中一個是「主要 Bot」。 + - Bot 有自己的雲端電腦,也可以經你同意後使用你的 Mac。 + - Bot 可以連接各種 App,替你傳訊息、打電話、開會、購物、排程做事。 +- **設定頁共 27 個分區**(依程式順序): + - 一般 + - 整合 + - 電腦 + - 權限 + - 用量與帳單 + - 更新 + - 整合方案 + - 外觀 + - 通知 + - 討論串 + - 系統 + - Bot + - 團隊 Bot + - 聯絡人 + - 已連結的號碼 + - 你的電腦 + - 管理裝置 + - 電腦使用 + - Bot 的電腦 + - 「一律詢問」規則 + - 「無需詢問直接允許」規則 + - 個人用量 + - 用量明細 + - Grok Bot 用量 + - 用量重設 + - 管理方案 + - Grok Bot 更新 +- **每個 Bot 的分頁**:概覽、例行任務、媒體、電腦、會議、成員。 +- **錯誤代碼分類**(共 108 種,分 9 類): + - 代理回合(13) + - 授權(22) + - Chrome 匯入(12,已停用) + - 桌面與電腦(9) + - 重建(5) + - 註冊(1) + - 儲存(14) + - 連線傳輸(16) + - 更新(16) + +--- + +## 2. 帳號、登入、多帳號 + +- **用 Cursor 帳號登入** + - 「使用 Cursor 登入」「已以 {email} 身分登入」「將你的 Cursor 帳戶連結到 Grok Bot」。 + - 會處理逾時、登出、開錯瀏覽器的情況:「瀏覽器不對?複製連結,然後在你使用 Cursor 的瀏覽器中開啟。」 +- **多個帳號並存** + - 「新增另一個帳戶」「已儲存的帳戶」「切換帳戶」。 + - 可以替帳號加標籤,例如「為此帳戶加上標籤,例如「工作」或「個人」」。標籤「Grok」是保留名稱,不能用。 +- **登出只清這台電腦** + - 「登出會從這台電腦移除該帳戶及其本地資料。Bot 和聊天記錄仍保存在該帳戶的雲端電腦上,重新登入後即可恢復。」 +- **組織的裝置政策** + - 「此裝置上的登入受到組織裝置政策限制。」 +- **帳號合併**(整合方案) + - 「正在合併你的帳戶」「正在轉移你的資料」「你不需要再次付款」。 + - 有些設定在離開舊版後就不能改回來。 +- **個人資料** + - 「編輯個人資料」「輸入你的名字」、更換大頭貼。 +- **第一次使用的問卷** + - 「你的名字」「你主要會處理哪類工作?」「你最初是從哪裡得知 Grok Bot 的?」 + - 來源選項包括 Instagram 或 Facebook、網紅或創作者、電視或戶外廣告。 +- **身分驗證** + - 電子郵件 N 位數驗證碼,可以「重新寄送驗證碼」。 + - 錯誤提示:驗證碼不符、已過期、錯誤次數過多。 + - 也支援密碼金鑰(passkey)和硬體安全金鑰(YubiKey)。 + +--- + +## 3. 方案、試用、用量、帳單 + +### 方案 + +- 有這些方案和席位:Pro/SuperGrok、Pro+、Ultra、Premium 席位、Standard 席位、SuperGrok Plus/Heavy。 +- 不同功能需要不同方案,例如: + - 「Grok Bot 需要 Pro 方案或 SuperGrok」 + - 「Bot 邀請需要 Premium 或 Ultra 方案」 + - 「播放語音訊息需要 Cursor Pro」 + - 「傳送意見回饋前請先升級到 Ultra」 +- **整合方案**:「一個方案即可取得 Grok Bot、SuperGrok、𝕏 Premium 和 Cursor…我們會為你合併現有的訂閱。」 +- Ultra 方案:「透過 Ultra 每週取得 $1,000 的 Grok Bot 用量」。 +- SuperGrok Heavy 方案:附送 Grok Bot 和 Cursor Ultra。 +- Bot 可以在聊天裡直接幫你打開結帳畫面:「選擇{plan}以繼續結帳;在你完成前不會收取任何費用。」 + +### 試用 + +- 「開始試用」「試用將於 {date} 結束」。 +- 可以提早結束:「這會立即結束你的 Grok Bot 試用…無論如何都不會向你的信用卡收費,試用絕不會自動轉為付費方案。」 +- 推薦好友:好友前 2 週免費,好友首次付款 7 天後,你得到一次免費的用量重設。 + +### 用量 + +- **每週用量**:「訂閱所包含的用量。每週重設。」「7 天後重設」「你已使用每週用量限額的 0%」。 +- **手動重設**:可以提前重設用量,但有次數限制,例如「你可以在 {when} 再使用一次重設」。 +- **用量明細**:可以依應用程式、依 Bot、依來源分開看,也有個人和團隊兩種檢視。 + - 每日用量圖表只適用於 2025 年 11 月 20 日之後的帳單週期。 + - 資料最多可能快取五分鐘。 +- 可以直接問 Bot:「哪些消耗最多?我的 Bot 和例行任務要怎麼樣才能用得更少?」 +- **群組聊天比較耗用量**:「每則訊息都會由多個 Bot 分別處理」。 +- **自動省用量**:例行任務一直跑、卻沒人看結果時,會自動暫停:「你離開期間,為避免不必要的花費,我暫停了你的所有例行任務。要我重新啟動它們嗎?」 +- **團隊統計**:每日/每週活躍使用者、最近 90 天的活躍使用者排行、每日訊息數。 +- **節省時數**:由 AI 估算 Bot 替團隊省下多少小時。 + +### 超額用量與帳單 + +- **隨選用量**:額外用量依實際使用計費。 + - 可以設每月上限,至少 $1,也可以設為無限制。 + - 上限是 Grok Bot 和 Cursor 合計。 +- **額度**:可以購買額度,額度兩年後過期。 +- **自動儲值**:「餘額低於 {門檻} 時」自動補值。 +- **其他**:付款方式、帳單地址、發票。有未付發票時:「帳戶持有人需要更新帳單資訊」。 +- Bot 擁有者的用量到上限時,其他人也用不了那個 Bot:「此 Bot 的擁有者已達到用量限額」。 + +--- + +## 4. Bot:建立、外觀、整理、主要 Bot + +### 建立 + +- 三種起步方式:「教它一項任務」「從示範中學習」「從頭開始」。 +- 也可以從 Bot 配方、範本或匯入建立。 +- 建立後 Bot 會先自我介紹:「你的 Bot 未能完成自我介紹」。 +- 名稱規則: + - 最多 52 個字元 + - 不能以連字號開頭或結尾 + - 至少要有一個字母或數字 + - 不能空白 +- 可以重新命名 Bot、變更 Bot 地址、複製 Bot、複製 Bot 的連結。 + +### 外觀 + +- **角色形狀**:圓角方形、圓球形、膠囊形、蛋形、豆形、藥丸、六角形、寶石、水晶、卵石、圓頂、圓柱形、拱形、楔形、盾牌、星形、星芒、愛心、雲朵、水滴、花朵、葉子、四葉草、南瓜形、方形。 +- **角色顏色**:黑色、紅色、橘色、黃色、綠色、青色、藍色、紫羅蘭色、洋紅色、棕色、灰色,也可以用強調色。 +- **頭像來源**: + - 用文字描述讓 AI 產生:「描述你的頭像…」 + - 選本機圖片:PNG、JPEG、WebP,依情況小於 2 MB 或 25 MB + - 選表情符號 +- 可以重設成 Bot 形象或成員頭像。 + +### 整理側邊欄 + +- **釘選**:右鍵釘選,或拖曳到釘選區。 +- **其他操作**:隱藏、封存、刪除 Bot,以及「已隱藏的 Bot」「已刪除的 Bot」清單。 +- **分區**:可以「建立分區」並拖曳排序。分區刪掉後,裡面的 Bot 會移回一般清單,不會刪掉任何 Bot。 +- **搜尋與排序**: + - 「搜尋 Bot、檔案和動作」 + - 依字母排序 + - 只看未讀 +- **顯示方式**:「精簡側邊欄」「調整側邊欄大小」。 +- **快速鍵**:聚焦側邊欄第 N 個 Bot、上一個/下一個 Bot。 + +### 主要 Bot + +- 「你的主要聯絡窗口。它會主動來看看,也能和其他 Bot 協調工作。」 +- 可以「設為主要」或「以其他 Bot 取代」。團隊 Bot 不能當主要 Bot。 + +### 指示與角色 + +- 「指示」「此 Bot 的運作方式」「將角色重設為預設」。 +- 可以為每個 Bot 選擇思考用的模型:「選擇 Grok Bot 用來思考的模型」。 + +--- + +## 5. 團隊 Bot、分享、成員、角色 + +### 團隊 Bot + +- **建立方式**: + - 從目前的聊天和記憶開始,這個 Bot 變成團隊 Bot,或保持私人。 + - 從頭建立。 + - 建立「團隊副本」:個人資訊只留在你這裡。 +- **推送更新**:「將你最新的編輯推送到團隊複本」。 +- **發布給團隊**: + - 「發布給團隊」「隊友可以在他們的 Bot 清單中找到此 Bot」。 + - 發布前會做安全審查:「修正以下問題前,發布已被阻擋」「安全審查未能完成,因此沒有發布任何內容」。 + - 可以「取消發布」。 +- **刪除**:「這會為你的整個團隊永久刪除此 Bot。隊友將失去它,以及他們在其中的聊天和例行任務。」 +- **限制**: + - 本機上的 Bot 不能和隊友的 Bot 待在同一個聊天室。 + - 只有已共享的 Bot 能加入群組聊天。 +- **連結預覽**:顯示 Bot 的名稱、描述、顏色和形狀。 + +### 分享與可見範圍 + +- **分享方式**: + - 分享給團隊、組織、特定成員或群組。 + - 透過電子郵件分享。 + - 公開連結。 +- **可見範圍選項**:「僅你可見」「僅限受邀成員」「{teamName} 中的任何人」「你組織中的所有成員」「公開」。 +- 「技能、例行任務和功能會與所有使用它的人共享」。 +- **管理員可以限制**: + - 「Enterprise 團隊已關閉公開分享」 + - 「只有團隊管理員可以共用 Bot」 + - 「你的團隊不允許公開範本」 +- **瀏覽別人分享的 Bot**:「與你或你的組織共享的 Bot。新增後會保留在側邊欄中。」 + +### 成員與角色 + +- **角色**: + - 擁有者 + - 管理者:「可以編輯 Bot 設定並分享給他人」,也能改個人資料、技能、外掛、密鑰、Slack 應用程式 + - 協作者:可以建立例行任務 + - 檢視者 + - 「可以與 Bot 聊天」的一般使用者 +- **成員管理**: + - 只有擁有者和管理者可以新增成員。 + - 可以限制只有清單上的人能用,或移除所有人只留自己。 + - 沒權限的人可以「申請存取權」。 +- **邀請隊友**: + - 用電子郵件或邀請連結。 + - 可以重新傳送或撤銷邀請。 + - 有邀請上限。 +- **組織**: + - 選擇組織、訪客組織、複製團隊 ID、等待管理員核准。 + - 換團隊時,會自動切回你帳戶的預設團隊。 + +--- + +## 6. 聊天 + +- **輸入框**: + - 「想問什麼都可以,或拖入檔案」,換行方式可以設定。 + - 每個 Bot 各自存草稿,也可以關掉草稿。 +- **即時狀態**: + - 「{names} 正在輸入」「正在閱讀」「{name} 正在工作」「已完成工作」「已停止,未回覆」。 + - 工作中會顯示不同的動作文字,例如:「正在仔細思考」「正在四處查找」「正在執行幾個指令」「正在寫程式碼」「正在畫草圖」「正在逐頁瀏覽」「正在記筆記」「正在拍影片」「正在操作螢幕」「正在移動檔案」「正在你的電腦上工作」「正在 {service} 中工作」。 +- **群組聊天**: + - 好幾個 Bot 加上真人一起聊。 + - 建立失敗時會保留草稿和收件人。 + - 系統會建議:「對於大多數工作,建議改為與單一 Bot 聊天」。 +- **Bot 之間交流**: + - 「正在詢問 {name}」「Bot 交流紀錄」。 + - 可以看兩個 Bot 之間的唯讀對話,也有「子代理」。 +- **訊息操作**: + - 回覆、引用、提及(@)、轉寄。 + - 表情回應:快速表情和搜尋表情。 + - 標成已讀或未讀、刪除、取消傳送。已經在傳送中的訊息不能取消。 +- **瀏覽與匯出**: + - 「顯示較早的訊息」「跳至被回覆的訊息」「在聊天中尋找」。 + - 匯出或複製文字記錄、複製對話 ID。 +- **對話管理**: + - 重新命名聊天、離開對話、把某人移出對話。 + - 「重設對話」:「對話紀錄將從頭開始。Bot 的記憶、檔案、例行任務和設定都會保留。」 +- **限制與同步**: + - 「此對話過長,無法繼續。請與這個 Bot 開始新的對話。」 + - 「此訊息太長,無法傳送。」 + - 「此 Bot 的附件空間已滿。」 + - 離線時顯示已儲存的訊息,連線後自動重新整理。 + - 版本太舊時,部分訊息需要更新 App 才看得到。 +- **評分**: + - 「這個結果如何?」「結果不錯/不佳」「答非所問」「評分 {score}/5」。 + - 會議結束後會問「Bot 在這次會議中表現如何?」 + +--- + +## 7. 任務、討論串、背景工作、Bot 提問 + +- **一次交代多件事**:「每件事都會以任務的形式顯示在這裡」。 + - 可以把任務標成完成。 + - 任務列可以放在側邊欄底部,或放在搜尋旁邊。 +- **討論串**: + - 每個任務各有自己的討論串。 + - 視窗夠寬時,討論串會並排顯示在主聊天旁邊。 +- **背景工作**: + - 包括非同步任務、背景任務、背景指令、子代理。 + - 「允許 {botName} 執行背景任務嗎?」「在背景繼續」。 +- **進度**:「第 {step} 步,共 {count} 步」「目標」「目標記錄」。 + - 步數用完時顯示「步驟已用盡」。 +- **等你處理**: + - 「等你處理:{原因}」「{callee} 需要你」「你已全部處理完畢」。 +- **Bot 向你提問**: + - 可以用選項回答(可以複選),或輸入你自己的回答,也可以不回答並忽略。 + - 有回覆時間限制:「請在 {remaining} 內回覆」「未及時回覆」。 + - 在團隊情境裡,只有被問的那個人能回答。 +- **中斷後接續**: + - 交還電腦、主機更新、傳送草稿、MCP 驗證之後,Bot 會接著做。 + - 接不上時會提示,例如「交還電腦後 Bot 未能恢復執行」。 + +--- + +## 8. 附件、語音、語音聊天、聽寫 + +- **附件**: + - 拖放、貼上圖片、上傳檔案或相片。 + - 檔案可以加進對話內容。 + - 每種檔案有大小上限,影片另有上限。 + - 空檔案和不支援的格式會被拒絕。 +- **聽寫**:點一下或按住開始聽寫,會轉成文字。 +- **語音訊息**: + - 可以錄製、播放、調整播放速度、跳到某個字。 + - 需要 Cursor Pro。 +- **語音聊天**: + - 可以發起語音聊天,一次只能進行一個。 + - 有語音聊天記錄。 + - 長的內容只唸開頭,其餘看文字記錄。 + - 可以被打斷,會標「{heard} 秒後被打斷」。 +- **麥克風**: + - 可以選麥克風、靜音。 + - 找不到麥克風或權限被拒時,會提示你去系統設定打開。 + +--- + +## 9. Bot 自己的雲端電腦 + +- **它是什麼**: + - 每個帳戶有一台雲端電腦,你所有的 Bot 共用。 + - Bot 在上面執行指令,並註明「在 Grok Bot 的電腦上執行」和工作目錄。 +- **狀態**: + - 啟動中、休眠中、喚醒中。Bot 需要時會自動喚醒,也可能「由訂閱事件喚醒」。 + - 「你的電腦已暫時暫停,可用後會自動恢復。」 +- **看畫面**: + - 電腦預覽、全螢幕、調整至螢幕大小、縮放、切換畫面、最大化或最小化視窗。 +- **兩種更新**: + - 重建電腦:「你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。」 + - 只更新底層軟體:「你的檔案、登入資訊和已安裝的應用程式都會保留。」 +- **排程更新**: + - 可以設成有新映像檔就在指定的當地時間自動更新。 + - 錯過排程有一小時的補執行時間;超過就要重新排程。 + - 更新會先停掉正在工作的 Bot,聊天會保留。 + - 更新開始後就不能取消或重新排程。 +- **復原**:「從你上次儲存的快照重新啟動它」。 + - 最近的變更可能會遺失,已安裝的 App 會被移除。 + - 「你的 Bot、檔案和登入資訊都是安全的。」 +- **重設**:重新建立電腦並重新連線。 +- **磁碟清理**:「檢查 Grok Bot 電腦的磁碟使用情況,並提出安全的清理方案供你核准」。 +- **終端機與環境密鑰**: + - 給指令使用的帳密,例如 CRM_API_TOKEN,值只能寫入、不能讀出。 + - 名稱要大寫字母開頭(A–Z、0–9、_)。 + - 值有長度和大小限制。 +- **網路流量**: + - 可以改走你的本機電腦,而不是雲端:「僅對新連線生效」。管理員可以關掉這個功能。 + - 支援 Tailscale:個人 tailnet、公司 tailnet、tag:grokbot、每位成員一台 Grok Bot 裝置。 + - 會提醒公司網管放行特定網域的串流連線。 + +--- + +## 10. 你的電腦(Mac)與其他裝置 + +- **讓 Bot 用你的電腦**: + - 「允許 Grok Bot 在這台電腦上執行任務,而不只是在它自己的電腦上」。 + - 「讓 Grok Bot 在你的電腦上開啟檔案並執行任務。自動審核仍會先檢查所有操作。」 +- **能做的事**: + - 執行指令,可以把輸入傳給執行中的指令。 + - 列出資料夾、讀檔、寫檔。 + - 存取私人檔案前會先問。 + - 使用其他 App:「Grok Bot 可以看到 {app},並在其中點擊和輸入」。 +- **在哪台電腦執行**:「在這台電腦上」「在 {machine} 上」「另一台電腦」。 + - 在另一台裝置上執行時,就在那台裝置上詢問你,卡片會自動更新。 +- **管理裝置**: + - 重新命名、看作業系統和上次開啟時間、在線或離線狀態。 + - 移除電腦:有次數限制,在線時不能移除。 +- **手機**: + - 「在你的手機上使用裝置端工具」,每次使用前先確認。 + - 有行動版 App。 +- **macOS 權限引導**: + - 需要的權限:螢幕錄製、輔助使用、自動化(郵件、訊息)、完全取用磁碟、登入項目。 + - App 會打開對應的系統設定面板,請你把小幫手拖進清單。 + - 公司電腦可以請管理員輸入密碼,或請 IT 允許。 +- **磁碟滿了**:「無法傳送你的訊息,因為這部電腦磁碟空間已滿。」 + +--- + +## 11. 操作螢幕(電腦使用)、接管、示範教學 + +- **小幫手程式**: + - 原版另外有一個小幫手程式 Grok Bot Computer Use,負責看你的螢幕、動滑鼠和鍵盤。 + - 它需要螢幕錄製和輔助使用兩個權限。 +- **工具種類**:截圖、點擊、移動滑鼠、按住或放開滑鼠鍵、拖曳、捲動、打字、按鍵、檢查權限。 +- **操作紀錄**:「已按 {keys}」「已拿起 {name}」「已放下」「已將 {name} 放到 {target}」。 +- **接管**: + - 你可以「取得控制權」,自己點擊和輸入,再把電腦交還給 Bot。 + - 也可以「僅觀看」。你離開期間畫面會暫停。 +- **示範教學**: + - 「錄下你執行任務的過程。{Bot} 會學習這些步驟,之後可以自行再次執行。」 + - 「{Bot} 正在觀看並學習」。 +- **錄下 Bot 的畫面**:「正在錄製 {Bot} 的電腦」。 +- **本機 Bot 沒有另外的桌面**:「此 Bot 直接在你的本機上執行,沒有另外的桌面可供串流。」 +- 從小幫手拆出來的時間和規則,見第 28 節。 + +--- + +## 12. 權限、核准、自動審核、規則 + +- **基本原則**: + - 「Grok Bot 能做什麼由你決定」 + - 「Grok Bot 只會取得你核准的存取權限」 + - 「Grok Bot 在進行變更前一定會先詢問你」 +- **會跳出詢問的動作**: + - 使用服務、在服務裡修改、刪除、透過服務傳送 + - 執行指令 + - 儲存例行任務、儲存會議指示 + - 分享 Bot + - 使用雲端代理、使用已連結的 App + - 傳送意見回饋 + - 打電話 + - 超過 $50 的購買 + - 敏感操作,例如變更登入或安全設定、把資料分享到聊天以外 +- **核准按鈕**: + - 「允許」「允許一次」「一律允許」「下次起一律允許」「全部允許」「不允許」「永不允許」「每次詢問」「拒絕一次(Esc)」。 + - 「允許」在本輪內有效,而且會涵蓋本輪剩下所有同類請求。 + - 「一律允許」一直有效,直到你在設定裡移除。 + - 團隊政策可以要求每一輪都要重新核准。 + - 拒絕時:「取消此要求且不執行該步驟;Bot 會跳過它並繼續」。 +- **核准卡片的狀態**: + - 已暫停等待核准、待審核、已過期、已允許一次、已在其他位置決定。 + - 沒及時回應時,Bot 就不做那件事。 + - 可以集中審核:「審核全部」「在聊天中審核」。 +- **自動審核**: + - 「根據自訂規則審核 Grok Bot 的操作」,可以選檢查得多仔細。 + - 用自然語言寫兩種規則:「一律詢問」和「無需詢問直接允許」,衝突時「先詢問」優先。 + - 寫規則的提示:寫明動作和對象,例如「寄給 dana@acme.com 的電子郵件」。 + - 團隊規則由管理員設定,對所有人有效,你的規則疊加在上面。 + - 管理員可以強制開啟自動審核,你的規則會保留,但不會被讀取。 + - 內建的安全檢查一律有效。 + - 「不檢查」模式:直接執行,但設成「需要核准」的工具仍然會問。 + - 紀錄:「已依你的規則自動核准」「已因你團隊的「先詢問」規則暫停」。 +- **一律允許清單**: + - 一律允許的 App、允許的收件人、外掛允許清單、萬用字元模式。 + - 外掛允許清單在共用頻道裡也有效。 +- **傳訊息的範圍**: + - 「對自己傳給此 Bot 的訊息一律允許」。 + - 「此設定僅適用於「訊息」,不適用於指令或檔案」。 +- **團隊 Bot 用誰的帳號**: + - 「這只會在回覆你自己的訊息時使用你的帳號。其他人無法使用。」 + - 可以允許所有連接器,但會提醒:「即使是其他人要求它們這樣做」。 +- **管理員控管**: + - 「已由你的管理員停用。你無法開啟此設定。」 + - 有上限設定,例如「你的團隊管理員最多允許…」。 + - 安全金鑰(YubiKey)每次使用前都會先問你。 + +--- + +## 13. 例行任務、排程、事件觸發 + +- **建立方式**:在聊天裡請 Bot 設定:「例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。」 +- **管理**: + - 暫停、繼續、全部暫停、編輯、刪除,刪除後無法復原。 + - 「立即執行」或「測試執行」。 + - 「即將進行」清單、隊友的例行任務。 + - 例行任務面板會和 Bot 參加的會議一起顯示。 +- **在哪裡跑**:可以選這個 Bot 的私人版本或團隊版本;你的例行任務只為你執行。 +- **誰能設定**:依設定而定,可以是任何使用者、只有協作者,或只有擁有者和管理者。 + +### 排程寫法(照原版文字) + +- 「每小時」「每天 9:00」「平日 9:00」「週末」「每個工作日」「每週的週一、週三」。 +- 「每月 5 日」「每月的第 N 個星期 X」「每年 M 月 D 日」「每 N 分鐘/小時/天/月/年」「僅一次」。 +- 時間後面可以加「左右」,表示彈性時間;也可以加時區,例如「(Asia/Taipei)」。 +- 程式裡的排程設定有: + - 允許提早或延後幾分鐘的時間範圍。 + - 準時(exact)或彈性(flexible)兩種模式。 +- **跟著行事曆**:「在所追蹤的行事曆活動開始時,無論行事曆將其移到何時」。 + +### 事件觸發(共 11 種,從程式讀到) + +1. **排程(cron)**:見上方。 +2. **Slack**:指定頻道,加上比對條件,例如任何訊息、被 @、提到某個字、出現某個表情回應。 +3. **GitHub**: + - 指定存放庫和事件,可以限定某個 PR、某些使用者、某個 CI 分支。 + - 事件有:PR 建立、更新、合併、關閉、收到留言;CI 通過、失敗;審查核准、要求修改、留言;審查討論串解決或重新開啟;議題被指派。 +4. **Origin**:存放庫活動。 +5. **Microsoft Teams**:指定租用戶、團隊、頻道,可以設訊息比對(也可以用正規表示式),以及是否擋掉未驗證的使用者。 +6. **Linear**:議題建立、狀態變更、週期結束,可以限定專案和團隊。 +7. **Sentry**:議題建立、指派、解決、封存、變回未解決、有變更,可以限定專案。 +8. **PagerDuty**:事故觸發、確認、升級、解決、有變更,可以限定服務。 +9. **電子郵件**: + - 監看一個收件匣,可以限定寄件人、要求通過驗證。 + - 也可以設成「包括未經驗證的寄件者」。 +10. **Webhook**: + - 外部服務 POST 到專屬網址,每次呼叫跑一次。 + - 用「Authorization: Bearer <金鑰>」驗證。 +11. **群組**:至少兩個觸發條件組成一組。 + +- **觸發失敗的提示**:表情回應後、監聽器連線後、頻道訊息後續處理失敗時,都會顯示。 +- **同步**:例行任務會跟雲端同步。同步失敗時:「事件觸發的例行任務在安全的情況下會繼續在本機執行,但排程的例行任務…可能會延遲。」 + +--- + +## 14. 外掛、市集、連接器、MCP + +- **外掛市集**: + - 可以瀏覽、搜尋、推薦(附推薦原因)、看最熱門、分類、排序、安裝、解除安裝、更新、發佈。 + - 外掛分官方、內建、團隊、自訂幾種。 +- **團隊政策**: + - 「你的團隊要求使用此外掛程式,因此無法解除安裝」。 + - 發布者要先取消發布才能移除。 +- **連接器**: + - 連結、中斷連線、重新連線、選擇資料中心或區域、清除偏好設定。 + - 會依你的使用習慣推薦:「使用者每天都會使用 {label},推薦連接器…時,請優先從這些工具著手」。 +- **帳號共用**: + - 共享連接器:連一次,整個團隊都能用,會提醒確認裡面沒有私人資訊。 + - 或每位隊友各自登入。 + - 也可以用服務帳戶(API 金鑰、權杖)代替個人憑證。 + - 管理員可以關掉「與 Bot 共用登入資訊」。 +- **外掛設定值和密鑰**: + - 安全儲存,團隊成員看不到。 + - 圓點表示已經存好的值,留空的欄位會被清除。 +- **OAuth 授權**:在瀏覽器完成授權後回到 App,失敗時可以重試。 +- **自訂 MCP 伺服器**: + - 用 JSON 設定,欄位有伺服器網址或指令、傳輸方式、參數、HTTP 標頭、OAuth 用戶端 ID 和密鑰。 + - 只接受 HTTPS 登入網址;需要密鑰的伺服器建議用遠端 HTTPS。 + - 指令型的伺服器在 Bot 的電腦上執行。 + - 有保留名稱:以 0 開頭,或包含 CURSOR_SANDBOX。 +- **限流**:服務限制請求頻率時,會在 N 秒後自動重試。 +- **出現過的服務**: + - 溝通:Slack、Microsoft Teams、Discord(即將推出) + - 開發與營運:GitHub、Linear、PagerDuty、Sentry、Cursor、Figma + - 檔案與行事曆:SharePoint、Google Calendar、Outlook + - 密碼:1Password、Bitwarden、LastPass + - 網路與付款:Tailscale、Stripe Link、Statsig + - 生活:Yelp、Google Maps、X + - Apple 的訊息、郵件、備忘錄、行事曆、提醒事項、聯絡人、地圖 + - 範本中提到 CRM、ATS、可觀測性工具、說明中心 + +--- + +## 15. Slack、Microsoft Teams、Discord + +### Slack + +- **把 Bot 帶進 Slack**: + - 原版會替 Bot 建立一個 Slack 應用程式。 + - 隊友可以私訊它或在頻道 @它,它會用一樣的外掛、內容和記憶回答。 +- **連結身分**:把你的 Slack 帳號連到 Cursor,讓團隊 Bot 知道你是誰。 +- **管理員核准**: + - 要求會送給 Slack 管理員;管理員拒絕時,應用程式會被刪掉。 + - 可以請管理員設定自動核准團隊 Bot。 +- **工作區**: + - 換工作區時,頻道不會跟著搬,要重新邀請 Bot。 + - 可以限制只用公司管理的工作區。 + - 有已核准網路、方案的 App 數量上限等限制。 +- **中斷連結**: + - 取消連結後,從 Slack 啟動的團隊 Bot 和雲端代理會停止。 + - 只有建立應用程式的那個帳號能管理它。 +- **錯誤**:限流、Slack 忙碌、傳送中斷,例如「請在 Slack 中確認訊息是否已送出」。 + +### Microsoft Teams + +- **把 Bot 帶進 Teams**: + - 它會以獨立的 Bot 出現,有自己的名稱和頭像,會在你選的團隊和頻道裡回覆。 + - 一個 Bot 只能加入一個 Teams 組織。 +- **兩種授權方式**: + - 管理員直接發布到組織的應用程式目錄。 + - 個人授權,再由管理員核准一次。 +- **狀態**:等管理員同意、等核准、等團隊擁有者新增、被拒、被政策封鎖。 +- **權限不足時**:沒有讀取頻道的權限,就只看得到 @ 它的訊息。 +- **帳號**:可以用公司或學校帳號,也支援個人帳號(Outlook.com、Hotmail、Live)。 +- **中斷連結**:會中斷你在組織裡所有 Bot 的 Microsoft 帳戶連結。 + +### Discord + +- 「在 Discord 伺服器和私訊中收發訊息(即將推出)」。Slack 和 Teams 的頻道收發也有「即將推出」的標示。 + +--- + +## 16. 程式碼與雲端代理(Cursor、GitHub) + +- **Cursor 雲端代理**: + - Bot 可以啟動雲端代理寫程式,在 Cursor 或 cursor.com 開啟它。 + - 「雲端代理協調助手」:啟動工作、追蹤卡住的項目、彙整進度。 +- **存放庫**: + - 新增存放庫、檢查存取權限。 + - 讀取並處理 PR、議題、檢查和程式碼,開 PR。 +- **PR 狀態檢查**:被拒、限流、連不上時會顯示提示。 +- **修訂與分支**:「在 {branch} 分支上…」「在此修訂中已變更」。 +- **外掛安裝**:外掛可以從 GitHub 安裝,需要 GitHub 驗證和 git 憑證。 + +--- + +## 17. 訊息管道:Mac「訊息」、郵件、專屬號碼、專屬信箱、X + +### Mac「訊息」(簡訊、iMessage) + +- **讀取**:「允許 Grok Bot 讀取這台 Mac 上「訊息」中的對話和附件」。需要「完全取用磁碟」,因為訊息存在受保護的資料夾裡。 +- **傳送**:「以你的身分從「訊息」App 傳送你核准的訊息」,每次傳送前都會先問。 + - 可以開「無需詢問直接傳送」,或指定不用問的收件人。 + - 團隊政策可以要求每則都要核准。 +- **路由**:訊息從哪台 Mac 送出可以切換。 +- **狀態**:待傳送、已送達、尚未送達、未傳送、離線時傳送。 +- **失敗**:找不到聊天、無法送達、沒有回應(「再次傳送前請先查看「訊息」」)、Mac 沒有連線、需要更新。 +- **設定位置**:「連結應用程式」→「Mac」→「訊息」。 + +### Mac「郵件」 + +- Bot 已經可以搜尋、閱讀和草擬郵件;完成設定後也能寄信,每封都會先問。 +- 寄信時「郵件」App 會短暫切到前面。Bot 會先檢查密件副本欄位。 +- **改成「在 Mail 開啟、由你按傳送」的情況**: + - 無法檢查密件副本 + - 郵件被加了密件副本 + - macOS 沒讓郵件 App 移到前面 + - 還沒設定傳送 + - 沒有主旨 + - 郵件在傳送前被改過 +- **其他狀態**:「在 Mail 的寄件匣中。恢復連線後,Mail 會將其傳送。」 + - 不確定有沒有寄出時:「再次傳送前,請先查看 Mail 中的「寄件備份」。」 + +### Bot 的專屬電話號碼 + +- 連結手機後就有 Bot 的號碼:用手機掃 QR 碼,再把連結碼傳給 Bot。 +- 訊息要用 iMessage 或 Google Messages 的 RCS 傳送;用一般簡訊會被提示改用這兩種。 +- 可以取消連結手機。取消後,那支號碼傳來的訊息不會再送到 Bot。 + +### Bot 的專屬電子郵件 + +- Bot 在專屬網域下有自己的信箱,開啟後地址不能改。 +- 用途:註冊驗證碼、收據、電子報摘要,經你同意後寄信。 +- 收到的郵件只會透過你設定的例行任務叫醒 Bot。 +- 地址規則:小寫字母、數字、連字號,至少一個字母或數字。 + +### X(Twitter)、X Chat + +- 草擬並發布貼文,可以選擇不帶某幾張相片。 +- 回覆 @帳號、X Chat 訊息草稿。 +- 捨棄草稿、追蹤、看追蹤者人數、複製貼文連結、分享到 X。 + +### 其他平台 + +- 其他平台要先連結(新增權杖)才能傳送訊息。程式碼裡支援「廣播訊息」。 + +--- + +## 18. 打電話 + +- 「{Bot} 想代你撥打電話」「撥打這個號碼?」「輸入 {Bot} 要告訴 {對方} 的內容」。 +- **狀態**:撥號中、等待接聽、通話中、已結束、意外結束、無法接通。 + - 無人接聽時顯示響了多久,例如「無人接聽 · 響鈴 N 分鐘」。 +- **其他**: + - 可以轉接給你、重新撥打。 + - 通話中有即時逐字稿。 + - 音訊和文字記錄保留 30 天。 +- **使用條款**: + - 只能打給美國的企業,以及同意接收聯絡的人。 + - 不能用於行銷、大量撥打、詐騙、騷擾、冒充他人、撥打緊急電話等。 + +--- + +## 19. 聯絡人與 Apple App(備忘錄、行事曆、提醒事項、地圖) + +- **聯絡人**: + - 「Grok Bot 只會查詢姓名、號碼和電子郵件地址,絕不會更改任何聯絡人。」 + - 讓 Bot 知道「媽媽」「亞歷克斯」是誰,顯示姓名而不是電話號碼。 +- **Mac 上的 Apple App**: + - 備忘錄:尋找、閱讀、儲存。 + - 郵件:搜尋、閱讀、草擬回覆。 + - 行事曆:查看行程和空閒時間、新增、移動、刪除行程。 + - 提醒事項:查看、新增、變更、勾選完成。 + - 地圖:尋找附近的地點。 + - 訊息:掌握對話、尋找舊訊息。 +- **人員**:搜尋人員、新增人員、暱稱、朋友或同事。性別和生日會從個人資料自動填入。 + +--- + +## 20. 會議、錄音、文字記錄 + +- **讓 Bot 參加會議**: + - 「把 Google Meet、Zoom 或 Microsoft Teams 的連結給你的 Bot。」 + - 可以排程,例如「30 秒後加入」「下次加入時間」。 + - 可以在手機上按任意鍵加入。 +- **徵得同意**:「將 Bot 加入會議前,請務必先徵得參與者的同意。參與者可以看到 Bot 正在轉錄。」 +- **會議指示**:可以儲存某場會議的指示,例如這場要做什麼,儲存前會先問你。 +- **會議聊天**:Bot 可以用自己的名義在會議聊天裡發言,也可以設定「此會議一律傳送」。 +- **錄音與文字記錄**:播放、拖曳進度、下載錄音、匯出文字記錄。 +- **會議邀請**:要連 Google Calendar 或 Outlook 才能寄出邀請。 +- **相關範本**: + - 會議準備:根據行事曆、CRM 和 Slack 準備資料包。 + - 通話常見問題挖掘:每個答案連回錄音。 + - 銷售通話教練:附時間戳記的建議。 + +--- + +## 21. 檔案預覽、圖表、Bot 做的頁面與 App + +- **檔案預覽**: + - 支援 PDF、Word、試算表、文字、程式碼、CSV、結構化資料(可以展開節點)。 + - 太大的檔案改成下載。試算表可以選取儲存格、用方向鍵移動、複製。 +- **圖表與視覺化**: + - 可以全螢幕、下載成 PNG、把表格複製成 HTML 或 Markdown。 + - 視覺化內容啟動超過 10 秒就會報錯。 +- **媒體**: + - 圖片藝廊、影片播放器、全螢幕地圖。 + - 下載全部、在瀏覽器開原圖。 + - 也支援付費媒體。 +- **Bot 做的東西**: + - 「這個 Bot 建立的頁面、檔案和應用程式會顯示在這裡,在你分享之前只有你看得到」。 + - 可以做的類型:文件、試算表、簡報、設計、頁面、檔案。 +- **頁面**: + - 重新命名、分區、階層式導覽、畫布、在頁面裡尋找。 + - 點頁面上任何地方就能留言給 Bot。 + - 分享範圍:知道連結的人、整個組織、限制存取、公開連結。可以邀請其他人一起協作。 + - 刪除後連結就失效。 +- **發布網站和 App**: + - 可以建置 App、準備託管,取得 App 網址。 + - 可以下架:「此應用程式將對所有人下線,連結也會失效。你之後可以重新發布。」 +- **Bot 的參考文件**: + - 上傳文件給 Bot 當知識,Bot 會保留文件內容。 + - 檔名以點開頭、太大、重複、或超過總量的檔案會被略過。 + +--- + +## 22. 記憶、技能、範本 + +### 記憶 + +- **三種記憶**: + - 只有你和這個 Bot 知道的 + - 它從團隊學到的 + - 它替團隊每個人記住的 +- **共用**:有些記憶由你所有的 Bot 共用。 +- **審核**:團隊學到的新內容會先等審核,才套用到所有人:「有哪些團隊學習內容待審核?請逐條帶我了解。」 +- **從其他 AI 助理匯入**: + 1. 複製一段提示詞,貼到另一個 AI 助理。 + 2. 把它的回答貼回來,加進記憶。 + - 內容太長時會請你貼短一點。 +- **工作領域**:會記住你主要做哪類工作,讓建議貼近你的領域。 + +### 技能 + +- **是什麼**:「技能是可重複的工作方式,讓你的 Bot 能一致地完成任務。」技能用 Markdown 寫成指示,並寫明「何時使用」。 +- **管理**: + - 建立、匯入、編輯、移除、搜尋、引用。 + - 可以請 Bot 幫你建立技能。 +- **發布到團隊市集**: + - 需要 Cursor 團隊,管理員可以關閉這個功能。 + - 下架時,你自己已安裝的技能會保留成私人技能。 +- **團隊金鑰**:團隊共用的 API 權杖。 + +### Bot 範本 + +- **管理**:建立、分享、更新、刪除範本;刪除範本不會影響已經建好的 Bot。 +- **分類**:熱門、精選、生產力、營運與財務、財務與法務、產品、研究、行銷、行政、設計、程式開發、資料與分析、銷售、知識、招募與人事、收件匣與協作、文件與檔案、工程、投資、智慧代理協調。 +- **內建範本**(部分): + - 幕僚長助手 + - 招募篩選、人才搜尋:「依既定標準評分應徵資料」「以你的語氣草擬開發信,並略過 ATS 中已有的人選」 + - 到職管理 + - 支出管理、發票協調、催款助手 + - 收件匣管理:「分類電子郵件、找出緊急討論串,並草擬回覆供你核准」 + - 行事曆協調、行政助理 + - 文件稽核、合約管理、資安問卷填寫:「根據你的信任中心和過往 RFP 草擬」 + - 會議準備、每日簡報撰寫、電子報撰寫 + - 業務賦能、銷售管道分析:「標示停滯和成交風險,並產出週一計分板」 + - 銷售通話教練、客戶研究、客戶經理 + - 客戶支援、客服單分流:「沒有待辦時保持安靜」 + - 產品意見回饋分析、功能需求追蹤、Beta 功能採用監控 + - 社群媒體管理、競爭情報分析、SEO/AEO 稽核 + - 簡報設計、原型製作、動效助手、Figma 工作 + - 遊戲測試操作:「在測試環境中重現錯誤,並提供包含步驟和螢幕截圖的資料包」 + - 供應商入口網站操作:「在沒有 API 的入口網站中處理續約和席次」 + - 雲端代理協調、AI 助理 + +--- + +## 23. 瀏覽器、網站登入、保險庫、1Password、匯入密碼 + +- **在 Bot 的電腦上登入網站**: + - 「正在等待你在 Grok Bot 的電腦上登入 {網站}…」。 + - 登入後 Bot 會從中斷的地方繼續。 +- **從 Chrome 複製 Cookie**: + - 介面上還有「從你電腦上的 Chrome 複製 Cookie」這張卡片。 + - 但程式裡這個功能已經註明「已移除」,現在的版本不會用。 +- **Grok Bot 保險庫**: + - 可以存登入資訊、卡片、聯絡資訊。 + - 「已安全儲存,絕不會顯示給你的 Bot」「Grok Bot 絕不會看到你的密碼」。 + - 會替你自動填入登入表單。 + - 保險庫有容量上限。 +- **防仿冒網站**: + - 「也要在 {domain} 上使用…嗎?只有在它與已儲存的網站屬於同一服務時才允許。仿冒網站可能會竊取你的密碼。」 +- **團隊共用登入**:Bot 可以用這些登入資訊替團隊任何人登入,只有管理者能查看或變更。 +- **安全表單**: + - Bot 需要你的機密資料時,會開一個安全表單讓你直接填,資料不會經過 Bot。 +- **匯入密碼**: + - 可以匯入從 1Password、Chrome、Edge、Safari、Apple Passwords、Firefox、Bitwarden、LastPass 匯出的 CSV。 + - 會自動辨識是哪一家的檔案。 + - 檔案上限 5 MB,只在這台電腦上讀取,不會上傳。 + - 重複的項目會略過。LastPass 的檔案會一併匯入卡片。 +- **1Password 整合**: + - 需要 1Password 8.12.42 以上,適用於個人版和 Family 版。 + - 運作方式:在 1Password 建一個專用保管庫和一個只能讀取它的服務帳戶,只有你移進那個保管庫的項目才會共用。 + - 每次使用都要你在 1Password 裡核准,Bot 永遠看不到密碼。 + - 設定方式有兩種:貼上服務帳戶權杖,或在裝有 1Password 的 Mac 上讓 App 自動完成。 + - 需要在 1Password 打開這些設定:「Integrate with 1Password CLI」「Keep 1Password in the menu bar」「Allow in the Background」。 + - 存取權會過期,到時要更新。 +- **個人資料自動填入**: + - 姓名(依護照寫法,支援 é、ñ)、出生日期、性別、地址、聯絡方式。 + - 同行旅客:成人、兒童、嬰兒。 +- **授權流程**: + - 在瀏覽器裡完成授權,視窗會自動關閉。 + - 會偵測瀏覽器登入的是不是另一個帳戶。 + +--- + +## 24. 代你購物與付款(卡片、Link、密碼金鑰) + +- **付款確認卡片**: + - 「用 {付款方式} 向 {網站} ({商家}) 付款 {金額} 購買 {商品}?寄送至 {地址}。」 + - 沒有收件地址時:「僅在不需寄送任何物品時付款」。 +- **每一筆都要你核准**;超過 $50 的購買要特別允許。 +- **卡片**: + - 可以拍照掃描卡片,正反面都可以,也可以拖入相片。 + - 相片格式:JPEG、PNG、GIF、WebP,總大小 20 MB 以內,一分鐘內不能傳太多次。 + - 光線不好時會請你重拍。 + - 「Grok Bot 絕對看不到已儲存卡片的卡號或安全碼。」 +- **Link 錢包(Stripe)**: + - 用 Link 帳戶付款,也可以在 Link 新增卡片。 + - 「Link 不接受以 Amex 進行 Grok 購買」。 +- **銀行**:可以連結銀行,查看財務和負債。 +- **密碼金鑰**: + - 「只有在你以密碼金鑰確認後,Grok Bot 才會核准購買」。 + - 設定流程:先用電子郵件驗證碼確認是你,再在這台電腦存一組密碼金鑰。 + - 每個帳號的密碼金鑰有數量上限。 +- **硬體安全金鑰**:敏感操作需要 YubiKey 這類硬體金鑰。 +- **失敗時保證不扣款**: + - 「此卡片已被拒絕。未產生任何扣款。」 + - 「此請求已過期。未產生任何扣款。」 + - 「你選擇了其他付款方式。未產生任何扣款。」 +- **訂單狀態**:已下單、已付款、已購買、已出貨、訂單已取消、追蹤包裹、預計送達。 + +--- + +## 25. 生活卡片:機票、餐廳、購物、天氣、地圖 + +- **機票**: + - 行程:來回、單程、多城市;直飛、轉機、過夜轉機、需要換機場。 + - 艙等:經濟、豪華經濟、商務、頭等。 + - 票價規定:可不可以改票或退票、手續費、託運和隨身行李。 + - 排序:最便宜、最快、最佳。 + - 價格會確認保證多久,價格變動時會提醒。 + - 座位和額外行李要訂完後到航空公司網站加購。 + - 會給訂位代號,並提醒「再次預訂前,請留意你的電子郵件是否收到確認信」。 +- **旅客資料**:照護照寫法填姓名,選航空公司的聯絡方式。 +- **飯店**:房型、每晚價格。 +- **餐廳(Yelp)**: + - 推薦餐廳、Yelp 評分、營業時間、可訂位時段。 + - 「幫我訂位 {名稱},{日期} {時間},{人數} 人」。 + - 候位:「候位 N–M 分鐘」「請在 {時間} 前抵達」。 + - 需要信用卡保留座位時,會改到 Yelp 上完成。 + - 分類:餐飲、酒吧、咖啡廳、甜點、麵包店、電影院、博物館、公園、夜生活、地標。 +- **購物**: + - 有現貨、缺貨、預購、更便宜的選擇、接單訂製、免運、今天送達、改日期送達。 +- **天氣**: + - 每小時和每日預報、降水機率、風速、濕度、體感溫度。 + - 可以選攝氏或華氏,有各種天氣狀況。 +- **地圖**:搜尋這個區域、在 Google Maps 開啟、地圖來源。 + +--- + +## 26. 設定:外觀、通知、更新、隱私、系統 + +- **外觀**: + - 淺色、深色、跟隨系統。 + - 強調色:用 Bot 的顏色替 1 對 1 的訊息和卡片上色,或保持黑色。 + - 精簡側邊欄。 +- **語言**: + - 選擇選單和訊息的語言,也可以自動偵測。 + - 打字時檢查拼字。溫度單位可選。 +- **通知**: + - 「Bot 完成任務或等你處理時通知你」「當有事項需要你注意或可供審查時」。 + - 通知音效:清亮嘀聲、滴答、鈴音 A、鈴音 B,可以關掉。也可以取消靜音。 +- **App 更新**: + - 自動更新、檢查更新、重新啟動以更新,你的 Bot 和工作都會保留。 + - 在你離開時自動更新。 + - 更新通道:穩定版、測試版、每夜版,切換後會立即檢查更新。 + - 用系統套件管理器安裝的版本,要用套件管理器更新,最多晚一天。 + - 要放在「應用程式」資料夾才能更新。下載的更新驗證不過就不安裝。 + - Lab 測試版永遠不會自動更新。 +- **隱私**: + - 第一次要先選資料隱私設定才能傳訊息。 + - 舊版隱私模式不相容,要換成新的隱私模式,資料同樣不會被拿去訓練。 + - 團隊可以強制隱私模式。 +- **時區**:排程和提醒用的時區。 +- **系統**: + - 硬體加速(視窗閃爍或一片空白時可以關掉,重新啟動後生效)。 + - 網路偵錯工具、功能旗標、偵錯、實驗、下載記錄檔。 +- **跨裝置同步**:設定會在你的裝置間同步,同步失敗時可以手動重試。 +- **讓 Bot 在聊天裡幫你改設定**:「Grok Bot 已將{設定}設為{值}」「Grok Bot 未能變更…請改為在此處變更」。 +- **說明**:說明中心、聯絡支援、重新開始到職引導、試驗場、複製設定連結。 +- **鍵盤**:鍵盤快速鍵清單、聚焦聊天、按 Esc 關閉。 + +--- + +## 27. 錯誤、狀態、意見回饋 + +- **模型與連線**: + - 「模型供應商已超載」「模型供應商目前負載過高。通常是暫時的。」 + - 「模型未能及時開始回應。請重試。」「此請求已受到速率限制。」 + - 「沒有網路連線」「此操作逾時」「伺服器傳送了此版本的 Grok Bot 無法讀取的回覆」。 +- **團隊還沒開通**:「團隊管理員完成 Grok Bot 設定後,成員才能傳送訊息」。 +- **一般錯誤**: + - 「Grok Bot 在顯示內容時發生非預期的錯誤。重新載入通常可以解決。」 + - 可以複製錯誤資訊和要求 ID。 +- **意見回饋**: + - 「我們重視所有意見回饋,無論是錯誤、想法或問題」,可以勾選希望支援團隊回覆。 + - 評分選項有不佳、不準確、品質不佳、太慢了。 + - 回報太多次會被限制。 +- **時間標示**:剛剛、今天、N 天前、到期日;統計區間有 7、30、90 天。 + +--- + +## 28. 原版內部規則(從程式拆出來的數字) + +這些是前面幾輪拆解原版程式得到的,詳細來源和函式位址都記在 MECHANICS.md。 + +- **回覆與重試**: + - 連線逾時 15 秒,中途 35 秒沒有內容就算卡住。 + - 最多嘗試 3 次,間隔 1 秒、2 秒(上限 4 秒)。 + - 429、408、529 和 5xx 錯誤會重試;已經有內容送出就不重試。 +- **長對話**:用到模型可讀長度的 70% 時,把舊內容整理成摘要,回合中多給 5% 寬限。 +- **回合**: + - 你在 Bot 工作時傳的訊息,會插進同一個回合。 + - 例行任務和其他 Bot 可以叫醒 Bot。 + - 回合結束有三種:成功、取消、錯誤。 +- **自動更新**: + - 啟動後 30 秒檢查第一次,之後每 1 小時加 0–5 分鐘的隨機延遲。 + - 要同時符合才自動更新:螢幕鎖定或螢幕保護程式開著、10 分鐘沒動作、沒有 Bot 在工作。 +- **操作螢幕**(拆自 Computer Use 小幫手): + - 截圖長邊縮到 1024,短邊不小於 8,最大 1 MB,JPEG 品質依序試 85、70、55。 + - 點擊:按住 0.03 秒,連點間隔 0.08 秒。 + - 打字:每段最多 20 個字元單位、不切斷文字,用 128 號鍵送出;每段按住 0.035 秒,段與段之間隔 0.02 秒。 + - 按鍵:按住 0.035 秒,多個鍵之間隔 0.02 秒。 + - 用 HID 系統狀態送出事件,不會凍結你自己的滑鼠和鍵盤。 + - 在背景視窗點擊時,不會把它拉到前面(skylight-no-raise)。 +- **Chrome 類 App 的判斷**: + - 識別碼是 com.google.Chrome、com.github.Electron,或以 com.google.Chrome. 開頭;或 App 裡有 Electron Framework、Chromium Embedded Framework、Google Chrome Framework 其中之一。 + - 這類 App 會先打開 AXManualAccessibility 和 AXEnhancedUserInterface 兩個輔助使用開關,網頁文字才讀得到。 +- **螢幕錄製權限**:macOS 14 以上才有;使用者拒絕截圖時的錯誤碼是 −3801。 +- **管理員鎖定**:可以從 macOS 系統偏好設定鎖住「直接操作」和「在這台電腦上執行」。 +- **字型**: + - 系統字型(英文 SF、中文蘋方),程式碼用 SF Mono。 + - 字級 11/12/13/14/15/17/22/26 px,行高 16/16/18/20/22/24/28/32 px。 + +--- + +## 29. 和 AnyBot 的對照 + +| 原版功能 | AnyBot | +|---|---| +| 多個 Bot、主要 Bot、釘選、搜尋、頭像形狀和顏色 | ✅ 有(形狀和顏色較少) | +| **換 AI 供應商**(原版只能用自己的模型) | ✅ AnyBot 獨有:7 家供應商,每個 Bot 可以各選 | +| 聊天、思考過程、插話、重新產生、摘要 | ✅ 有 | +| 例行任務:每天、每週、每月、每年、時區、立即執行 | ✅ 有 | +| 例行任務:每小時、每 N 分鐘、僅一次、「左右」彈性時間、跟著行事曆、事件觸發(Slack、GitHub、Linear、Sentry、PagerDuty、郵件、Webhook、群組) | ✅ 有(用你的 Token 定時查;AnyBot 開著時,或放在雲端主機 24 小時) | +| 在聊天裡請 Bot 操作 App(建立例行任務、換模型、改設定、交給其他 Bot) | ✅ 有 | +| 權限:允許一次、一律允許、拒絕、自動審核、「一律詢問」規則、直接操作、管理員鎖定 | ✅ 有 | +| Bot 的電腦 | ✅ 有:一台真的 Linux 桌面電腦(Docker,可放在這台 Mac 或你的伺服器),所有 Bot 共用 | +| 雲端電腦的狀態(建立/休眠/喚醒)、重建、復原、重設、快照、磁碟清理、環境密鑰、看畫面、接管 | ✅ 有,規則照原版(同時一個更新、10 分鐘內接續、90 秒還原等待) | +| 雲端電腦的排程自動更新(一小時補做) | ✅ 有 | +| 雲端電腦的網路改走你的電腦(僅新連線、管理員可關) | ✅ 有 | +| 你的電腦:檔案、指令、背景指令、把輸入傳給執行中的指令 | ✅ 有,另外支援 SSH 連到其他電腦 | +| 操作螢幕:截圖、點擊、按住/放開、打字、按鍵、捲動、拖曳、檢查權限(原版九個工具) | ✅ 有,時間照原版;你的 Mac 和 Bot 的電腦都可以 | +| 逐個 App 授權、接管(取得控制權/停止) | ✅ 有 | +| 在背景視窗點擊、示範教學錄製(步驟+錄影+每步畫面) | ✅ 有 | +| 瀏覽器:Bot 自己的瀏覽器、多個設定檔、讀你的 Chrome | ✅ 有 | +| 保險庫、1Password、安全表單(Bot 看不到密碼) | ✅ 有(macOS 鑰匙圈+1Password CLI) | +| 連接器、MCP(本機和遠端) | ✅ 有 | +| 帳號登入、多台裝置、管理裝置(在線/離線) | ✅ 有:你自己的帳號伺服器(薄後端,`relay/`),裝置清單顯示在線/離線 | +| 行動版 App | ✅ 手機網頁版:聊天、核准、看和操作 Bot 的電腦與你的 Mac、附加照片、推播通知、可加到主畫面 | +| 跨裝置同步 | ✅ 每日加密備份+在新電腦還原(不是即時同步) | +| 在另一台裝置上執行(裝置端工具) | ✅ 裝置連結:帳號裡的另一台 AnyBot 執行螢幕、檔案、指令、Mac App 工具(詢問出現在發出的那一端) | +| Bot 和聊天在雲端,關掉電腦也繼續 | ✅ `anybot --serve` 放在雲端主機或一直開著的 Mac,例行任務、觸發、訊息管道 24 小時運作 | +| 外掛市集 | ✅ 連接器市集:8 個常用 MCP 連接器一鍵加入(檔案、記憶、思考、抓網頁、Git、時間、Playwright 瀏覽器、GitHub),另外可以接任何 MCP 伺服器;原版市集的實際清單在它的雲端,看不到 | +| Mac 的提醒事項、行事曆、聯絡人、備忘錄、郵件(讀、起草、寄出)、訊息(傳送) | ✅ 有,工具名稱和規則照原版(寄出每封都問) | +| Slack、Discord、Telegram 訊息管道 | ✅ 有(用你自己的 Bot Token) | +| Microsoft Teams(裝置碼登入,聊天和頻道) | ✅ 有 | +| Mac「訊息」、郵件管道 | ✅ 有(用這台 Mac 的 App) | +| 專屬號碼、專屬信箱、X、打電話 | ✅ 用你自己的 Twilio 號碼(回簡訊、傳簡訊、打電話唸訊息)和 X 開發者 App(回覆提及、發文),傳出去的每次都先問你;信箱用 Mac「郵件」管道。帳號要你自己申請(付費) | +| 會議錄音、文字記錄、整理重點 | ✅ 有(用麥克風錄;Bot 不會自己加入線上會議) | +| 附件(拖、選、貼圖片)、聽寫(語音輸入) | ✅ 有 | +| 語音聊天(說話輸入+朗讀回答) | ✅ 有 | +| 語音通話(免按鍵,聽 → 回答 → 唸) | ✅ 有(原版可以邊說邊插話,AnyBot 是輪流說) | +| 記憶、技能、範本 | ✅ 有 | +| 分享 Bot(匯出/匯入檔案) | ✅ 有 | +| 團隊 Bot 的成員、角色 | ✅ 把一台 AnyBot 分享給帳號伺服器上的其他帳號:成員(聊天、附加檔案)或管理員(另外可以核准、停止、看和操作畫面);Bot 會知道是誰在說話 | +| 方案、用量、付款、代你購物 | ❌ 不需要(AnyBot 用你自己的 AI 金鑰) | +| 檔案預覽、圖表、Bot 做的頁面 | ✅ 有(在你的螢幕打開) | +| 發布網站 | ✅ Bot 用 publish_site 發布到你的帳號伺服器(`/s/帳號-名稱/`),每個網站名稱第一次發布先問你;網頁在沙盒裡執行,碰不到登入資料 | +| 機票、餐廳、天氣、商品、活動卡片 | ✅ 有 | +| 通知、你離開時自動更新、重新啟動、淺色和深色主題 | ✅ 有 | +| 討論串、群組聊天、表情回應、引用、匯出文字記錄 | ✅ 有 | diff --git a/box/Dockerfile b/box/Dockerfile new file mode 100644 index 0000000..562607c --- /dev/null +++ b/box/Dockerfile @@ -0,0 +1,26 @@ +# AnyBot's own computer ("Bot 的電腦"): a Linux desktop the bots share, like the original's box +# (an X11 desktop driven by xdotool, watched through noVNC on port 6080). +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb x11vnc xdotool x11-utils x11-xserver-utils imagemagick fluxbox xterm \ + novnc python3-websockify chromium fonts-noto-cjk fonts-noto-color-emoji \ + ca-certificates curl wget git jq unzip zip less nano vim-tiny procps psmisc \ + python3 python3-pip python3-venv nodejs npm sudo tini dbus-x11 xclip \ + && apt-get clean && rm -rf /var/lib/apt/lists/* \ + && useradd -m -s /bin/bash bot && echo 'bot ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/bot \ + && ln -sf /usr/share/novnc/vnc.html /usr/share/novnc/index.html +COPY start.sh /usr/local/bin/anybot-box-start +COPY fluxbox-init /etc/anybot/fluxbox-init +COPY overlay /etc/anybot/overlay +COPY Xresources /etc/anybot/Xresources +COPY menu /etc/anybot/menu +RUN chmod +x /usr/local/bin/anybot-box-start \ + # Chromium has no sandbox inside a container (no user namespaces); the box itself is the + # sandbox. Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open). + && printf '%s\n' 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic --test-type"' > /etc/chromium.d/anybot +ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 +EXPOSE 6080 +LABEL anybot.box.version="3" +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/usr/local/bin/anybot-box-start"] diff --git a/box/Xresources b/box/Xresources new file mode 100644 index 0000000..b14ddc2 --- /dev/null +++ b/box/Xresources @@ -0,0 +1,6 @@ +XTerm*faceName: Noto Sans Mono CJK TC +XTerm*faceSize: 11 +XTerm*background: #1f1f1f +XTerm*foreground: #f5f5f5 +XTerm*geometry: 100x30 +Xft.dpi: 96 diff --git a/box/fluxbox-init b/box/fluxbox-init new file mode 100644 index 0000000..a9e9be8 --- /dev/null +++ b/box/fluxbox-init @@ -0,0 +1,9 @@ +session.screen0.toolbar.visible: true +session.screen0.toolbar.placement: BottomCenter +session.screen0.toolbar.widthPercent: 100 +session.screen0.workspaces: 1 +session.screen0.workspaceNames: Bot 的電腦 +session.screen0.defaultDeco: NORMAL +session.styleOverlay: ~/.fluxbox/overlay +session.screen0.allowRemoteActions: true +session.screen0.rootCommand: xsetroot -solid '#2b2f36' diff --git a/box/menu b/box/menu new file mode 100644 index 0000000..b17d44e --- /dev/null +++ b/box/menu @@ -0,0 +1,7 @@ +[begin] (Bot 的電腦) + [exec] (瀏覽器 Chromium) {chromium --no-first-run --no-default-browser-check --disable-gpu} + [exec] (終端機) {xterm} + [separator] + [workspaces] (視窗) + [restart] (重新整理桌面) +[end] diff --git a/box/overlay b/box/overlay new file mode 100644 index 0000000..71e41b5 --- /dev/null +++ b/box/overlay @@ -0,0 +1,2 @@ +background: none +*.font: Noto Sans CJK TC-10 diff --git a/box/start.sh b/box/start.sh new file mode 100644 index 0000000..cfb662e --- /dev/null +++ b/box/start.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Boots the desktop: X server, window manager, VNC and the noVNC web viewer. +set -u +mkdir -p /home/bot/.fluxbox /home/bot/Desktop && cp -n /etc/anybot/fluxbox-init /home/bot/.fluxbox/init 2>/dev/null +cp -n /etc/anybot/overlay /home/bot/.fluxbox/overlay 2>/dev/null +cp -n /etc/anybot/Xresources /home/bot/.Xresources 2>/dev/null +cp /etc/anybot/menu /home/bot/.fluxbox/menu 2>/dev/null +chown -R bot:bot /home/bot +rm -f /tmp/.X1-lock /tmp/.X11-unix/X1 +Xvfb :1 -screen 0 "${SCREEN_SIZE}" -nolisten tcp & +for i in $(seq 1 50); do xdpyinfo -display :1 >/dev/null 2>&1 && break; sleep 0.1; done +su bot -c "DISPLAY=:1 xrdb -nocpp -merge /home/bot/.Xresources" +su bot -c "DISPLAY=:1 fluxbox >/tmp/fluxbox.log 2>&1 &" +(sleep 1.5; xsetroot -display :1 -solid '#2b2f36') & +xsetroot -display :1 -solid '#2b2f36' +x11vnc -display :1 -forever -shared -nopw -quiet -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 & +echo "anybot-box ready" > /tmp/anybot-box.ready +wait diff --git a/deploy/linux/anybot.desktop b/deploy/linux/anybot.desktop new file mode 100644 index 0000000..f33f366 --- /dev/null +++ b/deploy/linux/anybot.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=AnyBot +Comment=Your own AI bots that work on their own computer and yours +Comment[zh_TW]=你的 AI Bot:在自己的電腦和你的電腦上幫你做事 +Exec=anybot +Icon=anybot +Terminal=false +Categories=Utility;Office; +StartupWMClass=anybot diff --git a/deploy/linux/dev/Dockerfile b/deploy/linux/dev/Dockerfile new file mode 100644 index 0000000..a63ed9e --- /dev/null +++ b/deploy/linux/dev/Dockerfile @@ -0,0 +1,11 @@ +# AnyBot's Linux desktop build-and-test box: Rust, the libraries eframe/rodio need, a virtual +# screen (Xvfb) to run the window headless, a headless Wayland desktop (sway) for the Wayland +# path, and the desktop tools AnyBot uses on Linux. +FROM rust:1-bookworm +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libasound2-dev libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libx11-dev libxcursor1 libxrandr2 libxi6 libgl1 libegl1 libgl1-mesa-dri \ + libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev libxdo-dev libdbus-1-dev libxtst-dev libpipewire-0.3-dev libclang-dev libgbm-dev \ + xvfb xdotool x11-utils imagemagick libnotify-bin libsecret-tools espeak-ng xclip dbus-x11 gnome-keyring \ + fonts-noto-cjk ffmpeg poppler-utils ripgrep docker.io \ + sway foot wev grim wl-clipboard \ + && rm -rf /var/lib/apt/lists/* diff --git a/deploy/linux/dev/gnome/Dockerfile b/deploy/linux/dev/gnome/Dockerfile new file mode 100644 index 0000000..ec8178d --- /dev/null +++ b/deploy/linux/dev/gnome/Dockerfile @@ -0,0 +1,8 @@ +# A headless GNOME (Wayland) desktop for testing AnyBot's portal path, on top of the dev box. +# docker build -t anybot-linux-dev deploy/linux/dev && docker build -t anybot-linux-gnome deploy/linux/dev/gnome +FROM anybot-linux-dev +RUN apt-get update && apt-get install -y --no-install-recommends \ + gnome-shell xdg-desktop-portal xdg-desktop-portal-gnome pipewire wireplumber dbus-user-session gnome-settings-daemon \ + python3-dbusmock python3-gi gir1.2-glib-2.0 zenity locales \ + gstreamer1.0-tools gstreamer1.0-pipewire gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + && sed -i "s/# zh_TW.UTF-8/zh_TW.UTF-8/" /etc/locale.gen && locale-gen && rm -rf /var/lib/apt/lists/* diff --git a/deploy/linux/dev/gnome/op.py b/deploy/linux/dev/gnome/op.py new file mode 100644 index 0000000..057016d --- /dev/null +++ b/deploy/linux/dev/gnome/op.py @@ -0,0 +1,64 @@ +# The test's stand-in for the person: screenshots through the portal, clicks and keys through +# Mutter's own remote desktop interface. op.py shot out.png | click x y | key keysym +import sys, time, shutil, urllib.parse +from gi.repository import Gio, GLib +bus = Gio.bus_get_sync(Gio.BusType.SESSION) +def call(dest, path, iface, method, args=None, sig=None): + return bus.call_sync(dest, path, iface, method, args, None, 0, -1, None) +def shot(out): + token = 'op%d' % int(time.time() * 1000) + sender = bus.get_unique_name()[1:].replace('.', '_') + req = '/org/freedesktop/portal/desktop/request/%s/%s' % (sender, token) + loop = GLib.MainLoop(); res = {} + def on(conn, snd, path, iface, sig, params, *a): + res['r'] = params.unpack(); loop.quit() + bus.signal_subscribe(None, 'org.freedesktop.portal.Request', 'Response', req, None, 0, on) + call('org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', 'org.freedesktop.portal.Screenshot', 'Screenshot', + GLib.Variant('(sa{sv})', ('', {'handle_token': GLib.Variant('s', token), 'interactive': GLib.Variant('b', False)}))) + GLib.timeout_add_seconds(10, loop.quit); loop.run() + uri = res['r'][1]['uri'] + shutil.move(urllib.parse.unquote(uri[7:]), out) +def session(): + path = call('org.gnome.Mutter.RemoteDesktop', '/org/gnome/Mutter/RemoteDesktop', 'org.gnome.Mutter.RemoteDesktop', 'CreateSession').unpack()[0] + call('org.gnome.Mutter.RemoteDesktop', path, 'org.gnome.Mutter.RemoteDesktop.Session', 'Start') + return lambda m, a: call('org.gnome.Mutter.RemoteDesktop', path, 'org.gnome.Mutter.RemoteDesktop.Session', m, a) +def eye(out): + # Mutter's own screen cast (the operator's view, no portal), one frame through GStreamer. + import subprocess + sp = call('org.gnome.Mutter.ScreenCast', '/org/gnome/Mutter/ScreenCast', 'org.gnome.Mutter.ScreenCast', 'CreateSession', GLib.Variant('(a{sv})', ({},))).unpack()[0] + st = call('org.gnome.Mutter.ScreenCast', sp, 'org.gnome.Mutter.ScreenCast.Session', 'RecordMonitor', GLib.Variant('(sa{sv})', ('Meta-0', {}))).unpack()[0] + loop = GLib.MainLoop(); res = {} + def on(conn, snd, path, iface, sig, params, *a): + res['node'] = params.unpack()[0]; loop.quit() + bus.signal_subscribe(None, 'org.gnome.Mutter.ScreenCast.Stream', 'PipeWireStreamAdded', st, None, 0, on) + call('org.gnome.Mutter.ScreenCast', sp, 'org.gnome.Mutter.ScreenCast.Session', 'Start') + GLib.timeout_add_seconds(5, loop.quit); loop.run() + subprocess.run(['gst-launch-1.0', '-q', 'pipewiresrc', 'path=%d' % res['node'], 'num-buffers=3', '!', 'videoconvert', '!', 'pngenc', 'snapshot=true', '!', 'filesink', 'location=' + out], timeout=20) + call('org.gnome.Mutter.ScreenCast', sp, 'org.gnome.Mutter.ScreenCast.Session', 'Stop') +cmd = sys.argv[1] +if cmd == 'eye': + eye(sys.argv[2]) +if cmd == 'shot': + shot(sys.argv[2]) +elif cmd == 'click': + # Absolute moves need a screen cast stream tied to the remote desktop session. + rdp = call('org.gnome.Mutter.RemoteDesktop', '/org/gnome/Mutter/RemoteDesktop', 'org.gnome.Mutter.RemoteDesktop', 'CreateSession').unpack()[0] + sid = call('org.gnome.Mutter.RemoteDesktop', rdp, 'org.freedesktop.DBus.Properties', 'Get', GLib.Variant('(ss)', ('org.gnome.Mutter.RemoteDesktop.Session', 'SessionId'))).unpack()[0] + sp = call('org.gnome.Mutter.ScreenCast', '/org/gnome/Mutter/ScreenCast', 'org.gnome.Mutter.ScreenCast', 'CreateSession', GLib.Variant('(a{sv})', ({'remote-desktop-session-id': GLib.Variant('s', sid)},))).unpack()[0] + st = call('org.gnome.Mutter.ScreenCast', sp, 'org.gnome.Mutter.ScreenCast.Session', 'RecordMonitor', GLib.Variant('(sa{sv})', ('Meta-0', {}))).unpack()[0] + loop = GLib.MainLoop() + bus.signal_subscribe(None, 'org.gnome.Mutter.ScreenCast.Stream', 'PipeWireStreamAdded', st, None, 0, lambda *a: loop.quit()) + call('org.gnome.Mutter.RemoteDesktop', rdp, 'org.gnome.Mutter.RemoteDesktop.Session', 'Start') + GLib.timeout_add_seconds(5, loop.quit); loop.run() + import subprocess + gst = subprocess.Popen(['gst-launch-1.0', '-q', 'pipewiresrc', 'path=%d' % 0, '!', 'fakesink'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + s = lambda m, a: call('org.gnome.Mutter.RemoteDesktop', rdp, 'org.gnome.Mutter.RemoteDesktop.Session', m, a) + time.sleep(0.5) + s('NotifyPointerMotionAbsolute', GLib.Variant('(sdd)', (st, float(sys.argv[2]), float(sys.argv[3])))); time.sleep(0.2) + s('NotifyPointerButton', GLib.Variant('(ib)', (0x110, True))); time.sleep(0.05) + s('NotifyPointerButton', GLib.Variant('(ib)', (0x110, False))); time.sleep(0.3) +elif cmd == 'key': + s = session(); time.sleep(0.3) + for k in sys.argv[2:]: + s('NotifyKeyboardKeysym', GLib.Variant('(ub)', (int(k, 16), True))); time.sleep(0.05) + s('NotifyKeyboardKeysym', GLib.Variant('(ub)', (int(k, 16), False))); time.sleep(0.1) diff --git a/deploy/linux/dev/gnome/session.sh b/deploy/linux/dev/gnome/session.sh new file mode 100644 index 0000000..08f9c99 --- /dev/null +++ b/deploy/linux/dev/gnome/session.sh @@ -0,0 +1,11 @@ +export XDG_RUNTIME_DIR=/tmp/xdg-g; rm -rf $XDG_RUNTIME_DIR; mkdir -m 700 -p $XDG_RUNTIME_DIR +export XDG_CURRENT_DESKTOP=GNOME XDG_SESSION_TYPE=wayland LANG=zh_TW.UTF-8 +unset DISPLAY +dbus-run-session -- sh -c ' +echo "export DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR WAYLAND_DISPLAY=wayland-0 XDG_CURRENT_DESKTOP=GNOME XDG_SESSION_TYPE=wayland LANG=zh_TW.UTF-8 HOME=/home/t" > /tmp/env.sh +pipewire >/tmp/pw.log 2>&1 & +sleep 0.5; wireplumber >/tmp/wp.log 2>&1 & +gnome-shell --headless --wayland --no-x11 --virtual-monitor 1280x800 >/tmp/shell.log 2>&1 & +sleep 4 +/usr/libexec/xdg-desktop-portal >/tmp/xdp.log 2>&1 & +sleep infinity' diff --git a/deploy/linux/dev/gnome/test.sh b/deploy/linux/dev/gnome/test.sh new file mode 100755 index 0000000..6b98f41 --- /dev/null +++ b/deploy/linux/dev/gnome/test.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# AnyBot on GNOME Wayland (the desktop portals), from a Mac or anywhere with Docker: +# deploy/linux/dev/gnome/test.sh (from app/, after building both images) +# A headless GNOME 43 runs in the box; op.py plays the person: it answers GNOME's +# 「遠端桌面」 dialog (switch on 「允許遠端操作」, press 「分享」) and reads the screen through +# Mutter's own screen cast. Checks: the missing-switch message, typing English + Chinese into a +# text box, and clicking a small button exactly. +set -e +cd "$(dirname "$0")/../../../.." +docker rm -f anybot-gnome >/dev/null 2>&1 || true +docker run -d --name anybot-gnome -v "$PWD":/src -v anybot-linux-target:/target -w /src anybot-linux-gnome sh -c ' +mkdir -p /run/dbus; dbus-daemon --system --fork; (python3 -m dbusmock --template logind --system >/tmp/mock.log 2>&1 &); sleep 1 +useradd -m t; runuser -u t -- env HOME=/home/t sh deploy/linux/dev/gnome/session.sh' >/dev/null +sleep 10 +docker exec -u t anybot-gnome sh -c ' +. /tmp/env.sh; OP="python3 deploy/linux/dev/gnome/op.py"; A=/target/release/anybot +# Leave the overview, and answer GNOME'"'"'s one-time "allow screenshots?" as the person would. +$OP key ff1b; sleep 1 +gdbus call --session -d org.freedesktop.impl.portal.PermissionStore -o /org/freedesktop/impl/portal/PermissionStore -m org.freedesktop.impl.portal.PermissionStore.SetPermission screenshot true screenshot "" "[\"yes\"]" >/dev/null +approve() { sleep 5; [ "$1" = share-only ] || { $OP click 820 492; sleep 0.5; }; $OP click 841 289; sleep 6; } +echo "1. 分享 without 允許遠端操作:" +(timeout 60 $A --tool computer_use_screenshot {} > /tmp/a1.log 2>&1 &); approve share-only; sed "s/^/ /" /tmp/a1.log +echo "2. typing into a text box:" +(GDK_BACKEND=wayland zenity --entry --title t --text 輸入 > /tmp/zen.out 2>/dev/null &); sleep 3 +(timeout 60 $A --tool computer_use_screenshot {} computer_use_typing "{\"value\":\"Hi 你好,世界 ok\"}" computer_use_press_key "{\"key\":\"return\"}" > /tmp/a2.log 2>&1 &); approve +echo " text box got: [$(cat /tmp/zen.out)] (expected [Hi 你好,世界 ok])" +echo "3. clicking 「否」 in a yes/no dialog:" +(GDK_BACKEND=wayland zenity --question --title q --text 要繼續嗎 >/dev/null 2>&1; echo "$?" > /tmp/q.out) & sleep 3 +(timeout 60 $A --tool computer_use_screenshot {} computer_use_click "{\"x\":98,\"y\":168}" > /tmp/a3.log 2>&1 &); approve +echo " dialog answer: $(cat /tmp/q.out) (expected 1 = 否)" +' +docker rm -f anybot-gnome >/dev/null diff --git a/deploy/linux/dev/test.sh b/deploy/linux/dev/test.sh new file mode 100755 index 0000000..f86e7a6 --- /dev/null +++ b/deploy/linux/dev/test.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Build and test the Linux version from a Mac (or anywhere with Docker), on a virtual screen. +# docker build -t anybot-linux-dev deploy/linux/dev (once) +# deploy/linux/dev/test.sh (from app/) +# Runs: every unit test; the live X11 test (screenshot, clicks, keys, recording a demo); the +# keyring test (save / find / remove a login); the live Wayland test (headless sway: screenshot, +# clicks, Chinese typing, keys, scroll); and screenshots of the window into ./linux-shots. +set -e +cd "$(dirname "$0")/../../.." +mkdir -p linux-shots +docker run --rm -v "$PWD":/src -v anybot-linux-target:/target -v anybot-linux-cargo:/usr/local/cargo/registry \ + -v "$PWD/linux-shots":/out -e CARGO_TARGET_DIR=/target -w /src anybot-linux-dev sh -c ' +set -e +cargo build --release 2>&1 | grep -E "^(error|warning)" -A7 || true +Xvfb :99 -screen 0 1400x900x24 >/dev/null 2>&1 & +export DISPLAY=:99 LANG=zh_TW.UTF-8 +sleep 1 +cargo test --release 2>&1 | grep -E "test result|FAILED|panicked" +cargo test --release x11_live -- --ignored 2>&1 | grep -E "test result|panicked" +dbus-run-session -- sh -c "echo -n test | gnome-keyring-daemon --unlock --components=secrets >/dev/null 2>&1; sleep 1; cargo test --release keyring_live -- --ignored 2>&1 | grep -E \"test result|panicked\"" +useradd -m t 2>/dev/null; chmod 777 /out +runuser -u t -- env CARGO_TARGET_DIR=/target HOME=/home/t sh deploy/linux/dev/wayland_live.sh 2>&1 | sed -n "/--- wev heard/,\$p" +for v in chat computers general; do + ANYBOT_DATA_DIR=/tmp/anybot-data ANYBOT_SHOT_VIEW=$v ANYBOT_SHOT_DARK=1 timeout 90 /target/release/anybot --screenshot /out/$v.png >/dev/null 2>&1 && echo "shot $v" +done' diff --git a/deploy/linux/dev/wayland_live.sh b/deploy/linux/dev/wayland_live.sh new file mode 100755 index 0000000..fd848b3 --- /dev/null +++ b/deploy/linux/dev/wayland_live.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Runs inside the test box: a headless sway desktop with `wev` (it prints every click and key it +# gets) in front, then AnyBot's own computer tools against it. Prints what wev heard. +set -e +export XDG_RUNTIME_DIR=/tmp/xdg-wl && rm -rf $XDG_RUNTIME_DIR && mkdir -m 700 -p $XDG_RUNTIME_DIR +unset DISPLAY +cat > /tmp/sway.conf <<'CONF' +output HEADLESS-1 resolution 1280x800 position 0 0 +default_border none +CONF +WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman sway -c /tmp/sway.conf >/tmp/sway.log 2>&1 & +for i in $(seq 50); do [ -S $XDG_RUNTIME_DIR/wayland-1 ] && break; sleep 0.1; done +export WAYLAND_DISPLAY=wayland-1 +stdbuf -oL wev > /tmp/wev.log 2>&1 & +sleep 1.5 +BIN=${CARGO_TARGET_DIR:-target}/release/anybot +$BIN --tool computer_use_check_permissions '{}' \ + computer_use_screenshot '{}' \ + computer_use_click '{"x":400,"y":300}' \ + computer_use_click '{"x":420,"y":310,"button":"right"}' \ + computer_use_typing '{"value":"Hi 你好"}' \ + computer_use_press_key '{"key":"cmd+a"}' \ + computer_use_press_key '{"key":"return"}' \ + computer_use_scroll '{"x":400,"y":300,"direction":"down","amount":2}' +sleep 0.5 +grim /tmp/grim.png +echo "--- wev heard:" +grep -E "state: 1|axis: 0|depressed: 0+[1-9]" -A1 /tmp/wev.log | grep -oE "button: [0-9]+ \([a-z]+\)|utf8: '[^']+'|sym: (Control_L|Return)|axis: 0 \(vertical\), value: -?[0-9.]+|depressed: 0+[1-9]+" | uniq | tr '\n' ' '; echo +echo "--- screenshot (anybot vs grim):" +identify -format "%wx%h\n" /tmp/anybot-tool-1.jpg /tmp/grim.png +cp /tmp/anybot-tool-1.jpg /out/wayland-shot.jpg 2>/dev/null || true +cp /tmp/grim.png /out/wayland-grim.png 2>/dev/null || true diff --git a/deploy/linux/install.sh b/deploy/linux/install.sh new file mode 100755 index 0000000..83e6fa2 --- /dev/null +++ b/deploy/linux/install.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Install AnyBot on a Linux desktop for the current user (no root needed for this part). +# +# ./deploy/linux/install.sh build from source, then install +# ./deploy/linux/install.sh --deps also install the system packages (asks for sudo) +# +# Puts the app in ~/.local/bin/anybot, the menu entry in ~/.local/share/applications and the +# icon in ~/.local/share/icons. Run it from the app/ folder. +set -e +cd "$(dirname "$0")/../.." + +# What AnyBot uses on Linux (all optional except the first line; the app explains what's missing): +# libasound2 libxkbcommon-x11-0 libgl1 xdg-utils – the window and sound +# libnotify-bin notifications libsecret-tools saved logins (系統鑰匙圈) +# espeak-ng reading answers aloud zenity file pickers +# xclip / wl-clipboard pasting pictures; typing Chinese on GNOME/KDE Wayland +# ffmpeg video posters, Opus, demo videos +# docker.io the bot's own computer fonts-noto-cjk extra CJK glyphs (粉圓體 is built in) +DEPS_DEBIAN="libasound2 libxkbcommon-x11-0 libgl1 xdg-utils libnotify-bin libsecret-tools espeak-ng zenity xclip wl-clipboard ffmpeg docker.io fonts-noto-cjk" +DEPS_FEDORA="alsa-lib libxkbcommon-x11 mesa-libGL xdg-utils libnotify libsecret espeak-ng zenity xclip wl-clipboard ffmpeg-free moby-engine google-noto-sans-cjk-fonts" +DEPS_ARCH="alsa-lib libxkbcommon-x11 mesa xdg-utils libnotify libsecret espeak-ng zenity xclip wl-clipboard ffmpeg docker noto-fonts-cjk" +BUILD_DEBIAN="build-essential pkg-config libasound2-dev" + +if [ "$1" = "--deps" ]; then + if command -v apt-get >/dev/null; then + sudo apt-get update && sudo apt-get install -y $DEPS_DEBIAN $BUILD_DEBIAN + elif command -v dnf >/dev/null; then + sudo dnf install -y $DEPS_FEDORA gcc pkgconf alsa-lib-devel + elif command -v pacman >/dev/null; then + sudo pacman -S --needed $DEPS_ARCH base-devel + else + echo "Install these with your package manager: $DEPS_DEBIAN" + fi + if command -v docker >/dev/null && ! id -nG | grep -qw docker; then + echo "Adding you to the docker group (the bot's computer needs it); log out and back in afterwards." + sudo usermod -aG docker "$USER" + fi +fi + +OUT="${CARGO_TARGET_DIR:-target}/release/anybot" +if [ -x "$OUT" ] && [ "$1" != "--rebuild" ]; then + echo "Using the existing build ($OUT)." +else + command -v cargo >/dev/null || { echo "Rust is needed to build: https://rustup.rs"; exit 1; } + cargo build --release +fi + +BIN="$HOME/.local/bin" +APPS="$HOME/.local/share/applications" +ICONS="$HOME/.local/share/icons/hicolor" +mkdir -p "$BIN" "$APPS" "$ICONS/256x256/apps" "$ICONS/128x128/apps" "$ICONS/scalable/apps" +install -m 755 "$OUT" "$BIN/anybot" +install -m 644 deploy/linux/anybot.desktop "$APPS/anybot.desktop" +install -m 644 icons/anybot.svg "$ICONS/scalable/apps/anybot.svg" +"$BIN/anybot" --write-icon "$ICONS/256x256/apps/anybot.png" 256 +"$BIN/anybot" --write-icon "$ICONS/128x128/apps/anybot.png" 128 +command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" 2>/dev/null || true +command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$ICONS" 2>/dev/null || true + +echo "AnyBot is installed: find it in your app menu, or run: anybot" +case ":$PATH:" in *":$BIN:"*) ;; *) echo "(Add $BIN to your PATH to start it from a terminal.)" ;; esac +if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then + echo "Wayland: bots can see and use your screen. On GNOME/KDE the first time, a system window asks:" + echo " turn on 「允許遠端操作」 (Allow remote interaction), then press 「分享」 (Share)." + echo " Recording a demonstration (教它一項任務) needs an Xorg session." +fi diff --git a/deploy/mac/com.anybot.relay.plist b/deploy/mac/com.anybot.relay.plist new file mode 100644 index 0000000..95b3ece --- /dev/null +++ b/deploy/mac/com.anybot.relay.plist @@ -0,0 +1,27 @@ + + + + + + Labelcom.anybot.relay + ProgramArguments + + /Users/daniel/Library/Application Support/AnyBot/relay/anybot-relay + --bind127.0.0.1 + --port8780 + --data/Users/daniel/Library/Application Support/AnyBot/relay/data + + EnvironmentVariables + ANYBOT_RELAY_PUBLIC_URLhttps://mac-mini.tailbcc3a6.ts.net + RunAtLoad + KeepAlive + ThrottleInterval10 + StandardErrorPath/Users/daniel/Library/Application Support/AnyBot/relay/relay.log + + diff --git a/deploy/mac/com.anybot.serve.plist b/deploy/mac/com.anybot.serve.plist new file mode 100644 index 0000000..941bb58 --- /dev/null +++ b/deploy/mac/com.anybot.serve.plist @@ -0,0 +1,20 @@ + + + + + + Labelcom.anybot.serve + ProgramArguments + + /Applications/AnyBot.app/Contents/MacOS/anybot + --serve + + RunAtLoad + KeepAlive + StandardErrorPath/tmp/anybot-serve.log + + diff --git a/deploy/server/.env.example b/deploy/server/.env.example new file mode 100644 index 0000000..db689fd --- /dev/null +++ b/deploy/server/.env.example @@ -0,0 +1,14 @@ +# Copy to .env and fill in. +ANYBOT_USER=daniel +ANYBOT_PASSWORD=change-me-please +ANYBOT_HOST_NAME=雲端 AnyBot +# 1 = the first start takes over the bots from your Mac's newest backup. +ANYBOT_RESTORE_FROM_ACCOUNT= +# Only needed when other people should be able to sign up. +ANYBOT_RELAY_INVITE= +XAI_API_KEY= +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GEMINI_API_KEY= +# Public address, e.g. https://anybot.example.com +ANYBOT_RELAY_PUBLIC_URL= diff --git a/deploy/server/Dockerfile b/deploy/server/Dockerfile new file mode 100644 index 0000000..8d1ec1a --- /dev/null +++ b/deploy/server/Dockerfile @@ -0,0 +1,16 @@ +# AnyBot without a window (`anybot --serve`) on a Linux server: the bots, routines, triggers and +# channels run around the clock; the person uses the phone. The bot's own computer is a sibling +# container started through the host's Docker (the socket is mounted in docker-compose.yml). +# Build from the app/ folder: docker build -f deploy/server/Dockerfile -t anybot-serve . +FROM rust:1-bookworm AS build +WORKDIR /src +COPY . . +RUN cargo build --release --bin anybot + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep poppler-utils docker.io openssh-client \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/target/release/anybot /usr/local/bin/anybot +ENV ANYBOT_DATA_DIR=/data +VOLUME /data +CMD ["anybot", "--serve"] diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml new file mode 100644 index 0000000..a09616e --- /dev/null +++ b/deploy/server/docker-compose.yml @@ -0,0 +1,50 @@ +# Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself. +# 1. Point your domain at the server and put it in ../../relay/Caddyfile. +# 2. Put your account name and password in .env (see .env.example). +# 3. docker compose up -d +# 4. On the phone open https://your-domain, create the account (first time), and use it. +# AI keys: set them in .env, or set ANYBOT_RESTORE_FROM_ACCOUNT=1 to start from the newest backup +# your Mac made (bots, chats, routines and the keys you set there). +services: + relay: + build: + context: ../.. + dockerfile: relay/Dockerfile + restart: unless-stopped + environment: + # Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com + ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}" + ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" + volumes: + - relay-data:/data + caddy: + image: caddy:2 + restart: unless-stopped + ports: ["80:80", "443:443"] + volumes: + - ../../relay/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + depends_on: [relay] + anybot: + build: + context: ../.. + dockerfile: deploy/server/Dockerfile + restart: unless-stopped + depends_on: [relay] + environment: + ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}" + ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}" + ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}" + XAI_API_KEY: "${XAI_API_KEY:-}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + GEMINI_API_KEY: "${GEMINI_API_KEY:-}" + ANYBOT_RESTORE_FROM_ACCOUNT: "${ANYBOT_RESTORE_FROM_ACCOUNT:-}" + volumes: + - anybot-data:/data + # The bot's own computer runs as a container next to this one. + - /var/run/docker.sock:/var/run/docker.sock +volumes: + relay-data: + caddy-data: + anybot-data: diff --git a/fonts/OFL.txt b/fonts/OFL.txt new file mode 100644 index 0000000..7058b6a --- /dev/null +++ b/fonts/OFL.txt @@ -0,0 +1,101 @@ +[Kosugi Maru] +Copyright (c) 2010 MOTOYA CO.,LTD. + +[Varela Round] +Copyright (c) 2011-2016 The Varela Round Project Authors (https://github.com/alefalefalef/Varela-Round-Hebrew/), with Reserved Font Names 'Varela' and 'Varela Round'. + +[jf open huninn] +Copyright (c) 2020-2024 The jf open huninn font is redistributed by justfont Co., LTD., with Reserved Font Names 'open huninn' and 'huninn'. under SIL Open Font License. The Hanzi part of this project was derived from Kosugi Maru under Apache-2.0 (https://www.apache.org/licenses/LICENSE-2.0). + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/jf-openhuninn-2.1.ttf b/fonts/jf-openhuninn-2.1.ttf new file mode 100644 index 0000000..49ce714 Binary files /dev/null and b/fonts/jf-openhuninn-2.1.ttf differ diff --git a/icons/anybot.svg b/icons/anybot.svg new file mode 100644 index 0000000..56cfb72 --- /dev/null +++ b/icons/anybot.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/icons/lucide/LICENSE b/icons/lucide/LICENSE new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/icons/lucide/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/icons/lucide/alarm-clock.svg b/icons/lucide/alarm-clock.svg new file mode 100644 index 0000000..54ffd08 --- /dev/null +++ b/icons/lucide/alarm-clock.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/arrow-down.svg b/icons/lucide/arrow-down.svg new file mode 100644 index 0000000..c68934b --- /dev/null +++ b/icons/lucide/arrow-down.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/arrow-up.svg b/icons/lucide/arrow-up.svg new file mode 100644 index 0000000..54a8c17 --- /dev/null +++ b/icons/lucide/arrow-up.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/audio-lines.svg b/icons/lucide/audio-lines.svg new file mode 100644 index 0000000..aafc567 --- /dev/null +++ b/icons/lucide/audio-lines.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/bell-dot.svg b/icons/lucide/bell-dot.svg new file mode 100644 index 0000000..f3db439 --- /dev/null +++ b/icons/lucide/bell-dot.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/bot.svg b/icons/lucide/bot.svg new file mode 100644 index 0000000..fbb8798 --- /dev/null +++ b/icons/lucide/bot.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/calendar.svg b/icons/lucide/calendar.svg new file mode 100644 index 0000000..a737d0f --- /dev/null +++ b/icons/lucide/calendar.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/check.svg b/icons/lucide/check.svg new file mode 100644 index 0000000..4bfdcb0 --- /dev/null +++ b/icons/lucide/check.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/chevron-down.svg b/icons/lucide/chevron-down.svg new file mode 100644 index 0000000..e17e4c3 --- /dev/null +++ b/icons/lucide/chevron-down.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/chevron-right.svg b/icons/lucide/chevron-right.svg new file mode 100644 index 0000000..d5025d4 --- /dev/null +++ b/icons/lucide/chevron-right.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/circle-alert.svg b/icons/lucide/circle-alert.svg new file mode 100644 index 0000000..5943df4 --- /dev/null +++ b/icons/lucide/circle-alert.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/circle-arrow-up.svg b/icons/lucide/circle-arrow-up.svg new file mode 100644 index 0000000..2d02fe4 --- /dev/null +++ b/icons/lucide/circle-arrow-up.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/circle-check.svg b/icons/lucide/circle-check.svg new file mode 100644 index 0000000..dce1d41 --- /dev/null +++ b/icons/lucide/circle-check.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/circle-dot.svg b/icons/lucide/circle-dot.svg new file mode 100644 index 0000000..7685922 --- /dev/null +++ b/icons/lucide/circle-dot.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/circle-x.svg b/icons/lucide/circle-x.svg new file mode 100644 index 0000000..f89980b --- /dev/null +++ b/icons/lucide/circle-x.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/clock.svg b/icons/lucide/clock.svg new file mode 100644 index 0000000..662cc73 --- /dev/null +++ b/icons/lucide/clock.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/cloud-download.svg b/icons/lucide/cloud-download.svg new file mode 100644 index 0000000..38dcb44 --- /dev/null +++ b/icons/lucide/cloud-download.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/cloud-sun.svg b/icons/lucide/cloud-sun.svg new file mode 100644 index 0000000..58f11ba --- /dev/null +++ b/icons/lucide/cloud-sun.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/copy.svg b/icons/lucide/copy.svg new file mode 100644 index 0000000..adff028 --- /dev/null +++ b/icons/lucide/copy.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/credit-card.svg b/icons/lucide/credit-card.svg new file mode 100644 index 0000000..77e702e --- /dev/null +++ b/icons/lucide/credit-card.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/download.svg b/icons/lucide/download.svg new file mode 100644 index 0000000..16598ea --- /dev/null +++ b/icons/lucide/download.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/ellipsis.svg b/icons/lucide/ellipsis.svg new file mode 100644 index 0000000..22c3865 --- /dev/null +++ b/icons/lucide/ellipsis.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/eraser.svg b/icons/lucide/eraser.svg new file mode 100644 index 0000000..51ef523 --- /dev/null +++ b/icons/lucide/eraser.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/external-link.svg b/icons/lucide/external-link.svg new file mode 100644 index 0000000..f0927e8 --- /dev/null +++ b/icons/lucide/external-link.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/eye-off.svg b/icons/lucide/eye-off.svg new file mode 100644 index 0000000..009c7fb --- /dev/null +++ b/icons/lucide/eye-off.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/eye.svg b/icons/lucide/eye.svg new file mode 100644 index 0000000..f3e538e --- /dev/null +++ b/icons/lucide/eye.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/file-code.svg b/icons/lucide/file-code.svg new file mode 100644 index 0000000..ddeb766 --- /dev/null +++ b/icons/lucide/file-code.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/file-json.svg b/icons/lucide/file-json.svg new file mode 100644 index 0000000..04bb6b2 --- /dev/null +++ b/icons/lucide/file-json.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/file-music.svg b/icons/lucide/file-music.svg new file mode 100644 index 0000000..ab40cf7 --- /dev/null +++ b/icons/lucide/file-music.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/file-spreadsheet.svg b/icons/lucide/file-spreadsheet.svg new file mode 100644 index 0000000..9616bce --- /dev/null +++ b/icons/lucide/file-spreadsheet.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/file-text.svg b/icons/lucide/file-text.svg new file mode 100644 index 0000000..2bbb1d6 --- /dev/null +++ b/icons/lucide/file-text.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/icons/lucide/file-type.svg b/icons/lucide/file-type.svg new file mode 100644 index 0000000..d83e387 --- /dev/null +++ b/icons/lucide/file-type.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/icons/lucide/file-video.svg b/icons/lucide/file-video.svg new file mode 100644 index 0000000..752500e --- /dev/null +++ b/icons/lucide/file-video.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/file.svg b/icons/lucide/file.svg new file mode 100644 index 0000000..80c80a4 --- /dev/null +++ b/icons/lucide/file.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/folder.svg b/icons/lucide/folder.svg new file mode 100644 index 0000000..00b2508 --- /dev/null +++ b/icons/lucide/folder.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/globe.svg b/icons/lucide/globe.svg new file mode 100644 index 0000000..6c8dff2 --- /dev/null +++ b/icons/lucide/globe.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/history.svg b/icons/lucide/history.svg new file mode 100644 index 0000000..b27ccd7 --- /dev/null +++ b/icons/lucide/history.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/image.svg b/icons/lucide/image.svg new file mode 100644 index 0000000..ed5cef7 --- /dev/null +++ b/icons/lucide/image.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/info.svg b/icons/lucide/info.svg new file mode 100644 index 0000000..c35697b --- /dev/null +++ b/icons/lucide/info.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/key-round.svg b/icons/lucide/key-round.svg new file mode 100644 index 0000000..d325324 --- /dev/null +++ b/icons/lucide/key-round.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/keyboard.svg b/icons/lucide/keyboard.svg new file mode 100644 index 0000000..014e458 --- /dev/null +++ b/icons/lucide/keyboard.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/icons/lucide/layout-grid.svg b/icons/lucide/layout-grid.svg new file mode 100644 index 0000000..a241a8f --- /dev/null +++ b/icons/lucide/layout-grid.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/layout-template.svg b/icons/lucide/layout-template.svg new file mode 100644 index 0000000..80a65a7 --- /dev/null +++ b/icons/lucide/layout-template.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/loader.svg b/icons/lucide/loader.svg new file mode 100644 index 0000000..3f612ca --- /dev/null +++ b/icons/lucide/loader.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/icons/lucide/lock.svg b/icons/lucide/lock.svg new file mode 100644 index 0000000..d2f342b --- /dev/null +++ b/icons/lucide/lock.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/mail.svg b/icons/lucide/mail.svg new file mode 100644 index 0000000..fa09a67 --- /dev/null +++ b/icons/lucide/mail.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/map-pin.svg b/icons/lucide/map-pin.svg new file mode 100644 index 0000000..8ab4bab --- /dev/null +++ b/icons/lucide/map-pin.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/message-circle.svg b/icons/lucide/message-circle.svg new file mode 100644 index 0000000..efd31c1 --- /dev/null +++ b/icons/lucide/message-circle.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/messages-square.svg b/icons/lucide/messages-square.svg new file mode 100644 index 0000000..a386a09 --- /dev/null +++ b/icons/lucide/messages-square.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/mic.svg b/icons/lucide/mic.svg new file mode 100644 index 0000000..65b1dbe --- /dev/null +++ b/icons/lucide/mic.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/minus.svg b/icons/lucide/minus.svg new file mode 100644 index 0000000..9e8509e --- /dev/null +++ b/icons/lucide/minus.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/monitor.svg b/icons/lucide/monitor.svg new file mode 100644 index 0000000..ca0bc6b --- /dev/null +++ b/icons/lucide/monitor.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/move-down.svg b/icons/lucide/move-down.svg new file mode 100644 index 0000000..411b013 --- /dev/null +++ b/icons/lucide/move-down.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/move-up.svg b/icons/lucide/move-up.svg new file mode 100644 index 0000000..2fb69ed --- /dev/null +++ b/icons/lucide/move-up.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/notebook-pen.svg b/icons/lucide/notebook-pen.svg new file mode 100644 index 0000000..0c2802b --- /dev/null +++ b/icons/lucide/notebook-pen.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/panel-left-close.svg b/icons/lucide/panel-left-close.svg new file mode 100644 index 0000000..c339e54 --- /dev/null +++ b/icons/lucide/panel-left-close.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/panel-left-open.svg b/icons/lucide/panel-left-open.svg new file mode 100644 index 0000000..dc3783e --- /dev/null +++ b/icons/lucide/panel-left-open.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/panel-right.svg b/icons/lucide/panel-right.svg new file mode 100644 index 0000000..c87b24f --- /dev/null +++ b/icons/lucide/panel-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/paperclip.svg b/icons/lucide/paperclip.svg new file mode 100644 index 0000000..6d04d4b --- /dev/null +++ b/icons/lucide/paperclip.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/pause.svg b/icons/lucide/pause.svg new file mode 100644 index 0000000..f339b40 --- /dev/null +++ b/icons/lucide/pause.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/pencil.svg b/icons/lucide/pencil.svg new file mode 100644 index 0000000..2a168d4 --- /dev/null +++ b/icons/lucide/pencil.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/phone.svg b/icons/lucide/phone.svg new file mode 100644 index 0000000..b9649d8 --- /dev/null +++ b/icons/lucide/phone.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/pin.svg b/icons/lucide/pin.svg new file mode 100644 index 0000000..aa9752f --- /dev/null +++ b/icons/lucide/pin.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/plane.svg b/icons/lucide/plane.svg new file mode 100644 index 0000000..43c4151 --- /dev/null +++ b/icons/lucide/plane.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/play.svg b/icons/lucide/play.svg new file mode 100644 index 0000000..4bea6c4 --- /dev/null +++ b/icons/lucide/play.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/plus.svg b/icons/lucide/plus.svg new file mode 100644 index 0000000..ffac92e --- /dev/null +++ b/icons/lucide/plus.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/refresh-cw.svg b/icons/lucide/refresh-cw.svg new file mode 100644 index 0000000..325ae86 --- /dev/null +++ b/icons/lucide/refresh-cw.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/reply.svg b/icons/lucide/reply.svg new file mode 100644 index 0000000..1464115 --- /dev/null +++ b/icons/lucide/reply.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/search.svg b/icons/lucide/search.svg new file mode 100644 index 0000000..dcbb80f --- /dev/null +++ b/icons/lucide/search.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/settings.svg b/icons/lucide/settings.svg new file mode 100644 index 0000000..e384d79 --- /dev/null +++ b/icons/lucide/settings.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/share.svg b/icons/lucide/share.svg new file mode 100644 index 0000000..5a69a66 --- /dev/null +++ b/icons/lucide/share.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/shield-check.svg b/icons/lucide/shield-check.svg new file mode 100644 index 0000000..c710f66 --- /dev/null +++ b/icons/lucide/shield-check.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/shopping-bag.svg b/icons/lucide/shopping-bag.svg new file mode 100644 index 0000000..f4e34aa --- /dev/null +++ b/icons/lucide/shopping-bag.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/smile-plus.svg b/icons/lucide/smile-plus.svg new file mode 100644 index 0000000..864a598 --- /dev/null +++ b/icons/lucide/smile-plus.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/icons/lucide/sparkles.svg b/icons/lucide/sparkles.svg new file mode 100644 index 0000000..242401e --- /dev/null +++ b/icons/lucide/sparkles.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/square-pen.svg b/icons/lucide/square-pen.svg new file mode 100644 index 0000000..d831395 --- /dev/null +++ b/icons/lucide/square-pen.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/square.svg b/icons/lucide/square.svg new file mode 100644 index 0000000..a815544 --- /dev/null +++ b/icons/lucide/square.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/star.svg b/icons/lucide/star.svg new file mode 100644 index 0000000..f3ef5d3 --- /dev/null +++ b/icons/lucide/star.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/lucide/trash-2.svg b/icons/lucide/trash-2.svg new file mode 100644 index 0000000..6949228 --- /dev/null +++ b/icons/lucide/trash-2.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/icons/lucide/upload.svg b/icons/lucide/upload.svg new file mode 100644 index 0000000..66f45d2 --- /dev/null +++ b/icons/lucide/upload.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/user-round.svg b/icons/lucide/user-round.svg new file mode 100644 index 0000000..80fc489 --- /dev/null +++ b/icons/lucide/user-round.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/users.svg b/icons/lucide/users.svg new file mode 100644 index 0000000..6cdd675 --- /dev/null +++ b/icons/lucide/users.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/lucide/vault.svg b/icons/lucide/vault.svg new file mode 100644 index 0000000..95de3dc --- /dev/null +++ b/icons/lucide/vault.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/icons/lucide/volume-2.svg b/icons/lucide/volume-2.svg new file mode 100644 index 0000000..ba0e148 --- /dev/null +++ b/icons/lucide/volume-2.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/lucide/x.svg b/icons/lucide/x.svg new file mode 100644 index 0000000..c837a91 --- /dev/null +++ b/icons/lucide/x.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/lucide/zap.svg b/icons/lucide/zap.svg new file mode 100644 index 0000000..5a10e87 --- /dev/null +++ b/icons/lucide/zap.svg @@ -0,0 +1,15 @@ + + + + diff --git a/mechanics.json b/mechanics.json new file mode 100644 index 0000000..06b1cc6 --- /dev/null +++ b/mechanics.json @@ -0,0 +1,1475 @@ +{ + "version": 1, + "values": [ + { + "name": "native main program functions", + "group": "program", + "value": "entry 0x100000460 starts ElectronMain; 0x1000004c0 reopens closed std streams on /dev/null", + "source": "native_function_pseudocode 0x100000460, 0x1000004c0" + }, + { + "name": "original default model", + "group": "models", + "value": "grok-4.5", + "source": "main-core.cjs var Wp (SAND_DEFAULT_MODEL_ID)" + }, + { + "name": "model choice per bot", + "group": "models", + "value": "provider + model id", + "source": "main-core.cjs schema UEe {modelId,maxMode,parameters}; provider part added on request" + }, + { + "name": "providers offered", + "group": "models", + "value": "xAI Grok, OpenAI, Claude, Gemini, OpenRouter, Ollama, custom OpenAI-compatible", + "source": "person's request" + }, + { + "name": "default Claude model", + "group": "models", + "value": "claude-opus-5-5", + "source": "claude-api reference" + }, + { + "name": "default xAI model", + "group": "models", + "value": "grok-4", + "estimated": true + }, + { + "name": "default OpenAI model", + "group": "models", + "value": "gpt-5", + "estimated": true + }, + { + "name": "default Gemini model", + "group": "models", + "value": "gemini-2.5-flash", + "estimated": true + }, + { + "name": "default Ollama model", + "group": "models", + "value": "llama3.2", + "estimated": true + }, + { + "name": "first response timeout", + "group": "chat", + "value": 120, + "unit": "s", + "estimated": true + }, + { + "name": "connect timeout", + "group": "chat", + "value": 15000, + "unit": "ms", + "source": "main-core.cjs @1082927 $D=15e3 (gateway-sse-connect)" + }, + { + "name": "stream idle stall", + "group": "chat", + "value": 35000, + "unit": "ms", + "source": "main-core.cjs @1082927 VD=35e3 (gateway-sse-stall idleMs)" + }, + { + "name": "retry max attempts", + "group": "chat", + "value": 3, + "source": "main-core.cjs @1082927 D3.maxAttempts" + }, + { + "name": "retry initial delay", + "group": "chat", + "value": 1000, + "unit": "ms", + "source": "main-core.cjs @1082927 D3.initialDelayMs" + }, + { + "name": "retry max delay", + "group": "chat", + "value": 4000, + "unit": "ms", + "source": "main-core.cjs @1082927 D3.maxDelayMs" + }, + { + "name": "retry backoff factor", + "group": "chat", + "value": 2, + "source": "main-core.cjs @1082927 D3.backoffFactor" + }, + { + "name": "retry-after cap", + "group": "chat", + "value": 30000, + "unit": "ms", + "estimated": true + }, + { + "name": "retry overloaded provider", + "group": "chat", + "value": true, + "source": "main-core.cjs @689060 SAND-E0401 retryable:!0" + }, + { + "name": "retry context overflow", + "group": "chat", + "value": false, + "source": "main-core.cjs @689722 SAND-E0404 retryable:!1" + }, + { + "name": "retry after output started", + "group": "chat", + "value": false, + "source": "main-core.cjs @691348 SAND-E0415 (resumed only from a checkpoint)" + }, + { + "name": "Claude max output tokens", + "group": "chat", + "value": 64000, + "unit": "tokens", + "source": "claude-api reference (streaming default)" + }, + { + "name": "composer placeholder", + "group": "chat", + "value": "傳訊息給 {name}", + "source": "index.eager-app E6t, id pqr+oY" + }, + { + "name": "thinking label", + "group": "chat", + "value": "思考中", + "source": "index.eager-app zfn, id AUV+TY" + }, + { + "name": "send key", + "group": "controls", + "value": "Enter (Shift+Enter new line, ignored while composing)", + "source": "index.eager-app @2002346 mwt" + }, + { + "name": "sidebar sections", + "group": "layout", + "value": "主要 Bot / 已釘選的 Bot / Bot", + "source": "zh-TW catalog ids V8a0q9, vPVg0Q, ixkTse" + }, + { + "name": "sidebar default width", + "group": "layout", + "value": 280, + "unit": "px", + "source": "index.eager-app @912025 FT.expandedWidth" + }, + { + "name": "sidebar min width", + "group": "layout", + "value": 240, + "unit": "px", + "source": "index.eager-app @914394 sKe / @969523 QJe" + }, + { + "name": "sidebar max width", + "group": "layout", + "value": 400, + "unit": "px", + "source": "index.eager-app @914394 sKe / @969523 QJe" + }, + { + "name": "sidebar rail width", + "group": "layout", + "value": 88, + "unit": "px", + "source": "index.eager-app @969523 fD=88" + }, + { + "name": "sidebar collapse below", + "group": "layout", + "value": 210, + "unit": "px", + "source": "index.eager-app @969523 JJe=210" + }, + { + "name": "sidebar expand from", + "group": "layout", + "value": 220, + "unit": "px", + "source": "index.eager-app @969523 YJe=220" + }, + { + "name": "chat min width", + "group": "layout", + "value": 424, + "unit": "px", + "source": "index CSS --sand-chat-min-width" + }, + { + "name": "chat column width", + "group": "layout", + "value": 720, + "unit": "px", + "estimated": true, + "note": "message column from original: min(80%, 560px, width-82px) (messageColumnOfBlock); the outer column is still estimated" + }, + { + "name": "bubble radius", + "group": "style", + "value": 18, + "unit": "px", + "source": "index CSS --sand-transcript-prose-bubble-radius" + }, + { + "name": "bubble one-line height", + "group": "style", + "value": 36, + "unit": "px", + "source": "index CSS --sand-transcript-prose-one-line-height" + }, + { + "name": "transcript font size", + "group": "style", + "value": 15, + "unit": "px", + "source": "index CSS --sand-font-size-lg with --sand-transcript-prose-line-height:22px" + }, + { + "name": "base font size", + "group": "style", + "value": 14, + "unit": "px", + "source": "index CSS --sand-font-size-base" + }, + { + "name": "pane colour", + "group": "style", + "value": "#ffffff", + "source": "index CSS --sand-bg-pane" + }, + { + "name": "sidebar colour", + "group": "style", + "value": "#f5f5f5", + "source": "index CSS --sand-bg-sidebar" + }, + { + "name": "text colour", + "group": "style", + "value": "#1c1c1c", + "source": "index CSS --sand-text-primary" + }, + { + "name": "user bubble colour", + "group": "style", + "value": "#1c1c1c", + "source": "index CSS --sand-fill-bubble-user" + }, + { + "name": "agent bubble colour", + "group": "style", + "value": "#1c1c1c0f", + "source": "index CSS --sand-fill-bubble-agent" + }, + { + "name": "focus colour", + "group": "style", + "value": "#1e86fc", + "source": "index CSS --sand-border-focus-primary" + }, + { + "name": "dark theme colours", + "group": "style", + "value": "pane #1f1f1f, chrome #121212, sidebar #0e0e0e, ink #f5f5f5, user bubble ink 18%", + "source": "renderer index.eager-common appearance primitives (dark neutral/0..5) and alpha table g_" + }, + { + "name": "avatar shapes in original", + "group": "bots", + "value": 26, + "source": "menubar-tray/assets/bot-*.png" + }, + { + "name": "avatar shapes in rebuild", + "group": "bots", + "value": 12, + "estimated": true + }, + { + "name": "bot colours", + "group": "bots", + "value": 10, + "source": "index CSS --sand-fill-*-bubble-user plus black" + }, + { + "name": "theme options", + "group": "settings", + "value": "淺色 / 深色 / 跟隨系統", + "source": "catalog ids 1njn7W, pvnfJD, Ea4oxV" + }, + { + "name": "accent options", + "group": "settings", + "value": "Bot 顏色 / 黑色", + "source": "catalog ids p7v5PZ, THokF3, eeSkqM" + }, + { + "name": "search timeout", + "group": "tools", + "value": 25000, + "unit": "ms", + "source": "local-exec-daemon @2983388 Mu.MAIN_TIMEOUT_MS" + }, + { + "name": "search hard max lines", + "group": "tools", + "value": 10000, + "source": "local-exec-daemon @2983388 Mu.HARD_MAX_OUTPUT_LINES" + }, + { + "name": "lines given to the model", + "group": "tools", + "value": 2000, + "source": "local-exec-daemon @2966784 Jut=2e3" + }, + { + "name": "max file read", + "group": "tools", + "value": 8388608, + "unit": "bytes", + "source": "local-exec-daemon @2951190 gut" + }, + { + "name": "long command behaviour", + "group": "tools", + "value": "moved to background, read with read_terminal", + "source": "local-exec-daemon @3010873 Mye / @3016213 timeoutBehavior BACKGROUND" + }, + { + "name": "shell foreground timeout", + "group": "tools", + "value": 30000, + "unit": "ms", + "estimated": true + }, + { + "name": "shell max timeout", + "group": "tools", + "value": 600000, + "unit": "ms", + "estimated": true + }, + { + "name": "output head chars", + "group": "tools", + "value": 6000, + "estimated": true + }, + { + "name": "output tail chars", + "group": "tools", + "value": 6000, + "estimated": true + }, + { + "name": "permission order", + "group": "tools", + "value": "deny > ignore > allow > ask", + "source": "investigation fnd_8 (shouldBlockShellCommand, isShellCommandFullyAllowlisted, ignore globs)" + }, + { + "name": "always-allow grants", + "group": "tools", + "value": "per bot", + "source": "investigation fnd_12 recordLocalToolStandingGrant" + }, + { + "name": "summarize threshold", + "group": "agent", + "value": 70, + "unit": "%", + "source": "main-app.cjs client_speculative_summarization_config.tokenUsageThresholdPercentage" + }, + { + "name": "summarize tolerance", + "group": "agent", + "value": 5, + "unit": "%", + "source": "main-app.cjs client_speculative_summarization_config.tolerancePercentage" + }, + { + "name": "default context tokens", + "group": "agent", + "value": 128000, + "estimated": true + }, + { + "name": "max steps per turn", + "group": "agent", + "value": 25, + "estimated": true + }, + { + "name": "messages kept verbatim", + "group": "agent", + "value": 6, + "estimated": true + }, + { + "name": "max tracked processes per command", + "group": "tools", + "value": 4096, + "source": "cursor_proclist.node getDescendantPids 0x6748 (stops at 0xfff)" + }, + { + "name": "process walk", + "group": "tools", + "value": "breadth-first by parent pid, de-duplicated, roots included", + "source": "cursor_proclist.node 0x6748 / ProclistScanWorker::Execute 0x3d00" + }, + { + "name": "process memory formula", + "group": "tools", + "value": "resident bytes >> 20 = MB", + "source": "cursor_proclist.node getProcessInformation 0x5234" + }, + { + "name": "process cpu formula", + "group": "tools", + "value": "(user + system) time / 1000000 = ms", + "source": "cursor_proclist.node getProcessInformation 0x5234" + }, + { + "name": "command tags", + "group": "tools", + "value": "conversation id + request id in the environment", + "source": "cursor_proclist.node 0x5234 (CONVERSATION_ID= / REQUEST_ID=)" + }, + { + "name": "command sandbox", + "group": "tools", + "value": "(allow default)(deny file-read* … (subpath protected))", + "source": "sand-op-launcher _main (sandbox_init_with_parameters profile)" + }, + { + "name": "clean command environment", + "group": "tools", + "value": "API key variables removed", + "source": "sand-op-launcher _main (fresh env array)" + }, + { + "name": "turn starts on", + "group": "turns", + "value": "human message or wake (routine)", + "source": "main-app.cjs @3100283 w3n/P3n" + }, + { + "name": "message during a turn", + "group": "turns", + "value": "steers the same turn (not a new turn)", + "source": "main-app.cjs P3n turnSteer" + }, + { + "name": "turn outcomes", + "group": "turns", + "value": "success / cancelled / error", + "source": "node-agent-coordinator wl, k6" + }, + { + "name": "idle condition", + "group": "turns", + "value": "not running, no subagents, not awaiting, no handoff", + "source": "node-agent-coordinator ZF" + }, + { + "name": "default computer", + "group": "computers", + "value": "Bot 的電腦", + "source": "settings y$.botComputer (yGkA3X)" + }, + { + "name": "this computer needs opt-in", + "group": "computers", + "value": true, + "source": "setting local-execution (fuUvu2 / Hd1KB+)" + }, + { + "name": "approval names the machine", + "group": "computers", + "value": "允許 … 在 {machine} 上 … 嗎?", + "source": "catalog eQViQE" + }, + { + "name": "auto-review conflict rule", + "group": "permissions", + "value": "ask first wins", + "source": "catalog NFRGrQ" + }, + { + "name": "auto-review lists", + "group": "permissions", + "value": "allow without asking / always ask", + "source": "catalog geCUKn, 1YRRmk, JOecNC, Pca/iV" + }, + { + "name": "routine schedule frequencies", + "group": "routines", + "value": "daily / weekly / monthly / yearly", + "source": "index.eager-app @1508626 Prt" + }, + { + "name": "routine weekday order", + "group": "routines", + "value": "MO TU WE TH FR SA SU", + "source": "index.eager-app yA" + }, + { + "name": "every weekday", + "group": "routines", + "value": "exactly MO-FR", + "source": "index.eager-app isEveryWeekday (WORKWEEK)" + }, + { + "name": "routines created by", + "group": "routines", + "value": "asking the bot in chat", + "source": "catalog ZJmAzX" + }, + { + "name": "routine check interval", + "group": "routines", + "value": 1, + "unit": "s", + "estimated": true + }, + { + "name": "relaunch waits for old copy", + "group": "restart", + "value": true, + "source": "main-app.cjs @3195964 PBt (while kill -0 $pid)" + }, + { + "name": "relaunch poll interval", + "group": "restart", + "value": 0.2, + "unit": "s", + "source": "main-app.cjs @3195964 PBt (sleep 0.2)" + }, + { + "name": "relaunch helper output", + "group": "restart", + "value": "ignored (/dev/null), detached", + "source": "main-app.cjs Pur {detached:true, stdio:\"ignore\"}; native sub_1000004c0 0x1000004c0" + }, + { + "name": "relaunch clears start-up switches", + "group": "restart", + "value": true, + "source": "native entry 0x100000460 (ELECTRON_RUN_AS_NODE changes start-up)" + }, + { + "name": "restarting label", + "group": "restart", + "value": "正在重新啟動…", + "source": "renderer ljn, id fvg2KT" + }, + { + "name": "interrupted reply on load", + "group": "restart", + "value": "marked error, can regenerate", + "estimated": true + }, + { + "name": "missing permission", + "group": "permissions", + "value": "asks on the spot (pop-up), never fails quietly", + "source": "catalog eQViQE / ykDLyL (ask naming the machine)" + }, + { + "name": "this-computer request wording", + "group": "permissions", + "value": "允許 {bot} 在你的電腦上執行任務嗎?", + "source": "catalog Hd1KB+" + }, + { + "name": "request presentation", + "group": "permissions", + "value": "centred pop-up + dock attention + header pill", + "estimated": true + }, + { + "name": "away after idle", + "group": "notifications", + "value": 90, + "unit": "s", + "source": "main-app.cjs Z_e=9e4 (K4 presence)" + }, + { + "name": "presence poll", + "group": "notifications", + "value": 10, + "unit": "s", + "source": "main-app.cjs uBt=1e4" + }, + { + "name": "present means", + "group": "notifications", + "value": "not locked, not idle, window alive", + "source": "main-app.cjs K4" + }, + { + "name": "banners allowed when", + "group": "notifications", + "value": "authorized and alerts enabled", + "source": "main-app.cjs fBt; sand_notification_settings.node 0x35b4" + }, + { + "name": "notification settings read timeout", + "group": "notifications", + "value": 3, + "unit": "s", + "source": "sand_notification_settings.node getNotificationSettings 0x35b4 (dispatch_time 0xb2d05e00)" + }, + { + "name": "notify on", + "group": "notifications", + "value": "needs you / finished, while away", + "source": "catalog ttdOeI" + }, + { + "name": "web tools", + "group": "web", + "value": "web_fetch, browser_open, browser_read", + "estimated": true + }, + { + "name": "page text limit", + "group": "web", + "value": 20000, + "unit": "chars", + "estimated": true + }, + { + "name": "browser load wait", + "group": "web", + "value": 4, + "unit": "s", + "estimated": true + }, + { + "name": "reading a logged-in tab", + "group": "web", + "value": "asks first", + "source": "catalog 1Password/Chrome login copy asks; rule ask-first (NFRGrQ)" + }, + { + "name": "window list options", + "group": "web", + "value": "0x11 (on-screen only, no desktop), front to back", + "source": "sand_window_capture.node listWindows 0x52c0" + }, + { + "name": "window counts if", + "group": "web", + "value": "alpha > 0, width > 0, height > 0", + "source": "sand_window_capture.node listWindows 0x52c0" + }, + { + "name": "browser chosen", + "group": "web", + "value": "front-most visible browser window", + "source": "sand_window_capture.node listWindows 0x52c0 (front-to-back order)" + }, + { + "name": "screen capture needs", + "group": "web", + "value": "macOS 14+", + "source": "sand_window_capture.node screenCaptureAccess 0x5258" + }, + { + "name": "direct mode", + "group": "permissions", + "value": "one switch: this Mac + tools + auto-review; only safety checks and always-ask rules stop", + "source": "catalog ENHi8c / hrkSSQ (local execution with auto-review), n9YiIv" + }, + { + "name": "direct mode default always-ask rules", + "group": "permissions", + "value": "delete / pay / post or message as the person", + "estimated": true + }, + { + "name": "admin policy source (macOS)", + "group": "policy", + "value": "user defaults (MDM-forceable)", + "source": "main-core.cjs @727949 EZ/TZ (systemPreferences.getUserDefault)" + }, + { + "name": "native policy read on macOS", + "group": "policy", + "value": "returns null", + "source": "sand_notification_settings.node managedPolicyValueSync 0x1870" + }, + { + "name": "policy kinds", + "group": "policy", + "value": "unmanaged / pinned / invalid", + "source": "main-core.cjs Ak/mZ" + }, + { + "name": "invalid policy", + "group": "policy", + "value": "locks the switch off", + "estimated": true + }, + { + "name": "update first check", + "group": "update", + "value": 30, + "unit": "s", + "source": "main-app.cjs @3372407 bhr=3e4" + }, + { + "name": "update check interval", + "group": "update", + "value": 3600, + "unit": "s", + "source": "main-app.cjs khr=3600*1e3" + }, + { + "name": "update check jitter", + "group": "update", + "value": 300, + "unit": "s", + "source": "main-app.cjs Ihr=300*1e3" + }, + { + "name": "auto update idle threshold", + "group": "update", + "value": 600, + "unit": "s", + "source": "main-app.cjs @3365216 pQt=600" + }, + { + "name": "auto update needs", + "group": "update", + "value": "screen locked or screensaver, idle, no bot work", + "source": "main-app.cjs uQt/phr/uhr" + }, + { + "name": "auto update default", + "group": "update", + "value": true, + "source": "main-app.cjs getAutoUpdateWhenIdle ?? true" + }, + { + "name": "ready-update label", + "group": "update", + "value": "重新啟動以更新", + "source": "catalog FzpXvM" + }, + { + "name": "new build banner check", + "group": "update", + "value": 10, + "unit": "s", + "estimated": true + }, + { + "name": "bot browser", + "group": "web", + "value": "own Chrome-family profile, controlled directly (no browser settings)", + "source": "original browses in its own browser; catalog 'Copies cookies from Chrome…'" + }, + { + "name": "bot browser port", + "group": "web", + "value": 9333, + "estimated": true + }, + { + "name": "bot browser start wait", + "group": "web", + "value": 20, + "unit": "s", + "estimated": true + }, + { + "name": "Chrome login copy", + "group": "web", + "value": "not rebuilt: retired in the original", + "source": "main-core.cjs / index.eager-app error catalogue SAND-E0801..SAND-E0812, all 'retired … removed'" + }, + { + "name": "bot browser profiles", + "group": "web", + "value": "預設 plus added ones, each its own folder and window with its own logins", + "estimated": true + }, + { + "name": "bot browser default profile name", + "group": "web", + "value": "預設", + "estimated": true + }, + { + "name": "bot browser profile name max", + "group": "web", + "value": 30, + "unit": "characters", + "estimated": true + }, + { + "name": "bot browser added-profile port", + "group": "web", + "value": 0, + "estimated": true, + "note": "Chrome picks a free port and writes it to DevToolsActivePort" + }, + { + "name": "renderer bundle ids 1", + "group": "web", + "value": "com.github.Electron", + "source": "CUGrokBotService CURendererApp.isRenderer @0x1000a1b6c" + }, + { + "name": "renderer bundle ids 2", + "group": "web", + "value": "com.google.Chrome", + "source": "CUGrokBotService CURendererApp.isRenderer @0x1000a1b6c" + }, + { + "name": "renderer bundle id prefix", + "group": "web", + "value": "com.google.Chrome.", + "source": "CUGrokBotService CURendererApp.isRenderer @0x1000a1b6c" + }, + { + "name": "renderer electron executable piece", + "group": "web", + "value": "/Electron.app/Contents/MacOS/Electron", + "source": "CUGrokBotService CURendererApp.isRenderer @0x1000a1b6c" + }, + { + "name": "renderer frameworks 1", + "group": "web", + "value": "Electron Framework.framework", + "source": "CUGrokBotService CURendererApp.isRendererBundle @0x1000a19ec" + }, + { + "name": "renderer frameworks 2", + "group": "web", + "value": "Chromium Embedded Framework.framework", + "source": "CUGrokBotService CURendererApp.isRendererBundle @0x1000a19ec" + }, + { + "name": "renderer frameworks 3", + "group": "web", + "value": "Google Chrome Framework.framework", + "source": "CUGrokBotService CURendererApp.isRendererBundle @0x1000a19ec" + }, + { + "name": "renderer accessibility flags 1", + "group": "web", + "value": "AXManualAccessibility", + "source": "CUGrokBotService CURendererAccessibility.writeFlags @0x1000a1554" + }, + { + "name": "renderer accessibility flags 2", + "group": "web", + "value": "AXEnhancedUserInterface", + "source": "CUGrokBotService CURendererAccessibility.writeFlags @0x1000a1554" + }, + { + "name": "person browser profile name", + "group": "web", + "value": "你的瀏覽器", + "estimated": true + }, + { + "name": "accessibility read max elements", + "group": "web", + "value": 20000, + "estimated": true + }, + { + "name": "accessibility read depth", + "group": "web", + "value": 80, + "estimated": true + }, + { + "name": "accessibility read tries", + "group": "web", + "value": 5, + "estimated": true + }, + { + "name": "accessibility read retry pause", + "group": "web", + "value": 700, + "unit": "ms", + "estimated": true + }, + { + "name": "accessibility answer timeout", + "group": "web", + "value": 2.0, + "unit": "s", + "estimated": true + }, + { + "name": "screenshot long edge", + "group": "computer use", + "value": 1024, + "unit": "px", + "source": "local-exec-daemon main.cjs Gct=1024 (zct)" + }, + { + "name": "screenshot min edge", + "group": "computer use", + "value": 8, + "unit": "px", + "source": "local-exec-daemon main.cjs Hs=8 (zct)" + }, + { + "name": "screenshot max bytes", + "group": "computer use", + "value": 1048576, + "unit": "bytes", + "source": "local-exec-daemon main.cjs mg=1024*1024" + }, + { + "name": "screenshot jpeg quality 1", + "group": "computer use", + "value": 85, + "source": "local-exec-daemon main.cjs $3r=[85,70,55]" + }, + { + "name": "screenshot jpeg quality 2", + "group": "computer use", + "value": 70, + "source": "local-exec-daemon main.cjs $3r=[85,70,55]" + }, + { + "name": "screenshot jpeg quality 3", + "group": "computer use", + "value": 55, + "source": "local-exec-daemon main.cjs $3r=[85,70,55]" + }, + { + "name": "computer use tool names", + "group": "computer use", + "value": "computer_use_screenshot, _click, _typing, _press_key, _scroll, _mouse_move, _drag", + "source": "local-exec-daemon main.cjs @2860904 callTool" + }, + { + "name": "screenshots kept for the model", + "group": "computer use", + "value": 3, + "estimated": true + }, + { + "name": "event gap", + "group": "computer use", + "value": 8, + "unit": "ms", + "estimated": true + }, + { + "name": "longest key hold", + "group": "computer use", + "value": 5000, + "unit": "ms", + "estimated": true + }, + { + "name": "browser profile removal", + "group": "web", + "value": "added profiles only, window closed, asks to confirm", + "estimated": true + }, + { + "name": "typing chunk", + "group": "computer use", + "value": 20, + "unit": "UTF-16 units", + "source": "CUGrokBotService CUTypedCharacters.utf16Chunks @0x1000beb00" + }, + { + "name": "typing key code", + "group": "computer use", + "value": 128, + "source": "CUGrokBotService CUTypedCharacters.postChunks @0x1000beebc (0x80)" + }, + { + "name": "key hold", + "group": "computer use", + "value": 0.035, + "unit": "s", + "source": "CUGrokBotService postChunks @0x1000beebc / handlePressKey @0x100031764" + }, + { + "name": "key gap", + "group": "computer use", + "value": 0.02, + "unit": "s", + "source": "CUGrokBotService postChunks @0x1000beebc / handlePressKey @0x100031764" + }, + { + "name": "click hold", + "group": "computer use", + "value": 0.03, + "unit": "s", + "source": "CUGrokBotService CUActuator.click @0x100010120" + }, + { + "name": "click repeat gap", + "group": "computer use", + "value": 0.08, + "unit": "s", + "source": "CUGrokBotService CUActuator.click @0x100010120" + }, + { + "name": "event source state", + "group": "computer use", + "value": 1, + "source": "CUGrokBotService CGEventSourceCreate(1) in CUActuator.click/typeCharacters" + }, + { + "name": "local events suppression", + "group": "computer use", + "value": 0.0, + "unit": "s", + "source": "CUGrokBotService CGEventSourceSetLocalEventsSuppressionInterval(0)" + }, + { + "name": "window screen margin", + "group": "look", + "value": 80, + "unit": "px", + "estimated": true + }, + { + "name": "settings provider list width", + "group": "look", + "value": 200, + "unit": "px", + "estimated": true + }, + { + "name": "font size xs", + "group": "look", + "value": 12, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-font-size-xs" + }, + { + "name": "font size sm", + "group": "look", + "value": 13, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-font-size-sm" + }, + { + "name": "font size base", + "group": "look", + "value": 14, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-font-size-base" + }, + { + "name": "font size lg", + "group": "look", + "value": 15, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-font-size-lg" + }, + { + "name": "font size xl", + "group": "look", + "value": 17, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-font-size-xl" + }, + { + "name": "line height sm", + "group": "look", + "value": 18, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-line-height-sm" + }, + { + "name": "line height lg", + "group": "look", + "value": 22, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-line-height-lg" + }, + { + "name": "line height xl", + "group": "look", + "value": 24, + "unit": "px", + "source": "renderer index-Dh7EWA8t.css --sand-line-height-xl" + }, + { + "name": "sans font", + "group": "look", + "value": "-apple-system (SF for Latin, PingFang for Chinese)", + "source": "renderer index-Dh7EWA8t.css --cursor-font-family-sans" + }, + { + "name": "mono font", + "group": "look", + "value": "ui-monospace, SFMono-Regular, Menlo", + "source": "renderer index-Dh7EWA8t.css --cursor-font-family-mono" + }, + { + "name": "text font in rebuild", + "group": "look", + "value": "PingFang TC Regular for Chinese and English (one font, one baseline)", + "estimated": true + }, + { + "name": "drag steps per segment", + "group": "computer use", + "value": 6, + "source": "CUGrokBotService CURemoteToolService.handleDrag @0x10009dee4" + }, + { + "name": "drag hold before moving", + "group": "computer use", + "value": 0.04, + "unit": "s", + "source": "CUGrokBotService CURemoteToolService.handleDrag @0x10009dee4" + }, + { + "name": "drag minimum points", + "group": "computer use", + "value": 2, + "source": "CUGrokBotService CURemoteEventPoster.drag @0x10008c34c" + }, + { + "name": "scroll default amount", + "group": "computer use", + "value": 3, + "source": "CUGrokBotService CURemoteToolService.handleScroll @0x10009e200" + }, + { + "name": "scroll max amount", + "group": "computer use", + "value": 20, + "source": "CUGrokBotService CURemoteToolService.handleScroll @0x10009e200" + }, + { + "name": "scroll pixels per step", + "group": "computer use", + "value": 16, + "unit": "px", + "source": "CUGrokBotService CURemoteToolService.handleScroll @0x10009e200 ((amount+amount)<<3)" + }, + { + "name": "scroll event unit", + "group": "computer use", + "value": "pixel (0), 2 wheels", + "source": "CUGrokBotService handleScroll closure @0x10009e998" + }, + { + "name": "box health timeout ms", + "group": "computers", + "value": 3000, + "source": "main-app.cjs dev-box QQn=3e3 (probeGateway /health)" + }, + { + "name": "box snapshot timeout ms", + "group": "computers", + "value": 30000, + "source": "main-app.cjs dev-box VQn=3e4 (snapshotBoxStoreNow)" + }, + { + "name": "box restore copy-in wait ms", + "group": "computers", + "value": 90000, + "source": "main-app.cjs dev-box zQn=9e4 (readCopyInOutcome)" + }, + { + "name": "box restore copy-in poll ms", + "group": "computers", + "value": 2000, + "source": "main-app.cjs dev-box HQn=2e3" + }, + { + "name": "box rebuild resume freshness ms", + "group": "computers", + "value": 600000, + "source": "main-app.cjs dev-box $Qn=10*6e4 (recreate-resume.json)" + }, + { + "name": "box noVNC port", + "group": "computers", + "value": 6080, + "source": "main-core.cjs Ap.primaryNovncWebsockify:6080" + }, + { + "name": "box idle sleep seconds", + "group": "computers", + "value": 900, + "estimated": true + }, + { + "name": "box boot wait ms", + "group": "computers", + "value": 60000, + "estimated": true + }, + { + "name": "box secret max value bytes", + "group": "computers", + "value": 32768, + "estimated": true + }, + { + "name": "box secret max count", + "group": "computers", + "value": 100, + "estimated": true + }, + { + "name": "box viewer frame ms", + "group": "computers", + "value": 450, + "estimated": true + }, + { + "name": "local control bar seconds", + "group": "computers", + "value": 8, + "estimated": true + }, + { + "name": "demo double click ms", + "group": "computers", + "value": 400, + "estimated": true + }, + { + "name": "demo scroll merge ms", + "group": "computers", + "value": 1500, + "estimated": true + }, + { + "name": "mac tool max chars", + "group": "computers", + "value": 20000, + "source": "local-exec-daemon zlt=2e4 (notes/mail get maxChars)" + }, + { + "name": "mac contacts batch", + "group": "computers", + "value": 50, + "source": "local-exec-daemon Dlt=50" + }, + { + "name": "mac device call timeout ms", + "group": "computers", + "value": 30000, + "source": "local-exec-daemon g4r=3e4" + }, + { + "name": "mac mail scan", + "group": "computers", + "value": 300, + "estimated": true + }, + { + "name": "box schedule grace minutes", + "group": "computers", + "value": 60, + "source": "original: 錯過排程有一小時的補執行時間 (renderer zh-TW strings)" + }, + { + "name": "box egress proxy port", + "group": "computers", + "value": 8791, + "source": "main-core.cjs Ap.egressConnectProxy:8791" + }, + { + "name": "demo frames per second", + "group": "features", + "value": 2, + "estimated": true + }, + { + "name": "demo frame width", + "group": "features", + "value": 1280, + "estimated": true + }, + { + "name": "attachment max bytes", + "group": "features", + "value": 52428800, + "estimated": true + }, + { + "name": "attachment text chars", + "group": "features", + "value": 20000, + "source": "original reads mail/notes up to zlt=2e4" + }, + { + "name": "voice max seconds", + "group": "features", + "value": 600, + "estimated": true + }, + { + "name": "channel poll seconds", + "group": "features", + "value": 3, + "estimated": true + }, + { + "name": "telegram long poll seconds", + "group": "features", + "value": 25, + "estimated": true + }, + { + "name": "discord message max", + "group": "features", + "value": 2000, + "source": "Discord API message content limit" + }, + { + "name": "telegram message max", + "group": "features", + "value": 4096, + "source": "Telegram Bot API sendMessage limit" + }, + { + "name": "message max width", + "group": "layout", + "value": 560, + "source": "index.eager-app messageColumnOfBlock max-width:min(80cqi,560px,100cqi - 82px)" + }, + { + "name": "bubble padding", + "group": "layout", + "value": "7px 12px", + "source": "index CSS message: padding var(--sand-transcript-prose-block-padding)=7px / 12px" + }, + { + "name": "trigger github poll seconds", + "group": "routines", + "value": 60, + "source": "GitHub events API X-Poll-Interval: 60" + }, + { + "name": "trigger service poll seconds", + "group": "routines", + "value": 60, + "estimated": true + }, + { + "name": "trigger slack poll seconds", + "group": "routines", + "value": 10, + "estimated": true + }, + { + "name": "trigger calendar poll seconds", + "group": "routines", + "value": 30, + "estimated": true + }, + { + "name": "webhook port", + "group": "routines", + "value": 8792, + "estimated": true + }, + { + "name": "routine flexible minutes", + "group": "routines", + "value": 15, + "estimated": true + }, + { + "name": "routine min minutes", + "group": "routines", + "value": 5, + "estimated": true + }, + { + "name": "group trigger minimum", + "group": "routines", + "value": 2, + "source": "original: 群組至少兩個觸發條件" + }, + { + "name": "mcp call timeout seconds", + "group": "features", + "value": 120, + "estimated": true + }, + { + "name": "mcp tool name max", + "group": "features", + "value": 64, + "source": "model tool-name limit" + }, + { + "name": "memory max count", + "group": "features", + "value": 200, + "estimated": true + }, + { + "name": "memory max chars", + "group": "features", + "value": 500, + "estimated": true + }, + { + "name": "meeting max seconds", + "group": "features", + "value": 7200, + "estimated": true + }, + { + "name": "thread keep count", + "group": "features", + "value": 50, + "estimated": true + }, + { + "name": "card rows max", + "group": "features", + "value": 12, + "estimated": true + }, + { + "name": "call speech db", + "group": "features", + "value": -38, + "estimated": true + }, + { + "name": "call silence ticks", + "group": "features", + "value": 12, + "estimated": true + }, + { + "name": "call wait ticks", + "group": "features", + "value": 300, + "estimated": true + } + ] +} diff --git a/mobile/index.html b/mobile/index.html new file mode 100644 index 0000000..464e3f1 --- /dev/null +++ b/mobile/index.html @@ -0,0 +1,571 @@ + + + + + + + + + + + + + +AnyBot + + + +
+ +
+ +
+ + +
+

選擇要連的 AnyBot

+
+
+ + +
+
+

AnyBot

+ + +
+
+
+ + +
+
+ +
+

+ + +
+
+
+
+
+ + + + +
+
+ + +
+
+ +
+ + +
+
載入中…
+
+
+ + + + + + + + + +
+
+
+
+
+ +
Bot 需要你允許、或在你不在時做完事,手機會跳通知。
+ + + +
+ + + + diff --git a/relay/Caddyfile b/relay/Caddyfile new file mode 100644 index 0000000..0487d13 --- /dev/null +++ b/relay/Caddyfile @@ -0,0 +1,11 @@ +# Caddy gets and renews the HTTPS certificate by itself. +# Replace anybot.example.com with your own domain (its DNS must point to this server). +anybot.example.com { + encode gzip + reverse_proxy relay:8080 { + # The phone's "anything new?" request waits up to 35 s. + transport http { + read_timeout 60s + } + } +} diff --git a/relay/Cargo.lock b/relay/Cargo.lock new file mode 100644 index 0000000..16a817c --- /dev/null +++ b/relay/Cargo.lock @@ -0,0 +1,686 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anybot-relay" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "ring", + "serde", + "serde_json", + "ureq", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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-task", + "pin-project-lite", + "slab", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +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 = "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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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 = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7be76686bdf7198002062423ca0d8cf9b75a27c8e1de804513b97e97b555d9d" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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-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 = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/relay/Cargo.toml b/relay/Cargo.toml new file mode 100644 index 0000000..b969f28 --- /dev/null +++ b/relay/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "anybot-relay" +version = "0.1.0" +edition = "2021" + +# The thin backend: accounts, the device list, encrypted backups, and passing the phone's +# requests to the user's AnyBot (which keeps an outgoing connection here). +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ring = "0.17" +base64 = "0.22" +chrono = "0.4" +ureq = "3" + + +[profile.release] +opt-level = 2 diff --git a/relay/Dockerfile b/relay/Dockerfile new file mode 100644 index 0000000..e3eeccf --- /dev/null +++ b/relay/Dockerfile @@ -0,0 +1,16 @@ +# AnyBot 帳號伺服器 (thin backend). Build from the app/ folder: +# docker build -f relay/Dockerfile -t anybot-relay . +FROM rust:1-bookworm AS build +WORKDIR /src +COPY src/httpd.rs src/httpd.rs +COPY mobile mobile +COPY relay relay +RUN cd relay && cargo build --release + +FROM debian:bookworm-slim +RUN useradd -m relay && mkdir /data && chown relay /data +COPY --from=build /src/relay/target/release/anybot-relay /usr/local/bin/anybot-relay +USER relay +VOLUME /data +EXPOSE 8080 +CMD ["anybot-relay", "--port", "8080", "--data", "/data"] diff --git a/relay/README.md b/relay/README.md new file mode 100644 index 0000000..dd8e1c8 --- /dev/null +++ b/relay/README.md @@ -0,0 +1,133 @@ +# AnyBot 帳號伺服器(薄後端) + +帳號伺服器是一個很小的程式,放在一台一直開著、有網址的主機上。它負責四件事: + +1. **帳號**:帳號和密碼。密碼只存雜湊值。 +2. **轉送**:手機打開網址、登入後,伺服器把手機的要求轉給你的 AnyBot。 + - 你的 AnyBot 會主動連到伺服器,所以電腦不用開放任何連接埠,也不用固定 IP。 +3. **裝置**:記住帳號裡有哪幾台 AnyBot,以及每台在線還是離線。 + - 同一個帳號的 AnyBot 可以互相操作(裝置連結)。 +4. **備份和通知**: + - 每天收一份加密備份。金鑰由你的密碼產生,伺服器自己打不開。 + - Bot 需要你允許、或你不在時做完事,會推播通知到手機。 + +Bot 的大腦不在帳號伺服器上,而在 AnyBot 裡,所以伺服器很輕,最小的主機就夠用。 + +## 三種用法 + +| 用法 | AnyBot 跑在哪 | 手機能做什麼 | 電腦要開著嗎 | +|---|---|---|---| +| A. 只用 Mac | Mac 上的 AnyBot(有視窗) | 聊天、核准、看 Bot 的電腦、看你的 Mac(要先打開) | Mac 要開著、不能睡 | +| B. 一直開著的 Mac | Mac mini 跑 `anybot --serve`(不開視窗) | 同上 | 那台 Mac mini 開著就好 | +| C. 雲端主機 | Linux 主機跑 `anybot --serve` 加上 Bot 的電腦 | 同上,例行任務、觸發、訊息管道 24 小時運作 | 不用;Mac 開著時,Bot 還能透過裝置連結操作你的 Mac | + +## 架設帳號伺服器(用法 A、B) + +需要準備: + +- 一台有 Docker 的小主機; +- 一個網域,DNS 指到這台主機。 + +步驟: + +1. 把整個 `app/` 資料夾放到主機上。 +2. 編輯 `relay/Caddyfile`,把 `anybot.example.com` 換成你的網域。 +3. 在 `relay/` 資料夾執行 `docker compose up -d`。Caddy 會自動申請並更新 HTTPS 憑證。 +4. 手機打開 `https://你的網域`,按「還沒有帳號?建立一個」。 + - 第一個帳號一定可以建立。之後要讓別人註冊,在 `docker-compose.yml` 設定 `ANYBOT_RELAY_INVITE` 邀請碼。 +5. 在 Mac 上打開 AnyBot › 設定 › 手機與帳號,填伺服器網址、帳號、密碼,按「登入」。 + +不用 Docker 的話,可以直接編譯成一個執行檔: + +``` +cd relay && cargo build --release +./target/release/anybot-relay --port 8080 --data /var/lib/anybot-relay +``` + +這樣啟動之後,前面要再自己接一層 HTTPS,例如 Caddy 或 nginx。 + +### 環境變數 + +| 變數 | 用途 | +|---|---| +| `ANYBOT_RELAY_INVITE` | 第二個以後的帳號要填的邀請碼。空白表示不開放註冊 | +| `ANYBOT_RELAY_CONTACT` | 推播服務需要的聯絡方式,例如 `mailto:你@你的網域` 或 `https://你的網域` | +| `ANYBOT_RELAY_PUBLIC_URL` | 對外的網址,例如 `https://anybot.你的網域`。Bot 發布網站後,會拿這個組成網址告訴你 | +| `PORT` | 連接埠,預設 8080,也可以用 `--port` 指定 | + +## 家裡的電腦加 Tailscale(不用網域) + +1. 帳號伺服器只開在這台 Mac 自己(127.0.0.1:8780),再由 Tailscale serve 加上 HTTPS,變成 `https://mac-mini.tailbcc3a6.ts.net`,只有你 Tailscale 網路裡的裝置連得到。 + - 要先在 Tailscale 管理頁打開 HTTPS 和 Serve,再執行 `tailscale serve --bg 8780`。 + - Mac 版 Tailscale 只能轉給這台電腦自己的位址。 +2. 開機自動啟動:設定檔是 `deploy/mac/com.anybot.relay.plist`,裡面有安裝指令。 +3. AnyBot 設定 › 手機與帳號:伺服器填 `https://mac-mini.tailbcc3a6.ts.net`。 +4. iPhone:打開 Tailscale,用 Safari 打開同一個網址,登入,再按「分享 › 加入主畫面」。從主畫面打開後,在「⋯ › 開啟通知」打開推播。 +5. 驗證過:用 Chrome 訂閱推播,伺服器加密後經 Google 的推播服務送達,瀏覽器解開並顯示通知。 + +## 全部放在雲端(用法 C) + +``` +cd deploy/server +cp .env.example .env # 填帳號、密碼和 AI 金鑰 +# 編輯 ../../relay/Caddyfile 換成你的網域 +docker compose up -d +``` + +這會在一台主機上啟動三樣東西: + +- 帳號伺服器; +- 處理 HTTPS 的 Caddy; +- 不開視窗的 AnyBot。Bot 的電腦會用主機的 Docker 建立。 + +第一次啟動的注意事項: + +- 帳號還不存在時,AnyBot 每 30 秒重試一次。這時用手機建立帳號就好。 +- `.env` 裡設 `ANYBOT_RELAY_INVITE` 邀請碼,第一個帳號才不會被別人先搶走。 +- `ANYBOT_RESTORE_FROM_ACCOUNT=1`:第一次啟動時,從 Mac 最新的備份接手 Bot、聊天、例行任務和金鑰。 + +主機規格:Bot 的電腦裡有 Chromium,建議至少 2 GB 記憶體。 + +## 一直開著的 Mac(用法 B) + +1. 先在 AnyBot 視窗裡登入帳號。 +2. 關掉視窗版。 +3. 依照 `deploy/mac/com.anybot.serve.plist` 裡的說明,把它放進 `~/Library/LaunchAgents/`。 + +也可以直接用指令啟動: + +``` +ANYBOT_PHONE_PASSWORD=至少八個字 anybot --serve +``` + +這樣同一個網路裡的手機就能直接連,不用帳號伺服器。 + +## 團隊 + +在 AnyBot 設定 › 手機與帳號 › 分享給團隊,把這台 AnyBot 分享給伺服器上的其他帳號: + +- **成員**:可以聊天、附加檔案。 +- **管理員**:另外可以核准、停止、看和操作畫面。 + +對方要先在同一台伺服器建立帳號,需要邀請碼。 + +## 安全 + +- **密碼**:用 PBKDF2-SHA256 存(12 萬輪)。登入失敗太多次會暫停。 +- **登入憑證和電腦金鑰**:都只存雜湊值。 +- **手機能碰到的範圍**:只能碰到自己帳號裡的 AnyBot。帳號之間的操作(裝置連結)只開放給同一個帳號的 AnyBot,手機碰不到。 +- **你的 Mac**:要在 Mac 的「手機與帳號」設定裡打開,手機才能看或操作 Mac 本身的螢幕;裝置連結也一樣。兩者預設都關閉。 + - Bot 透過裝置連結做的每個動作,照樣依權限規則詢問你,手機上也能核准。 +- **備份**:在你的電腦上用 AES-256-GCM 加密後才上傳。 + +## 測試 + +``` +cd relay && cargo test --release +``` + +測試涵蓋: + +- 註冊、登入、轉送、離線狀態、備份; +- 登入憑證不以明文儲存; +- 推播內容的加密和簽章。 diff --git a/relay/docker-compose.yml b/relay/docker-compose.yml new file mode 100644 index 0000000..94ee94f --- /dev/null +++ b/relay/docker-compose.yml @@ -0,0 +1,27 @@ +# docker compose up -d (run in app/relay; edit Caddyfile first) +services: + relay: + build: + context: .. + dockerfile: relay/Dockerfile + restart: unless-stopped + environment: + # Set to let more people sign up (the first account is always allowed). + # Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com + ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}" + ANYBOT_RELAY_INVITE: "" + volumes: + - relay-data:/data + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + depends_on: [relay] +volumes: + relay-data: + caddy-data: diff --git a/relay/src/main.rs b/relay/src/main.rs new file mode 100644 index 0000000..98823e9 --- /dev/null +++ b/relay/src/main.rs @@ -0,0 +1,762 @@ +// AnyBot 帳號伺服器 (the thin backend). +// +// What it keeps: accounts (name + PBKDF2 password hash), signed-in phones (session tokens, stored +// hashed), the user's AnyBots ("hosts", tokens stored hashed), and one encrypted backup per account +// (sealed on the user's computer with a key made from the password; this server can't open it). +// +// What it does: each AnyBot keeps asking `GET /relay/host/next` (an outgoing connection, so the +// computer needs no open port). A phone signed in to the account calls `/h//api/...`; the +// request is handed to that AnyBot and its answer comes back. AnyBots of the same account can +// also reach each other (裝置連結) through `/relay/hosts//api/device/run`. +// +// Run: anybot-relay [--port 8080] [--data ./relay-data] (put HTTPS in front, e.g. Caddy) +// Sign-ups: the first account is free; after that ANYBOT_RELAY_INVITE must be set and given. + +#[path = "../../src/httpd.rs"] +mod httpd; +mod push; + +use httpd::{Req, Resp}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Sender}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +const PAGE: &str = include_str!("../../mobile/index.html"); +const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##; +const ICON: &str = r##""##; + +/// How long a host's "next request?" waits before answering "nothing". +const HOST_POLL_SECS: u64 = 25; +/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s). +const FORWARD_SECS: u64 = 35; +/// A host counts as online when it asked within this many seconds. +const ONLINE_SECS: u64 = 60; +/// Sessions last this long without use. +const SESSION_DAYS: i64 = 90; +const MAX_FAILS: u32 = 8; + +#[derive(Clone, Serialize, Deserialize, Default)] +struct User { + name: String, + pass: String, + created: i64, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +struct Session { + hash: String, + user: String, + device: String, + created: i64, + last: i64, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +struct Host { + id: String, + name: String, + user: String, + token_hash: String, + created: i64, + #[serde(default)] + last_seen: i64, + /// 團隊: other accounts this AnyBot is shared with, and what they may do. + #[serde(default)] + members: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +struct Member { + user: String, + /// "admin": everything the owner can do on the phone (approve, screens); + /// "member": chat with the bots and attach files only. + role: String, +} + +impl Host { + /// The account's role on this AnyBot (None = no access). + fn role_of(&self, user: &str) -> Option { + if self.user == user { + return Some("owner".into()); + } + self.members.iter().find(|m| m.user == user).map(|m| m.role.clone()) + } +} + +#[derive(Clone, Serialize, Deserialize, Default)] +struct Sub { + user: String, + sub: Value, + created: i64, +} + +#[derive(Serialize, Deserialize, Default)] +struct Data { + users: Vec, + sessions: Vec, + hosts: Vec, + /// Phones that turned notifications on. + #[serde(default)] + subs: Vec, +} + +const SW: &str = r#"self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim())); +self.addEventListener("push", (e) => { + let d = {}; + try { d = e.data ? e.data.json() : {}; } catch (_) { d = {body: e.data ? e.data.text() : ""}; } + e.waitUntil(self.registration.showNotification(d.title || "AnyBot", {body: d.body || "", tag: "bot-" + (d.bot || 0), renotify: true, data: d, icon: "icon.svg", badge: "icon.svg"})); +}); +self.addEventListener("notificationclick", (e) => { + e.notification.close(); + const d = e.notification.data || {}; + const url = "./#host=" + encodeURIComponent(d.host || "") + "&bot=" + (d.bot || ""); + e.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then((cs) => { + for (const c of cs) { c.postMessage({open: d}); return c.focus(); } + return self.clients.openWindow(url); + })); +}); +"#; + +struct Relay { + dir: PathBuf, + data: Mutex, + /// Requests waiting for each host. + queues: Mutex>>, + arrived: Condvar, + /// Where each forwarded request's answer goes. + waiting: Mutex>>, + /// When each host last asked (in memory; saved now and then). + seen: Mutex>, + fails: Mutex>, + invite: String, + vapid: push::Vapid, + contact: String, +} + +fn now() -> i64 { + chrono::Utc::now().timestamp() +} + +impl Relay { + fn load(dir: PathBuf) -> Relay { + let _ = std::fs::create_dir_all(dir.join("backups")); + let data: Data = std::fs::read(dir.join("data.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + let vapid = push::Vapid::load(&dir); + Relay { + dir, + data: Mutex::new(data), + queues: Mutex::new(HashMap::new()), + arrived: Condvar::new(), + waiting: Mutex::new(HashMap::new()), + seen: Mutex::new(HashMap::new()), + fails: Mutex::new(HashMap::new()), + invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(), + vapid, + // Push services want a way to reach whoever runs the server. + contact: std::env::var("ANYBOT_RELAY_CONTACT").ok().filter(|c| c.starts_with("mailto:") || c.starts_with("https://")).unwrap_or_else(|| "mailto:anybot-relay@example.com".into()), + } + } + + fn save(&self, d: &Data) { + let tmp = self.dir.join("data.json.tmp"); + if std::fs::write(&tmp, serde_json::to_vec_pretty(d).unwrap_or_default()).is_ok() { + let _ = std::fs::rename(&tmp, self.dir.join("data.json")); + } + } + + fn session(&self, req: &Req) -> Option { + let t = req.bearer(); + if t.is_empty() { + return None; + } + let h = httpd::sha256_hex(&t); + let mut d = self.data.lock().unwrap(); + let cutoff = now() - SESSION_DAYS * 86_400; + let s = d.sessions.iter_mut().find(|s| s.hash == h && s.last > cutoff)?; + // Count use at most once a minute (saves writing on every request). + if now() - s.last > 60 { + s.last = now(); + let user = s.user.clone(); + self.save(&d); + return Some(user); + } + Some(s.user.clone()) + } + + fn host(&self, req: &Req) -> Option { + let t = req.bearer(); + if t.is_empty() { + return None; + } + let h = httpd::sha256_hex(&t); + let d = self.data.lock().unwrap(); + d.hosts.iter().find(|x| x.token_hash == h).cloned() + } + + fn online(&self, id: &str) -> bool { + self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false) + } + + fn too_many_fails(&self, key: &str) -> bool { + let f = self.fails.lock().unwrap(); + matches!(f.get(key), Some((n, t)) if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(300)) + } + + fn failed(&self, key: &str) { + let mut f = self.fails.lock().unwrap(); + let e = f.entry(key.to_string()).or_insert((0, Instant::now())); + if e.1.elapsed() > Duration::from_secs(300) { + *e = (0, Instant::now()); + } + e.0 += 1; + e.1 = Instant::now(); + } + + /// Hand a request to a host and wait for its answer. + fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], who: (&str, &str)) -> Resp { + use base64::Engine; + if !self.online(host_id) { + return Resp::error(503, "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)"); + } + let id = httpd::random_hex(12); + let (tx, rx) = channel(); + self.waiting.lock().unwrap().insert(id.clone(), tx); + let job = json!({"id": id, "method": method, "path": path, "query": query, "body": base64::engine::general_purpose::STANDARD.encode(body), "user": who.0, "role": who.1}); + self.queues.lock().unwrap().entry(host_id.to_string()).or_default().push_back(job); + self.arrived.notify_all(); + let r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS)); + self.waiting.lock().unwrap().remove(&id); + // Not picked up in time: drop it so it doesn't run late. + if let Some(q) = self.queues.lock().unwrap().get_mut(host_id) { + q.retain(|j| j["id"].as_str() != Some(id.as_str())); + } + match r { + Ok(v) => { + let mut resp = Resp::bytes(v["ctype"].as_str().unwrap_or("application/octet-stream"), base64::engine::general_purpose::STANDARD.decode(v["body"].as_str().unwrap_or("")).unwrap_or_default()); + resp.status = v["status"].as_u64().unwrap_or(502) as u16; + for h in v["headers"].as_array().into_iter().flatten() { + if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) { + // Only harmless headers pass through. + if k.eq_ignore_ascii_case("x-size") { + resp.headers.push((k.into(), val.into())); + } + } + } + resp + } + Err(_) => Resp::error(504, "那台 AnyBot 沒有及時回應"), + } + } + + fn handle(&self, req: Req) -> Resp { + let parts: Vec = req.parts().iter().map(|s| s.to_string()).collect(); + let p: Vec<&str> = parts.iter().map(|s| s.as_str()).collect(); + match (req.method.as_str(), p.as_slice()) { + ("GET", []) | ("GET", ["index.html"]) => Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()) + .with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") + .with_header("Strict-Transport-Security", "max-age=31536000"), + ("GET", ["manifest.webmanifest"]) => Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), + ("GET", ["icon.svg"]) => Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), + ("GET", ["api", "hello"]) => Resp::json(&json!({"app": "anybot-relay"})), + ("GET", ["sw.js"]) => Resp::bytes("text/javascript; charset=utf-8", SW.as_bytes().to_vec()).with_header("Service-Worker-Allowed", "/"), + ("GET", ["relay", "push", "key"]) => Resp::json(&json!({"key": self.vapid.public_b64})), + ("POST", ["relay", "push", "subscribe"]) => { + let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; + let sub = req.json(); + let endpoint = sub["endpoint"].as_str().unwrap_or("").to_string(); + if !endpoint.starts_with("https://") || sub["keys"]["p256dh"].as_str().is_none() || sub["keys"]["auth"].as_str().is_none() { + return Resp::error(400, "通知訂閱資料不完整"); + } + let mut d = self.data.lock().unwrap(); + d.subs.retain(|s| s.sub["endpoint"].as_str() != Some(endpoint.as_str())); + d.subs.push(Sub { user, sub, created: now() }); + self.save(&d); + Resp::json(&json!({"ok": true})) + } + ("POST", ["relay", "push", "unsubscribe"]) => { + let endpoint = req.json()["endpoint"].as_str().unwrap_or("").to_string(); + let mut d = self.data.lock().unwrap(); + d.subs.retain(|s| s.sub["endpoint"].as_str() != Some(endpoint.as_str())); + self.save(&d); + Resp::json(&json!({"ok": true})) + } + // An AnyBot tells its person something (a bot needs them, or finished while they were away). + ("POST", ["relay", "host", "notify"]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let v = req.json(); + let payload = json!({"title": v["title"].as_str().unwrap_or("AnyBot").chars().take(80).collect::(), "body": v["body"].as_str().unwrap_or("").chars().take(200).collect::(), "bot": v["bot"], "host": h.id}); + let subs: Vec = self.data.lock().unwrap().subs.iter().filter(|s| matches!(h.role_of(&s.user).as_deref(), Some("owner") | Some("admin"))).cloned().collect(); + let mut gone = vec![]; + let mut sent = 0; + for s in &subs { + match push::send(&self.vapid, &s.sub, &payload, &self.contact) { + Ok(()) => sent += 1, + Err(true) => gone.push(s.sub["endpoint"].as_str().unwrap_or("").to_string()), + Err(false) => {} + } + } + if !gone.is_empty() { + let mut d = self.data.lock().unwrap(); + d.subs.retain(|s| !gone.iter().any(|g| s.sub["endpoint"].as_str() == Some(g.as_str()))); + self.save(&d); + } + Resp::json(&json!({"sent": sent})) + } + ("POST", ["relay", "signup"]) => self.signup(&req), + ("POST", ["relay", "login"]) => self.login(&req), + ("POST", ["relay", "logout"]) => { + let h = httpd::sha256_hex(&req.bearer()); + let mut d = self.data.lock().unwrap(); + d.sessions.retain(|s| s.hash != h); + self.save(&d); + Resp::json(&json!({"ok": true})) + } + ("GET", ["relay", "me"]) => { + let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; + let d = self.data.lock().unwrap(); + let hosts: Vec = d + .hosts + .iter() + .filter_map(|h| h.role_of(&user).map(|r| (h, r))) + .map(|(h, r)| { + let mut v = self.host_json(h); + v["role"] = json!(r); + if r != "owner" { + v["name"] = json!(format!("{}({} 的)", h.name, h.user)); + } + v + }) + .collect(); + let sessions: Vec = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"device": s.device, "created": s.created, "last": s.last})).collect(); + Resp::json(&json!({"user": user, "hosts": hosts, "sessions": sessions, "backup": self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user))).exists()})) + } + ("POST", ["relay", "hosts"]) => { + let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; + let name: String = req.json()["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect(); + let token = httpd::random_hex(32); + let h = Host { id: httpd::random_hex(8), name: name.clone(), user: user.clone(), token_hash: httpd::sha256_hex(&token), created: now(), last_seen: 0, members: vec![] }; + let mut d = self.data.lock().unwrap(); + // Signing the same computer in again replaces its old key (keeping who it's shared with). + let mut h = h; + if let Some(old) = d.hosts.iter().find(|x| x.user == user && x.name == name) { + h.members = old.members.clone(); + } + d.hosts.retain(|x| !(x.user == user && x.name == name)); + d.hosts.push(h.clone()); + self.save(&d); + Resp::json(&json!({"id": h.id, "token": token})) + } + ("POST", ["relay", "hosts", id, "remove"]) => { + let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; + let mut d = self.data.lock().unwrap(); + d.hosts.retain(|x| !(x.user == user && x.id == *id)); + self.save(&d); + Resp::json(&json!({"ok": true})) + } + ("GET", ["relay", "host", "next"]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); + let q = self.queues.lock().unwrap(); + let (mut q, _) = self.arrived.wait_timeout_while(q, Duration::from_secs(HOST_POLL_SECS), |q| q.get(&h.id).map(|x| x.is_empty()).unwrap_or(true)).unwrap(); + self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); + match q.get_mut(&h.id).and_then(|x| x.pop_front()) { + Some(job) => Resp::json(&job), + None => Resp::empty(204), + } + } + ("POST", ["relay", "host", "reply", id]) => { + if self.host(&req).is_none() { + return Resp::error(401, "這台電腦的金鑰無效"); + } + if let Some(tx) = self.waiting.lock().unwrap().remove(*id) { + let _ = tx.send(req.json()); + } + Resp::json(&json!({"ok": true})) + } + ("GET", ["relay", "host", "members"]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + Resp::json(&json!({"members": h.members.iter().map(|m| json!({"user": m.user, "role": m.role})).collect::>()})) + } + ("POST", ["relay", "host", "members"]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let v = req.json(); + let user = v["user"].as_str().unwrap_or("").trim().to_lowercase(); + let role = v["role"].as_str().unwrap_or("member").to_string(); + let mut d = self.data.lock().unwrap(); + if role != "remove" && !d.users.iter().any(|u| u.name == user) { + return Resp::error(404, "這台伺服器上沒有這個帳號(請對方先建立帳號)"); + } + if user == h.user { + return Resp::error(400, "這是你自己的帳號"); + } + if !matches!(role.as_str(), "member" | "admin" | "remove") { + return Resp::error(400, "角色只能是 member 或 admin"); + } + if let Some(x) = d.hosts.iter_mut().find(|x| x.id == h.id) { + x.members.retain(|m| m.user != user); + if role != "remove" { + x.members.push(Member { user, role }); + } + } + self.save(&d); + Resp::json(&json!({"ok": true})) + } + // 發布網站: an AnyBot publishes a folder; it is served at /s/-/. + ("POST", ["relay", "host", "sites", name]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let name = name.to_lowercase(); + if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Resp::error(400, "網站名稱只能用英文字母、數字和 -(最多 40 個字)"); + } + let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-")); + let dir = self.dir.join("sites").join(&site); + let v = req.json(); + let _ = std::fs::remove_dir_all(&dir); + if v["remove"].as_bool() == Some(true) { + return Resp::json(&json!({"ok": true})); + } + let mut n = 0; + for f in v["files"].as_array().into_iter().flatten() { + let rel = f["path"].as_str().unwrap_or(""); + if rel.is_empty() || rel.split('/').any(|c| c.is_empty() || c == "." || c == "..") { + continue; + } + let Ok(bytes) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, f["data"].as_str().unwrap_or("")) else { continue }; + let path = dir.join(rel); + let _ = std::fs::create_dir_all(path.parent().unwrap_or(&dir)); + if std::fs::write(&path, bytes).is_ok() { + n += 1; + } + } + let public = std::env::var("ANYBOT_RELAY_PUBLIC_URL").unwrap_or_default(); + Resp::json(&json!({"files": n, "path": format!("/s/{site}/"), "url": if public.is_empty() { String::new() } else { format!("{}/s/{site}/", public.trim_end_matches('/')) }})) + } + ("GET", ["s", site, rest @ ..]) => { + if site.contains("..") || rest.iter().any(|c| c.is_empty() || *c == "." || *c == "..") { + return Resp::error(404, "找不到"); + } + let mut path = self.dir.join("sites").join(site).join(rest.join("/")); + if path.is_dir() || rest.is_empty() { + path = path.join("index.html"); + } + let ext = path.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default(); + let ctype = match ext.as_str() { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "json" => "application/json", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "ico" => "image/x-icon", + "txt" | "md" => "text/plain; charset=utf-8", + "pdf" => "application/pdf", + "woff2" => "font/woff2", + "mp4" => "video/mp4", + _ => "application/octet-stream", + }; + match std::fs::read(&path) { + // Sandboxed: a published page runs in its own opaque origin, so it can never + // read the phone app's sign-in on this same address. + Ok(b) => Resp::bytes(ctype, b).with_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"), + Err(_) => Resp::error(404, "找不到這個網頁"), + } + } + ("GET", ["relay", "host", "peers"]) => { + let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let d = self.data.lock().unwrap(); + Resp::json(&json!({"hosts": d.hosts.iter().filter(|h| h.user == me.user).map(|h| self.host_json(h)).collect::>()})) + } + ("PUT", ["relay", "backup"]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let path = self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&h.user))); + let tmp = path.with_extension("tmp"); + match std::fs::write(&tmp, &req.body).and_then(|_| std::fs::rename(&tmp, &path)) { + Ok(()) => Resp::json(&json!({"ok": true, "bytes": req.body.len()})), + Err(e) => Resp::error(500, &e.to_string()), + } + } + ("GET", ["relay", "backup"]) => { + let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; + match std::fs::read(self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user)))) { + Ok(b) => Resp::bytes("application/octet-stream", b), + Err(_) => Resp::error(404, "還沒有備份"), + } + } + // 裝置連結: one AnyBot asks another AnyBot of the same account. + ("POST", ["relay", "hosts", target, "api", "device", "run"]) => { + let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let same = self.data.lock().unwrap().hosts.iter().any(|h| h.id == *target && h.user == me.user); + if !same || *target == me.id { + return Resp::error(403, "不是同一個帳號的電腦"); + } + self.forward(target, "POST", "/api/device/run", &HashMap::new(), &req.body, (&me.user, "device")) + } + // The phone, through to one of the account's AnyBots. + (m, ["h", host, rest @ ..]) => { + let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; + let role = self.data.lock().unwrap().hosts.iter().find(|h| h.id == *host).and_then(|h| h.role_of(&user)); + let Some(role) = role else { return Resp::error(404, "找不到這台 AnyBot") }; + if rest.first() != Some(&"api") || rest.get(1) == Some(&"device") { + return Resp::error(404, "沒有這個網址"); + } + let mut q = req.query.clone(); + q.remove("t"); + self.forward(host, m, &format!("/{}", rest.join("/")), &q, &req.body, (&user, &role)) + } + _ => Resp::error(404, "沒有這個網址"), + } + } + + fn host_json(&self, h: &Host) -> Value { + let online = self.online(&h.id); + json!({"id": h.id, "name": h.name, "online": online, "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }}) + } + + fn signup(&self, req: &Req) -> Resp { + let v = req.json(); + let user = v["user"].as_str().unwrap_or("").trim().to_lowercase(); + let pw = v["password"].as_str().unwrap_or(""); + if user.len() < 2 || user.len() > 64 || !user.chars().all(|c| c.is_ascii_alphanumeric() || "._@-".contains(c)) { + return Resp::error(400, "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)"); + } + if pw.chars().count() < 8 { + return Resp::error(400, "密碼至少 8 個字"); + } + let mut d = self.data.lock().unwrap(); + if !d.users.is_empty() && (self.invite.is_empty() || v["invite"].as_str() != Some(self.invite.as_str())) { + return Resp::error(403, if self.invite.is_empty() { "這台伺服器不開放註冊" } else { "邀請碼不對" }); + } + if d.users.iter().any(|u| u.name == user) { + return Resp::error(409, "這個帳號已經有人用了"); + } + d.users.push(User { name: user, pass: httpd::hash_password(pw), created: now() }); + self.save(&d); + Resp::json(&json!({"ok": true})) + } + + fn login(&self, req: &Req) -> Resp { + let v = req.json(); + let user = v["user"].as_str().unwrap_or("").trim().to_lowercase(); + let key = format!("{}|{user}", req.header("x-forwarded-for").split(',').next().unwrap_or(&req.peer).trim()); + if self.too_many_fails(&key) { + return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試"); + } + let stored = self.data.lock().unwrap().users.iter().find(|u| u.name == user).map(|u| u.pass.clone()); + let ok = stored.map(|p| httpd::check_password(v["password"].as_str().unwrap_or(""), &p)).unwrap_or(false); + if !ok { + self.failed(&key); + std::thread::sleep(Duration::from_millis(500)); + return Resp::error(401, "帳號或密碼不對"); + } + let token = httpd::random_hex(32); + let mut d = self.data.lock().unwrap(); + d.sessions.push(Session { hash: httpd::sha256_hex(&token), user, device: v["device"].as_str().unwrap_or("").chars().take(40).collect(), created: now(), last: now() }); + let cutoff = now() - SESSION_DAYS * 86_400; + d.sessions.retain(|s| s.last > cutoff); + self.save(&d); + Resp::json(&json!({"token": token})) + } + + /// Remember when hosts were last seen (for "離線(上次…)"). + fn save_seen(&self) { + let seen: Vec<(String, u64)> = self.seen.lock().unwrap().iter().map(|(k, v)| (k.clone(), v.elapsed().as_secs())).collect(); + let mut d = self.data.lock().unwrap(); + let mut changed = false; + for (id, ago) in seen { + if let Some(h) = d.hosts.iter_mut().find(|h| h.id == id) { + let t = now() - ago as i64; + if t - h.last_seen > 60 { + h.last_seen = t; + changed = true; + } + } + } + if changed { + self.save(&d); + } + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); + let port: u16 = arg("--port").and_then(|p| p.parse().ok()).or_else(|| std::env::var("PORT").ok().and_then(|p| p.parse().ok())).unwrap_or(8080); + let bind = arg("--bind").unwrap_or_else(|| "0.0.0.0".into()); + let dir = PathBuf::from(arg("--data").unwrap_or_else(|| "relay-data".into())); + let relay = Arc::new(Relay::load(dir.clone())); + let listener = std::net::TcpListener::bind((bind.as_str(), port)).unwrap_or_else(|e| { + eprintln!("無法使用連接埠 {port}:{e}"); + std::process::exit(1) + }); + eprintln!("AnyBot 帳號伺服器:http://{bind}:{port}(資料在 {})", dir.display()); + if relay.data.lock().unwrap().users.is_empty() { + eprintln!("還沒有帳號:第一個註冊的人會成為這台伺服器的使用者。"); + } + let r2 = relay.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(Duration::from_secs(60)); + r2.save_seen(); + }); + let r = relay.clone(); + httpd::serve(listener, Arc::new(move |req| r.handle(req)), Arc::new(|| true)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn post(port: u16, path: &str, token: &str, body: Value) -> (u16, Value) { + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let mut r = agent.post(&format!("http://127.0.0.1:{port}{path}")).header("Authorization", &format!("Bearer {token}")).send(body.to_string().as_bytes()).unwrap(); + let st = r.status().as_u16(); + (st, serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null)) + } + + fn get(port: u16, path: &str, token: &str) -> (u16, Vec) { + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let mut r = agent.get(&format!("http://127.0.0.1:{port}{path}")).header("Authorization", &format!("Bearer {token}")).call().unwrap(); + (r.status().as_u16(), r.body_mut().read_to_vec().unwrap_or_default()) + } + + #[test] + fn account_host_and_phone_round_trip() { + let dir = std::env::temp_dir().join(format!("anybot-relay-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let relay = Arc::new(Relay::load(dir.clone())); + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let r = relay.clone(); + std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true))); + + assert_eq!(post(port, "/relay/signup", "", json!({"user": "Daniel", "password": "12345678"})).0, 200); + // Second sign-up without an invite: refused. + assert_eq!(post(port, "/relay/signup", "", json!({"user": "eve", "password": "12345678"})).0, 403); + assert_eq!(post(port, "/relay/login", "", json!({"user": "daniel", "password": "wrong-pw"})).0, 401); + let (_, v) = post(port, "/relay/login", "", json!({"user": "daniel", "password": "12345678", "device": "Mac"})); + let session = v["token"].as_str().unwrap().to_string(); + let (_, h) = post(port, "/relay/hosts", &session, json!({"name": "工作室 Mac"})); + let (host_id, host_token) = (h["id"].as_str().unwrap().to_string(), h["token"].as_str().unwrap().to_string()); + + // Offline host: the phone is told so. + let (st, _) = get(port, &format!("/h/{host_id}/api/state"), &session); + assert_eq!(st, 503); + + // The "AnyBot": answers whatever comes in with the path it got. + let ht = host_token.clone(); + std::thread::spawn(move || loop { + let (st, body) = get(port, "/relay/host/next", &ht); + if st == 200 { + let job: Value = serde_json::from_slice(&body).unwrap(); + let answer = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"path": job["path"], "q": job["query"]}).to_string()), "headers": [["X-Size", "10x20"], ["Set-Cookie", "no"]]}); + post(port, &format!("/relay/host/reply/{}", job["id"].as_str().unwrap()), &ht, answer); + } + }); + std::thread::sleep(Duration::from_millis(300)); + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let mut resp = agent.get(&format!("http://127.0.0.1:{port}/h/{host_id}/api/state?since=3")).header("Authorization", &format!("Bearer {session}")).call().unwrap(); + assert_eq!(resp.status().as_u16(), 200); + assert_eq!(resp.headers().get("x-size").unwrap(), "10x20"); + assert!(resp.headers().get("set-cookie").is_none()); + let v: Value = serde_json::from_str(&resp.body_mut().read_to_string().unwrap()).unwrap(); + assert_eq!(v["path"], "/api/state"); + assert_eq!(v["q"]["since"], "3"); + + // The device endpoint is only for the account's other AnyBots, never the phone. + assert_eq!(get(port, &format!("/h/{host_id}/api/device/run"), &session).0, 404); + // Without a session: refused. + assert_eq!(get(port, &format!("/h/{host_id}/api/state"), "nope").0, 401); + + // Backup: the host uploads, the person downloads after signing in. + let agent2: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let st = agent2.put(&format!("http://127.0.0.1:{port}/relay/backup")).header("Authorization", &format!("Bearer {host_token}")).send(&[1u8, 2, 3][..]).unwrap().status().as_u16(); + assert_eq!(st, 200); + assert_eq!(get(port, "/relay/backup", &session).1, vec![1, 2, 3]); + let (_, me) = get(port, "/relay/me", &session); + let me: Value = serde_json::from_slice(&me).unwrap(); + assert_eq!(me["hosts"][0]["online"], true); + // Data survives a restart; tokens are stored hashed. + let saved = std::fs::read_to_string(dir.join("data.json")).unwrap(); + assert!(!saved.contains(&session) && !saved.contains(&host_token) && !saved.contains("12345678")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn team_members_get_their_role() { + let dir = std::env::temp_dir().join(format!("anybot-relay-team-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut relay = Relay::load(dir.clone()); + relay.invite = "join-us".into(); + let relay = Arc::new(relay); + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let r = relay.clone(); + std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true))); + post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"})); + assert_eq!(post(port, "/relay/signup", "", json!({"user": "amy", "password": "12345678", "invite": "nope"})).0, 403); + assert_eq!(post(port, "/relay/signup", "", json!({"user": "amy", "password": "12345678", "invite": "join-us"})).0, 200); + let boss = post(port, "/relay/login", "", json!({"user": "boss", "password": "12345678"})).1["token"].as_str().unwrap().to_string(); + let amy = post(port, "/relay/login", "", json!({"user": "amy", "password": "12345678"})).1["token"].as_str().unwrap().to_string(); + let h = post(port, "/relay/hosts", &boss, json!({"name": "辦公室"})).1; + let (host_id, host_token) = (h["id"].as_str().unwrap().to_string(), h["token"].as_str().unwrap().to_string()); + // Not shared yet: Amy can't see it. + assert_eq!(get(port, &format!("/h/{host_id}/api/state"), &amy).0, 404); + assert_eq!(post(port, "/relay/host/members", &host_token, json!({"user": "ghost", "role": "member"})).0, 404); + assert_eq!(post(port, "/relay/host/members", &host_token, json!({"user": "amy", "role": "member"})).0, 200); + let me: Value = serde_json::from_slice(&get(port, "/relay/me", &amy).1).unwrap(); + assert_eq!(me["hosts"][0]["role"], "member"); + // The host hears who asked and in which role. + let ht = host_token.clone(); + std::thread::spawn(move || loop { + let (st, body) = get(port, "/relay/host/next", &ht); + if st == 200 { + let job: Value = serde_json::from_slice(&body).unwrap(); + let answer = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"user": job["user"], "role": job["role"]}).to_string()), "headers": []}); + post(port, &format!("/relay/host/reply/{}", job["id"].as_str().unwrap()), &ht, answer); + } + }); + std::thread::sleep(Duration::from_millis(300)); + let v: Value = serde_json::from_slice(&get(port, &format!("/h/{host_id}/api/state"), &amy).1).unwrap(); + assert_eq!((v["user"].as_str(), v["role"].as_str()), (Some("amy"), Some("member"))); + let v: Value = serde_json::from_slice(&get(port, &format!("/h/{host_id}/api/state"), &boss).1).unwrap(); + assert_eq!(v["role"], "owner"); + // Removing ends access. + post(port, "/relay/host/members", &host_token, json!({"user": "amy", "role": "remove"})); + assert_eq!(get(port, &format!("/h/{host_id}/api/state"), &amy).0, 404); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn published_sites_are_sandboxed() { + let dir = std::env::temp_dir().join(format!("anybot-relay-sites-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let relay = Arc::new(Relay::load(dir.clone())); + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let r = relay.clone(); + std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true))); + post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"})); + let s = post(port, "/relay/login", "", json!({"user": "boss", "password": "12345678"})).1["token"].as_str().unwrap().to_string(); + let ht = post(port, "/relay/hosts", &s, json!({"name": "m"})).1["token"].as_str().unwrap().to_string(); + let b = |t: &str| base64::Engine::encode(&base64::engine::general_purpose::STANDARD, t); + let (st, v) = post(port, "/relay/host/sites/trip", &ht, json!({"files": [{"path": "index.html", "data": b("

台南

")}, {"path": "css/a.css", "data": b("h1{}")}, {"path": "../evil", "data": b("x")}]})); + assert_eq!((st, v["files"].as_u64()), (200, Some(2))); + assert_eq!(v["path"], "/s/boss-trip/"); + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let mut r = agent.get(&format!("http://127.0.0.1:{port}/s/boss-trip/")).call().unwrap(); + assert!(r.headers().get("content-security-policy").unwrap().to_str().unwrap().starts_with("sandbox")); + assert_eq!(r.body_mut().read_to_string().unwrap(), "

台南

"); + assert_eq!(get(port, "/s/boss-trip/css/a.css", "").0, 200); + assert_eq!(get(port, "/s/boss-trip/../../data.json", "").0, 404); + assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists()); + assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/relay/src/push.rs b/relay/src/push.rs new file mode 100644 index 0000000..5ec1b67 --- /dev/null +++ b/relay/src/push.rs @@ -0,0 +1,166 @@ +// Web Push (RFC 8030 / 8291 / 8292): the phone (an installed home-screen page) gets a notification +// when a bot needs the person or finishes while they're away. The payload is encrypted for the +// phone (aes128gcm); the push service only sees ciphertext. + +use base64::Engine; +use ring::rand::SecureRandom; +use ring::{aead, agreement, hkdf, signature}; +use serde_json::{json, Value}; +use std::path::Path; + +fn b64u(b: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b) +} + +fn unb64u(s: &str) -> Option> { + base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s.trim_end_matches('=')).ok() +} + +struct Len(usize); +impl hkdf::KeyType for Len { + fn len(&self) -> usize { + self.0 + } +} + +fn hkdf(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Vec { + let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, salt).extract(ikm); + let mut out = vec![0u8; len]; + prk.expand(&[info], Len(len)).expect("hkdf").fill(&mut out).expect("hkdf"); + out +} + +/// The server's signing key (made once, kept in the data folder). +pub struct Vapid { + pair: signature::EcdsaKeyPair, + pub public_b64: String, +} + +impl Vapid { + pub fn load(dir: &Path) -> Vapid { + let rng = ring::rand::SystemRandom::new(); + let path = dir.join("vapid.pk8"); + let pk8 = std::fs::read(&path).ok().unwrap_or_else(|| { + let doc = signature::EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("key"); + let _ = std::fs::write(&path, doc.as_ref()); + doc.as_ref().to_vec() + }); + let pair = signature::EcdsaKeyPair::from_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &pk8, &rng).expect("vapid key"); + let public_b64 = b64u(signature::KeyPair::public_key(&pair).as_ref()); + Vapid { pair, public_b64 } + } + + /// `Authorization: vapid t=…, k=…` for one push service. + fn header(&self, endpoint: &str, contact: &str) -> String { + let aud = endpoint.splitn(4, '/').take(3).collect::>().join("/"); + let exp = chrono::Utc::now().timestamp() + 12 * 3600; + let head = b64u(br#"{"typ":"JWT","alg":"ES256"}"#); + let claims = b64u(json!({"aud": aud, "exp": exp, "sub": contact}).to_string().as_bytes()); + let msg = format!("{head}.{claims}"); + let sig = self.pair.sign(&ring::rand::SystemRandom::new(), msg.as_bytes()).expect("sign"); + format!("vapid t={msg}.{}, k={}", b64u(sig.as_ref()), self.public_b64) + } +} + +/// Encrypt `payload` for one subscription (RFC 8291, one record). +pub fn encrypt(p256dh: &str, auth: &str, payload: &[u8]) -> Option> { + let rng = ring::rand::SystemRandom::new(); + let ua_public = unb64u(p256dh)?; + let auth_secret = unb64u(auth)?; + let eph = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng).ok()?; + let as_public = eph.compute_public_key().ok()?.as_ref().to_vec(); + let ecdh = agreement::agree_ephemeral(eph, &agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, &ua_public), |s| s.to_vec()).ok()?; + let mut key_info = b"WebPush: info\0".to_vec(); + key_info.extend(&ua_public); + key_info.extend(&as_public); + let ikm = hkdf(&auth_secret, &ecdh, &key_info, 32); + let mut salt = [0u8; 16]; + rng.fill(&mut salt).ok()?; + let cek = hkdf(&salt, &ikm, b"Content-Encoding: aes128gcm\0", 16); + let nonce = hkdf(&salt, &ikm, b"Content-Encoding: nonce\0", 12); + let key = aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_128_GCM, &cek).ok()?); + let mut data = payload.to_vec(); + data.push(2); // last record + key.seal_in_place_append_tag(aead::Nonce::try_assume_unique_for_key(&nonce).ok()?, aead::Aad::empty(), &mut data).ok()?; + let mut body = salt.to_vec(); + body.extend(4096u32.to_be_bytes()); + body.push(as_public.len() as u8); + body.extend(&as_public); + body.extend(data); + Some(body) +} + +/// Send one notification. Err(true) = the subscription is gone (forget it). +pub fn send(v: &Vapid, sub: &Value, payload: &Value, contact: &str) -> Result<(), bool> { + let endpoint = sub["endpoint"].as_str().ok_or(true)?; + if !endpoint.starts_with("https://") { + return Err(true); + } + let body = encrypt(sub["keys"]["p256dh"].as_str().unwrap_or(""), sub["keys"]["auth"].as_str().unwrap_or(""), payload.to_string().as_bytes()).ok_or(true)?; + let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(15))).http_status_as_error(false).build().into(); + let r = agent + .post(endpoint) + .header("Authorization", &v.header(endpoint, contact)) + .header("Content-Encoding", "aes128gcm") + .header("Content-Type", "application/octet-stream") + .header("TTL", "86400") + .header("Urgency", "high") + .send(&body[..]); + match r { + Ok(resp) => match resp.status().as_u16() { + 200..=299 => Ok(()), + 404 | 410 => Err(true), + _ => Err(false), + }, + Err(_) => Err(false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Decrypt as a browser would (RFC 8291), with the browser's private key. + #[test] + fn payload_decrypts_on_the_phone_side() { + let rng = ring::rand::SystemRandom::new(); + let ua = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng).unwrap(); + let ua_pub = ua.compute_public_key().unwrap().as_ref().to_vec(); + let mut auth = [0u8; 16]; + rng.fill(&mut auth).unwrap(); + let body = encrypt(&b64u(&ua_pub), &b64u(&auth), "需要你允許".as_bytes()).unwrap(); + let (salt, rest) = body.split_at(16); + let idlen = rest[4] as usize; + let as_pub = &rest[5..5 + idlen]; + let ct = &rest[5 + idlen..]; + let ecdh = agreement::agree_ephemeral(ua, &agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, as_pub), |s| s.to_vec()).unwrap(); + let mut info = b"WebPush: info\0".to_vec(); + info.extend(&ua_pub); + info.extend(as_pub); + let ikm = hkdf(&auth, &ecdh, &info, 32); + let cek = hkdf(salt, &ikm, b"Content-Encoding: aes128gcm\0", 16); + let nonce = hkdf(salt, &ikm, b"Content-Encoding: nonce\0", 12); + let key = aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_128_GCM, &cek).unwrap()); + let mut data = ct.to_vec(); + let plain = key.open_in_place(aead::Nonce::try_assume_unique_for_key(&nonce).unwrap(), aead::Aad::empty(), &mut data).unwrap(); + assert_eq!(&plain[..plain.len() - 1], "需要你允許".as_bytes()); + assert_eq!(*plain.last().unwrap(), 2); + } + + #[test] + fn vapid_token_verifies() { + let dir = std::env::temp_dir().join(format!("anybot-vapid-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let v = Vapid::load(&dir); + let h = v.header("https://web.push.apple.com/abc/def", "mailto:x@example.com"); + let t = h.strip_prefix("vapid t=").unwrap().split(", k=").next().unwrap(); + let (msg, sig) = t.rsplit_once('.').unwrap(); + let pubkey = unb64u(&v.public_b64).unwrap(); + signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, &pubkey).verify(msg.as_bytes(), &unb64u(sig).unwrap()).unwrap(); + let claims: Value = serde_json::from_slice(&unb64u(msg.split('.').nth(1).unwrap()).unwrap()).unwrap(); + assert_eq!(claims["aud"], "https://web.push.apple.com"); + // Same key after a restart. + assert_eq!(Vapid::load(&dir).public_b64, v.public_b64); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..3a7caff --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,1145 @@ +// The work loop: ask the model, run the tools it asks for (asking the person first when the +// rules say so), give it the results, and repeat until it answers in plain words. +// Long chats are folded into a summary before they outgrow the model's context. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::appctl; +use crate::computer; +use crate::model::{Message, Role, ToolStatus}; +use crate::providers::{self, AppCall, Approval, ApprovalAnswer, ApprovalKind, ChatRequest, StreamEvent}; +use crate::tools::{self, Decision}; +use crate::web; + +/// Context size assumed when the person hasn't set one (estimated). +pub const DEFAULT_CONTEXT_TOKENS: u64 = 128_000; +/// Summarize once the chat fills this share of the context: +/// main-app.cjs `client_speculative_summarization_config.tokenUsageThresholdPercentage:70`. +pub const COMPACT_THRESHOLD_PERCENT: u64 = 70; +/// Extra room allowed in the middle of a task before summarizing again: +/// same config, `tolerancePercentage:5`. +pub const COMPACT_TOLERANCE_PERCENT: u64 = 5; +/// Most model calls in one turn (estimated). +pub const MAX_STEPS: usize = 25; +/// Newest messages always kept word for word (estimated). +pub const KEEP_RECENT_MESSAGES: usize = 6; +/// Characters of each old message given to the summarizer (estimated). +const SUMMARY_SNIPPET_CHARS: usize = 2_000; + +pub struct Job { + pub req: ChatRequest, + /// Computers this bot may work on (empty when computer tools are off). + pub computers: Vec, + pub default_computer: String, + /// 在這台電腦上執行 is on. + pub local_execution: bool, + /// This bot's computer tools are on (when off, using them asks the person first). + pub tools_enabled: bool, + /// 直接操作 mode; the window can switch it on mid-turn ("全部允許"). + pub direct: Arc, + /// 在這台電腦上執行 is locked off by an admin policy: refuse, don't ask. + pub local_locked: bool, + pub grants: Vec, + pub context_tokens: u64, + pub via: String, + /// The bot's name, for the pop-up text. + pub via_name: String, + /// Messages the person sends while the turn runs (they steer it; no new turn). + pub steer: Arc>>, + /// 自動審核 rules, when on. + pub review: Option, + /// 示範教學 this bot has learned (for view_demo_step). + pub demos: Vec, +} + +#[derive(Clone, Default)] +pub struct Review { + /// 「無需詢問直接允許」規則 + pub allow: Vec, + /// 「一律詢問」規則 (wins when both match) + pub ask: Vec, +} + +pub enum ReviewVerdict { + Allow(String), + Ask(String), + NoMatch, +} + +/// Ask the model whether an action matches the person's rules. "Ask first" wins on conflict +/// (NFRGrQ: 規則衝突時,「先詢問」優先); built-in safety checks have already run before this. +fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool) -> ReviewVerdict { + if review.allow.is_empty() && review.ask.is_empty() { + return ReviewVerdict::NoMatch; + } + let list = |v: &[String]| if v.is_empty() { "(無)".to_string() } else { v.iter().enumerate().map(|(i, r)| format!("{}. {r}", i + 1)).collect::>().join("\n") }; + let req = ChatRequest { + system: "你是動作審核員。只根據使用者寫的規則判斷,不要自己加規則。只回一行 JSON:{\"verdict\":\"ask\"|\"allow\"|\"none\",\"rule\":\"符合的規則原文\"}。如果符合任何「一律詢問」規則就回 ask(優先);否則符合「無需詢問直接允許」規則就回 allow;都不符合回 none。".into(), + history: vec![Message::user(&format!("要審核的動作:{action}\n\n「一律詢問」規則:\n{}\n\n「無需詢問直接允許」規則:\n{}", list(&review.ask), list(&review.allow)))], + tools: vec![], + ..job.req.clone() + }; + let Ok(out) = providers::run_step(&req, cancel, &|_| {}) else { return ReviewVerdict::NoMatch }; + let text = out.text.trim(); + let json_part = text.find('{').and_then(|a| text.rfind('}').map(|b| &text[a..=b])).unwrap_or(text); + let v: serde_json::Value = serde_json::from_str(json_part).unwrap_or_default(); + let rule = v["rule"].as_str().unwrap_or("").to_string(); + match v["verdict"].as_str() { + Some("ask") => ReviewVerdict::Ask(rule), + Some("allow") => ReviewVerdict::Allow(rule), + _ => ReviewVerdict::NoMatch, + } +} + +/// Take what the person typed mid-turn and add it to the chat as steering. +fn drain_steer(job: &Job, history: &mut Vec, send: &dyn Fn(StreamEvent)) -> bool { + let queued: Vec = std::mem::take(&mut *job.steer.lock().unwrap()); + for t in &queued { + let m = Message { steer: true, ..Message::user(t) }; + send(StreamEvent::Steered(m.clone())); + history.push(m); + } + !queued.is_empty() +} + +/// Which computer a call asks for (permission to use it is checked separately). +fn target_for(job: &Job, args: &str) -> Result { + let v = tools::args_of(args); + let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); + job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| { + format!("沒有「{want}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")) + }) +} + +/// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated). +/// Which screen a computer_use call is for: the one named, else the bot's default when it has a +/// screen, else the bot's own desktop when there is one, else this Mac. +fn screen_target(job: &Job, args: &str) -> Result { + let v = tools::args_of(args); + let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. }); + let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()); + if let Some(n) = named { + let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦"))?; + if !has_screen(t) { + return Err(if n == crate::model::BOT_COMPUTER_NAME { + if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() } + } else { + format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。") + }); + } + return Ok(t.clone()); + } + if let Some(t) = job.computers.iter().find(|t| t.name == job.default_computer && has_screen(t)) { + return Ok(t.clone()); + } + job.computers + .iter() + .find(|t| matches!(t.place, tools::Place::Box { .. })) + .or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. }))) + .cloned() + .ok_or_else(|| "找不到可以操作螢幕的電腦".to_string()) +} + +pub fn estimate_tokens(s: &str) -> u64 { + let (mut ascii, mut other) = (0u64, 0u64); + for c in s.chars() { + if c.is_ascii() { ascii += 1 } else { other += 1 } + } + ascii / 4 + other +} + +fn history_tokens(system: &str, h: &[Message]) -> u64 { + estimate_tokens(system) + h.iter().map(|m| estimate_tokens(&m.text) + m.tool_calls.iter().map(|c| estimate_tokens(&c.args)).sum::() + 4).sum::() +} + +/// Where to cut so the newest messages stay and no tool call is split from its result: +/// the summary covers everything before a user message. +pub fn compaction_cut(h: &[Message]) -> Option { + if h.len() <= KEEP_RECENT_MESSAGES + 1 { + return None; + } + (1..=h.len() - KEEP_RECENT_MESSAGES).rev().find(|&i| h[i].role == Role::User && !h[..i].iter().all(|m| m.summary)) +} + +fn flatten(h: &[Message]) -> String { + let clip = |s: &str| -> String { + let mut t: String = s.chars().take(SUMMARY_SNIPPET_CHARS).collect(); + if s.chars().count() > SUMMARY_SNIPPET_CHARS { + t.push('…'); + } + t + }; + h.iter() + .map(|m| { + if m.summary { + return format!("【先前的摘要】\n{}", m.text); + } + match m.role { + Role::User => format!("使用者:{}", clip(&m.text)), + Role::Assistant => { + let calls: Vec = m.tool_calls.iter().map(|c| format!("[使用工具 {}:{}]", c.name, tools::describe(&c.name, &c.args))).collect(); + format!("助手:{} {}", clip(&m.text), calls.join(" ")) + } + Role::Tool => format!("工具結果({}):{}", m.tool_name, clip(&m.text)), + } + }) + .collect::>() + .join("\n\n") +} + +/// Fold older messages into one summary when the chat is over `percent` of the context. +/// Returns how many messages were replaced. +fn maybe_compact(job: &Job, history: &mut Vec, percent: u64, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Option { + let used = history_tokens(&job.req.system, history); + if used * 100 < job.context_tokens * percent { + return None; + } + let cut = compaction_cut(history)?; + send(StreamEvent::Retrying("對話很長了,正在把較早的內容整理成摘要…".into())); + let req = ChatRequest { + system: "你負責把一段對話整理成摘要,讓之後的對話能接著進行。請用條列寫下:使用者的目標與偏好、已經做出的決定、重要的事實與數字、提到的檔案路徑與指令結果、還沒完成的事。不要加入對話裡沒有的內容。".into(), + history: vec![Message::user(&format!("請整理下面這段對話:\n\n{}", flatten(&history[..cut])))], + tools: vec![], + ..job.req.clone() + }; + let quiet = |ev: StreamEvent| { + if matches!(ev, StreamEvent::Alive | StreamEvent::Retrying(_)) { + send(StreamEvent::Alive); + } + }; + let out = providers::run_step(&req, cancel, &quiet).ok()?; + if out.text.trim().is_empty() || cancel.load(Ordering::Relaxed) { + return None; + } + let summary = Message { role: Role::Assistant, text: out.text.trim().to_string(), summary: true, via: job.via.clone(), at: crate::model::now_ms(), ..Default::default() }; + history.splice(..cut, [summary.clone()]); + send(StreamEvent::Compacted(cut, summary)); + Some(cut) +} + +/// Wait for the person's answer; stopping the reply counts as a no. +fn wait_answer(rx: std::sync::mpsc::Receiver, cancel: &AtomicBool) -> ApprovalAnswer { + loop { + if cancel.load(Ordering::Relaxed) { + return ApprovalAnswer::Reject; + } + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(a) => return a, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(_) => return ApprovalAnswer::Reject, + } + } +} + +pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc, ctx: eframe::egui::Context) { + std::thread::spawn(move || { + let send = |ev: StreamEvent| { + let _ = tx.send((bot_id, ev)); + ctx.request_repaint(); + }; + let mut history = job.req.history.clone(); + let mut grants = job.grants.clone(); + // Turned on by an "always" answer mid-turn (the window saves the setting too). + let mut tools_on = job.tools_enabled; + let mut local_on = job.local_execution; + maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send); + for step in 0..MAX_STEPS { + if step > 0 { + maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send); + drain_steer(&job, &mut history, &send); + send(StreamEvent::NextStep(job.via.clone())); + } + let req = ChatRequest { history: history.clone(), ..job.req.clone() }; + let out = match providers::run_step(&req, &cancel, &send) { + Ok(o) => o, + Err(e) => { + send(StreamEvent::Error(e)); + return; + } + }; + if cancel.load(Ordering::Relaxed) { + return; + } + let msg = Message { + role: Role::Assistant, + text: out.text, + thinking: out.thinking, + via: job.via.clone(), + tool_calls: out.tool_calls, + blocks: out.blocks, + at: crate::model::now_ms(), + ..Default::default() + }; + send(StreamEvent::StepDone(msg.clone())); + let calls = msg.tool_calls.clone(); + history.push(msg); + if calls.is_empty() { + // Something typed while it answered keeps the same turn going. + if job.steer.lock().unwrap().is_empty() { + send(StreamEvent::Done); + return; + } + continue; + } + for call in calls { + if appctl::is_app_tool(&call.name) { + // App actions run on the window's side, which also asks for deletions. + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let (rtx, rrx) = channel(); + send(StreamEvent::AppAction(AppCall { call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), reply: rtx })); + let (output, ok) = loop { + if cancel.load(Ordering::Relaxed) { + return; + } + match rrx.recv_timeout(Duration::from_millis(200)) { + Ok(r) => break r, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(_) => break ("App 沒有回應".to_string(), false), + } + }; + send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // The person's own recording, shown back to the bot: nothing to ask. + if call.name == "view_demo_step" { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args); + send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); + let mut result = Message::tool_result(&call.id, &call.name, &output); + result.images.extend(img); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // Public web pages need no computer and no permission. + // …and neither does AnyBot's own browser (like Grok Bot's own browser). + if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let (output, ok, img) = web::run_full(&call.name, &call.args, &cancel); + send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); + let mut result = Message::tool_result(&call.id, &call.name, &output); + result.images.extend(img); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + let is_web = web::is_web_tool(&call.name); + let is_screen = computer::is_computer_tool(&call.name); + let is_mac = crate::macapps::is_mac_tool(&call.name); + let what = if is_mac { + format!("{} {}", crate::macapps::label(&call.name), crate::macapps::describe(&call.name, &call.args)) + } else if is_screen { + format!("{} {}", computer::label(&call.name), computer::describe(&call.name, &call.args)) + } else if is_web { + web::describe(&call.name, &call.args) + } else { + tools::describe(&call.name, &call.args) + }; + // Ask the person right away (a pop-up), and wait for the answer. + let ask_person = |kind: ApprovalKind, reason: String, grant: String| -> ApprovalAnswer { + let (atx, arx) = channel(); + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting)); + send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason, grant, reply: atx, kind })); + wait_answer(arx, &cancel) + }; + // 生活卡片: drawn by the window from the call itself. + if call.name == "show_card" { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); + let result = Message::tool_result(&call.id, &call.name, "卡片已顯示給使用者。"); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // 表情回應: an emoji on the person's latest message (no words needed). + if call.name == "react" { + let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect(); + send(StreamEvent::React(emoji.clone())); + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); + let result = Message::tool_result(&call.id, &call.name, &format!("已在使用者的訊息上按了 {emoji}。")); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // 傳檔案: put a file the bot made into the chat as a card the person can open. + if call.name == "send_file" { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string(); + let local = match target_for(&job, &call.args) { + Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), + Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)), + Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)), + Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()), + Err(e) => Err(e), + }; + let (output, ok) = match local.and_then(|p| crate::attach::store(bot_id, &p)) { + Ok(a) => { + let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes)); + send(StreamEvent::Attach(a)); + (out, true) + } + Err(e) => (e, false), + }; + send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // 檔案預覽 / Bot 做的頁面: open what the bot made on the person's screen. + if call.name == "open_for_person" { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let v = tools::args_of(&call.args); + let path = v["path"].as_str().unwrap_or("").to_string(); + let on_box = match target_for(&job, &call.args) { + Ok(t) => match t.place { + tools::Place::Box { dir } => Some(if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), + _ => None, + }, + Err(_) => None, + }; + let local = match (&on_box, target_for(&job, &call.args)) { + (Some(p), _) => crate::cloudbox::copy_out(p), + (None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)), + (None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)), + (None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)), + _ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()), + }; + let (output, ok) = match local { + Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() { + Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true), + _ => (format!("打不開 {}", p.display()), false), + }, + Ok(p) => (format!("找不到 {}", p.display()), false), + Err(e) => (e, false), + }; + send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // 打電話、傳簡訊、在 X 發文: speaks for the person, so asked every single time + // (like the original's send tools; no "always"). + if crate::phone::is_phone_tool(&call.name) { + let what = crate::phone::describe(&call.name, &call.args); + let answer = ask_person(ApprovalKind::Action, what, String::new()); + if cancel.load(Ordering::Relaxed) { + return; + } + let (output, status) = if answer == ApprovalAnswer::Reject { + ("使用者沒有允許。請不要再試,改問使用者。".to_string(), ToolStatus::Denied) + } else { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + match crate::phone::run(&call.name, &call.args) { + (t, true) => (t, ToolStatus::Done), + (t, false) => (t, ToolStatus::Failed), + } + }; + send(StreamEvent::ToolStatus(call.id.clone(), status)); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // 發布網站: put a page or folder the bot made on the web (asked once per site name). + if call.name == "publish_site" { + let v = tools::args_of(&call.args); + let path = v["path"].as_str().unwrap_or("").to_string(); + let name = v["name"].as_str().unwrap_or("").trim().to_lowercase(); + let grant = format!("publish:{name}"); + let mut allowed = grants.contains(&grant); + if !allowed { + match ask_person(ApprovalKind::Action, format!("把 {path} 發布成公開網站「{name}」(任何知道網址的人都看得到)"), grant.clone()) { + ApprovalAnswer::Once => allowed = true, + ApprovalAnswer::Always(g) => { + if !g.is_empty() && !grants.contains(&g) { + grants.push(g); + } + allowed = true; + } + ApprovalAnswer::Reject => {} + } + } + if cancel.load(Ordering::Relaxed) { + return; + } + let (output, status) = if !allowed { + ("使用者沒有允許發布。".to_string(), ToolStatus::Denied) + } else { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let local = match target_for(&job, &call.args) { + Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), + Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)), + Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)), + Ok(_) => Err("這台電腦上的檔案不能直接發布,請先複製到 Bot 的電腦。".to_string()), + Err(e) => Err(e), + }; + match local.and_then(|p| crate::remote::publish_site(&name, &p)) { + Ok(url) => (format!("已發布:{url}"), ToolStatus::Done), + Err(e) => (e, ToolStatus::Failed), + } + }; + send(StreamEvent::ToolStatus(call.id.clone(), status)); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // 保險庫: the bot sees only metadata; filling asks the person first. A login on a + // site it wasn't saved for warns about look-alike sites and can't be "always"; + // a card is asked every time (like the original's 允許一次 / 拒絕). + if call.name == crate::vault::LIST_TOOL { + let output = crate::vault::list_for_bot(&call.args); + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + if call.name == crate::vault::TOOL || call.name == crate::vault::CARD_TOOL || call.name == crate::vault::CONTACT_TOOL { + enum Plan { + Login(crate::vault::LoginPlan), + Card, + Contact, + } + let planned: Result<(Plan, String, String), String> = if call.name == crate::vault::TOOL { + crate::vault::plan_login(&call.args).map(|pl| { + let q = pl.question(); + let grant = if pl.matched { format!("login:{}", pl.host) } else { String::new() }; + (Plan::Login(pl), q, grant) + }) + } else if call.name == crate::vault::CARD_TOOL { + crate::vault::plan_card(&call.args).map(|(host, c)| (Plan::Card, format!("用保險庫的卡片({} •••• {})在 {host} 填入付款資料(Bot 看不到卡號和安全碼)", c.display_title(), c.last4), String::new())) + } else { + crate::vault::plan_contact(&call.args).map(|(host, c)| (Plan::Contact, format!("把保險庫的聯絡資訊「{}」填進 {host} 的表單", c.display_title()), format!("contact:{host}"))) + }; + let (output, status) = match planned { + Err(e) => (e, ToolStatus::Failed), + Ok((plan, question, grant)) => { + let mut allowed = !grant.is_empty() && grants.contains(&grant); + if !allowed { + match ask_person(ApprovalKind::Action, question, grant.clone()) { + ApprovalAnswer::Once => allowed = true, + ApprovalAnswer::Always(g) => { + if !g.is_empty() && !grants.contains(&g) { + grants.push(g); + } + allowed = true; + } + ApprovalAnswer::Reject => {} + } + } + if cancel.load(Ordering::Relaxed) { + return; + } + if !allowed { + ("使用者沒有允許使用保險庫的這個項目。請改問使用者。".to_string(), ToolStatus::Denied) + } else { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let r = match plan { + Plan::Login(pl) => crate::vault::fill_login(&pl, tools::args_of(&call.args)["submit"].as_bool().unwrap_or(false)), + Plan::Card => crate::vault::fill_card(&call.args), + Plan::Contact => crate::vault::fill_contact(&call.args), + }; + match r { + Ok(t) => (t, ToolStatus::Done), + Err(e) => (e, ToolStatus::Failed), + } + } + } + }; + send(StreamEvent::ToolStatus(call.id.clone(), status)); + let result = Message::tool_result(&call.id, &call.name, &output); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // MCP connectors: asked once per server (or always allowed), like the original's + // MCP allow-list; direct mode doesn't ask. + if crate::mcp::is_mcp_tool(&call.name) { + let (server, tool) = crate::mcp::resolve(&call.name).unwrap_or_default(); + let grant = format!("mcp:{server}"); + let args_short: String = call.args.chars().take(160).collect(); + let mut allowed = job.direct.load(Ordering::Relaxed) || grants.contains(&grant); + if !allowed { + match ask_person(ApprovalKind::Action, format!("使用連接器「{server}」的工具:{tool} {args_short}"), grant.clone()) { + ApprovalAnswer::Once => allowed = true, + ApprovalAnswer::Always(g) => { + if !g.is_empty() && !grants.contains(&g) { + grants.push(g); + } + allowed = true; + } + ApprovalAnswer::Reject => {} + } + } + if cancel.load(Ordering::Relaxed) { + return; + } + let (output, status, img) = if allowed { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let (o, ok, img) = crate::mcp::call(&call.name, &call.args); + (o, if ok { ToolStatus::Done } else { ToolStatus::Failed }, img) + } else { + ("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None) + }; + send(StreamEvent::ToolStatus(call.id.clone(), status)); + let mut result = Message::tool_result(&call.id, &call.name, &output); + result.images.extend(img); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } + // The browser tools use the person's own browser, so always this Mac. + // …and so does screen control: it's the person's own screen. + let target = if is_screen { + screen_target(&job, &call.args) + } else if is_mac || web::uses_this_computer(&call.name, &call.args) { + job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string()) + } else { + target_for(&job, &call.args) + }; + let on_box = matches!(target.as_ref().map(|t| &t.place), Ok(tools::Place::Box { .. })); + let on_device = match target.as_ref().map(|t| &t.place) { + Ok(tools::Place::Device { host }) => Some(host.clone()), + _ => None, + }; + // Permission to use computers at all, and this Mac in particular, is asked for + // when it's needed instead of the step failing quietly. + let mut gate: Result<(), String> = Ok(()); + // 直接操作: like Grok Bot after its one-time setup ("讓 Grok Bot 在你的電腦上開啟檔案和 + // 執行任務。自動審核仍會先檢查所有操作", ENHi8c), nothing is asked except what the + // safety checks and the person's "一律詢問" rules stop. + let direct = job.direct.load(Ordering::Relaxed); + if direct { + tools_on = true; + local_on = true; + } + if let Ok(t) = &target { + if !tools_on { + let reason = format!("讓 {} 使用電腦工具:{}\n(在 {} 上)", job.via_name, what, t.name); + match ask_person(ApprovalKind::EnableTools, reason, "tools:on".into()) { + ApprovalAnswer::Once => {} + ApprovalAnswer::Always(_) => tools_on = true, + ApprovalAnswer::Reject => gate = Err("使用者沒有允許使用電腦工具。請改用文字回答,或問使用者想怎麼做。".into()), + } + } + // An admin lock (policy) refuses outright instead of asking. + if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked { + gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, crate::model::BOT_COMPUTER_NAME)); + } + if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on { + let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name); + match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) { + ApprovalAnswer::Once => {} + ApprovalAnswer::Always(_) => local_on = true, + ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)), + } + } + } + if cancel.load(Ordering::Relaxed) { + return; + } + let decision = match (&target, &gate) { + // Looking at the screen and using the mouse/keyboard each ask once (or always). + // The Mac's own apps: reading asks once per app, writing once per app (or each + // time), sending every single time (the original never keeps a grant for it). + (Ok(_), Ok(())) if is_mac => { + let app = crate::macapps::app_of(&call.name); + if crate::macapps::is_send(&call.name) && call.name == crate::macapps::SEND_MESSAGE && grants.iter().any(|g| g == "messages:no-ask") { + Decision::Allow + } else if crate::macapps::is_send(&call.name) { + Decision::Ask { reason: format!("用你的「{app}」{what}"), grant: String::new() } + } else if crate::macapps::is_write(&call.name) { + let grant = format!("macapp-write:{app}"); + if grants.contains(&grant) { Decision::Allow } else { Decision::Ask { reason: format!("讓 {} 修改你的「{app}」:{what}", job.via_name), grant } } + } else { + let grant = format!("macapp:{app}"); + if grants.contains(&grant) || grants.contains(&format!("macapp-write:{app}")) { Decision::Allow } else { Decision::Ask { reason: format!("讓 {} 讀取你的「{app}」:{what}", job.via_name), grant } } + } + } + // Its own desktop: nothing to ask, like the original's box. + (Ok(_), Ok(())) if is_screen && on_box => Decision::Allow, + (Ok(_), Ok(())) if is_screen && call.name == "computer_use_check_permissions" => Decision::Allow, + // 裝置連結: another of the person's computers; seeing and operating it ask once each. + (Ok(t), Ok(())) if is_screen && on_device.is_some() => { + let grant = if call.name == "computer_use_screenshot" { format!("device-see:{}", t.name) } else { format!("device-control:{}", t.name) }; + if grants.contains(&grant) { + Decision::Allow + } else { + Decision::Ask { reason: format!("{}「{}」的螢幕:{what}", if call.name == "computer_use_screenshot" { "看" } else { "操作" }, t.name), grant } + } + } + (Ok(_), Ok(())) if is_screen => { + if call.name == "computer_use_screenshot" { + if grants.iter().any(|g| g == "computer:see") { + Decision::Allow + } else { + Decision::Ask { reason: format!("看你的螢幕畫面:{what}"), grant: "computer:see".into() } + } + } else { + // Per app, like Grok Bot: 「Grok Bot 可以看到 {app},並在其中點擊和輸入」. + let app = computer::target_app(&call.name, &call.args).unwrap_or_else(|| "目前的 App".into()); + let grant = format!("app:{app}"); + if grants.iter().any(|g| g == "computer:control" || *g == grant) { + Decision::Allow + } else { + Decision::Ask { reason: format!("讓 {} 看到「{app}」,並在其中點擊和輸入:{what}", job.via_name), grant } + } + } + } + // Opening a link is visible and harmless; reading a logged-in tab asks first. + (Ok(_), Ok(())) if is_web => { + if call.name == "browser_read" && web::read_needs_permission(&call.args) && !grants.iter().any(|g| g == "browser:read") { + Decision::Ask { reason: "讀取你瀏覽器目前分頁的內容(包含已登入的頁面):目前的分頁".into(), grant: "browser:read".into() } + } else { + Decision::Allow + } + } + (Ok(t), Ok(())) => tools::decide_on(&call.name, &call.args, t, &grants), + (Err(e), _) => Decision::Deny(e.clone()), + // The person said no to the pop-up: nothing more to ask. + (_, Err(_)) => Decision::Allow, + }; + // 自動審核: when the rules say yes, continue without asking (JOecNC); + // when an "always ask" rule matches, stop and ask (Pca/iV). + let mut review_note = String::new(); + let direct = job.direct.load(Ordering::Relaxed); + let decision = match (decision, &job.review) { + // Direct mode: go ahead, unless an "always ask" rule matches. + (Decision::Ask { reason, grant }, review) if direct => { + let verdict = match review { + Some(rv) if !rv.ask.is_empty() => review_action(&job, &Review { allow: vec![], ask: rv.ask.clone() }, &reason, &cancel), + _ => ReviewVerdict::NoMatch, + }; + match verdict { + ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() }, + _ => { + let _ = grant; + review_note = "(直接操作模式:已自動允許)\n".into(); + Decision::Allow + } + } + } + (Decision::Ask { reason, grant }, Some(rv)) => match review_action(&job, rv, &reason, &cancel) { + ReviewVerdict::Allow(rule) => { + review_note = format!("(自動審核:符合「{rule}」,直接允許)\n"); + Decision::Allow + } + ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() }, + ReviewVerdict::NoMatch => Decision::Ask { reason, grant }, + }, + (d, _) => d, + }; + // Sending mail or messages is approved one by one, even in direct mode. + let decision = if is_mac && crate::macapps::is_send(&call.name) && gate.is_ok() && target.is_ok() { + review_note.clear(); + Decision::Ask { reason: format!("用你的「{}」{what}(每一封都會先問你)", crate::macapps::app_of(&call.name)), grant: String::new() } + } else { + decision + }; + let approved = match decision { + _ if gate.is_err() => Err((gate.clone().unwrap_err(), ToolStatus::Denied)), + Decision::Allow => Ok(()), + Decision::Deny(r) => Err((format!("被安全規則擋下:{r}"), ToolStatus::Denied)), + Decision::Ask { reason, grant } => { + let machine = target.as_ref().map(|t| t.name.clone()).unwrap_or_default(); + let reason = format!("{reason}\n(在 {machine} 上)"); + match ask_person(ApprovalKind::Action, reason, grant) { + ApprovalAnswer::Once => Ok(()), + ApprovalAnswer::Always(g) => { + if !g.is_empty() && !grants.contains(&g) { + grants.push(g); + } + Ok(()) + } + ApprovalAnswer::Reject => Err(("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".into(), ToolStatus::Denied)), + } + } + }; + if cancel.load(Ordering::Relaxed) { + return; + } + let mut image = None; + let (output, status) = match (approved, &target) { + (Ok(()), Ok(t)) => { + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() }; + let (out, ok) = if is_mac { + crate::macapps::run(&call.name, &call.args) + } else if let (true, Some(host)) = (is_screen, on_device.as_ref()) { + match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) { + Ok(v) => { + image = v["image"].as_str().map(String::from); + (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false)) + } + Err(e) => (e, false), + } + } else if is_screen && on_box { + match crate::cloudbox::computer_run(&call.name, &tools::args_of(&call.args)) { + Ok((out, img)) => { + image = img; + (out, true) + } + Err(e) => (e, false), + } + } else if is_screen { + let (out, ok, img) = computer::run_by(&call.name, &call.args, &job.via_name); + image = img; + (out, ok) + } else if is_web { + web::run(&call.name, &call.args, &cancel) + } else { + tools::run_on(&call.name, &call.args, t, &cancel, &ids) + }; + (format!("{review_note}[{}] {out}", t.name), if ok { ToolStatus::Done } else { ToolStatus::Failed }) + } + (Ok(()), Err(e)) => (e.clone(), ToolStatus::Failed), + (Err(x), _) => x, + }; + send(StreamEvent::ToolStatus(call.id.clone(), status)); + let mut result = Message::tool_result(&call.id, &call.name, &output); + result.images.extend(image); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + if cancel.load(Ordering::Relaxed) { + return; + } + } + } + send(StreamEvent::NextStep(job.via.clone())); + send(StreamEvent::StepDone(Message::assistant(&format!("(這次已經用了 {MAX_STEPS} 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)"), &job.via))); + send(StreamEvent::Done); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ToolCall; + use crate::providers::ProviderKind; + + #[test] + fn cut_keeps_tool_pairs_and_recent_messages() { + let mut h = vec![]; + for i in 0..6 { + h.push(Message::user(&format!("q{i}"))); + let mut a = Message::assistant("", ""); + a.tool_calls = vec![ToolCall { id: format!("c{i}"), name: "list_dir".into(), args: "{}".into(), status: ToolStatus::Done }]; + h.push(a); + h.push(Message::tool_result(&format!("c{i}"), "list_dir", "x")); + } + let cut = compaction_cut(&h).unwrap(); + assert_eq!(h[cut].role, Role::User); + assert!(h.len() - cut >= KEEP_RECENT_MESSAGES); + assert!(compaction_cut(&h[..5]).is_none()); + } + + #[test] + fn tokens_count_chinese_heavier() { + assert_eq!(estimate_tokens("abcdefgh"), 2); + assert_eq!(estimate_tokens("你好"), 2); + } + + /// Full loop against a fake provider: the model asks to list a folder, gets the result, + /// then answers in words. + #[test] + fn loop_runs_tool_then_answers() { + use std::io::{Read, Write}; + use std::net::TcpListener; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let replies = [ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{}\"}}]}}]}\n\ndata: [DONE]\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"裡面有 a.txt\"}}]}\n\ndata: [DONE]\n\n", + ]; + for body in replies { + let (mut s, _) = l.accept().unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = t.find("\r\n\r\n") { + let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + let _ = s.write_all(body.as_bytes()); + } + }); + let w = std::env::temp_dir().join(format!("anybot-loop-{}", std::process::id())); + std::fs::create_dir_all(&w).unwrap(); + std::fs::write(w.join("a.txt"), "hi").unwrap(); + let job = Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() }, + computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }], + default_computer: crate::model::THIS_COMPUTER_NAME.into(), + local_execution: true, + grants: vec![], + context_tokens: DEFAULT_CONTEXT_TOKENS, + via: "test".into(), + via_name: "Bot".into(), + tools_enabled: true, + direct: Arc::new(AtomicBool::new(false)), + local_locked: false, + steer: Arc::new(Mutex::new(vec![])), + review: None, + demos: vec![], + }; + let (tx, rx) = channel(); + let cancel = Arc::new(AtomicBool::new(false)); + // Run the loop body on this thread's terms: spawn needs an egui context, so drive it directly. + let ctx = eframe::egui::Context::default(); + spawn(job, 1, tx, cancel, ctx); + let mut texts = vec![]; + let mut results = vec![]; + loop { + match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") { + (_, StreamEvent::ToolResult(m)) => results.push(m.text), + (_, StreamEvent::StepDone(m)) => texts.push(m.text), + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert!(results[0].contains("a.txt"), "{results:?}"); + assert_eq!(texts.last().unwrap(), "裡面有 a.txt"); + } + + /// A message sent while the bot works steers the same turn: the model sees it at the + /// next step and the turn only ends (one Done) after that. + #[test] + fn mid_turn_message_steers_the_same_turn() { + use std::io::{Read, Write}; + use std::net::TcpListener; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(String::new())); + let seen2 = seen.clone(); + std::thread::spawn(move || { + for body in ["data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\ndata: [DONE]\n\n", "data: {\"choices\":[{\"delta\":{\"content\":\"收到補充\"}}]}\n\ndata: [DONE]\n\n"] { + let (mut s, _) = l.accept().unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = t.find("\r\n\r\n") { + let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + *seen2.lock().unwrap() = String::from_utf8_lossy(&req).to_string(); + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + let _ = s.write_all(body.as_bytes()); + } + }); + let steer = Arc::new(Mutex::new(vec!["順便用英文".to_string()])); + let job = Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() }, + computers: vec![], + default_computer: crate::model::BOT_COMPUTER_NAME.into(), + local_execution: false, + grants: vec![], + context_tokens: DEFAULT_CONTEXT_TOKENS, + via: "test".into(), + via_name: "Bot".into(), + tools_enabled: true, + direct: Arc::new(AtomicBool::new(false)), + local_locked: false, + steer, + review: None, + demos: vec![], + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let (mut steered, mut done, mut texts) = (0, 0, vec![]); + loop { + match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") { + (_, StreamEvent::Steered(m)) => { + assert!(m.steer); + steered += 1; + } + (_, StreamEvent::StepDone(m)) => texts.push(m.text), + (_, StreamEvent::Done) => { + done += 1; + break; + } + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert_eq!((steered, done), (1, 1)); + assert_eq!(texts, vec!["好", "收到補充"]); + assert!(seen.lock().unwrap().contains("順便用英文")); + } + + /// Tools off and this Mac not allowed: the step pops up two requests (use tools, use this + /// computer) instead of failing, and runs once both are allowed. + #[test] + fn missing_permission_pops_up_and_continues() { + use std::io::{Read, Write}; + use std::net::TcpListener; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let replies = [ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{\\\"computer\\\":\\\"你的電腦\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"看完了\"}}]}\n\ndata: [DONE]\n\n", + ]; + for body in replies { + let (mut s, _) = l.accept().unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = t.find("\r\n\r\n") { + let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + let _ = s.write_all(body.as_bytes()); + } + }); + let w = std::env::temp_dir().join(format!("anybot-perm-{}", std::process::id())); + std::fs::create_dir_all(&w).unwrap(); + std::fs::write(w.join("note.txt"), "hi").unwrap(); + let job = Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("看看我的電腦")], tools: tools::specs(), session: String::new() }, + computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }], + default_computer: crate::model::THIS_COMPUTER_NAME.into(), + local_execution: false, + tools_enabled: false, + direct: Arc::new(AtomicBool::new(false)), + local_locked: false, + grants: vec![], + context_tokens: DEFAULT_CONTEXT_TOKENS, + via: "test".into(), + via_name: "Bot".into(), + steer: Arc::new(Mutex::new(vec![])), + review: None, + demos: vec![], + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let mut kinds = vec![]; + let mut result = String::new(); + loop { + match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") { + (_, StreamEvent::NeedApproval(a)) => { + kinds.push(a.kind); + let _ = a.reply.send(ApprovalAnswer::Once); + } + (_, StreamEvent::ToolResult(m)) => result = m.text, + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert_eq!(kinds, vec![ApprovalKind::EnableTools, ApprovalKind::EnableThisComputer]); + assert!(result.contains("note.txt"), "{result}"); + } + + /// 直接操作: tools off, this Mac off, a command that changes files — and still no question. + /// Dangerous commands stay blocked. + #[test] + fn direct_mode_asks_nothing_but_still_blocks_danger() { + use std::io::{Read, Write}; + use std::net::TcpListener; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let replies = [ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":\\\"echo hi > made.txt\\\",\\\"computer\\\":\\\"你的電腦\\\"}\"}},{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":\\\"sudo ls\\\",\\\"computer\\\":\\\"你的電腦\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"好了\"}}]}\n\ndata: [DONE]\n\n", + ]; + for body in replies { + let (mut s, _) = l.accept().unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = t.find("\r\n\r\n") { + let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + let _ = s.write_all(body.as_bytes()); + } + }); + let w = std::env::temp_dir().join(format!("anybot-direct-{}", std::process::id())); + std::fs::create_dir_all(&w).unwrap(); + let job = Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() }, + computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }], + default_computer: crate::model::THIS_COMPUTER_NAME.into(), + local_execution: false, + tools_enabled: false, + direct: Arc::new(AtomicBool::new(true)), + local_locked: false, + grants: vec![], + context_tokens: DEFAULT_CONTEXT_TOKENS, + via: "test".into(), + via_name: "Bot".into(), + steer: Arc::new(Mutex::new(vec![])), + review: None, + demos: vec![], + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let mut asked = 0; + let mut results = vec![]; + loop { + match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") { + (_, StreamEvent::NeedApproval(a)) => { + asked += 1; + let _ = a.reply.send(ApprovalAnswer::Reject); + } + (_, StreamEvent::ToolResult(m)) => results.push(m.text), + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert_eq!(asked, 0, "{results:?}"); + assert!(w.join("made.txt").exists()); + assert!(results[1].contains("一律不允許"), "{results:?}"); + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..2cec2ec --- /dev/null +++ b/src/app.rs @@ -0,0 +1,3844 @@ +// The window: bot list on the left, the chosen bot's chat on the right, +// plus the bot editor and the settings (look and AI providers). + +use chrono::Datelike; +use eframe::egui::{self, vec2, Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Stroke}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use crate::agent; +use crate::appctl; +use crate::avatar; +use crate::notify; +use crate::policy; +use crate::web; +use crate::model::{Accent, Bot, Message, Role, State, Theme, ToolStatus, TurnOutcome, TurnSettlement}; +use crate::providers::{self, AppCall, Approval, ApprovalAnswer, ApprovalKind, ChatRequest, ProviderKind, StreamEvent, ALL_PROVIDERS}; +use crate::tools; +use crate::i18n::t; +use crate::theme::{Palette, DARK, LIGHT}; + +mod attach_ui; +mod channels_ui; +mod computers_ui; +mod chat_ui; +mod files_ui; +mod panel_ui; +mod remote_ui; +mod sidebar_ui; +mod menu_ui; +mod vault_ui; +mod markdown_ui; + +// Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025), +// clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523): +// folds to an 88 px rail below 210 px, unfolds from the rail only past 220 px. +pub const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0; +pub const SIDEBAR_MIN_WIDTH: f32 = 240.0; +pub const SIDEBAR_MAX_WIDTH: f32 = 400.0; +pub const SIDEBAR_RAIL_WIDTH: f32 = 88.0; +pub const SIDEBAR_COLLAPSE_BELOW: f32 = 210.0; +pub const SIDEBAR_EXPAND_FROM: f32 = 220.0; +/// `--sand-chat-min-width:424px` (index CSS, class sand-1fiktjy). +pub const CHAT_MIN_WIDTH: f32 = 424.0; +/// Transcript text: `--sand-transcript-prose-line-height:22px` pairs with `--sand-font-size-lg:15px`. +/// The original's type scale (renderer index.css `--sand-font-size-*`): xxs 11, xs 12, sm 13, +/// base 14, lg 15, xl 17, xxl 22, xxxl 26 px, with `--sand-line-height-*` 16/16/18/20/22/24/28/32. +pub const FONT_XS: f32 = 12.0; +pub const FONT_SM: f32 = 13.0; +pub const FONT_BASE: f32 = 14.0; +pub const FONT_LG: f32 = 15.0; +pub const FONT_XL: f32 = 17.0; +pub const LINE_HEIGHT_SM: f32 = 18.0; +pub const LINE_HEIGHT_LG: f32 = 22.0; +pub const LINE_HEIGHT_XL: f32 = 24.0; +/// Chat text uses the lg step (15 px on 22 px lines). +pub const TRANSCRIPT_FONT: f32 = FONT_LG; +pub const TRANSCRIPT_LINE_HEIGHT: f32 = LINE_HEIGHT_LG; +/// `--sand-transcript-prose-bubble-radius:18px`, one-line bubble `--sand-transcript-prose-one-line-height:36px`. +pub const BUBBLE_RADIUS: u8 = 18; +pub const BUBBLE_ONE_LINE_HEIGHT: f32 = 36.0; +pub const TRANSCRIPT_MAX_WIDTH: f32 = 720.0; +/// Space kept above and below Settings / Bot 設定 so they never run off the window (estimated). +const WINDOW_SCREEN_MARGIN: f32 = 80.0; +/// Provider names in Settings fit on one line (「自訂(OpenAI 相容)」) (estimated). +const PROVIDER_LIST_WIDTH: f32 = 180.0; +/// Settings: dialog width, its left-hand page list, and a steady height so tabs don't jump. +const SETTINGS_WIDTH: f32 = 760.0; +const SETTINGS_NAV_WIDTH: f32 = 150.0; +const SETTINGS_HEIGHT: f32 = 600.0; +/// Bot 設定 dialog width. +const EDITOR_WIDTH: f32 = 540.0; +/// The label column in form rows ("預設電腦", "模型可讀長度"…). +const FORM_LABEL_WIDTH: f32 = 96.0; +/// Tool steps under a reply start a little in from the bubble's edge. +pub const SCREENSHOT_FRAMES: u32 = 120; + +#[derive(PartialEq, Clone, Copy)] +/// Like the original's seven panes (一般、整合、電腦、權限、保險庫、用量與帳單、更新); AnyBot has +/// 模型 (its own AI keys) where the original has 用量與帳單, and 帳戶 opens from 一般. +enum SettingsTab { + General, + Providers, + Channels, + Computers, + Review, + Vault, + Updates, + Remote, +} + +pub struct App { + state: State, + pal: Palette, + demo: bool, + screenshot: Option, + frames: u32, + shot_requested: bool, + shot: Arc>>>, + drafts: HashMap, + search: String, + streams: HashMap, + /// A tool waiting for the person's yes/no, per bot. + approvals: HashMap, + /// App actions (e.g. deleting a routine) waiting for that yes/no. + app_pending: HashMap, + /// Messages that arrived as a turn ended: they start the next turn. + pending_turns: Vec<(u64, Vec)>, + last_tick: Instant, + /// The routines list for this bot is open. + show_routines: Option, + new_remote: (String, String), + new_browser_profile: String, + browser_note: std::sync::Arc>, + confirm_remove_profile: Option, + confirm_delete_routine: Option<(u64, u64)>, + /// A short message shown above the message box (e.g. after a restart). + notice: Option, + /// Requests whose pop-up the person put off ("稍後再決定"); the chat card stays. + popup_hidden: std::collections::HashSet, + /// "全部允許,之後直接操作" was clicked in a request. + want_direct: std::cell::Cell, + /// Admin locks (policy.rs), read at start and when Settings opens. + managed: policy::Managed, + settings_was_open: bool, + /// When the program file this copy started from was built (its version). + exe_built: Option, + /// A newer build has replaced the program file since this copy started. + newer_build: Option, + last_build_check: Instant, + /// Next time to consider switching to a newer build by itself (30 s after start, then hourly). + next_update_apply: Instant, + window_focused: bool, + idle_secs: Option, + last_presence: Instant, + relaunching: bool, + /// The sidebar's "new version" banner was clicked. + relaunch_requested: bool, + new_allow_rule: String, + new_ask_rule: String, + side_drag: Option, + tx: Sender<(u64, StreamEvent)>, + rx: Receiver<(u64, StreamEvent)>, + show_settings: bool, + settings_tab: SettingsTab, + settings_provider: ProviderKind, + edit_bot: Option<(Bot, bool)>, + confirm_delete: Option, + model_fetch: HashMap, String>>>, + fetch_status: HashMap, + dirty: bool, + last_save: Instant, + stick_bottom: bool, + /// Bot 的電腦 viewer, rebuild dialog and the computers page. + cu: computers_ui::ComputerUi, + vault: vault_ui::VaultUi, + md: markdown_ui::MdState, + /// Files and pictures waiting to go with the next message. + att: attach_ui::AttachUi, + /// 訊息管道 (Slack / Discord / Telegram). + ch: channels_ui::ChannelUi, + /// A shared bot file being picked. + share_rx: Option>>, + /// 群組聊天: members still to answer in this round, and groups whose next member should start. + group_queue: HashMap>, + group_next: Vec, + /// 引用: the message being quoted in the next message, per bot. + quoting: HashMap, + /// Index of the message being drawn (for reactions). + msg_index: usize, + /// 手機與帳號. + rm: remote_ui::RemoteUi, + /// Running as `anybot --serve` (no window; the phone is the screen). + headless: bool, + /// 右側面板 and its tab (None = closed). + panel: Option, + /// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in). + popped: HashMap<(u64, usize, String), Instant>, + appeared: HashMap<(u64, usize), Instant>, + /// Messages whose steps / thinking are unfolded (⋯ › 顯示步驟). + steps_open: std::collections::HashSet<(u64, usize)>, + /// The file being previewed. + file_view: Option, + started_ms: i64, + /// The message whose hover menu (表情 / ⋯) is open: its buttons stay while it is. + menu_for: Option<(u64, usize)>, + /// Each face's motion (gaze, blink, pokes). + faces: HashMap, + search_open: bool, + confirm_section_delete: Option, + /// A group being renamed: (old name, what is typed). + renaming: Option<(String, String)>, + /// The group whose ⋯ menu is open (its button stays while it is). + group_menu_for: Option, + /// When the launch greeting started. + splash_at: Option, +} + +impl App { + pub fn new(ctx: &egui::Context, screenshot: Option) -> Self { + let demo = screenshot.is_some(); + let state = if demo { State::demo() } else { State::load() }; + let (tx, rx) = channel(); + let mut app = App { + state, + pal: LIGHT, + demo, + screenshot, + frames: 0, + shot_requested: false, + shot: Arc::new(Mutex::new(None)), + drafts: HashMap::new(), + search: String::new(), + streams: HashMap::new(), + approvals: HashMap::new(), + app_pending: HashMap::new(), + pending_turns: vec![], + last_tick: Instant::now(), + show_routines: None, + new_remote: Default::default(), + new_browser_profile: String::new(), + browser_note: Default::default(), + confirm_remove_profile: None, + confirm_delete_routine: None, + notice: None, + popup_hidden: Default::default(), + want_direct: Default::default(), + managed: if demo { policy::Managed::default() } else { policy::read() }, + settings_was_open: false, + exe_built: exe_modified(), + newer_build: None, + last_build_check: Instant::now(), + next_update_apply: Instant::now() + std::time::Duration::from_secs(notify::UPDATE_FIRST_CHECK_SECS), + window_focused: true, + idle_secs: None, + last_presence: Instant::now(), + relaunching: false, + relaunch_requested: false, + new_allow_rule: String::new(), + new_ask_rule: String::new(), + side_drag: None, + tx, + rx, + show_settings: false, + settings_tab: SettingsTab::General, + settings_provider: ProviderKind::Xai, + edit_bot: None, + confirm_delete: None, + model_fetch: HashMap::new(), + fetch_status: HashMap::new(), + dirty: false, + last_save: Instant::now(), + stick_bottom: true, + cu: Default::default(), + vault: Default::default(), + md: Default::default(), + att: Default::default(), + ch: Default::default(), + share_rx: None, + group_queue: HashMap::new(), + group_next: vec![], + quoting: HashMap::new(), + msg_index: 0, + rm: Default::default(), + headless: false, + panel: None, + popped: HashMap::new(), + appeared: HashMap::new(), + steps_open: Default::default(), + file_view: None, + started_ms: crate::model::now_ms(), + menu_for: None, + faces: HashMap::new(), + search_open: false, + confirm_section_delete: None, + renaming: None, + group_menu_for: None, + splash_at: None, + }; + crate::i18n::set(&app.state.language); + app.computers_start(); + if demo { + // The computer helper's pending step, so the picture shows the approval card. + let (reply, _) = channel(); + // This Mac isn't allowed yet, so the bot's step pops up a request. + app.state.local_execution = false; + app.approvals.insert(5, Approval { + call_id: "c2".into(), + tool: "shell".into(), + reason: "在你的電腦上執行任務,而不只是在它自己的電腦上:mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/\n(在 你的電腦 上)".into(), + grant: "local:on".into(), + reply, + kind: ApprovalKind::EnableThisComputer, + }); + app.streams.insert(5, Live { + cancel: Arc::new(AtomicBool::new(false)), + started: Instant::now(), + last: Instant::now(), + produced: true, + status: None, + paused: true, + steer: Arc::new(Mutex::new(vec!["2026-06 的照片先不要動".into()])), + direct: Arc::new(AtomicBool::new(false)), + turn_id: "demo".into(), + }); + // Optional view for checking the dialogs in a picture. + match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { + Ok("chat") => app.state.selected = 1, + Ok("settings") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Providers; + app.settings_provider = ProviderKind::Anthropic; + } + Ok("editor") => { + app.edit_bot = app.state.bot(5).cloned().map(|mut b| { + b.allow_rules = vec!["edit_file".into(), "shell:npm install".into()]; + (b, false) + }) + } + Ok("menu") => app.state.selected = 4, + Ok("rail") => app.state.sidebar_collapsed = true, + Ok("routines") => { + app.state.selected = 2; + app.show_routines = Some(2); + } + Ok("viewer") => { + crate::cloudbox::start_background(); + let _ = crate::cloudbox::ensure_running(); + app.cu.demo_live = true; + app.cu.viewer_open = true; + app.cu.fit = true; + } + Ok("attach") => { + app.state.selected = 1; + let dir = std::env::temp_dir().join("anybot-shot-attach"); + let _ = std::fs::create_dir_all(&dir); + let img = dir.join("行程截圖.png"); + let mut pic = image::RgbImage::from_pixel(640, 400, image::Rgb([0xe8, 0xf0, 0xfb])); + for x in 40..600 { for y in 60..90 { pic.put_pixel(x, y, image::Rgb([0x16, 0x80, 0xf3])); } } + let _ = pic.save(&img); + let doc = dir.join("台南景點清單.md"); + let _ = std::fs::write(&doc, "# 台南\n- 赤崁樓\n- 安平古堡\n"); + for f in [&img, &doc] { + if let Ok(a) = crate::attach::store(1, f) { + app.att.pending.entry(1).or_default().push(a); + } + } + } + Ok("channels") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Channels; + app.state.channels.push(crate::channels::Channel { id: 1, kind: crate::channels::Kind::Slack, name: "客服群".into(), bot_id: 2, token: "xoxb-demo".into(), target: "C0123ABCD".into(), enabled: true, client_id: String::new(), tenant: String::new() }); + } + Ok("group") => { + let id = app.state.next_id; + app.state.next_id += 1; + let mut g = app.state.bots[0].clone(); + g.id = id; + g.name = "旅遊小組".into(); + g.description = "研究助理、寫作夥伴一起回答".into(); + g.shape = "cloud".into(); + g.members = vec![2, 3]; + let mut m1 = Message::assistant("台南兩天:第一天安平、第二天府城老街。", "研究助理 · Claude · claude-opus-5-5"); + m1.author = 2; + m1.reactions = vec!["👍".into()]; + m1.tool_calls.push(crate::model::ToolCall { id: "card1".into(), name: "show_card".into(), args: serde_json::json!({"kind": "weather", "title": "台南 · 下週六", "subtitle": "中央氣象署預報", "rows": [{"label": "天氣", "value": "晴時多雲"}, {"label": "溫度", "value": "22–29°C"}, {"label": "降雨機率", "value": "10%"}], "link": "https://www.cwa.gov.tw"}).to_string(), status: crate::model::ToolStatus::Done }); + let mut m2 = Message::assistant("補充一個寫遊記的角度:用「老屋裡的光」當主題,照片挑早上拍的。", "寫作夥伴 · OpenAI · gpt-5"); + m2.author = 3; + g.messages = vec![Message::user("下個月去台南,大家給點建議"), m1, m2]; + app.quoting.insert(id, "台南兩天:第一天安平、第二天府城老街。".into()); + app.state.bots.push(g); + app.state.selected = id; + } + Ok("remote") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Remote; + app.state.remote.password = "x$y".into(); + app.state.remote.lan = true; + app.state.remote.relay_url = "https://anybot.example.com".into(); + app.state.remote.relay_user = "daniel".into(); + app.state.remote.host_token = "demo".into(); + app.state.remote.host_name = "工作室 Mac".into(); + app.state.remote.last_backup = chrono::Utc::now().timestamp() - 3_600; + app.state.remote.device_link = true; + app.state.remote.devices = vec![crate::remote::Device { name: "iPhone".into(), token_hash: String::new(), created: chrono::Utc::now().timestamp() - 86_400, last_seen: chrono::Utc::now().timestamp() - 600 }]; + } + Ok("working") => { + // The computer helper mid-task with no pending request: the ring and phrase. + app.state.selected = 5; + app.approvals.remove(&5); + if let Some(b) = app.state.bot_mut(5) { + if let Some(m) = b.messages.iter_mut().rev().find(|m| m.role == Role::Assistant) { + for c in m.tool_calls.iter_mut() { + c.name = "read_file".into(); + c.status = ToolStatus::Running; + } + } + } + } + Ok("files") | Ok("fileview") => { + for b in app.state.bots.iter_mut() { + if b.id == 2 || b.id == 3 { + b.folder = "工作".into(); + } + } + let dir = std::env::temp_dir().join("anybot-shot-files"); + let _ = std::fs::create_dir_all(&dir); + let csv = dir.join("orders_20261102_packages.csv"); + let mut rows = String::from("訂單號,訂單時間,店鋪,商品名稱,數量\n"); + for i in 0..22 { + rows.push_str(&format!("33165343590{:04},2026-10-29 20:13,{},特斯拉 Model Y 配件 {},{}\n", 5101 + i, ["3w旗艦店", "Tesla特斯拉官方", "特友之家旗艦店"][i % 3], i + 1, 1 + i % 3)); + } + let _ = std::fs::write(&csv, rows); + let md = dir.join("台南兩天一夜.md"); + let _ = std::fs::write(&md, "# 台南兩天一夜\n\n## 第一天\n- 赤崁樓\n- 國華街小吃\n\n## 第二天\n- 安平古堡\n- **四草綠色隧道**\n"); + let html = dir.join("報價頁.html"); + let _ = std::fs::write(&html, "

報價

總計 NT$ 12,800

"); + let png = dir.join("行程地圖.png"); + let mut pic = image::RgbImage::from_pixel(800, 500, image::Rgb([0xe8, 0xf0, 0xfb])); + for x in 100..700 { for y in 230..270 { pic.put_pixel(x, y, image::Rgb([0x16, 0x80, 0xf3])); } } + let _ = pic.save(&png); + let now = crate::model::now_ms(); + let mut atts = vec![]; + for f in [&csv, &md, &html, &png] { + if let Ok(a) = crate::attach::store(1, f) { + atts.push(a); + } + } + let mut u = Message::user("我有多了兩項訂單,請幫我更新,順便把行程整理成文件給我"); + u.at = now - 26 * 3_600_000; + u.reactions = vec!["❤️".into()]; + let mut a1 = Message::assistant("好,我去把新的兩張訂單抓下來,補進表格。", ""); + a1.at = u.at + 4_000; + let mut a2 = Message::assistant("更新好了,兩張 10/31 的新訂單已經補在最上面。\n\n- 特友之家的車門垃圾桶(2 個裝)買 1 組\n- 特友黑洛第二代油門剎車踏板買 2 個\n\n行程和報價也整理好了:", ""); + a2.at = u.at + 60_000; + a2.attachments = atts; + a2.reactions = vec!["👍".into()]; + let mut u2 = Message::user("太好了,謝謝!"); + u2.at = now - 60_000; + let mut b = app.state.bots[0].clone(); + b.messages = vec![u, a1, a2, u2]; + app.state.bots[0] = b; + app.state.selected = app.state.bots[0].id; + if std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("fileview") { + // ANYBOT_SHOT_FILE: preview that file instead of the sample spreadsheet. + match std::env::var("ANYBOT_SHOT_FILE") { + Ok(f) => { + let name = std::path::Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.open_file(&name, &f); + } + Err(_) => app.open_file("orders_20261102_packages.csv", &csv.display().to_string()), + } + } + } + Ok("panel") => { + // The real box, so the 電腦 card shows its screen. + crate::cloudbox::start_background(); + let _ = crate::cloudbox::ensure_running(); + app.cu.demo_live = true; + app.state.selected = 1; + app.panel = Some(panel_ui::PanelTab::Activity); + } + Ok("general") => { + app.show_settings = true; + app.settings_tab = SettingsTab::General; + } + Ok("computers") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Computers; + } + Ok("markdown") => { + // Every Markdown element the chat draws, in one reply. + let text = "## 出差流程整理\n先看重點,**下週三前**要送出申請;細節在 [公司內網](https://example.com/travel)。\n第二行直接換行,不用空一行。\n\n### 步驟\n1. 填出差單\n2. 主管核准\n - 金額超過 3 萬要*總經理*簽\n - ~~紙本~~ 改用線上表單\n3. 訂機票和住宿\n\n- [x] 護照效期確認\n- [ ] 換外幣\n\n> 提醒:發票抬頭要寫公司全名。\n\n| 項目 | 上限 | 備註 |\n|---|--:|---|\n| 住宿 | 3,500 | 每晚 |\n| 交通 | 實報實銷 | 留收據 |\n| 餐費 | 800 | `每日` |\n\n```python\n# 算總額\ndef total(days, rate=3500):\n return days * rate + 800 * days\n```\n\n```mermaid\nflowchart LR\n A[填出差單] --> B{超過 3 萬?}\n B -->|是| C[總經理簽核]\n B -->|否| D[主管簽核]\n C --> E[訂機票住宿]\n D --> E\n```\n\n---\n有問題再問我!"; + let mut u = Message::user("幫我整理出差流程"); + u.at = crate::model::now_ms() - 120_000; + let mut a = Message::assistant(text, ""); + a.at = u.at + 5_000; + let mut b = app.state.bots[0].clone(); + b.messages = vec![u, a]; + app.state.bots[0] = b; + app.state.selected = app.state.bots[0].id; + } + Ok("vault") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Vault; + } + Ok("review") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Review; + app.state.auto_review = true; + app.state.review_allow_rules = vec!["在工作資料夾裡建立或修改文字檔".into()]; + app.state.review_ask_rules = vec!["刪除任何檔案".into(), "花錢或付款".into()]; + } + _ => {} + } + if let Ok(l) = std::env::var("ANYBOT_SHOT_LANG") { + app.state.language = l.clone(); + crate::i18n::set(&l); + } + if std::env::var("ANYBOT_SHOT_DARK").is_ok() { + app.state.theme = Theme::Dark; + } + // For checking each screen without the demo's waiting pop-up on top. + if std::env::var("ANYBOT_SHOT_NOPOPUP").is_ok() { + app.popup_hidden.extend(app.state.bots.iter().map(|b| b.id)); + } + } + app.apply_theme(ctx); + app + } + + fn apply_theme(&mut self, ctx: &egui::Context) { + let dark = match self.state.theme { + Theme::Light => false, + Theme::Dark => true, + Theme::System => ctx.input(|i| i.raw.system_theme) == Some(egui::Theme::Dark), + }; + self.pal = if dark { DARK } else { LIGHT }; + let p = self.pal; + let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() }; + v.panel_fill = p.pane; + v.window_fill = p.elevated; + v.extreme_bg_color = p.sidebar; + v.text_edit_bg_color = Some(p.sidebar); + v.faint_bg_color = p.hover; + v.override_text_color = Some(p.text); + v.window_stroke = Stroke::new(1.0, p.border); + v.window_corner_radius = CornerRadius::same(14); + v.menu_corner_radius = CornerRadius::same(10); + v.selection.bg_fill = p.focus.gamma_multiply(0.35); + v.selection.stroke = Stroke::new(1.0, p.focus); + v.hyperlink_color = p.accent; + for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] { + w.corner_radius = CornerRadius::same(8); + } + v.widgets.inactive.weak_bg_fill = p.hover; + v.widgets.inactive.bg_fill = p.hover; + v.widgets.hovered.weak_bg_fill = p.active; + v.widgets.hovered.bg_fill = p.active; + v.widgets.active.weak_bg_fill = p.active; + v.widgets.inactive.bg_stroke = Stroke::NONE; + v.widgets.hovered.bg_stroke = Stroke::NONE; + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border); + ctx.set_visuals(v); + ctx.all_styles_mut(|s| { + // Menus like the original's: 16 px corners, 6 px inside, rows touching. + s.visuals.menu_corner_radius = CornerRadius::same(16); + s.spacing.menu_margin = Margin::same(6); + s.spacing.menu_spacing = 0.0; + s.visuals.popup_shadow = egui::epaint::Shadow { offset: [0, 10], blur: 30, spread: 0, color: Color32::from_black_alpha(70) }; + s.spacing.item_spacing = vec2(8.0, 6.0); + s.spacing.button_padding = vec2(10.0, 5.0); + s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(FONT_BASE)); + s.text_styles.insert(egui::TextStyle::Button, egui::FontId::proportional(FONT_BASE)); + s.text_styles.insert(egui::TextStyle::Small, egui::FontId::proportional(FONT_XS)); + s.text_styles.insert(egui::TextStyle::Heading, egui::FontId::proportional(FONT_XL)); + s.text_styles.insert(egui::TextStyle::Monospace, egui::FontId::monospace(FONT_SM)); + }); + } + + fn mark(&mut self) { + self.dirty = true; + } + + fn bot_color(&self, b: &Bot) -> Color32 { + let c = avatar::color(&b.color); + // A black avatar would vanish on the dark page: lift it to a dark grey there. + if self.pal.pane != LIGHT.pane && (c.r() as u32 + c.g() as u32 + c.b() as u32) < 120 { + return Color32::from_gray(0x4a); + } + c + } + + fn user_bubble(&self, b: &Bot) -> Color32 { + match self.state.accent { + Accent::BotColor if b.color != "black" => self.bot_color(b), + _ => self.pal.bubble_user, + } + } + + // ---------- chat ---------- + + fn send(&mut self, ctx: &egui::Context, bot_id: u64) { + let mut text = self.drafts.get(&bot_id).cloned().unwrap_or_default(); + if text.trim().is_empty() && !self.has_pending_attachments(bot_id) { + return; + } + // 引用: the quoted words go first, as a Markdown quote. + if let Some(q) = self.quoting.remove(&bot_id) { + let quoted: String = q.lines().take(8).map(|l| format!("> {l}\n")).collect(); + text = format!("{quoted}\n{}", text.trim()); + } + self.drafts.insert(bot_id, String::new()); + let attachments = self.take_attachments(bot_id); + // While it works, a new message steers the running turn (the original's `turnSteer`: + // it doesn't count as a new turn) and reaches the model at its next step. + if let Some(l) = self.streams.get(&bot_id) { + let (extra, _) = crate::attach::for_model(&attachments); + l.steer.lock().unwrap().push(format!("{}{extra}", text.trim())); + return; + } + if let Some(b) = self.state.bot_mut(bot_id) { + b.messages.push(Message { attachments, ..Message::user(text.trim()) }); + } + self.start_reply(ctx, bot_id); + } + + fn start_reply(&mut self, ctx: &egui::Context, bot_id: u64) { + let Some(conv) = self.state.bot(bot_id).cloned() else { return }; + // 群組聊天: the next member answers, with its own model, instructions and computers. + let speaker = if conv.members.is_empty() { + None + } else { + let q = self.group_queue.entry(bot_id).or_default(); + if q.is_empty() { + q.extend(conv.members.iter().copied().filter(|m| *m != bot_id)); + } + let mut next = None; + while let Some(m) = q.pop_front() { + if self.state.bot(m).is_some() { + next = Some(m); + break; + } + } + match next { + Some(m) => Some(m), + None => return, + } + }; + let b = match speaker.and_then(|m| self.state.bot(m).cloned()) { + Some(member) => member, + None => conv.clone(), + }; + let ps = self.state.provider(b.provider); + // Computer tools are always offered; if they're off for this bot (or this Mac isn't + // allowed yet), using one pops up a request instead of failing quietly. + let computers = self.targets_for(&b); + let names: Vec = computers.iter().map(|t| t.name.clone()).collect(); + let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() }; + let mut tool_specs = appctl::specs(); + tool_specs.extend(tools::specs_for(&names, &default_computer)); + tool_specs.extend(web::specs()); + // Screen tools: the bot's own desktop (when it has one) and this Mac. + let mut screens = vec![]; + if crate::cloudbox::available() { + screens.push(crate::model::BOT_COMPUTER_NAME.to_string()); + } + if crate::computer::screen_supported() { + screens.push(crate::model::THIS_COMPUTER_NAME.to_string()); + } + if cfg!(target_os = "macos") { + tool_specs.extend(crate::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE)); + } + tool_specs.extend(crate::mcp::specs()); + tool_specs.extend(crate::phone::specs()); + tool_specs.push(serde_json::json!({"name": "show_card", "description": "Show a tidy card in the chat for things like a flight, a restaurant or place, the weather, a product or an event (the original's life cards). Fill it from what you found; give the source link.", + "parameters": {"type": "object", "properties": { + "kind": {"type": "string", "enum": ["flight", "place", "weather", "product", "event", "other"]}, + "title": {"type": "string"}, "subtitle": {"type": "string"}, + "rows": {"type": "array", "items": {"type": "object", "properties": {"label": {"type": "string"}, "value": {"type": "string"}}, "required": ["label", "value"]}}, + "link": {"type": "string"}}, "required": ["kind", "title"]}})); + tool_specs.push(serde_json::json!({"name": "react", "description": "Put an emoji reaction on the person's latest message, like a friend would (e.g. ❤️ for thanks or good news, 👍 for OK, 😂 for a joke, 🎉 for a win, 👀 when you'll look into it). Use it now and then where it fits; you can still answer in words.", + "parameters": {"type": "object", "properties": {"emoji": {"type": "string", "description": "One emoji"}}, "required": ["emoji"]}})); + tool_specs.push(serde_json::json!({"name": "send_file", "description": "Send a file you made (spreadsheet, document, Markdown, web page, picture, PDF…) into the chat as a file card the person can preview and download. Use this to hand over results instead of only saying where the file is.", + "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "File path (relative to your folder)"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path"]}})); + tool_specs.push(serde_json::json!({"name": "open_for_person", "description": "Open a file you made (a web page, chart, report, picture, document) on the person's screen so they can see it, or a web address. From the bot's computer it is copied over first.", + "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "File path (relative to your folder) or http(s) address"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path"]}})); + if !self.state.remote.host_token.is_empty() { + tool_specs.push(serde_json::json!({"name": "publish_site", "description": "Publish a web page or a folder with index.html that you made as a public website on the person's AnyBot account server. Publishing again with the same name replaces it. Give the person the address you get back.", + "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder with index.html, or one .html file"}, "name": {"type": "string", "description": "Short site name: letters, digits and -"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path", "name"]}})); + } + if crate::botbrowser::available() { + tool_specs.extend(crate::vault::specs()); + } + if b.demos.iter().any(|d| d.shots.iter().any(|s| !s.is_empty())) { + tool_specs.push(crate::teach::view_step_spec()); + } + if !screens.is_empty() { + let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() }; + tool_specs.extend(crate::computer::specs_for(&screens, &default_screen)); + } + let req = ChatRequest { + kind: b.provider, + base_url: providers::resolve_base(b.provider, &ps.base_url), + api_key: providers::resolve_key(b.provider, &ps.api_key), + model: b.model.clone(), + system: { + let mut sys = system_prompt(&b, &self.state, &computers, &default_computer); + if speaker.is_some() { + let names: Vec = conv.members.iter().filter_map(|m| self.state.bot(*m)).map(|x| x.name.clone()).collect(); + sys.push_str(&format!("\n\n你在群組聊天「{}」裡,成員依序回答:{}。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。", conv.name, names.join("、"))); + } + sys + }, + history: group_history(&conv.messages, speaker, &self.state), + tools: tool_specs, + session: format!("anybot-bot-{bot_id}"), + }; + let via = if speaker.is_some() { format!("{} · {} · {}", b.name, b.provider.label(), b.model) } else { format!("{} · {}", b.provider.label(), b.model) }; + if let Some(c) = self.state.bot_mut(bot_id) { + c.messages.push(Message { author: speaker.unwrap_or(0), ..Message::assistant("", &via) }); + } + let cancel = Arc::new(AtomicBool::new(false)); + let steer = Arc::new(Mutex::new(Vec::new())); + let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis()); + let direct = Arc::new(AtomicBool::new(self.direct_on())); + self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id, direct: direct.clone() }); + self.stick_bottom = true; + let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() }); + let job = agent::Job { + req, + computers, + default_computer, + local_execution: self.local_on(), + local_locked: !self.managed.local.allows() || self.state.local_never, + tools_enabled: b.tools_enabled, + direct, + grants: { + let mut g = b.allow_rules.clone(); + if self.state.messages_no_ask { + g.push("messages:no-ask".into()); + } + g + }, + context_tokens: b.context_tokens.max(4_000), + via, + via_name: b.name.clone(), + steer, + review, + demos: b.demos.clone(), + }; + agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone()); + // (Its eyes start reading while it works; see face_pose.) + self.mark(); + } + + /// The computers a bot can be told to use (its own, this Mac, then SSH ones). + fn targets_for(&self, b: &Bot) -> Vec { + let mut v = vec![ + tools::Target { + name: crate::model::BOT_COMPUTER_NAME.into(), + place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } }, + }, + tools::Target { + name: crate::model::THIS_COMPUTER_NAME.into(), + place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) }, + }, + ]; + v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } })); + // 裝置連結: other AnyBots of this account that allow it. + v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::>()); + v + } + + /// Record how a turn ended (success / cancelled / error) and clear its live state. + fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) { + // 群組聊天: the next member answers after this one finishes well. + if self.group_queue.get(&bot_id).map(|q| !q.is_empty()).unwrap_or(false) { + if outcome == TurnOutcome::Success { + self.group_next.push(bot_id); + } else { + self.group_queue.remove(&bot_id); + } + } + let group_continues = self.group_next.contains(&bot_id); + if !group_continues { + self.channels_settled(bot_id, outcome); + } + if outcome == TurnOutcome::Success && !self.demo { + let in_call = self.call_state().map(|c| c.0 == bot_id).unwrap_or(false); + if let Some(b) = self.state.bot(bot_id).filter(|b| b.speak_replies && !in_call) { + if let Some(m) = b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.trim().is_empty() && !m.error) { + crate::voice::speak_as(&m.text, &b.voice, b.voice_rate); + } + } + } + if outcome == TurnOutcome::Success { + let now = self.faces.get(&bot_id).map(|f| f.now()).unwrap_or(0.0); + self.poke(bot_id, sidebar_ui::Poke::Nod, now); + } + let away = self.away() && self.state.notify; + if let Some(l) = self.streams.remove(&bot_id) { + if let Some(b) = self.state.bot_mut(bot_id) { + b.last_turn = Some(TurnSettlement { turn_id: l.turn_id, outcome, settled_at_ms: chrono::Utc::now().timestamp_millis() }); + // 「Bot 完成任務…時通知你」: a banner when it finishes while you're away. + if outcome == TurnOutcome::Success && away && !self.demo { + let last = b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.is_empty()).map(|m| first_line(&m.text, 80)).unwrap_or_default(); + if !self.headless { + notify::banner(&format!("{} 完成了", b.name), &last); + } + crate::remote::notify(&format!("{} 完成了", b.name), &last, bot_id); + } + } + // Messages that arrived too late to steer start the next turn. + let leftover: Vec = std::mem::take(&mut *l.steer.lock().unwrap()); + if !leftover.is_empty() { + self.pending_turns.push((bot_id, leftover)); + } + } + self.approvals.remove(&bot_id); + self.app_pending.remove(&bot_id); + } + + /// Wake a bot with a message: a new turn when idle, steering when it is already working. + fn wake(&mut self, ctx: &egui::Context, bot_id: u64, msg: Message) { + if let Some(l) = self.streams.get(&bot_id) { + l.steer.lock().unwrap().push(msg.text); + return; + } + let selected = self.state.selected; + if let Some(b) = self.state.bot_mut(bot_id) { + b.messages.push(msg); + if bot_id != selected { + b.unread = true; + } + } + self.start_reply(ctx, bot_id); + } + + /// Run due routines (a routine firing is a "wake" turn). + fn tick_routines(&mut self, ctx: &egui::Context) { + if self.demo || self.last_tick.elapsed() < std::time::Duration::from_secs(1) { + return; + } + self.last_tick = Instant::now(); + let now = crate::routines::now(); + let mut fire = vec![]; + for b in self.state.bots.iter_mut() { + if self.streams.contains_key(&b.id) { + continue; // fires once the bot is idle + } + for r in b.routines.iter_mut().filter(|r| !r.paused) { + if r.next_run.map(|t| t <= now).unwrap_or(false) { + r.last_run = Some(now); + r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now); + fire.push((b.id, appctl::wake_message(r))); + break; + } + } + } + for (id, msg) in fire { + self.wake(ctx, id, msg); + self.dirty = true; + } + for id in std::mem::take(&mut self.group_next) { + if !self.streams.contains_key(&id) { + self.start_reply(ctx, id); + } + } + for (id, texts) in std::mem::take(&mut self.pending_turns) { + if let Some(b) = self.state.bot_mut(id) { + for t in texts { + b.messages.push(Message::user(&t)); + } + } + self.start_reply(ctx, id); + } + } + + fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) { + let out = appctl::apply(&mut self.state, bot_id, &call.name, &call.args); + if out.theme_changed { + self.apply_theme(ctx); + } + let _ = call.reply.send((out.text, out.ok)); + for (target, msg) in out.wake { + self.wake(ctx, target, msg); + } + self.dirty = true; + } + + fn stop(&mut self, bot_id: u64) { + self.approvals.remove(&bot_id); + if let Some(call) = self.app_pending.remove(&bot_id) { + let _ = call.reply.send(("已停止".into(), false)); + } + if let Some(live) = self.streams.get(&bot_id) { + live.cancel.store(true, Ordering::Relaxed); + // Stopping keeps what was typed but not yet read: it becomes the next turn only if sent again. + live.steer.lock().unwrap().clear(); + self.settle(bot_id, TurnOutcome::Cancelled); + if let Some(b) = self.state.bot_mut(bot_id) { + for c in b.messages.iter_mut().rev().take(6).flat_map(|m| m.tool_calls.iter_mut()) { + if matches!(c.status, ToolStatus::Waiting | ToolStatus::Running) { + c.status = ToolStatus::Denied; + } + } + if let Some(m) = b.messages.last_mut() { + if m.role == Role::Assistant && m.text.is_empty() && m.tool_calls.is_empty() { + m.text = "(已停止)".into(); + } + } + } + self.mark(); + } + } + + /// "Restart" a stuck bot: stop its turn, approvals and background commands, and mark any + /// half-finished reply as interrupted so it can be retried. Chats and files stay. + fn restart_bot(&mut self, bot_id: u64) { + self.stop(bot_id); + let stopped = tools::stop_for(&bot_id.to_string()); + if let Some(b) = self.state.bot_mut(bot_id) { + b.repair_interrupted(); + let name = b.name.clone(); + self.notice = Some(if stopped > 0 { format!("已重新啟動「{name}」,也停止了 {stopped} 個背景指令。") } else { format!("已重新啟動「{name}」。") }); + } + self.dirty = true; + } + + /// 直接操作: one switch instead of many. Turns on this Mac, every bot's tools and auto-review, + /// starts the "always ask" rules with a few safe defaults if there are none, and tells running + /// turns to stop asking. + /// The switches as they actually apply: an admin lock (policy) wins over the saved setting. + fn direct_on(&self) -> bool { + self.state.direct_mode && self.managed.direct.allows() && self.managed.local.allows() + } + + fn local_on(&self) -> bool { + (self.state.local_execution || self.state.direct_mode && self.managed.direct.allows()) && self.managed.local.allows() + } + + fn enable_direct_mode(&mut self) { + if !self.managed.direct.allows() || !self.managed.local.allows() { + self.notice = Some("「直接操作」已由管理員關閉,沒辦法打開。".into()); + return; + } + let st = &mut self.state; + st.direct_mode = true; + st.local_execution = true; + st.auto_review = true; + for b in st.bots.iter_mut() { + b.tools_enabled = true; + } + if st.review_ask_rules.is_empty() { + st.review_ask_rules = crate::model::DEFAULT_ASK_RULES.iter().map(|s| s.to_string()).collect(); + } + for l in self.streams.values() { + l.direct.store(true, Ordering::Relaxed); + } + // Other bots waiting on a "may I use the computer?" question can go on too. + let waiting: Vec = self.approvals.iter().filter(|(_, a)| a.kind != ApprovalKind::Action).map(|(id, _)| *id).collect(); + for id in waiting { + if let Some(a) = self.approvals.remove(&id) { + let _ = a.reply.send(ApprovalAnswer::Always(a.grant.clone())); + } + } + self.notice = Some("已打開「直接操作」:Bot 會直接做事,只有危險操作和你的「一律詢問」規則會停下來問你。".into()); + self.dirty = true; + } + + fn disable_direct_mode(&mut self) { + self.state.direct_mode = false; + for l in self.streams.values() { + l.direct.store(false, Ordering::Relaxed); + } + self.dirty = true; + } + + fn answer(&mut self, ctx: &egui::Context, bot_id: u64, answer: ApprovalAnswer) { + if self.want_direct.replace(false) { + self.enable_direct_mode(); + } + if let Some(call) = self.app_pending.remove(&bot_id) { + self.approvals.remove(&bot_id); + if answer == ApprovalAnswer::Reject { + let _ = call.reply.send(("使用者拒絕了這個操作。".into(), false)); + } else { + self.run_app_action(ctx, bot_id, call); + } + self.mark(); + return; + } + if let Some(a) = self.approvals.remove(&bot_id) { + self.popup_hidden.remove(&bot_id); + if let ApprovalAnswer::Always(g) = &answer { + match a.kind { + // "Always" on these turns the setting on, so it won't ask again. + ApprovalKind::EnableTools => { + if let Some(b) = self.state.bot_mut(bot_id) { + b.tools_enabled = true; + } + } + ApprovalKind::EnableThisComputer => self.state.local_execution = true, + ApprovalKind::Action => { + if let Some(b) = self.state.bot_mut(bot_id) { + if !g.is_empty() && !b.allow_rules.contains(g) { + b.allow_rules.push(g.clone()); + } + } + } + } + } + let _ = a.reply.send(answer); + if let Some(l) = self.streams.get_mut(&bot_id) { + l.paused = false; + l.last = Instant::now(); + } + self.mark(); + } + } + + fn regenerate(&mut self, ctx: &egui::Context, bot_id: u64) { + if self.streams.contains_key(&bot_id) { + return; + } + if let Some(b) = self.state.bot_mut(bot_id) { + while b.messages.last().map(|m| m.role != Role::User).unwrap_or(false) { + b.messages.pop(); + } + if b.messages.is_empty() { + return; + } + } + self.start_reply(ctx, bot_id); + } + + fn drain_events(&mut self, ctx: &egui::Context) { + while let Ok((id, ev)) = self.rx.try_recv() { + // A stopped request may still deliver a few pieces; ignore them. + if !self.streams.contains_key(&id) { + continue; + } + let selected = self.state.selected; + if let Some(live) = self.streams.get_mut(&id) { + live.last = Instant::now(); + match &ev { + StreamEvent::Text(_) | StreamEvent::Thinking(_) | StreamEvent::ToolCall(_) => { + live.produced = true; + live.status = None; + } + StreamEvent::Retrying(msg) => { + live.status = Some(msg.clone()); + live.started = Instant::now(); + } + // A new model step gets the first-response deadline again. + StreamEvent::NextStep(_) => { + live.produced = false; + live.started = Instant::now(); + live.status = None; + } + // Waiting for the person or a running tool is not a stall. + StreamEvent::NeedApproval(_) => live.paused = true, + StreamEvent::ToolStatus(_, st) => live.paused = matches!(st, ToolStatus::Running | ToolStatus::Waiting), + StreamEvent::ToolResult(_) => live.paused = false, + _ => {} + } + } + if let StreamEvent::NeedApproval(a) = ev { + if let Some(b) = self.state.bot_mut(id) { + if id != selected { + b.unread = true; + } + if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == a.call_id) { + c.status = ToolStatus::Waiting; + } + } + self.approvals.insert(id, a); + self.on_new_approval(ctx, id); + continue; + } + if let StreamEvent::React(emoji) = &ev { + if let Some(b) = self.state.bot_mut(id) { + if let Some(i) = b.messages.iter().rposition(|m| m.role == Role::User && !m.wake) { + let m = &mut b.messages[i]; + if !m.reactions.contains(emoji) { + m.reactions.push(emoji.clone()); + self.popped.insert((id, i, emoji.clone()), Instant::now()); + } + } + } + self.dirty = true; + continue; + } + if let StreamEvent::Attach(a) = ev { + if let Some(b) = self.state.bot_mut(id) { + if let Some(m) = b.messages.iter_mut().rev().find(|m| m.role == Role::Assistant) { + m.attachments.push(a); + } + } + self.dirty = true; + continue; + } + if let StreamEvent::AppAction(call) = ev { + match appctl::needs_approval(&call.name, &call.args, &self.state) { + Some(reason) => { + let (reply, _) = channel(); + self.approvals.insert(id, Approval { call_id: call.call_id.clone(), tool: call.name.clone(), reason, grant: String::new(), reply, kind: ApprovalKind::Action }); + self.on_new_approval(ctx, id); + self.app_pending.insert(id, call); + if let Some(l) = self.streams.get_mut(&id) { + l.paused = true; + } + } + None => self.run_app_action(ctx, id, call), + } + continue; + } + let mut settle_with = None; + let Some(b) = self.state.bot_mut(id) else { continue }; + match ev { + StreamEvent::Alive | StreamEvent::Retrying(_) | StreamEvent::NeedApproval(_) | StreamEvent::AppAction(_) | StreamEvent::React(_) | StreamEvent::Attach(_) => continue, + StreamEvent::Steered(m) => b.messages.push(m), + StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)), + StreamEvent::ToolResult(m) => b.messages.push(m), + StreamEvent::Compacted(n, summary) => { + let n = n.min(b.messages.len()); + b.messages.splice(..n, [summary]); + } + StreamEvent::ToolStatus(call_id, st) => { + if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) { + c.status = st; + } + } + other => { + let Some(m) = b.messages.last_mut() else { continue }; + match other { + StreamEvent::Text(t) => m.text.push_str(&t), + StreamEvent::Thinking(t) => m.thinking.push_str(&t), + StreamEvent::ToolCall(c) => m.tool_calls.push(c), + StreamEvent::StepDone(full) => { + if m.role == Role::Assistant && !m.summary { + // Keep statuses the stream already set. + let mut full = full; + for c in full.tool_calls.iter_mut() { + if let Some(old) = m.tool_calls.iter().find(|o| o.id == c.id) { + c.status = old.status; + } + } + *m = full; + } + } + StreamEvent::Done => { + if m.role == Role::Assistant && m.text.is_empty() && m.tool_calls.is_empty() { + m.text = "(沒有回應內容)".into(); + } + if id != selected { + b.unread = true; + } + settle_with = Some(TurnOutcome::Success); + } + StreamEvent::Error(e) => { + if m.role != Role::Assistant || !m.tool_calls.is_empty() { + b.messages.push(Message { error: true, ..Message::assistant(&e, "") }); + } else { + if m.text.is_empty() { + m.text = e; + } else { + m.text.push_str(&format!("\n\n{e}")); + } + m.error = true; + } + settle_with = Some(TurnOutcome::Error); + } + _ => {} + } + } + } + if let Some(o) = settle_with { + self.settle(id, o); + } + self.dirty = true; + } + } + + /// Stall rules: SAND-E0402 firstTokenStall (deadline is server-side, estimated here) + /// and SAND-E0415 streamIdleStall (35 s of silence after output started). + fn watchdog(&mut self) { + let first = std::time::Duration::from_secs(providers::FIRST_RESPONSE_TIMEOUT_SECS); + let idle = std::time::Duration::from_millis(providers::STREAM_IDLE_STALL_MS); + let stalled: Vec<(u64, &str)> = self + .streams + .iter() + .filter_map(|(id, l)| { + if l.paused { + None + } else if !l.produced && l.started.elapsed() > first { + Some((*id, "模型未能及時開始回應。請重試。")) + } else if l.produced && l.last.elapsed() > idle { + Some((*id, "回應中斷:模型超過 35 秒沒有送出任何內容。")) + } else { + None + } + }) + .collect(); + for (id, msg) in stalled { + if let Some(l) = self.streams.get(&id) { + l.cancel.store(true, Ordering::Relaxed); + } + self.settle(id, TurnOutcome::Error); + if let Some(m) = self.state.bot_mut(id).and_then(|b| b.messages.last_mut()).filter(|m| m.role == Role::Assistant) { + if m.text.is_empty() { + m.text = msg.into(); + } else { + m.text.push_str(&format!("\n\n{msg}")); + } + m.error = true; + } + self.dirty = true; + } + } + + fn fetch_models(&mut self, kind: ProviderKind) { + let ps = self.state.provider(kind); + let base = providers::resolve_base(kind, &ps.base_url); + let key = providers::resolve_key(kind, &ps.api_key); + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(providers::list_models(kind, &base, &key)); + }); + self.model_fetch.insert(kind, rx); + self.fetch_status.insert(kind, "正在取得模型清單…".into()); + } + + fn poll_fetches(&mut self) { + let mut done = vec![]; + for (k, rx) in &self.model_fetch { + if let Ok(r) = rx.try_recv() { + done.push((*k, r)); + } + } + for (k, r) in done { + self.model_fetch.remove(&k); + match r { + Ok(list) => { + self.fetch_status.insert(k, format!("連線成功,找到 {} 個模型", list.len())); + self.state.provider_mut(k).known_models = list; + self.mark(); + } + Err(e) => { + self.fetch_status.insert(k, e); + } + } + } + } + + fn models_for(&self, kind: ProviderKind) -> Vec { + let ps = self.state.provider(kind); + let mut v: Vec = if ps.known_models.is_empty() { + kind.starter_models().iter().map(|s| s.to_string()).collect() + } else { + ps.known_models.clone() + }; + if !ps.default_model.is_empty() && !v.contains(&ps.default_model) { + v.insert(0, ps.default_model.clone()); + } + v + } + + fn import_bot_dialog(&mut self) { + let (tx, rx) = channel(); + self.share_rx = Some(rx); + std::thread::spawn(move || { + if let Some(p) = crate::share::choose_open() { + let _ = tx.send(std::fs::read_to_string(&p).map_err(|e| e.to_string())); + } + }); + } + + fn poll_import(&mut self) { + let Some(rx) = &self.share_rx else { return }; + let Ok(r) = rx.try_recv() else { return }; + self.share_rx = None; + let id = self.state.next_id; + let mut next = self.state.next_routine_id; + match r.and_then(|t| crate::share::import(&t, id, &mut next, crate::routines::now())) { + Ok(b) => { + let paused = b.routines.iter().filter(|r| r.trigger.is_some()).count(); + self.state.next_id += 1; + self.state.next_routine_id = next; + self.notice = Some(format!("已匯入「{}」。{}", b.name, if paused > 0 { format!("有 {paused} 個事件觸發的例行任務先暫停了:請跟 Bot 說要用的 Token,再請它繼續。") } else { String::new() })); + self.state.bots.push(b); + self.state.selected = id; + self.mark(); + } + Err(e) => self.notice = Some(format!("匯入失敗:{e}")), + } + } + + fn new_bot(&mut self) { + let id = self.state.next_id; + let shape = avatar::SHAPES[(id as usize) % avatar::SHAPES.len()]; + let color = avatar::COLORS[(id as usize) % avatar::COLORS.len()].0; + let base = self.state.bot(self.state.selected).map(|b| (b.provider, b.model.clone())); + let (provider, model) = base.unwrap_or((ProviderKind::Xai, ProviderKind::Xai.suggested_model().into())); + let b = Bot { + id, + name: "新 Bot".into(), + description: String::new(), + instructions: String::new(), + shape: shape.into(), + color: color.into(), + provider, + model, + pinned: false, + messages: vec![], + unread: false, + tools_enabled: false, + workdir: crate::model::default_workdir(), + allow_rules: vec![], + demos: vec![], + speak_replies: false, + voice: String::new(), + voice_rate: 1.0, + voice_lang: String::new(), + folder: String::new(), + memories: vec![], + members: vec![], + threads: vec![], + context_tokens: agent::DEFAULT_CONTEXT_TOKENS, + routines: vec![], + last_turn: None, + default_computer: crate::model::default_computer(), + }; + self.edit_bot = Some((b, true)); + } + + // ---------- sidebar ---------- + + /// Apply the original's drag rule (`QJe`) to a dragged width. + fn resize_sidebar(&mut self, raw: f32) { + let st = &mut self.state; + if st.sidebar_collapsed { + if raw >= SIDEBAR_EXPAND_FROM { + st.sidebar_collapsed = false; + st.sidebar_width = raw.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH); + } + } else if raw < SIDEBAR_COLLAPSE_BELOW { + st.sidebar_collapsed = true; + } else { + st.sidebar_width = raw.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH); + } + self.dirty = true; + } + + fn sidebar(&mut self, root: &mut egui::Ui) { + let p = self.pal; + let collapsed = self.state.sidebar_collapsed; + let width = if collapsed { SIDEBAR_RAIL_WIDTH } else { self.state.sidebar_width.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH) }; + let panel = egui::Panel::left("sidebar") + .exact_size(width) + .resizable(false) + .show_separator_line(false) + .frame(Frame::new().fill(p.sidebar).inner_margin(Margin::symmetric(if collapsed { 8 } else { 10 }, 12))) + .show(root, |ui| { + if collapsed { + self.rail(ui); + return; + } + self.sidebar_body(ui); + }); + // Drag the right edge to resize; narrow it far enough and it folds into the rail. + let edge = panel.response.rect; + let handle = egui::Rect::from_min_max(egui::pos2(edge.right() - 3.0, edge.top()), egui::pos2(edge.right() + 3.0, edge.bottom())); + let r = root.interact(handle, egui::Id::unique("sidebar-resize"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if r.drag_started() { + self.side_drag = Some(width); + } + if let Some(raw) = self.side_drag.as_mut() { + *raw += r.drag_delta().x; + let raw = *raw; + self.resize_sidebar(raw); + } + if r.drag_stopped() { + self.side_drag = None; + } + if r.hovered() || r.dragged() { + root.painter().vline(edge.right(), edge.y_range(), Stroke::new(2.0, p.border)); + } + } + + /// The folded sidebar: an 88 px rail of bot avatars. + fn rail(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + ui.vertical_centered(|ui| { + if icon_button(ui, "◨", "展開側邊欄", p).clicked() { + self.state.sidebar_collapsed = false; + self.mark(); + } + if icon_button(ui, "+", "新 Bot", p).clicked() { + self.new_bot(); + } + ui.add_space(8.0); + let mut order: Vec = vec![self.state.primary_bot]; + order.extend(self.pinned_ids()); + order.extend(self.state.bots.iter().filter(|b| !b.pinned && b.id != self.state.primary_bot).map(|b| b.id)); + egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(ui.available_height() - 40.0).show(ui, |ui| { + for id in order { + let Some(b) = self.state.bot(id).cloned() else { continue }; + let (rect, resp) = ui.allocate_exact_size(vec2(56.0, 48.0), egui::Sense::click()); + if self.state.selected == id || resp.hovered() { + ui.painter().rect_filled(rect, 10, if self.state.selected == id { p.active } else { p.hover }); + } + avatar::paint(ui.painter(), egui::Rect::from_center_size(rect.center(), vec2(32.0, 32.0)), &b.shape, self.bot_color(&b)); + if b.unread || self.streams.contains_key(&id) { + ui.painter().circle_filled(rect.right_top() + vec2(-8.0, 8.0), 4.0, if b.unread { p.accent } else { p.text3 }); + } + let resp = resp.on_hover_text(&b.name); + if resp.clicked() { + self.state.selected = id; + if let Some(b) = self.state.bot_mut(id) { + b.unread = false; + } + self.mark(); + } + resp.context_menu(|ui| self.bot_menu_items(ui, id)); + } + }); + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + if icon_button(ui, "⚙", "設定", p).clicked() { + self.show_settings = true; + } + }); + }); + } + + /// A bot's menu (right-click, and ⋯ at the top), grouped like the original's. + fn bot_menu_items(&mut self, ui: &mut egui::Ui, id: u64) { + use crate::icons::Icon; + let p = self.pal; + let Some(b) = self.state.bot(id).cloned() else { return }; + ui.set_min_width(menu_ui::MENU_W); + if id != self.state.primary_bot && menu_ui::item(ui, p, Icon::Pin, if b.pinned { "取消釘選" } else { "釘選" }).clicked() { + self.set_pinned(id, !b.pinned, None); + ui.close(); + } + self.folder_menu(ui, id); + if menu_ui::item(ui, p, Icon::Bell, if b.unread { "標示為已讀" } else { "標示為未讀" }).clicked() { + if let Some(b) = self.state.bot_mut(id) { + b.unread = !b.unread; + } + self.mark(); + ui.close(); + } + if id != self.state.primary_bot && menu_ui::item(ui, p, Icon::Star, "設為主要 Bot").clicked() { + self.state.primary_bot = id; + self.mark(); + ui.close(); + } + menu_ui::sep(ui, p); + if menu_ui::item(ui, p, Icon::Pencil, "編輯 Bot…").clicked() { + self.edit_bot = Some((b.clone(), false)); + ui.close(); + } + // 討論串: put this conversation aside and start fresh; earlier ones can come back. + if menu_ui::item(ui, p, Icon::NewChat, "開始新對話").clicked() { + self.stop(id); + if let Some(b) = self.state.bot_mut(id) { + if !b.messages.is_empty() { + let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into()); + let msgs = std::mem::take(&mut b.messages); + b.threads.insert(0, (title, msgs)); + b.threads.truncate(50); + } + } + self.mark(); + ui.close(); + } + let threads: Vec = b.threads.iter().map(|t| t.0.clone()).collect(); + if !threads.is_empty() { + menu_ui::sub(ui, p, Icon::History, "過去的對話", |ui| { + for (i, title) in threads.iter().enumerate() { + if menu_ui::item_text(ui, p, None, title).clicked() { + self.stop(id); + if let Some(b) = self.state.bot_mut(id) { + let (_, msgs) = b.threads.remove(i); + if !b.messages.is_empty() { + let cur_title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into()); + let cur = std::mem::take(&mut b.messages); + b.threads.insert(0, (cur_title, cur)); + } + b.messages = msgs; + } + self.mark(); + ui.close(); + } + } + }); + } + if menu_ui::item(ui, p, Icon::Record, "錄會議").on_hover_text(t("用麥克風錄下會議,結束後轉成文字記錄交給這個 Bot 整理")).clicked() { + let ctx = ui.ctx().clone(); + self.state.selected = id; + self.start_meeting(&ctx, id); + ui.close(); + } + menu_ui::sep(ui, p); + // 分享: as a file (look, model, instructions, routines, demonstrations; no chat or keys). + menu_ui::sub(ui, p, Icon::Share, "分享與匯出", |ui| { + if menu_ui::item(ui, p, Icon::Share, "匯出 Bot(分享給別人)…").clicked() { + if let Some(b) = self.state.bot(id) { + let (text, name) = (crate::share::export(b), format!("{}.anybot", b.name)); + let note = self.ch.note.clone(); + std::thread::spawn(move || { + if let Some(path) = crate::share::choose_save(&name) { + let path = if path.extension().is_none() { path.with_extension("anybot") } else { path }; + *note.lock().unwrap() = match std::fs::write(&path, text) { + Ok(()) => format!("已匯出到 {}", path.display()), + Err(e) => format!("匯出失敗:{e}"), + }; + } + }); + self.notice = Some(t("選好位置後就會存成 .anybot 檔,可以傳給別人匯入。聊天記錄、金鑰和 Token 不會帶出去。")); + } + ui.close(); + } + if menu_ui::item(ui, p, Icon::Download, "匯出文字記錄…").clicked() { + if let Some(b) = self.state.bot(id) { + let (md, name) = (transcript_markdown(b), format!("{} 對話.md", b.name)); + let note = self.ch.note.clone(); + std::thread::spawn(move || { + if let Some(path) = crate::share::choose_save(&name) { + *note.lock().unwrap() = match std::fs::write(&path, md) { + Ok(()) => format!("已匯出到 {}", path.display()), + Err(e) => format!("匯出失敗:{e}"), + }; + } + }); + } + ui.close(); + } + if menu_ui::item(ui, p, Icon::Upload, "匯入 Bot…").clicked() { + self.import_bot_dialog(); + ui.close(); + } + if menu_ui::item(ui, p, Icon::Copy, "複製對話 ID").clicked() { + ui.ctx().copy_text(format!("{id}")); + ui.close(); + } + }); + menu_ui::sep(ui, p); + if menu_ui::item(ui, p, Icon::Refresh, "重新啟動這個 Bot").on_hover_text(t("卡住時用:停止它目前的工作和背景指令,聊天記錄和檔案都會保留")).clicked() { + self.restart_bot(id); + ui.close(); + } + if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() { + self.stop(id); + if let Some(b) = self.state.bot_mut(id) { + b.messages.clear(); + } + self.mark(); + ui.close(); + } + if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() { + self.confirm_delete = Some(id); + ui.close(); + } + } + + // ---------- main pane ---------- + + fn main_pane(&mut self, root: &mut egui::Ui) { + let p = self.pal; + let ctx = &root.ctx().clone(); + if let Some(bot) = self.state.bot(self.state.selected).cloned() { + self.bot_panel(root, &bot); + } + egui::CentralPanel::default().frame(Frame::new().fill(p.pane)).show(root, |ui| { + let id = self.state.selected; + let Some(bot) = self.state.bot(id).cloned() else { + ui.centered_and_justified(|ui| ui.label(RichText::new(t("未選取 Bot")).color(p.text3))); + return; + }; + self.header(ui, &bot); + // The composer grows with a long draft: keep room for last frame's height. + let h_id = egui::Id::unique("composer-height"); + let composer_h: f32 = ui.data(|d| d.get_temp(h_id)).unwrap_or(116.0); + let avail = ui.available_height() - composer_h; + let w = ui.available_width(); + let col_w = (w - 48.0).min(TRANSCRIPT_MAX_WIDTH); + let pad = ((w - col_w) / 2.0).max(0.0); + egui::ScrollArea::vertical() + .id_salt(("transcript", id)) + .auto_shrink([false, false]) + .max_height(avail) + .stick_to_bottom(self.stick_bottom) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add_space(pad); + ui.vertical(|ui| { + ui.set_width(col_w); + ui.add_space(18.0); + if bot.messages.is_empty() { + self.empty_state(ui, &bot); + } + let n = bot.messages.len(); + for (i, m) in bot.messages.iter().enumerate() { + let last = i + 1 == n; + self.msg_index = i; + self.chat_message(ui, ctx, &bot, m, last); + } + // While it works: the ring and the shimmering phrase. + self.working_row(ui, &bot); + self.approval_card(ui, ctx, &bot); + ui.add_space(16.0); + }); + }); + }); + let top = ui.cursor().top(); + ui.horizontal(|ui| { + ui.add_space(pad); + ui.vertical(|ui| { + ui.set_width(col_w); + self.composer(ui, ctx, &bot); + }); + }); + let used = ui.cursor().top() - top; + if (used - composer_h).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(h_id, used)); + ui.ctx().request_repaint(); + } + }); + } + + /// The top of the chat, like the original: no bar — the bot's name in a rounded pill in the + /// middle (it opens 對話詳細資訊), and 分享 on the right (pills: 1 px border at 6 %, 36–41 px). + fn header(&mut self, ui: &mut egui::Ui, bot: &Bot) { + use panel_ui::PanelTab; + let p = self.pal; + let color = self.bot_color(bot); + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), egui::Sense::hover()); + // Right: request / working, 分享, ⋯ — laid out from the right edge. + let mut right = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(12.0, 10.0))).layout(Layout::right_to_left(Align::Center))); + right.spacing_mut().item_spacing.x = 8.0; + let more = pill_icon(&mut right, crate::icons::Icon::More, p).on_hover_text(t("更多")); + egui::Popup::menu(&more).show(|ui| self.bot_menu_items(ui, bot.id)); + if pill_text(&mut right, &t("分享"), p).on_hover_text(t("匯出 Bot(分享給別人)…")).clicked() { + let (text, name) = (crate::share::export(bot), format!("{}.anybot", bot.name)); + let note = self.ch.note.clone(); + std::thread::spawn(move || { + if let Some(path) = crate::share::choose_save(&name) { + let path = if path.extension().is_none() { path.with_extension("anybot") } else { path }; + *note.lock().unwrap() = match std::fs::write(&path, text) { + Ok(()) => format!("已匯出到 {}", path.display()), + Err(e) => format!("匯出失敗:{e}"), + }; + } + }); + } + if self.meeting_on() { + let red = Color32::from_rgb(0xfc, 0x2c, 0x3e); + if crate::icons::pill(&mut right, crate::icons::Icon::Record, &t("正在錄會議 · 結束"), red, Color32::WHITE, 36.0).on_hover_text(t("停止錄音,轉成文字記錄")).clicked() { + let ctx = right.ctx().clone(); + self.voice_toggle(&ctx, bot.id); + } + } + if self.approvals.contains_key(&bot.id) { + if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &t("需要你允許"), p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() { + self.popup_hidden.remove(&bot.id); + } + } + let right_w = bar.right() - right.min_rect().left(); + // Middle: the name pill (face + name), centred, never under the buttons on the right. + let font = egui::FontId::proportional(14.0); + let name_w = ui.painter().layout_no_wrap(bot.name.clone(), font.clone(), p.text).size().x; + let max_pill = (bar.width() - 2.0 * right_w - 24.0).max(120.0); + let pill_w = (name_w + 52.0).min(max_pill); + let mut cx = bar.center().x; + if cx + pill_w / 2.0 > bar.right() - right_w - 8.0 { + cx = bar.right() - right_w - 8.0 - pill_w / 2.0; + } + let rect = egui::Rect::from_center_size(egui::pos2(cx.max(bar.left() + pill_w / 2.0 + 8.0), bar.center().y), vec2(pill_w, 40.0)); + let r = ui.interact(rect, egui::Id::unique(("name-pill", bot.id)), egui::Sense::click()); + ui.painter().rect_filled(rect, 20, if r.hovered() { p.hover } else { p.elevated }); + ui.painter().rect_stroke(rect, 20, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 0.0))).layout(Layout::left_to_right(Align::Center))); + inner.spacing_mut().item_spacing.x = 8.0; + let (face, _) = inner.allocate_exact_size(vec2(26.0, 26.0), egui::Sense::hover()); + let _ = color; + self.live_face(&mut inner, bot.id, face, true); + inner.add(egui::Label::new(RichText::new(&bot.name).size(14.0).color(p.text)).truncate().selectable(false)); + let open = self.panel.is_some(); + if r.on_hover_text(t(if open { "關閉對話詳細資訊" } else { "查看對話詳細資訊" })).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + self.panel = if open { None } else { Some(PanelTab::Activity) }; + } + } + + fn empty_state(&mut self, ui: &mut egui::Ui, bot: &Bot) { + let p = self.pal; + ui.add_space(70.0); + ui.vertical_centered(|ui| { + avatar::avatar(ui, 72.0, &bot.shape, self.bot_color(bot)); + ui.add_space(10.0); + ui.label(RichText::new(&bot.name).size(22.0).strong()); + if !bot.description.is_empty() { + ui.label(RichText::new(&bot.description).color(p.text2)); + } + ui.add_space(6.0); + ui.label(RichText::new(format!("使用 {}·{}", bot.provider.label(), bot.model)).size(12.0).color(p.text3)); + if bot.tools_enabled { + ui.label(RichText::new(format!("可以在「{}」裡查看、修改檔案和執行指令,動手前會先問你", bot.workdir)).size(12.0).color(p.text3)); + } + let ps = self.state.provider(bot.provider); + if bot.provider.needs_key() && providers::resolve_key(bot.provider, &ps.api_key).is_empty() { + ui.add_space(10.0); + if ui.button(format!("設定 {} 的 API 金鑰", bot.provider.label())).clicked() { + self.show_settings = true; + self.settings_tab = SettingsTab::Providers; + self.settings_provider = bot.provider; + } + } + }); + } + + /// "The bot wants to …": allow once, always allow this kind, or say no. + /// The question, the details and the buttons, shared by the chat card and the pop-up. + fn approval_body(&self, ui: &mut egui::Ui, bot: &Bot, big: bool) -> Option { + let p = self.pal; + let a = self.approvals.get(&bot.id)?; + let (tool, reason, grant, kind) = (a.tool.clone(), a.reason.clone(), a.grant.clone(), a.kind); + // "允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?" (eQViQE): name the machine. + let (reason, machine) = match reason.rsplit_once("\n(在 ") { + Some((r, m)) => (r.to_string(), m.trim_end_matches(" 上)").to_string()), + None => (reason.clone(), String::new()), + }; + let what = if appctl::is_app_tool(&tool) { appctl::label(&tool) } else if crate::macapps::is_mac_tool(&tool) { crate::macapps::label(&tool) } else if crate::computer::is_computer_tool(&tool) { crate::computer::label(&tool) } else if web::is_web_tool(&tool) { web::label(&tool) } else { tools::label(&tool) }; + let title = match kind { + ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name), + // Hd1KB+: 允許…在這台電腦上執行任務,而不只是在它自己的電腦上 + ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name), + ApprovalKind::Action if machine.is_empty() => format!("允許 {} {}嗎?", bot.name, what), + ApprovalKind::Action => format!("允許 {} 在 {machine} 上{what}嗎?", bot.name), + }; + let note = match kind { + ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"), + ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"), + ApprovalKind::Action => None, + }; + let mut answer = None; + ui.label(RichText::new(title).strong().size(if big { 17.0 } else { 14.0 })); + ui.add_space(2.0); + let (head, detail) = reason.split_once(':').unwrap_or(("", reason.as_str())); + match note { + Some(n) => ui.label(RichText::new(n).size(12.5).color(p.text2)), + None if !head.is_empty() => ui.label(RichText::new(head).size(12.0).color(p.text3)), + None => ui.label(""), + }; + Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(detail).monospace().size(12.5).color(p.text)).wrap()); + }); + if !machine.is_empty() && kind != ApprovalKind::Action { + ui.label(RichText::new(format!("在:{machine}")).size(12.0).color(p.text3)); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let once = if kind == ApprovalKind::Action { "允許" } else { "允許這一次" }; + if ui.add(egui::Button::new(RichText::new(once).color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(0.0, if big { 30.0 } else { 0.0 }))).clicked() { + answer = Some(ApprovalAnswer::Once); + } + let always = match kind { + ApprovalKind::EnableTools => Some("一律允許這個 Bot 使用電腦".to_string()), + ApprovalKind::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()), + ApprovalKind::Action if !grant.is_empty() => Some(format!("一律允許{}", grant_label(&grant))), + ApprovalKind::Action => None, + }; + if let Some(label) = always { + if ui.button(label).clicked() { + answer = Some(ApprovalAnswer::Always(grant.clone())); + } + } + if ui.button(t("拒絕")).clicked() { + answer = Some(ApprovalAnswer::Reject); + } + }); + // One click to work like Grok Bot: allow this and stop asking from now on. + if !self.direct_on() && self.managed.direct.allows() && self.managed.local.allows() { + ui.add_space(4.0); + let b = crate::icons::label(ui, crate::icons::Icon::Zap, &t("全部允許,之後直接操作(像 Grok Bot 一樣)"), 13.5, p.accent).interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + if b.on_hover_text(t("打開「直接操作」:Bot 可以直接使用這台電腦和瀏覽器,不再一個個問你。危險指令、密碼檔和你的「一律詢問」規則仍會擋下來。可在設定 → 電腦關掉。")).clicked() { + self.want_direct.set(true); + answer = Some(ApprovalAnswer::Always(grant.clone())); + } + } + answer + } + + fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) { + let p = self.pal; + if !self.approvals.contains_key(&bot.id) { + return; + } + let mut answer = None; + let width = ui.available_width() - 32.0 - 28.0; + ui.horizontal_top(|ui| { + ui.add_space(32.0); + Frame::new().fill(p.elevated).stroke(Stroke::new(2.0, p.accent)).corner_radius(14).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(width); + answer = self.approval_body(ui, bot, false); + }); + }); + }); + ui.add_space(10.0); + if let Some(ans) = answer { + self.answer(ctx, bot.id, ans); + } + } + + /// A new request: show it as a pop-up (even over another bot's chat) and get the person's + /// attention (the Dock icon bounces when AnyBot isn't in front). + fn on_new_approval(&mut self, ctx: &egui::Context, bot_id: u64) { + self.popup_hidden.remove(&bot_id); + if !self.demo { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); + // Away from AnyBot (other app in front, or idle 90 s+): also a macOS banner. + if self.state.notify && self.away() { + if let (Some(b), Some(a)) = (self.state.bot(bot_id), self.approvals.get(&bot_id)) { + let what = a.reason.split('\n').next().unwrap_or("").to_string(); + if !self.headless { + notify::banner(&format!("{} 需要你允許", b.name), &what); + } + crate::remote::notify(&format!("{} 需要你允許", b.name), &what, bot_id); + } + } + } + } + + /// The person isn't looking at AnyBot: window not in front, or no input for 90 s + /// (the original's presence rule `K4`, idle `Z_e=9e4`). + fn away(&self) -> bool { + if self.headless { + return true; + } + notify::should_notify(self.window_focused, self.idle_secs) + } + + /// Re-read focus every frame and system idle time every 10 s (`uBt=1e4`). + fn refresh_presence(&mut self, ctx: &egui::Context) { + self.window_focused = ctx.input(|i| i.viewport().focused.unwrap_or(true)); + if !self.demo && self.last_presence.elapsed().as_secs_f64() >= notify::PRESENCE_POLL_SECS { + self.last_presence = Instant::now(); + self.idle_secs = notify::system_idle_secs(); + } + } + + /// The pop-up: a dimmed window with the request in the middle, so it can't be missed. + fn approval_popup(&mut self, ctx: &egui::Context) { + let selected = self.state.selected; + let mut ids: Vec = self.approvals.keys().copied().filter(|id| !self.popup_hidden.contains(id)).collect(); + ids.sort_by_key(|id| (*id != selected, *id)); + let Some(&bot_id) = ids.first() else { return }; + let Some(bot) = self.state.bot(bot_id).cloned() else { return }; + let p = self.pal; + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::unique("approval-dim")).order(egui::Order::Foreground).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(screen.size(), egui::Sense::click()); + ui.painter().rect_filled(rect, 0, Color32::from_black_alpha(if p.pane == LIGHT.pane { 70 } else { 120 })); + }); + let mut answer = None; + let mut later = false; + let mut go = false; + egui::Window::new("需要你的允許") + .id(egui::Id::unique("approval-popup")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .title_bar(false) + .fixed_size(vec2(440.0, 0.0)) + .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -20.0)) + .frame(Frame::new().fill(p.elevated).stroke(Stroke::new(2.0, p.accent)).corner_radius(16).inner_margin(Margin::same(18)).shadow(egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(60) })) + .show(ctx, |ui| { + ui.horizontal(|ui| { + avatar::avatar(ui, 28.0, &bot.shape, avatar::color(&bot.color)); + ui.label(RichText::new(format!("{} 正在等你決定", bot.name)).size(12.5).color(p.text3)); + if ids.len() > 1 { + ui.label(RichText::new(format!("(還有 {} 個請求)", ids.len() - 1)).size(12.0).color(p.text3)); + } + }); + ui.add_space(6.0); + answer = self.approval_body(ui, &bot, true); + ui.add_space(4.0); + ui.horizontal(|ui| { + if bot_id != selected && small_button(ui, "前往聊天", p).clicked() { + go = true; + } + if small_button(ui, "稍後再決定", p).on_hover_text(t("先關掉這個視窗;聊天裡的卡片還在,Bot 會一直等你")).clicked() { + later = true; + } + }); + }); + if let Some(ans) = answer { + self.answer(ctx, bot_id, ans); + } else if later { + self.popup_hidden.insert(bot_id); + } else if go { + self.state.selected = bot_id; + if let Some(b) = self.state.bot_mut(bot_id) { + b.unread = false; + } + self.stick_bottom = true; + } + } + + fn composer(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) { + let p = self.pal; + let streaming = self.streams.contains_key(&bot.id); + let composer_id = egui::Id::unique(("composer", bot.id)); + let had_focus = ui.memory(|m| m.has_focus(composer_id)); + let mut send_now = false; + // Enter sends, Shift+Enter is a new line, and Enter while an input method is + // composing (e.g. picking Chinese characters) is left alone: index.eager-app `mwt` (@2002346). + let composing = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if !text.is_empty()))); + if had_focus && !composing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)) { + send_now = true; + } + self.attachments_poll(ctx, bot.id, had_focus); + self.voice_poll(); + let mut draft = self.drafts.remove(&bot.id).unwrap_or_default(); + ui.add_space(4.0); + // Quote and attachments sit just above the pill. + if let Some(q) = self.quoting.get(&bot.id).cloned() { + ui.horizontal(|ui| { + ui.add_space(14.0); + let (r, _) = ui.allocate_exact_size(vec2(3.0, 18.0), egui::Sense::hover()); + ui.painter().rect_filled(r, 1, p.accent); + let short: String = q.lines().next().unwrap_or("").chars().take(60).collect(); + ui.label(RichText::new(format!("{}{short}…", t("引用:"))).size(FONT_XS).color(p.text2)); + if ui.add(egui::Button::new(RichText::new("×").size(13.0).color(p.text3)).frame(false)).clicked() { + self.quoting.remove(&bot.id); + } + }); + ui.add_space(4.0); + } + if self.has_pending_attachments(bot.id) { + ui.horizontal(|ui| { + ui.add_space(10.0); + self.pending_chips(ui, bot.id); + }); + ui.add_space(4.0); + } + // The original's input: one 44 px pill — +, the words, the mic, and a round button in the + // bot's colour (voice chat while empty, send ↑ once there is something to send). + Frame::new() + .fill(p.elevated) + .stroke(Stroke::new(1.0, if had_focus { p.border.gamma_multiply(1.8) } else { p.border })) + .corner_radius(22) + .inner_margin(Margin { left: 8, right: 8, top: 8, bottom: 8 }) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + // +: 附加檔案 / 教它一項任務 / the model. + let plus = round_tool(ui, crate::icons::Icon::Plus, p.hover, p.text2).on_hover_text(t("輸入框操作")); + egui::Popup::menu(&plus).show(|ui| { + ui.set_min_width(menu_ui::MENU_W); + if menu_ui::item(ui, p, crate::icons::Icon::Paperclip, "附加檔案").clicked() { + self.pick_attachments(ctx, bot.id); + ui.close(); + } + if crate::computer::demo_supported() && menu_ui::item(ui, p, crate::icons::Icon::Record, "教它一項任務").clicked() { + self.teach_quick(bot.id); + ui.close(); + } + menu_ui::sep(ui, p); + if bot.members.is_empty() { + self.model_picker(ui, bot); + } else { + let names: Vec = bot.members.iter().filter_map(|m| self.state.bot(*m)).map(|b| b.name.clone()).collect(); + ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2)); + } + }); + let right_w = 28.0 * 2.0 + 8.0 + 4.0; + let text_w = (ui.available_width() - right_w).max(60.0); + let hint = t("傳訊息給 {name}").replace("{name}", &bot.name); + ui.allocate_ui_with_layout(vec2(text_w, 28.0), Layout::top_down(Align::Min), |ui| { + egui::ScrollArea::vertical().id_salt(("composer_scroll", bot.id)).max_height(160.0).show(ui, |ui| { + ui.add_space(4.0); + ui.add(egui::TextEdit::multiline(&mut draft).id(composer_id).hint_text(RichText::new(hint).color(p.text3)).frame(Frame::NONE).margin(Margin::ZERO).desired_rows(1).desired_width(text_w).lock_focus(true)); + }); + }); + ui.with_layout(Layout::right_to_left(Align::Max), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let has_text = !draft.trim().is_empty() || self.has_pending_attachments(bot.id); + let call = self.call_state(); + let in_call = call.map(|c| c.0 == bot.id).unwrap_or(false); + let colour = self.bot_color(bot); + let colour = if colour.r() as u32 + colour.g() as u32 + colour.b() as u32 <= 0x30 * 3 { p.primary } else { colour }; + let ink = if colour == p.primary { p.on_primary } else { Color32::WHITE }; + if has_text { + let tip = if streaming { "補充給正在進行的工作(Enter)" } else { "傳送訊息" }; + if round_tool(ui, crate::icons::Icon::ArrowUp, colour, ink).on_hover_text(t(tip)).clicked() { + send_now = true; + } + } else if streaming { + if round_tool(ui, crate::icons::Icon::Stop, colour, ink).on_hover_text(t("停止")).clicked() { + self.stop(bot.id); + } + } else { + let fill = if in_call { Color32::from_rgb(0xfc, 0x2c, 0x3e) } else { colour }; + let tip = match call { + Some((id, phase)) if id == bot.id => format!("{} · {}", t(phase.label()), t("結束通話")), + _ => t("發起語音聊天"), + }; + if round_tool(ui, if in_call { crate::icons::Icon::Stop } else { crate::icons::Icon::Wave }, fill, if in_call { Color32::WHITE } else { ink }).on_hover_text(tip).clicked() { + if in_call { + self.call_end(); + } else if call.is_none() { + self.call_start(bot.id); + } + } + } + // 語音輸入: click to record, click again to turn it into text. + if let Some(started) = self.voice_recording(bot.id) { + let secs = started.elapsed().as_secs(); + let tt = ui.input(|i| i.time) as f32; + let red = Color32::from_rgb(0xfc, 0x2c, 0x3e); + let fill = red.gamma_multiply(0.75 + 0.25 * (tt * 3.0).sin().abs()); + if crate::icons::pill(ui, crate::icons::Icon::Record, &format!("{:02}:{:02}", secs / 60, secs % 60), fill, Color32::WHITE, 28.0).on_hover_text(crate::i18n::t("停止錄音並轉成文字")).clicked() { + self.voice_toggle(ctx, bot.id); + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else if self.voice_busy() { + chat_ui::spinner(ui, 18.0, p.text3); + } else if round_tool(ui, crate::icons::Icon::Mic, p.hover, p.text2).on_hover_text(t("開始語音輸入")).clicked() { + self.voice_toggle(ctx, bot.id); + } + if let Some((id, phase)) = call { + if id == bot.id { + ui.label(RichText::new(t(phase.label())).size(FONT_XS).color(p.text3)); + } + } + }); + }); + }); + ui.add_space(4.0); + let queued: Vec = self.streams.get(&bot.id).map(|l| l.steer.lock().unwrap().clone()).unwrap_or_default(); + let notice = self.notice.clone(); + ui.vertical_centered(|ui| { + if let Some(n) = notice.filter(|_| queued.is_empty()) { + ui.horizontal(|ui| { + ui.label(RichText::new(n).size(11.0).color(p.accent)); + if small_button(ui, "知道了", p).clicked() { + self.notice = None; + } + }); + } else if queued.is_empty() { + // Like the original: nothing under the input. + } else { + let first: String = queued.last().unwrap().chars().take(30).collect(); + let more = if queued.len() > 1 { format!(" 等 {} 則", queued.len()) } else { String::new() }; + ui.label(RichText::new(format!("已排入「{first}」{more},Bot 會在下一步看到")).size(11.0).color(p.accent)); + } + }); + ui.add_space(10.0); + self.drafts.insert(bot.id, draft); + if send_now { + self.send(ctx, bot.id); + ui.memory_mut(|m| m.request_focus(composer_id)); + } + } + + /// Settings → 電腦: AnyBot browser profiles (each with its own logins), switch / open / add. + fn browser_profiles_section(&mut self, ui: &mut egui::Ui, p: Palette) { + setting_title(ui, "AnyBot 瀏覽器設定檔", "每個設定檔是一個獨立的瀏覽器視窗,有自己的登入(例如工作和私人帳號)。Bot 會用「使用中」的那個,你也可以在聊天裡叫它換。", p); + if !crate::botbrowser::available() { + ui.label(RichText::new(t("需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。")).size(11.5).color(p.text3)); + return; + } + let current = crate::botbrowser::current(); + for name in crate::botbrowser::choices() { + ui.horizontal(|ui| { + let mut on = name == current; + if ui.radio_value(&mut on, true, RichText::new(&name).strong()).clicked() && name != current { + let _ = crate::botbrowser::set_current(&name); + } + if name == current { + ui.label(RichText::new(t("使用中")).size(11.5).color(p.accent)); + } + if name == crate::botbrowser::PERSON_PROFILE { + ui.label(RichText::new(t("你平常的 Chrome,切到哪個設定檔就讀哪個")).size(11.5).color(p.text2)); + if crate::axread::trusted(false) { + crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3); + } else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() { + if !crate::axread::trusted(true) { + let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn(); + } + } + return; + } + if ui.small_button(t("開啟視窗")).on_hover_text(t("打開這個設定檔的瀏覽器視窗,在裡面登入網站")).clicked() { + let note = self.browser_note.clone(); + let name = name.clone(); + *note.lock().unwrap() = format!("正在打開「{name}」…"); + std::thread::spawn(move || { + let r = crate::botbrowser::show(&name); + *note.lock().unwrap() = match r { + Ok(()) => format!("已打開「{name}」的視窗。在裡面登入一次,之後會記住。"), + Err(e) => e, + }; + }); + } + if name != crate::botbrowser::DEFAULT_PROFILE { + if self.confirm_remove_profile.as_deref() == Some(name.as_str()) { + if ui.small_button(RichText::new(t("確定移除(登入也會刪掉)")).color(p.danger)).clicked() { + *self.browser_note.lock().unwrap() = match crate::botbrowser::remove(&name) { + Ok(()) => format!("已移除「{name}」。"), + Err(e) => e, + }; + self.confirm_remove_profile = None; + } + if ui.small_button(crate::i18n::t("取消")).clicked() { + self.confirm_remove_profile = None; + } + } else if ui.small_button(t("移除")).clicked() { + self.confirm_remove_profile = Some(name.clone()); + } + } + }); + } + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.new_browser_profile).hint_text(t("新設定檔名稱,例如 工作")).desired_width(180.0)); + let ok = crate::botbrowser::check_name(&self.new_browser_profile).is_ok(); + if ui.add_enabled(ok, egui::Button::new("新增設定檔")).clicked() { + let r = crate::botbrowser::add(&self.new_browser_profile).and_then(|n| crate::botbrowser::set_current(&n)); + *self.browser_note.lock().unwrap() = match r { + Ok(n) => format!("已新增「{n}」並設為使用中。按「開啟視窗」登入你要的網站。"), + Err(e) => e, + }; + self.new_browser_profile.clear(); + } + }); + let note = self.browser_note.lock().unwrap().clone(); + if !note.is_empty() { + ui.label(RichText::new(note).size(11.5).color(p.text2)); + } + ui.label(RichText::new(t("選「你的瀏覽器」:Bot 像 Grok Bot 一樣從畫面讀你平常的 Chrome(macOS 會請你打開一次「輔助使用」),你在 Chrome 切到哪個設定檔,它就看哪個;讀之前會先問你。其他的是 AnyBot 自己的視窗,各自登入一次就好。")).size(11.0).color(p.text3)); + } + + /// The bot's routines: what repeats, when it runs next, pause / run now / delete. + fn routines_window(&mut self, ctx: &egui::Context) { + let Some(bot_id) = self.show_routines else { return }; + let Some(bot) = self.state.bot(bot_id).cloned() else { + self.show_routines = None; + return; + }; + let p = self.pal; + let mut action: Option<(&str, u64)> = None; + let body_h = dialog_max_height(ctx) - 48.0; + let dismissed = dialog(ctx, "routines", &format!("{} {}", bot.name, t("的例行任務")), 500.0, true, p, |ui| { + dialog_body(ui, "routines-body", body_h, |ui| self.routines_body(ui, &bot, &mut action)); + }); + let open = !dismissed; + self.routine_action(ctx, bot_id, action); + if !open { + self.show_routines = None; + } + } + + /// The routines of one bot with 暫停/立即執行/刪除 (the window and the side panel share it). + fn routines_body(&self, ui: &mut egui::Ui, bot: &Bot, action: &mut Option<(&'static str, u64)>) { + let p = self.pal; + if bot.routines.is_empty() { + ui.label(RichText::new(t("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。")).color(p.text2)); + ui.add_space(4.0); + hint(ui, "例如:「每個工作日早上 9 點整理新聞給我」", p); + return; + } + for r in &bot.routines { + setting_card(ui, p, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&r.title).strong().size(FONT_LG)); + if r.paused { + ui.label(RichText::new(t("已暫停")).size(11.5).color(p.text3)); + } + }); + let next = match (r.paused, r.next_run) { + (false, Some(t)) => format!(" · 下次 {}", crate::routines::describe_when(t, &r.schedule.timezone)), + _ => String::new(), + }; + match &r.trigger { + Some(t) => { + crate::icons::label(ui, crate::icons::Icon::Zap, &t.describe(), 12.5, p.text2); + if let Some(st) = crate::triggers::statuses().lock().unwrap().get(&r.id).cloned() { + ui.label(RichText::new(st).size(11.5).color(p.text3)); + } + } + None => { + ui.label(RichText::new(format!("{}{next}", crate::routines::describe(&r.schedule))).size(12.5).color(p.text2)); + } + } + hint(ui, r.prompt.clone(), p); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.add(secondary_button(if r.paused { "繼續" } else { "暫停" })).clicked() { + *action = Some(("toggle", r.id)); + } + if ui.add(secondary_button("立即執行")).clicked() { + *action = Some(("run", r.id)); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("刪除")).color(p.danger)).frame(false)).clicked() { + *action = Some(("delete", r.id)); + } + }); + }); + }); + } + hint(ui, "要新增或修改,直接在聊天裡告訴 Bot。", p); + } + + fn routine_action(&mut self, ctx: &egui::Context, bot_id: u64, action: Option<(&str, u64)>) { + if let Some((what, id)) = action { + let now = crate::routines::now(); + let mut wake = None; + if let Some(b) = self.state.bot_mut(bot_id) { + match what { + "toggle" => { + if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) { + r.paused = !r.paused; + r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now); + } + } + "run" => { + if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) { + r.last_run = Some(now); + wake = Some(appctl::wake_message(r)); + } + } + _ => self.confirm_delete_routine = Some((bot_id, id)), + } + } + if let Some(m) = wake { + self.state.selected = bot_id; + self.wake(ctx, bot_id, m); + } + self.dirty = true; + } + } + + fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) { + let p = self.pal; + let label = format!("{} · {}", short_provider(bot.provider), bot.model); + let mut choice: Option<(ProviderKind, String)> = None; + let mut open_editor = false; + let models: Vec<(ProviderKind, bool, Vec)> = ALL_PROVIDERS + .iter() + .map(|&kind| { + let ps = self.state.provider(kind); + (kind, !kind.needs_key() || !providers::resolve_key(kind, &ps.api_key).is_empty(), self.models_for(kind)) + }) + .collect(); + let row = menu_ui::item_text(ui, p, Some(crate::icons::Icon::Sparkle), &label).on_hover_text(t("選擇這個 Bot 用來思考的模型")); + egui::containers::menu::SubMenu::new().show(ui, &row, |ui| { + ui.set_min_width(menu_ui::MENU_W); + for (kind, ready, list) in &models { + let title = if *ready { kind.label().to_string() } else { format!("{}({})", kind.label(), t("未設定金鑰")) }; + let icon = if *kind == bot.provider { crate::icons::Icon::Check } else { crate::icons::Icon::Sparkle }; + let r = menu_ui::item_text(ui, p, Some(icon), &title); + egui::containers::menu::SubMenu::new().show(ui, &r, |ui| { + ui.set_min_width(220.0); + for m in list { + let sel = *kind == bot.provider && *m == bot.model; + if menu_ui::item_text(ui, p, Some(if sel { crate::icons::Icon::Check } else { crate::icons::Icon::Sparkle }), m).clicked() { + choice = Some((*kind, m.clone())); + ui.close(); + } + } + menu_ui::sep(ui, p); + if menu_ui::item(ui, p, crate::icons::Icon::Pencil, "其他模型…").clicked() { + choice = Some((*kind, self.default_model(*kind))); + open_editor = true; + ui.close(); + } + }); + } + }); + if let Some((k, m)) = choice { + if let Some(b) = self.state.bot_mut(bot.id) { + b.provider = k; + b.model = m; + if open_editor { + self.edit_bot = Some((b.clone(), false)); + } + } + self.mark(); + } + } + + fn default_model(&self, kind: ProviderKind) -> String { + let ps = self.state.provider(kind); + if ps.default_model.is_empty() { kind.suggested_model().to_string() } else { ps.default_model } + } + + // ---------- dialogs ---------- + + fn bot_editor(&mut self, ctx: &egui::Context) { + let Some((mut b, is_new)) = self.edit_bot.take() else { return }; + let p = self.pal; + let mut save = false; + let mut cancel = false; + // Title bar ~48 px and footer ~56 px; the form scrolls in between. + let body_h = dialog_max_height(ctx) - 104.0; + let title = if is_new { "建立 Bot" } else { "Bot 設定" }; + let dismissed = dialog(ctx, "bot-editor", title, EDITOR_WIDTH, false, p, |ui| { + dialog_body(ui, "bot-editor-body", body_h, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 16.0; + avatar::avatar(ui, 64.0, &b.shape, avatar::color(&b.color)); + ui.vertical(|ui| { + let w = ui.available_width(); + ui.label(RichText::new(t("Bot 名稱")).size(FONT_XS).color(p.text2)); + ui.add(egui::TextEdit::singleline(&mut b.name).desired_width(w)); + field_label(ui, "Bot 描述", p); + ui.add(egui::TextEdit::singleline(&mut b.description).hint_text(t("它負責做什麼?")).desired_width(w)); + }); + }); + ui.add_space(16.0); + setting_card(ui, p, |ui| { + setting_title(ui, "頭像", "", p); + let inactive_grey = if p.pane == LIGHT.pane { Color32::from_gray(185) } else { Color32::from_gray(90) }; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for s in avatar::SHAPES { + let r = avatar::avatar(ui, 28.0, s, if b.shape == s { avatar::color(&b.color) } else { inactive_grey }).on_hover_text(avatar::shape_label(s)); + if r.clicked() { + b.shape = s.into(); + } + } + }); + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for (key, name, _) in avatar::COLORS { + let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); + ui.painter().circle_filled(rect.center(), 10.0, avatar::color(key)); + if b.color == key { + ui.painter().circle_stroke(rect.center(), 13.0, Stroke::new(2.0, p.focus)); + } + if r.on_hover_text(name).clicked() { + b.color = key.into(); + } + } + }); + }); + setting_card(ui, p, |ui| { + setting_title(ui, "AI 供應商與模型", "", p); + ui.horizontal(|ui| { + let before = b.provider; + egui::ComboBox::from_id_salt("bot_provider").selected_text(b.provider.label()).width(170.0).show_ui(ui, |ui| { + for k in ALL_PROVIDERS { + ui.selectable_value(&mut b.provider, k, k.label()); + } + }); + if b.provider != before { + b.model = self.default_model(b.provider); + } + let models = self.models_for(b.provider); + let w = ui.available_width() - 80.0; + ui.add(egui::TextEdit::singleline(&mut b.model).hint_text(t("模型名稱")).desired_width(w)); + egui::ComboBox::from_id_salt("bot_model").selected_text("選擇").width(60.0).show_ui(ui, |ui| { + for m in models { + let sel = m == b.model; + if ui.selectable_label(sel, &m).clicked() { + b.model = m; + } + } + }); + }); + let ps = self.state.provider(b.provider); + if b.provider.needs_key() && providers::resolve_key(b.provider, &ps.api_key).is_empty() { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("還沒有設定 {} 的 API 金鑰", b.provider.label())).size(12.0).color(p.danger)); + if ui.add(egui::Button::new(RichText::new(t("前往設定")).size(12.0).color(p.accent)).frame(false)).clicked() { + self.show_settings = true; + self.settings_tab = SettingsTab::Providers; + self.settings_provider = b.provider; + } + }); + } + }); + setting_card(ui, p, |ui| { + setting_title(ui, "電腦操作", "", p); + ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令")); + if b.tools_enabled { + ui.add_space(6.0); + form_row(ui, "預設電腦", p, |ui| { + let names = self.state.computer_names(); + egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| { + for n in names { + let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() }; + ui.selectable_value(&mut b.default_computer, n, label); + } + }); + ui.label(RichText::new(t("聊天時說「在我的 Mac 上」就會換")).size(11.5).color(p.text3)); + }); + form_row(ui, "起始資料夾", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width())); + }); + form_row(ui, "模型可讀長度", p, |ui| { + egui::ComboBox::from_id_salt("ctx_tokens").selected_text(format!("{}K tokens", b.context_tokens / 1000)).width(150.0).show_ui(ui, |ui| { + for t in [32_000u64, 128_000, 200_000, 400_000, 1_000_000] { + ui.selectable_value(&mut b.context_tokens, t, format!("{}K tokens", t / 1000)); + } + }); + ui.label(RichText::new(t("用到 70% 時會把舊對話整理成摘要")).size(11.5).color(p.text3)); + }); + ui.add_space(2.0); + hint(ui, "在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", p); + if !b.allow_rules.is_empty() { + ui.add_space(6.0); + ui.label(RichText::new(t("已設定「一律允許」")).size(FONT_XS).color(p.text2)); + let mut remove = None; + ui.horizontal_wrapped(|ui| { + for (i, g) in b.allow_rules.iter().enumerate() { + let chip = egui::Button::new(RichText::new(format!("{} ×", grant_label(g).trim_matches(['「', '」']))).size(12.5)).fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(14); + if ui.add(chip).on_hover_text(t("移除這個允許")).clicked() { + remove = Some(i); + } + } + }); + if let Some(i) = remove { + b.allow_rules.remove(i); + } + } + } + }); + setting_card(ui, p, |ui| { + setting_title(ui, "指示", "Bot 應該記得的事、說話風格…", p); + ui.add(egui::TextEdit::multiline(&mut b.instructions).hint_text(t("例如:用繁體中文回答,簡短一點。")).desired_rows(4).desired_width(f32::INFINITY)); + }); + if !b.members.is_empty() { + let others: Vec<(u64, String)> = self.state.bots.iter().filter(|x| x.id != b.id && x.members.is_empty()).map(|x| (x.id, x.name.clone())).collect(); + setting_card(ui, p, |ui| { + setting_title(ui, "群組成員", "你傳訊息後,勾選的 Bot 依序回答(每個用自己的模型和設定),後面的會看到前面的回答。這個群組自己的模型不會用到。", p); + for (id, name) in others { + let mut on = b.members.contains(&id); + if ui.checkbox(&mut on, name).changed() { + if on { + b.members.push(id); + } else if b.members.len() > 1 { + b.members.retain(|m| *m != id); + } + } + } + }); + } + setting_card(ui, p, |ui| { + setting_title(ui, "記得的事", "你在聊天裡請 Bot 記住的事(「記住…」),每次聊天都會帶著。", p); + if b.memories.is_empty() { + hint(ui, "還沒有。跟 Bot 說「記住我喜歡靠窗的座位」試試看。", p); + } + let mut remove = None; + for (i, m) in b.memories.iter().enumerate() { + ui.horizontal(|ui| { + ui.add(egui::Label::new(RichText::new(format!("{}. {m}", i + 1)).size(FONT_SM)).wrap()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("忘記")).size(12.0).color(p.text3)).frame(false)).clicked() { + remove = Some(i); + } + }); + }); + } + if let Some(i) = remove { + b.memories.remove(i); + } + }); + }); + dialog_footer(ui, p, |ui| { + ui.checkbox(&mut b.pinned, t("釘選到側邊欄")); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled(!b.name.trim().is_empty() && !b.model.trim().is_empty(), primary_button(if is_new { "建立 Bot" } else { "儲存" }, p)).clicked() { + save = true; + } + if ui.add(secondary_button("取消")).clicked() { + cancel = true; + } + }); + }); + }); + let open = !dismissed; + if save { + b.name = b.name.trim().to_string(); + b.model = b.model.trim().to_string(); + if is_new { + self.state.next_id += 1; + self.state.selected = b.id; + self.state.bots.push(b); + } else if let Some(slot) = self.state.bot_mut(b.id) { + // Keep messages that arrived while the editor was open. + let msgs = std::mem::take(&mut slot.messages); + *slot = b; + slot.messages = msgs; + } + self.mark(); + } else if open && !cancel { + self.edit_bot = Some((b, is_new)); + } + } + + fn settings(&mut self, ctx: &egui::Context) { + if !self.show_settings { + self.settings_was_open = false; + self.vault.dialog = None; + return; + } + self.vault_dialogs(ctx); + // Re-read admin locks each time Settings opens. + if !self.settings_was_open && !self.demo { + self.managed = policy::read(); + } + self.settings_was_open = true; + let p = self.pal; + let mut fetch: Option = None; + let mut theme_changed = false; + let mut relaunch = false; + let max_h = dialog_max_height(ctx).min(SETTINGS_HEIGHT); + // The title bar is about 48 px; the rest is the page list and the page. + let page_h = max_h - 48.0; + let close = dialog(ctx, "settings", "設定", SETTINGS_WIDTH, true, p, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + Frame::new().fill(p.sidebar).corner_radius(CornerRadius { sw: 16, ..Default::default() }).inner_margin(Margin::symmetric(10, 12)).show(ui, |ui| { + // The page list narrows with a small window so the page keeps its room. + let nav_w = if ctx.content_rect().width() < SETTINGS_WIDTH + 40.0 { 118.0 } else { SETTINGS_NAV_WIDTH }; + ui.set_width(nav_w - 20.0); + ui.set_min_height(page_h - 24.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + use crate::icons::Icon; + for (tab, name, icon) in [(SettingsTab::General, "一般", Icon::Gear), (SettingsTab::Providers, "模型", Icon::Sparkle), (SettingsTab::Channels, "整合", Icon::Chat), (SettingsTab::Computers, "電腦", Icon::Monitor), (SettingsTab::Review, "權限", Icon::Shield), (SettingsTab::Vault, "保險庫", Icon::Vault), (SettingsTab::Updates, "更新", Icon::CloudDownload)] { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), egui::Sense::click()); + let on = self.settings_tab == tab || (tab == SettingsTab::General && self.settings_tab == SettingsTab::Remote); + if on || r.hovered() { + ui.painter().rect_filled(rect, 8, if on { p.active } else { p.hover }); + } + let col = if on { p.text } else { p.text2 }; + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.left_center() + vec2(18.0, 0.0), vec2(16.0, 16.0)), icon, col); + ui.painter().text(rect.left_center() + vec2(34.0, 0.0), egui::Align2::LEFT_CENTER, t(name), egui::FontId::proportional(FONT_BASE), col); + if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + self.settings_tab = tab; + } + } + }); + }); + let r = ui.min_rect(); + ui.painter().vline(r.right(), r.y_range(), Stroke::new(1.0, p.border)); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 6.0); + dialog_body(ui, ("settings-page", self.settings_tab as u8), page_h, |ui| match self.settings_tab { + SettingsTab::General => { + section(ui, "帳戶", p); + let acct = if self.state.remote.host_token.is_empty() { t("尚未登入帳號 · 手機、帳號、團隊、備份") } else { format!("{} · {}", self.state.remote.relay_user, self.state.remote.relay_url) }; + setting_row(ui, p, "帳戶", &acct, |ui| { + if ui.add(secondary_button("管理")).clicked() { + self.settings_tab = SettingsTab::Remote; + } + }); + section(ui, "外觀", p); + setting_row(ui, p, "主題", "為 AnyBot 選擇淺色或深色外觀", |ui| { + for (th, n) in [(Theme::System, "跟隨系統"), (Theme::Dark, "深色"), (Theme::Light, "淺色")] { + if ui.selectable_value(&mut self.state.theme, th, t(n)).clicked() { + theme_changed = true; + } + } + }); + setting_row(ui, p, "強調色", "用 Bot 的顏色為 1:1 Bot 訊息和卡片上色,或保持黑色", |ui| { + for (a, n) in [(Accent::Black, "黑色"), (Accent::BotColor, "Bot 顏色")] { + if ui.selectable_value(&mut self.state.accent, a, t(n)).clicked() { + self.dirty = true; + } + } + }); + setting_row(ui, p, "字型", "AnyBot 畫面使用的字型", |ui| { + let cur = crate::font_choice(); + for (v, n) in [("system", "系統字型"), ("huninn", "粉圓體")] { + if ui.selectable_label(cur == v, t(n)).clicked() && cur != v { + if let Some(d) = State::path().parent() { + let _ = std::fs::create_dir_all(d); + let _ = std::fs::write(d.join("font.txt"), v); + } + crate::load_fonts(ctx); + } + } + }); + setting_row(ui, p, "語言", "選擇 AnyBot 選單和 Bot 回答使用的語言", |ui| { + let cur = self.state.language.clone(); + let shown = if cur == "auto" { format!("{}({})", t("自動偵測"), crate::i18n::endonym(&crate::i18n::system())) } else { crate::i18n::endonym(&cur).to_string() }; + egui::ComboBox::from_id_salt("language").selected_text(shown).height(360.0).show_ui(ui, |ui| { + let mut pick = |ui: &mut egui::Ui, v: &str, label: String| { + if ui.selectable_label(cur == v, label).clicked() { + self.state.language = v.to_string(); + crate::i18n::set(v); + self.dirty = true; + } + }; + pick(ui, "auto", t("自動偵測")); + let mut rows: Vec<_> = crate::i18n::LANGS.to_vec(); + rows.sort_by(|a, b| a.1.cmp(b.1)); + for (tag, endo, eng) in rows { + pick(ui, tag, if endo == eng { endo.to_string() } else { format!("{endo} · {eng}") }); + } + }); + }); + if !matches!(crate::i18n::lang().as_str(), "zh-TW" | "zh-CN" | "en" | "ja") { + hint(ui, "選單會用英文顯示;Bot 會用你選的語言回答。", p); + } + section(ui, "通知", p); + setting_row(ui, p, "通知", "Bot 完成任務或等你處理時通知你", |ui| { + if ui.add(egui::Checkbox::without_text(&mut self.state.notify)).changed() { + self.dirty = true; + } + }); + hint(ui, "視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。登入帳號後也會推播到手機。", p); + section(ui, "Bot", p); + setting_row(ui, p, "時區", "設定 Bot 用於排程和提醒的時區", |ui| { + if ui.add(egui::TextEdit::singleline(&mut self.state.timezone).desired_width(150.0)).changed() { + self.dirty = true; + } + if ui.add(secondary_button("用這台電腦的")).clicked() { + self.state.timezone = crate::routines::local_timezone(); + self.dirty = true; + } + }); + if self.state.timezone.parse::().is_err() { + ui.label(RichText::new(t("看不懂這個時區,請用像 Asia/Taipei 這樣的名稱")).size(11.5).color(p.danger)); + } + section(ui, "系統", p); + setting_row(ui, p, "資料", "聊天記錄和金鑰只存在這台電腦上", |ui| { + if ui.add(secondary_button(if cfg!(target_os = "macos") { "在 Finder 中顯示" } else { "打開所在資料夾" })).clicked() { + crate::preview::reveal(&State::path()); + } + }); + } + SettingsTab::Updates => { + section(ui, "AnyBot 更新", p); + setting_row(ui, p, "版本", &version_label(self.exe_built), |ui| { + if let Some(tm) = self.newer_build { + ui.label(RichText::new(format!("{}{}", t("有更新的版本:"), version_label(Some(tm)))).size(12.0).color(p.accent)); + } else { + ui.label(RichText::new(t("已是最新版本")).size(12.0).color(p.text3)); + } + }); + setting_row(ui, p, "自動更新", "在你離開時自動換成新版本,你的 Bot 和工作都會保留", |ui| { + if ui.add(egui::Checkbox::without_text(&mut self.state.auto_update_when_away)).changed() { + self.dirty = true; + } + }); + hint(ui, "螢幕鎖定或螢幕保護程式開著、10 分鐘沒動作、而且沒有 Bot 在工作時,才會自己換成新版本。", p); + setting_row(ui, p, "重新啟動", "App 不太對勁時用:進行中的工作會停下,聊天記錄都會保留", |ui| { + let label = if self.relaunching { "正在重新啟動…" } else { "重新啟動 AnyBot" }; + if ui.add_enabled(!self.relaunching, secondary_button(label)).clicked() { + relaunch = true; + } + }); + } + SettingsTab::Vault => { + self.vault_page(ui); + } + SettingsTab::Computers => { + self.computers_page(ui); + } + SettingsTab::Remote => { + if ui.add(egui::Button::new(RichText::new(format!("‹ {}", t("一般"))).size(FONT_SM).color(p.text2)).frame(false)).clicked() { + self.settings_tab = SettingsTab::General; + } + self.remote_page(ui, ctx); + } + SettingsTab::Channels => { + section(ui, "訊息管道", p); + self.channels_page(ui, ctx); + section(ui, "連接器", p); + self.connectors_page(ui, ctx); + } + SettingsTab::Review => { + Frame::new().fill(p.accent.gamma_multiply(0.07)).stroke(Stroke::new(1.0, p.accent.gamma_multiply(0.45))).corner_radius(12).inner_margin(Margin::symmetric(16, 14)).show(ui, |ui| { + ui.set_width(ui.available_width()); + crate::icons::label(ui, crate::icons::Icon::Zap, &t("直接操作(像 Grok Bot 一樣)"), FONT_BASE, p.text); + hint(ui, "一個開關:讓 Bot 直接使用這台電腦和你的瀏覽器,不再一個個問你", p); + ui.add_space(4.0); + let allowed = self.managed.direct.allows() && self.managed.local.allows(); + let mut on = self.direct_on(); + if ui.add_enabled(allowed, egui::Checkbox::new(&mut on, t("打開直接操作"))).changed() { + if on { + self.enable_direct_mode(); + } else { + self.disable_direct_mode(); + } + } + if !allowed { + crate::icons::label(ui, crate::icons::Icon::Lock, &t("由你的管理員設定,無法變更"), 12.0, p.danger); + } + hint(ui, "仍會擋下:危險指令(sudo、格式化…)、密碼和金鑰檔、AnyBot 自己的資料,以及你在「自動審核」寫的「一律詢問」規則(預設:刪除檔案、付款、用你的帳號發文或傳訊息)。macOS 第一次讓 AnyBot 控制瀏覽器時還是會自己問一次,按「好」就行。", p); + }); + ui.add_space(12.0); + setting_card(ui, p, |ui| { + setting_title(ui, "自動審核", "依照自訂規則審核 Bot 的操作", p); + if ui.checkbox(&mut self.state.auto_review, t("開啟自動審核")).changed() { + self.dirty = true; + } + hint(ui, "為每種操作寫一條簡短的自然語言規則。規則衝突時,「先詢問」優先。這些規則只對你生效。內建的安全檢查一律有效。", p); + if !self.state.auto_review { + hint(ui, "自動審核關閉時不會使用規則", p); + } + }); + for (title, desc, which) in [("「無需詢問直接允許」規則", "操作符合這些規則時,自動繼續", 0), ("「一律詢問」規則", "操作符合這些規則時,停下來詢問", 1)] { + setting_card(ui, p, |ui| { + setting_title(ui, title, desc, p); + let rules = if which == 0 { &mut self.state.review_allow_rules } else { &mut self.state.review_ask_rules }; + let mut remove = None; + for (i, r) in rules.iter().enumerate() { + Frame::new().fill(p.elevated).corner_radius(8).inner_margin(Margin::symmetric(10, 4)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(r); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.text3)).frame(false)).clicked() { + remove = Some(i); + } + }); + }); + }); + } + if let Some(i) = remove { + rules.remove(i); + self.dirty = true; + } + let draft = if which == 0 { &mut self.new_allow_rule } else { &mut self.new_ask_rule }; + ui.add_space(2.0); + ui.horizontal(|ui| { + let w = ui.available_width() - 96.0; + ui.add(egui::TextEdit::singleline(draft).hint_text(if which == 0 { "例如:在工作資料夾裡建立文字檔" } else { "例如:刪除任何檔案" }).desired_width(w)); + if ui.add_enabled(!draft.trim().is_empty(), secondary_button("新增規則")).clicked() { + rules.push(draft.trim().to_string()); + draft.clear(); + self.dirty = true; + } + }); + }); + } + } + SettingsTab::Providers => { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(PROVIDER_LIST_WIDTH); + ui.spacing_mut().item_spacing.y = 2.0; + for k in ALL_PROVIDERS { + let ps = self.state.provider(k); + let ready = !k.needs_key() || !providers::resolve_key(k, &ps.api_key).is_empty(); + let (rect, r) = ui.allocate_exact_size(vec2(PROVIDER_LIST_WIDTH, 32.0), egui::Sense::click()); + let on = self.settings_provider == k; + if on || r.hovered() { + ui.painter().rect_filled(rect, 8, if on { p.active } else { p.hover }); + } + let dot = rect.left_center() + vec2(14.0, 0.0); + if ready { + ui.painter().circle_filled(dot, 4.0, Color32::from_rgb(0x2e, 0xa0, 0x43)); + } else { + ui.painter().circle_stroke(dot, 3.5, Stroke::new(1.2, p.text3)); + } + ui.painter().text(rect.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, k.label(), egui::FontId::proportional(FONT_BASE), if on { p.text } else { p.text2 }); + let tip = if ready { "已可使用" } else { "還沒設定金鑰" }; + if r.on_hover_text(tip).clicked() { + self.settings_provider = k; + } + } + }); + ui.add_space(16.0); + ui.vertical(|ui| { + let k = self.settings_provider; + let w = ui.available_width(); + ui.label(RichText::new(k.label()).strong().size(FONT_XL)); + hint(ui, k.key_help(), p); + ui.add_space(6.0); + let ps = self.state.provider_mut(k); + let mut changed = false; + if k.needs_key() || k == ProviderKind::Custom { + field_label(ui, "API 金鑰", p); + changed |= ui.add(egui::TextEdit::singleline(&mut ps.api_key).password(true).hint_text(k.env_var().map(|v| format!("貼上金鑰,或設定環境變數 {v}")).unwrap_or_else(|| "(選填)".into())).desired_width(w)).changed(); + } + field_label(ui, "服務網址", p); + changed |= ui.add(egui::TextEdit::singleline(&mut ps.base_url).hint_text(k.default_base_url()).desired_width(w)).changed(); + field_label(ui, "新 Bot 預設模型", p); + changed |= ui.add(egui::TextEdit::singleline(&mut ps.default_model).hint_text(k.suggested_model()).desired_width(w)).changed(); + let count = ps.known_models.len(); + if changed { + self.dirty = true; + } + ui.add_space(12.0); + ui.horizontal(|ui| { + let busy = self.model_fetch.contains_key(&k); + if ui.add_enabled(!busy, secondary_button("測試連線並取得模型清單")).clicked() { + fetch = Some(k); + } + if busy { + ui.spinner(); + } + }); + if let Some(s) = self.fetch_status.get(&k) { + hint(ui, s.clone(), p); + } else if count > 0 { + hint(ui, format!("已記住 {count} 個模型"), p); + } + }); + }); + } + }); + }); + }); + }); + let open = !close; + if let Some(k) = fetch { + self.fetch_models(k); + } + if theme_changed { + self.apply_theme(ctx); + self.dirty = true; + } + self.show_settings = open; + if relaunch { + self.relaunch(ctx); + } + } + + /// Every 10 s, see whether a newer build replaced the program file (then the banner shows). + fn check_for_newer_build(&mut self) { + if self.demo { + return; + } + // Noticing a new build is a cheap file check, so the banner keeps up quickly (estimated 10 s). + if self.last_build_check.elapsed().as_secs() >= 10 { + self.last_build_check = Instant::now(); + self.newer_build = match (exe_modified(), self.exe_built) { + (Some(now), Some(started)) if now > started => Some(now), + _ => None, + }; + } + // Switching to it by itself follows the original's schedule and away rule. + if Instant::now() >= self.next_update_apply { + let jitter = (chrono::Utc::now().timestamp_subsec_nanos() as f64) / 1e9; + self.next_update_apply = Instant::now() + std::time::Duration::from_secs(notify::next_update_check_secs(jitter)); + let bots_idle = self.streams.is_empty() && self.approvals.is_empty(); + let ready = self.newer_build.is_some(); + if ready + && notify::should_apply_update_now( + self.state.auto_update_when_away, + ready, + notify::screen_locked(), + notify::screensaver_active(), + notify::system_idle_secs(), + bots_idle, + ) + { + self.relaunch_requested = true; + } + } + } + + /// Save, start a fresh copy of the app, and close this one. + fn relaunch(&mut self, ctx: &egui::Context) { + let ids: Vec = self.streams.keys().copied().collect(); + for id in ids { + self.stop(id); + } + tools::stop_all(); + crate::cloudbox::shutdown(); + crate::mcp::shutdown(); + if !self.demo { + self.state.save(); + } + match std::env::current_exe().and_then(|exe| relaunch_command(std::process::id(), &exe).spawn()) { + Ok(_) => { + self.relaunching = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(e) => self.notice = Some(format!("沒辦法重新啟動:{e}。請手動關掉再打開 AnyBot。")), + } + } + + fn delete_dialog(&mut self, ctx: &egui::Context) { + let Some(id) = self.confirm_delete else { return }; + let name = self.state.bot(id).map(|b| b.name.clone()).unwrap_or_default(); + let mut close = false; + let p = self.pal; + let dismissed = dialog(ctx, "delete-bot", "刪除 Bot?", 380.0, true, p, |ui| { + dialog_body(ui, "delete-bot-body", 200.0, |ui| { + ui.label(RichText::new(format!("這會刪除「{name}」和它的聊天記錄。此操作無法復原。")).color(p.text2)); + }); + dialog_footer(ui, p, |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(danger_button("刪除", p)).clicked() { + self.stop(id); + self.state.bots.retain(|b| b.id != id); + if self.state.bots.is_empty() { + let mut fresh = State::first_run(); + fresh.bots.truncate(1); + fresh.bots[0].id = self.state.next_id; + self.state.next_id += 1; + self.state.bots = fresh.bots; + } + let first = self.state.bots[0].id; + if self.state.primary_bot == id { + self.state.primary_bot = first; + } + if self.state.selected == id { + self.state.selected = first; + } + self.dirty = true; + close = true; + } + if ui.add(secondary_button("取消")).clicked() { + close = true; + } + }); }); + }); + if close || dismissed { + self.confirm_delete = None; + } + } + + fn handle_screenshot(&mut self, ctx: &egui::Context) { + let Some(path) = self.screenshot.clone() else { return }; + self.frames += 1; + let need = std::env::var("ANYBOT_SHOT_FRAMES").ok().and_then(|v| v.parse().ok()).unwrap_or(SCREENSHOT_FRAMES); + // ANYBOT_SHOT_RESIZE=WxH: halfway through, resize the open window (the same path as the + // person dragging its edge), so the picture shows the layout after a live resize. + if self.frames == need / 2 { + if let Some((w, h)) = std::env::var("ANYBOT_SHOT_RESIZE").ok().and_then(|v| { + let (w, h) = v.split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }) { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); + } + } + if self.frames >= need && !self.shot_requested { + self.shot_requested = true; + let slot = self.shot.clone(); + let repaint = ctx.clone(); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::ScreenshotCallback::new(move |img| { + *slot.lock().unwrap() = Some(img); + repaint.request_repaint(); + }))); + } + let shot = self.shot.lock().unwrap().take(); + if let Some(img) = shot { + let [w, h] = img.size; + let bytes: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + match image::RgbaImage::from_raw(w as u32, h as u32, bytes) { + Some(buf) => { + if let Err(e) = buf.save(&path) { + eprintln!("could not save screenshot: {e}"); + } + } + None => eprintln!("screenshot had an unexpected size"), + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + ctx.request_repaint(); + } +} + +impl App { + /// Everything that keeps bots working, without drawing (the window runs it every frame; + /// `anybot --serve` runs it on its own). + fn background(&mut self, ctx: &egui::Context) { + self.drain_events(ctx); + if !self.demo { + self.channels_poll(ctx); + } + self.remote_start(ctx); + self.remote_tick(ctx); + self.tick_routines(ctx); + self.watchdog(); + self.poll_fetches(); + self.poll_import(); + } + + fn save_if_dirty(&mut self) { + if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) { + self.state.save(); + self.dirty = false; + self.last_save = Instant::now(); + } + } + + /// `anybot --serve` with ANYBOT_ACCOUNT=url|user and ANYBOT_ACCOUNT_PASSWORD: join the account + /// (and, with ANYBOT_RESTORE_FROM_ACCOUNT=1, start once from the newest backup). + fn serve_join(&mut self) { + let (Ok(acct), Ok(pw)) = (std::env::var("ANYBOT_ACCOUNT"), std::env::var("ANYBOT_ACCOUNT_PASSWORD")) else { return }; + if !self.state.remote.host_token.is_empty() { + return; + } + let Some((url, user)) = acct.rsplit_once('|') else { return }; + match crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "") { + Ok(s) => { + self.state.remote = s; + self.dirty = true; + eprintln!("AnyBot:已加入帳號 {user}({url})"); + } + Err(e) => { + eprintln!("AnyBot:登入帳號失敗(30 秒後再試):{e}"); + return; + } + } + let marker = State::path().with_file_name("restored-from-account"); + if std::env::var("ANYBOT_RESTORE_FROM_ACCOUNT").map(|v| v == "1").unwrap_or(false) && !marker.exists() { + match crate::remote::restore(url, user, &pw).and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) { + Ok(mut st) => { + st.remote = self.state.remote.clone(); + eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len()); + self.state = st; + self.dirty = true; + let _ = std::fs::write(&marker, chrono::Utc::now().to_rfc3339()); + } + Err(e) => eprintln!("AnyBot:沒有接手備份:{e}"), + } + } + } + + /// `anybot --serve`: no window. The bots, routines, triggers, channels and the phone page keep + /// running (e.g. on an always-on Mac mini or a server); the person uses the phone. + /// For testing a bot's tool use: `anybot --ask ` sends one message to the primary bot + /// with no window, refuses every approval (and says so), and prints the turn's steps. Run it + /// with ANYBOT_DATA_DIR pointing at a copy of the data folder. It doesn't connect to the + /// account, channels or routines. + pub fn ask_cli(ctx: &egui::Context, text: &str) -> ! { + let mut app = App::new(ctx, None); + app.headless = true; + let id = app.state.primary_bot; + let start = app.state.bot(id).map(|b| b.messages.len()).unwrap_or(0); + app.drafts.insert(id, text.to_string()); + app.send(ctx, id); + let t0 = Instant::now(); + let limit = std::env::var("ANYBOT_ASK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(900u64); + while app.streams.contains_key(&id) && t0.elapsed().as_secs() < limit { + if let Some(a) = app.approvals.remove(&id) { + println!(" ⚠️ 要求核准(自動拒絕):{} — {}", a.tool, a.reason); + let _ = a.reply.send(ApprovalAnswer::Reject); + } + app.drain_events(ctx); + app.watchdog(); + let _ = ctx.run_ui(Default::default(), |_| {}); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let secs = t0.elapsed().as_secs(); + let msgs = app.state.bot(id).map(|b| b.messages[start..].to_vec()).unwrap_or_default(); + let clip = |s: &str, n: usize| -> String { + let one = s.replace('\n', " ⏎ "); + if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::()) } else { one } + }; + let full = std::env::var("ANYBOT_ASK_FULL").is_ok(); + let mut steps = 0; + for m in &msgs { + match m.role { + Role::User => println!("👤 {}", m.text), + Role::Assistant => { + if !m.text.trim().is_empty() { + println!("🤖 {}", if full || m.tool_calls.is_empty() { m.text.clone() } else { clip(&m.text, 300) }); + } + for c in &m.tool_calls { + steps += 1; + println!(" 🔧 {} {}", c.name, clip(&c.args, 200)); + } + } + Role::Tool => println!(" ↳ {}{}", if m.images.is_empty() { "" } else { "[附截圖] " }, clip(&m.text, if full { 4000 } else { 400 })), + } + } + println!("—— {steps} 個工具步驟,{secs} 秒"); + app.state.save(); + std::process::exit(0) + } + + pub fn serve(ctx: &egui::Context) -> ! { + let mut app = App::new(ctx, None); + app.headless = true; + if let Ok(pw) = std::env::var("ANYBOT_PHONE_PASSWORD") { + if pw.chars().count() >= 8 && !crate::httpd::check_password(&pw, &app.state.remote.password) { + app.state.remote.password = crate::httpd::hash_password(&pw); + app.state.remote.devices.clear(); + } + app.state.remote.lan = true; + app.dirty = true; + } + if let Ok(name) = std::env::var("ANYBOT_HOST_NAME") { + if !name.trim().is_empty() && app.state.remote.host_name != name { + app.state.remote.host_name = name; + app.dirty = true; + } + } + app.serve_join(); + app.background(ctx); + if let Some(port) = crate::remote::lan_port() { + for a in crate::remote::addresses(port) { + eprintln!("AnyBot:手機可以打開 {a}"); + } + } + if !app.state.remote.relay_url.is_empty() { + eprintln!("AnyBot:透過帳號 {} 連線({})", app.state.remote.relay_user, app.state.remote.relay_url); + } + if app.state.remote.password.is_empty() && app.state.remote.host_token.is_empty() && std::env::var("ANYBOT_ACCOUNT").is_err() { + eprintln!("AnyBot:還沒有設定手機密碼或帳號。用 ANYBOT_PHONE_PASSWORD=… 或 ANYBOT_ACCOUNT=網址|帳號 + ANYBOT_ACCOUNT_PASSWORD=… 啟動。"); + } + let mut last_try = Instant::now(); + loop { + // The account server may not be up yet (or the account not made yet): keep trying. + if app.state.remote.host_token.is_empty() && last_try.elapsed().as_secs() >= 30 { + last_try = Instant::now(); + app.serve_join(); + } + app.background(ctx); + app.save_if_dirty(); + // egui wants a frame now and then to forget old state; nothing is drawn. + let _ = ctx.run_ui(Default::default(), |_| {}); + std::thread::sleep(std::time::Duration::from_millis(if app.streams.is_empty() { 250 } else { 60 })); + } + } +} + +impl eframe::App for App { + /// Screenshot mode only: ANYBOT_SHOT_CLICK="x,y@frame[;x,y@frame…][:right]" clicks there at that + /// frame (to check buttons really respond). + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + if !self.demo { + return; + } + // ANYBOT_SHOT_DRAG="x1,y1>x2,y2@frame": press, move over 12 frames, release. + if let Ok(d) = std::env::var("ANYBOT_SHOT_DRAG") { + if let Some((path, at)) = d.split_once('@') { + let at: u32 = at.trim().parse().unwrap_or(30); + let pts: Vec = path.split(['>', ',']).filter_map(|v| v.trim().parse().ok()).collect(); + if pts.len() == 4 { + let (a, b) = (egui::pos2(pts[0], pts[1]), egui::pos2(pts[2], pts[3])); + let f = self.frames + 1; + if f == at { + raw.events.push(egui::Event::PointerMoved(a)); + } else if f == at + 1 { + raw.events.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + } else if f > at + 1 && f <= at + 13 { + let k = (f - at - 1) as f32 / 12.0; + raw.events.push(egui::Event::PointerMoved(a + (b - a) * k)); + } else if f == at + 15 { + raw.events.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + } + } + } + // ANYBOT_SHOT_HOVER="x,y@frame": just move the pointer there (tooltips). + if let Ok(h) = std::env::var("ANYBOT_SHOT_HOVER") { + if let Some((xy, at)) = h.split_once('@') { + if let (Some((x, y)), Ok(at)) = (xy.split_once(','), at.trim().parse::()) { + if self.frames + 1 == at { + if let (Ok(x), Ok(y)) = (x.trim().parse::(), y.trim().parse::()) { + raw.events.push(egui::Event::PointerMoved(egui::pos2(x, y))); + } + } + } + } + } + let Ok(spec) = std::env::var("ANYBOT_SHOT_CLICK") else { return }; + for one in spec.split(';') { + let (pos, right) = match one.strip_suffix(":right") { Some(p) => (p, true), None => (one, false) }; + let Some((xy, at)) = pos.split_once('@') else { continue }; + let Some((x, y)) = xy.split_once(',') else { continue }; + let (Ok(x), Ok(y), Ok(at)) = (x.trim().parse::(), y.trim().parse::(), at.trim().parse::()) else { continue }; + let p = egui::pos2(x, y); + let button = if right { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; + if self.frames + 1 == at { + raw.events.push(egui::Event::PointerMoved(p)); + } + if self.frames + 1 == at + 1 { + raw.events.push(egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: Default::default() }); + } + if self.frames + 1 == at + 2 { + raw.events.push(egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: Default::default() }); + } + } + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = &ui.ctx().clone(); + self.handle_screenshot(ctx); + self.md_viewer(ctx); + self.refresh_presence(ctx); + self.check_for_newer_build(); + if std::mem::take(&mut self.relaunch_requested) { + self.relaunch(ctx); + } + self.background(ctx); + self.call_poll(ctx); + if self.state.theme == Theme::System { + self.apply_theme(ctx); + } + if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::N)) { + self.new_bot(); + } + if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::Comma)) { + self.show_settings = true; + } + self.sidebar(ui); + self.main_pane(ui); + self.bot_editor(ctx); + self.settings(ctx); + self.delete_dialog(ctx); + self.routines_window(ctx); + self.rebuild_dialog(ctx); + self.viewer_window(ctx); + self.local_control_bar(ctx); + self.teach_bar(ctx); + self.approval_popup(ctx); + self.file_viewer(ctx); + self.splash(ctx); + self.drop_overlay(ctx); + // Keep the routine clock ticking while the window sits idle. + if !self.demo { + ctx.request_repaint_after(std::time::Duration::from_secs(15)); + } + if !self.streams.is_empty() || !self.model_fetch.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + self.save_if_dirty(); + } + + fn on_exit(&mut self) { + // Closing mid-reply ends that turn as cancelled, so it isn't left looking stuck. + let ids: Vec = self.streams.keys().copied().collect(); + for id in ids { + self.stop(id); + } + tools::stop_all(); + crate::cloudbox::shutdown(); + if !self.demo { + self.state.save(); + } + } +} + +// ---------- helpers ---------- + +/// When the program file was last written (= when this build was made). +fn exe_modified() -> Option { + std::env::current_exe().ok().and_then(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()) +} + +/// "2026-11-03 20:57" in local time, or "?" when unknown. +fn version_label(t: Option) -> String { + t.map(|t| chrono::DateTime::::from(t).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| "?".into()) +} + +/// How often the relaunch helper checks whether the old copy has exited (main-app.cjs @3195964, +/// `PBt`: `while kill -0 "$pid" …; do sleep 0.2; done; exec "$app"`). +pub const RELAUNCH_POLL_SECS: f32 = 0.2; + +/// A detached helper that waits for this copy (`pid`) to exit, then starts `exe`, so two copies +/// never run at once and the old one's last save lands first. Like the original's `Pur` +/// (`spawn(shell, …, {detached:true, stdio:"ignore"})`), its input and output go nowhere; +/// the main program also reopens closed std streams on /dev/null at start (`sub_1000004c0`, +/// 0x1000004c0). Start-up switches are cleared so the new copy opens normally (the original's +/// `entry`, 0x100000460, changes how it starts when ELECTRON_RUN_AS_NODE is set). +pub fn relaunch_command(pid: u32, exe: &std::path::Path) -> std::process::Command { + let script = format!("while kill -0 \"$1\" 2>/dev/null; do sleep {RELAUNCH_POLL_SECS}; done; exec \"$2\""); + let mut c = std::process::Command::new("/bin/sh"); + c.arg("-c").arg(script).arg("anybot-relaunch").arg(pid.to_string()).arg(exe); + c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + for k in ["ANYBOT_SHOT_VIEW", tools::ENV_CONVERSATION_ID, tools::ENV_REQUEST_ID, "ELECTRON_RUN_AS_NODE"] { + c.env_remove(k); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + c +} + +/// A reply that is still coming in. +struct Live { + cancel: Arc, + started: Instant, + last: Instant, + produced: bool, + status: Option, + /// Waiting for an approval or a running tool: the stall watchdog stays quiet. + paused: bool, + /// What the person typed during this turn, waiting for the next step. + steer: Arc>>, + turn_id: String, + /// 直接操作 for this turn (switched on mid-turn by "全部允許"). + direct: Arc, +} + +/// How an "always allow" grant reads in the UI. +fn grant_label(g: &str) -> String { + match g { + "edit_file" => "「修改檔案」".into(), + "browser:read" => "「讀取瀏覽器分頁」".into(), + "read:anywhere" => "「讀取工作資料夾以外」".into(), + "computer:see" => "「看你的螢幕」".into(), + g if g.starts_with("app:") => format!("操作「{}」", &g[4..]), + g if g.starts_with("mcp:") => format!("使用連接器「{}」", &g[4..]), + g if g.starts_with("login:") => format!("登入 {}", &g[6..]), + g if g.starts_with("macapp-write:") => format!("修改「{}」", &g[13..]), + g if g.starts_with("macapp:") => format!("讀取「{}」", &g[7..]), + other => format!("「{}」", other.trim_start_matches("shell:")), + } +} + +/// Padding that makes a one-line bubble 36 px tall around 15 px text (egui row ≈ 18 px). +fn bubble_margin() -> Margin { + // index CSS `message`: padding-block var(--sand-transcript-prose-block-padding) = 7px, + // padding-inline 12px (7 + 22 + 7 = the 36 px one-line bubble). + debug_assert_eq!(7.0 * 2.0 + TRANSCRIPT_LINE_HEIGHT, BUBBLE_ONE_LINE_HEIGHT); + Margin::symmetric(12, 7) +} + +/// index.eager-app `messageColumnOfBlock`: max-width min(80cqi, 560px, 100cqi - 82px). +pub const MESSAGE_MAX_WIDTH: f32 = 560.0; +pub fn message_max_width(container: f32) -> f32 { + (container * 0.8).min(MESSAGE_MAX_WIDTH).min(container - 82.0).max(120.0) +} + +/// What the model sees: attachments read in, 引用 and 表情回應 noted, and in a group chat the other +/// members' answers as labelled words (their tool steps and private blocks left out). +fn group_history(messages: &[Message], speaker: Option, st: &State) -> Vec { + let mut out = vec![]; + let mut skip_tool_ids: std::collections::HashSet = Default::default(); + for m in messages { + let mut m = m.clone(); + if !m.attachments.is_empty() { + let (extra, images) = crate::attach::for_model(&m.attachments); + m.text = format!("{}{extra}", m.text); + m.images = images; + } + if m.role == Role::Assistant && !m.reactions.is_empty() { + m.text = format!("{}\n(使用者對這則回應:{})", m.text, m.reactions.join(" ")); + } + if let Some(me) = speaker { + if m.role == Role::Assistant && m.author != me && !m.summary { + for c in &m.tool_calls { + skip_tool_ids.insert(c.id.clone()); + } + if m.text.trim().is_empty() { + continue; + } + let who = st.bot(m.author).map(|b| b.name.clone()).unwrap_or_else(|| "其他成員".into()); + out.push(Message::user(&format!("【{who}】{}", m.text))); + continue; + } + if m.role == Role::Tool && skip_tool_ids.contains(&m.tool_call_id) { + continue; + } + } + out.push(m); + } + out +} + +/// 生活卡片: a flight, place, weather, product or event the bot found, drawn as a card. +fn card(ui: &mut egui::Ui, args: &str, p: Palette) { + let v = tools::args_of(args); + use crate::icons::Icon; + let icon = match v["kind"].as_str().unwrap_or("") { + "flight" => Icon::Plane, + "place" => Icon::MapPin, + "weather" => Icon::CloudSun, + "product" => Icon::ShoppingBag, + "event" => Icon::Calendar, + _ => Icon::Folder, + }; + ui.add_space(4.0); + Frame::new() + .fill(p.elevated) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(14) + .inner_margin(Margin::symmetric(14, 12)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(14) }) + .show(ui, |ui| { + ui.set_max_width(message_max_width(ui.available_width() + 32.0)); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, icon, p.text2); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + ui.label(RichText::new(v["title"].as_str().unwrap_or("")).strong().size(FONT_LG)); + if let Some(st) = v["subtitle"].as_str().filter(|x| !x.is_empty()) { + ui.label(RichText::new(st).size(FONT_XS).color(p.text3)); + } + }); + }); + let rows = v["rows"].as_array().cloned().unwrap_or_default(); + if !rows.is_empty() { + ui.add_space(6.0); + egui::Grid::new(("card", args.len(), v["title"].as_str().unwrap_or(""))).num_columns(2).spacing([14.0, 4.0]).show(ui, |ui| { + for r in rows.iter().take(12) { + ui.label(RichText::new(r["label"].as_str().unwrap_or("")).size(FONT_SM).color(p.text3)); + ui.add(egui::Label::new(RichText::new(r["value"].as_str().unwrap_or("")).size(FONT_SM)).wrap()); + ui.end_row(); + } + }); + } + if let Some(link) = v["link"].as_str().filter(|l| l.starts_with("http")) { + ui.add_space(4.0); + ui.hyperlink_to(RichText::new(t("查看來源 ↗")).size(FONT_XS).color(p.accent), link); + } + }); + ui.add_space(4.0); +} + +/// 匯出文字記錄: the chat as Markdown (tool steps folded in as quotes). +fn transcript_markdown(b: &Bot) -> String { + let mut s = format!("# {} 的對話\n\n匯出時間:{}\n\n", b.name, chrono::Local::now().format("%Y-%m-%d %H:%M")); + for m in &b.messages { + match m.role { + Role::User => { + s.push_str(&format!("**你**:{}\n", m.text)); + for a in &m.attachments { + s.push_str(&format!(" [附件] {}\n", a.name)); + } + s.push('\n'); + } + Role::Assistant if m.summary => s.push_str(&format!("> (較早的對話摘要){}\n\n", m.text.replace('\n', " "))), + Role::Assistant => { + if !m.text.trim().is_empty() { + s.push_str(&format!("**{}**:{}\n\n", b.name, m.text)); + } + for c in &m.tool_calls { + s.push_str(&format!("> [工具]{} {}\n", c.name, c.args.chars().take(200).collect::())); + } + if !m.tool_calls.is_empty() { + s.push('\n'); + } + } + Role::Tool => {} + } + } + s +} + +/// What the bot is told about itself, the app it lives in and the computers it can use. +fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str) -> String { + let mut s = format!("你是「{}」。", b.name); + if !b.description.trim().is_empty() { + s.push_str(&format!("你的角色:{}。", b.description.trim())); + } + if b.id == st.primary_bot { + s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。"); + } + let now = crate::routines::now_in(&st.timezone); + s.push_str(&format!( + "\n\n現在是 {}({},時區 {})。\n\ + 你在 AnyBot 這個 App 裡,可以直接操作 App:\n\ + - 使用者想定期或在某個時間做事(每天、每週一、每月一號、提醒我…),就用 create_routine 設成例行任務;時間照使用者的時區。查看、修改、暫停、刪除用 list_routines / update_routine / delete_routine。\n\ + - 換模型或供應商、改名字、建立新 Bot:update_bot / create_bot;看有哪些 Bot:list_bots。\n\ + - 主題、強調色、時區:update_settings。\n\ + - 使用者說「交給某某 Bot」或需要別的 Bot 專長時,用 message_bot。\n\ + 做完 App 操作後,用一句話告訴使用者結果(例如排程的時間)。\n\n\ + 你也可以上網和用使用者的瀏覽器:\n\ + - 公開網頁:web_fetch。\n\ + - 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開,再用 browser_read 讀分頁內容。例如看 Threads 帳號:browser_open(\"https://www.threads.net\") 然後 browser_read。\n\ + - 在網頁上做事(後台、報表、表單)要像人一樣操作:先 browser_screenshot 看一眼(圖上每個可點的東西都有編號),再用 browser_click/browser_type/browser_select/browser_press 照編號操作;表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。 + - 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。 + - 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ + - AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\ + - 你也能像人一樣操作螢幕:你自己的電腦(Bot 的電腦,Linux 桌面)或使用者的 Mac(你的電腦,用在使用者自己的 App)。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ + - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\ + - 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。", + now.format("%Y-%m-%d %H:%M"), + ["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize], + st.timezone + )); + if !b.routines.is_empty() { + s.push_str("\n\n你目前的例行任務:\n"); + s.push_str(&b.routines.iter().map(appctl::routine_line).collect::>().join("\n")); + } + if !computers.is_empty() { + let list: Vec = computers + .iter() + .map(|t| match &t.place { + tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()), + tools::Place::Box { dir } => { + let secrets = crate::cloudbox::secret_names(); + format!( + "- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}", + t.name, + t.name, + if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) } + ) + } + tools::Place::Local { workdir } => format!( + "- {}:{},起始資料夾 {}{}", + t.name, + if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" }, + workdir.display(), + if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" } + ), + tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name), + tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name), + }) + .collect(); + s.push_str(&format!( + "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ + - 使用者說在哪台電腦上做(「在我的 Mac 上」「在工作站上」),就把 computer 設成那台;沒說就用預設。\n\ + - 先用 list_dir、search、read_file 看清楚,再動手。\n\ + - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ + - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ + - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ + - 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。", + list.join("\n") + )); + } + let chans: Vec = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect(); + if !chans.is_empty() { + s.push_str(&format!("\n\n你也在這些訊息管道回覆別人:{}。以「[訊息管道]」開頭的訊息來自那裡,你這一回合最後的回答會自動傳回給對方,所以要簡潔、直接回答對方,不要用表格,也不要提到 AnyBot 的內部操作。對方不是使用者本人:不要透露使用者的私人資料,要動到使用者的電腦或帳號前先確認。", chans.join("、"))); + } + s.push_str(&crate::memory::prompt_section(&b.memories, &st.skills)); + let mcp_notes = crate::mcp::instructions(&st.mcp_servers); + let mcp_tools = crate::mcp::status(); + if !mcp_tools.is_empty() { + s.push_str(&format!("\n\n你也可以用使用者接上的連接器(MCP,工具名稱以 mcp__ 開頭):{}。第一次用某個連接器時 App 會問使用者。{}", mcp_tools.keys().cloned().collect::>().join("、"), if mcp_notes.is_empty() { String::new() } else { format!("\n連接器的使用說明:\n{mcp_notes}") })); + } + s.push_str(&crate::teach::prompt_section(&b.demos)); + if !b.instructions.trim().is_empty() { + s.push_str("\n\n"); + s.push_str(b.instructions.trim()); + } + s.push_str("\n\n做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。"); + s.push_str("\n\n"); + s.push_str(&crate::i18n::reply_instruction(&st.language)); + s +} + +fn short_provider(k: ProviderKind) -> &'static str { + match k { + ProviderKind::Xai => "Grok", + ProviderKind::OpenAi => "OpenAI", + ProviderKind::Anthropic => "Claude", + ProviderKind::Gemini => "Gemini", + ProviderKind::OpenRouter => "OpenRouter", + ProviderKind::OpenCodeGo => "OpenCode Go", + ProviderKind::Ollama => "Ollama", + ProviderKind::Custom => "自訂", + } +} + +/// The Chinese font's "·" already carries space on both sides; spaces around it doubled the gap. +fn tidy_dots(s: &str) -> String { + s.replace(" · ", "·") +} + +fn first_line(s: &str, max: usize) -> String { + let line = s.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); + let line = line.trim_start_matches(['#', '-', '*', ' ']); + let mut out: String = line.chars().take(max).collect(); + if line.chars().count() > max { + out.push('…'); + } + out +} + +fn icon_button(ui: &mut egui::Ui, icon: &str, tip: &str, p: Palette) -> egui::Response { + if let Some(ic) = crate::icons::Icon::from_name(icon) { + return crate::icons::button(ui, ic, 28.0, p.text2, p.hover).on_hover_text(t(tip)); + } + ui.add(egui::Button::new(RichText::new(icon).size(15.0).color(p.text2)).frame(false).min_size(vec2(26.0, 26.0))).on_hover_text(t(tip)) +} + +/// A header action: text only, a soft fill on hover. +#[allow(dead_code)] +fn quiet_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response { + ui.add(egui::Button::new(RichText::new(t(text)).size(FONT_SM).color(p.text2)).frame_when_inactive(false).corner_radius(8).min_size(vec2(0.0, 28.0))) +} + +fn small_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response { + ui.add(egui::Button::new(RichText::new(t(text)).size(11.5).color(p.text3)).frame_when_inactive(false).corner_radius(6).min_size(vec2(0.0, 22.0))) +} + +/// The input's 28 px round buttons (+, mic, voice chat / send). +fn round_tool(ui: &mut egui::Ui, icon: crate::icons::Icon, fill: Color32, ink: Color32) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); + let f = if r.hovered() { fill.gamma_multiply(0.82).to_opaque().lerp_to_gamma(fill, 0.3) } else { fill }; + ui.painter().circle_filled(rect.center(), 14.0, f); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.center(), vec2(15.0, 15.0)), icon, ink); + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A 36 px rounded button with a hairline border (the original's 分享 / ⋯). +fn pill_text(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response { + ui.add(egui::Button::new(RichText::new(text).size(14.0).color(p.text)).fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(18).min_size(vec2(0.0, 36.0))) +} + +fn pill_icon(ui: &mut egui::Ui, icon: crate::icons::Icon, p: Palette) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(36.0, 36.0), egui::Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, if r.hovered() { p.hover } else { p.elevated }); + ui.painter().circle_stroke(rect.center(), 17.5, Stroke::new(1.0, p.border)); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, p.text2); + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A group heading inside a settings page (like the original's section titles). +fn section(ui: &mut egui::Ui, title: &str, p: Palette) { + ui.add_space(14.0); + ui.label(RichText::new(t(title)).size(FONT_SM).strong().color(p.text3)); + ui.add_space(2.0); +} + +/// A small「?」after a setting's name. Its explanation shows as soon as the pointer is on it (no +/// waiting for the pointer to be still), and a click keeps it open until the next click. +fn help_mark(ui: &mut egui::Ui, text: &str, p: Palette) { + if text.trim().is_empty() { + return; + } + let (rect, r) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::click()); + let pinned_id = r.id.with("pinned"); + let mut pinned = ui.ctx().data(|d| d.get_temp::(pinned_id)).unwrap_or(false); + if r.clicked() { + pinned = !pinned; + } else if pinned && ui.input(|i| i.pointer.any_pressed()) && !r.hovered() { + pinned = false; + } + ui.ctx().data_mut(|d| d.insert_temp(pinned_id, pinned)); + let show = r.hovered() || pinned; + let col = if show { p.text } else { p.text3 }; + ui.painter().circle_stroke(rect.center(), 6.5, Stroke::new(1.1, col)); + ui.painter().text(rect.center() + vec2(0.0, 0.5), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(10.0), col); + if show { + let text = t(text); + egui::Area::new(r.id.with("help")) + .order(egui::Order::Tooltip) + .fixed_pos(rect.left_bottom() + vec2(-4.0, 6.0)) + .constrain(true) + .interactable(false) + .show(ui.ctx(), |ui| { + Frame::popup(ui.style()).corner_radius(10).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| { + ui.set_max_width(300.0); + ui.add(egui::Label::new(RichText::new(text).size(FONT_SM).color(p.text)).wrap()); + }); + }); + } + r.on_hover_cursor(egui::CursorIcon::Help); +} + +/// One setting: its name (with「?」for the explanation) on the left, its control on the right. +fn setting_row(ui: &mut egui::Ui, p: Palette, label: &str, desc: &str, control: impl FnOnce(&mut egui::Ui)) { + Frame::new().fill(p.elevated).corner_radius(10).inner_margin(Margin::symmetric(14, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let w = (ui.available_width() * 0.5).max(180.0); + ui.horizontal(|ui| { + ui.set_width(w); + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new(t(label)).size(FONT_BASE).color(p.text)); + help_mark(ui, desc, p); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.horizontal(control); + }); + }); + }); +} + +fn setting_title(ui: &mut egui::Ui, title: &str, desc: &str, p: Palette) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new(t(title)).strong().size(FONT_BASE)); + help_mark(ui, desc, p); + }); + ui.add_space(4.0); +} + +/// Small grey text under a setting. +fn hint(ui: &mut egui::Ui, text: impl Into, p: Palette) { + ui.add(egui::Label::new(RichText::new(t(&text.into())).size(11.5).color(p.text3)).wrap()); +} + +/// A rounded group of related settings, like a macOS settings card. +fn setting_card(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui) -> R) -> R { + let r = Frame::new() + .fill(p.hover.gamma_multiply(0.6)) + .stroke(Stroke::new(1.0, p.border.gamma_multiply(0.6))) + .corner_radius(12) + .inner_margin(Margin::symmetric(16, 14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui) + }) + .inner; + ui.add_space(12.0); + r +} + +/// A label column so form rows line up. +fn form_row(ui: &mut egui::Ui, label: &str, p: Palette, body: impl FnOnce(&mut egui::Ui)) { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(FORM_LABEL_WIDTH, 28.0), egui::Sense::hover()); + ui.painter().text(rect.left_center(), egui::Align2::LEFT_CENTER, t(label), egui::FontId::proportional(FONT_SM), p.text2); + body(ui); + }); +} + +fn field_label(ui: &mut egui::Ui, text: &str, p: Palette) { + ui.add_space(4.0); + ui.label(RichText::new(t(text)).size(FONT_XS).color(p.text2)); +} + +fn primary_button(text: &str, p: Palette) -> egui::Button<'static> { + egui::Button::new(RichText::new(t(text)).color(p.on_primary).strong()).fill(p.primary).corner_radius(8).min_size(vec2(72.0, 30.0)) +} + +fn secondary_button(text: &str) -> egui::Button<'static> { + egui::Button::new(t(text)).corner_radius(8).min_size(vec2(64.0, 30.0)) +} + +fn danger_button(text: &str, p: Palette) -> egui::Button<'static> { + egui::Button::new(RichText::new(t(text)).color(Color32::WHITE).strong()).fill(p.danger).corner_radius(8).min_size(vec2(72.0, 30.0)) +} + +/// Tallest a dialog may be: the window minus a margin above and below. +fn dialog_max_height(ctx: &egui::Context) -> f32 { + (ctx.content_rect().height() - WINDOW_SCREEN_MARGIN).max(240.0) +} + +/// A centred dialog over a dimmed window: title and × on top, then the caller's body. +/// Returns true when the person asked to close it (×, Esc, or a click outside when `outside_closes`). +fn dialog(ctx: &egui::Context, id: &str, title: &str, width: f32, outside_closes: bool, p: Palette, content: impl FnOnce(&mut egui::Ui)) -> bool { + let dark = p.pane != LIGHT.pane; + let mut close = false; + let resp = egui::Modal::new(egui::Id::unique(("dialog", id))) + .backdrop_color(Color32::from_black_alpha(if dark { 130 } else { 70 })) + .frame( + Frame::new() + .fill(p.elevated) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(16) + .shadow(egui::epaint::Shadow { offset: [0, 12], blur: 36, spread: 0, color: Color32::from_black_alpha(if dark { 110 } else { 45 }) }), + ) + .show(ctx, |ui| { + // Never wider than the window (a narrow window keeps the whole dialog on screen). + ui.set_width(width.min(ctx.content_rect().width() - 32.0).max(280.0)); + // Header, body and footer sit flush; each sets its own inner spacing. + ui.spacing_mut().item_spacing.y = 0.0; + Frame::new().inner_margin(Margin { left: 20, right: 12, top: 12, bottom: 12 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(t(title)).strong().size(FONT_XL)); + ui.add_space((ui.available_width() - 28.0).max(0.0)); + // Drawn, not a glyph: not every Chinese font has "✕". + let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 8, p.hover); + } + let c = rect.center(); + let stroke = Stroke::new(1.5, if r.hovered() { p.text } else { p.text2 }); + ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], stroke); + ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke); + if r.on_hover_text(t("關閉(Esc)")).clicked() { + close = true; + } + }); + }); + let r = ui.min_rect(); + ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, p.border)); + content(ui); + }); + let escape = ctx.input(|i| i.key_pressed(Key::Escape)); + close || escape || (outside_closes && resp.backdrop_response.clicked()) +} + +/// The scrolling middle of a dialog, `reserve` px kept free for its footer. +fn dialog_body(ui: &mut egui::Ui, id: impl std::hash::Hash + std::fmt::Debug, max_height: f32, body: impl FnOnce(&mut egui::Ui) -> R) -> R { + egui::ScrollArea::vertical() + .id_salt(id) + .max_height(max_height) + .auto_shrink([false, true]) + .show(ui, |ui| { + Frame::new().inner_margin(Margin { left: 20, right: 20, top: 16, bottom: 16 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 6.0; + body(ui) + }) + .inner + }) + .inner +} + +/// The bottom bar of a dialog: a line above, buttons laid out right to left. +fn dialog_footer(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) { + let top = ui.cursor().top(); + ui.painter().hline(ui.max_rect().x_range(), top, Stroke::new(1.0, p.border)); + Frame::new().inner_margin(Margin::symmetric(20, 12)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 6.0; + ui.horizontal(|ui| body(ui)); + }); +} + + +#[cfg(test)] +mod tests { + #[test] + fn group_members_see_each_other_as_labelled_words() { + let st = State::demo(); + let (a, b) = (st.bots[1].id, st.bots[2].id); + let mut tool_step = Message { author: a, ..Message::assistant("", "x") }; + tool_step.tool_calls.push(crate::model::ToolCall { id: "t1".into(), name: "shell".into(), args: "{}".into(), status: Default::default() }); + let msgs = vec![ + Message::user("大家覺得呢?"), + tool_step, + Message::tool_result("t1", "shell", "secret output"), + Message { author: a, reactions: vec!["👍".into()], ..Message::assistant("我覺得可以", "x") }, + ]; + let h = super::group_history(&msgs, Some(b), &st); + assert_eq!(h.len(), 2, "{h:?}"); + assert_eq!(h[1].role, Role::User); + assert!(h[1].text.starts_with(&format!("【{}】我覺得可以", st.bots[1].name)) && h[1].text.contains("👍")); + // The speaker's own steps stay as they are. + let own = super::group_history(&msgs, Some(a), &st); + assert_eq!(own.len(), 4); + } + + #[test] + fn transcript_export_reads_like_a_chat() { + let st = State::demo(); + let b = &st.bots[0]; + let md = super::transcript_markdown(b); + assert!(md.starts_with(&format!("# {} 的對話", b.name))); + assert!(md.contains("**你**:")); + } + + use super::*; + + /// The relaunch helper waits for the old copy to exit before starting the new one. + #[test] + fn relaunch_waits_for_the_old_copy() { + let dir = std::env::temp_dir().join(format!("anybot-relaunch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let marker = dir.join("started"); + let fake_app = dir.join("fake-app.sh"); + std::fs::write(&fake_app, format!("#!/bin/sh\necho \"${{ANYBOT_SHOT_VIEW:-clean}}\" > '{}'\n", marker.display())).unwrap(); + std::process::Command::new("chmod").arg("+x").arg(&fake_app).status().unwrap(); + let mut old = std::process::Command::new("sleep").arg("1").spawn().unwrap(); + // A leftover start-up switch in this copy's environment must not reach the new copy. + std::env::set_var("ANYBOT_SHOT_VIEW", "leftover"); + relaunch_command(old.id(), &fake_app).spawn().unwrap(); + std::env::remove_var("ANYBOT_SHOT_VIEW"); + std::thread::sleep(std::time::Duration::from_millis(500)); + assert!(!marker.exists(), "started before the old copy exited"); + old.wait().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(700)); + assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "clean"); + } +} diff --git a/src/app/attach_ui.rs b/src/app/attach_ui.rs new file mode 100644 index 0000000..032bb61 --- /dev/null +++ b/src/app/attach_ui.rs @@ -0,0 +1,394 @@ +// Attachments in the window: the chips above the message box, the + button, dropping files on +// the window, pasting a picture with ⌘V, and the thumbnails in sent messages. + +use super::*; +use crate::attach::{self, Attachment}; + +#[derive(Default)] +pub struct AttachUi { + pub pending: HashMap>, + picked: Option)>>, + thumbs: HashMap>, + pub error: Option, + voice: Option<(u64, crate::voice::Recording)>, + voice_rx: Option)>>, + /// The recording is a meeting (longer; becomes a transcript to summarize). + pub meeting: bool, + /// 通話模式: the bot being talked to, and where the call is. + pub call: Option<(u64, CallPhase)>, + call_rec: Option, + call_rx: Option>>, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum CallPhase { + Listening, + Transcribing, + Thinking, + Speaking, +} + +impl CallPhase { + pub fn label(self) -> &'static str { + match self { + CallPhase::Listening => "正在聽…", + CallPhase::Transcribing => "聽懂中…", + CallPhase::Thinking => "想一下…", + CallPhase::Speaking => "正在說…", + } + } +} + +impl App { + fn add_attachment_paths(&mut self, bot_id: u64, paths: Vec) { + for path in paths { + match attach::store(bot_id, &path) { + Ok(a) => self.att.pending.entry(bot_id).or_default().push(a), + Err(e) => self.att.error = Some(e), + } + } + } + + /// Files dropped on the window, the picker's answer, and ⌘V with a picture on the clipboard. + pub(super) fn attachments_poll(&mut self, ctx: &egui::Context, bot_id: u64, composer_focused: bool) { + let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect()); + if !dropped.is_empty() { + self.add_attachment_paths(bot_id, dropped); + } + if let Some(rx) = &self.att.picked { + if let Ok((id, paths)) = rx.try_recv() { + self.att.picked = None; + self.add_attachment_paths(id, paths); + } + } + // ⌘V with no text on the clipboard: maybe a picture. + let pasted_text = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(t) if !t.is_empty()))); + let cmd_v = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command))); + if composer_focused && cmd_v && !pasted_text && !self.demo { + if let Some(a) = attach::paste_image(bot_id) { + self.att.pending.entry(bot_id).or_default().push(a); + } + } + } + + pub(super) fn pick_attachments(&mut self, ctx: &egui::Context, bot_id: u64) { + let (tx, rx) = channel(); + self.att.picked = Some(rx); + let c = ctx.clone(); + std::thread::spawn(move || { + let paths = attach::pick_files(); + let _ = tx.send((bot_id, paths)); + c.request_repaint(); + }); + } + + /// "Drop to attach" while files hover over the window. + pub(super) fn drop_overlay(&self, ctx: &egui::Context) { + if !ctx.input(|i| !i.raw.hovered_files.is_empty()) { + return; + } + let p = self.pal; + let screen = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("drop-overlay"))); + painter.rect_filled(screen, 0, p.pane.gamma_multiply(0.85)); + let inner = screen.shrink(24.0); + painter.rect_stroke(inner, 18, Stroke::new(2.0, p.accent), egui::StrokeKind::Inside); + painter.text(inner.center(), egui::Align2::CENTER_CENTER, "放開以附加檔案", egui::FontId::proportional(FONT_XL), p.accent); + } + + fn thumb(&mut self, ctx: &egui::Context, path: &str) -> Option { + if let Some(t) = self.att.thumbs.get(path) { + return t.clone(); + } + let t = image::open(path).ok().map(|img| { + let img = img.thumbnail(240, 240).to_rgba8(); + let size = [img.width() as usize, img.height() as usize]; + ctx.load_texture(format!("thumb-{path}"), egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR) + }); + self.att.thumbs.insert(path.to_string(), t.clone()); + t + } + + /// One attachment: a picture thumbnail, or a file chip. Returns true when its × was clicked. + pub(super) fn attachment_chip(&mut self, ui: &mut egui::Ui, a: &Attachment, removable: bool) -> bool { + let p = self.pal; + let ctx = ui.ctx().clone(); + let mut remove = false; + let thumb = if a.image { self.thumb(&ctx, &a.path) } else { None }; + let frame = Frame::new().fill(p.hover).corner_radius(10).inner_margin(if thumb.is_some() { Margin::same(3) } else { Margin::symmetric(10, 6) }); + let r = frame + .show(ui, |ui| { + ui.horizontal(|ui| { + if let Some(t) = &thumb { + let s = t.size_vec2(); + let k = (64.0 / s.y).min(120.0 / s.x); + ui.add(egui::Image::new((t.id(), s * k)).corner_radius(8)); + } else { + let (rect, _) = ui.allocate_exact_size(vec2(14.0, 16.0), egui::Sense::hover()); + let r = rect.shrink2(vec2(1.5, 1.0)); + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.3, p.text2), egui::StrokeKind::Middle); + ui.painter().line_segment([r.left_top() + vec2(3.0, 5.0), r.right_top() + vec2(-3.0, 5.0)], Stroke::new(1.0, p.text3)); + ui.painter().line_segment([r.left_top() + vec2(3.0, 8.5), r.right_top() + vec2(-3.0, 8.5)], Stroke::new(1.0, p.text3)); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + let name: String = a.name.chars().take(28).collect(); + ui.label(RichText::new(name).size(FONT_XS).color(p.text)); + ui.label(RichText::new(attach::human(a.bytes)).size(10.5).color(p.text3)); + }); + } + if removable && ui.add(egui::Button::new(RichText::new("×").size(13.0).color(p.text3)).frame(false)).on_hover_text(t("移除")).clicked() { + remove = true; + } + }); + }) + .response; + if !removable && r.interact(egui::Sense::click()).on_hover_text(t("打開")).clicked() { + crate::preview::open_with_system(&a.path); + } + remove + } + + /// The chips above the message box. + pub(super) fn pending_chips(&mut self, ui: &mut egui::Ui, bot_id: u64) { + let list = self.att.pending.get(&bot_id).cloned().unwrap_or_default(); + if list.is_empty() && self.att.error.is_none() { + return; + } + let mut remove = None; + ui.horizontal_wrapped(|ui| { + for (i, a) in list.iter().enumerate() { + if self.attachment_chip(ui, a, true) { + remove = Some(i); + } + } + }); + if let Some(i) = remove { + if let Some(v) = self.att.pending.get_mut(&bot_id) { + let a = v.remove(i); + let _ = std::fs::remove_file(&a.path); + } + } + if let Some(e) = self.att.error.clone() { + ui.horizontal(|ui| { + ui.label(RichText::new(e).size(FONT_XS).color(self.pal.danger)); + if ui.add(egui::Button::new(RichText::new(t("知道了")).size(FONT_XS)).frame(false)).clicked() { + self.att.error = None; + } + }); + } + ui.add_space(4.0); + } + + /// Take this bot's pending attachments for a message (copying them onto its own computer when + /// it's running). + pub(super) fn take_attachments(&mut self, bot_id: u64) -> Vec { + let mut list = self.att.pending.remove(&bot_id).unwrap_or_default(); + for a in list.iter_mut() { + if let Ok(dst) = crate::cloudbox::copy_in(&a.path, bot_id, &a.name) { + a.on_box = dst; + } + } + list + } + + pub(super) fn has_pending_attachments(&self, bot_id: u64) -> bool { + self.att.pending.get(&bot_id).map(|v| !v.is_empty()).unwrap_or(false) + } +} + +// ---------- 語音輸入 ---------- + +impl App { + pub(super) fn voice_recording(&self, bot_id: u64) -> Option { + self.att.voice.as_ref().filter(|(id, _)| *id == bot_id).map(|(_, r)| r.started) + } + + pub(super) fn voice_busy(&self) -> bool { + self.att.voice_rx.is_some() + } + + /// The mic button: start, or stop and turn the recording into text. + pub(super) fn voice_toggle(&mut self, ctx: &egui::Context, bot_id: u64) { + if let Some((id, rec)) = self.att.voice.take() { + let lang = self.state.bot(id).map(|b| b.voice_lang.clone()).unwrap_or_default(); + let ps = self.state.provider(ProviderKind::OpenAi); + let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url); + let key = providers::resolve_key(ProviderKind::OpenAi, &ps.api_key); + let (tx, rx) = channel(); + self.att.voice_rx = Some(rx); + let c = ctx.clone(); + std::thread::spawn(move || { + let r = rec.finish().and_then(|f| { + let t = crate::voice::transcribe_lang(&base, &key, &f, &lang); + let _ = std::fs::remove_file(&f); + t + }); + let _ = tx.send((id, r)); + c.request_repaint(); + }); + return; + } + crate::voice::stop_speaking(); + match crate::voice::start() { + Ok(r) => self.att.voice = Some((bot_id, r)), + Err(e) => self.att.error = Some(e), + } + } + + /// 會議記錄: a long recording that becomes a transcript for the bot to summarize. + pub(super) fn start_meeting(&mut self, ctx: &egui::Context, bot_id: u64) { + if self.att.voice.is_some() { + return; + } + self.att.meeting = true; + self.voice_toggle(ctx, bot_id); + if self.att.voice.is_none() { + self.att.meeting = false; + } + } + + pub(super) fn meeting_on(&self) -> bool { + self.att.meeting && self.att.voice.is_some() + } + + /// Put the text where the person was typing. + pub(super) fn voice_poll(&mut self) { + let max = if self.att.meeting { crate::voice::MEETING_MAX_SECS } else { crate::voice::MAX_SECS }; + if let Some((started, max)) = self.att.voice.as_ref().map(|(_, r)| (r.started, max)) { + if started.elapsed().as_secs() >= max { + if let Some((_, r)) = self.att.voice.take() { + r.cancel(); + } + self.att.error = Some("錄音最長 10 分鐘,已經停止。".into()); + } + } + let Some(rx) = &self.att.voice_rx else { return }; + if let Ok((id, r)) = rx.try_recv() { + self.att.voice_rx = None; + let meeting = std::mem::take(&mut self.att.meeting); + match r { + Ok(t) if t.is_empty() => self.att.error = Some("沒有聽到說話。".into()), + Ok(t) if meeting => { + // The transcript goes with the message as a file; the bot is asked to sum it up. + let dir = std::env::temp_dir().join("anybot-meetings"); + let _ = std::fs::create_dir_all(&dir); + let file = dir.join(format!("會議記錄 {}.txt", chrono::Local::now().format("%Y-%m-%d %H%M"))); + match std::fs::write(&file, &t).map_err(|e| e.to_string()).and_then(|_| crate::attach::store(id, &file)) { + Ok(a) => { + self.att.pending.entry(id).or_default().push(a); + self.drafts.insert(id, "這是剛才會議的文字記錄,請整理:重點、做了哪些決定、待辦事項(誰、做什麼、什麼時候)。".into()); + } + Err(e) => self.att.error = Some(format!("存不了會議記錄:{e}")), + } + } + Ok(t) => { + let d = self.drafts.entry(id).or_default(); + if !d.is_empty() && !d.ends_with([' ', '\n']) { + d.push(' '); + } + d.push_str(&t); + } + Err(e) => self.att.error = Some(e), + } + } + } +} + +// ---------- 通話模式 ---------- + +impl App { + pub(super) fn call_start(&mut self, bot_id: u64) { + crate::voice::stop_speaking(); + self.att.call = Some((bot_id, CallPhase::Listening)); + self.att.call_rec = None; + self.att.call_rx = None; + } + + pub(super) fn call_end(&mut self) { + if let Some(r) = self.att.call_rec.take() { + r.cancel(); + } + crate::voice::stop_speaking(); + self.att.call = None; + self.att.call_rx = None; + } + + pub(super) fn call_state(&self) -> Option<(u64, CallPhase)> { + self.att.call + } + + /// Listen → understand → the bot answers → it's read aloud → listen again. + pub(super) fn call_poll(&mut self, ctx: &egui::Context) { + let Some((bot_id, phase)) = self.att.call else { return }; + ctx.request_repaint_after(std::time::Duration::from_millis(150)); + match phase { + CallPhase::Listening => { + if self.att.call_rec.is_none() { + match crate::voice::listen() { + Ok(r) => self.att.call_rec = Some(r), + Err(e) => { + self.att.error = Some(e); + self.call_end(); + } + } + return; + } + if self.att.call_rec.as_mut().map(|r| r.done()).unwrap_or(false) { + let rec = self.att.call_rec.take().unwrap(); + let lang = self.state.bot(bot_id).map(|b| b.voice_lang.clone()).unwrap_or_default(); + let ps = self.state.provider(ProviderKind::OpenAi); + let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url); + let key = providers::resolve_key(ProviderKind::OpenAi, &ps.api_key); + let (tx, rx) = channel(); + self.att.call_rx = Some(rx); + let c = ctx.clone(); + std::thread::spawn(move || { + let r = rec.finish().and_then(|f| { + let t = crate::voice::transcribe_lang(&base, &key, &f, &lang); + let _ = std::fs::remove_file(&f); + t + }); + let _ = tx.send(r); + c.request_repaint(); + }); + self.att.call = Some((bot_id, CallPhase::Transcribing)); + } + } + CallPhase::Transcribing => { + let Some(rx) = &self.att.call_rx else { return }; + if let Ok(r) = rx.try_recv() { + self.att.call_rx = None; + match r { + Ok(t) if !t.trim().is_empty() => { + self.drafts.insert(bot_id, t); + self.send(ctx, bot_id); + self.att.call = Some((bot_id, CallPhase::Thinking)); + } + // Nothing said: keep listening; a real error ends the call. + Err(e) if !e.contains("沒有聽到") => { + self.att.error = Some(e); + self.call_end(); + } + _ => self.att.call = Some((bot_id, CallPhase::Listening)), + } + } + } + CallPhase::Thinking => { + if !self.streams.contains_key(&bot_id) && !self.approvals.contains_key(&bot_id) { + let answer = self.state.bot(bot_id).and_then(|b| b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.trim().is_empty())).map(|m| m.text.clone()); + // Answers are read here, not by the 朗讀 switch (no double reading). + if let Some(a) = answer { + let (v, r) = self.state.bot(bot_id).map(|b| (b.voice.clone(), b.voice_rate)).unwrap_or_default(); + crate::voice::speak_as(&a, &v, if r > 0.0 { r } else { 1.0 }); + } + self.att.call = Some((bot_id, CallPhase::Speaking)); + } + } + CallPhase::Speaking => { + if !crate::voice::speaking() { + self.att.call = Some((bot_id, CallPhase::Listening)); + } + } + } + } +} diff --git a/src/app/channels_ui.rs b/src/app/channels_ui.rs new file mode 100644 index 0000000..744fd39 --- /dev/null +++ b/src/app/channels_ui.rs @@ -0,0 +1,445 @@ +// 訊息管道 in the window: the settings page, turning incoming messages into turns, and posting +// the bot's answer back when the turn ends. + +use super::*; +use crate::channels::{self, Channel, Incoming, Kind, ReplyTo}; + +pub struct ChannelUi { + pub tx: Sender, + pub rx: Receiver, + /// Where each bot's current answer goes back to. + pub replies: HashMap>, + new_kind: Kind, + new_name: String, + new_token: String, + new_target: String, + new_bot: u64, + pub note: Arc>, + started: bool, + trig_tx: Sender, + trig_rx: Receiver, + /// What the running watchers were started with (restart when it changes). + trig_sig: String, + new_client: String, + new_tenant: String, + /// Teams sign-in: the code to show, and the refresh token when done. + ms_login: Arc)>>, + mcp_sig: String, + mcp_errors: Arc>>, + mcp_busy: Arc, + new_mcp: (String, String, String, String), + /// 連接器市集 was asked for (scroll it into view once). + pub focus_market: bool, +} + +impl Default for ChannelUi { + fn default() -> Self { + let (tx, rx) = channel(); + let (trig_tx, trig_rx) = channel(); + ChannelUi { tx, rx, replies: HashMap::new(), new_kind: Kind::Telegram, new_name: String::new(), new_token: String::new(), new_target: String::new(), new_bot: 0, note: Default::default(), started: false, trig_tx, trig_rx, trig_sig: String::new(), new_client: String::new(), new_tenant: String::new(), ms_login: Default::default(), mcp_sig: String::new(), mcp_errors: Default::default(), mcp_busy: Default::default(), new_mcp: Default::default(), focus_market: false } + } +} + +impl App { + pub(super) fn channels_restart(&mut self, ctx: &egui::Context) { + if self.demo { + return; + } + let c = ctx.clone(); + channels::restart(self.state.channels.clone(), self.ch.tx.clone(), Arc::new(move || c.request_repaint())); + self.ch.started = true; + } + + /// Event-triggered routines: (re)start their watchers when they change, and run them when they fire. + fn triggers_poll(&mut self, ctx: &egui::Context) { + let watches: Vec = self + .state + .bots + .iter() + .flat_map(|b| b.routines.iter().filter(|r| !r.paused).filter_map(move |r| r.trigger.clone().map(|t| crate::triggers::Watch { bot_id: b.id, routine_id: r.id, trigger: t }))) + .collect(); + let sig = watches.iter().map(|w| format!("{}:{}:{:?}", w.bot_id, w.routine_id, w.trigger)).collect::>().join("|"); + if sig != self.ch.trig_sig { + self.ch.trig_sig = sig; + let slack = self.state.channels.iter().find(|c| c.kind == Kind::Slack && !c.token.trim().is_empty()).map(|c| c.token.clone()).unwrap_or_default(); + let c = ctx.clone(); + crate::triggers::restart(watches, slack, self.ch.trig_tx.clone(), Arc::new(move || c.request_repaint())); + } + while let Ok(f) = self.ch.trig_rx.try_recv() { + let Some(b) = self.state.bot_mut(f.bot_id) else { continue }; + let Some(r) = b.routines.iter_mut().find(|r| r.id == f.routine_id) else { continue }; + if r.paused { + continue; + } + r.last_run = Some(crate::routines::now()); + let text = format!("[觸發]例行任務「{}」被觸發:{}\n要做的事:{}", r.title, f.what, r.prompt); + self.wake(ctx, f.bot_id, Message { wake: true, ..Message::user(&text) }); + self.dirty = true; + } + } + + /// New messages from Slack / Discord / Telegram wake their bot. + pub(super) fn channels_poll(&mut self, ctx: &egui::Context) { + if !self.ch.started { + self.channels_restart(ctx); + } + self.triggers_poll(ctx); + self.mcp_poll(ctx); + // Microsoft rotates refresh tokens: keep the newest. + let rotated: Vec<(u64, String)> = crate::teams::rotated().lock().unwrap().iter().map(|(k, v)| (*k, v.clone())).collect(); + for (id, tok) in rotated { + if let Some(c) = self.state.channels.iter_mut().find(|c| c.id == id && c.token != tok) { + c.token = tok; + self.dirty = true; + } + } + while let Ok(m) = self.ch.rx.try_recv() { + if self.state.bot(m.bot_id).is_none() { + continue; + } + let text = format!("[訊息管道]{}「{}」的 {}:{}", m.reply.kind.label(), m.reply.channel_name, m.from, m.text); + self.ch.replies.entry(m.bot_id).or_default().push(m.reply.clone()); + self.wake(ctx, m.bot_id, Message { wake: true, ..Message::user(&text) }); + self.dirty = true; + } + } + + /// The turn ended: send its answer back where the message came from. + pub(super) fn channels_settled(&mut self, bot_id: u64, outcome: TurnOutcome) { + let Some(targets) = self.ch.replies.remove(&bot_id) else { return }; + let answer = match outcome { + TurnOutcome::Success => self.state.bot(bot_id).and_then(|b| b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.trim().is_empty() && !m.error)).map(|m| m.text.clone()), + TurnOutcome::Error => Some("抱歉,這次沒有完成,請再試一次。".to_string()), + TurnOutcome::Cancelled => None, + }; + let Some(answer) = answer else { return }; + let note = self.ch.note.clone(); + std::thread::spawn(move || { + for t in targets { + if let Err(e) = channels::reply(&t, &answer) { + *note.lock().unwrap() = format!("回覆到 {}「{}」失敗:{e}", t.kind.label(), t.channel_name); + } + } + }); + } + + pub(super) fn channels_page(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + let p = self.pal; + setting_card(ui, p, |ui| { + setting_title(ui, "訊息管道", "讓 Bot 在 Slack、Discord、Telegram、Microsoft Teams、Mac 的「訊息」或「郵件」回覆別人。收到訊息時 Bot 會像收到你的訊息一樣開始工作,做完後把回答傳回去(Slack 回在討論串,Discord 和 Telegram 引用原訊息)。AnyBot 開著時才會收訊息。", p); + hint(ui, "Microsoft Teams 用你自己的 Microsoft 帳號登入(裝置碼),不需要公開網址;Bot 會以你的身分在聊天或頻道裡回覆。", p); + }); + let mut remove = None; + let mut changed = false; + let statuses = channels::statuses().lock().unwrap().clone(); + let bots: Vec<(u64, String)> = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect(); + for (i, c) in self.state.channels.iter_mut().enumerate() { + setting_card(ui, p, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(c.kind.label()).size(FONT_XS).color(p.text2)); + ui.label(RichText::new(&c.name).strong().size(FONT_BASE)); + let st = statuses.get(&c.id); + let (col, text) = match st { + _ if !c.enabled => (p.text3, "已暫停".to_string()), + Some(s) if s.ok => (Color32::from_rgb(0x14, 0xc3, 0x70), format!("{}{}", s.note, if s.received > 0 { format!("(收到 {} 則)", s.received) } else { String::new() })), + Some(s) => (p.danger, s.note.clone()), + None => (p.text3, "連線中…".into()), + }; + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 4.0, col); + ui.add(egui::Label::new(RichText::new(text).size(11.5).color(p.text3)).truncate()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("移除")).size(12.0).color(p.danger)).frame(false)).clicked() { + remove = Some(i); + } + if ui.checkbox(&mut c.enabled, t("開啟")).changed() { + changed = true; + } + }); + }); + let bot = bots.iter().find(|b| b.0 == c.bot_id).map(|b| b.1.clone()).unwrap_or_else(|| "(Bot 已刪除)".into()); + hint(ui, format!("由「{bot}」回覆 · {}{}", if c.target.is_empty() { "任何聊天".to_string() } else { c.target.clone() }, if c.kind == Kind::Telegram { "" } else { "" }), p); + }); + } + if let Some(i) = remove { + self.state.channels.remove(i); + changed = true; + } + // ── 新增 ── + setting_card(ui, p, |ui| { + setting_title(ui, "新增管道", "", p); + // Wraps onto a second line in a narrow window instead of widening the dialog. + ui.horizontal_wrapped(|ui| { + for k in [Kind::Telegram, Kind::Slack, Kind::Discord, Kind::Teams, Kind::IMessage, Kind::Email, Kind::Sms, Kind::X] { + if ui.selectable_label(self.ch.new_kind == k, k.label()).clicked() && self.ch.new_kind != k { + self.ch.new_kind = k; + self.ch.new_token.clear(); + self.ch.new_target.clear(); + } + } + }); + let k = self.ch.new_kind; + if self.ch.new_bot == 0 || !bots.iter().any(|b| b.0 == self.ch.new_bot) { + self.ch.new_bot = self.state.selected; + } + form_row(ui, "名稱", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.ch.new_name).hint_text(t("例如 客服群")).desired_width(ui.available_width())); + }); + form_row(ui, "回覆的 Bot", p, |ui| { + let cur = bots.iter().find(|b| b.0 == self.ch.new_bot).map(|b| b.1.clone()).unwrap_or_default(); + egui::ComboBox::from_id_salt("channel-bot").selected_text(cur).show_ui(ui, |ui| { + for (id, name) in &bots { + ui.selectable_value(&mut self.ch.new_bot, *id, name); + } + }); + }); + if k == Kind::Teams { + form_row(ui, "應用程式 ID", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.ch.new_client).hint_text(t("Entra 應用程式(用戶端)ID")).desired_width(ui.available_width()).font(egui::TextStyle::Monospace)); + }); + form_row(ui, "租用戶", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.ch.new_tenant).hint_text(t("留空 = 公司或學校帳號")).desired_width(ui.available_width()).font(egui::TextStyle::Monospace)); + }); + hint(ui, k.token_hint(), p); + // A finished sign-in fills the token. + if let Some(tok) = self.ch.ms_login.lock().unwrap().1.take() { + self.ch.new_token = tok; + } + ui.horizontal(|ui| { + let signed = !self.ch.new_token.is_empty(); + if ui.add_enabled(!self.ch.new_client.trim().is_empty(), secondary_button(if signed { "重新登入 Microsoft" } else { "登入 Microsoft" })).clicked() { + let (client, tenant, slot, c) = (self.ch.new_client.clone(), self.ch.new_tenant.clone(), self.ch.ms_login.clone(), ctx.clone()); + slot.lock().unwrap().0 = "正在取得登入代碼…".into(); + std::thread::spawn(move || { + match crate::teams::device_code(&client, &tenant) { + Ok((code, url, device, interval)) => { + slot.lock().unwrap().0 = format!("在打開的網頁輸入代碼:{code}({url})"); + crate::preview::open_with_system(&url); + c.request_repaint(); + let r = crate::teams::wait_for_login(&client, &tenant, &device, interval, std::time::Duration::from_secs(900)); + let mut s = slot.lock().unwrap(); + match r { + Ok(t) => { + s.0 = "已登入 Microsoft。".into(); + s.1 = Some(t); + } + Err(e) => s.0 = e, + } + } + Err(e) => slot.lock().unwrap().0 = format!("拿不到登入代碼:{e}"), + } + c.request_repaint(); + }); + } + let msg = self.ch.ms_login.lock().unwrap().0.clone(); + if !msg.is_empty() { + ui.label(RichText::new(msg).size(FONT_XS).color(p.text2)); + } + }); + } else if matches!(k, Kind::IMessage | Kind::Email) { + hint(ui, k.token_hint(), p); + self.ch.new_token = "local".into(); + } else { + form_row(ui, "Token", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.ch.new_token).password(true).hint_text(t("貼上 Bot Token")).desired_width(ui.available_width())); + }); + hint(ui, k.token_hint(), p); + } + form_row(ui, match k { Kind::Telegram => "聊天 ID", Kind::Teams => "聊天或頻道", Kind::IMessage => "對象", Kind::Email => "信箱", Kind::Sms => "你的號碼", Kind::X => "(不用填)", _ => "頻道 ID" }, p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.ch.new_target).hint_text(k.target_hint()).desired_width(ui.available_width()).font(egui::TextStyle::Monospace)); + }); + let needs_target = !matches!(k, Kind::Telegram | Kind::IMessage | Kind::Email | Kind::X); + let ok = !self.ch.new_token.trim().is_empty() && (!needs_target || !self.ch.new_target.trim().is_empty()); + ui.horizontal(|ui| { + if k != Kind::Teams && ui.add_enabled(!self.ch.new_token.trim().is_empty(), secondary_button(if matches!(k, Kind::IMessage | Kind::Email) { "測試" } else { "測試 Token" })).clicked() { + let (kind, token, note) = (k, self.ch.new_token.clone(), self.ch.note.clone()); + *note.lock().unwrap() = "測試中…".into(); + let c = ctx.clone(); + std::thread::spawn(move || { + *note.lock().unwrap() = channels::test(kind, &token).unwrap_or_else(|e| e); + c.request_repaint(); + }); + } + if ui.add_enabled(ok, primary_button("新增", p)).clicked() { + let id = self.state.next_channel_id.max(1); + self.state.next_channel_id = id + 1; + let name = if self.ch.new_name.trim().is_empty() { k.label().to_string() } else { self.ch.new_name.trim().to_string() }; + self.state.channels.push(Channel { id, kind: k, name, bot_id: self.ch.new_bot, token: self.ch.new_token.trim().into(), target: self.ch.new_target.trim().into(), enabled: true, client_id: self.ch.new_client.trim().into(), tenant: self.ch.new_tenant.trim().into() }); + self.ch.new_client.clear(); + self.ch.new_tenant.clear(); + self.ch.new_name.clear(); + self.ch.new_token.clear(); + self.ch.new_target.clear(); + changed = true; + } + }); + let note = self.ch.note.lock().unwrap().clone(); + if !note.is_empty() { + ui.label(RichText::new(note).size(FONT_XS).color(p.text2)); + } + }); + if changed { + self.dirty = true; + self.channels_restart(ctx); + } + } +} + +// ---------- 連接器 (MCP) ---------- + +impl App { + /// Connect the configured MCP servers when they change (in the background). + fn mcp_poll(&mut self, ctx: &egui::Context) { + let sig = serde_json::to_string(&self.state.mcp_servers).unwrap_or_default(); + if sig == self.ch.mcp_sig || self.ch.mcp_busy.load(Ordering::SeqCst) { + return; + } + self.ch.mcp_sig = sig; + let servers = self.state.mcp_servers.clone(); + let (errs, busy, c) = (self.ch.mcp_errors.clone(), self.ch.mcp_busy.clone(), ctx.clone()); + busy.store(true, Ordering::SeqCst); + std::thread::spawn(move || { + *errs.lock().unwrap() = crate::mcp::sync(&servers); + busy.store(false, Ordering::SeqCst); + c.request_repaint(); + }); + } + + pub(super) fn connectors_page(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + let p = self.pal; + let before = serde_json::to_string(&self.state.mcp_servers).unwrap_or_default(); + setting_card(ui, p, |ui| { + setting_title(ui, "連接器(MCP)", "接上 Model Context Protocol 伺服器,Bot 就能用它的工具(例如 GitHub、Notion、資料庫、你公司的內部系統)。每個連接器第一次被使用時會先問你。", p); + hint(ui, "本機伺服器填指令(例如 npx -y @modelcontextprotocol/server-filesystem ~/Documents);遠端伺服器填網址(Streamable HTTP),需要時加上 Authorization 標頭。", p); + }); + let status = crate::mcp::status(); + let errors = self.ch.mcp_errors.lock().unwrap().clone(); + let busy = self.ch.mcp_busy.load(Ordering::SeqCst); + let mut remove = None; + for (i, s) in self.state.mcp_servers.iter_mut().enumerate() { + setting_card(ui, p, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&s.name).strong().size(FONT_BASE)); + let (col, text) = match (s.enabled, status.get(&s.name), errors.iter().find(|e| e.0 == s.name)) { + (false, _, _) => (p.text3, "已關閉".to_string()), + (_, Some(n), _) => (Color32::from_rgb(0x14, 0xc3, 0x70), format!("已連上,{n} 個工具")), + (_, _, Some(e)) => (p.danger, e.1.clone()), + _ if busy => (p.text3, "連線中…".to_string()), + _ => (p.text3, "未連線".to_string()), + }; + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 4.0, col); + ui.add(egui::Label::new(RichText::new(text).size(11.5).color(p.text3)).truncate()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("移除")).size(12.0).color(p.danger)).frame(false)).clicked() { + remove = Some(i); + } + ui.checkbox(&mut s.enabled, t("開啟")); + }); + }); + let what = if s.url.is_empty() { format!("{} {}", s.command, s.args.join(" ")) } else { s.url.clone() }; + ui.label(RichText::new(what).monospace().size(11.5).color(p.text3)); + field_label(ui, "給 Bot 的使用說明(選填)", p); + ui.add(egui::TextEdit::multiline(&mut s.instructions).desired_rows(1).desired_width(f32::INFINITY).hint_text(t("例如:查詢客戶資料時先用 search_customers"))); + }); + } + if let Some(i) = remove { + self.state.mcp_servers.remove(i); + } + let tools = crate::mcp::tools(); + if !tools.is_empty() { + setting_card(ui, p, |ui| { + setting_title(ui, "可以用的工具", "", p); + for t in tools.iter().take(60) { + ui.label(RichText::new(format!("{} · {}", t.server, t.name)).size(FONT_XS).color(p.text2)).on_hover_text(&t.description); + } + }); + } + setting_card(ui, p, |ui| { + setting_title(ui, "技能", "所有 Bot 共用的做法說明。在聊天裡教 Bot 一套做法並請它「存成技能」;Bot 需要時會先讀取再照做。", p); + if self.state.skills.is_empty() { + hint(ui, "還沒有技能。", p); + } + let mut remove = None; + for (i, k) in self.state.skills.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(RichText::new(&k.name).strong().size(FONT_SM)).on_hover_text(&k.instructions); + ui.add(egui::Label::new(RichText::new(&k.description).size(FONT_XS).color(p.text3)).truncate()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.text3)).frame(false)).clicked() { + remove = Some(i); + } + }); + }); + } + if let Some(i) = remove { + self.state.skills.remove(i); + self.dirty = true; + } + }); + // 外掛市集: common connectors, one click away (ones needing a key fill the form below). + setting_card(ui, p, |ui| { + if std::mem::take(&mut self.ch.focus_market) { + ui.scroll_to_cursor(Some(Align::TOP)); + } + setting_title(ui, "連接器市集", "常用的連接器,按「加入」就能用。需要金鑰的會填好下面的表單,補上金鑰再按「新增」。本機的需要先裝 Node.js(npx)或 uv(uvx)。", p); + for (name, desc, cmd, needs) in crate::mcp::MARKET { + let added = self.state.mcp_servers.iter().any(|s| s.name == name); + ui.horizontal(|ui| { + ui.label(RichText::new(name).strong().size(FONT_SM)); + ui.add(egui::Label::new(RichText::new(desc).size(FONT_XS).color(p.text3)).truncate()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if added { + ui.label(RichText::new(t("已加入")).size(12.0).color(p.text3)); + } else if ui.add(secondary_button("加入")).clicked() { + if needs.is_empty() { + self.state.mcp_servers.push(crate::mcp::market_server(name, cmd)); + } else { + self.ch.new_mcp = (name.to_string(), cmd.to_string(), if cmd.starts_with("http") { String::new() } else { needs.to_string() }, if cmd.starts_with("http") { needs.to_string() } else { String::new() }); + } + } + }); + }); + } + }); + setting_card(ui, p, |ui| { + setting_title(ui, "新增連接器", "", p); + let (name, cmd, env, headers) = &mut self.ch.new_mcp; + form_row(ui, "名稱", p, |ui| { + ui.add(egui::TextEdit::singleline(name).hint_text(t("例如 github")).desired_width(ui.available_width())); + }); + form_row(ui, "指令或網址", p, |ui| { + ui.add(egui::TextEdit::singleline(cmd).hint_text(t("npx -y @modelcontextprotocol/server-github 或 https://…/mcp")).desired_width(ui.available_width()).font(egui::TextStyle::Monospace)); + }); + form_row(ui, "環境變數", p, |ui| { + ui.add(egui::TextEdit::singleline(env).hint_text(t("GITHUB_TOKEN=…(多個用空白分開,本機伺服器用)")).password(true).desired_width(ui.available_width())); + }); + form_row(ui, "標頭", p, |ui| { + ui.add(egui::TextEdit::singleline(headers).hint_text(t("Authorization: Bearer …(遠端伺服器用)")).password(true).desired_width(ui.available_width())); + }); + let ok = !name.trim().is_empty() && !cmd.trim().is_empty() && !self.state.mcp_servers.iter().any(|s| s.name == name.trim()); + if ui.add_enabled(ok, primary_button("新增", p)).clicked() { + let c = cmd.trim().to_string(); + let is_url = c.starts_with("http://") || c.starts_with("https://"); + let parts: Vec = c.split_whitespace().map(String::from).collect(); + let envs = env.split_whitespace().filter_map(|kv| kv.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect(); + let hdrs = headers.split_once(':').map(|(k, v)| std::iter::once((k.trim().to_string(), v.trim().to_string())).collect()).unwrap_or_default(); + self.state.mcp_servers.push(crate::mcp::Server { + name: name.trim().into(), + command: if is_url { String::new() } else { parts.first().cloned().unwrap_or_default() }, + args: if is_url { vec![] } else { parts.into_iter().skip(1).collect() }, + env: envs, + url: if is_url { c } else { String::new() }, + headers: hdrs, + enabled: true, + instructions: String::new(), + }); + self.ch.new_mcp = Default::default(); + } + }); + let _ = ctx; + if serde_json::to_string(&self.state.mcp_servers).unwrap_or_default() != before { + self.dirty = true; + } + } +} diff --git a/src/app/chat_ui.rs b/src/app/chat_ui.rs new file mode 100644 index 0000000..c37e043 --- /dev/null +++ b/src/app/chat_ui.rs @@ -0,0 +1,541 @@ +// 對話內容, like the original (captured from Grok Bot itself): +// • the bot's words in grey bubbles on the left (no avatar, no model name, no tool rows); +// the person's in the bot's colour on the right, text always reading left to right; +// • the date in small grey text between messages that are far apart; +// • on hover, three small buttons next to the bubble: 表情回應, 回覆, ⋯ (steps and thinking +// live behind ⋯); +// • files as cards that open a preview; +// • while it works: a spinning ring and a phrase that shimmers ("正在讀取檔案"…), picked from +// the original's own list by what the bot is doing; +// • motion from the original's tokens: rows fade/rise in over 240 ms, a reaction pops in over +// 500 ms with a little overshoot, the ring turns every 1.2 s, the shimmer sweeps every 1.4 s. + +use super::*; + +const ROW_ENTER: f32 = 0.24; +const REACTION_POP: f32 = 0.5; +const SPIN_SECS: f64 = 1.2; +const SHIMMER_SECS: f64 = 1.4; +/// Reactions offered at once (the rest are in "更多表情符號"). +const QUICK: [&str; 6] = ["❤️", "👍", "😂", "🎉", "🙏", "👀"]; +const MORE: [&str; 24] = ["😊", "😍", "🥹", "😮", "😢", "😡", "🤔", "👏", "🙌", "💪", "🔥", "✨", "✅", "❌", "💯", "🚀", "☕", "🍰", "🌟", "💡", "📌", "👌", "🤝", "🫶"]; + +/// The original's status phrases (renderer table vit). +const THINKING: [&str; 3] = ["正在仔細思考", "正在琢磨", "正在整理思緒"]; +const WORKING: [&str; 5] = ["正在完成", "正在推進", "正在處理", "正在搞定", "正在一點一點推進"]; + +/// What the bot is doing, as the original says it. +pub fn status_phrase(tool: Option<&str>, on_box: bool, waiting: bool, has_text: bool, seed: u64) -> String { + let pick = |list: &[&str]| list[(seed as usize) % list.len()].to_string(); + if waiting { + return "需要你的輸入".into(); + } + let Some(name) = tool else { return if has_text { pick(&WORKING) } else { pick(&THINKING) } }; + match name { + "read_file" | "list_dir" | "search" => "正在讀取檔案".into(), + "edit_file" | "send_file" => "正在記下來".into(), + "shell" | "write_terminal" => "正在執行幾個指令".into(), + "read_terminal" => "正在等待結果".into(), + "web_fetch" | "browser_read" => "正在閱讀資料".into(), + "browser_open" => "正在逐頁瀏覽".into(), + "message_bot" => "正在詢問另一個 Bot".into(), + "mac_send_mail" | "mac_send_message" | "send_sms" | "post_to_x" | "mac_draft_mail" => "正在傳送訊息".into(), + "publish_site" => "正在發布".into(), + "vault_list" => "正在查看保險庫".into(), + "browser_fill_login" | "browser_fill_card" | "browser_fill_contact" => "正在填入保險庫的資料".into(), + n if n.starts_with("computer_use") => if on_box { "正在四處點擊".into() } else { "正在你的電腦上工作".into() }, + n if n.contains("search") => "正在四處查找".into(), + n if n.starts_with("mcp__") => "正在已連結的應用程式中工作".into(), + n if n.starts_with("mac_") || n.starts_with("ios_") || n.starts_with("mobile_") => "正在你的電腦上工作".into(), + _ => pick(&WORKING), + } +} + +/// "11月2日週一 上午9:50" (today: just the time). +pub fn date_label(ms: i64) -> String { + use chrono::{Datelike, Local, TimeZone, Timelike}; + let Some(d) = Local.timestamp_millis_opt(ms).single() else { return String::new() }; + let now = Local::now(); + let wd = ["週一", "週二", "週三", "週四", "週五", "週六", "週日"][d.weekday().num_days_from_monday() as usize]; + let (h, ampm) = match d.hour() { + 0 => (12, "上午"), + h @ 1..=11 => (h, "上午"), + 12 => (12, "下午"), + h => (h - 12, "下午"), + }; + let time = format!("{}{}:{:02}", t(ampm), h, d.minute()); + if d.date_naive() == now.date_naive() { + time + } else if d.year() == now.year() { + format!("{}月{}日{} {time}", d.month(), d.day(), t(wd)) + } else { + format!("{}年{}月{}日 {time}", d.year(), d.month(), d.day()) + } +} + +/// An ease with a small overshoot (the reaction pop). +fn pop(x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); + let c1 = 1.70158; + let c3 = c1 + 1.0; + 1.0 + c3 * (x - 1.0).powi(3) + c1 * (x - 1.0).powi(2) +} + +fn ease_out(x: f32) -> f32 { + 1.0 - (1.0 - x.clamp(0.0, 1.0)).powi(3) +} + +/// The indeterminate ring (the original's working arc). +pub fn spinner(ui: &mut egui::Ui, size: f32, col: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(size, size), egui::Sense::hover()); + let time = ui.input(|i| i.time); + let c = rect.center(); + let r = size / 2.0 - 1.5; + ui.painter().circle_stroke(c, r, Stroke::new(2.0, col.gamma_multiply(0.2))); + let start = (time / SPIN_SECS * std::f64::consts::TAU) as f32; + let pts: Vec = (0..=16).map(|i| { + let a = start + 1.7 * i as f32 / 16.0; + c + vec2(a.cos(), a.sin()) * r + }).collect(); + ui.painter().add(egui::Shape::line(pts, Stroke::new(2.0, col))); + ui.ctx().request_repaint(); +} + +/// Text with a light band sweeping across it (the original's sand-shimmer-text). +pub fn shimmer(ui: &mut egui::Ui, text: &str, base: Color32, bright: Color32, size: f32) { + let time = ui.input(|i| i.time); + let chars: Vec = text.chars().collect(); + let n = chars.len() as f32; + let pos = ((time % SHIMMER_SECS) / SHIMMER_SECS) as f32 * (n + 6.0) - 3.0; + let mut job = egui::text::LayoutJob::default(); + for (i, ch) in chars.iter().enumerate() { + let k = (1.0 - ((i as f32 - pos).abs() / 3.0)).clamp(0.0, 1.0); + let col = base.lerp_to_gamma(bright, k); + job.append(&ch.to_string(), 0.0, egui::TextFormat { font_id: egui::FontId::proportional(size), color: col, ..Default::default() }); + } + ui.label(job); + ui.ctx().request_repaint(); +} + +impl App { + /// One message of the chat. + pub(super) fn chat_message(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, m: &Message, last: bool) { + let p = self.pal; + let idx = self.msg_index; + // Tool results and empty step messages stay out of the chat (⋯ › 顯示步驟 shows them). + if m.role == Role::Tool { + return; + } + let has_body = !m.text.trim().is_empty() || !m.attachments.is_empty() || m.tool_calls.iter().any(|c| c.name == "show_card"); + let steps_open = self.steps_open.contains(&(bot.id, idx)); + if m.role == Role::Assistant && !has_body && !m.summary && !steps_open { + return; + } + // The date, when this message is far from the one before it. + if m.at > 0 { + let prev = bot.messages[..idx].iter().rev().find(|x| x.at > 0 && x.role != Role::Tool).map(|x| x.at); + let new_day = |a: i64, b: i64| { + use chrono::TimeZone; + let d = |ms| chrono::Local.timestamp_millis_opt(ms).single().map(|x| x.date_naive()); + d(a) != d(b) + }; + if prev.map(|pa| m.at - pa > 30 * 60_000 || new_day(pa, m.at)).unwrap_or(true) { + ui.add_space(10.0); + ui.vertical_centered(|ui| ui.label(RichText::new(date_label(m.at)).size(12.0).color(p.text3))); + ui.add_space(10.0); + } + } + // New messages fade and rise in (240 ms). + let alpha = if m.at > self.started_ms { + let t0 = *self.appeared.entry((bot.id, idx)).or_insert_with(Instant::now); + let e = t0.elapsed().as_secs_f32() / ROW_ENTER; + if e < 1.0 { + ctx.request_repaint(); + } + ease_out(e) + } else { + 1.0 + }; + ui.scope(|ui| { + ui.set_opacity(alpha); + ui.add_space((1.0 - alpha) * 8.0); + match m.role { + Role::User => self.user_message(ui, ctx, bot, m, last, idx), + _ if m.summary => { + Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(14).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { + crate::icons::label(ui, crate::icons::Icon::Summary, &t("較早的對話已整理成摘要"), 13.0, p.text2); + egui::CollapsingHeader::new(RichText::new(t("展開摘要")).size(12.5).color(p.text3)).id_salt(("summary", bot.id, idx)).show(ui, |ui| { + self.markdown(ui, &m.text, p.text2, false); + }); + }); + ui.add_space(10.0); + } + _ => self.bot_message(ui, ctx, bot, m, last, idx), + } + }); + } + + fn user_message(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, m: &Message, last: bool, idx: usize) { + let p = self.pal; + let streaming = self.streams.contains_key(&bot.id); + for a in m.attachments.clone() { + ui.with_layout(Layout::right_to_left(Align::Min), |ui| self.file_card(ui, &a)); + ui.add_space(6.0); + } + // Messages that woke the bot (a routine, a trigger, a channel, another bot) carry a tag: + // shown as a small line with an icon above the bubble, not inside it. + let (tag, body) = wake_tag(&m.text); + if let Some((icon, label)) = tag { + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + crate::icons::label(ui, icon, &t(label), 12.0, p.text3); + }); + ui.add_space(2.0); + } + let shown_text = body.to_string(); + if !m.text.trim().is_empty() { + let hover_id = egui::Id::unique(("hover-user", bot.id, idx)); + let was_hovered = ctx.data(|d| d.get_temp::(hover_id)).unwrap_or(false); + let row = ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let fill = self.user_bubble(bot); + let text_col = if fill != p.bubble_user { Color32::WHITE } else if p.bubble_user.a() < 0xff { p.text } else { p.on_primary }; + let max_w = message_max_width(ui.available_width()); + // As wide as the words (up to the column's limit), lines reading left to right. + let rich = RichText::new(&shown_text).size(TRANSCRIPT_FONT).line_height(Some(TRANSCRIPT_LINE_HEIGHT)).color(text_col); + let text_w = egui::WidgetText::from(rich.clone()).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::FontSelection::Default).size().x; + let inner_w = text_w.min(max_w - 24.0).max(8.0); + Frame::new().fill(fill).corner_radius(BUBBLE_RADIUS).inner_margin(bubble_margin()).show(ui, |ui| { + ui.set_width(inner_w); + ui.with_layout(Layout::top_down(Align::Min), |ui| { + ui.add(egui::Label::new(rich).wrap()); + }); + }); + // Hover: ↩ and ⋯ to the left of the bubble (kept while the menu is open). + let menu_open = self.menu_for == Some((bot.id, idx)) && egui::Popup::is_any_open(ctx); + if was_hovered || menu_open { + ui.add_space(4.0); + if hover_icon(ui, crate::icons::Icon::Reply, p).on_hover_text(t("回覆")).clicked() { + self.quoting.insert(bot.id, m.text.clone()); + } + let more = hover_icon(ui, crate::icons::Icon::More, p).on_hover_text(t("更多訊息操作")); + if more.clicked() { + self.menu_for = Some((bot.id, idx)); + } + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(menu_ui::MENU_W); + if menu_ui::item(ui, p, crate::icons::Icon::Copy, "複製").clicked() { + ctx.copy_text(m.text.clone()); + ui.close(); + } + if menu_ui::item(ui, p, crate::icons::Icon::Reply, "回覆").clicked() { + self.quoting.insert(bot.id, m.text.clone()); + ui.close(); + } + }); + } + }); + let hovered = row.response.contains_pointer(); + if hovered != was_hovered { + ctx.data_mut(|d| d.insert_temp(hover_id, hovered)); + ctx.request_repaint(); + } + } + // The bot's reactions on this message. + if !m.reactions.is_empty() { + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + ui.add_space(6.0); + for r in m.reactions.iter().rev() { + self.reaction_pill(ui, bot.id, idx, r, false); + } + }); + } + // A message that never got an answer can be sent again. + if last && !streaming { + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + if small_button(ui, "重新傳送", p).clicked() { + self.start_reply(ctx, bot.id); + } + ui.label(RichText::new(t("還沒有回覆")).size(11.5).color(p.text3)); + }); + } + ui.add_space(10.0); + } + + fn bot_message(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, m: &Message, last: bool, idx: usize) { + let p = self.pal; + let streaming = last && self.streams.contains_key(&bot.id); + // In a group chat, who said it. + if let Some(a) = self.state.bot(m.author).filter(|_| m.author != 0) { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label(RichText::new(&a.name).size(12.0).color(p.text3)); + }); + ui.add_space(2.0); + } + if !m.text.trim().is_empty() { + let hover_id = egui::Id::unique(("hover-bot", bot.id, idx)); + let was_hovered = ctx.data(|d| d.get_temp::(hover_id)).unwrap_or(false); + let row = ui.horizontal_top(|ui| { + let max_w = message_max_width(ui.available_width()); + let fill = if m.error { p.danger.gamma_multiply(0.12) } else { files_ui::agent_fill(p) }; + let col = if m.error { p.danger } else { p.text }; + Frame::new().fill(fill).corner_radius(BUBBLE_RADIUS).inner_margin(bubble_margin()).show(ui, |ui| { + ui.set_max_width(max_w); + ui.with_layout(Layout::top_down(Align::Min), |ui| self.markdown(ui, &m.text, col, streaming)); + }); + // Hover: 表情回應, 回覆, ⋯ to the right of the bubble (like the original). They stay + // while one of their menus is open, so moving onto the menu doesn't close it. + let menu_open = self.menu_for == Some((bot.id, idx)) && egui::Popup::is_any_open(ctx); + if (was_hovered || menu_open) && !streaming { + ui.add_space(2.0); + ui.vertical(|ui| { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let react = hover_icon(ui, crate::icons::Icon::Smile, p).on_hover_text(t("新增表情回應")); + egui::Popup::menu(&react).show(|ui| { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.add_space(4.0); + for e in QUICK { + if ui.add(egui::Button::new(RichText::new(e).size(20.0)).frame(false).min_size(vec2(34.0, 34.0)).corner_radius(10)).clicked() { + self.toggle_reaction(bot.id, idx, e); + ui.close(); + } + } + }); + menu_ui::sep(ui, p); + menu_ui::caption(ui, p, "更多表情符號"); + ui.add_space(4.0); + egui::Grid::new(("more-emoji", bot.id, idx)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, e) in MORE.iter().enumerate() { + if ui.add(egui::Button::new(RichText::new(*e).size(18.0)).frame(false).min_size(vec2(30.0, 30.0)).corner_radius(8)).clicked() { + self.toggle_reaction(bot.id, idx, e); + ui.close(); + } + if i % 8 == 7 { + ui.end_row(); + } + } + }); + }); + if hover_icon(ui, crate::icons::Icon::Reply, p).on_hover_text(t("回覆")).clicked() { + self.quoting.insert(bot.id, m.text.clone()); + } + if react.clicked() { + self.menu_for = Some((bot.id, idx)); + } + let more = hover_icon(ui, crate::icons::Icon::More, p).on_hover_text(t("更多訊息操作")); + if more.clicked() { + self.menu_for = Some((bot.id, idx)); + } + egui::Popup::menu(&more).show(|ui| { + use crate::icons::Icon; + ui.set_min_width(menu_ui::MENU_W); + if menu_ui::item(ui, p, Icon::Copy, "複製").clicked() { + ctx.copy_text(m.text.clone()); + ui.close(); + } + if menu_ui::item(ui, p, Icon::Reply, "回覆").clicked() { + self.quoting.insert(bot.id, m.text.clone()); + ui.close(); + } + if last && menu_ui::item(ui, p, Icon::Refresh, "重新產生").clicked() { + self.regenerate(ctx, bot.id); + ui.close(); + } + let steps = self.steps_of(bot, idx); + if steps > 0 || !m.thinking.is_empty() { + let open = self.steps_open.contains(&(bot.id, idx)); + let label = if open { t("隱藏步驟") } else { format!("{}({steps})", t("顯示步驟")) }; + if menu_ui::item_text(ui, p, Some(Icon::Template), &label).clicked() { + if open { + self.steps_open.remove(&(bot.id, idx)); + } else { + self.steps_open.insert((bot.id, idx)); + } + ui.close(); + } + } + if !m.via.is_empty() { + menu_ui::sep(ui, p); + menu_ui::caption(ui, p, &tidy_dots(&m.via)); + } + }); + }); + }); + } + }); + let hovered = row.response.contains_pointer(); + if hovered != was_hovered { + ctx.data_mut(|d| d.insert_temp(hover_id, hovered)); + ctx.request_repaint(); + } + } + // Steps and thinking, when unfolded from ⋯. + if self.steps_open.contains(&(bot.id, idx)) { + self.steps_list(ui, bot, idx); + } + for c in m.tool_calls.iter().filter(|c| c.name == "show_card") { + ui.add_space(6.0); + card(ui, &c.args, p); + } + for a in m.attachments.clone() { + ui.add_space(6.0); + self.file_card(ui, &a); + } + if !m.reactions.is_empty() { + ui.add_space(4.0); + ui.horizontal(|ui| { + for r in m.reactions.clone() { + self.reaction_pill(ui, bot.id, idx, &r, true); + } + }); + } + ui.add_space(6.0); + } + + /// How many steps (tool calls) belong to the reply at `idx` and the step messages right + /// before it in the same turn. + fn steps_of(&self, bot: &Bot, idx: usize) -> usize { + self.step_range(bot, idx).map(|i| bot.messages[i].tool_calls.iter().filter(|c| c.name != "show_card").count()).sum() + } + + fn step_range(&self, bot: &Bot, idx: usize) -> std::ops::Range { + let mut start = idx; + while start > 0 && bot.messages[start - 1].role != Role::User { + start -= 1; + } + start..idx + 1 + } + + fn steps_list(&mut self, ui: &mut egui::Ui, bot: &Bot, idx: usize) { + let p = self.pal; + Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(12).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { + ui.set_max_width(message_max_width(ui.available_width())); + for i in self.step_range(bot, idx) { + let m = &bot.messages[i]; + if !m.thinking.is_empty() { + egui::CollapsingHeader::new(RichText::new(t("思考過程")).size(12.5).color(p.text3)).id_salt(("think", bot.id, i)).show(ui, |ui| { + ui.label(RichText::new(&m.thinking).size(12.5).color(p.text3)); + }); + } + for c in m.tool_calls.iter().filter(|c| c.name != "show_card") { + use crate::icons::Icon; + let (icon, col) = match c.status { + ToolStatus::Waiting => (Icon::Alert, p.accent), + ToolStatus::Running => (Icon::Loader, p.text2), + ToolStatus::Done => (Icon::CircleCheck, Color32::from_rgb(0x1f, 0xb3, 0x5d)), + ToolStatus::Denied => (Icon::Minus, p.text3), + ToolStatus::Failed => (Icon::CircleX, p.danger), + }; + let what: String = crate::remote::tool_label(&c.name).to_string(); + let detail: String = if appctl::is_app_tool(&c.name) { appctl::describe(&c.name, &c.args) } else if crate::computer::is_computer_tool(&c.name) { crate::computer::describe(&c.name, &c.args) } else if web::is_web_tool(&c.name) { web::describe(&c.name, &c.args) } else { tools::describe(&c.name, &c.args) }; + let result = bot.messages.iter().find(|r| r.role == Role::Tool && r.tool_call_id == c.id).map(|r| r.text.chars().take(4000).collect::()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, icon, col); + ui.label(RichText::new(t(&what)).size(12.5).color(p.text2)); + let l = ui.add(egui::Label::new(RichText::new(detail.chars().take(80).collect::()).monospace().size(11.5).color(p.text3)).truncate()); + if let Some(r) = result { + l.on_hover_text(r); + } + }); + } + } + }); + ui.add_space(4.0); + } + + /// The working row at the end of the chat: ring + shimmering phrase. + pub(super) fn working_row(&mut self, ui: &mut egui::Ui, bot: &Bot) { + let p = self.pal; + let Some(live) = self.streams.get(&bot.id) else { return }; + let waiting = self.approvals.contains_key(&bot.id); + let last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant); + let running = last.and_then(|m| m.tool_calls.iter().rev().find(|c| matches!(c.status, ToolStatus::Running | ToolStatus::Waiting))); + let on_box = running.map(|c| tools::args_of(&c.args)["computer"].as_str().map(|n| n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true)).unwrap_or(false); + let has_text = last.map(|m| !m.text.trim().is_empty()).unwrap_or(false); + let seed = live.turn_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64)); + let phrase = live.status.clone().unwrap_or_else(|| t(&status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed))); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.spacing_mut().item_spacing.x = 8.0; + spinner(ui, 16.0, if waiting { p.accent } else { p.text2 }); + shimmer(ui, &phrase, p.text3, p.text, 14.0); + }); + ui.add_space(10.0); + } + + fn toggle_reaction(&mut self, bot_id: u64, idx: usize, e: &str) { + if let Some(b) = self.state.bot_mut(bot_id) { + if let Some(m) = b.messages.get_mut(idx) { + if let Some(i) = m.reactions.iter().position(|x| x == e) { + m.reactions.remove(i); + } else { + m.reactions.push(e.to_string()); + self.popped.insert((bot_id, idx, e.to_string()), Instant::now()); + } + } + } + self.mark(); + } + + /// One reaction under a message; new ones pop in. The person can take theirs back. + fn reaction_pill(&mut self, ui: &mut egui::Ui, bot_id: u64, idx: usize, e: &str, mine: bool) { + let p = self.pal; + let scale = match self.popped.get(&(bot_id, idx, e.to_string())) { + Some(t0) if t0.elapsed().as_secs_f32() < REACTION_POP => { + ui.ctx().request_repaint(); + pop(t0.elapsed().as_secs_f32() / REACTION_POP) + } + _ => 1.0, + }; + let (rect, r) = ui.allocate_exact_size(vec2(34.0, 24.0), egui::Sense::click()); + let fill = files_ui::agent_fill(p); + ui.painter().rect_filled(egui::Rect::from_center_size(rect.center(), rect.size() * scale.max(0.01)), 12, if r.hovered() && mine { p.hover } else { fill }); + ui.painter().rect_stroke(egui::Rect::from_center_size(rect.center(), rect.size() * scale.max(0.01)), 12, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, e, egui::FontId::proportional(14.0 * scale.max(0.01)), p.text); + if mine { + if r.on_hover_text(t("移除這個回應")).clicked() { + self.toggle_reaction(bot_id, idx, e); + } + } else { + r.on_hover_text(t("Bot 的表情回應")); + } + } +} + +/// "[例行任務]…" → (icon, label) and the words after the tag. +pub fn wake_tag(text: &str) -> (Option<(crate::icons::Icon, &'static str)>, &str) { + use crate::icons::Icon; + // (The emoji forms are what older chats were saved with.) + for (tag, icon, label) in [("[例行任務]", Icon::Alarm, "例行任務"), ("[觸發]", Icon::Zap, "觸發"), ("[訊息管道]", Icon::Message, "訊息管道"), ("[來自 Bot]", Icon::Bot, "來自其他 Bot"), ("⏰ ", Icon::Alarm, "例行任務"), ("⚡ ", Icon::Zap, "觸發"), ("💬 ", Icon::Message, "訊息管道"), ("📨 ", Icon::Bot, "來自其他 Bot")] { + if let Some(rest) = text.strip_prefix(tag) { + return (Some((icon, label)), rest); + } + } + (None, text) +} + +/// A 26 px quiet icon button (the hover actions). +fn hover_icon(ui: &mut egui::Ui, icon: crate::icons::Icon, p: Palette) -> egui::Response { + crate::icons::button(ui, icon, 26.0, p.text3, p.hover) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phrases_follow_what_it_does() { + assert_eq!(status_phrase(Some("read_file"), false, false, false, 0), "正在讀取檔案"); + assert_eq!(status_phrase(Some("computer_use_click"), true, false, false, 0), "正在四處點擊"); + assert_eq!(status_phrase(Some("computer_use_click"), false, false, false, 0), "正在你的電腦上工作"); + assert_eq!(status_phrase(None, false, true, false, 0), "需要你的輸入"); + assert!(THINKING.contains(&status_phrase(None, false, false, false, 7).as_str())); + assert!(WORKING.contains(&status_phrase(None, false, false, true, 7).as_str())); + assert!((pop(1.0) - 1.0).abs() < 1e-4 && pop(0.6) > 1.0); + } +} diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs new file mode 100644 index 0000000..1820a05 --- /dev/null +++ b/src/app/computers_ui.rs @@ -0,0 +1,1499 @@ +// The computers part of the window: 「Bot 的電腦」 (status, live screen, take control, rebuilds, +// snapshots, secrets, where it runs), 「你的電腦」 (macOS permissions, the per-app grants) and +// the floating bar shown while a bot drives this Mac. +// +// Wording follows the original's strings where it has them (ORIGINAL_FEATURES.md §9–§11). + +use super::*; +use crate::cloudbox::{self, Rebuild, Run}; + +/// How often the viewer asks the box for a new picture (estimated). +const VIEWER_FRAME_MS: u64 = 450; +/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac. +const LOCAL_BAR_SECS: u64 = 8; + +#[derive(Default)] +pub struct ComputerUi { + /// 其他電腦 is expanded. + pub others_open: bool, + /// The account's other AnyBots (id, name, online), fetched from the relay now and then. + pub peers: Arc>>>, + pub peers_at: Option, + /// 你的電腦's name being edited. + pub renaming_machine: Option, + /// 更新: the first click arms it for 3 s (「再點選一次以確認」). + pub arm_update: Option, + /// 要移除「…」嗎? for another computer (its index). + pub confirm_remove: Option, + /// 要重設 AnyBot 的電腦嗎? + pub confirm_reset: bool, + /// 一律允許的應用程式 is expanded. + pub apps_open: bool, + pub viewer_open: bool, + /// 取得控制權: the person's clicks and keys go to the box. + pub control: bool, + pub fit: bool, + texture: Option, + frame: Arc>>, + shown_seq: u64, + frame_size: [usize; 2], + worker: Option>, + input_tx: Option>, + pub confirm: Option, + pub note: Arc>, + disk: Arc>>, + new_secret: (String, String), + replace_secret: Option, + secret_error: String, + docker_host: Option, + show_log: bool, + teach_name: String, + teach_for: Option, + teach_error: String, + /// Screenshot mode: show the real box in the viewer. + pub demo_live: bool, + schedule_edit: String, + pub update_ready: bool, + update_checked: Option, + update_flag: Arc, + /// The 電腦 card's small live picture of the box's screen. + thumb: Option, + thumb_img: Arc>>, + thumb_busy: Arc, + thumb_at: Option, +} + +enum Input { + Click(i64, i64, &'static str, u32), + Move(i64, i64), + Scroll(i64, i64, bool, u32), + Key(String), + Text(String), +} + +fn status_color(r: &Run, p: Palette) -> Color32 { + match r { + Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70), + Run::Starting(_) | Run::Rebuilding(..) => Color32::from_rgb(0xf6, 0x94, 0x12), + Run::Failed(_) => p.danger, + _ => p.text3, + } +} + +/// A rounded status pill: coloured dot + label. +fn status_pill(ui: &mut egui::Ui, r: &Run, p: Palette) { + let label = r.label(); + let font = egui::FontId::proportional(FONT_XS); + let galley = ui.painter().layout_no_wrap(label, font, p.text2); + let size = vec2(galley.size().x + 26.0, 22.0); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + ui.painter().rect_filled(rect, 11, p.hover); + let c = rect.left_center() + vec2(11.0, 0.0); + let col = status_color(r, p); + if matches!(r, Run::Starting(_) | Run::Rebuilding(..)) { + let t = ui.input(|i| i.time) as f32; + ui.painter().circle_filled(c, 4.0 + (t * 4.0).sin().abs() * 2.0, col.gamma_multiply(0.35)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); + } + ui.painter().circle_filled(c, 3.5, col); + ui.painter().galley(rect.left_center() + vec2(20.0, -galley.size().y / 2.0), galley, p.text2); +} + +/// A little monitor drawn with lines (no emoji font needed). +fn monitor_icon(ui: &mut egui::Ui, size: f32, col: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(size, size), egui::Sense::hover()); + let s = size / 20.0; + let screen = egui::Rect::from_min_size(rect.min + vec2(2.0 * s, 3.0 * s), vec2(16.0 * s, 11.0 * s)); + ui.painter().rect_stroke(screen, 2.5 * s, Stroke::new(1.5, col), egui::StrokeKind::Inside); + ui.painter().line_segment([rect.min + vec2(10.0 * s, 14.0 * s), rect.min + vec2(10.0 * s, 17.0 * s)], Stroke::new(1.5, col)); + ui.painter().line_segment([rect.min + vec2(6.5 * s, 17.5 * s), rect.min + vec2(13.5 * s, 17.5 * s)], Stroke::new(1.5, col)); +} + + +impl App { + /// Called once at start. + pub(super) fn computers_start(&mut self) { + if !self.demo { + cloudbox::start_background(); + } + } + + // ---------- settings page ---------- + + pub(super) fn computers_page(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + let run = cloudbox::run_state(); + // Check now and then whether this AnyBot carries a newer computer. + if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) { + self.cu.update_checked = Some(Instant::now()); + let flag = self.cu.update_flag.clone(); + std::thread::spawn(move || flag.store(cloudbox::update_available(), Ordering::SeqCst)); + } + self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst); + ui.spacing_mut().item_spacing.y = 0.0; + + // ── 你的電腦 ── (title: this computer's name, with a pencil to rename it) + let label = if self.state.machine_label.trim().is_empty() { crate::remote::default_host_name() } else { self.state.machine_label.clone() }; + let renaming = self.cu.renaming_machine.clone(); + let mut rename_to: Option> = None; + let mut keep_buf: Option = None; + let mut start_rename = false; + sec_title(ui, "你的電腦", p, |ui| match renaming { + Some(mut buf) => { + let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(180.0).char_limit(100)); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if r.lost_focus() { + let cancel = ui.input(|i| i.key_pressed(Key::Escape)); + let v = buf.trim().to_string(); + rename_to = Some(if !cancel && !v.is_empty() { Some(v) } else { None }); + } else { + keep_buf = Some(buf); + } + } + None => { + if crate::icons::button(ui, crate::icons::Icon::Pencil, 14.0, p.text3, p.hover).on_hover_text(format!("{},{label}", t("重新命名這台電腦"))).clicked() { + start_rename = true; + } + ui.label(RichText::new(&label).size(FONT_SM).color(p.text2)); + } + }); + if let Some(buf) = keep_buf { + self.cu.renaming_machine = Some(buf); + } + match rename_to { + Some(Some(v)) => { + self.state.machine_label = v; + self.cu.renaming_machine = None; + self.mark(); + } + Some(None) => self.cu.renaming_machine = None, + None => {} + } + if start_rename { + self.cu.renaming_machine = Some(label.clone()); + } + sec_card(ui, p, |ui| { + // 在這台電腦上執行: 一律允許 / 每次詢問 / 永不允許 (default 每次詢問). + let locked = !self.managed.local.allows(); + let current = if self.state.local_never || locked { "never" } else if self.state.local_execution { "always" } else { "ask" }; + let name = |v: &str| { + t(match v { + "always" => "一律允許", + "never" => "永不允許", + _ => "每次詢問", + }) + }; + let mut pick: Option<&str> = None; + sec_row(ui, p, "在這台電腦上執行", "讓 Bot 在你的電腦上開啟檔案並執行任務。自動審核仍會先檢查所有操作。", |ui| { + ui.add_enabled_ui(!locked, |ui| { + egui::ComboBox::from_id_salt("local-permission").selected_text(name(current)).width(110.0).show_ui(ui, |ui| { + for v in ["always", "ask", "never"] { + if ui.selectable_label(current == v, name(v)).clicked() { + pick = Some(v); + } + } + }); + }); + }); + if let Some(v) = pick { + self.state.local_execution = v == "always"; + self.state.local_never = v == "never"; + self.mark(); + } + if locked { + sec_note(ui, p, "由你的管理員設定,無法變更"); + } + // 經這台電腦轉送流量 (the box's new connections leave through this computer). + let allowed = self.managed.egress.allows() && cloudbox::docker_host().is_empty(); + let mut via = cloudbox::egress_via_mac() && allowed; + let help = "將 Bot 的電腦的網路流量改經這台電腦而非雲端轉送,例如要連公司內網或 VPN 才看得到的服務。僅對新連線生效。"; + let desc = if !self.managed.egress.allows() { + "已由管理員停用".to_string() + } else if !cloudbox::docker_host().is_empty() { + "Bot 的電腦在別的主機上時不能用".to_string() + } else if via { + match (crate::egress::port(), &run) { + (_, Run::Asleep) => "電腦休眠中,喚醒後會重新連線".to_string(), + (Some(_), Run::Running) => format!("本次已轉送 {} 個連線", crate::egress::CONNECTIONS.load(Ordering::Relaxed)), + _ => "等待 Bot 的電腦連線".to_string(), + } + } else { + String::new() + }; + let mut egress_err = None; + sec_row_s(ui, p, "經這台電腦轉送流量", &desc, help, |ui| { + if ui.add_enabled(allowed, toggle(&mut via)).changed() { + if let Err(e) = cloudbox::set_egress_via_mac(via) { + egress_err = Some(e); + } + } + }); + if let Some(e) = egress_err { + *self.cu.note.lock().unwrap() = e; + } + // 其他電腦: every AnyBot signed in to this account (from the relay), then SSH computers. + let signed_in = !self.state.remote.relay_url.is_empty() && !self.state.remote.host_token.is_empty(); + if signed_in && !self.demo && self.cu.peers_at.map_or(true, |t| t.elapsed().as_secs() > 20) { + self.cu.peers_at = Some(Instant::now()); + let (slot, settings) = (self.cu.peers.clone(), self.state.remote.clone()); + let ctx = ui.ctx().clone(); + std::thread::spawn(move || { + let me = settings.host_id.clone(); + let list: Vec<_> = crate::remote::devices_online(&settings).into_iter().filter(|(id, _, _)| *id != me).collect(); + *slot.lock().unwrap() = Some(list); + ctx.request_repaint(); + }); + } + let peers: Vec<(String, String, bool)> = if self.demo { vec![("d1".into(), "辦公室的 Mac mini".into(), true), ("d2".into(), "家裡的筆電".into(), false)] } else { self.cu.peers.lock().unwrap().clone().unwrap_or_default() }; + let ssh = self.state.remote_computers.clone(); + let n = peers.len() + ssh.len(); + let mut names: Vec = peers.iter().map(|p| p.1.clone()).collect(); + names.extend(ssh.iter().map(|c| c.name.clone())); + let others_help = if signed_in { "在其他電腦安裝 AnyBot 並登入同一個帳號,就會自動出現在這裡。也可以用 SSH 加入。" } else { "登入 AnyBot 帳號(設定 → 一般 → 手機與帳號)後,同一個帳號裡的電腦會自動出現在這裡。也可以用 SSH 加入。" }; + let desc = if n == 0 { + String::new() + } else if n == 1 { + match (peers.first(), ssh.first()) { + (Some(p), _) => format!("{} · {}", p.1, t(if p.2 { "目前在線上" } else { "離線" })), + (_, Some(c)) => format!("{} · SSH", c.name), + _ => String::new(), + } + } else { + names.join("、") + }; + let open = self.cu.others_open; + let mut flip = false; + sec_row_s(ui, p, "其他電腦", &desc, others_help, |ui| { + let b = egui::Button::new(RichText::new(format!("{n} {}", if open { "⌄" } else { "›" })).size(FONT_SM)).corner_radius(8); + if ui.add(b).clicked() { + flip = true; + } + }); + if flip { + self.cu.others_open = !open; + } + if open { + for (_, name, online) in &peers { + let status = t(if *online { "目前在線上" } else { "離線" }); + sec_row_s(ui, p, name, &format!("{} · {status}", t("同一個帳號")), "", |ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 3.0, if *online { Color32::from_rgb(0x22, 0xa0, 0x55) } else { p.text3 }); + }); + } + + for (i, c) in ssh.iter().enumerate() { + let mut remove = false; + sec_row_s(ui, p, &c.name, &format!("SSH · {}", c.ssh_target), "", |ui| { + if ui.add(egui::Button::new(RichText::new(t("移除")).color(p.danger)).corner_radius(8)).on_hover_text(format!("{} {}", t("移除"), c.name)).clicked() { + remove = true; + } + }); + if remove { + self.cu.confirm_remove = Some(i); + } + } + let mut add = None; + let taken = self.state.computer_names(); + Frame::new().inner_margin(Margin::symmetric(14, 8)).show(ui, |ui| { + ui.horizontal(|ui| { + let w = (ui.available_width() - 80.0) / 2.0; + ui.add(egui::TextEdit::singleline(&mut self.new_remote.0).hint_text(t("SSH 電腦名稱,例如 工作站")).desired_width(w - 8.0)); + ui.add(egui::TextEdit::singleline(&mut self.new_remote.1).hint_text("me@studio.local").desired_width(w - 8.0)); + let ok = !self.new_remote.0.trim().is_empty() && !self.new_remote.1.trim().is_empty() && !taken.contains(&self.new_remote.0.trim().to_string()); + if ui.add_enabled(ok, secondary_button("新增")).clicked() { + add = Some(crate::model::RemoteComputer { name: self.new_remote.0.trim().into(), ssh_target: self.new_remote.1.trim().into(), dir: String::new() }); + } + }); + }); + if let Some(c) = add { + self.state.remote_computers.push(c); + self.new_remote = Default::default(); + self.mark(); + } + } + // This computer for the account's other AnyBots (裝置連結). + if signed_in { + let mut link = self.state.remote.device_link; + sec_row(ui, p, "讓帳號裡的其他電腦使用這台", "同一個帳號的其他 AnyBot(例如放在伺服器上的)可以看這台的畫面、在這台上做事。每個動作還是會先問你。要讓 Bot 操作別台,就在那台打開這個。要移除一台電腦,在那台登出帳號即可。", |ui| { + ui.add(toggle(&mut link)); + }); + if link != self.state.remote.device_link { + self.state.remote.device_link = link; + self.mark(); + } + } + }); + + // ── 訊息 (Mac) ── + if cfg!(target_os = "macos") { + sec_title(ui, "訊息", p, |_| {}); + let mut send = self.state.messages_send; + let mut no_ask = self.state.messages_no_ask; + sec_card(ui, p, |ui| { + sec_row(ui, p, "傳送訊息", "允許 Bot 以你的身分從這台 Mac 上的「訊息」傳送訊息", |ui| { + ui.add(toggle(&mut send)); + }); + if send { + sec_row(ui, p, "無需詢問直接傳送", "每則訊息都跳過核准卡片", |ui| { + ui.add(toggle(&mut no_ask)); + }); + } + }); + if send != self.state.messages_send || no_ask != self.state.messages_no_ask { + self.state.messages_send = send; + self.state.messages_no_ask = no_ask; + self.mark(); + } + } + + // ── AnyBot Computer Use: seeing and using this computer's screen ── + self.mac_permissions_card(ui); + + // ── Bot 的電腦 ── + sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p)); + #[derive(PartialEq)] + enum Act { + None, + Recheck, + Download, + Docker, + Browser, + Sleep, + Wake, + View, + UpdateClick, + Reset, + Snapshot, + AskClean, + Disk, + } + let mut act = Act::None; + let armed = self.cu.arm_update.filter(|t| t.elapsed().as_secs_f32() < 3.0).is_some(); + if armed { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); + } + let busy_rebuild = matches!(run, Run::Rebuilding(..)); + let update_ready = self.cu.update_ready; + let working: Vec = self.streams.keys().filter_map(|id| self.state.bot(*id).map(|b| b.name.clone())).collect(); + let mut on = cloudbox::auto_update_at().is_some(); + let mut at = cloudbox::auto_update_at().unwrap_or_else(|| self.cu.schedule_edit.clone()); + if at.is_empty() { + at = "03:00".into(); + } + let mut schedule_changed = false; + let disk = self.cu.disk.lock().unwrap().clone(); + let note = self.cu.note.lock().unwrap().clone(); + sec_card(ui, p, |ui| { + match &run { + Run::NoDocker(why) => { + sec_row_s(ui, p, "Bot 的電腦", "還沒有 Docker", &format!("{why}\n在那之前,Bot 的電腦是一個封閉的資料夾(沒有桌面)。"), |ui| { + if ui.add(secondary_button("重新檢查")).clicked() { + act = Act::Recheck; + } + if cfg!(target_os = "macos") { + if ui.add(primary_button("下載 OrbStack", p)).clicked() { + act = Act::Download; + } + } else if ui.add(primary_button("安裝 Docker 的說明", p)).clicked() { + act = Act::Docker; + } + }); + return; + } + Run::Rebuilding(phase, detail) => { + let steps: &[&str] = if matches!(phase.as_str(), "backing-up" | "moving") { &["backing-up", "creating", "moving"] } else { &["cleaning-up", "wiping", "creating", "restoring"] }; + let at = steps.iter().position(|s| s == phase).unwrap_or(0); + Frame::new().inner_margin(Margin::symmetric(14, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + for (i, s) in steps.iter().enumerate() { + let done = i < at; + let on = i == at; + let col = if done || on { p.accent } else { p.text3 }; + let icon = if done { crate::icons::Icon::CircleCheck } else if on { crate::icons::Icon::Loader } else { crate::icons::Icon::Minus }; + crate::icons::label(ui, icon, cloudbox::phase_label(s), FONT_XS, col); + if i + 1 < steps.len() { + ui.label(RichText::new("—").size(FONT_XS).color(p.text3)); + } + } + }); + ui.add_space(6.0); + ui.add(egui::ProgressBar::new((at as f32 + 0.5) / steps.len() as f32).desired_height(6.0).fill(p.accent)); + ui.add_space(4.0); + hint(ui, format!("{detail}…(開始後就不能取消)"), p); + }); + } + _ => { + // The screen: AnyBot has no per-bot overview, so it opens from here. + sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。閒置一陣子會自動休眠,需要時自動喚醒。", |ui| { + if ui.add(primary_button("開啟電腦", p)).on_hover_text(t("看 Bot 的電腦的螢幕,也可以取得控制權自己操作")).clicked() { + act = Act::View; + } + match run { + Run::Running => { + if ui.add(secondary_button("休眠")).clicked() { + act = Act::Sleep; + } + } + _ => { + if ui.add(secondary_button("喚醒電腦")).clicked() { + act = Act::Wake; + } + } + } + if ui.add(secondary_button("在瀏覽器開啟")).on_hover_text(t("用 noVNC 全螢幕觀看")).clicked() { + act = Act::Browser; + } + }); + } + } + // 更新 Bot 的電腦: two clicks (the first arms it for 3 s). + sec_row(ui, p, "更新 Bot 的電腦", "更新你的 Bot 共用的電腦。你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。", |ui| { + let b = if armed { + egui::Button::new(RichText::new(t("再點選一次以確認")).color(p.danger)).fill(p.danger.gamma_multiply(0.14)).corner_radius(8).min_size(vec2(64.0, 30.0)) + } else { + secondary_button("更新") + }; + if ui.add_enabled(update_ready && !busy_rebuild, b).clicked() { + act = Act::UpdateClick; + } + }); + // The status line under it. + let (icon, text, accent) = if !update_ready { + (crate::icons::Icon::CircleCheck, t("你的電腦已是最新版本"), true) + } else if !working.is_empty() { + let names = if working.len() > 3 { format!("{}、另外 {} 位", working[..3].join("、"), working.len() - 3) } else { working.join("、") }; + (crate::icons::Icon::Clock, format!("更新會停止 {names}"), false) + } else { + (crate::icons::Icon::Update, t("有新版的 Bot 的電腦可以更新"), true) + }; + Frame::new().inner_margin(Margin { left: 14, right: 14, top: 0, bottom: 8 }).show(ui, |ui| { + let fill = if accent { p.accent.gamma_multiply(0.09) } else { p.hover }; + let stroke = if accent { Stroke::new(0.5, p.accent.gamma_multiply(0.6)) } else { Stroke::NONE }; + Frame::new().fill(fill).stroke(stroke).corner_radius(8).inner_margin(Margin { left: 12, right: 8, top: 8, bottom: 8 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + crate::icons::label(ui, icon, &text, FONT_SM, if accent { p.accent } else { p.text2 }); + }); + }); + // 自動更新 (at a local time each day, an hour to catch up). + let desc = if on { format!("每天 {}", at.trim()) } else { String::new() }; + sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| { + let changed = ui.add(toggle(&mut on)).changed(); + let r = ui.add(egui::TextEdit::singleline(&mut at).desired_width(56.0).char_limit(5).font(egui::TextStyle::Monospace)); + if changed || r.lost_focus() { + schedule_changed = true; + } + }); + // 重設 Bot 的電腦: from the last snapshot (red, asks first). + let snap = cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string()); + let desc = match &snap { Some(t) => format!("上次快照:{t}"), None => t("還沒有快照") }; + sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,休眠前也會自動存一份。", |ui| { + if ui.add_enabled(!busy_rebuild, danger_button("重設", p)).clicked() { + act = Act::Reset; + } + if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;休眠前也會自動存一份")).clicked() { + act = Act::Snapshot; + } + }); + // 磁碟 (AnyBot). + sec_row(ui, p, "磁碟", "查看 Bot 的電腦用了多少空間,或請主要 Bot 提出清理方案(等你核准才刪)", |ui| { + if ui.add(secondary_button("請 Bot 提出清理方案")).clicked() { + act = Act::AskClean; + } + if ui.add(secondary_button("檢查用量")).clicked() { + act = Act::Disk; + } + }); + if let Some(d) = &disk { + Frame::new().inner_margin(Margin::symmetric(14, 6)).show(ui, |ui| { + Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(d).monospace().size(11.5).color(p.text2)); + }); + }); + } + if !note.is_empty() { + sec_note(ui, p, ¬e); + } + }); + if schedule_changed && chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() { + cloudbox::set_auto_update_at(on.then(|| at.trim().to_string())); + } + self.cu.schedule_edit = at; + match act { + Act::None => {} + Act::Recheck => cloudbox::refresh(), + Act::Download => crate::preview::open_with_system("https://orbstack.dev/download"), + Act::Docker => crate::preview::open_with_system("https://docs.docker.com/engine/install/"), + Act::View => self.open_viewer(), + Act::Sleep => self.box_task(|| cloudbox::sleep_now().map(|_| "已休眠。".to_string())), + Act::Wake => self.box_task(|| cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())), + Act::Browser => self.box_task(|| { + let url = cloudbox::viewer_url(false)?; + crate::preview::open_with_system(&url); + Ok(String::new()) + }), + Act::UpdateClick => { + if armed { + self.cu.arm_update = None; + let ids: Vec = self.streams.keys().copied().collect(); + for id in ids { + self.stop(id); + } + *self.cu.note.lock().unwrap() = match cloudbox::rebuild(Rebuild::Update) { + Ok(()) => t("更新已開始。"), + Err(e) => e, + }; + } else { + self.cu.arm_update = Some(Instant::now()); + } + } + Act::Reset => self.cu.confirm_reset = true, + Act::Snapshot => self.box_task(|| cloudbox::save_snapshot().map(|n| format!("已儲存快照({})。", cloudbox::human(n)))), + Act::AskClean => self.ask_primary_bot("檢查「Bot 的電腦」的磁碟使用情況,並提出安全的清理方案供我核准。先列出用量最大的地方,不要直接刪除任何東西。"), + Act::Disk => { + let slot = self.cu.disk.clone(); + *slot.lock().unwrap() = Some("檢查中…".into()); + std::thread::spawn(move || { + let r = cloudbox::disk_report().unwrap_or_else(|e| e); + *slot.lock().unwrap() = Some(r); + }); + } + } + + // ── 終端機與環境密鑰 ── + if !matches!(run, Run::NoDocker(_)) { + sec_title(ui, "終端機與環境密鑰", p, |_| {}); + sec_card(ui, p, |ui| { + Frame::new().inner_margin(Margin::symmetric(14, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 4.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new(t("給指令用的帳密")).size(FONT_SM).color(p.text2)); + help_mark(ui, "例如 CRM_API_TOKEN。Bot 執行指令時會變成環境變數;值只能寫入,不能讀出。", p); + }); + let names = cloudbox::secret_names(); + let mut remove = None; + for n in &names { + ui.horizontal(|ui| { + ui.label(RichText::new(n).monospace().size(FONT_SM)); + ui.label(RichText::new("••••••••").size(FONT_SM).color(p.text3)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.danger)).frame(false)).clicked() { + remove = Some(n.clone()); + } + if ui.add(egui::Button::new(RichText::new(t("取代")).size(12.0).color(p.text2)).frame(false)).clicked() { + self.cu.new_secret = (n.clone(), String::new()); + self.cu.replace_secret = Some(n.clone()); + } + }); + }); + } + if let Some(n) = remove { + cloudbox::remove_secret(&n); + } + ui.horizontal(|ui| { + let w = (ui.available_width() - 80.0) / 2.0; + let replacing = self.cu.replace_secret.is_some(); + ui.add_enabled(!replacing, egui::TextEdit::singleline(&mut self.cu.new_secret.0).hint_text(t("名稱,例如 CRM_API_TOKEN")).desired_width(w - 8.0).font(egui::TextStyle::Monospace)); + ui.add(egui::TextEdit::singleline(&mut self.cu.new_secret.1).password(true).hint_text(t("值")).desired_width(w - 8.0)); + let ok = !self.cu.new_secret.0.trim().is_empty() && !self.cu.new_secret.1.is_empty(); + if ui.add_enabled(ok, primary_button(if replacing { "取代" } else { "新增" }, p)).clicked() { + match cloudbox::set_secret(&self.cu.new_secret.0, &self.cu.new_secret.1) { + Ok(()) => { + self.cu.new_secret = Default::default(); + self.cu.replace_secret = None; + self.cu.secret_error.clear(); + } + Err(e) => self.cu.secret_error = e, + } + } + }); + let up = self.cu.new_secret.0.to_uppercase(); + if up != self.cu.new_secret.0 { + self.cu.new_secret.0 = up; + } + if !self.cu.secret_error.is_empty() { + ui.label(RichText::new(&self.cu.secret_error).size(FONT_XS).color(p.danger)); + } + }); + }); + + // ── 在哪裡執行 ── + sec_title(ui, "在哪裡執行", p, |_| {}); + let current = cloudbox::docker_host(); + let mut edit = self.cu.docker_host.take().unwrap_or_else(|| current.clone()); + let mut apply = false; + sec_card(ui, p, |ui| { + sec_row(ui, p, "Docker 主機", "預設在這台電腦的 Docker 裡。也可以放到雲端或公司伺服器,例如 ssh://me@server。換位置後會在新的地方重新建立,舊檔案可以用快照搬過去。", |ui| { + if ui.add_enabled(edit.trim() != current, primary_button("套用", p)).clicked() { + apply = true; + } + ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace)); + }); + }); + if apply { + cloudbox::set_docker_host(edit.trim()); + } + self.cu.docker_host = Some(edit); + if ui.add(egui::Button::new(RichText::new(t(if self.cu.show_log { "隱藏紀錄" } else { "顯示紀錄" })).size(FONT_XS).color(p.text3)).frame(false)).clicked() { + self.cu.show_log = !self.cu.show_log; + } + if self.cu.show_log { + let lines = cloudbox::log_lines(); + Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + if lines.is_empty() { + ui.label(RichText::new(t("(沒有紀錄)")).size(11.5).color(p.text3)); + } + for l in lines.iter().rev().take(30) { + ui.label(RichText::new(l).monospace().size(11.0).color(p.text2)); + } + }); + } + } + + // ── 示範教學、瀏覽器 ── + ui.add_space(SEC_GAP * 0.5); + ui.spacing_mut().item_spacing.y = 6.0; + self.teach_card(ui); + setting_card(ui, p, |ui| self.browser_profiles_section(ui, p)); + self.computers_dialogs(ui.ctx()); + } + + /// 要移除「…」嗎? and 要重設 AnyBot 的電腦嗎? + fn computers_dialogs(&mut self, ctx: &egui::Context) { + let p = self.pal; + if let Some(i) = self.cu.confirm_remove { + let name = self.state.remote_computers.get(i).map(|c| c.name.clone()).unwrap_or_default(); + let mut keep = true; + let mut remove = false; + let close = dialog(ctx, "remove-computer", &format!("要移除「{name}」嗎?"), 420.0, true, p, |ui| { + dialog_body(ui, "remove-computer-body", 200.0, |ui| { + ui.label(t("Bot 將無法再在這台電腦上執行任務。之後可以再用 SSH 加回來。")); + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(danger_button("移除", p)).clicked() { + remove = true; + keep = false; + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if remove && i < self.state.remote_computers.len() { + self.state.remote_computers.remove(i); + self.mark(); + } + if !keep || close { + self.cu.confirm_remove = None; + } + } + if self.cu.confirm_reset { + let working = self.streams.len(); + let has_snap = cloudbox::snapshot_time().is_some(); + let mut keep = true; + let mut go = false; + let close = dialog(ctx, "reset-computer", "要重設 AnyBot 的電腦嗎?", 460.0, true, p, |ui| { + dialog_body(ui, "reset-computer-body", 320.0, |ui| { + let w = if working == 0 { t("所有執行中的工作都將被中斷。") } else { format!("{working} 個 Bot 正在工作,將會被中斷。") }; + ui.label(if has_snap { format!("從上次儲存的快照還原。這會關閉目前的電腦,並以最新版本啟動一台全新的電腦。{w}") } else { format!("還沒有快照:這會換一台全新的電腦,裡面的檔案和登入資訊都會清除。{w}") }); + ui.add_space(8.0); + Frame::new().fill(p.danger.gamma_multiply(0.08)).corner_radius(8).inner_margin(Margin::same(10)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(t("警告:尚未同步到快照的工作(包括最近的檔案)將會遺失。已安裝的應用程式和套件會被移除。")).size(FONT_SM).color(p.danger)).wrap()); + }); + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(danger_button("重設 AnyBot 的電腦", p)).clicked() { + go = true; + keep = false; + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if go { + let ids: Vec = self.streams.keys().copied().collect(); + for id in ids { + self.stop(id); + } + let kind = if has_snap { Rebuild::Restore } else { Rebuild::Reset }; + *self.cu.note.lock().unwrap() = match cloudbox::rebuild(kind) { + Ok(()) => t("重設已開始。"), + Err(e) => e, + }; + } + if !keep || close { + self.cu.confirm_reset = false; + } + } + } + + /// 「AnyBot Computer Use」: what seeing and using this computer's screen needs. + pub(super) fn mac_permissions_card(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + if cfg!(target_os = "linux") { + #[cfg(target_os = "linux")] + { + let kind = crate::desk::kind(); + let (state, sub) = match kind { + crate::desk::Kind::X11 => ("已授予", "X11 桌面:可以截圖、點擊、打字,也能錄製示範"), + crate::desk::Kind::Wlr => ("已授予", "Wayland 桌面:可以截圖、點擊、打字(錄製示範需要 Xorg 工作階段)"), + crate::desk::Kind::Portal => ("第一次使用時詢問", "Wayland 桌面:第一次使用時系統會跳出視窗,打開「允許遠端操作」再按「分享」(錄製示範需要 Xorg 工作階段)"), + crate::desk::Kind::None if crate::desk::wayland() => ("無法使用", "找不到 xdg-desktop-portal 的遠端桌面功能,請安裝 xdg-desktop-portal-gnome 或 -kde"), + crate::desk::Kind::None => ("無法使用", "這台電腦沒有顯示器"), + }; + sec_title(ui, "AnyBot Computer Use", p, |_| {}); + sec_card(ui, p, |ui| { + sec_row(ui, p, "看畫面和操作", sub, |ui| { + ui.label(RichText::new(t(state)).size(FONT_SM).color(p.text2)); + }); + }); + } + self.allowed_apps_section(ui); + return; + } + if !cfg!(target_os = "macos") { + return; + } + let see = crate::computer::screen_capture_ok(); + let control = crate::axread::trusted(false); + sec_title(ui, "AnyBot Computer Use", p, |_| {}); + let mut open_pane = None; + sec_card(ui, p, |ui| { + let state = |ok: bool| t(if ok { "已授予" } else { "未授予" }); + sec_row(ui, p, "螢幕錄製", "讓 Bot 看到你的螢幕", |ui| { + ui.label(RichText::new(state(see)).size(FONT_SM).color(if see { p.text2 } else { p.danger })); + }); + sec_row(ui, p, "輔助使用", "讓 Bot 點擊和打字,也用來錄製示範", |ui| { + ui.label(RichText::new(state(control)).size(FONT_SM).color(if control { p.text2 } else { p.danger })); + }); + if !see || !control { + sec_row(ui, p, "授予權限", "打開系統設定的面板。把 AnyBot(或「終端機」)加到清單裡並打開。", |ui| { + if ui.add(primary_button("開啟設定", p)).clicked() { + open_pane = Some(if !see { "Privacy_ScreenCapture" } else { "Privacy_Accessibility" }); + } + }); + } + }); + if let Some(pane) = open_pane { + let _ = std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}")).spawn(); + } + self.allowed_apps_section(ui); + } + + /// 一律允許的應用程式: apps bots may use without asking (per bot), with a remove button. + fn allowed_apps_section(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + let apps: Vec<(u64, String, String)> = self + .state + .bots + .iter() + .flat_map(|b| b.allow_rules.iter().filter_map(|g| g.strip_prefix("app:")).map(|a| (b.id, b.name.clone(), a.to_string())).collect::>()) + .collect(); + if apps.is_empty() { + return; + } + let open = self.cu.apps_open; + let mut remove = None; + let mut flip = false; + sec_card(ui, p, |ui| { + sec_row(ui, p, "一律允許的應用程式", "Bot 無需事先詢問即可使用這些應用程式", |ui| { + let b = egui::Button::new(RichText::new(format!("{} 個應用程式 {}", apps.len(), if open { "⌄" } else { "›" })).size(FONT_SM)).corner_radius(8); + if ui.add(b).clicked() { + flip = true; + } + }); + if open { + for (id, bot, app) in &apps { + sec_row_s(ui, p, app, &format!("{bot} 可以使用"), "", |ui| { + if crate::icons::button(ui, crate::icons::Icon::Trash, 14.0, p.text3, p.hover).on_hover_text(format!("{} {app}", t("移除"))).clicked() { + remove = Some((*id, format!("app:{app}"))); + } + }); + } + } + }); + if flip { + self.cu.apps_open = !open; + } + if let Some((id, g)) = remove { + if let Some(b) = self.state.bot_mut(id) { + b.allow_rules.retain(|x| *x != g); + } + self.mark(); + } + } + + fn box_task(&mut self, f: impl FnOnce() -> Result + Send + 'static) { + let note = self.cu.note.clone(); + *note.lock().unwrap() = "處理中…".into(); + std::thread::spawn(move || { + let r = f(); + *note.lock().unwrap() = match r { + Ok(s) => s, + Err(e) => e, + }; + cloudbox::refresh(); + }); + } + + fn ask_primary_bot(&mut self, text: &str) { + let id = self.state.primary_bot; + self.drafts.insert(id, text.to_string()); + self.state.selected = id; + self.show_settings = false; + } + + /// The confirm dialog for a rebuild (the original's wording). + pub(super) fn rebuild_dialog(&mut self, ctx: &egui::Context) { + let Some(kind) = self.cu.confirm else { return }; + let p = self.pal; + let mut go = false; + let mut close = dialog(ctx, "box-rebuild", &format!("{}?", kind.title()), 420.0, true, p, |ui| { + dialog_body(ui, "box-rebuild-body", 300.0, |ui| { + ui.add(egui::Label::new(RichText::new(kind.warning()).size(FONT_BASE)).wrap()); + if kind == Rebuild::Update && cloudbox::is_busy() { + ui.add_space(6.0); + ui.label(RichText::new(t("有 Bot 正在用這台電腦,等它做完再重建。")).size(FONT_XS).color(p.danger)); + } + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let btn = if kind == Rebuild::Reset { danger_button(kind.title(), p) } else { primary_button(kind.title(), p) }; + if ui.add(btn).clicked() { + go = true; + } + if ui.add(secondary_button("取消")).clicked() { + go = false; + self.cu.confirm = None; + } + }); + }); + }); + if go { + if kind == Rebuild::Update { + // 更新會先停掉正在工作的 Bot,聊天會保留. + let ids: Vec = self.streams.keys().copied().collect(); + for id in ids { + self.stop(id); + } + } + *self.cu.note.lock().unwrap() = match cloudbox::rebuild(kind) { + Ok(()) => format!("{}已開始。", kind.title()), + Err(e) => e, + }; + close = true; + } + if close { + self.cu.confirm = None; + } + } + + // ---------- the live screen ---------- + + /// The box's screen as a small picture, refreshed every few seconds while it is shown (taking + /// it never wakes the box; asleep it shows the last picture). + pub(super) fn box_thumbnail(&mut self, ctx: &egui::Context) -> Option<(egui::TextureId, egui::Vec2)> { + if let Some(img) = self.cu.thumb_img.lock().unwrap().take() { + match &mut self.cu.thumb { + Some(t) => t.set(img, egui::TextureOptions::LINEAR), + None => self.cu.thumb = Some(ctx.load_texture("box-thumb", img, egui::TextureOptions::LINEAR)), + } + } + let every = if cloudbox::run_state().is_running() { 3 } else { 30 }; + if !self.cu.thumb_at.map(|t| t.elapsed().as_secs() < every).unwrap_or(false) && !self.cu.thumb_busy.swap(true, Ordering::SeqCst) { + self.cu.thumb_at = Some(Instant::now()); + let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone()); + std::thread::spawn(move || { + if let Some(img) = cloudbox::thumbnail(320) { + let size = [img.width() as usize, img.height() as usize]; + *slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())); + c.request_repaint(); + } + busy.store(false, Ordering::SeqCst); + }); + } + ctx.request_repaint_after(std::time::Duration::from_secs(every)); + self.cu.thumb.as_ref().map(|t| (t.id(), t.size_vec2())) + } + + pub(super) fn open_viewer(&mut self) { + self.cu.viewer_open = true; + self.cu.fit = true; + cloudbox::set_viewer_open(true); + self.show_settings = false; + if !cloudbox::run_state().is_running() && !self.demo { + self.box_task(|| cloudbox::ensure_running().map(|_| String::new())); + } + } + + fn close_viewer(&mut self) { + self.cu.viewer_open = false; + self.cu.control = false; + cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst); + cloudbox::set_viewer_open(false); + if let Some(stop) = self.cu.worker.take() { + stop.store(true, Ordering::SeqCst); + } + self.cu.input_tx = None; + } + + fn ensure_viewer_threads(&mut self, ctx: &egui::Context) { + if self.cu.worker.is_some() || (self.demo && !self.cu.demo_live) { + return; + } + let stop = Arc::new(AtomicBool::new(false)); + self.cu.worker = Some(stop.clone()); + let slot = self.cu.frame.clone(); + let c = ctx.clone(); + let s2 = stop.clone(); + std::thread::spawn(move || { + let mut seq = 0u64; + while !s2.load(Ordering::SeqCst) { + let got = cloudbox::frame(); + if let Some(img) = got { + seq += 1; + let size = [img.width() as usize, img.height() as usize]; + let ci = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()); + *slot.lock().unwrap() = Some((seq, ci)); + c.request_repaint(); + std::thread::sleep(std::time::Duration::from_millis(VIEWER_FRAME_MS)); + } else { + std::thread::sleep(std::time::Duration::from_millis(1000)); + c.request_repaint(); + } + } + }); + let (tx, rx) = channel::(); + self.cu.input_tx = Some(tx); + std::thread::spawn(move || { + while let Ok(i) = rx.recv() { + match i { + Input::Click(x, y, b, n) => cloudbox::viewer_click(x, y, b, n), + Input::Move(x, y) => cloudbox::viewer_move(x, y), + Input::Scroll(x, y, down, n) => cloudbox::viewer_scroll(x, y, down, n), + Input::Key(k) => cloudbox::viewer_key(&k), + Input::Text(t) => cloudbox::viewer_text(&t), + } + } + }); + } + + pub(super) fn viewer_window(&mut self, ctx: &egui::Context) { + if !self.cu.viewer_open { + return; + } + self.ensure_viewer_threads(ctx); + let p = self.pal; + if let Some((seq, img)) = self.cu.frame.lock().unwrap().take() { + if seq != self.cu.shown_seq { + self.cu.shown_seq = seq; + self.cu.frame_size = img.size; + match &mut self.cu.texture { + Some(t) => t.set(img, egui::TextureOptions::LINEAR), + None => self.cu.texture = Some(ctx.load_texture("box-screen", img, egui::TextureOptions::LINEAR)), + } + } + } + let run = cloudbox::run_state(); + let screen = ctx.content_rect(); + let open = true; + let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0); + let busy = cloudbox::is_busy(); + let mut close_clicked = false; + egui::Window::new("Bot 的電腦") + .id(egui::Id::unique("box-viewer")) + .title_bar(false) + .default_size(default) + .default_pos(screen.center() - default / 2.0) + .min_size(vec2(420.0, 300.0)) + .resizable(true) + .collapsible(false) + .frame( + Frame::new() + .fill(p.elevated) + .corner_radius(14) + .inner_margin(Margin::symmetric(14, 12)) + .stroke(Stroke::new(1.0, p.border)) + .shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + monitor_icon(ui, 20.0, p.text); + ui.label(RichText::new(crate::model::BOT_COMPUTER_NAME).strong().size(FONT_LG)); + status_pill(ui, &run, p); + if busy && !self.cu.control { + crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent); + } + if self.cu.control { + crate::icons::label(ui, crate::icons::Icon::Users, &t("你正在操作,Bot 已暫停"), FONT_XS, p.accent); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 8, p.hover); + } + let c = rect.center(); + let st = Stroke::new(1.5, if r.hovered() { p.text } else { p.text2 }); + ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], st); + ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], st); + if r.on_hover_text(t("關閉")).clicked() { + close_clicked = true; + } + ui.add_space(4.0); + if ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() { + let control = self.cu.control; + self.box_task(move || { + let url = cloudbox::viewer_url(control)?; + crate::preview::open_with_system(&url); + Ok(String::new()) + }); + } + let fit_label = if self.cu.fit { "實際大小" } else { "調整至視窗大小" }; + if ui.add(secondary_button(fit_label)).clicked() { + self.cu.fit = !self.cu.fit; + } + let (label, fill, col) = if self.cu.control { ("交還給 Bot", p.accent, Color32::WHITE) } else { ("取得控制權", p.primary, p.on_primary) }; + let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0)); + if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() { + self.cu.control = !self.cu.control; + cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst); + } + }); + }); + ui.add_space(6.0); + let avail = ui.available_size(); + match (self.cu.texture.as_ref().map(|t| t.id()), run.is_running()) { + (Some(tex_id), true) => { + let [fw, fh] = self.cu.frame_size; + let (fw, fh) = (fw.max(1) as f32, fh.max(1) as f32); + let size = if self.cu.fit { + let k = (avail.x / fw).min((avail.y.max(120.0)) / fh); + vec2(fw * k, fh * k) + } else { + vec2(fw, fh) + }; + egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { + let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; + let resp = ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)); + if self.cu.control { + ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside); + self.forward_input(ui, &resp, (fw, fh)); + } + }); + } + _ => { + let (rect, _) = ui.allocate_exact_size(vec2(avail.x, avail.y.max(200.0)), egui::Sense::hover()); + ui.painter().rect_filled(rect, 8, Color32::from_rgb(0x2b, 0x2f, 0x36)); + let text = match &run { + Run::Running => "正在連線…".to_string(), + Run::NoDocker(why) => why.clone(), + Run::Failed(e) => format!("發生問題:{e}"), + other => format!("{}…", other.label()), + }; + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(FONT_BASE), Color32::from_gray(220)); + if matches!(run, Run::Asleep | Run::Absent) { + let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0)); + if ui.put(b, primary_button("喚醒", p)).clicked() { + self.box_task(|| cloudbox::ensure_running().map(|_| String::new())); + } + } + } + } + }); + if close_clicked || !open { + self.close_viewer(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS)); + } + + /// Clicks, scrolls and keys on the picture go to the box while the person has control. + fn forward_input(&mut self, ui: &mut egui::Ui, resp: &egui::Response, frame: (f32, f32)) { + let Some(tx) = self.cu.input_tx.clone() else { return }; + let to_box = |pos: egui::Pos2| -> (i64, i64) { + let r = resp.rect; + (((pos.x - r.left()) / r.width() * frame.0).round() as i64, ((pos.y - r.top()) / r.height() * frame.1).round() as i64) + }; + if resp.clicked() || resp.secondary_clicked() || resp.double_clicked() { + resp.request_focus(); + } + if let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) { + let (x, y) = to_box(pos); + if resp.double_clicked() { + let _ = tx.send(Input::Click(x, y, "left", 2)); + } else if resp.clicked() { + let _ = tx.send(Input::Click(x, y, "left", 1)); + } else if resp.secondary_clicked() { + let _ = tx.send(Input::Click(x, y, "right", 1)); + } else if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta.y); + if d.abs() > 0.5 { + let _ = tx.send(Input::Scroll(x, y, d < 0.0, ((d.abs() / 40.0).ceil() as u32).max(1))); + } else if ui.input(|i| i.pointer.delta().length() > 3.0) { + let _ = tx.send(Input::Move(x, y)); + } + } + } + if !resp.has_focus() { + return; + } + ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); + let events = ui.input(|i| i.events.clone()); + for e in events { + match e { + egui::Event::Text(t) => { + let _ = tx.send(Input::Text(t)); + } + egui::Event::Paste(t) => { + let _ = tx.send(Input::Text(t)); + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + let name = match key { + Key::Enter => "Return", + Key::Tab => "Tab", + Key::Backspace => "BackSpace", + Key::Delete => "Delete", + Key::Escape => "Escape", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::Home => "Home", + Key::End => "End", + Key::PageUp => "Prior", + Key::PageDown => "Next", + _ if modifiers.command || modifiers.ctrl || modifiers.alt => { + let n = key.name(); + if n.chars().count() == 1 { + n + } else { + continue; + } + } + _ => continue, + }; + let mut combo = vec![]; + if modifiers.command || modifiers.ctrl { + combo.push("ctrl".to_string()); + } + if modifiers.alt { + combo.push("alt".to_string()); + } + if modifiers.shift && (name.len() > 1 || !combo.is_empty()) { + combo.push("shift".to_string()); + } + combo.push(if name.len() == 1 { name.to_lowercase() } else { name.to_string() }); + let _ = tx.send(Input::Key(combo.join("+"))); + } + _ => {} + } + } + } + + // ---------- the floating bar while a bot drives this Mac ---------- + + pub(super) fn local_control_bar(&mut self, ctx: &egui::Context) { + if self.demo { + return; + } + let held = crate::computer::HELD_BY_PERSON.load(Ordering::SeqCst); + let recent = crate::computer::LAST_ACTION.lock().unwrap().clone().filter(|(t, _)| t.elapsed().as_secs() < LOCAL_BAR_SECS); + if !held && recent.is_none() { + return; + } + let who = recent.map(|r| r.1).filter(|w| !w.is_empty()).unwrap_or_else(|| "Bot".into()); + let p = self.pal; + let mut stop_bot: Option = None; + let mut toggle = false; + let monitor_w = ctx.input(|i| i.viewport().monitor_size).map(|s| s.x).unwrap_or(1440.0); + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("anybot-local-control"), + egui::ViewportBuilder::default() + .with_title("AnyBot 正在操作你的電腦") + .with_inner_size([440.0, 46.0]) + .with_position([(monitor_w - 440.0) / 2.0, 34.0]) + .with_decorations(false) + .with_always_on_top() + .with_resizable(false) + .with_transparent(true), + |ui, _| { + Frame::new().fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(23).inner_margin(Margin::symmetric(16, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, if held { p.text3 } else { Color32::from_rgb(0xfc, 0x2c, 0x3e) }); + let text = if held { "你正在操作,Bot 已暫停".to_string() } else { format!("{who} 正在操作你的電腦") }; + ui.label(RichText::new(text).size(FONT_SM).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if !held && ui.add(egui::Button::new(RichText::new(t("停止")).size(FONT_XS)).corner_radius(14)).clicked() { + stop_bot = Some(who.clone()); + } + let label = if held { "交還給 Bot" } else { "取得控制權" }; + if ui.add(egui::Button::new(RichText::new(label).size(FONT_XS).strong().color(p.on_primary)).fill(p.primary).corner_radius(14)).clicked() { + toggle = true; + } + }); + }); + }); + }, + ); + if toggle { + crate::computer::HELD_BY_PERSON.store(!held, Ordering::SeqCst); + if held { + *crate::computer::LAST_ACTION.lock().unwrap() = None; + } + } + if let Some(name) = stop_bot { + let ids: Vec = self.streams.keys().copied().filter(|id| self.state.bot(*id).map(|b| b.name == name).unwrap_or(false)).collect(); + for id in ids { + self.stop(id); + } + *crate::computer::LAST_ACTION.lock().unwrap() = None; + } + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } +} + +// ---------- 示範教學 ---------- + +impl App { + /// 教它一項任務 from the + menu: start recording right away under a dated name. + pub(super) fn teach_quick(&mut self, bot_id: u64) { + let Some(bot) = self.state.bot(bot_id).cloned() else { return }; + if crate::teach::recording().is_some() { + return; + } + self.cu.teach_name = format!("{} {}", t("示範"), chrono::Local::now().format("%m/%d %H:%M")); + match crate::teach::start(&bot.name) { + Ok(()) => { + self.cu.teach_for = Some(bot_id); + self.cu.teach_error.clear(); + } + Err(e) => self.att.error = Some(e), + } + } + + pub(super) fn teach_card(&mut self, ui: &mut egui::Ui) { + if !crate::computer::demo_supported() { + return; + } + let p = self.pal; + let bot_id = self.state.selected; + let Some(bot) = self.state.bot(bot_id).cloned() else { return }; + setting_card(ui, p, |ui| { + setting_title(ui, "示範教學", &format!("錄下你在這台 Mac 上執行任務的過程。{} 會學習這些步驟,之後可以自行再次執行。", bot.name), p); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.cu.teach_name).hint_text(t("這個任務叫什麼,例如 匯出本月報表")).desired_width(ui.available_width() - 110.0)); + let ok = !self.cu.teach_name.trim().is_empty() && crate::teach::recording().is_none(); + if ui.add_enabled(ok, primary_button("開始錄製", p)).clicked() { + match crate::teach::start(&bot.name) { + Ok(()) => { + self.cu.teach_for = Some(bot_id); + self.cu.teach_error.clear(); + self.show_settings = false; + } + Err(e) => self.cu.teach_error = e, + } + } + }); + if !self.cu.teach_error.is_empty() { + ui.add(egui::Label::new(RichText::new(&self.cu.teach_error).size(FONT_XS).color(p.danger)).wrap()); + } + hint(ui, "按下開始後照平常的方式操作;做完按畫面上方的「完成」。會記錄點擊、打字和捲動,並且每 0.5 秒拍一張螢幕畫面(需要「螢幕與系統錄音」權限),完成後合成錄影;每一步會留一張當時的畫面給 Bot 看。不會擋住或改變你的操作。", p); + let mut remove = None; + for (i, d) in bot.demos.iter().enumerate() { + ui.add_space(6.0); + Frame::new().fill(p.elevated).corner_radius(10).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(&d.name).strong().size(FONT_SM)); + ui.label(RichText::new(format!("{} · {} 個步驟", d.created, d.steps.len())).size(11.5).color(p.text3)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.text3)).frame(false)).clicked() { + remove = Some(i); + } + if !d.video.is_empty() && ui.add(egui::Button::new(RichText::new(t("播放錄影")).size(12.0).color(p.accent)).frame(false)).clicked() { + crate::preview::open_with_system(&d.video.to_string()); + } + }); + }); + egui::CollapsingHeader::new(RichText::new(t("步驟")).size(FONT_XS).color(p.text2)).id_salt(("demo", bot_id, i)).show(ui, |ui| { + for (n, s) in d.steps.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("{}. {s}", n + 1)).size(FONT_XS).color(p.text2)); + if let Some(shot) = d.shots.get(n).filter(|x| !x.is_empty()) { + if ui.add(egui::Button::new(RichText::new(t("畫面")).size(11.0).color(p.accent)).frame(false)).on_hover_text(t("打開這一步當時的畫面")).clicked() { + crate::preview::open_with_system(&shot.to_string()); + } + } + }); + } + }); + }); + } + if let Some(i) = remove { + if let Some(b) = self.state.bot_mut(bot_id) { + b.demos.remove(i); + } + self.mark(); + } + }); + } + + /// 「{Bot} 正在觀看並學習」 with 完成 / 取消, on top of every window while recording. + pub(super) fn teach_bar(&mut self, ctx: &egui::Context) { + let Some((who, since)) = crate::teach::recording() else { return }; + let p = self.pal; + let mut finish = false; + let mut cancel = false; + let monitor_w = ctx.input(|i| i.viewport().monitor_size).map(|s| s.x).unwrap_or(1440.0); + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("anybot-teach"), + egui::ViewportBuilder::default() + .with_title("AnyBot 示範教學") + .with_inner_size([420.0, 46.0]) + .with_position([(monitor_w - 420.0) / 2.0, 34.0]) + .with_decorations(false) + .with_always_on_top() + .with_resizable(false) + .with_transparent(true), + |ui, _| { + Frame::new().fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(23).inner_margin(Margin::symmetric(16, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + let t = ui.input(|i| i.time) as f32; + ui.painter().circle_filled(r.center(), 4.5, Color32::from_rgb(0xfc, 0x2c, 0x3e).gamma_multiply(0.6 + 0.4 * (t * 3.0).sin().abs())); + let secs = since.elapsed().as_secs(); + ui.label(RichText::new(format!("{who} 正在觀看並學習 {:02}:{:02}", secs / 60, secs % 60)).size(FONT_SM).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(crate::i18n::t("取消")).size(FONT_XS)).corner_radius(14)).clicked() { + cancel = true; + } + if ui.add(egui::Button::new(RichText::new(crate::i18n::t("完成")).size(FONT_XS).strong().color(p.on_primary)).fill(p.primary).corner_radius(14)).clicked() { + finish = true; + } + }); + }); + }); + }, + ); + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + if cancel { + crate::teach::cancel(); + } + if finish { + let demo = crate::teach::finish(&self.cu.teach_name); + let n = demo.steps.len(); + if let Some(id) = self.cu.teach_for.take() { + if let Some(b) = self.state.bot_mut(id) { + b.demos.retain(|d| d.name != demo.name); + b.demos.push(demo); + } + self.notice = Some(format!("已記下「{}」({n} 個步驟)。之後跟 Bot 說「照示範做 {}」就可以。", self.cu.teach_name.trim(), self.cu.teach_name.trim())); + self.state.selected = id; + } + self.cu.teach_name.clear(); + self.mark(); + } + } +} + +/// An on/off switch (the original's toggle, size md). +fn toggle(on: &mut bool) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + let size = vec2(36.0, 20.0); + let (rect, mut r) = ui.allocate_exact_size(size, egui::Sense::click()); + if r.clicked() { + *on = !*on; + r.mark_changed(); + } + let t = ui.ctx().animate_bool_responsive(r.id, *on); + let off_col = ui.visuals().widgets.inactive.bg_fill; + let on_col = ui.visuals().selection.bg_fill; + let fill = if ui.is_enabled() { egui::lerp(egui::Rgba::from(off_col)..=egui::Rgba::from(on_col), t).into() } else { off_col.gamma_multiply(0.6) }; + ui.painter().rect_filled(rect, 10, fill); + let x = egui::lerp((rect.left() + 10.0)..=(rect.right() - 10.0), t); + ui.painter().circle_filled(egui::pos2(x, rect.center().y), 8.0, Color32::WHITE); + r.on_hover_cursor(egui::CursorIcon::PointingHand) + } +} + +/// The original's settings layout: sections 28 px apart, a small grey title (and something on +/// its right), then a card of rows (12 px corners, a hairline, 8 px top and bottom). +const SEC_GAP: f32 = 28.0; + +fn sec_title(ui: &mut egui::Ui, title: &str, p: Palette, accessory: impl FnOnce(&mut egui::Ui)) { + ui.add_space(SEC_GAP * 0.5); + ui.horizontal(|ui| { + ui.add_space(14.0); + ui.label(RichText::new(t(title)).size(FONT_SM).color(p.text2)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add_space(4.0); + accessory(ui); + }); + }); + ui.add_space(8.0); +} + +fn sec_card(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) { + Frame::new() + .fill(p.hover.gamma_multiply(0.45)) + .stroke(Stroke::new(0.5, p.border)) + .corner_radius(12) + .inner_margin(Margin::symmetric(0, 8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 0.0; + body(ui) + }); + ui.add_space(SEC_GAP * 0.5); +} + +/// A row: the setting's name (「?」 explains on hover) on the left, its control on the right. +fn sec_row(ui: &mut egui::Ui, p: Palette, label: &str, help: &str, control: impl FnOnce(&mut egui::Ui)) { + sec_row_s(ui, p, label, "", help, control) +} + +/// A row with a short status line under the name (目前在線上、上次快照…). +fn sec_row_s(ui: &mut egui::Ui, p: Palette, label: &str, status: &str, help: &str, control: impl FnOnce(&mut egui::Ui)) { + Frame::new().inner_margin(Margin::symmetric(14, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let w = (ui.available_width() * 0.58).max(200.0); + ui.vertical(|ui| { + ui.set_width(w); + ui.spacing_mut().item_spacing.y = 2.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new(t(label)).size(FONT_BASE).color(p.text)); + help_mark(ui, help, p); + }); + if !status.is_empty() { + ui.add(egui::Label::new(RichText::new(t(status)).size(FONT_SM).color(p.text2)).truncate()); + } + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + control(ui) + }); + }); + }); +} + +/// A small note inside a card. +fn sec_note(ui: &mut egui::Ui, p: Palette, text: &str) { + Frame::new().inner_margin(Margin { left: 14, right: 14, top: 0, bottom: 6 }).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(t(text)).size(FONT_XS).color(p.text3)).wrap()); + }); +} diff --git a/src/app/files_ui.rs b/src/app/files_ui.rs new file mode 100644 index 0000000..c2497d3 --- /dev/null +++ b/src/app/files_ui.rs @@ -0,0 +1,758 @@ +// 檔案卡片和預覽 (the original: a rounded card with the file's icon, name and size; clicking it +// opens a large viewer — spreadsheets as a grid with row numbers, documents rendered, pictures +// shown — with download and close at the top right). + +use super::*; +use crate::preview::{self, Preview}; +use std::sync::{Arc, Mutex}; + +pub struct FileView { + pub name: String, + pub path: String, + preview: Preview, + sheet: usize, + image: Option, + /// PDF pages (or a video's first frame) drawn on a worker thread. + job: Option>>, + pages: Vec, + audio: Option, + audio_err: Option, + /// The audio's length, known before it plays. + audio_len: Option, +} + +/// Pictures made on a worker thread for the viewer. +#[derive(Default)] +pub struct PageJob { + ready: Vec, + total: usize, + done: bool, + error: Option, +} + +/// An audio file playing inside AnyBot (rodio: the same on macOS, Windows and Linux). +pub struct AudioPlay { + _sink: rodio::MixerDeviceSink, + player: rodio::Player, + total: Option, +} + +impl AudioPlay { + fn start(path: &str) -> Result { + let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| t("找不到可以播放聲音的裝置").to_string())?; + sink.log_on_drop(false); + let file = std::fs::File::open(path).map_err(|e| format!("{}:{e}", t("讀不到檔案")))?; + let dec = match rodio::Decoder::try_from(file) { + Ok(d) => d, + // Opus and the rarer formats: let ffmpeg (when installed) turn it into WAV first. + Err(_) => { + let wav = std::env::temp_dir().join(format!("anybot-audio-{}.wav", std::process::id())); + let ok = std::process::Command::new("ffmpeg") + .env("PATH", crate::cloudbox::tool_path()) + .args(["-loglevel", "error", "-y", "-i", path]) + .arg(&wav) + .stdin(std::process::Stdio::null()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + let f = std::fs::File::open(&wav).ok().filter(|_| ok); + f.and_then(|f| rodio::Decoder::try_from(f).ok()).ok_or_else(|| t("這種音訊格式沒辦法在 AnyBot 裡播放,可以用其他 App 打開。").to_string())? + } + }; + let total = rodio::Source::total_duration(&dec); + let player = rodio::Player::connect_new(sink.mixer()); + player.append(dec); + Ok(AudioPlay { _sink: sink, player, total }) + } +} + +fn clock(d: std::time::Duration) -> String { + let s = d.as_secs(); + if s >= 3600 { + format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60) + } else { + format!("{}:{:02}", s / 60, s % 60) + } +} + +/// Draw a PDF's pages, or a video's first frame (with ffmpeg when it is installed), off the UI thread. +fn start_job(kind: &str, path: &str) -> Option>> { + let job = Arc::new(Mutex::new(PageJob::default())); + let j = job.clone(); + let path = std::path::PathBuf::from(path); + match kind { + "pdf" => { + std::thread::spawn(move || { + let r = preview::pdf_pages(&path, 1400.0, 300, |_, total, img| { + let mut g = j.lock().unwrap(); + g.total = total; + g.ready.push(img); + drop(g); + // Stop when the viewer was closed. + Arc::strong_count(&j) > 1 + }); + let mut g = j.lock().unwrap(); + g.error = r.err(); + g.done = true; + }); + } + "video" => { + std::thread::spawn(move || { + let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms())); + let ok = std::process::Command::new("ffmpeg") + .env("PATH", crate::cloudbox::tool_path()) + .args(["-loglevel", "error", "-y", "-ss", "0.5", "-i"]) + .arg(&path) + .args(["-frames:v", "1", "-vf", "scale='min(1280,iw)':-2"]) + .arg(&out) + .stdin(std::process::Stdio::null()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + let mut g = j.lock().unwrap(); + if ok { + if let Ok(img) = image::open(&out) { + g.ready.push(img.to_rgba8()); + } + } + let _ = std::fs::remove_file(&out); + g.done = true; + }); + } + _ => return None, + } + Some(job) +} + +fn texture(ctx: &egui::Context, name: &str, img: &image::RgbaImage) -> egui::TextureHandle { + let size = [img.width() as usize, img.height() as usize]; + ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR) +} + +const CARD_W: f32 = 320.0; +const CARD_H: f32 = 64.0; + +/// The grey of the bot's bubbles (rgb 31 in dark mode like the original; a light grey in light). +pub fn agent_fill(p: Palette) -> Color32 { + if p.pane.r() < 128 { + Color32::from_rgb(0x21, 0x21, 0x21) + } else { + Color32::from_rgb(0xf1, 0xf1, 0xf1) + } +} + +impl App { + /// A file in the chat. Click: preview. Right side on hover: save a copy. + pub(super) fn file_card(&mut self, ui: &mut egui::Ui, a: &crate::attach::Attachment) { + let p = self.pal; + let (rect, r) = ui.allocate_exact_size(vec2(CARD_W.min(ui.available_width()), CARD_H), egui::Sense::click()); + let fill = agent_fill(p); + ui.painter().rect_filled(rect, 16, if r.hovered() { fill.gamma_multiply(0.9).to_opaque() } else { fill }); + let icon = egui::Rect::from_min_size(rect.left_top() + vec2(14.0, 12.0), vec2(34.0, 40.0)); + big_file_icon(ui.painter(), icon, &a.name, p); + let tx = icon.right() + 14.0; + let max_w = rect.right() - tx - 12.0; + let name = fit_name(ui, &a.name, max_w); + ui.painter().text(egui::pos2(tx, rect.top() + 16.0), egui::Align2::LEFT_TOP, name, egui::FontId::proportional(14.0), p.text); + let sub = if a.bytes > 0 { crate::attach::human(a.bytes) } else { preview::kind_label(&a.name) }; + ui.painter().text(egui::pos2(tx, rect.top() + 36.0), egui::Align2::LEFT_TOP, sub, egui::FontId::proportional(12.0), p.text3); + if r.on_hover_text(format!("{}({})", a.name, t("點一下預覽"))).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + self.open_file(&a.name, &a.path); + } + } + + pub(super) fn open_file(&mut self, name: &str, path: &str) { + let preview = preview::load(std::path::Path::new(path), name); + let job = start_job(preview::kind_of(name), path); + self.file_view = Some(FileView { name: name.to_string(), path: path.to_string(), preview, sheet: 0, image: None, job, pages: vec![], audio: None, audio_err: None, audio_len: None }); + if let Some(fv) = self.file_view.as_mut().filter(|_| preview::kind_of(name) == "audio") { + fv.audio_len = std::fs::File::open(path).ok().and_then(|f| rodio::Decoder::try_from(f).ok()).and_then(|d| rodio::Source::total_duration(&d)); + } + } + + /// The large viewer. + pub(super) fn file_viewer(&mut self, ctx: &egui::Context) { + let Some(fv) = self.file_view.as_mut() else { return }; + let p = self.pal; + let screen = ctx.content_rect(); + let w = (screen.width() - 80.0).clamp(320.0, 1100.0); + let h = (screen.height() - 80.0).max(300.0); + let dark = p.pane.r() < 128; + let mut close = false; + let mut save = false; + let mut open_app = false; + let resp = egui::Modal::new(egui::Id::unique("file-viewer")) + .backdrop_color(Color32::from_black_alpha(if dark { 150 } else { 80 })) + .frame(Frame::new().fill(p.pane).stroke(Stroke::new(1.0, p.border)).corner_radius(14).shadow(egui::epaint::Shadow { offset: [0, 12], blur: 40, spread: 0, color: Color32::from_black_alpha(90) })) + .show(ctx, |ui| { + ui.set_width(w); + ui.set_height(h); + ui.spacing_mut().item_spacing.y = 0.0; + // Title, what it is, and download / close. + Frame::new().inner_margin(Margin { left: 16, right: 10, top: 12, bottom: 10 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.add(egui::Label::new(RichText::new(&fv.name).size(14.0).strong().color(p.text)).truncate()); + let sub = match &fv.preview { + Preview::Table(s) => format!("{} {}", s.get(fv.sheet).map(|x| x.1.len().saturating_sub(1)).unwrap_or(0), t("列")), + Preview::Pdf => match fv.job.as_ref().map(|j| j.lock().unwrap().total) { + Some(n) if n > 0 => format!("PDF · {n} {}", t("頁")), + _ => "PDF".into(), + }, + _ => preview::kind_label(&fv.name), + }; + ui.label(RichText::new(sub).size(12.0).color(p.text3)); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + close = crate::icons::button(ui, crate::icons::Icon::Close, 32.0, p.text2, p.hover).on_hover_text(t("關閉預覽")).clicked(); + save = crate::icons::button(ui, crate::icons::Icon::Download, 32.0, p.text2, p.hover).on_hover_text(t("下載檔案")).clicked(); + open_app = small_button(ui, "用其他 App 打開", p).clicked(); + }); + }); + }); + let r = ui.min_rect(); + ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, p.border)); + let body_h = ui.available_height(); + match &fv.preview { + Preview::Table(sheets) => { + let tabs = sheets.len() > 1; + let grid_h = body_h - if tabs { 40.0 } else { 0.0 }; + if let Some((_, rows)) = sheets.get(fv.sheet) { + Frame::new().inner_margin(Margin::same(12)).show(ui, |ui| sheet_grid(ui, rows, grid_h - 24.0, p)); + } + if tabs { + ui.horizontal(|ui| { + ui.add_space(12.0); + for (i, (name, _)) in sheets.iter().enumerate() { + if ui.selectable_label(fv.sheet == i, name).clicked() { + fv.sheet = i; + } + } + }); + } + } + Preview::Markdown(md) => { + egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + Frame::new().inner_margin(Margin::symmetric(28, 20)).show(ui, |ui| { + ui.set_max_width(760.0); + doc_markdown(ui, md, p); + }); + }); + } + Preview::Html(text) => { + Frame::new().inner_margin(Margin::symmetric(16, 10)).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.add(primary_button("在瀏覽器開啟", p)).clicked() { + open_app = true; + } + ui.label(RichText::new(t("下面是網頁的內容(版面以瀏覽器為準)")).size(12.0).color(p.text3)); + }); + }); + egui::ScrollArea::vertical().max_height(body_h - 52.0).auto_shrink([false, false]).show(ui, |ui| { + Frame::new().inner_margin(Margin::symmetric(28, 8)).show(ui, |ui| { + ui.set_max_width(760.0); + doc_markdown(ui, text, p); + }); + }); + } + Preview::Image => { + if fv.image.is_none() { + fv.image = load_texture(ui.ctx(), &fv.path); + } + match &fv.image { + Some(tex) => { + let avail = vec2(ui.available_width() - 24.0, body_h - 24.0); + let size = tex.size_vec2(); + let s = (avail.x / size.x).min(avail.y / size.y).min(1.0); + ui.vertical_centered(|ui| { + ui.add_space(12.0); + ui.add(egui::Image::new((tex.id(), size * s)).corner_radius(8)); + }); + } + None => { + ui.add_space(20.0); + ui.vertical_centered(|ui| ui.label(RichText::new(t("這張圖片打不開")).color(p.text3))); + } + } + } + Preview::Pdf => { + let (mut done, mut err, mut total) = (true, None, 0); + if let Some(job) = &fv.job { + let mut g = job.lock().unwrap(); + for (i, img) in g.ready.drain(..).enumerate() { + let n = fv.pages.len() + i; + fv.pages.push(texture(ui.ctx(), &format!("pdf-page-{n}"), &img)); + } + done = g.done; + err = g.error.clone(); + total = g.total; + } + if !done { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); + } + if fv.pages.is_empty() { + ui.add_space(40.0); + ui.vertical_centered(|ui| match err { + Some(e) if done => { + ui.label(RichText::new(t(&e)).color(p.text2)); + ui.add_space(10.0); + if ui.add(secondary_button("用其他 App 打開")).clicked() { + open_app = true; + } + } + _ => { + ui.add(egui::Spinner::new().size(22.0).color(p.text3)); + ui.add_space(8.0); + ui.label(RichText::new(t("正在打開 PDF…")).size(13.0).color(p.text3)); + } + }); + } else { + egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + let page_w = (ui.available_width() - 48.0).min(900.0); + ui.vertical_centered(|ui| { + ui.spacing_mut().item_spacing.y = 14.0; + ui.add_space(16.0); + for (i, tex) in fv.pages.iter().enumerate() { + let size = tex.size_vec2() * (page_w / tex.size_vec2().x); + let r = ui.add(egui::Image::new((tex.id(), size)).corner_radius(4)); + ui.painter().rect_stroke(r.rect, 4, Stroke::new(1.0, p.border), egui::StrokeKind::Outside); + if total > 1 { + r.on_hover_text(format!("{} {} / {total}", t("第"), i + 1)); + } + } + if !done { + ui.add(egui::Spinner::new().size(18.0).color(p.text3)); + } else if total > fv.pages.len() { + ui.label(RichText::new(format!("{} {} {}", t("只顯示前"), fv.pages.len(), t("頁,完整內容請用其他 App 打開"))).size(12.0).color(p.text3)); + } + ui.add_space(16.0); + }); + }); + } + } + Preview::Audio => { + ui.add_space((body_h * 0.28).max(30.0)); + ui.vertical_centered(|ui| { + let (tile, _) = ui.allocate_exact_size(vec2(88.0, 88.0), egui::Sense::hover()); + let accent = Color32::from_rgb(0x0e, 0xa5, 0xe9); + ui.painter().rect_filled(tile, 22, accent.gamma_multiply(0.16)); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(tile.center(), vec2(40.0, 40.0)), crate::icons::Icon::FileAudio, accent); + ui.add_space(18.0); + let playing = fv.audio.as_ref().is_some_and(|a| !a.player.is_paused() && !a.player.empty()); + let (pos, total) = fv.audio.as_ref().map(|a| (a.player.get_pos(), a.total.or(fv.audio_len))).unwrap_or((Default::default(), fv.audio_len)); + let (btn, br) = ui.allocate_exact_size(vec2(56.0, 56.0), egui::Sense::click()); + ui.painter().circle_filled(btn.center(), 28.0, if br.hovered() { p.text } else { p.text.gamma_multiply(0.92) }); + let icon = if playing { crate::icons::Icon::Pause } else { crate::icons::Icon::Play }; + let off = if playing { 0.0 } else { 2.0 }; + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(btn.center() + vec2(off, 0.0), vec2(22.0, 22.0)), icon, p.pane); + if br.on_hover_text(if playing { t("暫停") } else { t("播放") }).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + match &fv.audio { + Some(a) if a.player.empty() => fv.audio = None, + Some(a) if playing => a.player.pause(), + Some(a) => a.player.play(), + None => {} + } + if fv.audio.is_none() { + match AudioPlay::start(&fv.path) { + Ok(a) => fv.audio = Some(a), + Err(e) => fv.audio_err = Some(e), + } + } + } + ui.add_space(16.0); + // Progress: drag to jump. + let bar_w = (ui.available_width() - 120.0).min(420.0); + let (bar, bresp) = ui.allocate_exact_size(vec2(bar_w, 18.0), egui::Sense::click_and_drag()); + let line = egui::Rect::from_center_size(bar.center(), vec2(bar_w, 4.0)); + ui.painter().rect_filled(line, 2, p.hover); + let frac = match total { + Some(t) if t.as_secs_f32() > 0.0 => (pos.as_secs_f32() / t.as_secs_f32()).clamp(0.0, 1.0), + _ => 0.0, + }; + let mut done_part = line; + done_part.set_right(line.left() + line.width() * frac); + ui.painter().rect_filled(done_part, 2, p.text); + ui.painter().circle_filled(egui::pos2(done_part.right(), line.center().y), 6.0, p.text); + if let (Some(a), Some(tot), Some(at)) = (&fv.audio, total, bresp.interact_pointer_pos()) { + let f = ((at.x - line.left()) / line.width()).clamp(0.0, 1.0); + let _ = a.player.try_seek(tot.mul_f32(f)); + } + ui.add_space(6.0); + let time = match total { + Some(tot) => format!("{} / {}", clock(pos), clock(tot)), + None => clock(pos), + }; + ui.label(RichText::new(time).size(12.0).color(p.text3)); + if let Some(e) = &fv.audio_err { + ui.add_space(10.0); + ui.label(RichText::new(e).size(13.0).color(p.danger)); + ui.add_space(6.0); + if ui.add(secondary_button("用其他 App 打開")).clicked() { + open_app = true; + } + } + if playing { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); + } + }); + } + Preview::Video => { + let mut done = true; + if let Some(job) = &fv.job { + let mut g = job.lock().unwrap(); + if let Some(img) = g.ready.pop() { + fv.image = Some(texture(ui.ctx(), "video-poster", &img)); + } + done = g.done; + } + if !done { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + ui.vertical_centered(|ui| { + ui.add_space(16.0); + let avail = vec2(ui.available_width() - 48.0, body_h - 90.0); + let (rect, resp) = match &fv.image { + Some(tex) => { + let size = tex.size_vec2(); + let s = (avail.x / size.x).min(avail.y / size.y).min(1.5); + let r = ui.add(egui::Image::new((tex.id(), size * s)).corner_radius(10).sense(egui::Sense::click())); + (r.rect, r) + } + None => { + let size = vec2(avail.x.min(640.0), (avail.x.min(640.0) * 9.0 / 16.0).min(avail.y)); + let (r, resp) = ui.allocate_exact_size(size, egui::Sense::click()); + ui.painter().rect_filled(r, 10, Color32::from_rgb(0x10, 0x10, 0x10)); + if !done { + egui::Spinner::new().size(20.0).color(Color32::from_gray(150)).paint_at(ui, egui::Rect::from_center_size(r.center() + vec2(0.0, 52.0), vec2(20.0, 20.0))); + } + (r, resp) + } + }; + // The play button in the middle. + ui.painter().circle_filled(rect.center(), 32.0, Color32::from_black_alpha(if resp.hovered() { 200 } else { 150 })); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.center() + vec2(2.0, 0.0), vec2(26.0, 26.0)), crate::icons::Icon::Play, Color32::WHITE); + if resp.on_hover_text(t("播放影片")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + open_app = true; + } + ui.add_space(12.0); + ui.label(RichText::new(t("影片會用電腦的播放器播放")).size(12.0).color(p.text3)); + }); + } + Preview::Text(text) => { + egui::ScrollArea::both().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + Frame::new().inner_margin(Margin::symmetric(16, 12)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(text).monospace().size(12.5).color(p.text)).extend()); + }); + }); + } + Preview::Other(msg) => { + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label(RichText::new(t(msg)).color(p.text2)); + ui.add_space(10.0); + if ui.add(secondary_button("用其他 App 打開")).clicked() { + open_app = true; + } + }); + } + } + }); + let path = fv.path.clone(); + let name = fv.name.clone(); + if open_app { + preview::open_with_system(&path); + } + if save { + // 下載: save a copy where the person picks (Downloads by default). + std::thread::spawn(move || { + if let Some(to) = crate::share::choose_save(&name) { + let _ = std::fs::copy(&path, to); + } + }); + } + if close || resp.should_close() { + self.file_view = None; + } + } +} + +fn load_texture(ctx: &egui::Context, path: &str) -> Option { + let img = preview::decode_image(std::path::Path::new(path))?; + Some(texture(ctx, "file-view", &img)) +} + +/// A document in the viewer (Markdown, Word and web pages): headings, paragraphs with bold, +/// italics and `code`, bulleted and numbered lists, quotes, rules, code blocks and tables. +pub(super) fn doc_markdown(ui: &mut egui::Ui, md: &str, p: Palette) { + ui.spacing_mut().item_spacing.y = 6.0; + let lines: Vec<&str> = md.lines().collect(); + let mut i = 0; + while i < lines.len() { + let line = lines[i].trim_end(); + let t = line.trim_start(); + if t.starts_with("```") { + let mut code = String::new(); + i += 1; + while i < lines.len() && !lines[i].trim_start().starts_with("```") { + code.push_str(lines[i]); + code.push('\n'); + i += 1; + } + Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::same(12)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(code.trim_end()).monospace().size(12.5).color(p.text)).wrap()); + }); + } else if t.starts_with('|') && i + 1 < lines.len() && is_rule_row(lines[i + 1]) { + let mut rows = vec![table_cells(t)]; + i += 2; + while i < lines.len() && lines[i].trim_start().starts_with('|') { + rows.push(table_cells(lines[i].trim())); + i += 1; + } + doc_table(ui, &rows, p); + continue; + } else if t.is_empty() { + ui.add_space(2.0); + } else if t == "---" || t == "***" || t == "___" { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), egui::Sense::hover()); + ui.painter().hline(r.x_range(), r.center().y, Stroke::new(1.0, p.border)); + } else if let Some((level, h)) = heading(t) { + ui.add_space(if level == 1 { 10.0 } else { 6.0 }); + let size = [24.0, 20.0, 17.0][level - 1]; + ui.add(egui::Label::new(inline_job(h, size, p.text, p, true)).wrap()); + ui.add_space(2.0); + } else if let Some(q) = t.strip_prefix("> ").or_else(|| t.strip_prefix('>')) { + ui.horizontal_top(|ui| { + let (bar, _) = ui.allocate_exact_size(vec2(3.0, 22.0), egui::Sense::hover()); + ui.painter().rect_filled(bar, 2, p.border); + ui.add_space(8.0); + ui.add(egui::Label::new(inline_job(q, 15.0, p.text2, p, false)).wrap()); + }); + } else if let Some((marker, item)) = list_item(t) { + let indent = (line.len() - t.len()) as f32 * 8.0; + ui.horizontal_top(|ui| { + ui.add_space(6.0 + indent); + ui.add_sized(vec2(22.0, 22.0), egui::Label::new(RichText::new(marker).size(15.0).color(p.text2))); + ui.add(egui::Label::new(inline_job(item, 15.0, p.text, p, false)).wrap()); + }); + } else { + ui.add(egui::Label::new(inline_job(t, 15.0, p.text, p, false)).wrap()); + } + i += 1; + } +} + +fn heading(t: &str) -> Option<(usize, &str)> { + let n = t.chars().take_while(|c| *c == '#').count(); + (1..=6).contains(&n).then(|| t[n..].strip_prefix(' ')).flatten().map(|h| (n.min(3), h)) +} + +fn list_item(t: &str) -> Option<(String, &str)> { + for b in ["- ", "* ", "+ "] { + if let Some(rest) = t.strip_prefix(b) { + return Some(("•".into(), rest)); + } + } + let digits = t.chars().take_while(|c| c.is_ascii_digit()).count(); + if digits > 0 && digits < 4 { + if let Some(rest) = t[digits..].strip_prefix(". ").or_else(|| t[digits..].strip_prefix(") ")) { + return Some((format!("{}.", &t[..digits]), rest)); + } + } + None +} + +fn is_rule_row(l: &str) -> bool { + let t = l.trim(); + t.starts_with('|') && t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) +} + +fn table_cells(l: &str) -> Vec { + let inner = l.trim().trim_start_matches('|').trim_end_matches('|'); + let mut cells = vec![]; + let mut cur = String::new(); + let mut esc = false; + for c in inner.chars() { + match c { + '\\' if !esc => esc = true, + '|' if !esc => cells.push(std::mem::take(&mut cur).trim().to_string()), + c => { + if esc && c != '|' { + cur.push('\\'); + } + esc = false; + cur.push(c); + } + } + } + cells.push(cur.trim().to_string()); + cells +} + +fn doc_table(ui: &mut egui::Ui, rows: &[Vec], p: Palette) { + let n = rows.iter().map(|r| r.len()).max().unwrap_or(0); + egui::ScrollArea::horizontal().id_salt(ui.next_auto_id()).auto_shrink([false, true]).show(ui, |ui| { + Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(8).show(ui, |ui| { + egui::Grid::new(ui.next_auto_id()).spacing(vec2(0.0, 0.0)).show(ui, |ui| { + for (ri, r) in rows.iter().enumerate() { + for ci in 0..n { + let cell = r.get(ci).map(String::as_str).unwrap_or(""); + let fill = if ri == 0 { p.hover } else { Color32::TRANSPARENT }; + Frame::new().fill(fill).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { + ui.set_min_width(60.0); + ui.set_max_width(320.0); + ui.add(egui::Label::new(inline_job(cell, 14.0, if ri == 0 { p.text } else { p.text2 }, p, ri == 0)).wrap()); + }); + } + ui.end_row(); + } + }); + }); + }); + ui.add_space(4.0); +} + +/// One line of Markdown text with **bold**, *italics* and `code`. +fn inline_job(s: &str, size: f32, col: Color32, p: Palette, strong: bool) -> egui::text::LayoutJob { + use egui::text::{LayoutJob, TextFormat}; + let mut job = LayoutJob::default(); + let (mut bold, mut italic, mut code) = (strong, false, false); + let mut buf = String::new(); + let flush = |job: &mut LayoutJob, buf: &mut String, bold: bool, italic: bool, code: bool| { + if buf.is_empty() { + return; + } + let mut f = TextFormat { font_id: if code { egui::FontId::monospace(size - 1.5) } else { egui::FontId::proportional(size) }, color: if bold { p.text } else { col }, italics: italic, ..Default::default() }; + if code { + f.background = p.hover; + } + job.append(buf, 0.0, f); + buf.clear(); + }; + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '`' { + flush(&mut job, &mut buf, bold, italic, code); + code = !code; + } else if !code && c == '*' && chars.get(i + 1) == Some(&'*') { + flush(&mut job, &mut buf, bold, italic, code); + bold = !bold; + i += 1; + } else if !code && c == '*' && (italic || chars.get(i + 1).is_some_and(|n| !n.is_whitespace())) { + flush(&mut job, &mut buf, bold, italic, code); + italic = !italic; + } else { + buf.push(c); + } + i += 1; + } + flush(&mut job, &mut buf, bold, italic, code); + job +} + +/// A spreadsheet grid like the original's: row numbers on the left, the first row as header, +/// columns sized to their contents (60–260 px), long cells cut with "…" (full text on hover). +fn sheet_grid(ui: &mut egui::Ui, rows: &[Vec], height: f32, p: Palette) { + let font = egui::FontId::proportional(13.0); + let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + let mut widths = vec![60.0f32; ncols]; + for r in rows.iter().take(60) { + for (i, c) in r.iter().enumerate() { + let w = ui.painter().layout_no_wrap(c.clone(), font.clone(), p.text).size().x + 20.0; + widths[i] = widths[i].max(w.min(260.0)); + } + } + let num_w = 44.0; + let row_h = 28.0; + let total_w = num_w + widths.iter().sum::(); + let line = Stroke::new(1.0, p.border); + egui::ScrollArea::both().max_height(height.max(100.0)).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| { + for ri in range { + let (rect, resp) = ui.allocate_exact_size(vec2(total_w, row_h), egui::Sense::hover()); + let header = ri == 0; + if header { + ui.painter().rect_filled(rect, 0, p.hover); + } + // Row number. + let nr = egui::Rect::from_min_size(rect.min, vec2(num_w, row_h)); + ui.painter().rect_filled(nr, 0, p.hover); + ui.painter().text(nr.right_center() - vec2(8.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{}", ri + 1), egui::FontId::proportional(12.0), p.text3); + let mut x = rect.left() + num_w; + let mut hovered_cell: Option = None; + for (ci, w) in widths.iter().enumerate() { + let cell = egui::Rect::from_min_size(egui::pos2(x, rect.top()), vec2(*w, row_h)); + let text = rows[ri].get(ci).cloned().unwrap_or_default(); + let shown = fit_text(ui, &text, w - 16.0, &font); + let col = if header { p.text } else { p.text2 }; + ui.painter().with_clip_rect(cell).text(cell.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, shown, font.clone(), col); + ui.painter().vline(cell.right(), cell.y_range(), line); + if resp.hovered() && cell.contains(ui.ctx().pointer_hover_pos().unwrap_or_default()) && !text.is_empty() { + hovered_cell = Some(text); + } + x += w; + } + ui.painter().hline(rect.x_range(), rect.bottom(), line); + ui.painter().vline(nr.right(), nr.y_range(), line); + if let Some(full) = hovered_cell { + resp.on_hover_text(full); + } + } + }); +} + +fn fit_text(ui: &egui::Ui, s: &str, max_w: f32, font: &egui::FontId) -> String { + if ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut n = chars.len(); + while n > 1 { + n -= 1; + let c: String = chars[..n].iter().collect::() + "…"; + if ui.painter().layout_no_wrap(c.clone(), font.clone(), Color32::WHITE).size().x <= max_w { + return c; + } + } + "…".into() +} + +/// "orders_20261102_packa… .xlsx": keep the extension like the original. +fn fit_name(ui: &egui::Ui, name: &str, max_w: f32) -> String { + let font = egui::FontId::proportional(14.0); + if ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x <= max_w { + return name.to_string(); + } + let (stem, ext) = name.rsplit_once('.').map(|(a, b)| (a.to_string(), format!(" .{b}"))).unwrap_or((name.to_string(), String::new())); + let chars: Vec = stem.chars().collect(); + let mut n = chars.len(); + while n > 1 { + n -= 1; + let c = chars[..n].iter().collect::() + "…" + &ext; + if ui.painter().layout_no_wrap(c.clone(), font.clone(), Color32::WHITE).size().x <= max_w { + return c; + } + } + name.to_string() +} + +/// The card's icon: the file kind's Lucide icon on a soft tile of the kind's colour +/// (spreadsheets green, Markdown purple, web pages orange, PDF red, documents blue). +fn big_file_icon(painter: &egui::Painter, r: egui::Rect, name: &str, p: Palette) { + let col = match preview::kind_of(name) { + "sheet" => Color32::from_rgb(0x1f, 0xb3, 0x5d), + "markdown" => Color32::from_rgb(0x8b, 0x5c, 0xf6), + "html" => Color32::from_rgb(0xf9, 0x73, 0x16), + "pdf" => Color32::from_rgb(0xe5, 0x48, 0x4d), + "doc" | "docx" => Color32::from_rgb(0x16, 0x80, 0xf3), + "json" => Color32::from_rgb(0xea, 0xb3, 0x08), + "image" | "video" | "audio" => Color32::from_rgb(0x0e, 0xa5, 0xe9), + _ => p.text2, + }; + let tile = egui::Rect::from_center_size(r.center(), vec2(40.0, 40.0)); + painter.rect_filled(tile, 10, col.gamma_multiply(0.16)); + crate::icons::paint(painter, egui::Rect::from_center_size(tile.center(), vec2(22.0, 22.0)), crate::icons::for_file(name), col); +} diff --git a/src/app/markdown_ui.rs b/src/app/markdown_ui.rs new file mode 100644 index 0000000..d96ecaa --- /dev/null +++ b/src/app/markdown_ui.rs @@ -0,0 +1,797 @@ +// Chat Markdown, modelled on the original's TranscriptMarkdown (react-markdown + remark-gfm): +// headings (h1 22 px, h2 17 px), single newlines kept as line breaks, **bold**, *italic*, +// ~~strikethrough~~, `inline code`, links, nested lists and task lists, quotes, tables (8 px +// cells, bottom borders, scroll sideways), code blocks (12 px corners, a border, scroll sideways, +// 複製程式碼 on hover), rules, and ```mermaid diagrams drawn once the reply is complete (click +// to view large; 「無法顯示此圖表」 above the source when one can't be drawn). + +use super::*; +use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use std::hash::{Hash, Hasher}; + +#[derive(Clone, Default, Debug, PartialEq)] +pub struct Span { + pub text: String, + pub bold: bool, + pub italic: bool, + pub strike: bool, + pub code: bool, + pub link: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Block { + Para(Vec), + Heading(u8, Vec), + Code { lang: String, text: String }, + Quote(Vec), + List { start: Option, items: Vec }, + Table { aligns: Vec, head: Vec>, rows: Vec>> }, + Rule, + Math(String), +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct ListItem { + pub task: Option, + pub blocks: Vec, +} + +/// Before parsing (like the original): "•", "◦", "▪" bullets become Markdown list items. +fn prepare(text: &str) -> String { + text.lines() + .map(|l| { + let t = l.trim_start(); + let indent = &l[..l.len() - t.len()]; + for b in ['•', '◦', '▪'] { + if let Some(rest) = t.strip_prefix(b) { + return format!("{indent}- {}", rest.trim_start()); + } + } + l.to_string() + }) + .collect::>() + .join("\n") +} + +/// Markdown → blocks. +pub fn parse(text: &str) -> Vec { + let text = prepare(text); + let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS | Options::ENABLE_MATH; + // Containers being filled: (kind, blocks so far). + enum Frame { + Root(Vec), + Quote(Vec), + List(Option, Vec), + Item(ListItem), + } + let mut stack: Vec = vec![Frame::Root(vec![])]; + let mut spans: Vec = vec![]; + let mut style = Span::default(); + let mut links: Vec = vec![]; + let mut in_inline: Option> = None; // Some(None)=paragraph, Some(Some(n))=heading + let mut code: Option<(String, String)> = None; + // Tables. + let mut table: Option<(Vec, Vec>, Vec>>, bool)> = None; + let mut row: Vec> = vec![]; + + fn push_block(stack: &mut [Frame], b: Block) { + match stack.last_mut() { + Some(Frame::Root(v)) | Some(Frame::Quote(v)) => v.push(b), + Some(Frame::Item(it)) => it.blocks.push(b), + Some(Frame::List(_, items)) => { + // Text directly in a tight list item arrives without an Item frame only in odd input. + items.push(ListItem { task: None, blocks: vec![b] }); + } + None => {} + } + } + let flush = |stack: &mut Vec, spans: &mut Vec, heading: Option| { + if spans.is_empty() { + return; + } + let s = std::mem::take(spans); + push_block(stack, match heading { + Some(n) => Block::Heading(n, s), + None => Block::Para(s), + }); + }; + let add_text = |spans: &mut Vec, style: &Span, links: &[String], t: &str, code: bool| { + let span = Span { text: t.to_string(), code, link: links.last().cloned(), ..style.clone() }; + if let Some(last) = spans.last_mut() { + if (Span { text: String::new(), ..last.clone() }) == (Span { text: String::new(), ..span.clone() }) { + last.text.push_str(t); + return; + } + } + spans.push(span); + }; + + for ev in Parser::new_ext(&text, opts) { + if let Some((_, buf)) = code.as_mut() { + match ev { + Event::Text(t) => { + buf.push_str(&t); + continue; + } + Event::End(TagEnd::CodeBlock) => { + let (lang, text) = code.take().unwrap_or_default(); + push_block(&mut stack, Block::Code { lang, text: text.trim_end_matches('\n').to_string() }); + continue; + } + _ => continue, + } + } + match ev { + Event::Start(Tag::Paragraph) => in_inline = Some(None), + Event::End(TagEnd::Paragraph) => { + flush(&mut stack, &mut spans, None); + in_inline = None; + } + Event::Start(Tag::Heading { level, .. }) => { + in_inline = Some(Some(match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + _ => 4, + })) + } + Event::End(TagEnd::Heading(_)) => { + let h = in_inline.flatten(); + flush(&mut stack, &mut spans, h.or(Some(4))); + in_inline = None; + } + Event::Start(Tag::CodeBlock(kind)) => { + flush(&mut stack, &mut spans, None); + let lang = match kind { + CodeBlockKind::Fenced(l) => l.split_whitespace().next().unwrap_or("").to_lowercase(), + CodeBlockKind::Indented => String::new(), + }; + code = Some((lang, String::new())); + } + Event::Start(Tag::BlockQuote(_)) => { + flush(&mut stack, &mut spans, None); + stack.push(Frame::Quote(vec![])); + } + Event::End(TagEnd::BlockQuote(_)) => { + flush(&mut stack, &mut spans, None); + if let Some(Frame::Quote(v)) = stack.pop() { + push_block(&mut stack, Block::Quote(v)); + } + } + Event::Start(Tag::List(start)) => { + flush(&mut stack, &mut spans, None); + stack.push(Frame::List(start, vec![])); + } + Event::End(TagEnd::List(_)) => { + if let Some(Frame::List(start, items)) = stack.pop() { + push_block(&mut stack, Block::List { start, items }); + } + } + Event::Start(Tag::Item) => stack.push(Frame::Item(ListItem::default())), + Event::End(TagEnd::Item) => { + flush(&mut stack, &mut spans, None); + if let Some(Frame::Item(it)) = stack.pop() { + if let Some(Frame::List(_, items)) = stack.last_mut() { + items.push(it); + } + } + } + Event::TaskListMarker(done) => { + if let Some(Frame::Item(it)) = stack.last_mut() { + it.task = Some(done); + } + } + Event::Start(Tag::Table(aligns)) => { + flush(&mut stack, &mut spans, None); + table = Some((aligns, vec![], vec![], false)); + } + Event::Start(Tag::TableHead) => { + if let Some(t) = table.as_mut() { + t.3 = true; + } + row.clear(); + } + Event::End(TagEnd::TableHead) => { + if let Some(t) = table.as_mut() { + t.1 = std::mem::take(&mut row); + t.3 = false; + } + } + Event::Start(Tag::TableRow) => row.clear(), + Event::End(TagEnd::TableRow) => { + if let Some(t) = table.as_mut() { + t.2.push(std::mem::take(&mut row)); + } + } + Event::Start(Tag::TableCell) => spans.clear(), + Event::End(TagEnd::TableCell) => row.push(std::mem::take(&mut spans)), + Event::End(TagEnd::Table) => { + if let Some((aligns, head, rows, _)) = table.take() { + push_block(&mut stack, Block::Table { aligns, head, rows }); + } + } + Event::Start(Tag::Emphasis) => style.italic = true, + Event::End(TagEnd::Emphasis) => style.italic = false, + Event::Start(Tag::Strong) => style.bold = true, + Event::End(TagEnd::Strong) => style.bold = false, + Event::Start(Tag::Strikethrough) => style.strike = true, + Event::End(TagEnd::Strikethrough) => style.strike = false, + Event::Start(Tag::Link { dest_url, .. }) => links.push(dest_url.to_string()), + Event::End(TagEnd::Link) => { + links.pop(); + } + Event::Start(Tag::Image { dest_url, .. }) => links.push(dest_url.to_string()), + Event::End(TagEnd::Image) => { + links.pop(); + } + Event::Text(t) => add_text(&mut spans, &style, &links, &t, false), + Event::Code(t) => add_text(&mut spans, &style, &links, &t, true), + Event::InlineMath(t) => add_text(&mut spans, &style, &links, &t, true), + Event::DisplayMath(t) => { + flush(&mut stack, &mut spans, None); + push_block(&mut stack, Block::Math(t.trim().to_string())); + } + Event::SoftBreak | Event::HardBreak => add_text(&mut spans, &style, &links, "\n", false), + Event::Html(t) | Event::InlineHtml(t) => { + // Like the original: raw HTML shows as text, except a few harmless tags. + let s = t.trim(); + let tag = s.trim_start_matches('<').trim_start_matches('/').trim_end_matches('>').trim_end_matches('/').trim().to_lowercase(); + if ["br"].contains(&tag.as_str()) { + add_text(&mut spans, &style, &links, "\n", false); + } else if !["b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "sub", "sup", "mark"].contains(&tag.as_str()) { + add_text(&mut spans, &style, &links, &t, false); + } + } + Event::Rule => { + flush(&mut stack, &mut spans, None); + push_block(&mut stack, Block::Rule); + } + Event::FootnoteReference(t) => add_text(&mut spans, &style, &links, &format!("[{t}]"), false), + _ => {} + } + let _ = in_inline; + } + // Unclosed code fence while streaming: show what there is. + if let Some((lang, text)) = code.take() { + push_block(&mut stack, Block::Code { lang, text }); + } + flush(&mut stack, &mut spans, None); + while stack.len() > 1 { + match stack.pop() { + Some(Frame::Quote(v)) => push_block(&mut stack, Block::Quote(v)), + Some(Frame::List(start, items)) => push_block(&mut stack, Block::List { start, items }), + Some(Frame::Item(it)) => { + if let Some(Frame::List(_, items)) = stack.last_mut() { + items.push(it); + } + } + _ => {} + } + } + match stack.pop() { + Some(Frame::Root(v)) => v, + _ => vec![], + } +} + +/// A light syntax colouring for code blocks: comments, strings, numbers and common keywords. +fn highlight(code: &str, lang: &str, base: Color32, dark: bool) -> Vec<(String, Color32)> { + let (c_kw, c_str, c_num, c_com) = if dark { + (Color32::from_rgb(0xc6, 0x92, 0xff), Color32::from_rgb(0x8f, 0xd6, 0x8a), Color32::from_rgb(0xf0, 0xb2, 0x6b), Color32::from_gray(0x80)) + } else { + (Color32::from_rgb(0x8a, 0x3f, 0xc9), Color32::from_rgb(0x1f, 0x7a, 0x3a), Color32::from_rgb(0xb3, 0x5c, 0x00), Color32::from_gray(0x8a)) + }; + const KW: &[&str] = &[ + "fn", "let", "mut", "pub", "struct", "enum", "impl", "use", "mod", "match", "if", "else", "for", "while", "loop", "return", "break", "continue", "in", "as", "const", "static", "trait", "where", + "def", "class", "import", "from", "with", "try", "except", "finally", "lambda", "yield", "async", "await", "pass", "raise", "None", "True", "False", "self", "function", "var", "new", "this", + "export", "default", "extends", "interface", "type", "null", "undefined", "true", "false", "public", "private", "protected", "void", "int", "string", "bool", "func", "package", "go", "select", + "case", "switch", "echo", "then", "fi", "do", "done", "elif", "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "JOIN", "ORDER", "BY", "GROUP", "AND", "OR", "NOT", "AS", "ON", "INTO", "VALUES", + ]; + let hash_comment = matches!(lang, "python" | "py" | "sh" | "bash" | "zsh" | "shell" | "ruby" | "rb" | "yaml" | "yml" | "toml" | "r" | "perl" | "dockerfile" | "makefile" | "conf" | "ini"); + let sql = matches!(lang, "sql"); + let mut out: Vec<(String, Color32)> = vec![]; + let push = |out: &mut Vec<(String, Color32)>, s: &str, c: Color32| { + if let Some(l) = out.last_mut() { + if l.1 == c { + l.0.push_str(s); + return; + } + } + out.push((s.to_string(), c)); + }; + let chars: Vec = code.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); + // Comments. + if rest == "//" || rest == "/*" || (hash_comment && c == '#') || (sql && rest == "--") { + let end = if rest == "/*" { + let s: String = chars[i..].iter().collect(); + s.find("*/").map(|e| i + s[..e + 2].chars().count()).unwrap_or(chars.len()) + } else { + chars[i..].iter().position(|x| *x == '\n').map(|e| i + e).unwrap_or(chars.len()) + }; + push(&mut out, &chars[i..end].iter().collect::(), c_com); + i = end; + continue; + } + // Strings. + if c == '"' || c == '\'' || c == '`' { + let mut j = i + 1; + while j < chars.len() && chars[j] != c && !(chars[j] == '\n' && c != '`') { + if chars[j] == '\\' { + j += 1; + } + j += 1; + } + let end = (j + 1).min(chars.len()); + push(&mut out, &chars[i..end].iter().collect::(), c_str); + i = end; + continue; + } + if c.is_ascii_digit() { + let end = chars[i..].iter().position(|x| !(x.is_ascii_alphanumeric() || *x == '.' || *x == '_')).map(|e| i + e).unwrap_or(chars.len()); + push(&mut out, &chars[i..end].iter().collect::(), c_num); + i = end; + continue; + } + if c.is_alphabetic() || c == '_' { + let end = chars[i..].iter().position(|x| !(x.is_alphanumeric() || *x == '_')).map(|e| i + e).unwrap_or(chars.len()); + let w: String = chars[i..end].iter().collect(); + push(&mut out, &w, if KW.contains(&w.as_str()) { c_kw } else { base }); + i = end; + continue; + } + push(&mut out, &c.to_string(), base); + i += 1; + } + out +} + +/// What the chat keeps between frames: drawn diagrams, the copied-code check mark, the big view. +#[derive(Default)] +pub struct MdState { + diagrams: HashMap, + copied: Option<(u64, Instant)>, + /// The diagram shown large: (key, zoom, pan). + viewer: Option<(u64, f32, egui::Vec2)>, +} + +enum Diagram { + Drawing(Arc>>>), + Ready(egui::TextureHandle, egui::Vec2), + Failed(String), +} + +fn key_of(s: &str, dark: bool) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + dark.hash(&mut h); + h.finish() +} + +impl App { + /// A message's Markdown. `streaming`: the reply is still coming (diagrams wait for the end). + pub(super) fn markdown(&mut self, ui: &mut egui::Ui, text: &str, col: Color32, streaming: bool) { + let blocks = parse(text); + ui.spacing_mut().item_spacing.y = 0.0; + let n = blocks.len(); + for (i, b) in blocks.iter().enumerate() { + self.md_block(ui, b, col, streaming, 0); + if i + 1 < n { + ui.add_space(10.0); + } + } + } + + fn md_block(&mut self, ui: &mut egui::Ui, b: &Block, col: Color32, streaming: bool, depth: usize) { + let p = self.pal; + match b { + Block::Para(spans) => self.md_spans(ui, spans, TRANSCRIPT_FONT, col, false), + Block::Heading(level, spans) => { + let size = match level { + 1 => 22.0, + 2 => 17.0, + _ => TRANSCRIPT_FONT, + }; + self.md_spans(ui, spans, size, col, true); + } + Block::Rule => { + ui.add_space(4.0); + let r = ui.available_rect_before_wrap(); + ui.painter().hline(r.x_range(), r.top(), Stroke::new(1.0, p.border)); + ui.add_space(4.0); + } + Block::Quote(inner) => { + let top = ui.cursor().top(); + Frame::new().inner_margin(Margin { left: 14, right: 0, top: 0, bottom: 0 }).show(ui, |ui| { + for (i, b) in inner.iter().enumerate() { + if i > 0 { + ui.add_space(8.0); + } + self.md_block(ui, b, p.text2, streaming, depth); + } + }); + let x = ui.min_rect().left() + 2.0; + ui.painter().vline(x, top..=ui.cursor().top(), Stroke::new(3.0, p.border)); + } + Block::List { start, items } => { + for (i, it) in items.iter().enumerate() { + if i > 0 { + ui.add_space(4.0); + } + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let (mrect, _) = ui.allocate_exact_size(vec2(22.0, TRANSCRIPT_LINE_HEIGHT), egui::Sense::hover()); + match (it.task, start) { + (Some(done), _) => { + let r = egui::Rect::from_center_size(mrect.center(), vec2(14.0, 14.0)); + if done { + ui.painter().rect_filled(r, 4, p.primary); + crate::icons::paint(ui.painter(), r.shrink(2.0), crate::icons::Icon::Check, p.on_primary); + } else { + ui.painter().rect_stroke(r, 4, Stroke::new(1.3, p.text3), egui::StrokeKind::Inside); + } + } + (None, Some(s)) => { + ui.painter().text(mrect.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{}.", s + i as u64), egui::FontId::proportional(TRANSCRIPT_FONT), col); + } + (None, None) => { + let c = mrect.center() + vec2(-2.0, 0.0); + match depth % 3 { + 0 => ui.painter().circle_filled(c, 2.6, col), + 1 => ui.painter().circle_stroke(c, 2.6, Stroke::new(1.1, col)), + _ => ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(4.6, 4.6)), 0, col), + }; + } + } + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for (j, b) in it.blocks.iter().enumerate() { + if j > 0 { + ui.add_space(4.0); + } + let c = if it.task == Some(true) { p.text3 } else { col }; + self.md_block(ui, b, c, streaming, depth + 1); + } + }); + }); + } + } + Block::Table { aligns, head, rows } => self.md_table(ui, aligns, head, rows, col), + Block::Math(t) => self.md_code(ui, "math", t, col), + Block::Code { lang, text } if lang == "mermaid" => self.md_mermaid(ui, text, col, streaming), + Block::Code { lang, text } => self.md_code(ui, lang, text, col), + } + } + + /// One paragraph or heading: a single text layout (wraps like prose, selectable), with + /// links that open in the browser. + fn md_spans(&mut self, ui: &mut egui::Ui, spans: &[Span], size: f32, col: Color32, heading: bool) { + let p = self.pal; + let link_col = if p.pane == LIGHT.pane { Color32::from_rgb(0x0b, 0x6d, 0xd3) } else { Color32::from_rgb(0x5a, 0xa9, 0xff) }; + let mut job = egui::text::LayoutJob::default(); + // 粉圓體 has one weight: bold words (and headings) are drawn a second time half a pixel + // to the right, from a twin layout where everything else is transparent. + let mut bold_job = egui::text::LayoutJob::default(); + let mut ranges: Vec<(std::ops::Range, String)> = vec![]; + let mut strikes: Vec> = vec![]; + let mut chars = 0usize; + for s in spans { + let mut fmt = egui::TextFormat { + font_id: if s.code { egui::FontId::monospace(size * 0.93) } else { egui::FontId::proportional(size) }, + color: if s.link.is_some() { link_col } else { col }, + line_height: Some(if size >= 22.0 { 28.0 } else if size > TRANSCRIPT_FONT { LINE_HEIGHT_XL } else if size < TRANSCRIPT_FONT { LINE_HEIGHT_SM } else { TRANSCRIPT_LINE_HEIGHT }), + italics: s.italic, + ..Default::default() + }; + if s.code { + fmt.background = p.hover; + } + if s.strike { + fmt.color = p.text3; + } + if s.link.is_some() { + fmt.underline = Stroke::new(1.0, link_col.gamma_multiply(0.5)); + } + let n = s.text.chars().count(); + if let Some(l) = &s.link { + ranges.push((chars..chars + n, l.clone())); + } + if s.strike { + strikes.push(chars..chars + n); + } + chars += n; + let mut twin = fmt.clone(); + if !(heading || s.bold) { + twin.color = Color32::TRANSPARENT; + } + twin.background = Color32::TRANSPARENT; + twin.underline = Stroke::NONE; + twin.strikethrough = Stroke::NONE; + bold_job.append(&s.text, 0.0, twin); + job.append(&s.text, 0.0, fmt); + } + job.wrap.max_width = ui.available_width(); + bold_job.wrap.max_width = job.wrap.max_width; + let any_bold = heading || spans.iter().any(|s| s.bold); + let galley = ui.fonts_mut(|f| f.layout_job(job.clone())); + let label = egui::Label::new(job).sense(if ranges.is_empty() { egui::Sense::hover() } else { egui::Sense::click() }); + let resp = ui.add(label); + if any_bold { + let g2 = ui.fonts_mut(|f| f.layout_job(bold_job)); + ui.painter().galley(resp.rect.min + vec2(0.5, 0.0), g2, col); + } + // ~~Strikethrough~~: a line through those characters (drawn here; the label doesn't). + if !strikes.is_empty() { + let mut idx = 0usize; + for row in &galley.rows { + let y = resp.rect.min.y + row.pos.y + row.row.size.y * 0.5; + for g in &row.row.glyphs { + if strikes.iter().any(|r| r.contains(&idx)) { + let x = resp.rect.min.x + row.pos.x + g.pos.x; + ui.painter().hline(x..=x + g.advance_width, y, Stroke::new(1.4, p.text2)); + } + idx += 1; + } + if row.ends_with_newline { + idx += 1; + } + } + } + if ranges.is_empty() { + return; + } + if let Some(pos) = resp.hover_pos() { + let idx = galley.cursor_from_pos(pos - resp.rect.min).index.0; + if let Some((_, url)) = ranges.iter().find(|(r, _)| r.contains(&idx)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + let url = url.clone(); + let resp = resp.on_hover_text(&url); + if resp.clicked() { + let _ = crate::preview::system_open(&url).spawn(); + } + } + } + } + + fn md_code(&mut self, ui: &mut egui::Ui, lang: &str, text: &str, col: Color32) { + let p = self.pal; + let dark = p.pane != LIGHT.pane; + let key = key_of(text, dark); + let resp = Frame::new().fill(p.hover.gamma_multiply(0.7)).stroke(Stroke::new(1.0, p.border)).corner_radius(12).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::horizontal().id_salt(("code", key)).auto_shrink([false, true]).show(ui, |ui| { + let mut job = egui::text::LayoutJob::default(); + for (s, c) in highlight(text, lang, col, dark) { + job.append(&s, 0.0, egui::TextFormat { font_id: egui::FontId::monospace(12.5), color: c, line_height: Some(LINE_HEIGHT_SM), ..Default::default() }); + } + job.wrap.max_width = f32::INFINITY; + ui.add(egui::Label::new(job).extend()); + }); + }); + // 複製程式碼 at the top right while hovered (a check for 2 s after copying). + let rect = resp.response.rect; + if ui.rect_contains_pointer(rect) || self.md.copied.map(|(k, _)| k == key).unwrap_or(false) { + let copied = self.md.copied.filter(|(k, at)| *k == key && at.elapsed().as_secs_f32() < 2.0).is_some(); + let br = egui::Rect::from_min_size(rect.right_top() + vec2(-34.0, 6.0), vec2(28.0, 28.0)); + let r = ui.interact(br, egui::Id::unique(("copy-code", key)), egui::Sense::click()); + ui.painter().rect_filled(br, 8, if r.hovered() { p.active } else { p.elevated }); + crate::icons::paint(ui.painter(), br.shrink(7.0), if copied { crate::icons::Icon::Check } else { crate::icons::Icon::Copy }, p.text2); + if r.on_hover_text(t(if copied { "已複製" } else { "複製程式碼" })).clicked() { + ui.ctx().copy_text(text.to_string()); + self.md.copied = Some((key, Instant::now())); + } + if copied { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); + } + } + } + + fn md_table(&mut self, ui: &mut egui::Ui, aligns: &[Alignment], head: &[Vec], rows: &[Vec>], col: Color32) { + let ncols = head.len().max(rows.iter().map(|r| r.len()).max().unwrap_or(0)); + let plain = |c: &[Span]| c.iter().map(|s| s.text.as_str()).collect::(); + let key = key_of(&format!("{:?}{:?}", head.iter().map(|c| plain(c)).collect::>(), rows.len()), false); + egui::ScrollArea::horizontal().id_salt(("table", key)).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new(("md-table", key)).spacing(vec2(0.0, 0.0)).min_col_width(40.0).show(ui, |ui| { + let cell = |this: &mut Self, ui: &mut egui::Ui, c: Option<&Vec>, i: usize, header: bool| { + let align = match aligns.get(i) { + Some(Alignment::Right) => Align::Max, + Some(Alignment::Center) => Align::Center, + _ => Align::Min, + }; + Frame::new().inner_margin(Margin::same(8)).show(ui, |ui| { + ui.set_max_width(320.0); + ui.with_layout(Layout::top_down(align), |ui| { + if let Some(c) = c { + this.md_spans(ui, c, FONT_SM, col, header); + } + }); + }); + }; + for i in 0..ncols { + cell(self, ui, head.get(i), i, true); + } + ui.end_row(); + for r in rows { + for i in 0..ncols { + cell(self, ui, r.get(i), i, false); + } + ui.end_row(); + } + }); + }); + } + + fn md_mermaid(&mut self, ui: &mut egui::Ui, src: &str, col: Color32, streaming: bool) { + let p = self.pal; + // While the reply streams the diagram is still being written: show the source. + if streaming { + return self.md_code(ui, "mermaid", src, col); + } + let dark = p.pane != LIGHT.pane; + let key = key_of(src, dark); + if !self.md.diagrams.contains_key(&key) { + let slot = Arc::new(Mutex::new(None)); + let (s2, src2) = (slot.clone(), src.to_string()); + let ctx = ui.ctx().clone(); + std::thread::spawn(move || { + *s2.lock().unwrap() = Some(crate::diagram::render(&src2, dark, 2.0)); + ctx.request_repaint(); + }); + self.md.diagrams.insert(key, Diagram::Drawing(slot)); + } + // A finished drawing becomes a texture. + let finished = match self.md.diagrams.get(&key) { + Some(Diagram::Drawing(slot)) => slot.lock().unwrap().take(), + _ => None, + }; + { + if let Some(done) = finished { + let d = match done { + Ok(img) => { + let size = vec2(img.width() as f32 / 2.0, img.height() as f32 / 2.0); + let tex = ui.ctx().load_texture(format!("mermaid-{key}"), egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()), egui::TextureOptions::LINEAR); + Diagram::Ready(tex, size) + } + Err(e) => Diagram::Failed(e), + }; + self.md.diagrams.insert(key, d); + } + } + match self.md.diagrams.get(&key) { + Some(Diagram::Ready(tex, size)) => { + let w = size.x.min(ui.available_width()); + let shown = vec2(w, size.y * w / size.x.max(1.0)); + let (tex, natural) = (tex.clone(), *size); + let r = ui.add(egui::Image::new((tex.id(), shown)).corner_radius(6).sense(egui::Sense::click())); + if r.on_hover_text(t("按一下放大檢視")).on_hover_cursor(egui::CursorIcon::ZoomIn).clicked() { + self.md.viewer = Some((key, 1.0, egui::Vec2::ZERO)); + } + let _ = natural; + } + Some(Diagram::Failed(why)) => { + let why = why.clone(); + crate::icons::label(ui, crate::icons::Icon::Alert, &t("無法顯示此圖表"), 12.5, p.text3).on_hover_text(why); + ui.add_space(4.0); + self.md_code(ui, "mermaid", src, col); + } + _ => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new(t("正在繪製圖表…")).size(FONT_SM).color(p.text3)); + }); + } + } + } + + /// The diagram viewer: big, zoom with the buttons or ⌘ + scroll, drag to move, save as PNG. + pub(super) fn md_viewer(&mut self, ctx: &egui::Context) { + let p = self.pal; + let Some((key, mut zoom, mut pan)) = self.md.viewer else { return }; + let Some(Diagram::Ready(tex, size)) = self.md.diagrams.get(&key) else { + self.md.viewer = None; + return; + }; + let (tex, size) = (tex.clone(), *size); + let mut keep = true; + let w = (ctx.content_rect().width() - 80.0).max(320.0); + let h = (ctx.content_rect().height() - 160.0).max(240.0); + let close = dialog(ctx, "mermaid-view", "圖表", w, true, p, |ui| { + let (area, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), egui::Sense::click_and_drag()); + if resp.dragged() { + pan += resp.drag_delta(); + } + if resp.hovered() { + let scroll = ui.input(|i| i.zoom_delta()); + if scroll != 1.0 { + zoom = (zoom * scroll).clamp(0.1, 8.0); + } + } + let fit = (area.width() / size.x).min(area.height() / size.y).min(2.0); + let shown = size * fit * zoom; + let rect = egui::Rect::from_center_size(area.center() + pan, shown); + let painter = ui.painter().with_clip_rect(area); + painter.image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + dialog_footer(ui, p, |ui| { + if ui.add(secondary_button("縮小")).clicked() { + zoom = (zoom / 1.4).max(0.1); + } + if ui.add(secondary_button("放大")).clicked() { + zoom = (zoom * 1.4).min(8.0); + } + if ui.add(secondary_button("重設")).clicked() { + zoom = 1.0; + pan = egui::Vec2::ZERO; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(primary_button("完成", p)).clicked() { + keep = false; + } + if ui.add(secondary_button("下載 PNG")).clicked() { + self.save_diagram_png(key); + } + }); + }); + }); + self.md.viewer = if keep && !close { Some((key, zoom, pan)) } else { None }; + } + + fn save_diagram_png(&mut self, key: u64) { + // Draw it again at 2× (the texture can't be read back) and save to Downloads. + let Some(src) = self.state.bot(self.state.selected).and_then(|b| { + b.messages.iter().rev().flat_map(|m| parse(&m.text)).find_map(|blk| match blk { + Block::Code { lang, text } if lang == "mermaid" && (key_of(&text, true) == key || key_of(&text, false) == key) => Some(text), + _ => None, + }) + }) else { + return; + }; + let dark = self.pal.pane != LIGHT.pane; + if let Ok(img) = crate::diagram::render(&src, dark, 2.0) { + let dir = dirs::download_dir().unwrap_or_else(std::env::temp_dir); + let path = dir.join(format!("圖表-{}.png", chrono::Local::now().format("%Y%m%d-%H%M%S"))); + if img.save(&path).is_ok() { + crate::preview::reveal(&path); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_from_markdown() { + let b = parse("# 標題\n第一行\n第二行 **粗** 和 `code` [連結](https://x.com)\n\n- a\n - b\n- [x] 完成\n\n1. 一\n2. 二\n\n> 引用\n\n| A | B |\n|---|--:|\n| 1 | 2 |\n\n```rust\nfn main() {}\n```\n\n```mermaid\ngraph TD; A-->B\n```\n\n---\n• 項目"); + assert!(matches!(&b[0], Block::Heading(1, s) if s[0].text == "標題")); + match &b[1] { + Block::Para(s) => { + let all: String = s.iter().map(|x| x.text.as_str()).collect(); + assert_eq!(all, "第一行\n第二行 粗 和 code 連結"); + assert!(s.iter().any(|x| x.bold && x.text == "粗")); + assert!(s.iter().any(|x| x.code && x.text == "code")); + assert!(s.iter().any(|x| x.link.as_deref() == Some("https://x.com"))); + } + other => panic!("{other:?}"), + } + assert!(matches!(&b[2], Block::List { start: None, items } if items.len() == 2 && items[1].task == Some(true) && matches!(&items[0].blocks[1], Block::List { .. }))); + assert!(matches!(&b[3], Block::List { start: Some(1), items } if items.len() == 2)); + assert!(matches!(&b[4], Block::Quote(v) if v.len() == 1)); + assert!(matches!(&b[5], Block::Table { head, rows, aligns } if head.len() == 2 && rows.len() == 1 && aligns[1] == Alignment::Right)); + assert!(matches!(&b[6], Block::Code { lang, text } if lang == "rust" && text == "fn main() {}")); + assert!(matches!(&b[7], Block::Code { lang, .. } if lang == "mermaid")); + assert!(matches!(&b[8], Block::Rule)); + assert!(matches!(&b[9], Block::List { .. }), "• bullets become a list"); + // An unclosed fence (still streaming) keeps its text. + assert!(matches!(parse("看:\n```py\nprint(1)").last(), Some(Block::Code { text, .. }) if text.contains("print(1)"))); + // Prices stay text (no single-$ math). + assert!(matches!(&parse("一杯 $5,兩杯 $10")[0], Block::Para(s) if s.iter().all(|x| !x.code))); + let hl = highlight("let x = \"a\"; // hi", "rust", Color32::WHITE, false); + assert!(hl.iter().any(|(s, _)| s == "let") && hl.iter().any(|(s, _)| s.starts_with("// hi"))); + } + +} diff --git a/src/app/menu_ui.rs b/src/app/menu_ui.rs new file mode 100644 index 0000000..d9c1518 --- /dev/null +++ b/src/app/menu_ui.rs @@ -0,0 +1,74 @@ +// Menus like the original's (captured from Grok Bot itself): 200 px wide, 16 px corners, rows +// 32 px tall with a 16 px icon on the left and 14 px text, a soft rounded highlight, hairline +// separators between groups, a red 刪除 with a bin, and "›" on rows that open a submenu. + +use super::*; + +pub const MENU_W: f32 = 200.0; +const ROW_H: f32 = 32.0; + +fn row(ui: &mut egui::Ui, p: Palette, icon: Option, label: &str, col: Color32, chevron: bool) -> egui::Response { + // Rows touch (no gap between them) and the menu stays its own width, not the screen's. + ui.spacing_mut().item_spacing.y = 0.0; + let galley_w = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(14.0), col).size().x; + let w = ui.min_rect().width().max(MENU_W - 12.0).max(galley_w + if icon.is_some() { 64.0 } else { 36.0 }); + let (rect, r) = ui.allocate_exact_size(vec2(w, ROW_H), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 8, p.hover); + } + let x = rect.left() + 10.0; + if let Some(ic) = icon { + let icol = if col == p.danger { p.danger } else { p.text2 }; + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(egui::pos2(x + 8.0, rect.center().y), vec2(16.0, 16.0)), ic, icol); + } + let tx = if icon.is_some() { x + 28.0 } else { x + 2.0 }; + ui.painter().text(egui::pos2(tx, rect.center().y), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(14.0), col); + if chevron { + let c = egui::pos2(rect.right() - 14.0, rect.center().y); + let st = Stroke::new(1.4, p.text3); + ui.painter().line_segment([c + vec2(-2.5, -4.5), c + vec2(2.0, 0.0)], st); + ui.painter().line_segment([c + vec2(-2.5, 4.5), c + vec2(2.0, 0.0)], st); + } + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// One menu row with an icon. +pub fn item(ui: &mut egui::Ui, p: Palette, icon: crate::icons::Icon, label: &str) -> egui::Response { + row(ui, p, Some(icon), &t(label), p.text, false) +} + +/// A row whose words are already translated / built. +pub fn item_text(ui: &mut egui::Ui, p: Palette, icon: Option, label: &str) -> egui::Response { + row(ui, p, icon, label, p.text, false) +} + +/// The red one (刪除…). +pub fn danger(ui: &mut egui::Ui, p: Palette, icon: crate::icons::Icon, label: &str) -> egui::Response { + row(ui, p, Some(icon), &t(label), p.danger, false) +} + +/// A row that opens a submenu on hover ("›"). +pub fn sub(ui: &mut egui::Ui, p: Palette, icon: crate::icons::Icon, label: &str, content: impl FnOnce(&mut egui::Ui) -> R) { + let r = row(ui, p, Some(icon), &t(label), p.text, true); + egui::containers::menu::SubMenu::new().show(ui, &r, |ui| { + ui.set_min_width(MENU_W - 12.0); + content(ui) + }); +} + +/// A hairline between groups. +pub fn sep(ui: &mut egui::Ui, p: Palette) { + ui.spacing_mut().item_spacing.y = 0.0; + ui.add_space(4.0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.min_rect().width().max(MENU_W - 12.0), 1.0), egui::Sense::hover()); + ui.painter().hline(rect.x_range().shrink(8.0), rect.center().y, Stroke::new(1.0, p.border)); + ui.add_space(4.0); +} + +/// Small grey words above a group of rows. +pub fn caption(ui: &mut egui::Ui, p: Palette, text: &str) { + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.label(RichText::new(t(text)).size(12.0).color(p.text3)); + }); +} diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs new file mode 100644 index 0000000..a21902b --- /dev/null +++ b/src/app/panel_ui.rs @@ -0,0 +1,499 @@ +// 對話詳細資訊 (the right panel), laid out like the original (captured from Grok Bot itself): +// a round ✕ at the top right, the bot's big face and name, two small tabs 動態|資料庫, then +// rounded cards — 建議 (新增至 Slack / 建立例行工作 / 加入會議), 電腦 (Bot 的電腦 with its +// state), 語音聊天 (語音 / 速度 / 語言). 資料庫 lists the chat's files under 本週 and 更早. +// Measured: panel 280 px; cards radius 12, 1 px border at 6 %, 4 px inside; text 13 px, section +// titles 13 px at 66 %, secondary 12 px at 54 %; buttons are 36 px pills. + +use super::*; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PanelTab { + /// 動態 + Activity, + /// 資料庫 + Library, +} + +pub const PANEL_WIDTH: f32 = 280.0; +/// Narrowest the side panel gets before it floats over the chat instead. +pub const PANEL_MIN_WIDTH: f32 = 260.0; +const ROW_H: f32 = 36.0; +/// Files shown per group before 「展開」. +const LIBRARY_FOLD: usize = 5; + +impl App { + pub(super) fn bot_panel(&mut self, root: &mut egui::Ui, bot: &Bot) { + let Some(tab) = self.panel else { return }; + let p = self.pal; + let ctx = root.ctx().clone(); + // Next to the chat when the chat keeps its minimum width; otherwise it floats over the + // chat, so a narrow window never squeezes the chat. + let avail = root.available_width(); + if avail - PANEL_MIN_WIDTH >= CHAT_MIN_WIDTH { + let w = (avail - CHAT_MIN_WIDTH).min(PANEL_WIDTH); + egui::Panel::right("bot-panel").exact_size(w).resizable(false).frame(Frame::new().fill(p.pane).inner_margin(Margin { left: 16, right: 16, top: 10, bottom: 12 })).show(root, |ui| self.panel_contents(ui, &ctx, bot, tab)); + } else { + let screen = ctx.content_rect(); + let w = PANEL_WIDTH.min(avail - 16.0).max(220.0); + egui::Area::new(egui::Id::unique("bot-panel-overlay")).order(egui::Order::Foreground).anchor(egui::Align2::RIGHT_TOP, vec2(-8.0, 8.0)).show(&ctx, |ui| { + Frame::new().fill(p.pane).stroke(Stroke::new(1.0, p.border)).corner_radius(16).inner_margin(Margin::symmetric(16, 10)).shadow(egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(60) }).show(ui, |ui| { + ui.set_width(w); + ui.set_max_height(screen.height() - 16.0); + self.panel_contents(ui, &ctx, bot, tab); + }); + }); + } + } + + fn panel_contents(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot, tab: PanelTab) { + let p = self.pal; + // ✕ at the top right (36 px round, like the original). + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if round_button(ui, p, 36.0).on_hover_text(t("關閉")).clicked() { + self.panel = None; + } + }); + }); + egui::ScrollArea::vertical().id_salt(("panel", bot.id, tab as u8)).auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + // The bot: big face (編輯 Bot 頭像) and its name. + ui.vertical_centered(|ui| { + let (face, r) = ui.allocate_exact_size(vec2(96.0, 96.0), egui::Sense::click()); + self.live_face(ui, bot.id, face, true); + if r.on_hover_text(t("編輯 Bot 頭像")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + self.edit_bot = Some((bot.clone(), false)); + } + ui.add_space(14.0); + ui.add(egui::Label::new(RichText::new(&bot.name).size(19.0).strong().color(p.text)).truncate()); + if !bot.description.is_empty() { + ui.add_space(3.0); + ui.add(egui::Label::new(RichText::new(&bot.description).size(12.0).color(p.text3)).truncate()); + } + ui.add_space(16.0); + // 動態|資料庫 + ui.horizontal(|ui| { + let tabs = [(PanelTab::Activity, "動態"), (PanelTab::Library, "資料庫")]; + let w: f32 = tabs.iter().map(|(_, n)| text_width(ui, &t(n), 13.0) + 24.0).sum::() + 4.0; + ui.add_space(((ui.available_width() - w) / 2.0).max(0.0)); + ui.spacing_mut().item_spacing.x = 4.0; + for (tb, name) in tabs { + let on = tb == tab; + let b = egui::Button::new(RichText::new(t(name)).size(13.0).color(if on { p.text } else { p.text2 })).fill(if on { p.active } else { Color32::TRANSPARENT }).corner_radius(14).min_size(vec2(0.0, 26.0)); + if ui.add(b).clicked() { + self.panel = Some(tb); + } + } + }); + }); + ui.add_space(22.0); + match tab { + PanelTab::Activity => self.panel_activity(ui, ctx, bot), + PanelTab::Library => self.panel_library(ui, bot), + } + ui.add_space(16.0); + }); + } + + fn panel_activity(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) { + let p = self.pal; + // What it is doing now (only while there is something). + if self.approvals.contains_key(&bot.id) || self.streams.contains_key(&bot.id) { + panel_title(ui, "進行中", p); + let waiting = self.approvals.contains_key(&bot.id); + let status = self.streams.get(&bot.id).and_then(|l| l.status.clone()).unwrap_or_else(|| t("工作中…")); + let mut clicked = false; + panel_card(ui, p, |ui| { + clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &t("需要你允許"), "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() }; + }); + if clicked { + if waiting { + self.popup_hidden.remove(&bot.id); + } else { + self.stop(bot.id); + } + } + } + // 建議 + panel_title(ui, "建議", p); + let mut open_channels = false; + let mut new_routine = false; + let mut meeting = false; + let meeting_label = if self.meeting_on() { t("結束會議錄音") } else { t("加入會議") }; + panel_card(ui, p, |ui| { + open_channels = panel_row(ui, p, Some(crate::icons::Icon::Chat), &t("新增至 Slack"), "", true).clicked(); + new_routine = panel_row(ui, p, Some(crate::icons::Icon::Clock), &t("建立例行工作"), "", true).clicked(); + meeting = panel_row(ui, p, Some(crate::icons::Icon::Calendar), &meeting_label, "", true).clicked(); + }); + if open_channels { + self.show_settings = true; + self.settings_tab = SettingsTab::Channels; + } + if new_routine { + self.drafts.insert(bot.id, t("幫我建立一個例行任務:")); + } + if meeting { + self.voice_toggle(ctx, bot.id); + } + // 例行任務 (when it has some) + if !bot.routines.is_empty() { + panel_title(ui, "例行任務", p); + let mut open = false; + panel_card(ui, p, |ui| { + for r in bot.routines.iter().take(6) { + let when = match (&r.trigger, r.paused) { + (_, true) => t("已暫停"), + (Some(tr), _) => tr.describe(), + (None, _) => crate::routines::describe(&r.schedule), + }; + if panel_row_sub(ui, p, &r.title, &when).clicked() { + open = true; + } + } + }); + if open { + self.show_routines = Some(bot.id); + } + } + // 電腦: the bot's own computer with a live picture of its screen (like the original). + panel_title(ui, "電腦", p); + let mut view = false; + let thumb = if crate::cloudbox::available() { self.box_thumbnail(ctx) } else { None }; + panel_card(ui, p, |ui| { + let run = crate::cloudbox::run_state(); + let running = run.is_running(); + let state = if crate::cloudbox::available() { t(&run.label()) } else { t("沒有 Docker") }; + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 9, p.hover); + } + ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, t("Bot 的電腦"), egui::FontId::proportional(13.0), p.text); + ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3); + // The screen: 16:10, rounded, the real desktop when there is a picture. + let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0)); + match thumb { + Some((tex, size)) => { + let mut img = egui::Image::new((tex, size)).fit_to_exact_size(box_r.size()).corner_radius(7); + if !running { + img = img.tint(Color32::from_gray(110)); + } + img.paint_at(ui, box_r); + } + None => { + ui.painter().rect_filled(box_r, 7, p.active); + let c = box_r.center(); + for dx in [-5.0, 5.0] { + ui.painter().rect_filled(egui::Rect::from_center_size(c + vec2(dx, 0.0), vec2(4.0, 8.0)), 2, p.text3); + } + } + } + ui.painter().rect_stroke(box_r, 7, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + if running { + ui.painter().circle_filled(box_r.right_top() + vec2(-6.0, 6.0), 3.5, Color32::from_rgb(0x14, 0xc3, 0x70)); + } + let tip = if running { "看 Bot 的電腦的螢幕,也可以取得控制權自己操作" } else { "叫醒 Bot 的電腦並看它的螢幕" }; + view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t(tip)).clicked(); + }); + if view { + if crate::cloudbox::available() { + self.open_viewer(); + } else { + self.show_settings = true; + self.settings_tab = SettingsTab::Computers; + } + } + // 成員 (group chats) + if !bot.members.is_empty() { + panel_title(ui, "成員", p); + let mut edit = false; + let members: Vec = bot.members.iter().filter_map(|m| self.state.bot(*m).cloned()).collect(); + let colors: Vec = members.iter().map(|b| self.bot_color(b)).collect(); + panel_card(ui, p, |ui| { + for (b, col) in members.iter().zip(colors) { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 9, p.hover); + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 0.0))).layout(Layout::left_to_right(Align::Center))); + avatar::avatar(&mut child, 20.0, &b.shape, col); + child.label(RichText::new(&b.name).size(13.0).color(p.text)); + edit |= r.clicked(); + } + }); + if edit { + self.edit_bot = Some((bot.clone(), false)); + } + } + // 語音聊天: 語音、速度、語言 + panel_title(ui, "語音聊天", p); + let mut b2 = bot.clone(); + let mut changed = false; + panel_card(ui, p, |ui| { + let voices = crate::voice::voices(); + let shown = if b2.voice.is_empty() { t("自動") } else { b2.voice.clone() }; + panel_select(ui, p, "語音", ("voice", bot.id), &shown, |ui| { + if ui.selectable_label(b2.voice.is_empty(), t("自動(依語言)")).clicked() { + b2.voice.clear(); + changed = true; + } + for (name, lang) in voices.iter().take(80) { + if ui.selectable_label(b2.voice == *name, format!("{name} · {lang}")).clicked() { + b2.voice = name.clone(); + changed = true; + crate::voice::speak_as(&t("你好,我是你的 Bot。"), name, b2.voice_rate); + } + } + }); + let rate = if b2.voice_rate > 0.0 { b2.voice_rate } else { 1.0 }; + panel_select(ui, p, "速度", ("rate", bot.id), &format!("{}x", trim_rate(rate)), |ui| { + for s in crate::voice::SPEEDS { + if ui.selectable_label((rate - s).abs() < 0.01, format!("{}x", trim_rate(s))).clicked() { + b2.voice_rate = s; + changed = true; + } + } + }); + let lang_name = crate::voice::SPOKEN.iter().find(|l| l.0 == b2.voice_lang).map(|l| t(l.1)).unwrap_or_else(|| b2.voice_lang.clone()); + panel_select(ui, p, "語言", ("lang", bot.id), &lang_name, |ui| { + for (code, name) in crate::voice::SPOKEN { + if ui.selectable_label(b2.voice_lang == code, t(name)).clicked() { + b2.voice_lang = code.to_string(); + changed = true; + } + } + }); + // 朗讀回答 (AnyBot: read every answer aloud, not only in a call). + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), egui::Sense::click()); + ui.painter().text(rect.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, t("朗讀每個回答"), egui::FontId::proportional(13.0), p.text); + let sw = egui::Rect::from_center_size(rect.right_center() - vec2(28.0, 0.0), vec2(34.0, 20.0)); + ui.painter().rect_filled(sw, 10, if b2.speak_replies { p.primary } else { p.active }); + let knob = if b2.speak_replies { sw.right_center() - vec2(10.0, 0.0) } else { sw.left_center() + vec2(10.0, 0.0) }; + ui.painter().circle_filled(knob, 8.0, if b2.speak_replies { p.on_primary } else { p.pane }); + if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + b2.speak_replies = !b2.speak_replies; + if !b2.speak_replies { + crate::voice::stop_speaking(); + } + changed = true; + } + }); + if changed { + if let Some(b) = self.state.bot_mut(bot.id) { + b.voice = b2.voice; + b.voice_rate = b2.voice_rate; + b.voice_lang = b2.voice_lang; + b.speak_replies = b2.speak_replies; + } + self.mark(); + } + } + + /// 資料庫: the chat's files, newest first, under 本週 and 更早 (5 each, then 展開). + fn panel_library(&mut self, ui: &mut egui::Ui, bot: &Bot) { + let p = self.pal; + let mut files: Vec<(String, String, i64)> = vec![]; + let mut seen = std::collections::HashSet::new(); + let modified = |path: &str| std::fs::metadata(path).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64).unwrap_or(0); + for m in bot.messages.iter().rev() { + for a in &m.attachments { + if seen.insert(a.path.clone()) { + files.push((a.name.clone(), a.path.clone(), modified(&a.path))); + } + } + // Pages and files the bot opened for the person (on this Mac). + for c in m.tool_calls.iter().filter(|c| c.name == "open_for_person") { + let path = tools::args_of(&c.args)["path"].as_str().unwrap_or("").to_string(); + if path.starts_with('/') && std::path::Path::new(&path).exists() && seen.insert(path.clone()) { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let when = modified(&path); + files.push((name, path, when)); + } + } + } + files.sort_by(|a, b| b.2.cmp(&a.2)); + if files.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label(RichText::new(t("這個對話裡的檔案會出現在這裡")).size(12.0).color(p.text3)); + }); + return; + } + let week_ago = chrono::Utc::now().timestamp() - 7 * 86_400; + let (recent, older): (Vec<_>, Vec<_>) = files.into_iter().partition(|f| f.2 >= week_ago); + for (title, list, key) in [("本週", recent, "lib-week"), ("更早", older, "lib-older")] { + if list.is_empty() { + continue; + } + panel_title(ui, title, p); + let id = egui::Id::unique((key, bot.id)); + let expanded: bool = ui.data(|d| d.get_temp(id)).unwrap_or(false); + let more = list.len() > LIBRARY_FOLD; + let mut toggle = false; + panel_card(ui, p, |ui| { + for (name, path, _) in list.iter().take(if expanded { list.len() } else { LIBRARY_FOLD }) { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 9, p.hover); + } + file_glyph(ui.painter(), egui::Rect::from_center_size(rect.left_center() + vec2(20.0, 0.0), vec2(16.0, 18.0)), name, p); + let text = shorten(ui, name, rect.width() - 46.0); + ui.painter().text(rect.left_center() + vec2(36.0, 0.0), egui::Align2::LEFT_CENTER, text, egui::FontId::proportional(13.0), p.text); + if r.on_hover_text(name).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + crate::preview::open_with_system(&path.to_string()); + } + } + if more { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), egui::Sense::click()); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, t(if expanded { "收合" } else { "展開" }), egui::FontId::proportional(12.5), p.text2); + toggle = r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked(); + } + }); + if toggle { + ui.data_mut(|d| d.insert_temp(id, !expanded)); + } + } + } +} + +/// A 36 px round button with ✕ (the original's close). +fn round_button(ui: &mut egui::Ui, p: Palette, size: f32) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(size, size), egui::Sense::click()); + ui.painter().circle_filled(rect.center(), size / 2.0, if r.hovered() { p.hover } else { p.elevated }); + ui.painter().circle_stroke(rect.center(), size / 2.0 - 0.5, Stroke::new(1.0, p.border)); + let c = rect.center(); + let st = Stroke::new(1.5, p.text2); + ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], st); + ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], st); + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) { + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label(RichText::new(t(title)).size(13.0).color(p.text.gamma_multiply(0.66))); + }); + ui.add_space(8.0); +} + +/// The original's card: radius 12, a hairline border, 4 px inside. +fn panel_card(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) { + Frame::new().fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(12).inner_margin(Margin::same(4)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 0.0; + body(ui); + }); + ui.add_space(22.0); +} + +/// One row: icon, label, then a value or a chevron. +fn panel_row(ui: &mut egui::Ui, p: Palette, icon: Option, label: &str, value: &str, chevron: bool) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 9, p.hover); + } + let mut x = rect.left() + 10.0; + if let Some(ic) = icon { + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(egui::pos2(x + 8.0, rect.center().y), vec2(15.0, 15.0)), ic, p.text2); + x += 26.0; + } + ui.painter().text(egui::pos2(x, rect.center().y), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(13.0), p.text); + let right = rect.right() - 12.0; + if chevron { + let c = egui::pos2(right - 3.0, rect.center().y); + let st = Stroke::new(1.4, p.text3); + ui.painter().line_segment([c + vec2(-3.0, -4.5), c + vec2(1.5, 0.0)], st); + ui.painter().line_segment([c + vec2(-3.0, 4.5), c + vec2(1.5, 0.0)], st); + } else if !value.is_empty() { + ui.painter().text(egui::pos2(right, rect.center().y), egui::Align2::RIGHT_CENTER, value, egui::FontId::proportional(13.0), p.text2); + } + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A two-line row (title and a smaller grey line). +fn panel_row_sub(ui: &mut egui::Ui, p: Palette, title: &str, sub: &str) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 9, p.hover); + } + let w = rect.width() - 20.0; + let title = shorten(ui, title, w); + let sub = shorten_size(ui, sub, w, 12.0); + ui.painter().text(rect.left_top() + vec2(10.0, 8.0), egui::Align2::LEFT_TOP, title, egui::FontId::proportional(13.0), p.text); + ui.painter().text(rect.left_top() + vec2(10.0, 26.0), egui::Align2::LEFT_TOP, sub, egui::FontId::proportional(12.0), p.text3); + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A label with a small drop-down on the right (語音 Helix ⌄). +fn panel_select(ui: &mut egui::Ui, p: Palette, label: &str, id: impl std::hash::Hash + std::fmt::Debug, shown: &str, menu: impl FnOnce(&mut egui::Ui)) { + ui.horizontal(|ui| { + ui.set_height(ROW_H); + ui.add_space(10.0); + ui.label(RichText::new(t(label)).size(13.0).color(p.text)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add_space(4.0); + // Plain text and a small chevron, no box (like the original's "Helix ⌄"). + let w = &mut ui.style_mut().visuals.widgets; + for st in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + st.weak_bg_fill = Color32::TRANSPARENT; + st.bg_fill = Color32::TRANSPARENT; + st.bg_stroke = Stroke::NONE; + } + w.hovered.weak_bg_fill = p.hover; + w.open.weak_bg_fill = p.hover; + egui::ComboBox::from_id_salt(id).selected_text(RichText::new(shown).size(13.0).color(p.text2)).icon(chevron_down).width(0.0).height(320.0).show_ui(ui, menu); + }); + }); +} + +/// The small ⌄ after a value. +fn chevron_down(ui: &egui::Ui, rect: egui::Rect, visuals: &egui::style::WidgetVisuals, _open: bool) { + let c = rect.center(); + let st = Stroke::new(1.3, visuals.fg_stroke.color.gamma_multiply(0.7)); + ui.painter().line_segment([c + vec2(-3.5, -1.5), c + vec2(0.0, 2.0)], st); + ui.painter().line_segment([c + vec2(3.5, -1.5), c + vec2(0.0, 2.0)], st); +} + +fn trim_rate(r: f32) -> String { + let s = format!("{r:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn text_width(ui: &egui::Ui, s: &str, size: f32) -> f32 { + ui.painter().layout_no_wrap(s.to_string(), egui::FontId::proportional(size), Color32::WHITE).size().x +} + +fn shorten(ui: &egui::Ui, s: &str, max_w: f32) -> String { + shorten_size(ui, s, max_w, 13.0) +} + +/// Cut text to fit, keeping the end of a file name ("orders_….xlsx" like the original). +fn shorten_size(ui: &egui::Ui, s: &str, max_w: f32, size: f32) -> String { + if text_width(ui, s, size) <= max_w { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let tail: String = s.rsplit_once('.').filter(|(_, e)| e.chars().count() <= 5).map(|(_, e)| format!(".{e}")).unwrap_or_default(); + let tail_n = tail.chars().count(); + let mut n = chars.len().saturating_sub(tail_n); + while n > 1 { + let cand: String = chars[..n].iter().collect::() + "…" + &tail; + if text_width(ui, &cand, size) <= max_w { + return cand; + } + n -= 1; + } + "…".into() +} + +/// A small mark for the file's kind (the same Lucide icons as the cards). +fn file_glyph(painter: &egui::Painter, r: egui::Rect, name: &str, p: Palette) { + let col = match crate::preview::kind_of(name) { + "sheet" => Color32::from_rgb(0x1f, 0xb3, 0x5d), + "markdown" => Color32::from_rgb(0x8b, 0x5c, 0xf6), + "html" => Color32::from_rgb(0xf9, 0x73, 0x16), + "pdf" => Color32::from_rgb(0xe5, 0x48, 0x4d), + _ => p.text2, + }; + crate::icons::paint(painter, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), crate::icons::for_file(name), col); +} diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs new file mode 100644 index 0000000..bd08465 --- /dev/null +++ b/src/app/remote_ui.rs @@ -0,0 +1,560 @@ +// 手機與帳號 in the window: what the phone sees (published when something changes), carrying out +// what the phone asks, and the settings page (phone password, account at the relay, backups, +// 裝置連結). + +use super::*; +use crate::remote::{self, Cmd, Settings}; +use serde_json::json; +use std::hash::{Hash, Hasher}; + +#[derive(Default)] +pub struct RemoteUi { + sig: u64, + last_publish: Option, + /// The settings the server threads were last given. + pushed: Option, + pw1: String, + pw2: String, + relay_url: String, + user: String, + pass: String, + invite: String, + create: bool, + note: Arc>, + join_rx: Option>>, + restore_rx: Option, String>>>, + busy: Arc, + peers: Arc>>, + last_seen_mark: Option, + peers_at: Option, + /// 團隊: (user, role) shared with, loaded from the relay; the form to add one. + members: Arc, String>>>>, + new_member: String, + new_admin: bool, + started: bool, +} + +/// Approvals as the phone shows them. +fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value { + let what = remote::tool_label(&a.tool); + let title = match a.kind { + ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name), + ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name), + ApprovalKind::Action => format!("{} 想要{what}", bot.name), + }; + json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": a.kind != ApprovalKind::Action || !a.grant.is_empty()}) +} + +impl App { + pub(super) fn remote_start(&mut self, ctx: &egui::Context) { + if self.demo || self.rm.started { + return; + } + self.rm.started = true; + let c = ctx.clone(); + remote::hub().set_repaint(Arc::new(move || c.request_repaint())); + self.remote_apply_settings(); + } + + /// Hand changed settings to the server threads (and restart what depends on them). + fn remote_apply_settings(&mut self) { + let s = self.state.remote.clone(); + if self.rm.pushed.as_ref() == Some(&s) { + return; + } + let old = self.rm.pushed.replace(s.clone()); + remote::hub().set_settings(&s); + let lan_changed = old.as_ref().map(|o| o.lan) != Some(s.lan); + if lan_changed { + match remote::set_lan(s.lan) { + Ok(_) => {} + Err(e) => *self.rm.note.lock().unwrap() = e, + } + } + let relay_changed = old.as_ref().map(|o| (o.relay_url.clone(), o.host_token.clone())) != Some((s.relay_url.clone(), s.host_token.clone())); + if relay_changed { + remote::relay_restart(&s); + } + } + + /// Every frame: run what the phone asked and refresh what it sees. + pub(super) fn remote_tick(&mut self, ctx: &egui::Context) { + if self.demo { + return; + } + self.remote_apply_settings(); + for c in remote::hub().take_cmds() { + self.remote_cmd(ctx, c); + } + if let Some(rx) = &self.rm.join_rx { + if let Ok(r) = rx.try_recv() { + self.rm.join_rx = None; + match r { + Ok(s) => { + self.state.remote = s; + self.rm.pass.clear(); + *self.rm.note.lock().unwrap() = "已登入,這台 AnyBot 已加入帳號".into(); + self.mark(); + } + Err(e) => *self.rm.note.lock().unwrap() = e, + } + } + } + if let Some(rx) = &self.rm.restore_rx { + if let Ok(r) = rx.try_recv() { + self.rm.restore_rx = None; + match r.and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| format!("備份內容讀不出來:{e}"))) { + Ok(mut st) => { + // Keep this computer's own phone and account settings. + st.remote = self.state.remote.clone(); + let n = st.bots.len(); + self.state = st; + self.state.save(); + *self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot"); + } + Err(e) => *self.rm.note.lock().unwrap() = e, + } + } + } + // 自動備份: once a day while signed in. + let s = &self.state.remote; + if s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) { + self.remote_backup(); + } + // 裝置連結: keep the list of this account's computers fresh (once a minute). + if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) { + self.rm.peers_at = Some(Instant::now()); + let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone()); + std::thread::spawn(move || { + let v = remote::devices_online(&s); + if !v.is_empty() { + *peers.lock().unwrap() = v; + } + }); + } + if !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) { + self.remote_publish(); + } + } + + fn remote_signature(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.state.remote.host_name.hash(&mut h); + for b in &self.state.bots { + (b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h); + if let Some(m) = b.messages.last() { + (m.text.len(), m.tool_calls.len(), m.error).hash(&mut h); + for c in &m.tool_calls { + (c.status as u8).hash(&mut h); + } + } + if let Some(l) = self.streams.get(&b.id) { + (1u8, &l.status, l.paused).hash(&mut h); + } + self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h); + } + h.finish() + } + + fn remote_publish(&mut self) { + let sig = self.remote_signature(); + if sig == self.rm.sig { + return; + } + self.rm.sig = sig; + self.rm.last_publish = Some(Instant::now()); + let names: HashMap = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect(); + let mut bots: Vec<&Bot> = self.state.bots.iter().collect(); + // Pinned first, then the most recently active. + bots.sort_by_key(|b| (!b.pinned, -(b.last_turn.as_ref().map(|t| t.settled_at_ms).unwrap_or(0)))); + let mut view = remote::View { host: self.state.remote.host_name.clone(), ..Default::default() }; + for b in bots { + let live = self.streams.get(&b.id); + let last = b.messages.iter().rev().find(|m| !m.text.trim().is_empty() && m.role != Role::Tool).map(|m| first_line(&m.text, 60)).unwrap_or_default(); + let c = crate::avatar::color(&b.color); + let hex = format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()); + view.bots.push(json!({ + "id": b.id, "name": b.name, "description": b.description, "color": hex, "shape": b.shape, + "busy": live.is_some(), "status": live.and_then(|l| l.status.clone()).unwrap_or_default(), + "unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned, + })); + let start = b.messages.len().saturating_sub(remote::PHONE_TAIL); + view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names)).collect()); + if let Some(a) = self.approvals.get(&b.id) { + view.approvals.push(approval_json(b, a)); + } + } + remote::hub().publish(view); + } + + fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) { + match c { + Cmd::Send { bot, text, attachments } => { + if self.state.bot(bot).is_none() { + return; + } + let atts: Vec = attachments + .iter() + .filter(|p| std::path::Path::new(p).starts_with(crate::model::State::path().parent().unwrap_or(std::path::Path::new("/")))) + .filter_map(|p| { + let meta = std::fs::metadata(p).ok()?; + let file = std::path::Path::new(p).file_name()?.to_string_lossy().to_string(); + let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file); + Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: p.clone(), bytes: meta.len(), on_box: String::new() }) + }) + .collect(); + if let Some(l) = self.streams.get(&bot) { + let (extra, _) = crate::attach::for_model(&atts); + l.steer.lock().unwrap().push(format!("{text}{extra}")); + return; + } + if let Some(b) = self.state.bot_mut(bot) { + b.messages.push(Message { attachments: atts, ..Message::user(&text) }); + } + self.start_reply(ctx, bot); + } + Cmd::Stop { bot } => self.stop(bot), + Cmd::Answer { bot, answer } => { + let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default(); + let a = match answer.as_str() { + "once" => ApprovalAnswer::Once, + "always" => ApprovalAnswer::Always(grant), + _ => ApprovalAnswer::Reject, + }; + self.answer(ctx, bot, a); + } + Cmd::Read { bot } => { + if let Some(b) = self.state.bot_mut(bot) { + if b.unread { + b.unread = false; + self.mark(); + } + } + } + Cmd::NewThread { bot } => { + self.stop(bot); + if let Some(b) = self.state.bot_mut(bot) { + if !b.messages.is_empty() { + let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into()); + let msgs = std::mem::take(&mut b.messages); + b.threads.insert(0, (title, msgs)); + b.threads.truncate(50); + } + } + self.mark(); + } + Cmd::DeviceAdded(d) => { + self.state.remote.devices.push(d); + self.mark(); + } + Cmd::DeviceSeen(h) => { + if let Some(gone) = h.strip_prefix('-') { + self.state.remote.devices.retain(|d| d.token_hash != gone); + self.mark(); + } else if !self.rm.last_seen_mark.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) { + self.rm.last_seen_mark = Some(Instant::now()); + if let Some(d) = self.state.remote.devices.iter_mut().find(|d| d.token_hash == h) { + d.last_seen = chrono::Utc::now().timestamp(); + self.mark(); + } + } + } + } + } + + fn remote_backup(&mut self) { + if self.rm.busy.swap(true, Ordering::SeqCst) { + return; + } + self.state.remote.last_backup = chrono::Utc::now().timestamp(); + self.mark(); + let s = self.state.remote.clone(); + let mut st = self.state.clone(); + // The backup is for the bots; this computer's own keys to the relay and phones stay here. + st.remote = Default::default(); + let bytes = serde_json::to_vec(&st).unwrap_or_default(); + let (note, busy) = (self.rm.note.clone(), self.rm.busy.clone()); + std::thread::spawn(move || { + *note.lock().unwrap() = match remote::backup_now(&s, &bytes) { + Ok(n) => format!("已備份({},已加密)", crate::cloudbox::human(n as u64)), + Err(e) => e, + }; + busy.store(false, Ordering::SeqCst); + }); + } + + pub(super) fn remote_page(&mut self, ui: &mut egui::Ui, _ctx: &egui::Context) { + let p = self.pal; + let mut changed = false; + setting_card(ui, p, |ui| { + setting_title(ui, "手機與帳號", "在手機上和 Bot 聊天、核准它要做的事、看著它的電腦。", p); + hint(ui, "兩種連法:同一個網路(或 Tailscale)直接連這台電腦;或登入帳號,透過你的帳號伺服器從任何地方連,這台電腦不用開放任何連接埠。", p); + }); + + // ── 帳號(薄後端) ── + setting_card(ui, p, |ui| { + setting_title(ui, "AnyBot 帳號", "帳號伺服器保管帳號、裝置清單和加密備份,手機從外面連進來也靠它轉送。", p); + let s = &self.state.remote; + if !s.host_token.is_empty() { + let status = remote::hub().relay_status(); + ui.horizontal(|ui| { + let ok = status == "已連線"; + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 4.0, if ok { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 }); + ui.label(RichText::new(format!("{} · {}", s.relay_user, s.relay_url)).size(FONT_BASE)); + ui.label(RichText::new(if status.is_empty() { "—".to_string() } else { status }).size(12.0).color(p.text3)); + }); + hint(ui, format!("手機打開 {} 用同一個帳號登入就能用。可以「加入主畫面」當成 App。", s.relay_url), p); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new(t("這台電腦在手機上叫")).size(13.0).color(p.text2)); + if ui.add(egui::TextEdit::singleline(&mut self.state.remote.host_name).desired_width(180.0)).lost_focus() { + changed = true; + } + }); + if ui.checkbox(&mut self.state.remote.auto_backup, t("每天自動備份到帳號(加密,金鑰由你的密碼產生,伺服器打不開)")).changed() { + changed = true; + } + ui.horizontal(|ui| { + let busy = self.rm.busy.load(Ordering::SeqCst); + if ui.add_enabled(!busy, secondary_button(if busy { "備份中…" } else { "立刻備份" })).clicked() { + self.remote_backup(); + } + if self.state.remote.last_backup > 0 { + let t = chrono::DateTime::from_timestamp(self.state.remote.last_backup, 0).map(|d| d.with_timezone(&chrono::Local).format("%m/%d %H:%M").to_string()).unwrap_or_default(); + ui.label(RichText::new(format!("上次備份 {t}")).size(12.0).color(p.text3)); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("登出帳號")).size(12.5).color(p.danger)).frame(false)).clicked() { + let keep_name = self.state.remote.host_name.clone(); + let s = &mut self.state.remote; + s.host_id.clear(); + s.host_token.clear(); + s.backup_key.clear(); + s.host_name = keep_name; + changed = true; + } + }); + }); + } else { + let busy = self.rm.join_rx.is_some(); + form_row(ui, "帳號伺服器", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.relay_url).hint_text(t("https://anybot.你的網域")).desired_width(f32::INFINITY)); + }); + form_row(ui, "帳號", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.user).desired_width(f32::INFINITY)); + }); + form_row(ui, "密碼", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY)); + }); + ui.checkbox(&mut self.rm.create, t("建立新帳號")); + if self.rm.create { + form_row(ui, "邀請碼", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.invite).hint_text(t("伺服器設定了才需要")).desired_width(f32::INFINITY)); + }); + } + ui.horizontal(|ui| { + let ready = !self.rm.relay_url.trim().is_empty() && !self.rm.user.trim().is_empty() && self.rm.pass.len() >= 8; + if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() { + let (tx, rx) = channel(); + self.rm.join_rx = Some(rx); + let (url, user, pass, create, invite, base) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone()); + std::thread::spawn(move || { + let _ = tx.send(remote::relay_join(&base, &url, &user, &pass, create, &invite)); + }); + } + if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() { + let (tx, rx) = channel(); + self.rm.restore_rx = Some(rx); + let (url, user, pass) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone()); + std::thread::spawn(move || { + let _ = tx.send(remote::restore(&url, &user, &pass)); + }); + } + }); + hint(ui, "密碼至少 8 個字。帳號伺服器在 relay/ 資料夾,可以放在任何一台小主機(說明見 relay/README.md)。", p); + } + let note = self.rm.note.lock().unwrap().clone(); + if !note.is_empty() { + ui.label(RichText::new(note).size(12.5).color(p.text2)); + } + }); + + // ── 同一個網路 ── + setting_card(ui, p, |ui| { + setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p); + let has_pw = !self.state.remote.password.is_empty(); + if ui.add_enabled(has_pw, egui::Checkbox::new(&mut self.state.remote.lan, t("讓手機直接連這台電腦"))).changed() { + changed = true; + } + if self.state.remote.lan { + if let Some(port) = remote::lan_port() { + for a in remote::addresses(port) { + ui.horizontal(|ui| { + ui.label(RichText::new(&a).monospace().size(13.0)); + if ui.small_button(t("複製")).clicked() { + ui.ctx().copy_text(a.clone()); + } + }); + } + } + } + ui.add_space(4.0); + form_row(ui, if has_pw { "更改手機密碼" } else { "設定手機密碼" }, p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.pw1).password(true).hint_text(t("至少 8 個字")).desired_width(f32::INFINITY)); + }); + form_row(ui, "再輸入一次", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.pw2).password(true).desired_width(f32::INFINITY)); + }); + let ok = self.rm.pw1.chars().count() >= 8 && self.rm.pw1 == self.rm.pw2; + if ui.add_enabled(ok, secondary_button("儲存密碼")).clicked() { + self.state.remote.password = crate::httpd::hash_password(&self.rm.pw1); + // A new password signs every phone out. + self.state.remote.devices.clear(); + self.rm.pw1.clear(); + self.rm.pw2.clear(); + changed = true; + } + if !self.rm.pw2.is_empty() && self.rm.pw1 != self.rm.pw2 { + hint(ui, "兩次輸入的密碼不一樣", p); + } + hint(ui, "不要把這個連接埠開放到網際網路上;從外面連請用上面的帳號,或 Tailscale。", p); + }); + + // ── 允許手機做什麼 ── + setting_card(ui, p, |ui| { + setting_title(ui, "手機可以做什麼", "聊天、核准、看 Bot 的電腦一律可以。下面這些會碰到這台電腦本身,預設關閉。", p); + if ui.checkbox(&mut self.state.remote.mac_from_phone, t("從手機看這台電腦的螢幕並操作(點、打字、按鍵)")).changed() { + changed = true; + } + if ui.checkbox(&mut self.state.remote.device_link, t("裝置連結:讓帳號裡其他的 AnyBot(例如放在伺服器上的)操作這台電腦")).changed() { + changed = true; + } + hint(ui, "裝置連結開著時,伺服器上的 Bot 可以把「你的電腦」的螢幕、檔案、指令和 Mac App 工具交給這台電腦執行。每個動作仍照那邊的權限規則詢問你(手機上也能核准)。", p); + }); + + // ── 已登入的裝置 ── + setting_card(ui, p, |ui| { + setting_title(ui, "直接連線的裝置", "用手機密碼登入過的手機和瀏覽器", p); + if self.state.remote.devices.is_empty() { + hint(ui, "還沒有", p); + } + let mut remove = None; + for (i, d) in self.state.remote.devices.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(RichText::new(&d.name).size(FONT_BASE)); + let seen = chrono::DateTime::from_timestamp(d.last_seen.max(d.created), 0).map(|t| t.with_timezone(&chrono::Local).format("最近 %m/%d %H:%M").to_string()).unwrap_or_default(); + ui.label(RichText::new(seen).size(12.0).color(p.text3)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("登出")).size(12.0).color(p.danger)).frame(false)).clicked() { + remove = Some(i); + } + }); + }); + } + if let Some(i) = remove { + self.state.remote.devices.remove(i); + changed = true; + } + }); + + // ── 團隊 ── + if !self.state.remote.host_token.is_empty() { + setting_card(ui, p, |ui| { + setting_title(ui, "分享給團隊", "讓帳號伺服器上的其他帳號也能用這台 AnyBot 的 Bot。", p); + hint(ui, "成員:可以和 Bot 聊天、附加檔案;Bot 會知道是誰在說話。管理員:另外可以核准 Bot 要做的事、停止、看和操作電腦畫面。需要你允許的事只通知你和管理員。", p); + let loaded = self.rm.members.lock().unwrap().clone(); + if loaded.is_none() { + *self.rm.members.lock().unwrap() = Some(Ok(vec![])); + let (m, s) = (self.rm.members.clone(), self.state.remote.clone()); + std::thread::spawn(move || *m.lock().unwrap() = Some(remote::members(&s))); + } + let mut change: Option<(String, &str)> = None; + match loaded { + Some(Ok(list)) => { + for (user, role) in list { + ui.horizontal(|ui| { + ui.label(RichText::new(&user).size(FONT_BASE)); + ui.label(RichText::new(if role == "admin" { "管理員" } else { "成員" }).size(12.0).color(p.text3)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(t("移除")).size(12.0).color(p.danger)).frame(false)).clicked() { + change = Some((user.clone(), "remove")); + } + }); + }); + } + } + Some(Err(e)) => hint(ui, format!("讀不到分享清單:{e}"), p), + None => {} + } + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.new_member).hint_text(t("對方的帳號")).desired_width(160.0)); + ui.checkbox(&mut self.rm.new_admin, t("管理員")); + if ui.add_enabled(!self.rm.new_member.trim().is_empty(), secondary_button("分享")).clicked() { + change = Some((self.rm.new_member.trim().to_string(), if self.rm.new_admin { "admin" } else { "member" })); + self.rm.new_member.clear(); + } + }); + // Talk to the relay off the window's thread, then show the new list. + if let Some((user, role)) = change { + let (m, note, s, role) = (self.rm.members.clone(), self.rm.note.clone(), self.state.remote.clone(), role.to_string()); + std::thread::spawn(move || { + let r = remote::set_member(&s, &user, &role); + *note.lock().unwrap() = match r { + Ok(()) if role == "remove" => format!("已停止分享給 {user}"), + Ok(()) => format!("已分享給 {user}"), + Err(e) => e, + }; + *m.lock().unwrap() = Some(remote::members(&s)); + }); + } + }); + } + + // ── 帳號裡的其他電腦 ── + if !self.state.remote.host_token.is_empty() { + setting_card(ui, p, |ui| { + setting_title(ui, "帳號裡的電腦", "登入同一個帳號的 AnyBot。開了裝置連結的電腦,Bot 可以操作它。", p); + if ui.small_button(t("重新整理")).clicked() || (self.rm.peers.lock().unwrap().is_empty() && !self.rm.busy.load(Ordering::SeqCst)) { + let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone()); + let first = peers.lock().unwrap().is_empty(); + if first { + peers.lock().unwrap().push((String::new(), "讀取中…".into(), false)); + } + std::thread::spawn(move || { + let v = remote::devices_online(&s); + *peers.lock().unwrap() = if v.is_empty() { vec![(String::new(), "(沒有)".into(), false)] } else { v }; + }); + } + for (id, name, online) in self.rm.peers.lock().unwrap().iter() { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + if !id.is_empty() { + ui.painter().circle_filled(r.center(), 4.0, if *online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 }); + } + let me = *id == self.state.remote.host_id; + ui.label(RichText::new(format!("{name}{}", if me { "(這台)" } else { "" })).size(13.5)); + }); + } + }); + } + if changed { + self.mark(); + } + } + + /// Other AnyBots of this account that let bots operate them (裝置連結), as computers. + pub(super) fn linked_devices(&self) -> Vec { + if self.state.remote.host_token.is_empty() { + return vec![]; + } + self.rm + .peers + .lock() + .unwrap() + .iter() + .filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id) + .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } }) + .collect() + } +} diff --git a/src/app/sidebar_ui.rs b/src/app/sidebar_ui.rs new file mode 100644 index 0000000..b62c66c --- /dev/null +++ b/src/app/sidebar_ui.rs @@ -0,0 +1,944 @@ +// 左側欄 and the faces' motion, like the original (captured from Grok Bot itself): +// • top right: two 36 px round buttons, 搜尋 and 新 Bot; +// • the primary bot alone at the top, a big face with a small star badge and its name; +// • the other bots in folders (資料夾; "未分區" for the rest): 54 px rows, 12 px corners, a +// 40 px face, name and last line, section titles 13 px at 66 %; +// • at the bottom: you (initials and name) and 連結應用程式. +// The faces move like the original's mark (renderer engine eo/ia/da): they turn toward the +// pointer (smoothing 1 − 0.86^(dt·60)), blink now and then, bounce when clicked (0.62 s, squash +// and stretch), spin when their bot starts working (0.9 s) and nod when it's done (0.5 s). +// At launch the primary bot greets you in the middle of the window before the app fades in. + +use super::*; + +/// One face's motion state. +#[derive(Clone, Debug)] +pub struct Face { + gaze: egui::Vec2, + last: f64, + next_blink: f64, + blink_at: f64, + poke: Option<(Poke, f64)>, +} + +impl Face { + /// The clock this face last saw (for pokes started outside a frame). + pub fn now(&self) -> f64 { + self.last + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[allow(dead_code)] +pub enum Poke { + Spin, + Bounce, + Nod, + /// Glance left, then right (the greeting). + Look, +} + +const BLINK: f64 = 0.24; +const SPLASH: f64 = 1.6; +const SPLASH_FADE: f64 = 0.35; + +fn duration(p: Poke) -> f64 { + match p { + Poke::Spin => 0.9, + Poke::Bounce => 0.62, + Poke::Nod => 0.5, + Poke::Look => 0.7, + } +} + +/// sin² bump between a and b (the original's `aa`). +fn bump(e: f32, a: f32, b: f32) -> f32 { + if e <= a || e >= b { + 0.0 + } else { + (PI * (e - a) / (b - a)).sin().powi(2) + } +} + +use std::f32::consts::PI; + +/// Pseudo-random 0..1 from a seed (blinks don't all happen together). +fn rand01(seed: u64) -> f64 { + let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03; + x ^= x >> 29; + x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9); + x ^= x >> 32; + (x % 10_000) as f64 / 10_000.0 +} + +impl App { + /// This face's pose now. `follow`: turn toward the pointer. + pub(super) fn face_pose(&mut self, ctx: &egui::Context, id: u64, rect: egui::Rect, follow: bool) -> avatar::Pose { + let now = ctx.input(|i| i.time); + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let f = self.faces.entry(id).or_insert_with(|| Face { gaze: egui::Vec2::ZERO, last: now, next_blink: now + 1.0 + 4.0 * rand01(id), blink_at: -10.0, poke: None }); + let dt = (now - f.last).clamp(0.0, 0.1) as f32; + f.last = now; + // Gaze: toward the pointer when it is near (within 2 face widths, like `fa`). + let working = self.streams.contains_key(&id); + let waiting = self.approvals.contains_key(&id); + let pointer_target = match (follow, pointer) { + (true, Some(pp)) => { + let r = rect.width().max(1.0) * 2.0; + let d = pp - rect.center(); + if d.length() < r * 3.0 { + vec2((d.x / r).clamp(-1.0, 1.0), (-d.y / r).clamp(-1.0, 1.0)) + } else { + egui::Vec2::ZERO + } + } + _ => egui::Vec2::ZERO, + }; + // What the eyes say: working → they read slowly left and right; waiting for you → they + // look up at you; otherwise they follow the pointer. + let target = if waiting { + vec2(0.0, 0.85) + } else if working { + let ph = (now * 1.1 + id as f64) as f32; + vec2(ph.sin() * 0.75, -0.35 + 0.08 * (ph * 2.0).sin()) + } else { + pointer_target + }; + let f = self.faces.get_mut(&id).expect("face"); + let k = 1.0 - 0.86f32.powf(dt * 60.0); + f.gaze += (target - f.gaze) * k; + // Blink every 2.5–6.5 s. + if now >= f.next_blink { + f.blink_at = now; + f.next_blink = now + 4.0 + 4.0 * rand01((now * 1000.0) as u64 ^ id); + } + let bt = ((now - f.blink_at) / BLINK) as f32; + let blink = if (0.0..1.0).contains(&bt) { (PI * bt).sin() } else { 0.0 }; + let mut pose = avatar::Pose { gaze: f.gaze, blink, ..Default::default() }; + if let Some((kind, at)) = f.poke { + let e = ((now - at) / duration(kind)) as f32; + if e >= 1.0 { + f.poke = None; + } else { + match kind { + Poke::Spin => pose.yaw = 180.0 * 2.0 * (1.0 - (PI * e).cos()), + Poke::Nod => pose.pitch = 16.0 * bump(e, 0.0, 1.0), + Poke::Look => pose.gaze = vec2(-(2.0 * PI * e).sin() * 0.9, 0.1), + Poke::Bounce => { + let t = bump(e, 0.0, 0.18) + bump(e, 0.82, 1.0); + let n = bump(e, 0.18, 0.82); + pose.lift = 0.3 * (PI * ((e - 0.14) / 0.72).clamp(0.0, 1.0)).sin(); + pose.squash = vec2(1.0 + 0.08 * t - 0.04 * n, 1.0 - 0.1 * t + 0.06 * n); + } + } + } + } + let moving = f.poke.is_some() || (f.gaze - target).length() > 0.002 || blink > 0.0 || working; + if moving { + ctx.request_repaint(); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((f.next_blink - now).max(0.05))); + } + pose + } + + pub(super) fn poke(&mut self, id: u64, kind: Poke, now: f64) { + if let Some(f) = self.faces.get_mut(&id) { + f.poke = Some((kind, now)); + } else { + self.faces.insert(id, Face { gaze: egui::Vec2::ZERO, last: now, next_blink: now + 2.0, blink_at: -10.0, poke: Some((kind, now)) }); + } + } + + /// A face drawn with its motion (and, while the bot works, three small dots circling). + pub(super) fn live_face(&mut self, ui: &mut egui::Ui, id: u64, rect: egui::Rect, follow: bool) { + let painter = ui.painter().clone(); + let ctx = ui.ctx().clone(); + self.paint_face(&ctx, &painter, id, rect, follow); + } + + fn paint_face(&mut self, ctx: &egui::Context, painter: &egui::Painter, id: u64, rect: egui::Rect, follow: bool) { + let Some(b) = self.state.bot(id).cloned() else { return }; + let pose = self.face_pose(ctx, id, rect, follow); + let col = self.bot_color(&b); + avatar::paint_posed(painter, rect, &b.shape, col, pose); + } + + /// The sidebar's contents (the panel itself, its width and dragging live in sidebar()). + /// The bottom row (you, 連結應用程式) has its own fixed strip so it is never cut off. + pub(super) fn sidebar_body(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + // The part of the panel that is really on screen (the window can be shorter than the + // panel thinks while it is being resized). + let visible = ui.clip_rect().intersect(ui.ctx().content_rect()); + let full = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), ui.max_rect().bottom().min(visible.bottom() - 34.0))); + let bottom_h = 48.0 + if self.newer_build.is_some() { 38.0 } else { 0.0 }; + let top = egui::Rect::from_min_max(full.min, egui::pos2(full.right(), full.bottom() - bottom_h)); + let bottom = egui::Rect::from_min_max(egui::pos2(full.left(), full.bottom() - bottom_h), full.max); + ui.scope_builder(egui::UiBuilder::new().max_rect(top), |ui| self.sidebar_list(ui)); + ui.scope_builder(egui::UiBuilder::new().max_rect(bottom).layout(Layout::bottom_up(Align::LEFT)), |ui| { + ui.horizontal(|ui| { + ui.set_height(44.0); + let name = self.person_name(); + let initials: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect::().to_uppercase(); + let initials = if initials.is_empty() { name.chars().take(1).collect() } else { initials }; + let w = ui.available_width() - 46.0; + let (rect, r) = ui.allocate_exact_size(vec2(w, 40.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 12, p.hover); + } + let c = rect.left_center() + vec2(20.0, 0.0); + ui.painter().circle_filled(c, 16.0, p.active); + ui.painter().circle_stroke(c, 15.5, Stroke::new(1.0, p.border)); + ui.painter().text(c, egui::Align2::CENTER_CENTER, initials, egui::FontId::proportional(12.0), p.text2); + let label = shorten_width(ui, &name, w - 52.0, 14.0); + ui.painter().text(rect.left_center() + vec2(44.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(14.0), p.text); + if r.on_hover_text(t("設定(⌘,)")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + self.show_settings = true; + self.settings_tab = SettingsTab::General; + } + if pill_icon(ui, crate::icons::Icon::Apps, p).on_hover_text(t("連接器市集")).clicked() { + self.show_settings = true; + self.settings_tab = SettingsTab::Channels; + self.ch.focus_market = true; + } + }); + if let Some(tm) = self.newer_build { + if crate::icons::pill(ui, crate::icons::Icon::Update, &format!("{}({})", t("重新啟動以更新"), version_label(Some(tm))), p.accent, Color32::WHITE, 30.0).clicked() { + self.relaunch_requested = true; + } + } + }); + } + + /// Top buttons, the primary bot and the groups (scrolling). + fn sidebar_list(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + // 搜尋 / + at the top right. + offers a new bot or a new group. + ui.horizontal(|ui| { + ui.set_height(36.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let plus = pill_icon(ui, crate::icons::Icon::Plus, p).on_hover_text(t("新增")); + egui::Popup::menu(&plus).show(|ui| { + ui.set_min_width(menu_ui::MENU_W); + ui.spacing_mut().item_spacing.y = 0.0; + if menu_ui::item(ui, p, crate::icons::Icon::Bot, "新增 Bot").clicked() { + self.new_bot(); + ui.close(); + } + if menu_ui::item(ui, p, crate::icons::Icon::Folder, "新增分區").clicked() { + self.create_section(None); + ui.close(); + } + menu_ui::sep(ui, p); + self.new_bot_menu(ui); + }); + let open = self.search_open || !self.search.is_empty(); + if pill_icon(ui, crate::icons::Icon::Search, p).on_hover_text(t("搜尋 Bot")).clicked() { + self.search_open = !open; + if open { + self.search.clear(); + } + } + }); + }); + if self.search_open || !self.search.is_empty() { + ui.add_space(8.0); + Frame::new().fill(p.hover).corner_radius(10).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(14.0, 14.0)), crate::icons::Icon::Search, p.text3); + let resp = ui.add(egui::TextEdit::singleline(&mut self.search).hint_text(t("搜尋 Bot")).frame(Frame::NONE).desired_width(f32::INFINITY)); + if self.search_open && self.search.is_empty() && !resp.has_focus() && !resp.lost_focus() { + resp.request_focus(); + } + }); + }); + } + let q = self.search.trim().to_lowercase(); + let matches = |b: &Bot| q.is_empty() || b.name.to_lowercase().contains(&q) || b.description.to_lowercase().contains(&q); + let primary = self.state.bot(self.state.primary_bot).filter(|b| matches(b)).map(|b| b.id); + let pins: Vec = self.pinned_ids().into_iter().filter(|id| self.state.bot(*id).map(|b| matches(b)).unwrap_or(false)).collect(); + let folders = self.all_folders(); + let has_sections = !folders.is_empty(); + // 分區 in order (None = 未分區); pinned bots show only in the grid. + let mut sections: Vec<(Option, Vec)> = self.section_order().into_iter().map(|k| (k, vec![])).collect(); + for b in self.state.bots.iter().filter(|b| b.id != self.state.primary_bot && !b.pinned && matches(b)) { + let key = if has_sections && folders.contains(&b.folder) { Some(b.folder.clone()) } else { None }; + if let Some(s) = sections.iter_mut().find(|s| s.0 == key) { + s.1.push(b.id); + } + } + // While searching, empty sections are hidden. + if !q.is_empty() { + sections.retain(|s| !s.1.is_empty()); + } + let dragged_id = egui::DragAndDrop::payload::(ui.ctx()).map(|x| *x); + let dragging = dragged_id.is_some(); + let mut moved: Option<(u64, Option)> = None; + let mut pin_to: Option<(u64, usize)> = None; + let n_sections = sections.len(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + self.pin_grid(ui, primary, &pins, dragged_id, &mut pin_to); + if primary.is_none() && pins.is_empty() && sections.iter().all(|s| s.1.is_empty()) && !q.is_empty() { + ui.add_space(12.0); + ui.label(RichText::new(t("沒有相符的 Bot")).color(p.text3)); + } + for (si, (folder, ids)) in sections.into_iter().enumerate() { + let is_loose = folder.is_none(); + // 未分區 hides when empty (unless a bot is being dragged). + if is_loose && has_sections && ids.is_empty() && !dragging { + continue; + } + if !has_sections && ids.is_empty() && !dragging { + continue; + } + let title = folder.clone().unwrap_or_else(|| if has_sections { t("未分區") } else { "Bot".into() }); + let start = ui.cursor().top(); + ui.add_space(14.0); + let renaming = self.renaming.as_ref().filter(|r| Some(&r.0) == folder.as_ref()).cloned(); + let mut folded_now = false; + match renaming { + Some((old, mut buf)) => { + // Inline rename: Enter or clicking away saves, Esc cancels. + let resp = ui.horizontal(|ui| { + ui.add_space(6.0); + ui.add(egui::TextEdit::singleline(&mut buf).desired_width(ui.available_width() - 12.0).font(egui::FontId::proportional(13.0))) + }).inner; + if !resp.has_focus() && !resp.lost_focus() { + resp.request_focus(); + } + if resp.lost_focus() { + self.renaming = None; + let cancel = ui.input(|i| i.key_pressed(Key::Escape)); + let new = buf.trim().to_string(); + if !cancel && !new.is_empty() && new != old && !self.all_folders().contains(&new) { + self.rename_folder(&old, &new); + } + } else { + self.renaming = Some((old, buf)); + } + } + None => { + let key = folder.clone().unwrap_or_default(); + let folded = self.state.folded.contains(&key) && has_sections; + folded_now = folded; + let (rect, head) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), egui::Sense::click()); + let hovered = ui.rect_contains_pointer(rect); + if hovered { + ui.painter().rect_filled(rect, 8, p.hover); + } + let tcol = p.text.gamma_multiply(0.66); + ui.painter().text(rect.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, &title, egui::FontId::proportional(13.0), tcol); + let tw = ui.painter().layout_no_wrap(title.clone(), egui::FontId::proportional(13.0), tcol).size().x; + if has_sections && (hovered || folded) { + let c = rect.left_center() + vec2(8.0 + tw + 10.0, 0.0); + let st = Stroke::new(1.3, tcol); + if folded { + ui.painter().line_segment([c + vec2(-2.0, -3.5), c + vec2(1.5, 0.0)], st); + ui.painter().line_segment([c + vec2(-2.0, 3.5), c + vec2(1.5, 0.0)], st); + } else { + ui.painter().line_segment([c + vec2(-3.5, -1.5), c + vec2(0.0, 2.0)], st); + ui.painter().line_segment([c + vec2(3.5, -1.5), c + vec2(0.0, 2.0)], st); + } + } + if folded { + ui.painter().text(rect.right_center() - vec2(10.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{}", ids.len()), egui::FontId::proportional(13.0), tcol); + } + // ⋯ on hover opens the same menu as a right-click. + let br = egui::Rect::from_center_size(rect.right_center() - vec2(if folded { 34.0 } else { 14.0 }, 0.0), vec2(22.0, 22.0)); + let menu_key = folder.clone().unwrap_or_else(|| "\u{0}loose".into()); + let mut menu_btn = None; + if hovered || self.group_menu_for.as_deref() == Some(menu_key.as_str()) { + let mr = ui.interact(br, egui::Id::unique(("section-more", &menu_key)), egui::Sense::click()); + if mr.hovered() { + ui.painter().rect_filled(br, 6, p.active); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(br.center(), vec2(14.0, 14.0)), crate::icons::Icon::More, tcol); + menu_btn = Some(mr.on_hover_text(t("分區操作"))); + } + let menu_clicked = menu_btn.as_ref().map(|m| m.clicked()).unwrap_or(false); + if head.clicked() && !menu_clicked && has_sections { + if folded { + self.state.folded.retain(|x| *x != key); + } else { + self.state.folded.push(key.clone()); + } + self.mark(); + } + if head.double_clicked() { + if let Some(f) = folder.clone() { + self.renaming = Some((f.clone(), f)); + } + } + let (first, last) = (si == 0, si + 1 == n_sections); + let f2 = folder.clone(); + let edit = |this: &mut Self, ui: &mut egui::Ui| { + ui.set_min_width(180.0); + if !has_sections { + // The flat list: start the first 分區 here. + if menu_ui::item(ui, p, crate::icons::Icon::Folder, "建立分區").clicked() { + this.create_section(None); + ui.close(); + } + return; + } + if let Some(f) = &f2 { + if menu_ui::item(ui, p, crate::icons::Icon::Pencil, "重新命名").clicked() { + this.renaming = Some((f.clone(), f.clone())); + ui.close(); + } + } + if ui.add_enabled_ui(!first, |ui| menu_ui::item(ui, p, crate::icons::Icon::ArrowUp, "上移")).inner.clicked() { + this.move_section(&f2, -1); + ui.close(); + } + if ui.add_enabled_ui(!last, |ui| menu_ui::item(ui, p, crate::icons::Icon::ArrowDown, "下移")).inner.clicked() { + this.move_section(&f2, 1); + ui.close(); + } + if let Some(f) = &f2 { + menu_ui::sep(ui, p); + if menu_ui::danger(ui, p, crate::icons::Icon::Trash, "刪除").clicked() { + this.confirm_section_delete = Some(f.clone()); + ui.close(); + } + } + }; + head.context_menu(|ui| edit(self, ui)); + if let Some(mr) = menu_btn { + if mr.clicked() { + self.group_menu_for = Some(menu_key.clone()); + } + let open = egui::Popup::menu(&mr).show(|ui| edit(self, ui)); + if open.is_none() && self.group_menu_for.as_deref() == Some(menu_key.as_str()) && !mr.clicked() { + self.group_menu_for = None; + } + } + } + } + if !folded_now { + ui.add_space(6.0); + if ids.is_empty() && folder.is_some() { + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label(RichText::new(t(if dragging { "放到這裡" } else { "將 Bot 拖曳到這裡" })).size(12.0).color(p.text3)); + }); + } + for id in ids { + self.bot_row(ui, id); + } + } + // The whole section is a drop target while a bot (or a pinned tile) is dragged. + let area = egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), start), egui::pos2(ui.max_rect().right(), ui.cursor().top())); + let zone = ui.interact(area, egui::Id::unique(("section-drop", si, folder.clone())), egui::Sense::hover()); + if zone.dnd_hover_payload::().map(|id| *id != self.state.primary_bot).unwrap_or(false) { + ui.painter().rect_stroke(area.shrink(1.0), 12, Stroke::new(1.5, p.accent), egui::StrokeKind::Inside); + } + if let Some(id) = zone.dnd_release_payload::() { + if *id != self.state.primary_bot { + moved = Some((*id, folder.clone())); + } + } + } + ui.add_space(8.0); + }); + if let Some((id, f)) = moved { + // Dropping a pinned tile on a section unpins it and moves it there. + self.set_pinned(id, false, None); + if let Some(b) = self.state.bot_mut(id) { + b.folder = f.unwrap_or_default(); + } + self.mark(); + } + if let Some((id, at)) = pin_to { + self.set_pinned(id, true, Some(at)); + } + // 刪除分區: asked first; its bots go back to 未分區. + if let Some(name) = self.confirm_section_delete.clone() { + let ctx = ui.ctx().clone(); + let mut keep = true; + let close = dialog(&ctx, "section-delete", &format!("刪除「{name}」"), 400.0, true, p, |ui| { + dialog_body(ui, "section-delete-body", 160.0, |ui| { + ui.label(t("其中的 Bot 會移至 未分區,不會刪除任何 Bot。")); + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(danger_button("刪除", p)).clicked() { + self.delete_folder(&name); + keep = false; + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if !keep || close { + self.confirm_section_delete = None; + } + } + // The dragged bot follows the pointer. + if let (Some(id), Some(pos)) = (egui::DragAndDrop::payload::(ui.ctx()), ui.ctx().pointer_hover_pos()) { + if let Some(b) = self.state.bot(*id).cloned() { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("drag-bot"))); + let rect = egui::Rect::from_min_size(pos + vec2(8.0, 4.0), vec2(170.0, 36.0)); + painter.rect_filled(rect, 12, p.elevated); + painter.rect_stroke(rect, 12, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + avatar::paint(&painter, egui::Rect::from_min_size(rect.min + vec2(8.0, 6.0), vec2(24.0, 24.0)), &b.shape, self.bot_color(&b)); + painter.text(rect.left_center() + vec2(40.0, 0.0), egui::Align2::LEFT_CENTER, shorten_width(ui, &b.name, 118.0, 13.5), egui::FontId::proportional(13.5), p.text); + } + } + } + + /// Every group, in order (saved ones first, then any a bot names). + pub(super) fn all_folders(&self) -> Vec { + let mut v = self.state.folders.clone(); + for b in &self.state.bots { + if !b.folder.is_empty() && !v.contains(&b.folder) { + v.push(b.folder.clone()); + } + } + v + } + + fn rename_folder(&mut self, old: &str, new: &str) { + for f in self.state.folders.iter_mut() { + if f == old { + *f = new.to_string(); + } + } + if !self.state.folders.iter().any(|f| f == new) { + self.state.folders.push(new.to_string()); + } + for b in self.state.bots.iter_mut() { + if b.folder == old { + b.folder = new.to_string(); + } + } + self.mark(); + } + + /// 分區 in display order; None is 未分區. + pub(super) fn section_order(&self) -> Vec> { + let mut v: Vec> = self.all_folders().into_iter().map(Some).collect(); + let at = self.state.loose_index.unwrap_or(v.len()).min(v.len()); + v.insert(at, None); + v + } + + fn move_section(&mut self, key: &Option, by: i32) { + let mut order = self.section_order(); + if let Some(i) = order.iter().position(|x| x == key) { + let j = (i as i32 + by).clamp(0, order.len() as i32 - 1) as usize; + order.swap(i, j); + self.state.loose_index = order.iter().position(|x| x.is_none()); + self.state.folders = order.into_iter().flatten().collect(); + self.mark(); + } + } + + /// A new 分區 at the top (新增分區, 新增分區 2…), renaming right away; `bot` moves into it. + pub(super) fn create_section(&mut self, bot: Option) { + let all = self.all_folders(); + let base = t("新增分區"); + let mut n = 1; + let name = loop { + let c = if n == 1 { base.clone() } else { format!("{base} {n}") }; + if !all.contains(&c) { + break c; + } + n += 1; + }; + let mut folders = all; + folders.insert(0, name.clone()); + if let Some(i) = self.state.loose_index.as_mut() { + *i += 1; + } + self.state.folders = folders; + if let Some(id) = bot { + self.set_pinned(id, false, None); + if let Some(b) = self.state.bot_mut(id) { + b.folder = name.clone(); + } + } + self.renaming = Some((name.clone(), name)); + self.mark(); + } + + /// 已釘選的 Bot in grid order (the primary bot has its own tile first). + pub(super) fn pinned_ids(&self) -> Vec { + let mut v: Vec = self.state.pin_order.iter().copied().filter(|id| *id != self.state.primary_bot && self.state.bot(*id).map(|b| b.pinned).unwrap_or(false)).collect(); + for b in &self.state.bots { + if b.pinned && b.id != self.state.primary_bot && !v.contains(&b.id) { + v.push(b.id); + } + } + v + } + + /// Pin (at a place in the grid, default the end) or unpin a bot. + pub(super) fn set_pinned(&mut self, id: u64, on: bool, at: Option) { + let mut order = self.pinned_ids(); + order.retain(|x| *x != id); + if on && id != self.state.primary_bot { + let at = at.unwrap_or(order.len()).min(order.len()); + order.insert(at, id); + } + if let Some(b) = self.state.bot_mut(id) { + b.pinned = on; + } + self.state.pin_order = order; + self.mark(); + } + + /// The grid at the top: the primary bot, then 已釘選的 Bot. Bots dragged here are pinned; + /// tiles dragged within it are reordered (a tile dragged to a 分區 is unpinned there). + fn pin_grid(&mut self, ui: &mut egui::Ui, primary: Option, pins: &[u64], dragged: Option, pin_to: &mut Option<(u64, usize)>) { + let p = self.pal; + let tiles: Vec = primary.into_iter().chain(pins.iter().copied()).collect(); + let dragging_other = dragged.map(|d| d != self.state.primary_bot).unwrap_or(false); + if tiles.is_empty() && !dragging_other { + return; + } + ui.add_space(10.0); + let w = ui.available_width(); + let cols = ((w / 92.0).floor() as usize).max(1); + let tile_w = w / cols as f32; + let tile_h = 100.0; + // Room for one more tile while a bot that isn't pinned yet is dragged over. + let showing_zone = dragging_other && pins.is_empty() && !dragged.map(|d| pins.contains(&d)).unwrap_or(false); + let rows = tiles.len().div_ceil(cols).max(1); + let grid_h = rows as f32 * tile_h + if showing_zone { 58.0 } else { 0.0 }; + let (area, _) = ui.allocate_exact_size(vec2(w, grid_h), egui::Sense::hover()); + let rect_of = |i: usize| egui::Rect::from_min_size(area.min + vec2((i % cols) as f32 * tile_w, (i / cols) as f32 * tile_h), vec2(tile_w, tile_h)); + for (i, id) in tiles.iter().enumerate() { + self.pin_tile(ui, rect_of(i), *id, Some(*id) == primary); + } + if showing_zone { + let zr = egui::Rect::from_min_size(egui::pos2(area.left() + 6.0, area.bottom() - 54.0), vec2(w - 12.0, 48.0)); + ui.painter().rect_stroke(zr, 12, Stroke::new(1.2, p.border), egui::StrokeKind::Inside); + ui.painter().text(zr.center(), egui::Align2::CENTER_CENTER, t("拖曳到此處以釘選"), egui::FontId::proportional(12.5), p.text3); + } + let zone = ui.interact(area, egui::Id::unique("pin-grid-drop"), egui::Sense::hover()); + let over = zone.dnd_hover_payload::().map(|id| *id != self.state.primary_bot).unwrap_or(false); + // Where it would land: before the tile under the pointer (after the primary tile). + let index_at = |pos: egui::Pos2| -> usize { + let first = if primary.is_some() { 1 } else { 0 }; + let mut idx = pins.len(); + for (i, _) in pins.iter().enumerate() { + let r = rect_of(i + first); + if (pos.y < r.bottom() && pos.x < r.center().x) || pos.y < r.top() { + idx = i; + break; + } + } + idx + }; + if over { + ui.painter().rect_stroke(area.shrink(1.0), 12, Stroke::new(1.5, p.accent), egui::StrokeKind::Inside); + if let Some(pos) = ui.ctx().pointer_hover_pos() { + let i = index_at(pos) + if primary.is_some() { 1 } else { 0 }; + if !pins.is_empty() { + let r = rect_of(i.min(tiles.len())); + let x = if i >= tiles.len() { rect_of(tiles.len() - 1).right() } else { r.left() }; + let r = if i >= tiles.len() { rect_of(tiles.len() - 1) } else { r }; + ui.painter().vline(x, r.y_range().shrink(14.0), Stroke::new(2.0, p.accent)); + } + } + } + if let Some(id) = zone.dnd_release_payload::() { + if *id != self.state.primary_bot { + let at = ui.ctx().pointer_interact_pos().map(index_at).unwrap_or(pins.len()); + // Moving a tile later in the grid: its own slot frees up first. + let at = match pins.iter().position(|x| x == &*id) { + Some(from) if from < at => at - 1, + _ => at, + }; + *pin_to = Some((*id, at)); + } + } + } + + /// One tile of the grid: a big face and the name (★ for the primary bot). + fn pin_tile(&mut self, ui: &mut egui::Ui, rect: egui::Rect, id: u64, is_primary: bool) { + let p = self.pal; + let Some(b) = self.state.bot(id).cloned() else { return }; + let sense = if is_primary { egui::Sense::click() } else { egui::Sense::click_and_drag() }; + let hit = egui::Rect::from_center_size(rect.center(), vec2(rect.width().min(104.0) - 4.0, 96.0)); + let r = ui.interact(hit, egui::Id::unique(("pin-tile", id)), sense); + if !is_primary { + r.dnd_set_drag_payload(id); + } + let selected = self.state.selected == id; + if selected || r.hovered() { + ui.painter().rect_filled(hit, 14, if selected { p.active } else { p.hover }); + } + let face = egui::Rect::from_center_size(rect.center_top() + vec2(0.0, 36.0), vec2(56.0, 56.0)); + self.live_face(ui, id, face, true); + if is_primary { + let badge = face.right_bottom() + vec2(-6.0, -6.0); + ui.painter().circle_filled(badge, 9.0, p.sidebar); + ui.painter().circle_filled(badge, 7.5, Color32::from_rgb(0xf9, 0x73, 0x16)); + let star: Vec = (0..10).map(|i| { + let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0; + let rr = if i % 2 == 0 { 4.6 } else { 2.0 }; + badge + vec2(a.cos() * rr, a.sin() * rr) + }).collect(); + ui.painter().add(egui::Shape::convex_polygon(star.clone(), Color32::WHITE, Stroke::NONE)); + for i in 0..5 { + ui.painter().add(egui::Shape::convex_polygon(vec![badge, star[(2 * i + 9) % 10], star[2 * i], star[(2 * i + 1) % 10]], Color32::WHITE, Stroke::NONE)); + } + } + let name = shorten_width(ui, &b.name, hit.width() - 8.0, 13.0); + ui.painter().text(rect.center_top() + vec2(0.0, 80.0), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(13.0), p.text); + if b.unread || self.approvals.contains_key(&id) { + ui.painter().circle_filled(face.right_top() + vec2(4.0, 2.0), 4.5, p.accent); + } + let r = r.on_hover_text(if is_primary { t("主要 Bot") } else { b.name.clone() }).on_hover_cursor(egui::CursorIcon::PointingHand); + if r.clicked() { + self.select_bot(ui.ctx(), id); + } + r.context_menu(|ui| self.bot_menu_items(ui, id)); + } + + fn delete_folder(&mut self, f: &str) { + let order = self.section_order(); + let gone_before_loose = order.iter().position(|x| x.as_deref() == Some(f)).zip(order.iter().position(|x| x.is_none())).map(|(a, b)| a < b).unwrap_or(false); + if gone_before_loose { + if let Some(i) = self.state.loose_index.as_mut() { + *i = i.saturating_sub(1); + } + } + self.state.folders.retain(|x| x != f); + for b in self.state.bots.iter_mut() { + if b.folder == f { + b.folder.clear(); + } + } + self.mark(); + } + + /// One bot in a folder (the original: 54 px, 12 px corners, 40 px face). + pub(super) fn bot_row(&mut self, ui: &mut egui::Ui, id: u64) { + let p = self.pal; + let Some(b) = self.state.bot(id).cloned() else { return }; + let selected = self.state.selected == id; + let streaming = self.streams.contains_key(&id); + let waiting = self.approvals.contains_key(&id); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 54.0), egui::Sense::click_and_drag()); + // Drag a bot onto another group to move it. + resp.dnd_set_drag_payload(id); + let dragged = resp.dragged(); + let fill = if selected { p.active } else if resp.hovered() || dragged { p.hover } else { Color32::TRANSPARENT }; + ui.painter().rect_filled(rect, 12, fill); + let face = egui::Rect::from_min_size(rect.min + vec2(8.0, 7.0), vec2(40.0, 40.0)); + self.live_face(ui, id, face, true); + let tx = rect.left() + 56.0; + let text_w = (rect.right() - 22.0 - tx).max(10.0); + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + let one_line = |text: String, size: f32, color: Color32| { + let mut job = egui::text::LayoutJob::single_section(text, egui::TextFormat::simple(egui::FontId::proportional(size), color)); + job.wrap = egui::text::TextWrapping::truncate_at_width(text_w); + painter.layout_job(job) + }; + painter.galley(egui::pos2(tx, rect.top() + 9.0), one_line(b.name.clone(), 15.0, p.text), p.text); + let sub = if waiting { + t("需要你允許") + } else if streaming { + t("正在工作…") + } else { + b.messages.iter().rev().find(|m| m.role != Role::Tool && !m.text.is_empty()).map(|m| first_line(&m.text, 80)).filter(|s| !s.is_empty()).unwrap_or_else(|| b.description.clone()) + }; + let sub_col = if waiting { p.accent } else { p.text3 }; + painter.galley(egui::pos2(tx, rect.top() + 30.0), one_line(sub, 13.0, sub_col), sub_col); + if waiting { + ui.painter().circle_filled(egui::pos2(rect.right() - 12.0, rect.center().y), 5.0, p.accent); + ui.painter().circle_stroke(egui::pos2(rect.right() - 12.0, rect.center().y), 7.5, Stroke::new(1.5, p.accent.gamma_multiply(0.4))); + } else if b.unread { + ui.painter().circle_filled(egui::pos2(rect.right() - 12.0, rect.center().y), 4.0, Color32::from_rgb(0x16, 0x80, 0xf3)); + } + if resp.clicked() { + self.select_bot(ui.ctx(), id); + } + resp.context_menu(|ui| self.bot_menu_items(ui, id)); + } + + fn select_bot(&mut self, ctx: &egui::Context, id: u64) { + self.state.selected = id; + if let Some(b) = self.state.bot_mut(id) { + b.unread = false; + } + self.stick_bottom = true; + let now = ctx.input(|i| i.time); + self.poke(id, Poke::Bounce, now); + self.mark(); + } + + /// The name shown at the bottom left: the account, else the Mac's user. + fn person_name(&self) -> String { + if !self.state.remote.relay_user.is_empty() { + return self.state.remote.relay_user.clone(); + } + static NAME: std::sync::OnceLock = std::sync::OnceLock::new(); + NAME.get_or_init(|| { + std::process::Command::new("id").arg("-F").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "你".into())) + }) + .clone() + } + + /// Group chat and templates (in +). + pub(super) fn new_bot_menu(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + if menu_ui::item(ui, p, crate::icons::Icon::Users, "群組聊天").on_hover_text(t("群組聊天(好幾個 Bot 一起回答)")).clicked() { + let id = self.state.next_id; + let members: Vec = self.state.bots.iter().filter(|b| b.members.is_empty()).map(|b| b.id).take(3).collect(); + let sel = self.state.selected; + let _ = crate::appctl::apply(&mut self.state, sel, "create_bot", &serde_json::json!({"name": "群組聊天", "description": "成員依序回答"}).to_string()); + if let Some(b) = self.state.bot_mut(id) { + b.members = members; + b.shape = "cloud".into(); + } + self.state.selected = id; + self.edit_bot = self.state.bot(id).cloned().map(|b| (b, false)); + self.mark(); + ui.close(); + } + menu_ui::sub(ui, p, crate::icons::Icon::Template, "從範本建立", |ui| { + ui.set_min_width(260.0); + for tp in crate::memory::TEMPLATES.iter() { + if menu_ui::item_text(ui, p, None, tp.name).on_hover_text(tp.description).clicked() { + let id = self.state.next_id; + let sel = self.state.selected; + let _ = crate::appctl::apply(&mut self.state, sel, "create_bot", &serde_json::json!({"template": tp.name}).to_string()); + if self.state.bot(id).is_some() { + self.state.selected = id; + self.edit_bot = self.state.bot(id).cloned().map(|b| (b, false)); + } + self.mark(); + ui.close(); + } + } + }); + } + + /// 移至 in a bot's menu: every 分區 (✓ the current one, 未分區 too), then 建立分區. With no + /// 分區 yet it is one item, 移至新分區. Pinned bots have no 移至 (they live in the grid). + pub(super) fn folder_menu(&mut self, ui: &mut egui::Ui, id: u64) { + let Some(b) = self.state.bot(id).cloned() else { return }; + if b.pinned || id == self.state.primary_bot { + return; + } + let p = self.pal; + let folders = self.all_folders(); + if folders.is_empty() { + if menu_ui::item(ui, p, crate::icons::Icon::Folder, "移至新分區").clicked() { + self.create_section(Some(id)); + ui.close(); + } + return; + } + let current = if folders.contains(&b.folder) { b.folder.clone() } else { String::new() }; + let order = self.section_order(); + menu_ui::sub(ui, p, crate::icons::Icon::Folder, "移至", |ui| { + let mut pick: Option = None; + for k in &order { + let key = k.clone().unwrap_or_default(); + let label = k.clone().unwrap_or_else(|| t("未分區")); + let icon = if key == current { crate::icons::Icon::Check } else { crate::icons::Icon::Folder }; + if menu_ui::item_text(ui, p, Some(icon), &label).clicked() { + pick = Some(key); + } + } + menu_ui::sep(ui, p); + if menu_ui::item(ui, p, crate::icons::Icon::Plus, "建立分區").clicked() { + self.create_section(Some(id)); + ui.close(); + } + if let Some(f) = pick { + if let Some(b) = self.state.bot_mut(id) { + b.folder = f; + } + self.mark(); + ui.close(); + } + }); + } + + /// 啟動動畫: the primary bot greets you in the middle (a spin, a hop and a blink), then the + /// window fades in. Returns true while it is showing. + pub(super) fn splash(&mut self, ctx: &egui::Context) -> bool { + if self.demo && std::env::var("ANYBOT_SHOT_SPLASH").is_err() { + return false; + } + let now = ctx.input(|i| i.time); + let start = *self.splash_at.get_or_insert(now); + let t = now - start; + if t > SPLASH + SPLASH_FADE { + return false; + } + let p = self.pal; + let id = self.state.primary_bot; + // Grow in, look around once (left, right), hop, then blink — each step easy to see. + if (0.55..0.6).contains(&t) && self.faces.get(&id).map(|f| f.poke.is_none()).unwrap_or(true) { + self.poke(id, Poke::Look, now); + } + if (1.25..1.3).contains(&t) && self.faces.get(&id).map(|f| f.poke.is_none()).unwrap_or(true) { + self.poke(id, Poke::Bounce, now); + } + let fade = if t > SPLASH { 1.0 - ((t - SPLASH) / SPLASH_FADE) as f32 } else { 1.0 }; + // Painted straight onto the top layer: covers the whole window from the first frame. + let screen = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("splash"))); + painter.rect_filled(screen, 0, p.pane.gamma_multiply(fade)); + let grow = (1.0 - (1.0 - (t / 0.4).min(1.0) as f32).powi(3)).max(0.01); + let size = 120.0 * grow; + let face = egui::Rect::from_center_size(screen.center() - vec2(0.0, 24.0), vec2(size, size)); + let mut faded = painter.clone(); + faded.set_opacity(fade); + self.paint_face(ctx, &faded, id, face, true); + if let Some(b) = self.state.bot(id) { + let a = ((t - 0.35) / 0.4).clamp(0.0, 1.0) as f32 * fade; + painter.text(screen.center() + vec2(0.0, 62.0), egui::Align2::CENTER_CENTER, &b.name, egui::FontId::proportional(18.0), p.text.gamma_multiply(a)); + } + // Clicks wait until it has faded. + if t < SPLASH { + egui::Area::new(egui::Id::unique("splash-block")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + ui.allocate_exact_size(screen.size(), egui::Sense::click()); + }); + } + ctx.request_repaint(); + true + } +} + +pub(super) fn shorten_width(ui: &egui::Ui, s: &str, max_w: f32, size: f32) -> String { + let w = |x: &str| ui.painter().layout_no_wrap(x.to_string(), egui::FontId::proportional(size), Color32::WHITE).size().x; + if w(s) <= max_w { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut n = chars.len(); + while n > 1 { + n -= 1; + let c = chars[..n].iter().collect::() + "…"; + if w(&c) <= max_w { + return c; + } + } + "…".into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bump_and_rand() { + assert_eq!(bump(0.0, 0.0, 1.0), 0.0); + assert!((bump(0.5, 0.0, 1.0) - 1.0).abs() < 1e-5); + assert!((0.0..1.0).contains(&rand01(42))); + assert_ne!(rand01(1), rand01(2)); + } +} diff --git a/src/app/vault_ui.rs b/src/app/vault_ui.rs new file mode 100644 index 0000000..5cc1c3f --- /dev/null +++ b/src/app/vault_ui.rs @@ -0,0 +1,839 @@ +// 設定 → 保險庫: logins, cards and contact info the bots can fill for the person (never seeing +// passwords, card numbers or security codes). Layout follows the original's vault pane: +// 「新項目」 menu (登入資訊 / 卡 / 聯絡資訊 · 匯入), tabs with counts, 搜尋保險庫, rows with a +// tile, title and subtitle, per-type empty states, and a footer note. + +use super::*; +use super::sidebar_ui::shorten_width; +use crate::icons::Icon; +use crate::vault_store::{self as store, Address, ImportPlan, Item, Kind, RowStatus}; + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum VaultTab { + #[default] + All, + Login, + Card, + Contact, +} + +#[derive(Default, Clone)] +pub struct LoginForm { + pub id: Option, + pub sites: Vec, + pub username: String, + pub password: String, + pub notes: String, + pub err: String, +} + +#[derive(Default, Clone)] +pub struct CardForm { + pub id: Option, + pub number: String, + pub expiry: String, + pub csc: String, + pub show_csc: bool, + pub holder: String, + pub nickname: String, + /// "" = 無帳單地址, "new" = typed below, else a contact's id. + pub billing_from: String, + pub billing: Address, + pub notes: String, + pub locked: String, + pub err: String, +} + +#[derive(Default, Clone)] +pub struct ContactForm { + pub id: Option, + pub label: String, + pub name: String, + pub address: Address, + pub phones: Vec, + pub emails: Vec, + pub notes: String, + pub err: String, +} + +#[derive(Default)] +pub struct ImportState { + pub picking: Option>>>>, + pub file: String, + pub plan: Option, + pub err: String, + /// Rows still to save (indexes into plan.rows), and how many there were. + pub queue: Vec, + pub total: usize, + pub stop: bool, + pub done: Option<(usize, usize, Vec<(String, String)>)>, +} + +pub enum VaultDialog { + ViewLogin(String), + Login(LoginForm), + Card(CardForm), + Contact(ContactForm), + Remove(String, String, Kind), + Import(ImportState), +} + +#[derive(Default)] +pub struct VaultUi { + pub tab: VaultTab, + pub search: String, + pub items: Option, String>>, + pub dialog: Option, + pub note: String, +} + +fn kind_label(k: Kind) -> &'static str { + match k { + Kind::Login => "登入資訊", + Kind::Card => "卡片", + Kind::Contact => "聯絡資訊", + } +} + +fn kind_icon(k: Kind) -> Icon { + match k { + Kind::Login => Icon::Key, + Kind::Card => Icon::CreditCard, + Kind::Contact => Icon::Person, + } +} + +impl App { + fn vault_items(&mut self) -> Vec { + if self.vault.items.is_none() { + self.vault.items = Some(if self.demo { Ok(demo_items()) } else { store::list() }); + } + match self.vault.items.as_ref() { + Some(Ok(v)) => v.clone(), + _ => vec![], + } + } + + fn vault_refresh(&mut self) { + self.vault.items = None; + } + + pub(super) fn vault_page(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + let items = self.vault_items(); + // Header: title and 「新項目」. + ui.horizontal(|ui| { + ui.label(RichText::new(t("保險庫")).strong().size(FONT_XL)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let open = self.vault.dialog.is_some(); + let b = ui.add_enabled(!open, primary_button("新項目", p)); + egui::Popup::menu(&b).show(|ui| { + ui.set_min_width(menu_ui::MENU_W); + ui.spacing_mut().item_spacing.y = 0.0; + if menu_ui::item(ui, p, Icon::Key, "登入資訊").clicked() { + self.vault.dialog = Some(VaultDialog::Login(LoginForm { sites: vec![String::new()], ..Default::default() })); + ui.close(); + } + if menu_ui::item(ui, p, Icon::CreditCard, "卡").clicked() { + self.vault.dialog = Some(VaultDialog::Card(CardForm::default())); + ui.close(); + } + if menu_ui::item(ui, p, Icon::Person, "聯絡資訊").clicked() { + self.vault.dialog = Some(VaultDialog::Contact(ContactForm { phones: vec![String::new()], emails: vec![String::new()], ..Default::default() })); + ui.close(); + } + menu_ui::sep(ui, p); + if menu_ui::item(ui, p, Icon::FileText, "匯入").clicked() { + self.vault.dialog = Some(VaultDialog::Import(ImportState::default())); + ui.close(); + } + }); + }); + }); + hint(ui, "Bot 可以看到登入資訊和聯絡資訊,以便替你填寫", p); + ui.add_space(10.0); + if let Some(Err(e)) = &self.vault.items { + crate::icons::label(ui, Icon::Alert, &format!("{}:{e}", t("讀不到保險庫")), 13.0, p.danger); + return; + } + // Tabs with counts, and the search box. + let count = |k: Option| items.iter().filter(|i| k.map(|k| i.kind == k).unwrap_or(true)).count(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (tab, label, k) in [(VaultTab::All, "全部", None), (VaultTab::Login, "登入資訊", Some(Kind::Login)), (VaultTab::Card, "卡片", Some(Kind::Card)), (VaultTab::Contact, "聯絡資訊", Some(Kind::Contact))] { + let on = self.vault.tab == tab; + let text = format!("{} {}", t(label), count(k)); + let b = egui::Button::new(RichText::new(text).size(FONT_SM).color(if on { p.text } else { p.text2 })).fill(if on { p.active } else { Color32::TRANSPARENT }).corner_radius(8).min_size(vec2(0.0, 28.0)); + if ui.add(b).clicked() { + self.vault.tab = tab; + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(8, 4)).show(ui, |ui| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(14.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0)), Icon::Search, p.text3); + let resp = ui.add(egui::TextEdit::singleline(&mut self.vault.search).hint_text(t("搜尋保險庫")).frame(Frame::NONE).desired_width(150.0)); + if resp.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) { + self.vault.search.clear(); + } + if !self.vault.search.is_empty() && ui.add(egui::Button::new(RichText::new("×").color(p.text3)).frame(false)).on_hover_text(t("清除搜尋")).clicked() { + self.vault.search.clear(); + } + }); + }); + }); + }); + ui.add_space(8.0); + let q = self.vault.search.trim().to_lowercase(); + let want = |i: &&Item| match self.vault.tab { + VaultTab::All => true, + VaultTab::Login => i.kind == Kind::Login, + VaultTab::Card => i.kind == Kind::Card, + VaultTab::Contact => i.kind == Kind::Contact, + }; + let shown: Vec<&Item> = items.iter().filter(want).filter(|i| q.is_empty() || i.haystack().contains(&q)).collect(); + let mut open = None; + setting_card(ui, p, |ui| { + if shown.is_empty() { + let text = if !q.is_empty() { + format!("找不到與「{}」相符的結果", self.vault.search.trim()) + } else { + match self.vault.tab { + VaultTab::All => "尚未儲存任何內容。透過新項目新增登入資訊、卡片或聯絡資訊。", + VaultTab::Login => "尚無登入資訊。透過新項目新增一筆,或從 CSV 匯入。", + VaultTab::Card => "尚無已儲存的卡片。透過新項目新增一張。", + VaultTab::Contact => "還沒有聯絡資訊。透過新項目新增。", + } + .to_string() + }; + ui.add_space(6.0); + ui.label(RichText::new(t(&text)).color(p.text3).size(FONT_SM)); + ui.add_space(6.0); + return; + } + // While searching 「全部」, results are grouped by kind. + let grouped = !q.is_empty() && self.vault.tab == VaultTab::All; + let mut last_kind = None; + for it in shown { + if grouped && last_kind != Some(it.kind) { + ui.add_space(4.0); + ui.label(RichText::new(t(kind_label(it.kind))).size(FONT_XS).color(p.text3)); + last_kind = Some(it.kind); + } + if self.vault_row(ui, it, p).clicked() { + open = Some(it.clone()); + } + } + }); + if let Some(it) = open { + self.vault.dialog = Some(match it.kind { + Kind::Login => VaultDialog::ViewLogin(it.id.clone()), + Kind::Card => VaultDialog::Card(CardForm { + id: Some(it.id.clone()), + expiry: format!("{:02}/{:02}", it.exp_month, it.exp_year % 100), + holder: it.holder.clone(), + nickname: it.nickname.clone(), + billing_from: if it.billing.is_empty() { String::new() } else { "new".into() }, + billing: it.billing.clone(), + notes: it.notes.clone(), + locked: format!("{} •••• {}", it.brand, it.last4), + ..Default::default() + }), + Kind::Contact => VaultDialog::Contact(ContactForm { + id: Some(it.id.clone()), + label: it.title.clone(), + name: it.name.clone(), + address: it.address.clone(), + phones: if it.phones.is_empty() { vec![String::new()] } else { it.phones.clone() }, + emails: if it.emails.is_empty() { vec![String::new()] } else { it.emails.clone() }, + notes: it.notes.clone(), + ..Default::default() + }), + }); + } + ui.add_space(8.0); + let has_cards = items.iter().any(|i| i.kind == Kind::Card); + hint( + ui, + if has_cards { "Bot 絕對看不到已儲存卡片的卡號或安全碼。它可以看到登入資訊和聯絡資訊,以便替你填寫。" } else { "Bot 可以看到登入資訊和聯絡資訊,以便替你填寫" }, + p, + ); + hint(ui, "每次填入前都會先問你。資料加密存在這台電腦上,金鑰放在系統鑰匙圈。登入時保險庫沒有的網站,也會找 macOS 鑰匙圈和 1Password(裝了 op 指令並登入時)。", p); + if !self.vault.note.is_empty() { + ui.label(RichText::new(&self.vault.note).size(FONT_XS).color(p.text2)); + } + } + + fn vault_row(&self, ui: &mut egui::Ui, it: &Item, p: Palette) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 48.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 10, p.hover); + } + let tile = egui::Rect::from_min_size(rect.left_center() + vec2(6.0, -16.0), vec2(32.0, 32.0)); + ui.painter().rect_filled(tile, 8, p.active); + crate::icons::paint(ui.painter(), tile.shrink(8.0), kind_icon(it.kind), p.text2); + let title = shorten_width(ui, &it.display_title(), rect.width() - 90.0, FONT_SM); + let sub = if it.subtitle().is_empty() && !it.notes.is_empty() { format!("{}{}", t("提示:"), it.notes) } else { it.subtitle() }; + let sub = shorten_width(ui, &sub, rect.width() - 90.0, FONT_XS); + let x = tile.right() + 12.0; + ui.painter().text(egui::pos2(x, rect.center().y - 9.0), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(FONT_SM), p.text); + ui.painter().text(egui::pos2(x, rect.center().y + 10.0), egui::Align2::LEFT_CENTER, sub, egui::FontId::proportional(FONT_XS), p.text3); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(14.0, 14.0)), Icon::ChevronRight, p.text3); + r.on_hover_cursor(egui::CursorIcon::PointingHand) + } + + /// The open vault dialog, if any (called every frame from the settings window). + pub(super) fn vault_dialogs(&mut self, ctx: &egui::Context) { + let p = self.pal; + let Some(d) = self.vault.dialog.take() else { return }; + let items = self.vault_items(); + let mut next: Option = None; + let mut saved = false; + match d { + VaultDialog::ViewLogin(id) => { + let Some(it) = items.iter().find(|i| i.id == id).cloned() else { return }; + let title = it.sites.first().map(|s| store::registrable_domain(&store::host_of(s))).unwrap_or_else(|| it.display_title()); + let mut keep = true; + let close = dialog(ctx, "vault-view", &title, 440.0, true, p, |ui| { + dialog_body(ui, "vault-view-body", dialog_max_height(ctx) - 120.0, |ui| { + field_label(ui, "網站", p); + ui.horizontal(|ui| { + ui.label(it.sites.join("、")); + if let Some(first) = it.sites.first() { + if ui.add(egui::Button::new(RichText::new(t("開啟")).size(FONT_XS)).corner_radius(6)).on_hover_text(format!("{} {}", t("開啟"), first)).clicked() { + let url = if first.contains("://") { first.clone() } else { format!("https://{first}") }; + let _ = crate::preview::system_open(&url).spawn(); + } + } + }); + if !it.username.is_empty() { + field_label(ui, "使用者名稱", p); + ui.label(&it.username); + } + field_label(ui, "密碼", p); + ui.label("••••••••••••••").on_hover_text(t("已儲存的密碼,已隱藏")); + if !it.notes.is_empty() { + field_label(ui, "提示", p); + ui.label(&it.notes); + } + }); + dialog_footer(ui, p, |ui| { + if ui.add(danger_button("移除", p)).clicked() { + next = Some(VaultDialog::Remove(it.id.clone(), it.display_title(), Kind::Login)); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(primary_button("完成", p)).clicked() { + keep = false; + } + if ui.add(secondary_button("編輯")).clicked() { + next = Some(VaultDialog::Login(LoginForm { id: Some(it.id.clone()), sites: it.sites.clone(), username: it.username.clone(), notes: it.notes.clone(), ..Default::default() })); + } + }); + }); + }); + if next.is_none() && keep && !close { + next = Some(VaultDialog::ViewLogin(id)); + } + } + VaultDialog::Login(mut f) => { + let title = match &f.id { + Some(_) => format!("{} {} {}", t("編輯"), f.sites.first().map(|s| store::registrable_domain(&store::host_of(s))).unwrap_or_default(), t("的登入資訊")), + None => t("新登入資訊"), + }; + let mut keep = true; + let close = dialog(ctx, "vault-login", &title, 480.0, false, p, |ui| { + dialog_body(ui, "vault-login-body", dialog_max_height(ctx) - 120.0, |ui| { + field_label(ui, "網站", p); + let mut drop = None; + let n = f.sites.len(); + for (i, s) in f.sites.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(s).hint_text("example.com").desired_width(ui.available_width() - if n > 1 { 34.0 } else { 0.0 })); + if n > 1 && ui.add(egui::Button::new("×").frame(false)).on_hover_text(t("移除")).clicked() { + drop = Some(i); + } + }); + let d = store::registrable_domain(&store::host_of(s)); + if !d.is_empty() { + ui.label(RichText::new(format!("在 {d} 及其子網域上填入")).size(FONT_XS).color(p.text3)); + } + } + if let Some(i) = drop { + f.sites.remove(i); + } + if f.sites.len() < store::MAX_SITES && ui.add(egui::Button::new(RichText::new(t("新增網站")).size(FONT_XS)).frame(false)).clicked() { + f.sites.push(String::new()); + } + field_label(ui, "使用者名稱或電子郵件(選填)", p); + ui.add(egui::TextEdit::singleline(&mut f.username).hint_text("name@example.com").desired_width(f32::INFINITY)); + field_label(ui, "密碼", p); + ui.add(egui::TextEdit::singleline(&mut f.password).password(true).hint_text(if f.id.is_some() { "••••••••••••••(留空就不變)" } else { "" }).desired_width(f32::INFINITY)); + field_label(ui, "提示(選填)", p); + ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY)); + if f.id.is_none() { + let host = f.sites.first().map(|s| store::host_of(s)).unwrap_or_default(); + hint(ui, if host.is_empty() { t("Bot 可以使用此登入資訊替你登入") } else { format!("Bot 可以使用此登入資訊替你登入 {host}") }, p); + } + if !f.err.is_empty() { + ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger)); + } + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ok = f.sites.iter().any(|s| !s.trim().is_empty()) && (f.id.is_some() || !f.password.is_empty()); + if ui.add_enabled(ok, primary_button("儲存登入資訊", p)).clicked() { + let r = if self.demo { Ok(String::new()) } else { store::put_login(store::LoginInput { id: f.id.clone(), sites: f.sites.clone(), username: f.username.clone(), password: f.password.clone(), notes: f.notes.clone() }) }; + match r { + Ok(_) => { + keep = false; + saved = true; + } + Err(e) => f.err = e, + } + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if keep && !close { + next = Some(VaultDialog::Login(f)); + } + } + VaultDialog::Card(mut f) => { + let editing = f.id.is_some(); + let title = if editing { format!("{} {}", t("編輯"), f.locked) } else { t("新卡片") }; + let contacts: Vec = items.iter().filter(|i| i.kind == Kind::Contact && !i.address.is_empty()).cloned().collect(); + let mut keep = true; + let close = dialog(ctx, "vault-card", &title, 480.0, false, p, |ui| { + dialog_body(ui, "vault-card-body", dialog_max_height(ctx) - 120.0, |ui| { + field_label(ui, "卡號", p); + if editing { + ui.horizontal(|ui| { + crate::icons::label(ui, Icon::Lock, &f.locked, 13.0, p.text2); + ui.label(RichText::new(t("已鎖定")).size(FONT_XS).color(p.text3)); + }); + ui.label(RichText::new(t("若要變更卡號,請移除此卡片後重新新增")).size(FONT_XS).color(p.text3)); + } else { + ui.add(egui::TextEdit::singleline(&mut f.number).hint_text("1234 5678 9012 3456").desired_width(f32::INFINITY)); + let d = store::digits(&f.number); + if d.len() >= 12 { + let (msg, col) = if store::luhn_ok(&d) { (store::card_brand(&d).to_string(), p.text3) } else { (t("請輸入有效的卡號"), p.danger) }; + ui.label(RichText::new(msg).size(FONT_XS).color(col)); + } + } + ui.horizontal(|ui| { + ui.vertical(|ui| { + field_label(ui, "有效期限", p); + ui.add(egui::TextEdit::singleline(&mut f.expiry).hint_text(t("月 / 年")).desired_width(110.0)); + }); + if !editing { + ui.vertical(|ui| { + field_label(ui, "安全碼(選填)", p); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut f.csc).password(!f.show_csc).hint_text(t("3 或 4 位數字")).desired_width(110.0)); + let icon = if f.show_csc { Icon::EyeOff } else { Icon::Eye }; + if crate::icons::button(ui, icon, 16.0, p.text2, p.hover).on_hover_text(t(if f.show_csc { "隱藏安全碼" } else { "顯示安全碼" })).clicked() { + f.show_csc = !f.show_csc; + } + }); + }); + } + }); + field_label(ui, "持卡人姓名(選填)", p); + ui.add(egui::TextEdit::singleline(&mut f.holder).char_limit(store::MAX_TITLE).desired_width(f32::INFINITY)); + field_label(ui, "暱稱(選填)", p); + ui.add(egui::TextEdit::singleline(&mut f.nickname).hint_text(t("例如:旅行")).char_limit(store::MAX_TITLE).desired_width(f32::INFINITY)); + field_label(ui, "帳單地址", p); + let current = if f.billing_from.is_empty() { t("無帳單地址") } else if f.billing_from == "new" { t("輸入地址") } else { contacts.iter().find(|c| c.id == f.billing_from).map(|c| c.display_title()).unwrap_or_default() }; + egui::ComboBox::from_id_salt("vault-billing").selected_text(current).width(240.0).show_ui(ui, |ui| { + ui.selectable_value(&mut f.billing_from, String::new(), t("無帳單地址")); + for c in &contacts { + if ui.selectable_label(f.billing_from == c.id, format!("{} · {}", c.display_title(), c.address.one_line())).clicked() { + f.billing_from = c.id.clone(); + f.billing = c.address.clone(); + } + } + if ui.selectable_label(f.billing_from == "new", t("新增帳單地址")).clicked() { + f.billing_from = "new".into(); + } + }); + if f.billing_from == "new" { + address_fields(ui, &mut f.billing, p); + } + field_label(ui, "提示(選填)", p); + ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY)); + hint(ui, "Bot 永遠不會看到卡號和安全碼。每次付款填卡前都會問你。", p); + if !f.err.is_empty() { + ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger)); + } + }); + dialog_footer(ui, p, |ui| { + if let Some(id) = f.id.clone() { + if ui.add(danger_button("移除", p)).clicked() { + next = Some(VaultDialog::Remove(id, f.locked.clone(), Kind::Card)); + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(primary_button("儲存卡片", p)).clicked() { + let billing = if f.billing_from.is_empty() { Address::default() } else { f.billing.clone() }; + let r = if self.demo { + Ok(String::new()) + } else { + store::put_card(store::CardInput { id: f.id.clone(), number: f.number.clone(), expiry: f.expiry.clone(), csc: f.csc.clone(), holder: f.holder.clone(), nickname: f.nickname.clone(), billing, notes: f.notes.clone() }) + }; + match r { + Ok(_) => { + keep = false; + saved = true; + } + Err(e) => f.err = e, + } + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if next.is_none() && keep && !close { + next = Some(VaultDialog::Card(f)); + } + } + VaultDialog::Contact(mut f) => { + let title = if f.id.is_some() { t("編輯聯絡資訊") } else { t("新增聯絡資訊") }; + let mut keep = true; + let close = dialog(ctx, "vault-contact", &title, 500.0, false, p, |ui| { + dialog_body(ui, "vault-contact-body", dialog_max_height(ctx) - 120.0, |ui| { + field_label(ui, "姓名", p); + ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(f32::INFINITY)); + address_fields(ui, &mut f.address, p); + rows_editor(ui, "手機", "新增手機號碼", &mut f.phones, store::valid_phone, "請輸入有效的電話號碼", p); + rows_editor(ui, "電子郵件地址", "新增電子郵件", &mut f.emails, store::valid_email, "請輸入有效的電子郵件地址", p); + field_label(ui, "標籤(選填)", p); + ui.add(egui::TextEdit::singleline(&mut f.label).hint_text(t("例如:住家、公司")).desired_width(f32::INFINITY)); + field_label(ui, "提示(選填)", p); + ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY)); + if !f.err.is_empty() { + ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger)); + } + }); + dialog_footer(ui, p, |ui| { + if let Some(id) = f.id.clone() { + if ui.add(danger_button("移除", p)).clicked() { + let name = if f.label.trim().is_empty() { f.name.clone() } else { f.label.clone() }; + next = Some(VaultDialog::Remove(id, name, Kind::Contact)); + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(primary_button("儲存聯絡資訊", p)).clicked() { + let r = if self.demo { + Ok(String::new()) + } else { + store::put_contact(store::ContactInput { id: f.id.clone(), label: f.label.clone(), name: f.name.clone(), address: f.address.clone(), phones: f.phones.clone(), emails: f.emails.clone(), notes: f.notes.clone() }) + }; + match r { + Ok(_) => { + keep = false; + saved = true; + } + Err(e) => f.err = e, + } + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if next.is_none() && keep && !close { + next = Some(VaultDialog::Contact(f)); + } + } + VaultDialog::Remove(id, name, kind) => { + let (q, body) = match kind { + Kind::Login => (t("要移除這筆登入資訊嗎?"), format!("Bot 將無法再用它登入 {name}")), + Kind::Card => (format!("要移除「{name}」嗎?"), t("Bot 將無法再使用這張卡片付款")), + Kind::Contact => (format!("要移除「{name}」嗎?"), t("Bot 將無法再使用此聯絡資訊")), + }; + let mut keep = true; + let close = dialog(ctx, "vault-remove", &q, 420.0, true, p, |ui| { + dialog_body(ui, "vault-remove-body", 200.0, |ui| { + ui.label(body); + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(danger_button("移除", p)).clicked() { + if !self.demo { + if let Err(e) = store::remove(&id) { + self.vault.note = format!("{}:{e}", t("無法移除。請重試。")); + } + } + keep = false; + saved = true; + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + if keep && !close { + next = Some(VaultDialog::Remove(id, name, kind)); + } + } + VaultDialog::Import(mut st) => { + if self.vault_import(ctx, &mut st) { + next = Some(VaultDialog::Import(st)); + } + saved = true; + } + } + self.vault.dialog = next; + if saved { + self.vault_refresh(); + } + } + + /// 從 CSV 匯入. Returns false when the dialog should close. + fn vault_import(&mut self, ctx: &egui::Context, st: &mut ImportState) -> bool { + let p = self.pal; + // A file was picked (the picker runs off the window's thread). + if let Some(slot) = st.picking.clone() { + if let Some(files) = slot.lock().unwrap().take() { + st.picking = None; + if let Some(f) = files.into_iter().next() { + st.file = f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + st.err.clear(); + st.plan = None; + match std::fs::metadata(&f).map(|m| m.len()) { + Ok(n) if n > store::MAX_IMPORT_BYTES => st.err = t("該檔案太大。請選擇小於 5 MB 的 CSV 檔案。"), + Ok(_) => match std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()) { + Ok(text) => match store::plan_import(&text) { + Ok(plan) => st.plan = Some(plan), + Err(e) => st.err = e, + }, + Err(_) => st.err = t("無法讀取該檔案。請再試一次"), + }, + Err(_) => st.err = t("無法讀取該檔案。請再試一次"), + } + } + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + } + // Saving: a few rows per frame so the progress bar moves and 停止匯入 works. + if !st.queue.is_empty() { + if st.stop { + let left = st.queue.len(); + st.queue.clear(); + if let Some(d) = st.done.as_mut() { + d.2.push((String::new(), format!("匯入在所有項目儲存完成前就已停止({left} 個沒有匯入)"))); + } + } else if let Some(plan) = st.plan.as_ref() { + let take: Vec = st.queue.drain(..st.queue.len().min(20)).collect(); + for i in take { + let row = &plan.rows[i]; + let d = st.done.get_or_insert((0, 0, vec![])); + match if self.demo { Ok(()) } else { store::import_row(row) } { + Ok(()) if row.kind == Kind::Card => d.1 += 1, + Ok(()) => d.0 += 1, + Err(e) => d.2.push((row.label.clone(), e)), + } + } + ctx.request_repaint(); + } + } + let mut keep = true; + let close = dialog(ctx, "vault-import", "從 CSV 匯入", 560.0, false, p, |ui| { + dialog_body(ui, "vault-import-body", dialog_max_height(ctx) - 140.0, |ui| { + let importing = !st.queue.is_empty(); + if let (Some((logins, cards, fails)), false) = (&st.done, importing) { + // Summary. + let line = if *cards > 0 { format!("已匯入 {logins} 筆登入資訊和 {cards} 張卡片") } else { format!("已匯入 {logins} 筆登入資訊") }; + crate::icons::label(ui, Icon::CircleCheck, &line, 14.0, p.text); + if let Some(plan) = &st.plan { + let dup = plan.count(|s| *s == RowStatus::AlreadySaved); + if dup > 0 { + ui.label(RichText::new(format!("已略過保險庫中已有的 {dup} 個項目")).size(FONT_SM).color(p.text2)); + } + } + if !fails.is_empty() { + ui.label(RichText::new(format!("{} 個項目無法匯入", fails.len())).size(FONT_SM).color(p.danger)); + for (name, e) in fails.iter().take(30) { + ui.label(RichText::new(if name.is_empty() { e.clone() } else { format!("{name}:{e}") }).size(FONT_XS).color(p.text3)); + } + } + return; + } + if importing { + let done = st.total - st.queue.len(); + ui.label(format!("正在匯入第 {done} 個,共 {} 個…", st.total)); + ui.add(egui::ProgressBar::new(done as f32 / st.total.max(1) as f32)); + return; + } + match &st.plan { + None => { + ui.label(t("從 1Password、Chrome、Edge、Safari、Apple Passwords、Firefox、Bitwarden 或 LastPass 匯出的 CSV 檔案匯入登入資訊")); + ui.label(RichText::new(t("從 LastPass 匯出的檔案會一併匯入卡片")).size(FONT_XS).color(p.text3)); + ui.label(RichText::new(t("檔案只會在這部裝置上讀取,絕不會上傳")).size(FONT_XS).color(p.text3)); + if st.picking.is_some() { + ui.label(RichText::new(t("正在等你選檔案…")).size(FONT_XS).color(p.text3)); + } + } + Some(plan) => { + crate::icons::label(ui, Icon::FileText, &format!("{}:{}", t(plan.format.banner()), st.file), 13.0, p.text2); + ui.add_space(4.0); + let ready = plan.count(|s| *s == RowStatus::Ready); + let dup = plan.count(|s| *s == RowStatus::AlreadySaved); + let bad = plan.count(|s| matches!(s, RowStatus::Error(_))); + let skip = plan.count(|s| matches!(s, RowStatus::Skipped(_))); + ui.label(RichText::new(format!("{ready} 個項目已就緒 · {dup} 個項目已在你的保險庫中 · {bad} 個項目有錯誤 · 已略過 {skip} 個項目")).size(FONT_SM)); + ui.add_space(6.0); + ui.label(RichText::new(t("此檔案中的項目")).size(FONT_XS).color(p.text3)); + for r in plan.rows.iter().take(300) { + ui.horizontal(|ui| { + let (rr, _) = ui.allocate_exact_size(vec2(16.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), rr.shrink(2.0), kind_icon(r.kind), p.text3); + ui.label(RichText::new(&r.label).size(FONT_SM)); + ui.label(RichText::new(&r.secondary).size(FONT_XS).color(p.text3)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (s, c) = match &r.status { + RowStatus::Ready => (t("已就緒"), p.text3), + RowStatus::AlreadySaved => (t("已在你的保險庫中"), p.text3), + RowStatus::Error(e) => (t(e), p.danger), + RowStatus::Skipped(e) => (t(e), p.text3), + }; + ui.label(RichText::new(s).size(FONT_XS).color(c)); + }); + }); + } + if plan.rows.len() > 300 { + ui.label(RichText::new(format!("…還有 {} 列", plan.rows.len() - 300)).size(FONT_XS).color(p.text3)); + } + } + } + if !st.err.is_empty() { + ui.label(RichText::new(&st.err).size(FONT_XS).color(p.danger)); + } + }); + dialog_footer(ui, p, |ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if !st.queue.is_empty() { + if ui.add(secondary_button("停止匯入")).clicked() { + st.stop = true; + } + return; + } + if st.done.is_some() { + if ui.add(primary_button("完成", p)).clicked() { + keep = false; + } + return; + } + let ready: Vec = st.plan.as_ref().map(|pl| pl.rows.iter().enumerate().filter(|(_, r)| r.status == RowStatus::Ready).map(|(i, _)| i).collect()).unwrap_or_default(); + if st.plan.is_some() && ui.add_enabled(!ready.is_empty(), primary_button(&format!("匯入 {} 個項目", ready.len()), p)).clicked() { + st.total = ready.len(); + st.queue = ready; + st.done = Some((0, 0, vec![])); + } + let pick = if st.plan.is_some() { "選擇其他檔案" } else { "選擇 CSV 檔案" }; + if ui.add_enabled(st.picking.is_none(), if st.plan.is_some() { secondary_button(pick) } else { primary_button(pick, p) }).clicked() { + let slot = Arc::new(Mutex::new(None)); + st.picking = Some(slot.clone()); + std::thread::spawn(move || { + let files = crate::attach::pick_files(); + *slot.lock().unwrap() = Some(files); + }); + } + if ui.add(secondary_button("取消")).clicked() { + keep = false; + } + }); + }); + }); + keep && !close + } +} + +/// The six address fields. +fn address_fields(ui: &mut egui::Ui, a: &mut Address, p: Palette) { + field_label(ui, "街道地址", p); + ui.add(egui::TextEdit::singleline(&mut a.line1).desired_width(f32::INFINITY)); + field_label(ui, "公寓、套房或單位(選填)", p); + ui.add(egui::TextEdit::singleline(&mut a.line2).desired_width(f32::INFINITY)); + ui.horizontal(|ui| { + let w = (ui.available_width() - 8.0) / 2.0; + ui.vertical(|ui| { + field_label(ui, "城市", p); + ui.add(egui::TextEdit::singleline(&mut a.city).desired_width(w)); + }); + ui.vertical(|ui| { + field_label(ui, "州/省/地區", p); + ui.add(egui::TextEdit::singleline(&mut a.region).desired_width(w)); + }); + }); + ui.horizontal(|ui| { + let w = (ui.available_width() - 8.0) / 2.0; + ui.vertical(|ui| { + field_label(ui, "郵遞區號", p); + ui.add(egui::TextEdit::singleline(&mut a.postal).desired_width(w)); + }); + ui.vertical(|ui| { + field_label(ui, "國家/地區", p); + ui.add(egui::TextEdit::singleline(&mut a.country).desired_width(w)); + }); + }); +} + +/// Phone or email rows: the first is the main one; others can become it or be removed. +fn rows_editor(ui: &mut egui::Ui, label: &str, add: &str, rows: &mut Vec, valid: fn(&str) -> bool, bad: &str, p: Palette) { + if rows.is_empty() { + rows.push(String::new()); + } + let mut drop = None; + let mut main = None; + for i in 0..rows.len() { + field_label(ui, &if i == 0 { t(label) } else { format!("{} {}", t(label), i + 1) }, p); + ui.horizontal(|ui| { + let extra = if i > 0 { 110.0 } else { 0.0 }; + ui.add(egui::TextEdit::singleline(&mut rows[i]).desired_width(ui.available_width() - extra)); + if i > 0 { + if ui.add(egui::Button::new(RichText::new(t("設為主要")).size(FONT_XS)).frame(false)).clicked() { + main = Some(i); + } + if ui.add(egui::Button::new("×").frame(false)).on_hover_text(t("移除")).clicked() { + drop = Some(i); + } + } + }); + let v = rows[i].trim(); + if !v.is_empty() && !valid(v) { + ui.label(RichText::new(t(bad)).size(FONT_XS).color(p.danger)); + } else if !v.is_empty() && rows[..i].iter().any(|r| r.trim() == v) { + ui.label(RichText::new(t("上方已列出")).size(FONT_XS).color(p.text3)); + } + } + if let Some(i) = main { + let v = rows.remove(i); + rows.insert(0, v); + } + if let Some(i) = drop { + rows.remove(i); + } + if rows.len() < store::MAX_ROWS && ui.add(egui::Button::new(RichText::new(t(add)).size(FONT_XS)).frame(false)).clicked() { + rows.push(String::new()); + } +} + +/// Screenshot mode: a few made-up items. +fn demo_items() -> Vec { + vec![ + Item { id: "d1".into(), kind: Kind::Login, title: "github.com".into(), sites: vec!["github.com".into()], username: "me@example.com".into(), notes: "工作用的帳號".into(), ..Default::default() }, + Item { id: "d2".into(), kind: Kind::Login, title: "momoshop.com.tw".into(), sites: vec!["momoshop.com.tw".into()], username: "0912xxx678".into(), ..Default::default() }, + Item { id: "d3".into(), kind: Kind::Card, title: "Visa".into(), brand: "Visa".into(), last4: "4242".into(), nickname: "旅行".into(), exp_month: 8, exp_year: 2029, ..Default::default() }, + Item { id: "d4".into(), kind: Kind::Contact, title: "住家".into(), name: "王小明".into(), address: Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), country: "台灣".into(), ..Default::default() }, phones: vec!["0912-345-678".into()], ..Default::default() }, + ] +} diff --git a/src/appctl.rs b/src/appctl.rs new file mode 100644 index 0000000..683bf17 --- /dev/null +++ b/src/appctl.rs @@ -0,0 +1,645 @@ +// Tools that let a bot run the app itself: set up routines, manage bots, talk to other bots, +// change settings and list computers. In the original, routines are made by asking in chat +// ("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。", ZJmAzX) and the primary bot +// "也能和其他 Bot 協調工作" (mQQ/Uq). These run on the window's thread because they change the app. + +use serde_json::{json, Value}; + +use crate::model::{self, Bot, Message, State, Theme, Accent}; +use crate::providers::{ProviderKind, ALL_PROVIDERS}; +use crate::routines::{self, Routine, Schedule}; + +pub const NAMES: [&str; 20] = [ + "list_routines", "create_routine", "update_routine", "delete_routine", "run_routine_now", + "list_bots", "create_bot", "update_bot", "message_bot", "list_computers", "update_settings", + "remember", "forget", "save_skill", "use_skill", "delete_skill", + "create_group", "move_to_group", "rename_group", "delete_group", +]; + +pub fn is_app_tool(name: &str) -> bool { + NAMES.contains(&name) +} + +pub fn label(name: &str) -> &'static str { + match name { + "list_routines" => "查看例行任務", + "create_routine" => "建立例行任務", + "update_routine" => "修改例行任務", + "delete_routine" => "刪除例行任務", + "run_routine_now" => "立即執行例行任務", + "list_bots" => "查看 Bot", + "create_bot" => "建立 Bot", + "update_bot" => "修改 Bot", + "message_bot" => "傳訊息給其他 Bot", + "list_computers" => "查看電腦", + "update_settings" => "修改設定", + "remember" => "記住", + "forget" => "忘記", + "save_skill" => "存成技能", + "use_skill" => "讀取技能", + "delete_skill" => "刪除技能", + "create_group" => "建立群組", + "move_to_group" => "移到群組", + "rename_group" => "重新命名群組", + "delete_group" => "刪除群組", + _ => "App 操作", + } +} + +fn schedule_schema() -> Value { + json!({ + "type": "object", + "description": "When it repeats, in the person's words turned into fields.", + "properties": { + "frequency": {"type": "string", "enum": ["minutely", "hourly", "daily", "weekly", "monthly", "yearly"]}, + "interval": {"type": "integer", "description": "Every N minutes (at least 5)/hours/days/weeks/months/years (default 1)."}, + "once": {"type": "boolean", "description": "僅一次: run once at the next matching time, then stop."}, + "flexible": {"type": "boolean", "description": "「左右」: the exact minute doesn't matter (runs within ±15 minutes)."}, + "daysOfWeek": {"type": "array", "items": {"type": "string", "enum": routines::WEEKDAYS}, "description": "For weekly. Workdays = MO..FR."}, + "monthDay": {"type": "integer", "description": "Day of the month (monthly/yearly)."}, + "weekdayOfMonth": {"type": "object", "properties": {"week": {"type": "integer", "description": "1-4, or -1 for the last"}, "day": {"type": "string", "enum": routines::WEEKDAYS}}}, + "month": {"type": "integer", "description": "1-12 (yearly)."}, + "time": {"type": "string", "description": "24-hour HH:MM (hourly: only the minutes count; minutely: ignored, use 00:00)."}, + "timezone": {"type": "string", "description": "IANA name; leave empty for the person's time zone."} + }, + "required": ["frequency", "time"] + }) +} + +pub fn specs() -> Vec { + vec![ + json!({"name": "list_routines", "description": "List this bot's routines (scheduled, repeating tasks).", "parameters": {"type": "object", "properties": {}}}), + json!({"name": "remember", "description": "Keep a fact or preference the person wants you to remember across chats.", "parameters": {"type": "object", "properties": {"fact": {"type": "string"}}, "required": ["fact"]}}), + json!({"name": "forget", "description": "Forget a remembered fact (its number or words in it).", "parameters": {"type": "object", "properties": {"what": {"type": "string"}}, "required": ["what"]}}), + json!({"name": "save_skill", "description": "Save a reusable procedure as a skill every bot can use (replaces one with the same name).", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string", "description": "One line: when to use it"}, "instructions": {"type": "string", "description": "The full steps"}}, "required": ["name", "description", "instructions"]}}), + json!({"name": "use_skill", "description": "Load a skill's full instructions before doing that kind of task.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}), + json!({"name": "delete_skill", "description": "Delete a skill.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}), + json!({"name": "create_routine", "description": "Set up a routine when the person wants something done on a schedule (every morning, every Monday, on the 1st…) or when something happens (a PR is merged, CI fails, a Slack message mentions a word, a Sentry/PagerDuty/Linear change, a new email, a webhook call): give schedule or trigger. The prompt is what you will be asked each time it runs.", + "parameters": {"type": "object", "properties": { + "title": {"type": "string"}, "prompt": {"type": "string"}, "schedule": schedule_schema(), + "trigger": crate::triggers::schema(), + "computer": {"type": "string", "description": "Computer to work on when it runs (optional)."}}, "required": ["title", "prompt"]}}), + json!({"name": "update_routine", "description": "Change, pause or resume a routine.", + "parameters": {"type": "object", "properties": { + "id": {"type": "integer"}, "title": {"type": "string"}, "prompt": {"type": "string"}, "schedule": schedule_schema(), "trigger": crate::triggers::schema(), "paused": {"type": "boolean"}}, "required": ["id"]}}), + json!({"name": "delete_routine", "description": "Delete a routine. The person is asked first.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}), + json!({"name": "run_routine_now", "description": "Run a routine once right away.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}), + json!({"name": "list_bots", "description": "List the person's bots with their provider, model and what they do.", "parameters": {"type": "object", "properties": {}}}), + json!({"name": "create_bot", "description": "Create a new bot, optionally from a template (a ready role and instructions).", + "parameters": {"type": "object", "properties": { + "template": {"type": "string", "enum": crate::memory::TEMPLATES.iter().map(|t| t.name).collect::>()}, + "name": {"type": "string"}, "description": {"type": "string"}, "instructions": {"type": "string"}, + "provider": {"type": "string", "enum": ALL_PROVIDERS.iter().map(|p| p.key()).collect::>()}, "model": {"type": "string"}}, "required": []}}), + json!({"name": "update_bot", "description": "Change a bot (yourself when bot is empty): name, description, instructions, provider, model, pin, default computer.", + "parameters": {"type": "object", "properties": { + "bot": {"type": "string", "description": "Bot name; empty = yourself."}, + "name": {"type": "string"}, "description": {"type": "string"}, "instructions": {"type": "string"}, + "provider": {"type": "string", "enum": ALL_PROVIDERS.iter().map(|p| p.key()).collect::>()}, "model": {"type": "string"}, + "pinned": {"type": "boolean"}, "default_computer": {"type": "string"}}}}), + json!({"name": "create_group", "description": "Make a group (a folder in the sidebar that holds bots, e.g. 文宣組). Optionally put bots in it right away.", + "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "bots": {"type": "array", "items": {"type": "string"}, "description": "Bot names to move into it"}}, "required": ["name"]}}), + json!({"name": "move_to_group", "description": "Move bots into a sidebar group (made if it doesn't exist). An empty group name moves them back to 未分區.", + "parameters": {"type": "object", "properties": {"group": {"type": "string"}, "bots": {"type": "array", "items": {"type": "string"}}}, "required": ["group", "bots"]}}), + json!({"name": "rename_group", "description": "Rename a sidebar group.", "parameters": {"type": "object", "properties": {"group": {"type": "string"}, "new_name": {"type": "string"}}, "required": ["group", "new_name"]}}), + json!({"name": "delete_group", "description": "Delete a sidebar group; its bots move to 未分區 (nothing is deleted).", "parameters": {"type": "object", "properties": {"group": {"type": "string"}}, "required": ["group"]}}), + json!({"name": "message_bot", "description": "Hand work to another bot: sends it a message and wakes it up. Its answer appears in its own chat.", + "parameters": {"type": "object", "properties": {"bot": {"type": "string"}, "text": {"type": "string"}}, "required": ["bot", "text"]}}), + json!({"name": "list_computers", "description": "List the computers you can work on and whether each is available.", "parameters": {"type": "object", "properties": {}}}), + json!({"name": "update_settings", "description": "Change app settings: theme (light/dark/system), accent (bot_color/black), timezone (IANA). Running tasks on the person's own computer can only be turned on by the person.", + "parameters": {"type": "object", "properties": { + "theme": {"type": "string", "enum": ["light", "dark", "system"]}, "accent": {"type": "string", "enum": ["bot_color", "black"]}, "timezone": {"type": "string"}}}}), + ] +} + +/// One line for the chat row. +pub fn describe(name: &str, args: &str) -> String { + let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + match name { + "create_routine" | "update_routine" => { + let t = s("title"); + let when = v.get("schedule").and_then(|x| serde_json::from_value::(x.clone()).ok()).map(|x| routines::describe(&x)).unwrap_or_default(); + format!("{t} {when}").trim().to_string() + } + "delete_routine" | "run_routine_now" => format!("#{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), + "message_bot" => format!("給 {}:{}", s("bot"), s("text").chars().take(40).collect::()), + "create_bot" | "update_bot" => s("name").to_string() + &s("bot"), + _ => String::new(), + } +} + +/// App actions that need a yes from the person first (the original confirms deletions: +/// "這會刪除例行任務並停止其後續執行。此操作無法復原。", VAgxC4). +pub fn needs_approval(name: &str, args: &str, state: &State) -> Option { + let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); + if name == "delete_routine" { + let id = v.get("id").and_then(Value::as_u64).unwrap_or(0); + let title = state.bots.iter().flat_map(|b| b.routines.iter()).find(|r| r.id == id).map(|r| r.title.clone()).unwrap_or_else(|| format!("#{id}")); + return Some(format!("刪除例行任務:「{title}」。這會停止它之後的執行,無法復原。")); + } + None +} + +pub struct Outcome { + pub text: String, + pub ok: bool, + /// Bots to wake with a new turn (message_bot, run_routine_now). + pub wake: Vec<(u64, Message)>, + pub theme_changed: bool, +} + +fn ok(text: String) -> Outcome { + Outcome { text, ok: true, wake: vec![], theme_changed: false } +} + +fn err(text: String) -> Outcome { + Outcome { text, ok: false, wake: vec![], theme_changed: false } +} + +fn find_bot<'a>(state: &'a State, name: &str) -> Option<&'a Bot> { + let n = name.trim().to_lowercase(); + state.bots.iter().find(|b| b.name.to_lowercase() == n).or_else(|| state.bots.iter().find(|b| b.name.to_lowercase().contains(&n))) +} + +fn provider_of(key: &str) -> Option { + ALL_PROVIDERS.iter().copied().find(|p| p.key() == key) +} + +pub fn routine_line(r: &Routine) -> String { + let next = match (r.paused, r.next_run) { + (true, _) => "已暫停".to_string(), + (false, Some(t)) => format!("下次 {}", routines::describe_when(t, &r.schedule.timezone)), + _ => "沒有下次".to_string(), + }; + if let Some(t) = &r.trigger { + return format!("#{} 「{}」:事件觸發({}){}", r.id, r.title, t.describe(), if r.paused { " · 已暫停" } else { "" }); + } + format!("#{} 「{}」:{} · {}", r.id, r.title, routines::describe(&r.schedule), next) +} + +/// A routine firing, as the message that wakes the bot. +pub fn wake_message(r: &Routine) -> Message { + Message { wake: true, ..Message::user(&format!("[例行任務]「{}」時間到了:{}", r.title, r.prompt)) } +} + +pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome { + let v: Value = match serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }) { + Ok(v) => v, + Err(_) => return err("參數不是有效的 JSON".into()), + }; + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); + let now = routines::now(); + match name { + "list_routines" => { + let Some(b) = state.bot(bot_id) else { return err("找不到這個 Bot".into()) }; + if b.routines.is_empty() { + return ok("還沒有例行任務。".into()); + } + ok(b.routines.iter().map(routine_line).collect::>().join("\n")) + } + "create_routine" => { + let trigger = match v.get("trigger").filter(|t| !t.is_null()) { + Some(t) => match serde_json::from_value::(t.clone()).map_err(|e| e.to_string()).and_then(|t| t.validate().map(|_| t)) { + Ok(t) => Some(t), + Err(e) => return err(format!("觸發條件有問題:{e}")), + }, + None => None, + }; + let default_sched = json!({"frequency": "daily", "time": "09:00"}); + let raw = match (v.get("schedule"), &trigger) { + (Some(r), _) => r, + (None, Some(_)) => &default_sched, + (None, None) => return err("缺少 schedule(或用 trigger 設定事件觸發)".into()), + }; + let mut raw = raw.clone(); + if raw.get("timezone").and_then(Value::as_str).unwrap_or("").is_empty() { + raw["timezone"] = json!(state.timezone); + } + let sched = match serde_json::from_value::(raw).map_err(|e| e.to_string()).and_then(routines::validate) { + Ok(x) => x, + Err(e) => return err(format!("排程有問題:{e}")), + }; + let id = state.next_routine_id; + state.next_routine_id += 1; + let r = Routine { + id, + title: s("title").unwrap_or_else(|| "例行任務".into()), + prompt: s("prompt").unwrap_or_default(), + next_run: if trigger.is_some() { None } else { routines::next_run(&sched, now, now) }, + schedule: sched, + paused: false, + created_at: now, + last_run: None, + computer: s("computer").unwrap_or_default(), + trigger, + }; + let line = routine_line(&r); + match state.bot_mut(bot_id) { + Some(b) => b.routines.push(r), + None => return err("找不到這個 Bot".into()), + } + ok(format!("已建立例行任務 {line}")) + } + "update_routine" | "delete_routine" | "run_routine_now" => { + let id = v.get("id").and_then(Value::as_u64).unwrap_or(0); + let Some(owner) = state.routine_owner(id) else { return err(format!("找不到例行任務 #{id}")) }; + let tz = state.timezone.clone(); + let b = state.bot_mut(owner).unwrap(); + let idx = b.routines.iter().position(|r| r.id == id).unwrap(); + match name { + "delete_routine" => { + let r = b.routines.remove(idx); + ok(format!("已刪除例行任務「{}」", r.title)) + } + "run_routine_now" => { + let r = &mut b.routines[idx]; + r.last_run = Some(now); + let msg = wake_message(r); + Outcome { text: format!("已開始執行「{}」", r.title), ok: true, wake: vec![(owner, msg)], theme_changed: false } + } + _ => { + let r = &mut b.routines[idx]; + if let Some(t) = s("title") { + r.title = t; + } + if let Some(p) = s("prompt") { + r.prompt = p; + } + if let Some(raw) = v.get("schedule") { + let mut raw = raw.clone(); + if raw.get("timezone").and_then(Value::as_str).unwrap_or("").is_empty() { + raw["timezone"] = json!(tz); + } + match serde_json::from_value::(raw).map_err(|e| e.to_string()).and_then(routines::validate) { + Ok(x) => r.schedule = x, + Err(e) => return err(format!("排程有問題:{e}")), + } + } + if let Some(p) = v.get("paused").and_then(Value::as_bool) { + r.paused = p; + } + if let Some(t) = v.get("trigger") { + if t.is_null() { + r.trigger = None; + } else { + match serde_json::from_value::(t.clone()).map_err(|e| e.to_string()).and_then(|t| t.validate().map(|_| t)) { + Ok(t) => r.trigger = Some(t), + Err(e) => return err(format!("觸發條件有問題:{e}")), + } + } + } + r.next_run = if r.trigger.is_some() { None } else { routines::next_run(&r.schedule, r.created_at, now) }; + ok(format!("已更新 {}", routine_line(r))) + } + } + } + "create_group" | "move_to_group" => { + let group = s(if name == "create_group" { "name" } else { "group" }).unwrap_or_default().trim().to_string(); + if name == "create_group" && group.is_empty() { + return err("請給群組名稱".into()); + } + if !group.is_empty() && !state.folders.contains(&group) { + state.folders.push(group.clone()); + } + let names: Vec = v.get("bots").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(); + let mut moved = vec![]; + let mut missing = vec![]; + for n in names { + match find_bot(state, &n).map(|b| b.id) { + Some(id) => { + if let Some(b) = state.bot_mut(id) { + b.folder = group.clone(); + moved.push(b.name.clone()); + } + } + None => missing.push(n), + } + } + let mut text = if name == "create_group" { format!("已建立群組「{group}」。") } else { String::new() }; + if !moved.is_empty() { + text.push_str(&format!("已把 {} 移到{}。", moved.join("、"), if group.is_empty() { "未分區".to_string() } else { format!("群組「{group}」") })); + } + if !missing.is_empty() { + text.push_str(&format!("找不到:{}。", missing.join("、"))); + } + Outcome { text, ok: missing.is_empty() || !moved.is_empty(), wake: vec![], theme_changed: false } + } + "rename_group" => { + let (old, new) = (s("group").unwrap_or_default(), s("new_name").unwrap_or_default().trim().to_string()); + if new.is_empty() { + return err("請給新名稱".into()); + } + let exists = state.folders.contains(&old) || state.bots.iter().any(|b| b.folder == old); + if !exists { + return err(format!("沒有叫「{old}」的群組")); + } + for f in state.folders.iter_mut() { + if *f == old { + *f = new.clone(); + } + } + if !state.folders.contains(&new) { + state.folders.push(new.clone()); + } + for b in state.bots.iter_mut() { + if b.folder == old { + b.folder = new.clone(); + } + } + ok(format!("已把群組「{old}」改名為「{new}」。")) + } + "delete_group" => { + let g = s("group").unwrap_or_default(); + let n = state.bots.iter().filter(|b| b.folder == g).count(); + if !state.folders.contains(&g) && n == 0 { + return err(format!("沒有叫「{g}」的群組")); + } + state.folders.retain(|f| *f != g); + for b in state.bots.iter_mut() { + if b.folder == g { + b.folder.clear(); + } + } + ok(format!("已刪除群組「{g}」,裡面的 {n} 個 Bot 移到未分區。")) + } + "list_bots" => ok(state + .bots + .iter() + .map(|b| { + let mut line = format!("「{}」{}{} · {} · {}", b.name, if b.id == state.primary_bot { "(主要 Bot)" } else { "" }, if b.folder.is_empty() { String::new() } else { format!("[群組:{}]", b.folder) }, b.provider.label(), b.model); + if !b.description.is_empty() { + line.push_str(&format!(":{}", b.description)); + } + if b.id == bot_id { + line.push_str("(就是你)"); + } + line + }) + .collect::>() + .join("\n")), + "remember" => { + let Some(b) = state.bot_mut(bot_id) else { return err("找不到這個 Bot".into()) }; + match crate::memory::remember(&mut b.memories, &s("fact").unwrap_or_default()) { + Ok(t) => ok(t), + Err(e) => err(e), + } + } + "forget" => { + let Some(b) = state.bot_mut(bot_id) else { return err("找不到這個 Bot".into()) }; + match crate::memory::forget(&mut b.memories, &s("what").unwrap_or_default()) { + Ok(t) => ok(t), + Err(e) => err(e), + } + } + "save_skill" => { + let name = s("name").unwrap_or_default().trim().to_string(); + let instructions = s("instructions").unwrap_or_default(); + if name.is_empty() || instructions.trim().is_empty() { + return err("技能要有名稱和做法".into()); + } + let k = crate::memory::Skill { name: name.clone(), description: s("description").unwrap_or_default(), instructions }; + match state.skills.iter_mut().find(|x| x.name == name) { + Some(x) => *x = k, + None => state.skills.push(k), + } + ok(format!("已存成技能「{name}」,所有 Bot 都能用。")) + } + "use_skill" => { + let name = s("name").unwrap_or_default(); + match state.skills.iter().find(|k| k.name == name.trim()).or_else(|| state.skills.iter().find(|k| k.name.contains(name.trim()) && !name.trim().is_empty())) { + Some(k) => ok(format!("技能「{}」的做法:\n{}", k.name, k.instructions)), + None => err(format!("沒有叫「{name}」的技能。有:{}", state.skills.iter().map(|k| k.name.as_str()).collect::>().join("、"))), + } + } + "delete_skill" => { + let name = s("name").unwrap_or_default(); + let before = state.skills.len(); + state.skills.retain(|k| k.name != name.trim()); + if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) } + } + "create_bot" => { + // 範本: a ready role and instructions. + let template = s("template").and_then(|t| crate::memory::TEMPLATES.iter().find(|x| x.name == t.trim())); + let name = s("name").or_else(|| template.map(|t| t.name.to_string())).unwrap_or_else(|| "新 Bot".into()); + let base = state.bot(bot_id).cloned(); + let provider = s("provider").and_then(|p| provider_of(&p)).or(base.as_ref().map(|b| b.provider)).unwrap_or(ProviderKind::Xai); + let model = s("model").unwrap_or_else(|| if Some(provider) == base.as_ref().map(|b| b.provider) { base.as_ref().unwrap().model.clone() } else { provider.suggested_model().into() }); + let id = state.next_id; + state.next_id += 1; + let shape = crate::avatar::SHAPES[(id as usize) % crate::avatar::SHAPES.len()]; + let color = crate::avatar::COLORS[(id as usize) % crate::avatar::COLORS.len()].0; + state.bots.push(Bot { + id, + name: name.clone(), + description: s("description").or_else(|| template.map(|t| t.description.to_string())).unwrap_or_default(), + instructions: s("instructions").or_else(|| template.map(|t| t.instructions.to_string())).unwrap_or_default(), + shape: template.map(|t| t.shape).unwrap_or(shape).into(), + color: template.map(|t| t.color).unwrap_or(color).into(), + provider, + model: model.clone(), + pinned: false, + messages: vec![], + unread: false, + tools_enabled: false, + workdir: model::default_workdir(), + allow_rules: vec![], + demos: vec![], + speak_replies: false, + voice: String::new(), + voice_rate: 1.0, + voice_lang: String::new(), + folder: String::new(), + memories: vec![], + members: vec![], + threads: vec![], + context_tokens: model::default_context_tokens(), + routines: vec![], + last_turn: None, + default_computer: model::default_computer(), + }); + ok(format!("已建立 Bot「{name}」({} · {model})", provider.label())) + } + "update_bot" => { + let target = match s("bot").filter(|n| !n.trim().is_empty()) { + Some(n) => match find_bot(state, &n) { + Some(b) => b.id, + None => return err(format!("找不到叫「{n}」的 Bot")), + }, + None => bot_id, + }; + let computers = state.computer_names(); + let b = state.bot_mut(target).unwrap(); + let mut changed = vec![]; + if let Some(x) = s("name") { + b.name = x; + changed.push("名稱"); + } + if let Some(x) = s("description") { + b.description = x; + changed.push("描述"); + } + if let Some(x) = s("instructions") { + b.instructions = x; + changed.push("指示"); + } + if let Some(p) = s("provider") { + match provider_of(&p) { + Some(k) => { + b.provider = k; + if s("model").is_none() { + b.model = k.suggested_model().into(); + } + changed.push("AI 供應商"); + } + None => return err(format!("沒有「{p}」這個供應商")), + } + } + if let Some(m) = s("model") { + b.model = m; + changed.push("模型"); + } + if let Some(p) = v.get("pinned").and_then(Value::as_bool) { + b.pinned = p; + changed.push("釘選"); + } + if let Some(c) = s("default_computer") { + if !computers.contains(&c) { + return err(format!("沒有「{c}」這台電腦。可以用:{}", computers.join("、"))); + } + b.default_computer = c; + changed.push("預設電腦"); + } + if changed.is_empty() { + return err("沒有要改的項目".into()); + } + ok(format!("已更新「{}」的{}(現在:{} · {})", b.name, changed.join("、"), b.provider.label(), b.model)) + } + "message_bot" => { + let Some(n) = s("bot") else { return err("要指定 Bot".into()) }; + let Some(target) = find_bot(state, &n).map(|b| b.id) else { return err(format!("找不到叫「{n}」的 Bot")) }; + if target == bot_id { + return err("不能傳訊息給自己".into()); + } + let from = state.bot(bot_id).map(|b| b.name.clone()).unwrap_or_default(); + let text = s("text").unwrap_or_default(); + let msg = Message { wake: true, ..Message::user(&format!("[來自 Bot]「{from}」:{text}")) }; + let name = state.bot(target).map(|b| b.name.clone()).unwrap_or_default(); + Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false } + } + "list_computers" => { + let mut lines = vec![ + format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME), + format!("{}:這台 Mac,{}", model::THIS_COMPUTER_NAME, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }), + ]; + lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target))); + ok(lines.join("\n")) + } + "update_settings" => { + let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false }; + let mut changed = vec![]; + if let Some(t) = s("theme") { + state.theme = match t.as_str() { + "dark" => Theme::Dark, + "system" => Theme::System, + _ => Theme::Light, + }; + out.theme_changed = true; + changed.push(format!("主題 → {t}")); + } + if let Some(a) = s("accent") { + state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black }; + changed.push(format!("強調色 → {a}")); + } + if let Some(z) = s("timezone") { + if z.parse::().is_err() { + return err(format!("「{z}」不是有效的時區名稱(例如 Asia/Taipei)")); + } + state.timezone = z.clone(); + changed.push(format!("時區 → {z}")); + } + if changed.is_empty() { + return err("沒有要改的設定".into()); + } + out.text = format!("已更新:{}", changed.join("、")); + out + } + other => err(format!("沒有「{other}」這個操作")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chat_sets_up_and_manages_routines() { + let mut st = State::first_run(); + let r = apply(&mut st, 1, "create_routine", r#"{"title":"早報","prompt":"整理新聞","schedule":{"frequency":"weekly","daysOfWeek":["MO","TU","WE","TH","FR"],"time":"09:00"}}"#); + assert!(r.ok, "{}", r.text); + assert!(r.text.contains("每個工作日 09:00"), "{}", r.text); + let b = st.bot(1).unwrap(); + assert_eq!(b.routines.len(), 1); + assert!(b.routines[0].next_run.unwrap() > routines::now()); + let id = b.routines[0].id; + assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":true}}"#)).text.contains("已暫停")); + assert!(needs_approval("delete_routine", &format!(r#"{{"id":{id}}}"#), &st).unwrap().contains("早報")); + let w = apply(&mut st, 1, "run_routine_now", &format!(r#"{{"id":{id}}}"#)); + assert_eq!(w.wake[0].0, 1); + assert!(w.wake[0].1.wake); + assert!(apply(&mut st, 1, "delete_routine", &format!(r#"{{"id":{id}}}"#)).ok); + assert!(st.bot(1).unwrap().routines.is_empty()); + assert!(!apply(&mut st, 1, "create_routine", r#"{"title":"x","prompt":"y","schedule":{"frequency":"weekly","time":"09:00"}}"#).ok); + } + + /// The person's case: the app closed while two replies were coming in, leaving empty + /// replies that looked stuck. Loading repairs them so they can be retried. + #[test] + fn interrupted_replies_are_repaired() { + let mut st = State::first_run(); + let b = st.bot_mut(1).unwrap(); + b.messages = vec![ + Message::user("你好"), + Message::assistant("你好!", "x"), + Message::user("找宮古島攻略"), + Message { thinking: "想一下".into(), ..Message::assistant("", "x") }, + Message::user("嗨"), + Message::assistant("", "x"), + ]; + assert!(b.repair_interrupted()); + assert_eq!(b.messages[1].text, "你好!"); + assert!(b.messages[3].error && b.messages[5].error); + assert_eq!(b.messages[5].text, model::INTERRUPTED_NOTE); + assert_eq!(b.last_turn.as_ref().unwrap().outcome, model::TurnOutcome::Error); + assert!(!b.repair_interrupted(), "a second pass changes nothing"); + } + + #[test] + fn chat_manages_bots_and_settings() { + let mut st = State::first_run(); + assert!(apply(&mut st, 1, "update_bot", r#"{"provider":"openai","model":"gpt-5-mini"}"#).text.contains("OpenAI · gpt-5-mini")); + assert!(apply(&mut st, 1, "create_bot", r#"{"name":"翻譯官","provider":"gemini"}"#).ok); + assert!(st.bots.iter().any(|b| b.name == "翻譯官" && b.provider == ProviderKind::Gemini)); + let m = apply(&mut st, 1, "message_bot", r#"{"bot":"寫作","text":"幫我想標題"}"#); + assert!(m.ok && m.wake[0].0 == 3, "{}", m.text); + assert!(apply(&mut st, 1, "update_bot", r#"{"default_computer":"火星"}"#).text.contains("沒有")); + assert!(apply(&mut st, 1, "update_settings", r#"{"theme":"dark","timezone":"Asia/Tokyo"}"#).theme_changed); + assert_eq!(st.timezone, "Asia/Tokyo"); + assert!(apply(&mut st, 1, "list_computers", "{}").text.contains("還沒")); + } + + #[test] + fn groups_from_the_chat() { + let mut st = State::first_run(); + let names: Vec = st.bots.iter().take(2).map(|b| b.name.clone()).collect(); + let o = apply(&mut st, 1, "create_group", &serde_json::json!({"name": "文宣組", "bots": names}).to_string()); + assert!(o.ok && o.text.contains("文宣組"), "{}", o.text); + assert_eq!(st.bots.iter().filter(|b| b.folder == "文宣組").count(), 2); + assert!(st.folders.contains(&"文宣組".to_string())); + assert!(apply(&mut st, 1, "rename_group", r#"{"group":"文宣組","new_name":"行銷組"}"#).ok); + assert_eq!(st.bots.iter().filter(|b| b.folder == "行銷組").count(), 2); + assert!(apply(&mut st, 1, "list_bots", "{}").text.contains("[群組:行銷組]")); + assert!(apply(&mut st, 1, "delete_group", r#"{"group":"行銷組"}"#).ok); + assert!(st.bots.iter().all(|b| b.folder.is_empty()) && st.folders.is_empty()); + assert!(!apply(&mut st, 1, "rename_group", r#"{"group":"沒有","new_name":"x"}"#).ok); + } +} diff --git a/src/attach.rs b/src/attach.rs new file mode 100644 index 0000000..3969e4d --- /dev/null +++ b/src/attach.rs @@ -0,0 +1,205 @@ +// Files and pictures the person gives a bot with a message (the original: 附件 — drag files in, +// paste a picture, or pick files; pictures go to the model, documents are read for it). +// Each one is copied into AnyBot's data folder, so the chat keeps working after the original moves. + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Largest file accepted (estimated). +pub const MAX_FILE_BYTES: u64 = 52_428_800; +/// Text read from one file for the model (estimated; the original reads 20,000 characters of mail +/// and notes). +pub const MAX_TEXT_CHARS: usize = 20_000; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Attachment { + pub name: String, + /// The copy in AnyBot's data folder. + pub path: String, + #[serde(default)] + pub image: bool, + #[serde(default)] + pub bytes: u64, + /// Where it was also put on the bot's own computer (empty when not). + #[serde(default)] + pub on_box: String, +} + +fn dir_for(bot_id: u64) -> PathBuf { + crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("attachments").join(format!("bot-{bot_id}")) +} + +pub fn is_image_name(name: &str) -> bool { + let n = name.to_lowercase(); + [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e)) +} + +/// Copy a file in as an attachment. +pub fn store(bot_id: u64, src: &Path) -> Result { + let meta = std::fs::metadata(src).map_err(|e| format!("讀不到 {}:{e}", src.display()))?; + if meta.is_dir() { + return Err(format!("「{}」是資料夾,請壓縮後再附加,或請 Bot 直接處理那個資料夾。", src.display())); + } + if meta.len() > MAX_FILE_BYTES { + return Err(format!("「{}」太大了(最多 50 MB)", src.display())); + } + let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "附件".into()); + let dir = dir_for(bot_id); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f"))); + std::fs::copy(src, &dst).map_err(|e| format!("複製失敗:{e}"))?; + Ok(Attachment { image: is_image_name(&name), name, path: dst.display().to_string(), bytes: meta.len(), on_box: String::new() }) +} + +/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment. +pub fn paste_image(bot_id: u64) -> Option { + let dir = dir_for(bot_id); + std::fs::create_dir_all(&dir).ok()?; + let name = format!("貼上的圖片 {}.png", chrono::Local::now().format("%H%M%S")); + let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f"))); + if !cfg!(target_os = "macos") { + let png = crate::platform::clipboard_png_linux()?; + std::fs::write(&dst, &png).ok()?; + return Some(Attachment { name, path: dst.display().to_string(), image: true, bytes: png.len() as u64, on_box: String::new() }); + } + let script = r#"ObjC.import('AppKit'); +function run(argv) { + const img = $.NSImage.alloc.initWithPasteboard($.NSPasteboard.generalPasteboard); + if (img.isNil()) return 'none'; + const rep = $.NSBitmapImageRep.imageRepWithData(img.TIFFRepresentation); + const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})); + return png.writeToFileAtomically(argv[0], true) ? 'ok' : 'fail'; +}"#; + let out = std::process::Command::new("osascript").args(["-l", "JavaScript", "-e", script, "--"]).arg(&dst).output().ok()?; + if String::from_utf8_lossy(&out.stdout).trim() != "ok" { + return None; + } + let bytes = std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0); + Some(Attachment { name, path: dst.display().to_string(), image: true, bytes, on_box: String::new() }) +} + +/// The Finder file picker (several files at once). +pub fn pick_files() -> Vec { + if !cfg!(target_os = "macos") { + return crate::platform::pick_files_linux(&crate::i18n::t("選擇要附加的檔案")); + } + let script = r#"function run() { + const app = Application.currentApplication(); app.includeStandardAdditions = true; + try { return app.chooseFile({withPrompt: '選擇要附加的檔案', multipleSelectionsAllowed: true}).map(f => f.toString()).join('\n'); } catch (e) { return ''; } +}"#; + std::process::Command::new("osascript") + .args(["-l", "JavaScript", "-e", script]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::trim).filter(|l| !l.is_empty()).map(PathBuf::from).collect()) + .unwrap_or_default() +} + +pub fn human(b: u64) -> String { + crate::cloudbox::human(b) +} + +/// A picture, sized like a screenshot, as base64 JPEG. +pub fn image_b64(path: &str) -> Option { + // The viewer's decoder: every common format, SVG, and HEIC/AVIF through sips or ffmpeg. + let img = image::DynamicImage::ImageRgba8(crate::preview::decode_image(std::path::Path::new(path))?); + let img = img.to_rgb8(); + let (w, h) = img.dimensions(); + let (fw, fh, resize) = crate::computer::fit(w, h); + let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; + crate::computer::encode_jpeg(&img).map(|j| base64::engine::general_purpose::STANDARD.encode(j)) +} + +fn clip(s: &str) -> String { + let n = s.chars().count(); + if n <= MAX_TEXT_CHARS { + s.to_string() + } else { + format!("{}\n…(後面省略 {} 個字)", s.chars().take(MAX_TEXT_CHARS).collect::(), n - MAX_TEXT_CHARS) + } +} + +/// The text inside a document, when it can be read here. +pub fn read_text(path: &str, name: &str) -> Option { + let n = name.to_lowercase(); + let ext = n.rsplit('.').next().unwrap_or(""); + // Word and web pages: read in AnyBot itself (same on every system). + if ext == "docx" { + if let Ok(md) = crate::preview::docx_markdown(std::path::Path::new(path)) { + return Some(md).filter(|t| !t.trim().is_empty()); + } + } + if ext == "html" || ext == "htm" { + let bytes = std::fs::read(path).ok()?; + return Some(crate::preview::html_markdown(&String::from_utf8_lossy(&bytes))).filter(|t| !t.trim().is_empty()); + } + if ["docx", "doc", "rtf", "rtfd", "odt", "webarchive", "pages"].contains(&ext) && cfg!(target_os = "macos") { + let out = std::process::Command::new("textutil").args(["-convert", "txt", "-stdout", path]).output().ok()?; + return out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()).filter(|t| !t.trim().is_empty()); + } + if ext == "pdf" { + // pdftotext keeps the layout best; without it, read the text in AnyBot (pure Rust). + if let Some(t) = std::process::Command::new("pdftotext").env("PATH", crate::cloudbox::tool_path()).args(["-layout", path, "-"]).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| !t.trim().is_empty()) { + return Some(t); + } + let bytes = std::fs::read(path).ok()?; + // NFKC: PDFs often carry CJK radicals ("⽂") for the ordinary characters ("文"). + use unicode_normalization::UnicodeNormalization; + return std::panic::catch_unwind(|| pdf_extract::extract_text_from_mem(&bytes).ok()).ok().flatten().map(|t| t.nfkc().collect::()).filter(|t| !t.trim().is_empty()); + } + let bytes = std::fs::read(path).ok()?; + if bytes.len() > 2 * 1024 * 1024 || bytes.iter().take(8000).any(|b| *b == 0) { + return None; + } + String::from_utf8(bytes).ok() +} + +/// What the model gets for a message's attachments: extra text and pictures. +pub fn for_model(atts: &[Attachment]) -> (String, Vec) { + let mut text = String::new(); + let mut images = vec![]; + for a in atts { + let where_ = if a.on_box.is_empty() { format!("檔案在使用者的 Mac:{}", a.path) } else { format!("檔案在使用者的 Mac:{};也已放進 Bot 的電腦:{}", a.path, a.on_box) }; + if a.image { + match image_b64(&a.path) { + Some(b) => { + images.push(b); + text.push_str(&format!("\n\n[附件圖片「{}」,就是下面這張。{where_}]", a.name)); + } + None => text.push_str(&format!("\n\n[附件圖片「{}」讀不出來。{where_}]", a.name)), + } + } else if let Some(t) = read_text(&a.path, &a.name) { + text.push_str(&format!("\n\n[附件「{}」({})。{where_}]\n```\n{}\n```", a.name, human(a.bytes), clip(&t))); + } else { + text.push_str(&format!("\n\n[附件「{}」({}),這裡讀不出內容,可以用工具處理。{where_}]", a.name, human(a.bytes))); + } + } + (text, images) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_and_images_reach_the_model() { + let dir = std::env::temp_dir().join(format!("anybot-attach-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let txt = dir.join("筆記.md"); + std::fs::write(&txt, "# 標題\n內容 hello").unwrap(); + let png = dir.join("圖.png"); + image::RgbImage::from_pixel(2000, 1000, image::Rgb([10, 20, 30])).save(&png).unwrap(); + let bin = dir.join("data.bin"); + std::fs::write(&bin, [0u8, 1, 2, 0, 5]).unwrap(); + let atts: Vec = [&txt, &png, &bin].iter().map(|p| store(99_999, p).unwrap()).collect(); + assert!(atts[1].image && !atts[0].image); + let (text, images) = for_model(&atts); + assert!(text.contains("內容 hello") && text.contains("附件圖片「圖.png」") && text.contains("讀不出內容"), "{text}"); + assert_eq!(images.len(), 1); + for a in &atts { + let _ = std::fs::remove_file(&a.path); + } + let _ = std::fs::remove_dir_all(&dir); + assert!(store(1, Path::new("/nonexistent/x")).is_err()); + } +} diff --git a/src/avatar.rs b/src/avatar.rs new file mode 100644 index 0000000..b1f5f78 --- /dev/null +++ b/src/avatar.rs @@ -0,0 +1,201 @@ +// Bot avatars: a simple shape in the bot's colour with two slanted oval eyes, +// drawn in the style of the original's bot faces (not copied from its pictures). + +use eframe::egui::{self, pos2, vec2, Color32, Pos2, Rect, Shape, Stroke}; +use std::f32::consts::PI; + +pub const SHAPES: [&str; 12] = [ + "squircle", "blob", "capsule", "egg", "hex", "gem", "cloud", "arch", "star", "heart", "shield", "pebble", +]; + +pub fn shape_label(s: &str) -> &'static str { + match s { + "squircle" => "圓角方", + "blob" => "圓球", + "capsule" => "膠囊", + "egg" => "蛋", + "hex" => "六角", + "gem" => "寶石", + "cloud" => "雲", + "arch" => "拱門", + "star" => "星星", + "heart" => "愛心", + "shield" => "盾牌", + "pebble" => "石頭", + _ => "圓球", + } +} + +/// Bot colours, from the original's bubble fills (index CSS --sand-fill-*-bubble-user). +pub const COLORS: [(&str, &str, [u8; 3]); 10] = [ + ("black", "黑色", [0x1c, 0x1c, 0x1c]), + ("red", "紅", [0xc8, 0x25, 0x32]), + ("orange", "橘", [0xd2, 0x55, 0x0a]), + ("yellow", "黃", [0xc2, 0x73, 0x0b]), + ("green", "綠", [0x0d, 0x96, 0x55]), + ("cyan", "青", [0x0d, 0x93, 0x81]), + ("blue", "藍", [0x12, 0x6f, 0xd4]), + ("purple", "紫", [0x74, 0x42, 0xd1]), + ("magenta", "洋紅", [0xc8, 0x27, 0x79]), + ("brown", "棕", [0x86, 0x59, 0x2e]), +]; + +pub fn color(name: &str) -> Color32 { + let c = COLORS.iter().find(|c| c.0 == name).map(|c| c.2).unwrap_or(COLORS[0].2); + Color32::from_rgb(c[0], c[1], c[2]) +} + +fn ellipse_points(c: Pos2, rx: f32, ry: f32, rot: f32, n: usize) -> Vec { + let (s, co) = rot.sin_cos(); + (0..n) + .map(|i| { + let t = i as f32 / n as f32 * 2.0 * PI; + let (x, y) = (rx * t.cos(), ry * t.sin()); + pos2(c.x + x * co - y * s, c.y + x * s + y * co) + }) + .collect() +} + +fn rounded_poly(rect: Rect, r: f32) -> Vec { + let mut pts = vec![]; + let corners = [ + (rect.right_bottom() - vec2(r, r), 0.0), + (rect.left_bottom() + vec2(r, -r), 0.5 * PI), + (rect.left_top() + vec2(r, r), PI), + (rect.right_top() + vec2(-r, r), 1.5 * PI), + ]; + for (c, a0) in corners { + for k in 0..=6 { + let a = a0 + k as f32 / 6.0 * 0.5 * PI; + pts.push(pos2(c.x + r * a.cos(), c.y + r * a.sin())); + } + } + pts +} + +/// How a face is held at this moment (the original's mark: gaze, blink, squash, lift, spin). +#[derive(Clone, Copy, Debug)] +pub struct Pose { + /// Where it looks, -1…1 on each axis (y up). + pub gaze: egui::Vec2, + /// 0 = open, 1 = shut. + pub blink: f32, + /// Horizontal / vertical scale (a bounce squashes and stretches). + pub squash: egui::Vec2, + /// Upward hop, as a share of the size. + pub lift: f32, + /// Turn around the vertical axis in degrees (a spin). + pub yaw: f32, + /// Nod: eyes drop a little. + pub pitch: f32, +} + +impl Default for Pose { + fn default() -> Self { + Pose { gaze: egui::Vec2::ZERO, blink: 0.0, squash: vec2(1.0, 1.0), lift: 0.0, yaw: 0.0, pitch: 0.0 } + } +} + +/// A face with a pose: the body leans toward the gaze (the original turns it up to 26° sideways +/// and 19° up/down; drawn flat here as a shift), the eyes move further, blink and spin. +pub fn paint_posed(painter: &egui::Painter, rect: Rect, shape: &str, col: Color32, pose: Pose) { + // Not edge to edge: the face sits inside its cell with a little room around it. + let rect = Rect::from_center_size(rect.center(), rect.size() * 0.84); + let s = rect.width().min(rect.height()) / 2.0; + let turn = pose.yaw.to_radians().cos(); + let sx = pose.squash.x * turn.abs().max(0.06); + let sy = pose.squash.y; + let shift = vec2(pose.gaze.x * 0.05 * s, -pose.gaze.y * 0.04 * s - pose.lift * s * 0.55); + let body = Rect::from_center_size(rect.center() + shift, vec2(rect.width() * sx, rect.height() * sy)); + // Squash from the feet: keep the bottom where it was when it flattens. + let body = body.translate(vec2(0.0, (1.0 - sy) * rect.height() / 2.0)); + paint_body(painter, body, shape, col); + if turn <= 0.0 { + return; + } + let c = body.center(); + let bs = body.width().min(body.height()) / 2.0; + let eye = eye_colour(col); + let open = (1.0 - pose.blink).clamp(0.06, 1.0); + let look = vec2(pose.gaze.x * 0.16 * s * turn, -pose.gaze.y * 0.12 * s + pose.pitch * 0.01 * s); + for dx in [-0.27, 0.27] { + let center = pos2(c.x + dx * body.width() / 2.0, c.y + 0.02 * bs) + look; + painter.add(Shape::convex_polygon(ellipse_points(center, s * 0.11 * sx, s * 0.22 * open * sy, -0.35, 20), eye, Stroke::NONE)); + } +} + +fn eye_colour(col: Color32) -> Color32 { + if col.r() as u32 + col.g() as u32 + col.b() as u32 > 600 { Color32::from_rgb(0x1c, 0x1c, 0x1c) } else { Color32::from_rgb(0xfc, 0xfc, 0xfc) } +} + +pub fn paint(painter: &egui::Painter, rect: Rect, shape: &str, col: Color32) { + paint_body(painter, rect, shape, col); + let c = rect.center(); + let s = rect.width().min(rect.height()) / 2.0; + let eye = eye_colour(col); + for dx in [-0.27, 0.27] { + painter.add(Shape::convex_polygon(ellipse_points(pos2(c.x + dx * s, c.y + 0.02 * s), s * 0.11, s * 0.22, -0.35, 20), eye, Stroke::NONE)); + } +} + +fn paint_body(painter: &egui::Painter, rect: Rect, shape: &str, col: Color32) { + let c = rect.center(); + let s = rect.width().min(rect.height()) / 2.0; + let body = |pts: Vec| Shape::convex_polygon(pts, col, Stroke::NONE); + match shape { + "squircle" => painter.add(body(rounded_poly(Rect::from_center_size(c, vec2(2.0 * s, 2.0 * s) * 0.92), s * 0.42))), + "capsule" => painter.add(body(rounded_poly(Rect::from_center_size(c, vec2(1.35 * s, 2.0 * s)), s * 0.66))), + "egg" => painter.add(body( + (0..48) + .map(|i| { + let t = i as f32 / 48.0 * 2.0 * PI; + let ry = if t.sin() < 0.0 { s * 1.0 } else { s * 0.86 }; + pos2(c.x + s * 0.8 * t.cos(), c.y + 0.08 * s + ry * t.sin()) + }) + .collect(), + )), + "hex" => painter.add(body((0..6).map(|i| { let a = i as f32 / 6.0 * 2.0 * PI + PI / 6.0; pos2(c.x + s * a.cos(), c.y + s * a.sin()) }).collect())), + "gem" => painter.add(body(vec![pos2(c.x - s * 0.55, c.y - s * 0.8), pos2(c.x + s * 0.55, c.y - s * 0.8), pos2(c.x + s, c.y - s * 0.2), pos2(c.x, c.y + s), pos2(c.x - s, c.y - s * 0.2)])), + "arch" => painter.add(body( + (0..=24) + .map(|i| { let a = PI + i as f32 / 24.0 * PI; pos2(c.x + s * 0.85 * a.cos(), c.y - s * 0.15 + s * 0.85 * a.sin()) }) + .chain([pos2(c.x + s * 0.85, c.y + s), pos2(c.x - s * 0.85, c.y + s)]) + .collect(), + )), + "shield" => painter.add(body(vec![ + pos2(c.x - s * 0.85, c.y - s * 0.9), + pos2(c.x + s * 0.85, c.y - s * 0.9), + pos2(c.x + s * 0.85, c.y + s * 0.05), + pos2(c.x + s * 0.5, c.y + s * 0.65), + pos2(c.x, c.y + s), + pos2(c.x - s * 0.5, c.y + s * 0.65), + pos2(c.x - s * 0.85, c.y + s * 0.05), + ])), + "pebble" => painter.add(body(ellipse_points(c, s, s * 0.78, -0.25, 48))), + "cloud" => { + for (dx, dy, r) in [(-0.45, 0.15, 0.55), (0.45, 0.15, 0.55), (0.0, -0.2, 0.65), (0.0, 0.35, 0.55)] { + painter.circle_filled(pos2(c.x + dx * s, c.y + dy * s), r * s, col); + } + painter.add(Shape::Noop) + } + "heart" => { + painter.circle_filled(pos2(c.x - s * 0.45, c.y - s * 0.25), s * 0.55, col); + painter.circle_filled(pos2(c.x + s * 0.45, c.y - s * 0.25), s * 0.55, col); + painter.add(body(vec![pos2(c.x - s * 0.97, c.y - s * 0.1), pos2(c.x + s * 0.97, c.y - s * 0.1), pos2(c.x, c.y + s)])) + } + "star" => { + let pt = |i: usize, r: f32| { let a = -PI / 2.0 + i as f32 * PI / 5.0; pos2(c.x + r * a.cos(), c.y + 0.08 * s + r * a.sin()) }; + for i in 0..5 { + painter.add(body(vec![pt(2 * i + 9, s * 0.48), pt(2 * i, s * 1.05), pt(2 * i + 1, s * 0.48)])); + } + painter.add(body((0..10).filter(|i| i % 2 == 1).map(|i| pt(i, s * 0.5)).collect())) + } + _ => painter.add(Shape::convex_polygon(ellipse_points(c, rect.width() / 2.0 * 0.95, rect.height() / 2.0 * 0.95, 0.0, 48), col, Stroke::NONE)), + }; +} + +pub fn avatar(ui: &mut egui::Ui, size: f32, shape: &str, col: Color32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), egui::Sense::click()); + paint(ui.painter(), rect, shape, col); + resp +} diff --git a/src/axread.rs b/src/axread.rs new file mode 100644 index 0000000..e737e9c --- /dev/null +++ b/src/axread.rs @@ -0,0 +1,379 @@ +// Read the person's own browser window through macOS accessibility, the way Grok Bot's Computer +// Use helper (CUGrokBotService) sees their screen: no browser setting ("允許來自 Apple 事件的 +// JavaScript") and no remote control of Chrome, so it works in whichever Chrome profile the person +// has open. macOS asks once to let AnyBot use 輔助使用 (Accessibility), as it does for that helper. +// +// Decompiled rules (CUGrokBotService, CUCore): +// - `CURendererApp.isRenderer(bundleIdentifier:executablePath:)` @0x1000a1b6c: an app is a +// Chromium/Electron "renderer" if its bundle id is `com.github.Electron` or `com.google.Chrome`, +// starts with `com.google.Chrome.`, its executable path contains +// `/Electron.app/Contents/MacOS/Electron`, or `isRendererBundle(atPath:)` says so. +// - `CURendererApp.isRendererBundle(atPath:)` @0x1000a19ec: `/Contents/Frameworks/` holds +// one of 3 frameworks: Electron Framework, Chromium Embedded Framework, Google Chrome Framework. +// - `CURendererAccessibility.writeFlags(on:)` @0x1000a1554: on such apps sets +// `AXManualAccessibility` then `AXEnhancedUserInterface` on the app's accessibility element, so +// their web content shows up in the accessibility tree. + +/// Bundle ids that are renderers outright (isRenderer @0x1000a1b6c). +pub const RENDERER_BUNDLE_IDS: [&str; 2] = ["com.github.Electron", "com.google.Chrome"]; +/// Bundle id prefix for Chrome's channels and helpers (isRenderer @0x1000a1b6c). +pub const RENDERER_BUNDLE_PREFIX: &str = "com.google.Chrome."; +/// Executable path piece of a bare Electron app (isRenderer @0x1000a1b6c). +pub const ELECTRON_EXE_PIECE: &str = "/Electron.app/Contents/MacOS/Electron"; +/// Frameworks that mark a Chromium-based bundle (isRendererBundle @0x1000a19ec). +pub const RENDERER_FRAMEWORKS: [&str; 3] = ["Electron Framework.framework", "Chromium Embedded Framework.framework", "Google Chrome Framework.framework"]; +/// The two flags written on renderer apps, in order (writeFlags @0x1000a1554). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub const RENDERER_AX_FLAGS: [&str; 2] = ["AXManualAccessibility", "AXEnhancedUserInterface"]; +/// Most accessibility elements walked for one read (estimated). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub const MAX_AX_NODES: usize = 20_000; +/// Deepest accessibility nesting followed (estimated). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const MAX_AX_DEPTH: usize = 80; +/// Tries while Chrome builds its accessibility tree after the flags are set (estimated). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const READ_TRIES: usize = 5; +/// Pause between those tries (estimated). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const RETRY_PAUSE_MS: u64 = 700; +/// Longest wait for one accessibility answer (estimated). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const AX_TIMEOUT_SECS: f32 = 2.0; + +/// isRenderer(bundleIdentifier:executablePath:) + isRendererBundle(atPath:), with the framework +/// check passed in so it can be tested. +pub fn is_renderer(bundle_id: Option<&str>, exe_path: Option<&str>, has_framework: impl Fn(&str) -> bool) -> bool { + if let Some(id) = bundle_id { + if RENDERER_BUNDLE_IDS.contains(&id) || id.starts_with(RENDERER_BUNDLE_PREFIX) { + return true; + } + } + if let Some(p) = exe_path { + if p.contains(ELECTRON_EXE_PIECE) { + return true; + } + } + RENDERER_FRAMEWORKS.iter().any(|f| has_framework(f)) +} + +/// The `.app` folder an executable lives in. +pub fn bundle_of(exe: &str) -> Option<&str> { + exe.find(".app/").map(|i| &exe[..i + 4]) +} + +/// The person's own browser process (not AnyBot's own window, which runs from AnyBot's folder). +pub fn pick_person_pid(ps: &str, exe: &str) -> Option { + ps.lines().find_map(|l| { + let l = l.trim_start(); + let (pid, args) = l.split_once(' ')?; + let args = args.trim_start(); + let own = args.strip_prefix(exe)?; + (own.is_empty() || own.starts_with(' ')).then_some(())?; + (!args.contains("/AnyBot/browser") && !args.contains(" --type=")).then_some(())?; + pid.parse().ok() + }) +} + +pub struct Page { + pub title: String, + pub url: String, + pub text: String, +} + +#[cfg(target_os = "macos")] +mod mac { + use super::*; + use std::ffi::{c_char, c_void, CString}; + + type CFTypeRef = *const c_void; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrustedWithOptions(options: CFTypeRef) -> bool; + fn AXUIElementCreateApplication(pid: i32) -> CFTypeRef; + fn AXUIElementSetAttributeValue(el: CFTypeRef, attr: CFTypeRef, value: CFTypeRef) -> i32; + fn AXUIElementCopyAttributeValue(el: CFTypeRef, attr: CFTypeRef, value: *mut CFTypeRef) -> i32; + fn AXUIElementSetMessagingTimeout(el: CFTypeRef, secs: f32) -> i32; + static kAXTrustedCheckOptionPrompt: CFTypeRef; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFStringCreateWithCString(alloc: CFTypeRef, s: *const c_char, enc: u32) -> CFTypeRef; + fn CFRelease(x: CFTypeRef); + fn CFRetain(x: CFTypeRef) -> CFTypeRef; + fn CFGetTypeID(x: CFTypeRef) -> usize; + fn CFStringGetTypeID() -> usize; + fn CFArrayGetTypeID() -> usize; + fn CFURLGetTypeID() -> usize; + fn CFArrayGetCount(a: CFTypeRef) -> isize; + fn CFArrayGetValueAtIndex(a: CFTypeRef, i: isize) -> CFTypeRef; + fn CFStringGetLength(s: CFTypeRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(len: isize, enc: u32) -> isize; + fn CFStringGetCString(s: CFTypeRef, buf: *mut c_char, size: isize, enc: u32) -> bool; + fn CFURLGetString(u: CFTypeRef) -> CFTypeRef; + fn CFDictionaryCreate(alloc: CFTypeRef, keys: *const CFTypeRef, values: *const CFTypeRef, n: isize, kcb: *const c_void, vcb: *const c_void) -> CFTypeRef; + static kCFBooleanTrue: CFTypeRef; + static kCFTypeDictionaryKeyCallBacks: [usize; 6]; + static kCFTypeDictionaryValueCallBacks: [usize; 5]; + } + + const UTF8: u32 = 0x0800_0100; + + /// An owned Core Foundation value, released when dropped. + struct Cf(CFTypeRef); + impl Drop for Cf { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { CFRelease(self.0) } + } + } + } + + fn cfstr(s: &str) -> Cf { + let c = CString::new(s).unwrap_or_default(); + Cf(unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) }) + } + + fn string_of(v: CFTypeRef) -> Option { + unsafe { + if v.is_null() || CFGetTypeID(v) != CFStringGetTypeID() { + return None; + } + let size = CFStringGetMaximumSizeForEncoding(CFStringGetLength(v), UTF8) + 1; + let mut buf = vec![0u8; size.max(1) as usize]; + if !CFStringGetCString(v, buf.as_mut_ptr() as *mut c_char, size, UTF8) { + return None; + } + let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); + Some(String::from_utf8_lossy(&buf[..end]).into_owned()) + } + } + + fn attr(el: CFTypeRef, name: &str) -> Option { + let key = cfstr(name); + let mut out: CFTypeRef = std::ptr::null(); + let err = unsafe { AXUIElementCopyAttributeValue(el, key.0, &mut out) }; + (err == 0 && !out.is_null()).then(|| Cf(out)) + } + + fn attr_string(el: CFTypeRef, name: &str) -> Option { + attr(el, name).and_then(|v| string_of(v.0)) + } + + fn children(el: CFTypeRef) -> (Option, Vec) { + let Some(arr) = attr(el, "AXChildren") else { return (None, vec![]) }; + unsafe { + if CFGetTypeID(arr.0) != CFArrayGetTypeID() { + return (Some(arr), vec![]); + } + let n = CFArrayGetCount(arr.0); + let items = (0..n).map(|i| CFArrayGetValueAtIndex(arr.0, i)).collect(); + (Some(arr), items) + } + } + + /// Is AnyBot allowed to use accessibility? `prompt` makes macOS show its own question. + pub fn trusted(prompt: bool) -> bool { + unsafe { + if !prompt { + return AXIsProcessTrustedWithOptions(std::ptr::null()); + } + let keys = [kAXTrustedCheckOptionPrompt]; + let values = [kCFBooleanTrue]; + let dict = Cf(CFDictionaryCreate(std::ptr::null(), keys.as_ptr(), values.as_ptr(), 1, kCFTypeDictionaryKeyCallBacks.as_ptr() as *const c_void, kCFTypeDictionaryValueCallBacks.as_ptr() as *const c_void)); + AXIsProcessTrustedWithOptions(dict.0) + } + } + + /// writeFlags(on:) @0x1000a1554: both flags on the app element, in the same order. + fn write_flags(app: CFTypeRef) -> bool { + RENDERER_AX_FLAGS.iter().fold(true, |ok, f| { + let key = cfstr(f); + let err = unsafe { AXUIElementSetAttributeValue(app, key.0, kCFBooleanTrue) }; + ok && err == 0 + }) + } + + fn find_role(el: CFTypeRef, role: &str, seen: &mut usize, depth: usize) -> Option { + if *seen >= MAX_AX_NODES || depth > MAX_AX_DEPTH { + return None; + } + *seen += 1; + let (_keep, kids) = children(el); + for k in kids { + if attr_string(k, "AXRole").as_deref() == Some(role) { + unsafe { CFRetain(k) }; + return Some(Cf(k)); + } + if let Some(found) = find_role(k, role, seen, depth + 1) { + return Some(found); + } + } + None + } + + /// Visible text under the page: runs of static text become one line. + fn collect(el: CFTypeRef, out: &mut Vec, line: &mut String, seen: &mut usize, depth: usize, max_chars: usize, total: &mut usize) { + if *seen >= MAX_AX_NODES || depth > MAX_AX_DEPTH || *total > max_chars { + return; + } + *seen += 1; + let (_keep, kids) = children(el); + for k in kids { + let role = attr_string(k, "AXRole").unwrap_or_default(); + if role == "AXStaticText" { + if let Some(t) = attr_string(k, "AXValue") { + *total += t.chars().count(); + line.push_str(&t); + } + } else { + flush(out, line); + collect(k, out, line, seen, depth + 1, max_chars, total); + flush(out, line); + } + } + } + + fn flush(out: &mut Vec, line: &mut String) { + let t = line.trim(); + if !t.is_empty() { + out.push(t.to_string()); + } + line.clear(); + } + + fn url_of(web: CFTypeRef) -> String { + let Some(u) = attr(web, "AXURL") else { return String::new() }; + unsafe { + if CFGetTypeID(u.0) == CFURLGetTypeID() { + string_of(CFURLGetString(u.0)).unwrap_or_default() + } else { + string_of(u.0).unwrap_or_default() + } + } + } + + pub fn read(pid: i32, renderer: bool, max_chars: usize) -> Result { + let app = Cf(unsafe { AXUIElementCreateApplication(pid) }); + if app.0.is_null() { + return Err("連不上你的瀏覽器".into()); + } + unsafe { AXUIElementSetMessagingTimeout(app.0, AX_TIMEOUT_SECS) }; + if renderer { + write_flags(app.0); + } + let mut last_title = String::new(); + for i in 0..READ_TRIES { + if i > 0 { + std::thread::sleep(std::time::Duration::from_millis(RETRY_PAUSE_MS)); + } + let Some(win) = attr(app.0, "AXFocusedWindow").or_else(|| attr(app.0, "AXMainWindow")) else { continue }; + last_title = attr_string(win.0, "AXTitle").unwrap_or_default(); + let mut seen = 0; + let Some(web) = find_role(win.0, "AXWebArea", &mut seen, 0) else { continue }; + let title = attr_string(web.0, "AXTitle").filter(|t| !t.is_empty()).unwrap_or_else(|| last_title.clone()); + let (mut lines, mut line, mut seen, mut total) = (vec![], String::new(), 0, 0); + collect(web.0, &mut lines, &mut line, &mut seen, 0, max_chars, &mut total); + flush(&mut lines, &mut line); + if lines.is_empty() && i + 1 < READ_TRIES { + continue; + } + return Ok(Page { title, url: url_of(web.0), text: lines.join("\n") }); + } + if last_title.is_empty() { + Err("你的瀏覽器現在沒有打開的視窗".into()) + } else { + Err(format!("讀不到「{last_title}」的頁面內容")) + } + } +} + +#[cfg(target_os = "macos")] +pub use mac::trusted; + +#[cfg(not(target_os = "macos"))] +pub fn trusted(_prompt: bool) -> bool { + false +} + +/// Read the front window of the person's browser at `exe` (e.g. Chrome's executable). +pub fn read_person_browser(exe: &str, max_chars: usize) -> Result { + if !cfg!(target_os = "macos") { + return Err("只支援 macOS".into()); + } + if !trusted(true) { + let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn(); + return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into()); + } + let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default(); + let Some(pid) = pick_person_pid(&ps, exe) else { return Err("你的瀏覽器沒有開著。請先打開它(或叫我用 browser_open 打開網址)。".into()) }; + let bundle = bundle_of(exe).unwrap_or(""); + let bundle_id = std::process::Command::new("plutil") + .args(["-extract", "CFBundleIdentifier", "raw", "-o", "-", &format!("{bundle}/Contents/Info.plist")]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()); + let renderer = is_renderer(bundle_id.as_deref(), Some(exe), |f| std::path::Path::new(bundle).join("Contents/Frameworks").join(f).exists()); + #[cfg(target_os = "macos")] + { + mac::read(pid, renderer, max_chars) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (pid, renderer, max_chars); + Err("只支援 macOS".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renderer_apps_follow_the_original_rule() { + let none = |_: &str| false; + assert!(is_renderer(Some("com.google.Chrome"), None, none)); + assert!(is_renderer(Some("com.google.Chrome.canary"), None, none)); + assert!(is_renderer(Some("com.github.Electron"), None, none)); + assert!(is_renderer(None, Some("/tmp/x/Electron.app/Contents/MacOS/Electron"), none)); + assert!(!is_renderer(Some("com.google.Chromeish"), None, none)); + assert!(!is_renderer(Some("com.apple.Safari"), Some("/Applications/Safari.app/Contents/MacOS/Safari"), none)); + // Brave / Edge carry their own framework names, so only the framework check could catch them. + assert!(is_renderer(Some("com.example.app"), None, |f| f == "Electron Framework.framework")); + } + + #[test] + fn the_persons_browser_is_not_anybots_window() { + let exe = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + let ps = format!( + " 101 {exe} --user-data-dir=/Users/me/Library/Application Support/AnyBot/browser --remote-debugging-port=9333\n 102 {exe} --type=utility\n 205 {exe}\n 300 {exe} --type=renderer" + ); + assert_eq!(pick_person_pid(&ps, exe), Some(205)); + assert_eq!(pick_person_pid(" 1 /Applications/Safari.app/Contents/MacOS/Safari", exe), None); + } + + #[test] + fn bundle_is_found_from_the_executable() { + assert_eq!(bundle_of("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), Some("/Applications/Google Chrome.app")); + assert_eq!(bundle_of("/usr/bin/true"), None); + } +} + +#[cfg(test)] +mod live { + /// By hand: `cargo test live_person_chrome -- --ignored --nocapture`. Prints counts only. + #[test] + #[ignore] + fn live_person_chrome() { + println!("trusted={}", super::trusted(false)); + if super::trusted(false) { + match super::read_person_browser("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", 5000) { + Ok(p) => println!("lines={} title_chars={} url_ok={}", p.text.lines().count(), p.title.chars().count(), p.url.starts_with("http")), + Err(e) => println!("err={e}"), + } + } + } +} diff --git a/src/botbrowser.rs b/src/botbrowser.rs new file mode 100644 index 0000000..3c78256 --- /dev/null +++ b/src/botbrowser.rs @@ -0,0 +1,468 @@ +// AnyBot's own browser window, like Grok Bot's own browser: a separate Chrome-family profile that +// AnyBot starts and talks to directly (Chrome's DevTools protocol on a local port), so reading +// and opening pages needs no browser setting ("允許來自 Apple 事件的 JavaScript") and no macOS +// "control this app" prompt. The person logs in to sites in this window once; the profile keeps it. +// +// Profiles (設定檔): the person's own Chrome profiles can't be used (macOS refuses other apps +// Chrome's data folder, and Chrome refuses remote control of its normal folder), so AnyBot's +// browser keeps its own: 「預設」 plus any the person or the bot adds (工作, 私人…). Each is its own +// browser folder and window with its own logins; switching picks which one the bot uses. + +use serde_json::{json, Value}; +use std::io::ErrorKind; +use std::net::{SocketAddr, TcpStream}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Local port for talking to the bot's browser (estimated; any free local port works). +pub const PORT: u16 = 9333; +/// How long to wait for the browser to come up (estimated). +const START_TIMEOUT_SECS: u64 = 20; +/// How long a page read may take (estimated). +const READ_TIMEOUT_SECS: u64 = 20; +/// The first profile: the browser folder made before profiles existed (its logins stay). +pub const DEFAULT_PROFILE: &str = "預設"; +/// The person's own browser (read through accessibility, like Grok Bot's Computer Use helper): +/// whichever Chrome profile they have open there is the one read. +pub const PERSON_PROFILE: &str = "你的瀏覽器"; +/// Other ways people name their own browser. +const PERSON_ALIASES: [&str; 6] = ["你的 chrome", "我的 chrome", "我的瀏覽器", "你的chrome", "我的chrome", "my chrome"]; +/// Longest profile name (estimated). +pub const PROFILE_NAME_MAX_CHARS: usize = 30; +/// File Chrome writes into its folder with the port it chose (`--remote-debugging-port=0`). +const ACTIVE_PORT_FILE: &str = "DevToolsActivePort"; + +/// Chrome-family browsers that can be started as AnyBot's own window. +#[cfg(target_os = "macos")] +const CANDIDATES: &[(&str, &str)] = &[ + ("Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ("Brave Browser", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"), + ("Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + ("Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"), +]; +/// Linux: the usual install places (distribution packages, the vendors' .deb/.rpm, Snap, Flatpak). +#[cfg(not(target_os = "macos"))] +const CANDIDATES: &[(&str, &str)] = &[ + ("Google Chrome", "/usr/bin/google-chrome-stable"), + ("Google Chrome", "/usr/bin/google-chrome"), + ("Google Chrome", "/opt/google/chrome/chrome"), + ("Brave Browser", "/usr/bin/brave-browser"), + ("Brave Browser", "/opt/brave.com/brave/brave"), + ("Microsoft Edge", "/usr/bin/microsoft-edge"), + ("Chromium", "/usr/bin/chromium"), + ("Chromium", "/usr/bin/chromium-browser"), + ("Chromium", "/snap/bin/chromium"), + ("Chromium", "/var/lib/flatpak/exports/bin/org.chromium.Chromium"), +]; + +pub fn engine() -> Option<(&'static str, &'static str)> { + CANDIDATES.iter().copied().find(|(_, p)| std::path::Path::new(p).exists()) +} + +pub fn available() -> bool { + engine().is_some() +} + +fn data_dir() -> PathBuf { + crate::model::State::path().parent().map(PathBuf::from).unwrap_or_else(std::env::temp_dir) +} + +/// The 「預設」 profile lives with AnyBot's data (logins stay between runs). +pub fn profile_dir() -> PathBuf { + data_dir().join("browser") +} + +fn profiles_root() -> PathBuf { + data_dir().join("browser-profiles") +} + +fn dir_for(name: &str) -> PathBuf { + if name == DEFAULT_PROFILE { profile_dir() } else { profiles_root().join(name) } +} + +/// All profiles: 「預設」 first, then the added ones by name. +pub fn profiles() -> Vec { + let mut extra: Vec = std::fs::read_dir(profiles_root()) + .map(|it| it.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(str::to_string)).collect()) + .unwrap_or_default(); + extra.sort(); + let mut all = vec![DEFAULT_PROFILE.to_string()]; + all.extend(extra.into_iter().filter(|n| n != DEFAULT_PROFILE)); + all +} + +fn current_file() -> PathBuf { + data_dir().join("browser-profile.txt") +} + +/// Every choice: AnyBot's own profiles, then the person's own browser. +pub fn choices() -> Vec { + let mut all = profiles(); + // Reading the person's own browser goes through macOS accessibility; not on Linux. + if cfg!(target_os = "macos") { + all.push(PERSON_PROFILE.to_string()); + } + all +} + +/// The profile the bot uses when it isn't told which. +pub fn current() -> String { + let want = std::fs::read_to_string(current_file()).unwrap_or_default().trim().to_string(); + match_profile(&choices(), &want).unwrap_or_else(|| DEFAULT_PROFILE.to_string()) +} + +pub fn set_current(name: &str) -> Result { + let name = resolve(Some(name))?; + std::fs::write(current_file(), &name).map_err(|e| format!("存不了設定檔選擇:{e}"))?; + Ok(name) +} + +/// Find a profile by name, ignoring case and spaces at the ends. +pub fn match_profile(list: &[String], want: &str) -> Option { + let w = want.trim().to_lowercase(); + if w.is_empty() { + return None; + } + if PERSON_ALIASES.contains(&w.as_str()) && list.iter().any(|n| n == PERSON_PROFILE) { + return Some(PERSON_PROFILE.to_string()); + } + list.iter().find(|n| n.to_lowercase() == w).cloned() +} + +/// Which profile a tool call means: the one named, or the current one. +pub fn resolve(requested: Option<&str>) -> Result { + match requested.map(str::trim).filter(|s| !s.is_empty()) { + None => Ok(current()), + Some(r) => { + let all = choices(); + match_profile(&all, r).ok_or_else(|| format!("AnyBot 瀏覽器沒有叫「{r}」的設定檔。現有:{}。要新的可以用 browser_profiles 新增(action: add)。", all.join("、"))) + } + } +} + +/// A usable profile name: not empty, not too long, no slashes or dots-only names. +pub fn check_name(name: &str) -> Result { + let n = name.trim(); + if n.is_empty() { + return Err("請給設定檔名稱".into()); + } + if n.chars().count() > PROFILE_NAME_MAX_CHARS { + return Err(format!("設定檔名稱最多 {PROFILE_NAME_MAX_CHARS} 個字")); + } + if n == PERSON_PROFILE || PERSON_ALIASES.contains(&n.to_lowercase().as_str()) { + return Err(format!("「{n}」是留給你自己瀏覽器的名字,請換一個")); + } + if n.contains('/') || n.contains(':') || n.starts_with('.') || n.chars().any(char::is_control) { + return Err("設定檔名稱不能有 / 或 : 也不能用 . 開頭".into()); + } + Ok(n.to_string()) +} + +/// Make a new empty profile (its own folder, so its own logins). +pub fn add(name: &str) -> Result { + let n = check_name(name)?; + if let Some(have) = match_profile(&profiles(), &n) { + return Ok(have); + } + std::fs::create_dir_all(profiles_root().join(&n)).map_err(|e| format!("建不了設定檔:{e}"))?; + Ok(n) +} + +/// Remove an added profile and its logins (the person's choice, from Settings). 「預設」 and the +/// person's own browser can't be removed, and an open window must be closed first. +pub fn remove(name: &str) -> Result<(), String> { + let all = profiles(); + let n = match_profile(&all, name).ok_or_else(|| format!("沒有叫「{name}」的設定檔"))?; + if n == DEFAULT_PROFILE || n == PERSON_PROFILE { + return Err(format!("「{n}」不能移除")); + } + if live_port(&n).is_some() { + return Err(format!("「{n}」的視窗還開著,請先關掉它再移除。")); + } + let dir = profiles_root().join(&n); + // Only ever a folder directly inside AnyBot's browser-profiles folder. + if dir.parent() != Some(profiles_root().as_path()) { + return Err("設定檔位置不對,沒有移除".into()); + } + std::fs::remove_dir_all(&dir).map_err(|e| format!("移除不了「{n}」:{e}"))?; + if current() == n || !choices().contains(¤t()) { + let _ = std::fs::write(current_file(), DEFAULT_PROFILE); + } + Ok(()) +} + +pub(crate) fn local() -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).http_status_as_error(false).build().into() +} + +/// The fixed port of 「預設」 (`ANYBOT_BROWSER_PORT` overrides it, for testing). +fn default_port() -> u16 { + std::env::var("ANYBOT_BROWSER_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(PORT) +} + +fn endpoint(port: u16, path: &str) -> String { + format!("http://127.0.0.1:{port}{path}") +} + +fn alive(port: u16) -> bool { + port != 0 && local().get(&endpoint(port, "/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false) +} + +/// First line of Chrome's DevToolsActivePort file. +pub fn parse_active_port(text: &str) -> Option { + text.lines().next()?.trim().parse().ok().filter(|p| *p != 0) +} + +/// The port a running window of this profile answers on, if any. +fn live_port(name: &str) -> Option { + let file = std::fs::read_to_string(dir_for(name).join(ACTIVE_PORT_FILE)).ok().and_then(|t| parse_active_port(&t)); + let fixed = (name == DEFAULT_PROFILE).then(default_port); + [fixed, file].into_iter().flatten().find(|p| alive(*p)) +} + +/// Percent-encode a URL so it can ride in `/json/new?`. +pub fn encode_component(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b':' | b'/' => (b as char).to_string(), + _ => format!("%{b:02X}"), + }) + .collect() +} + +/// Start this profile's window if it isn't running. Returns (browser name, port, just started). +pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> { + let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) }; + if profile == PERSON_PROFILE { + return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into()); + } + if let Some(p) = live_port(profile) { + return Ok((name, p, false)); + } + let dir = dir_for(profile); + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::remove_file(dir.join(ACTIVE_PORT_FILE)); + // 「預設」 keeps its fixed port; other profiles let Chrome pick a free one and read it back. + let launch_port = if profile == DEFAULT_PROFILE { default_port() } else { 0 }; + let mut c = Command::new(bin); + c.arg(format!("--user-data-dir={}", dir.display())) + .arg(format!("--remote-debugging-port={launch_port}")) + .arg("--remote-allow-origins=http://127.0.0.1") + .arg("--no-first-run") + .arg("--no-default-browser-check") + .arg(first_url.unwrap_or("about:blank")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + c.spawn().map_err(|e| format!("打不開 {name}:{e}"))?; + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(START_TIMEOUT_SECS) { + if let Some(p) = live_port(profile) { + return Ok((name, p, true)); + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(format!("{name} 沒有及時打開(AnyBot 瀏覽器,設定檔「{profile}」)。請再試一次。")) +} + +/// Open a link in a new tab of the bot's browser (in the named profile, which becomes current). +pub fn open(url: &str, profile: Option<&str>) -> Result { + let prof = resolve(profile)?; + let _ = set_current(&prof); + let (name, port, fresh) = ensure(&prof, Some(url))?; + if !fresh { + let r = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(url)))).send_empty().map_err(|e| e.to_string())?; + if r.status().as_u16() >= 400 { + return Err(format!("{name} 沒辦法開新分頁({})", r.status())); + } + } + std::thread::sleep(Duration::from_secs(3)); + Ok(format!("已在 AnyBot 瀏覽器({name},設定檔「{prof}」)打開 {url}。接著用 browser_read 讀內容。如果頁面要登入,請使用者在那個視窗登入一次,之後會記住。")) +} + +/// Bring a profile's window up (Settings button): start it, or bring its front tab forward. +pub fn show(profile: &str) -> Result<(), String> { + let (_, port, fresh) = ensure(profile, None)?; + if fresh { + return Ok(()); + } + let list: Value = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; + let id = list.as_array().and_then(|a| a.iter().find(|t| t["type"] == "page")).and_then(|t| t["id"].as_str()).map(str::to_string); + match id { + Some(id) => local().get(&endpoint(port, &format!("/json/activate/{id}"))).call().map(|_| ()).map_err(|e| e.to_string()), + None => local().put(&endpoint(port, "/json/new?about:blank")).send_empty().map(|_| ()).map_err(|e| e.to_string()), + } +} + +/// The browser_profiles tool: list, add or switch profiles. +pub fn profiles_tool(action: &str, name: &str) -> Result { + let list_text = || { + let cur = current(); + choices().iter().map(|p| if *p == cur { format!("{p}(使用中)") } else { p.clone() }).collect::>().join("、") + }; + match action.trim() { + "" | "list" => Ok(format!("AnyBot 瀏覽器的設定檔:{}。每個設定檔有自己的登入。「{PERSON_PROFILE}」是使用者平常的瀏覽器:使用者在那裡切到哪個 Chrome 設定檔,就讀哪個。", list_text())), + "add" | "create" => { + let n = add(name)?; + set_current(&n)?; + Ok(format!("已新增並切換到設定檔「{n}」(全新的,還沒登入任何網站)。用 browser_open 打開網站後,請使用者在那個視窗登入一次。現在:{}", list_text())) + } + "use" | "switch" => { + let n = set_current(name)?; + let note = if n == PERSON_PROFILE { "(使用者平常的瀏覽器:請使用者在那裡切到想要的 Chrome 設定檔)" } else { "" }; + Ok(format!("已切換到「{n}」{note},之後的 browser_open / browser_read 都用它。現在:{}", list_text())) + } + other => Err(format!("不懂「{other}」,action 請用 list、add 或 use")), + } +} + +/// The page to read: the first ordinary page tab (Chrome lists the most recent first). +pub fn pick_page(list: &Value) -> Option<(String, String, String)> { + list.as_array()?.iter().find(|t| t["type"] == "page" && !t["url"].as_str().unwrap_or("").starts_with("devtools://")).map(|t| { + (t["webSocketDebuggerUrl"].as_str().unwrap_or("").to_string(), t["title"].as_str().unwrap_or("").to_string(), t["url"].as_str().unwrap_or("").to_string()) + }) +} + +fn evaluate(port: u16, ws_url: &str, expression: &str) -> Result { + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| e.to_string())?; + stream.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS))).ok(); + let (mut ws, _) = tungstenite::client::client(ws_url, stream).map_err(|e| format!("連不上 AnyBot 瀏覽器:{e}"))?; + let msg = json!({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expression, "returnByValue": true, "awaitPromise": true}}); + ws.send(tungstenite::Message::Text(msg.to_string().into())).map_err(|e| e.to_string())?; + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(READ_TIMEOUT_SECS) { + return Err("讀取頁面太久了".into()); + } + match ws.read() { + Ok(tungstenite::Message::Text(t)) => { + let v: Value = serde_json::from_str(t.as_str()).unwrap_or(Value::Null); + if v["id"] == 1 { + let _ = ws.close(None); + if let Some(e) = v.get("error") { + return Err(format!("頁面拒絕讀取:{e}")); + } + return Ok(v["result"]["result"]["value"].clone()); + } + } + Ok(_) => {} + Err(tungstenite::Error::Io(e)) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => return Err("讀取頁面太久了".into()), + Err(e) => return Err(e.to_string()), + } + } +} + +/// Run JavaScript in the current tab of the bot's browser. Returns (address, result). +pub fn eval_current(profile: Option<&str>, expression: &str) -> Result<(String, Value), String> { + let prof = resolve(profile)?; + let (_, port, _) = ensure(&prof, None)?; + let list: Value = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; + let Some((ws_url, _, url)) = pick_page(&list) else { return Err("AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開登入頁。".into()) }; + let v = evaluate(port, &ws_url, expression)?; + Ok((url, v)) +} + +const READ_EXPR: &str = "JSON.stringify({t:document.title,u:location.href,x:document.body?document.body.innerText:''})"; + +/// Title, address and visible text of the current tab of the bot's browser. +pub fn read(max_chars: usize, profile: Option<&str>) -> Result { + let prof = resolve(profile)?; + let (name, port, _) = ensure(&prof, None)?; + let list: Value = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; + let Some((ws_url, title, url)) = pick_page(&list) else { return Err("AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開網址。".into()) }; + let v = evaluate(port, &ws_url, READ_EXPR)?; + let page: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null); + let text: String = page["x"].as_str().unwrap_or("").lines().map(str::trim).filter(|l| !l.is_empty()).collect::>().join("\n"); + let title = page["t"].as_str().map(str::to_string).unwrap_or(title); + let url = page["u"].as_str().map(str::to_string).unwrap_or(url); + let mut clipped: String = text.chars().take(max_chars).collect(); + if text.chars().count() > max_chars { + clipped.push_str("\n…(後面還有,已截斷)"); + } + let login_hint = { + let l = clipped.to_lowercase(); + (l.contains("log in") || l.contains("sign in") || clipped.contains("登入")) && clipped.chars().count() < 1500 + }; + let mut out = format!("瀏覽器:AnyBot 瀏覽器({name},設定檔「{prof}」)\n標題:{title}\n網址:{url}\n\n{clipped}"); + // The links, buttons and fields, numbered for browser_click / browser_type / browser_select. + if let Ok((_, _, list, _)) = crate::browser::Tab::current(Some(&prof)).and_then(|mut t| crate::browser::elements(&mut t)) { + if !list.is_empty() { + out.push_str("\n\n可以操作的元素(用 browser_click、browser_type、browser_select 等工具的 ref;連結的網址在 → 後面,不要自己猜網址;看不懂時用 browser_screenshot):\n"); + out.push_str(&list.join("\n")); + } + } + if login_hint { + out.push_str("\n\n(這頁看起來要登入。請使用者在 AnyBot 瀏覽器視窗登入一次,之後就會記住。)"); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Live check against a running Chrome on ANYBOT_BROWSER_PORT (run by hand with --ignored). + #[test] + #[ignore] + fn reads_a_real_page() { + let r = open("https://example.com", None).unwrap(); + assert!(r.contains("example.com"), "{r}"); + let page = read(2000, None).unwrap(); + assert!(page.contains("Example Domain"), "{page}"); + println!("{page}"); + } + + #[test] + fn urls_are_encoded_for_new_tabs() { + assert_eq!(encode_component("https://www.threads.net/@me?x=1&y=2"), "https://www.threads.net/%40me%3Fx%3D1%26y%3D2"); + } + + #[test] + fn first_real_page_is_read() { + let list = serde_json::json!([ + {"type": "service_worker", "url": "https://x", "webSocketDebuggerUrl": "ws://a"}, + {"type": "page", "url": "devtools://devtools", "webSocketDebuggerUrl": "ws://b"}, + {"type": "page", "url": "https://www.threads.net/", "title": "Threads", "webSocketDebuggerUrl": "ws://c"} + ]); + assert_eq!(pick_page(&list).unwrap(), ("ws://c".to_string(), "Threads".to_string(), "https://www.threads.net/".to_string())); + } + + #[test] + fn profiles_are_found_by_name() { + let list = vec![DEFAULT_PROFILE.to_string(), "工作".to_string(), "Personal".to_string()]; + assert_eq!(match_profile(&list, " personal ").as_deref(), Some("Personal")); + assert_eq!(match_profile(&list, "工作").as_deref(), Some("工作")); + assert_eq!(match_profile(&list, "私人"), None); + assert_eq!(match_profile(&list, ""), None); + let with_person = vec![DEFAULT_PROFILE.to_string(), PERSON_PROFILE.to_string()]; + assert_eq!(match_profile(&with_person, "我的 Chrome").as_deref(), Some(PERSON_PROFILE)); + assert_eq!(match_profile(&list, "我的 Chrome"), None); + assert!(check_name("我的 Chrome").is_err()); + assert!(check_name(PERSON_PROFILE).is_err()); + } + + #[test] + fn profile_names_are_checked() { + assert_eq!(check_name(" 工作 ").unwrap(), "工作"); + assert!(check_name("").is_err()); + assert!(check_name("a/b").is_err()); + assert!(check_name("..").is_err()); + assert!(check_name(&"字".repeat(PROFILE_NAME_MAX_CHARS + 1)).is_err()); + assert!(check_name(&"字".repeat(PROFILE_NAME_MAX_CHARS)).is_ok()); + } + + #[test] + fn chosen_port_is_read_back() { + assert_eq!(parse_active_port("53211\n/devtools/browser/abc\n"), Some(53211)); + assert_eq!(parse_active_port("0\n"), None); + assert_eq!(parse_active_port(""), None); + } + +} diff --git a/src/browser.rs b/src/browser.rs new file mode 100644 index 0000000..de705cd --- /dev/null +++ b/src/browser.rs @@ -0,0 +1,919 @@ +// AnyBot's browser, the full toolset. botbrowser.rs starts Chrome and keeps profiles; this file +// drives a tab through the Chrome DevTools Protocol like a person would: +// - real mouse and keyboard (Input.dispatchMouseEvent / insertText / dispatchKeyEvent), so +// React date pickers, dropdowns and menus that ignore scripted events still respond; +// - a numbered list of everything clickable (links with their addresses, buttons, fields, +// dropdowns, tabs, calendar cells — anything the page gives a pointer cursor); +// - after each action only what changed (new address, new text), not the whole page again; +// - tab screenshots (no screen-recording permission needed), tables as Markdown, find on page, +// waiting for text, tabs, back/forward/reload, file uploads, downloads, PDF, page dialogs and +// running a script on the page. + +use serde_json::{json, Value}; +use std::io::ErrorKind; +use std::net::{SocketAddr, TcpStream}; +use std::time::{Duration, Instant}; + +use crate::botbrowser; + +const CALL_TIMEOUT_SECS: u64 = 30; +/// Longest element list in one answer. +const MAX_ELEMENTS: usize = 250; + +/// One DevTools connection to a tab. +pub struct Tab { + ws: tungstenite::WebSocket, + next: u64, +} + +fn targets(port: u16) -> Result, String> { + let v: Value = botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; + Ok(v.as_array().cloned().unwrap_or_default().into_iter().filter(|t| t["type"] == "page" && !t["url"].as_str().unwrap_or("").starts_with("devtools://")).collect()) +} + +/// The browser's port for a profile (starting it if needed). +pub fn port_of(profile: Option<&str>) -> Result<(String, u16), String> { + let prof = botbrowser::resolve(profile)?; + let (_, port, _) = botbrowser::ensure(&prof, None)?; + Ok((prof, port)) +} + +impl Tab { + /// The tab in front (Chrome lists the most recently used first). + pub fn current(profile: Option<&str>) -> Result { + let (_, port) = port_of(profile)?; + let list = targets(port)?; + let t = list.first().ok_or("AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開網址。")?; + Tab::connect(port, t) + } + + fn connect(port: u16, t: &Value) -> Result { + let ws_url = t["webSocketDebuggerUrl"].as_str().ok_or("這個分頁不能控制(可能已被別的工具連著)")?; + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| e.to_string())?; + stream.set_read_timeout(Some(Duration::from_secs(CALL_TIMEOUT_SECS))).ok(); + let (ws, _) = tungstenite::client::client(ws_url, stream).map_err(|e| format!("連不上 AnyBot 瀏覽器:{e}"))?; + Ok(Tab { ws, next: 0 }) + } + + /// One DevTools command; events that arrive meanwhile are skipped. + pub fn call(&mut self, method: &str, params: Value) -> Result { + self.next += 1; + let id = self.next; + self.ws.send(tungstenite::Message::Text(json!({"id": id, "method": method, "params": params}).to_string().into())).map_err(|e| e.to_string())?; + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(CALL_TIMEOUT_SECS) { + return Err("瀏覽器太久沒有回應(頁面可能跳出了對話框:可以用 browser_dialog 處理)".into()); + } + match self.ws.read() { + Ok(tungstenite::Message::Text(t)) => { + let v: Value = serde_json::from_str(t.as_str()).unwrap_or(Value::Null); + if v["id"] == id { + if let Some(e) = v.get("error") { + return Err(e["message"].as_str().unwrap_or("瀏覽器拒絕了這個動作").to_string()); + } + return Ok(v["result"].clone()); + } + } + Ok(_) => {} + Err(tungstenite::Error::Io(e)) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => return Err("瀏覽器太久沒有回應(頁面可能跳出了對話框:可以用 browser_dialog 處理)".into()), + Err(e) => return Err(e.to_string()), + } + } + } + + /// Run JavaScript and get its value back. + pub fn eval(&mut self, expr: &str) -> Result { + let r = self.call("Runtime.evaluate", json!({"expression": expr, "returnByValue": true, "awaitPromise": true, "userGesture": true}))?; + if let Some(ex) = r.get("exceptionDetails") { + return Err(format!("頁面上的程式出錯:{}", ex["exception"]["description"].as_str().or(ex["text"].as_str()).unwrap_or(""))); + } + Ok(r["result"]["value"].clone()) + } + + fn close(mut self) { + let _ = self.ws.close(None); + } +} + +// ---------- what the page offers ---------- + +/// Numbers everything clickable or fillable (data-anybot-ref) and lists it. +const ELEMENTS_JS: &str = r#"(() => { + const vis = el => { const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return false; const s = getComputedStyle(el); return s.visibility !== 'hidden' && s.display !== 'none' && +s.opacity > 0.05; }; + const clean = s => (s || '').replace(/\s+/g, ' ').trim(); + const name = el => clean(el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('placeholder') || el.title || el.getAttribute('alt') || (el.querySelector('img[alt]') || {}).alt || '').slice(0, 80); + const label = el => { const a = el.getAttribute('aria-label'); if (a) return clean(a).slice(0, 60); if (el.labels && el.labels[0]) return clean(el.labels[0].innerText).slice(0, 60); const id = el.getAttribute('aria-labelledby'); if (id) { const l = document.getElementById(id); if (l) return clean(l.innerText).slice(0, 60); } return clean(el.getAttribute('placeholder') || el.name || el.title || ''); }; + const base = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[contenteditable=""],[contenteditable=true],[role=button],[role=link],[role=tab],[role=menuitem],[role=menuitemcheckbox],[role=option],[role=checkbox],[role=radio],[role=combobox],[role=switch],[role=treeitem],[role=gridcell],[onclick],[tabindex]:not([tabindex="-1"])'; + const set = new Set(document.querySelectorAll(base)); + // Anything the page shows a pointer cursor for (calendar days, cards, custom buttons…). + for (const el of document.querySelectorAll('div,span,li,td,th,p,img,svg,i,label')) { + if (set.size > 4000) break; + if (getComputedStyle(el).cursor === 'pointer' && !(el.parentElement && getComputedStyle(el.parentElement).cursor === 'pointer')) set.add(el); + } + document.querySelectorAll('[data-anybot-ref]').forEach(e => e.removeAttribute('data-anybot-ref')); + const inView = el => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; }; + const ordered = [...set].filter(el => vis(el) && !el.closest('[aria-hidden=true]')).sort((a, b) => (a.compareDocumentPosition(b) & 4) ? -1 : 1); + const out = []; const seen = new Set(); let n = 0; + for (const el of ordered) { + const tag = el.tagName.toLowerCase(); const role = el.getAttribute('role') || ''; + let kind, text = name(el), extra = ''; + if (tag === 'a') { kind = '連結'; extra = el.href ? '→ ' + el.href : ''; } + else if (tag === 'select') { kind = '下拉選單'; text = label(el); extra = [...el.options].map(o => (o.selected ? '✓' : '') + clean(o.text)).slice(0, 20).join(' / '); } + else if (tag === 'input' || tag === 'textarea') { + const t = (el.type || 'text').toLowerCase(); + if (t === 'checkbox' || t === 'radio') { kind = t === 'checkbox' ? '勾選框' : '選項'; text = label(el) || text; extra = el.checked ? '已勾' : '未勾'; } + else if (t === 'submit' || t === 'button' || t === 'reset') kind = '按鈕'; + else if (t === 'file') { kind = '上傳檔案'; text = label(el) || '選擇檔案'; } + else { kind = '輸入框'; text = label(el); extra = t === 'password' ? (el.value ? '(已填)' : '(密碼)') : (el.value ? '目前:' + clean(el.value).slice(0, 40) : ''); if (el.readOnly) extra += '(唯讀,點它會打開選擇器)'; } + } + else if (el.isContentEditable) { kind = '輸入框'; text = label(el) || clean(el.innerText).slice(0, 40); } + else if (tag === 'button' || role === 'button' || tag === 'summary') kind = '按鈕'; + else if (role === 'tab') { kind = '分頁標籤'; if (el.getAttribute('aria-selected') === 'true') extra = '(目前)'; } + else if (role === 'menuitem' || role === 'menuitemcheckbox' || role === 'option' || role === 'treeitem') { kind = '選單項目'; if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true') extra = '(已選)'; } + else if (role === 'combobox') { kind = '下拉選單'; extra = clean(el.innerText).slice(0, 40); } + else if (role === 'link') kind = '連結'; + else if (role === 'checkbox' || role === 'switch' || role === 'radio') { kind = '勾選框'; extra = el.getAttribute('aria-checked') === 'true' ? '已勾' : '未勾'; } + else if (role === 'gridcell' || tag === 'td') { kind = '格子'; const full = el.getAttribute('title') || el.getAttribute('aria-label') || el.getAttribute('data-date') || (el.closest('[title]') || {}).title || ''; if (full && full !== text) text = text ? text + '(' + full + ')' : full; } + else { kind = '可點的項目'; const cell = el.closest('td[title],[role=gridcell][aria-label],[data-date]'); if (cell) { kind = '格子'; const full = cell.getAttribute('title') || cell.getAttribute('aria-label') || cell.getAttribute('data-date'); if (full && full !== text) text = text ? text + '(' + full + ')' : full; } } + if (!text && !extra) continue; + if (el.disabled || el.getAttribute('aria-disabled') === 'true') extra += '(停用)'; + const key = kind + '|' + text + '|' + extra; + if (seen.has(key)) continue; + seen.add(key); + n += 1; el.setAttribute('data-anybot-ref', String(n)); + out.push('[' + n + '] ' + kind + '「' + text + '」' + (extra ? ' ' + extra : '') + (inView(el) ? '' : ' (要捲動)')); + if (n >= %MAX%) break; + } + return JSON.stringify({t: document.title, u: location.href, list: out, more: ordered.length > n}); +})()"#; + +/// The numbered list of what can be clicked or filled. +pub fn elements(tab: &mut Tab) -> Result<(String, String, Vec, bool), String> { + let v = tab.eval(&ELEMENTS_JS.replace("%MAX%", &MAX_ELEMENTS.to_string()))?; + let p: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null); + let list = p["list"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(); + Ok((p["t"].as_str().unwrap_or("").into(), p["u"].as_str().unwrap_or("").into(), list, p["more"].as_bool().unwrap_or(false))) +} + +/// Draws a numbered box over every listed element that is on screen (removed after the shot). +const MARKS_JS: &str = r#"(() => { + const old = document.getElementById('__anybot_marks'); if (old) old.remove(); + const box = document.createElement('div'); box.id = '__anybot_marks'; + box.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647'; + const colors = ['#e11d48', '#2563eb', '#16a34a', '#d97706', '#9333ea']; + for (const el of document.querySelectorAll('[data-anybot-ref]')) { + const r = el.getBoundingClientRect(); + if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth || r.width < 2) continue; + const n = el.getAttribute('data-anybot-ref'); const c = colors[n % colors.length]; + const f = document.createElement('div'); + f.style.cssText = `position:fixed;left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;border:1.5px solid ${c};border-radius:3px;box-sizing:border-box`; + const tag = document.createElement('div'); + tag.textContent = n; + tag.style.cssText = `position:fixed;left:${Math.max(0, r.left - 1)}px;top:${Math.max(0, r.top - 15)}px;background:${c};color:#fff;font:bold 11px/14px -apple-system,Arial;padding:0 3px;border-radius:3px`; + box.appendChild(f); box.appendChild(tag); + } + document.documentElement.appendChild(box); + return 1; +})()"#; + +const SNAPSHOT_JS: &str = "JSON.stringify({t: document.title, u: location.href, x: document.body ? document.body.innerText : ''})"; + +fn snapshot(tab: &mut Tab) -> (String, String, Vec) { + let v = tab.eval(SNAPSHOT_JS).unwrap_or(Value::Null); + let p: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null); + let lines = p["x"].as_str().unwrap_or("").lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect(); + (p["t"].as_str().unwrap_or("").into(), p["u"].as_str().unwrap_or("").into(), lines) +} + +/// What an action changed: the address, and the lines of text that are new. +fn changes(before: &(String, String, Vec), after: &(String, String, Vec)) -> String { + let mut out = vec![]; + if before.1 != after.1 { + out.push(format!("網址變成:{}", after.1)); + } + if before.0 != after.0 { + out.push(format!("標題:{}", after.0)); + } + let old: std::collections::HashSet<&String> = before.2.iter().collect(); + let new: Vec<&String> = after.2.iter().filter(|l| !old.contains(l)).collect(); + let gone = before.2.iter().filter(|l| !after.2.contains(l)).count(); + if new.is_empty() && gone == 0 && before.1 == after.1 { + out.push("頁面看起來沒有變化(可能要等一下、換一個元素,或用 browser_screenshot 看看)。".into()); + } else if !new.is_empty() { + let shown: Vec = new.iter().take(40).map(|l| l.chars().take(160).collect()).collect(); + out.push(format!("新出現的文字:\n{}{}", shown.join("\n"), if new.len() > 40 { format!("\n…還有 {} 行(用 browser_read 看全部)", new.len() - 40) } else { String::new() })); + } + out.join("\n") +} + +// ---------- finding an element ---------- + +/// The element a bot means: its number, else visible text / label / placeholder. +fn find_js(r#ref: &str, text: &str) -> String { + let rf = serde_json::to_string(r#ref.trim().trim_matches(['[', ']'])).unwrap_or_default(); + let tx = serde_json::to_string(text.trim()).unwrap_or_default(); + format!( + r#"(() => {{ + const rf = {rf}, tx = {tx}; + if (rf) {{ const e = document.querySelector('[data-anybot-ref="' + rf + '"]'); if (e) return e; }} + if (!tx) return null; + const t = s => (s || '').replace(/\s+/g, ' ').trim(); + const vis = e => {{ const r = e.getBoundingClientRect(); return r.width > 1 && r.height > 1; }}; + const all = [...document.querySelectorAll('a,button,[role],input,select,textarea,label,summary,li,td,th,span,div,p,img')].filter(vis); + const hit = e => [t(e.innerText), t(e.getAttribute('aria-label')), t(e.getAttribute('placeholder')), t(e.title), t(e.value), e.labels && e.labels[0] ? t(e.labels[0].innerText) : '']; + return all.find(e => hit(e).includes(tx)) || all.filter(e => hit(e).some(h => h.includes(tx))).sort((a, b) => t(a.innerText).length - t(b.innerText).length)[0] || null; +}})()"# + ) +} + +/// Bring the element into view and get its centre (CSS pixels in the viewport). +fn locate(tab: &mut Tab, r#ref: &str, text: &str) -> Result<(f64, f64, String), String> { + let js = format!( + "(() => {{ const el = {}; if (!el) return null; el.scrollIntoView({{block: 'center', inline: 'center'}}); const r = el.getBoundingClientRect(); return JSON.stringify({{x: r.left + r.width / 2, y: r.top + r.height / 2, tag: el.tagName, type: el.type || '', ro: !!el.readOnly}}); }})()", + find_js(r#ref, text) + ); + let v = tab.eval(&js)?; + let what = if !r#ref.trim().is_empty() { format!("[{}]", r#ref.trim().trim_matches(['[', ']'])) } else { format!("「{}」", text.trim()) }; + let p: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).ok_or_else(|| format!("這一頁找不到 {what}。用 browser_read 或 browser_elements 看最新的清單(編號每次讀取會重排)。"))?; + Ok((p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0), format!("{what}{}", if p["tag"] == "INPUT" && p["ro"] == true { "(唯讀欄位)" } else { "" }))) +} + +fn mouse(tab: &mut Tab, kind: &str, x: f64, y: f64, button: &str, count: i64) -> Result<(), String> { + tab.call("Input.dispatchMouseEvent", json!({"type": kind, "x": x, "y": y, "button": button, "buttons": if kind == "mousePressed" { 1 } else { 0 }, "clickCount": count})).map(|_| ()) +} + +/// A real click (the page can't tell it from a person's). +fn click_at(tab: &mut Tab, x: f64, y: f64, button: &str, count: i64) -> Result<(), String> { + mouse(tab, "mouseMoved", x, y, "none", 0)?; + for n in 1..=count { + mouse(tab, "mousePressed", x, y, button, n)?; + mouse(tab, "mouseReleased", x, y, button, n)?; + } + Ok(()) +} + +/// Key names the bot may use → (key, code, keyCode, text). +fn key_info(k: &str) -> Option<(&'static str, &'static str, i64, &'static str)> { + Some(match k.to_lowercase().as_str() { + "enter" | "return" => ("Enter", "Enter", 13, "\r"), + "tab" => ("Tab", "Tab", 9, ""), + "escape" | "esc" => ("Escape", "Escape", 27, ""), + "backspace" => ("Backspace", "Backspace", 8, ""), + "delete" => ("Delete", "Delete", 46, ""), + "space" => (" ", "Space", 32, " "), + "arrowdown" | "down" => ("ArrowDown", "ArrowDown", 40, ""), + "arrowup" | "up" => ("ArrowUp", "ArrowUp", 38, ""), + "arrowleft" | "left" => ("ArrowLeft", "ArrowLeft", 37, ""), + "arrowright" | "right" => ("ArrowRight", "ArrowRight", 39, ""), + "pagedown" => ("PageDown", "PageDown", 34, ""), + "pageup" => ("PageUp", "PageUp", 33, ""), + "home" => ("Home", "Home", 36, ""), + "end" => ("End", "End", 35, ""), + "a" => ("a", "KeyA", 65, "a"), + "c" => ("c", "KeyC", 67, "c"), + "v" => ("v", "KeyV", 86, "v"), + "x" => ("x", "KeyX", 88, "x"), + "f" => ("f", "KeyF", 70, "f"), + _ => return None, + }) +} + +/// Press a key or combination ("Enter", "ctrl+a", "shift+tab"). +fn press(tab: &mut Tab, combo: &str) -> Result<(), String> { + let parts: Vec<&str> = combo.split('+').map(str::trim).filter(|s| !s.is_empty()).collect(); + let (key, mods) = parts.split_last().ok_or("請給按鍵,例如 Enter")?; + let mut m = 0; + for x in mods { + m |= match x.to_lowercase().as_str() { + "alt" | "option" => 1, + "ctrl" | "control" => 2, + "cmd" | "meta" | "command" => 4, + "shift" => 8, + other => return Err(format!("不認得的按鍵「{other}」")), + }; + } + let (k, code, kc, text) = key_info(key).ok_or_else(|| format!("不認得的按鍵「{key}」,可以用 Enter、Tab、Escape、Backspace、方向鍵、PageDown、Home、End,或 ctrl+a 這類組合"))?; + let text = if m & (1 | 2 | 4) != 0 { "" } else { text }; + tab.call("Input.dispatchKeyEvent", json!({"type": if text.is_empty() { "rawKeyDown" } else { "keyDown" }, "key": k, "code": code, "windowsVirtualKeyCode": kc, "modifiers": m, "text": text}))?; + tab.call("Input.dispatchKeyEvent", json!({"type": "keyUp", "key": k, "code": code, "windowsVirtualKeyCode": kc, "modifiers": m}))?; + Ok(()) +} + +fn settle(ms: u64) { + std::thread::sleep(Duration::from_millis(ms)); +} + +// ---------- the tools ---------- + +fn s(args: &Value, k: &str) -> String { + args.get(k).map(|v| v.as_str().map(String::from).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })).unwrap_or_default().trim().to_string() +} + +pub const NAMES: [&str; 18] = [ + "browser_elements", + "browser_click", + "browser_hover", + "browser_type", + "browser_press", + "browser_select", + "browser_scroll", + "browser_wait", + "browser_find", + "browser_table", + "browser_screenshot", + "browser_tabs", + "browser_back", + "browser_upload", + "browser_download", + "browser_pdf", + "browser_dialog", + "browser_eval", +]; + +pub fn is_tool(name: &str) -> bool { + NAMES.contains(&name) +} + +pub fn label(name: &str) -> &'static str { + match name { + "browser_elements" => "列出網頁上的按鈕", + "browser_click" => "在瀏覽器點擊", + "browser_hover" => "在瀏覽器停在上面", + "browser_type" => "在瀏覽器輸入", + "browser_press" => "在瀏覽器按鍵", + "browser_select" => "在瀏覽器選擇", + "browser_scroll" => "捲動瀏覽器", + "browser_wait" => "等網頁載入", + "browser_find" => "在網頁上找字", + "browser_table" => "讀網頁表格", + "browser_screenshot" => "瀏覽器截圖", + "browser_tabs" => "瀏覽器分頁", + "browser_back" => "瀏覽器上一頁", + "browser_upload" => "上傳檔案到網頁", + "browser_download" => "從網頁下載", + "browser_pdf" => "把網頁存成 PDF", + "browser_dialog" => "處理網頁對話框", + "browser_eval" => "在網頁執行程式", + _ => "瀏覽器", + } +} + +pub fn describe(name: &str, args: &str) -> String { + let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); + let target = if !s(&v, "ref").is_empty() { format!("[{}]", s(&v, "ref")) } else { s(&v, "text") }; + match name { + "browser_type" => format!("{target} ← {}", s(&v, "value").chars().take(40).collect::()), + "browser_select" => format!("{target} → {}", s(&v, "option")), + "browser_press" => s(&v, "key"), + "browser_scroll" => if target.is_empty() { s(&v, "direction") } else { target }, + "browser_wait" => if s(&v, "text").is_empty() { format!("{} 秒", s(&v, "seconds")) } else { format!("等「{}」", s(&v, "text")) }, + "browser_find" => format!("「{}」", s(&v, "text")), + "browser_tabs" => format!("{} {}", s(&v, "action"), s(&v, "index")), + "browser_back" => s(&v, "direction"), + "browser_upload" => s(&v, "path"), + "browser_eval" => s(&v, "script").chars().take(60).collect(), + _ => target, + } +} + +/// The tools' definitions (the model reads these). +pub fn specs(profile: &Value) -> Vec { + let target = |extra: Value| { + let mut p = json!({"ref": {"type": "string", "description": "The element's number from the latest browser_read / browser_elements / action result"}, "text": {"type": "string", "description": "Or its visible text, label or placeholder"}, "profile": profile}); + if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + p + }; + vec![ + json!({"name": "browser_elements", "description": "List everything that can be clicked or filled on the current tab of AnyBot's browser, numbered: links (with addresses), buttons, fields, dropdowns, tabs, menu items, calendar days and other clickable items. Items marked (要捲動) are off screen. Cheaper than browser_read when you only need the controls.", + "parameters": {"type": "object", "properties": {"profile": profile}}}), + json!({"name": "browser_click", "description": "Click an element in AnyBot's browser with a real mouse click (works on menus, date pickers, custom dropdowns, tabs, calendar days). By number or visible text. Returns what changed on the page. Use double for double-click, button=right for a context menu. Ask the person before anything that buys, pays, sends, posts or deletes.", + "parameters": {"type": "object", "properties": target(json!({"double": {"type": "boolean"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}}))}}), + json!({"name": "browser_hover", "description": "Move the mouse over an element (opens hover menus and tooltips) and report what appeared.", + "parameters": {"type": "object", "properties": target(json!({}))}}), + json!({"name": "browser_type", "description": "Type into a field with real keystrokes (clears it first unless append is true). Works for search boxes, forms and date fields (type the date the way the field shows it, then press Enter). Never type passwords: use browser_fill_login.", + "parameters": {"type": "object", "properties": target(json!({"value": {"type": "string"}, "append": {"type": "boolean"}, "submit": {"type": "boolean", "description": "Press Enter afterwards"}})), "required": ["value"]}}), + json!({"name": "browser_press", "description": "Press a key or combination in AnyBot's browser: Enter, Tab, Escape, Backspace, ArrowDown, PageDown, Home, End, ctrl+a, shift+tab… Optionally on an element first (it gets focus).", + "parameters": {"type": "object", "properties": target(json!({"key": {"type": "string"}})), "required": ["key"]}}), + json!({"name": "browser_select", "description": "Choose an option in a dropdown. Native dropdowns are set directly; custom ones are opened and the option is clicked for you.", + "parameters": {"type": "object", "properties": target(json!({"option": {"type": "string", "description": "The option's visible text, e.g. 近 30 天"}})), "required": ["option"]}}), + json!({"name": "browser_scroll", "description": "Scroll the page (down/up/top/bottom), or bring an element into view by number or text. Use it to load more items on long or endless lists.", + "parameters": {"type": "object", "properties": target(json!({"direction": {"type": "string", "enum": ["down", "up", "top", "bottom"]}}))}}), + json!({"name": "browser_wait", "description": "Wait until some text appears on the page (up to 20 s), or just wait some seconds, e.g. after starting a report or a slow search.", + "parameters": {"type": "object", "properties": {"text": {"type": "string"}, "seconds": {"type": "number"}, "profile": profile}}}), + json!({"name": "browser_find", "description": "Find text on the current page and return each place with the words around it (like ⌘F). Good for checking a number or a product name without reading the whole page.", + "parameters": {"type": "object", "properties": {"text": {"type": "string"}, "profile": profile}, "required": ["text"]}}), + json!({"name": "browser_table", "description": "Read the tables (and table-like grids) on the current page as Markdown, with headers. Use it for reports, product lists, orders and rankings instead of reading the raw text. index picks one table; all=true reads every table.", + "parameters": {"type": "object", "properties": {"index": {"type": "integer"}, "all": {"type": "boolean"}, "max_rows": {"type": "integer"}, "profile": profile}}}), + json!({"name": "browser_screenshot", "description": "See the current tab of AnyBot's browser as a picture with a numbered box on every clickable thing (the numbers are browser_click refs) — the quickest way to find where to click: look once, then click by number. Also for charts, layouts and captchas. full=true takes the whole page; ref/text takes just one element; marks=false for a clean picture. Needs no screen-recording permission.", + "parameters": {"type": "object", "properties": target(json!({"full": {"type": "boolean"}, "marks": {"type": "boolean"}}))}}), + json!({"name": "browser_tabs", "description": "The tabs of AnyBot's browser: list them, switch to one (index), open a new one (url), or close one (index).", + "parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "switch", "new", "close"]}, "index": {"type": "integer"}, "url": {"type": "string"}, "profile": profile}, "required": ["action"]}}), + json!({"name": "browser_back", "description": "Go back, forward or reload the current tab.", + "parameters": {"type": "object", "properties": {"direction": {"type": "string", "enum": ["back", "forward", "reload"]}, "profile": profile}}}), + json!({"name": "browser_upload", "description": "Put a file from this computer into a page's file chooser (the field listed as 上傳檔案). path is a full path on this computer.", + "parameters": {"type": "object", "properties": target(json!({"path": {"type": "string"}})), "required": ["path"]}}), + json!({"name": "browser_download", "description": "Click a download/export button (by number or text) and wait for the file; returns where it was saved (Downloads/AnyBot). Use for 匯出報表 / Export CSV, then read the file.", + "parameters": {"type": "object", "properties": target(json!({}))}}), + json!({"name": "browser_pdf", "description": "Save the current page as a PDF in Downloads/AnyBot and return its path.", + "parameters": {"type": "object", "properties": {"profile": profile}}}), + json!({"name": "browser_dialog", "description": "Answer a pop-up dialog the page opened (alert / confirm / prompt) when the browser stops responding: accept or dismiss, with text for prompts.", + "parameters": {"type": "object", "properties": {"accept": {"type": "boolean"}, "text": {"type": "string"}, "profile": profile}, "required": ["accept"]}}), + json!({"name": "browser_eval", "description": "Run a short read-only JavaScript expression on the current page and return its value (JSON), for data the other tools can't reach (e.g. values inside a chart). It can't send requests, navigate or submit: never call a site's own APIs or download its code — that gets the account flagged as a bot.", + "parameters": {"type": "object", "properties": {"script": {"type": "string"}, "profile": profile}, "required": ["script"]}}), + ] +} + +fn downloads_dir() -> std::path::PathBuf { + let d = dirs::download_dir().unwrap_or_else(std::env::temp_dir).join("AnyBot"); + let _ = std::fs::create_dir_all(&d); + d +} + +/// Run one browser tool. Returns (text for the bot, a JPEG as base64 when it took a picture). +pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> { + let profile = Some(s(args, "profile")).filter(|p| !p.is_empty()); + let profile = profile.as_deref(); + let (rf, text) = (s(args, "ref"), s(args, "text")); + match name { + "browser_tabs" => return tabs(args, profile).map(|t| (t, None)), + "browser_wait" if s(args, "text").is_empty() => { + let secs = args["seconds"].as_f64().unwrap_or(2.0).clamp(0.2, 30.0); + settle((secs * 1000.0) as u64); + return Ok((format!("已等 {secs} 秒。"), None)); + } + _ => {} + } + let mut tab = Tab::current(profile)?; + let r = (|| -> Result<(String, Option), String> { + match name { + "browser_elements" => { + let (title, url, list, more) = elements(&mut tab)?; + Ok((format!("標題:{title}\n網址:{url}\n\n{}{}", list.join("\n"), if more { "\n…還有更多(捲動後再列一次)" } else { "" }), None)) + } + "browser_click" | "browser_hover" => { + let before = snapshot(&mut tab); + let (x, y, what) = locate(&mut tab, &rf, &text)?; + settle(150); + if name == "browser_hover" { + // Travel onto it (a jump straight there can skip the hover handlers). + mouse(&mut tab, "mouseMoved", x - 40.0, y - 20.0, "none", 0)?; + settle(60); + mouse(&mut tab, "mouseMoved", x - 10.0, y - 4.0, "none", 0)?; + settle(60); + mouse(&mut tab, "mouseMoved", x, y, "none", 0)?; + // Chrome may not update hover for a window that isn't in front: fire the + // hover events on the element too. + tab.eval(&format!("(() => {{ const el = {}; if (!el) return 0; const r = el.getBoundingClientRect(); const o = {{bubbles: true, cancelable: true, view: window, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2}}; for (const t of ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove']) el.dispatchEvent(new (t.startsWith('pointer') ? PointerEvent : MouseEvent)(t, o)); return 1; }})()", find_js(&rf, &text)))?; + } else { + let button = match s(args, "button").as_str() { + "right" => "right", + "middle" => "middle", + _ => "left", + }; + click_at(&mut tab, x, y, button, if args["double"].as_bool().unwrap_or(false) { 2 } else { 1 })?; + } + settle(1500); + let after = snapshot(&mut tab); + Ok((format!("已{} {what}。\n{}", if name == "browser_hover" { "停在" } else { "點" }, changes(&before, &after)), None)) + } + "browser_type" => { + let before = snapshot(&mut tab); + let (x, y, what) = locate(&mut tab, &rf, &text)?; + click_at(&mut tab, x, y, "left", 1)?; + settle(150); + if !args["append"].as_bool().unwrap_or(false) { + // Select what's there so typing replaces it (works in inputs and editors). + tab.eval("(() => { const e = document.activeElement; if (e && 'select' in e && typeof e.select === 'function') e.select(); else document.execCommand('selectAll'); return 1; })()")?; + press(&mut tab, "Backspace")?; + } + tab.call("Input.insertText", json!({"text": s(args, "value")}))?; + if args["submit"].as_bool().unwrap_or(false) { + settle(150); + press(&mut tab, "Enter")?; + } + settle(1200); + let after = snapshot(&mut tab); + let now: String = tab.eval("(() => { const e = document.activeElement; return e && 'value' in e ? (e.type === 'password' ? '' : String(e.value)) : ''; })()")?.as_str().unwrap_or("").chars().take(80).collect(); + Ok((format!("已在 {what} 輸入。{}\n{}", if now.is_empty() { String::new() } else { format!("欄位現在是「{now}」。") }, changes(&before, &after)), None)) + } + "browser_press" => { + let before = snapshot(&mut tab); + if !rf.is_empty() || !text.is_empty() { + let (x, y, _) = locate(&mut tab, &rf, &text)?; + click_at(&mut tab, x, y, "left", 1)?; + settle(150); + } + press(&mut tab, &s(args, "key"))?; + settle(1000); + let after = snapshot(&mut tab); + Ok((format!("已按 {}。\n{}", s(args, "key"), changes(&before, &after)), None)) + } + "browser_select" => { + let option = s(args, "option"); + let before = snapshot(&mut tab); + let js = format!( + r#"(() => {{ const el = {}; if (!el) return 'missing'; if (el.tagName !== 'SELECT') return 'custom'; + const want = {}.toLowerCase(); const o = [...el.options].find(o => o.text.trim().toLowerCase() === want || o.value.toLowerCase() === want) || [...el.options].find(o => o.text.trim().toLowerCase().includes(want)); + if (!o) return 'no-option:' + [...el.options].map(o => o.text.trim()).join(' / '); + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(el, o.value); + el.dispatchEvent(new Event('input', {{bubbles: true}})); el.dispatchEvent(new Event('change', {{bubbles: true}})); return 'ok'; }})()"#, + find_js(&rf, &text), + serde_json::to_string(&option).unwrap_or_default() + ); + let r = tab.eval(&js)?.as_str().unwrap_or("").to_string(); + if r == "missing" { + return Err("這一頁找不到那個下拉選單。用 browser_elements 看最新的清單。".into()); + } + if let Some(opts) = r.strip_prefix("no-option:") { + return Err(format!("沒有「{option}」這個選項。可選:{opts}")); + } + if r == "custom" { + // A custom dropdown: open it with a real click, then click the option. + let (x, y, _) = locate(&mut tab, &rf, &text)?; + click_at(&mut tab, x, y, "left", 1)?; + settle(700); + let (ox, oy, _) = locate(&mut tab, "", &option).map_err(|_| format!("已打開選單,但找不到「{option}」這個選項。用 browser_elements 看選單裡有什麼。"))?; + click_at(&mut tab, ox, oy, "left", 1)?; + } + settle(1200); + let after = snapshot(&mut tab); + Ok((format!("已選「{option}」。\n{}", changes(&before, &after)), None)) + } + "browser_scroll" => { + let before = snapshot(&mut tab); + if !rf.is_empty() || !text.is_empty() { + let (_, _, what) = locate(&mut tab, &rf, &text)?; + settle(500); + let after = snapshot(&mut tab); + return Ok((format!("已捲到 {what}。\n{}", changes(&before, &after)), None)); + } + let js = match s(args, "direction").as_str() { + "up" => "window.scrollBy(0, -innerHeight * 0.85)", + "top" => "window.scrollTo(0, 0)", + "bottom" => "window.scrollTo(0, document.documentElement.scrollHeight)", + _ => "window.scrollBy(0, innerHeight * 0.85)", + }; + // Pages that scroll an inner box: scroll the biggest scrollable element too. + tab.eval(&format!("(() => {{ {js}; const boxes = [...document.querySelectorAll('*')].filter(e => e.scrollHeight > e.clientHeight + 40 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)).sort((a, b) => b.clientHeight - a.clientHeight); if (boxes[0]) {{ const b = boxes[0]; const d = '{}'; if (d === 'top') b.scrollTop = 0; else if (d === 'bottom') b.scrollTop = b.scrollHeight; else b.scrollTop += (d === 'up' ? -1 : 1) * b.clientHeight * 0.85; }} return 1; }})()", s(args, "direction")))?; + settle(1200); + let after = snapshot(&mut tab); + let pos = tab.eval("JSON.stringify([Math.round(scrollY), document.documentElement.scrollHeight, innerHeight])")?; + Ok((format!("已捲動(位置 {})。\n{}", pos.as_str().unwrap_or(""), changes(&before, &after)), None)) + } + "browser_wait" => { + let want = s(args, "text"); + let limit = args["seconds"].as_f64().unwrap_or(20.0).clamp(1.0, 60.0); + let start = Instant::now(); + while start.elapsed().as_secs_f64() < limit { + let (_, _, lines) = snapshot(&mut tab); + if lines.iter().any(|l| l.contains(&want)) { + return Ok((format!("「{want}」出現了({:.1} 秒)。", start.elapsed().as_secs_f64()), None)); + } + settle(500); + } + Err(format!("等了 {limit} 秒,「{want}」沒有出現。")) + } + "browser_find" => { + let want = s(args, "text"); + let (_, url, lines) = snapshot(&mut tab); + let hits: Vec = lines.iter().enumerate().filter(|(_, l)| l.to_lowercase().contains(&want.to_lowercase())).take(30).map(|(i, _)| { + let ctx: Vec<&str> = lines[i.saturating_sub(2)..(i + 3).min(lines.len())].iter().map(String::as_str).collect(); + format!("• {}", ctx.join(" | ").chars().take(300).collect::()) + }).collect(); + if hits.is_empty() { + return Ok((format!("這一頁({url})找不到「{want}」。"), None)); + } + Ok((format!("「{want}」出現 {} 處:\n{}", hits.len(), hits.join("\n")), None)) + } + "browser_table" => table(&mut tab, args).map(|t| (t, None)), + "browser_screenshot" => { + let mut params = json!({"format": "jpeg", "quality": 75}); + // Numbered boxes on every clickable thing in view (the default for a plain + // screenshot): look once, then click by number. + let marks = args["marks"].as_bool().unwrap_or(rf.is_empty() && text.is_empty()); + let mut listed = vec![]; + if marks { + let (_, _, list, _) = elements(&mut tab)?; + tab.eval(MARKS_JS)?; + let in_view: std::collections::HashSet = tab.eval("JSON.stringify([...document.querySelectorAll('[data-anybot-ref]')].filter(e => { const r = e.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth && r.width > 1; }).map(e => e.getAttribute('data-anybot-ref')))")?.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); + listed = list.into_iter().filter(|l| l.strip_prefix('[').and_then(|r| r.split(']').next()).map(|n| in_view.contains(n)).unwrap_or(false)).collect(); + settle(120); + } + if !rf.is_empty() || !text.is_empty() { + let v = tab.eval(&format!("(() => {{ const el = {}; if (!el) return null; el.scrollIntoView({{block: 'center'}}); const r = el.getBoundingClientRect(); return JSON.stringify([r.left + scrollX, r.top + scrollY, r.width, r.height]); }})()", find_js(&rf, &text)))?; + let r: Vec = v.as_str().and_then(|s| serde_json::from_str(s).ok()).ok_or("找不到那個元素")?; + settle(300); + params["clip"] = json!({"x": r[0], "y": r[1], "width": r[2].max(1.0), "height": r[3].max(1.0), "scale": 1}); + params["captureBeyondViewport"] = json!(true); + } else if args["full"].as_bool().unwrap_or(false) { + let m = tab.call("Page.getLayoutMetrics", json!({}))?; + let (w, h) = (m["cssContentSize"]["width"].as_f64().unwrap_or(1280.0), m["cssContentSize"]["height"].as_f64().unwrap_or(2000.0).min(12000.0)); + params["clip"] = json!({"x": 0, "y": 0, "width": w, "height": h, "scale": 1}); + params["captureBeyondViewport"] = json!(true); + } + let r = tab.call("Page.captureScreenshot", params); + if marks { + let _ = tab.eval("(() => { const o = document.getElementById('__anybot_marks'); if (o) o.remove(); return 1; })()"); + } + let r = r?; + let b64 = r["data"].as_str().ok_or("截圖失敗")?.to_string(); + // Keep pictures a sensible size for the model. + let b64 = shrink(&b64).unwrap_or(b64); + let (title, url, _) = snapshot(&mut tab); + let mut text_out = format!("AnyBot 瀏覽器分頁的截圖:{title}\n網址:{url}"); + if marks && !listed.is_empty() { + text_out.push_str(&format!("\n圖上紅框裡的數字就是元素編號,用 browser_click 的 ref 點它:\n{}", listed.join("\n"))); + } + Ok((text_out, Some(b64))) + } + "browser_back" => { + let before = snapshot(&mut tab); + match s(args, "direction").as_str() { + "forward" => tab.eval("history.forward()").map(|_| ())?, + "reload" => tab.call("Page.reload", json!({})).map(|_| ())?, + _ => tab.eval("history.back()").map(|_| ())?, + } + settle(2000); + let after = snapshot(&mut tab); + Ok((format!("好了。\n{}", changes(&before, &after)), None)) + } + "browser_upload" => { + let path = s(args, "path"); + let full = crate::tools::expand_home(&path); + if !full.is_file() { + return Err(format!("這台電腦上沒有「{path}」這個檔案")); + } + let js = format!("(() => {{ let el = {}; if (el && el.tagName !== 'INPUT') el = el.querySelector('input[type=file]') || el.closest('label')?.querySelector('input[type=file]'); return el || document.querySelector('input[type=file]'); }})()", find_js(&rf, &text)); + let r = tab.call("Runtime.evaluate", json!({"expression": js, "returnByValue": false}))?; + let obj = r["result"]["objectId"].as_str().ok_or("這一頁找不到上傳檔案的欄位")?.to_string(); + tab.call("DOM.setFileInputFiles", json!({"files": [full.display().to_string()], "objectId": obj}))?; + tab.eval(&format!("(() => {{ const el = {js}; if (el) el.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()"))?; + settle(1000); + Ok((format!("已把 {} 放進上傳欄位。", full.display()), None)) + } + "browser_download" => { + let dir = downloads_dir(); + let clicked_at = std::time::SystemTime::now() - Duration::from_secs(1); + tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir.display().to_string(), "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir.display().to_string()})))?; + let (x, y, what) = locate(&mut tab, &rf, &text)?; + click_at(&mut tab, x, y, "left", 1)?; + let start = Instant::now(); + while start.elapsed().as_secs() < 60 { + settle(700); + // A new file, or one written again since the click (same name replaced). + let now: Vec = std::fs::read_dir(&dir).map(|r| r.flatten().filter(|e| e.metadata().and_then(|m| m.modified()).map(|t| t >= clicked_at).unwrap_or(false)).map(|e| e.path()).collect()).unwrap_or_default(); + if let Some(f) = now.iter().find(|p| !p.extension().map(|e| e == "crdownload" || e == "tmp").unwrap_or(false)) { + return Ok((format!("已點 {what},下載好了:{}(可以用 read_file 讀它)", f.display()), None)); + } + } + Err(format!("已點 {what},但 60 秒內沒有下載到檔案(有些網站要先選格式或日期,或在新分頁產生報表)。")) + } + "browser_pdf" => { + let r = tab.call("Page.printToPDF", json!({"printBackground": true}))?; + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or("")).map_err(|e| e.to_string())?; + let (title, _, _) = snapshot(&mut tab); + let safe: String = title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).take(60).collect(); + let path = downloads_dir().join(format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Local::now().format("%Y%m%d-%H%M%S"))); + std::fs::write(&path, bytes).map_err(|e| e.to_string())?; + Ok((format!("已存成 PDF:{}", path.display()), None)) + } + "browser_dialog" => { + let mut p = json!({"accept": args["accept"].as_bool().unwrap_or(true)}); + if !s(args, "text").is_empty() { + p["promptText"] = json!(s(args, "text")); + } + tab.call("Page.handleJavaScriptDialog", p).map_err(|_| "現在沒有對話框".to_string())?; + Ok(("已處理對話框。".into(), None)) + } + "browser_eval" => { + let script = s(args, "script"); + if let Some(bad) = eval_refusal(&script) { + return Err(bad); + } + let v = tab.eval(&script)?; + let out = if v.is_string() { v.as_str().unwrap_or("").to_string() } else { serde_json::to_string_pretty(&v).unwrap_or_default() }; + Ok((out.chars().take(20000).collect(), None)) + } + other => Err(format!("沒有「{other}」這個工具")), + } + })(); + tab.close(); + r +} + +/// browser_eval only reads the page: no requests to the site's servers, no navigation, no +/// submitting. Calling a site's private APIs gets accounts flagged (Shopee answered with a captcha). +pub fn eval_refusal(script: &str) -> Option { + let l = script.to_lowercase().replace(' ', ""); + let banned = ["fetch(", "xmlhttprequest", "sendbeacon", "websocket(", "eventsource(", ".submit(", "location=", "location.href=", "location.assign", "location.replace", "window.open(", "document.cookie=", "localstorage.setitem", "import(", "navigator.serviceworker", "window.fetch="]; + banned.iter().find(|b| l.contains(*b)).map(|b| { + format!("browser_eval 只能讀取頁面上已經有的東西,不能發送請求或改變網站(不允許 {b})。請像人一樣用 browser_click / browser_type / browser_select 操作頁面;直接呼叫網站的內部 API 會被當成機器人(例如跳出拼圖驗證)。") + }) +} + +/// Re-encode a screenshot no wider than 1280 px. +fn shrink(b64: &str) -> Option { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + let img = image::load_from_memory(&bytes).ok()?; + let img = if img.width() > 1280 { img.resize(1280, u32::MAX, image::imageops::FilterType::Triangle) } else { img }; + let img = if img.height() > 4000 { img.crop_imm(0, 0, img.width(), 4000) } else { img }; + let mut out = std::io::Cursor::new(vec![]); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 72).encode_image(&img.to_rgb8()).ok()?; + Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner())) +} + +fn tabs(args: &Value, profile: Option<&str>) -> Result { + let (_, port) = port_of(profile)?; + let http = |path: &str, put: bool| -> Result { + let url = format!("http://127.0.0.1:{port}{path}"); + let r = if put { botbrowser::local().put(&url).send_empty() } else { botbrowser::local().get(&url).call() }; + r.map_err(|e| e.to_string())?.into_body().read_to_string().map_err(|e| e.to_string()) + }; + let list = targets(port)?; + let idx = args["index"].as_u64().map(|i| i as usize); + let pick = |i: Option| -> Result<&Value, String> { i.and_then(|i| list.get(i.wrapping_sub(1))).ok_or_else(|| "請給分頁編號(先用 action=list 看)".to_string()) }; + match s(args, "action").as_str() { + "switch" => { + let t = pick(idx)?; + http(&format!("/json/activate/{}", t["id"].as_str().unwrap_or("")), false)?; + Ok(format!("已切到分頁 {}:{}({})", idx.unwrap_or(0), t["title"].as_str().unwrap_or(""), t["url"].as_str().unwrap_or(""))) + } + "close" => { + let t = pick(idx)?; + http(&format!("/json/close/{}", t["id"].as_str().unwrap_or("")), false)?; + Ok(format!("已關閉分頁:{}", t["title"].as_str().unwrap_or(""))) + } + "new" => { + let url = crate::web::normalize_url(&s(args, "url")).unwrap_or_else(|_| "about:blank".into()); + http(&format!("/json/new?{}", botbrowser::encode_component(&url)), true)?; + settle(2500); + Ok(format!("已開新分頁:{url}")) + } + _ => Ok(if list.is_empty() { + "沒有開著的分頁。".into() + } else { + list.iter().enumerate().map(|(i, t)| format!("{}. {}{}\n {}", i + 1, t["title"].as_str().unwrap_or(""), if i == 0 { "(目前)" } else { "" }, t["url"].as_str().unwrap_or(""))).collect::>().join("\n") + }), + } +} + +/// HTML tables and ARIA grids as Markdown. +fn table(tab: &mut Tab, args: &Value) -> Result { + let js = r#"(() => { + const clean = s => (s || '').replace(/\s+/g, ' ').trim().replace(/\|/g, '/'); + const vis = el => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; }; + const tables = []; + for (const t of document.querySelectorAll('table')) { + if (!vis(t)) continue; + const rows = [...t.querySelectorAll('tr')].map(r => [...r.children].filter(c => c.tagName === 'TD' || c.tagName === 'TH').map(c => clean(c.innerText))).filter(r => r.some(x => x)); + if (rows.length) tables.push({caption: clean((t.caption || {}).innerText || t.getAttribute('aria-label') || ''), rows}); + } + for (const g of document.querySelectorAll('[role=grid],[role=table],[role=treegrid]')) { + if (!vis(g) || g.tagName === 'TABLE') continue; + const rows = [...g.querySelectorAll('[role=row]')].map(r => [...r.querySelectorAll('[role=columnheader],[role=cell],[role=gridcell],[role=rowheader]')].map(c => clean(c.innerText))).filter(r => r.some(x => x)); + if (rows.length) tables.push({caption: clean(g.getAttribute('aria-label') || ''), rows}); + } + // Fixed-header tables split into a header table and a body table (common in admin pages): + // glue a lone header row onto the next table with the same width. + const out = []; + for (let i = 0; i < tables.length; i++) { + const t = tables[i], n = tables[i + 1]; + if (t.rows.length === 1 && n && n.rows[0] && n.rows[0].length === t.rows[0].length) { out.push({caption: t.caption || n.caption, rows: t.rows.concat(n.rows)}); i++; } + else out.push(t); + } + return JSON.stringify(out); +})()"#; + let v = tab.eval(js)?; + let tables: Vec = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); + if tables.is_empty() { + return Ok("這一頁沒有表格。數字如果在圖表裡,用 browser_screenshot 看,或用 browser_read 讀文字。".into()); + } + let max_rows = args["max_rows"].as_u64().unwrap_or(200) as usize; + let pick: Vec<(usize, &Value)> = match (args["index"].as_u64(), args["all"].as_bool().unwrap_or(false)) { + (Some(i), _) => tables.iter().enumerate().filter(|(j, _)| *j + 1 == i as usize).collect(), + (None, true) => tables.iter().enumerate().collect(), + (None, false) => tables.iter().enumerate().max_by_key(|(_, t)| t["rows"].as_array().map(|r| r.len()).unwrap_or(0)).into_iter().collect(), + }; + let mut out = format!("這一頁有 {} 個表格。\n", tables.len()); + for (i, t) in pick { + let rows: Vec> = t["rows"].as_array().map(|rs| rs.iter().map(|r| r.as_array().map(|c| c.iter().map(|x| x.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default()).collect()).unwrap_or_default(); + let width = rows.iter().map(|r| r.len()).max().unwrap_or(0); + out.push_str(&format!("\n### 表格 {}{}({} 列)\n", i + 1, if t["caption"].as_str().unwrap_or("").is_empty() { String::new() } else { format!(":{}", t["caption"].as_str().unwrap_or("")) }, rows.len().saturating_sub(1))); + for (k, r) in rows.iter().take(max_rows + 1).enumerate() { + let mut cells = r.clone(); + cells.resize(width, String::new()); + out.push_str(&format!("| {} |\n", cells.join(" | "))); + if k == 0 { + out.push_str(&format!("|{}\n", "---|".repeat(width))); + } + } + if rows.len() > max_rows + 1 { + out.push_str(&format!("…還有 {} 列(可以捲動或換頁再讀)\n", rows.len() - max_rows - 1)); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_and_changes() { + assert_eq!(key_info("Enter").unwrap().0, "Enter"); + assert!(key_info("F13").is_none()); + let a = ("t".to_string(), "u1".to_string(), vec!["a".to_string(), "b".to_string()]); + let b = ("t".to_string(), "u2".to_string(), vec!["a".to_string(), "c".to_string()]); + let c = changes(&a, &b); + assert!(c.contains("網址變成:u2") && c.contains("新出現的文字:\nc")); + assert!(changes(&a, &a).contains("沒有變化")); + assert_eq!(NAMES.len(), specs(&json!({"type": "string"})).len()); + } + + /// A dashboard like Shopee's affiliate pages: a React-style date picker that only reacts to + /// real clicks, a custom dropdown, a table, a hover menu, an export. Run with ANYBOT_DATA_DIR + /// set to a scratch folder; opens a browser window for a moment. + #[test] + #[ignore] + fn drives_a_dashboard() { + use std::io::{Read, Write}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + for mut c in l.incoming().flatten() { + let mut buf = [0u8; 4096]; + let n = c.read(&mut buf).unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let (ctype, body): (&str, Vec) = if req.starts_with("GET /export") { + ("text/csv\r\nContent-Disposition: attachment; filename=\"report.csv\"", "商品,佣金\nA,10\n".as_bytes().to_vec()) + } else { + ("text/html; charset=utf-8", r#"成效 +
2026-11-03
+ + +
預設
+ +
商品分潤率
手機殼8%
乳液12%
+

點擊 0

匯出報表 +"#.as_bytes().to_vec()) + }; + let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + let _ = c.write_all(&body); + } + }); + crate::botbrowser::add("drive-test").unwrap(); + let p = |v: Value| { let mut v = v; v["profile"] = json!("drive-test"); v }; + let go = || -> Result<(), String> { + crate::botbrowser::open(&format!("http://127.0.0.1:{port}/"), Some("drive-test"))?; + let (els, _) = run("browser_elements", &p(json!({})))?; + assert!(els.contains("「數據周期」"), "{els}"); + run("browser_hover", &p(json!({"text": "商品分析"})))?; + let (shown, _) = run("browser_eval", &p(json!({"script": "getComputedStyle(document.getElementById('sub')).display"})))?; + assert_eq!(shown.trim(), "block"); + // Real click opens the picker; a calendar day (only a pointer cursor) is listed and clickable. + let (r, _) = run("browser_click", &p(json!({"text": "數據周期"})))?; + assert!(r.contains("近30天"), "{r}"); + let (r, _) = run("browser_click", &p(json!({"text": "近30天"})))?; + assert!(r.contains("點擊 60,訂單 3"), "{r}"); + let (r, _) = run("browser_select", &p(json!({"text": "排序", "option": "佣金由高到低"})))?; + assert!(r.contains("佣金由高到低"), "{r}"); + let (r, _) = run("browser_type", &p(json!({"text": "搜尋商品", "value": "手機殼", "submit": true})))?; + assert!(r.contains("搜尋:手機殼"), "{r}"); + let (t, _) = run("browser_table", &p(json!({})))?; + assert!(t.contains("| 商品 | 分潤率 |") && t.contains("| 乳液 | 12% |"), "{t}"); + let (f, _) = run("browser_find", &p(json!({"text": "手機殼"})))?; + assert!(f.contains("搜尋:手機殼") && f.contains("8%"), "{f}"); + let (_, img) = run("browser_screenshot", &p(json!({})))?; + assert!(img.map(|b| b.len() > 1000).unwrap_or(false)); + let (tabs, _) = run("browser_tabs", &p(json!({"action": "list"})))?; + assert!(tabs.contains("成效"), "{tabs}"); + let (d, _) = run("browser_download", &p(json!({"text": "匯出報表"})))?; + assert!(d.contains("report") && d.contains(".csv"), "{d}"); + let (v, _) = run("browser_eval", &p(json!({"script": "document.querySelectorAll('[role=row]').length"})))?; + assert_eq!(v.trim(), "3"); + assert!(run("browser_eval", &p(json!({"script": "fetch('/api/x')"}))).is_err()); + let (t, img) = run("browser_screenshot", &p(json!({})))?; + assert!(img.is_some() && t.contains("紅框") && t.contains("[1]"), "{t}"); + let (gone, _) = run("browser_eval", &p(json!({"script": "!document.getElementById('__anybot_marks')"})))?; + assert_eq!(gone.trim(), "true"); + Ok(()) + }; + let r = go(); + let _ = crate::botbrowser::remove("drive-test"); + r.unwrap(); + } +} diff --git a/src/channels.rs b/src/channels.rs new file mode 100644 index 0000000..d133a48 --- /dev/null +++ b/src/channels.rs @@ -0,0 +1,805 @@ +// 訊息管道: a bot answers people in Slack, Discord or Telegram (the original: Slack / Microsoft +// Teams / Discord apps, plus Telegram-style messaging channels). The original's servers receive +// those messages; a desktop app has no public address, so here each channel is polled with the +// person's own bot token, and the bot's final answer of that turn is posted back (in a thread / +// as a reply to the message). + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +/// How often Slack and Discord are checked (estimated; within both APIs' rate limits). +pub const POLL_SECS: u64 = 3; +/// Telegram long-poll wait. +pub const TELEGRAM_WAIT_SECS: u64 = 25; +/// Longest single message each service takes. +pub const DISCORD_MAX: usize = 2000; +pub const TELEGRAM_MAX: usize = 4096; +pub const SLACK_MAX: usize = 39_000; + +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] +pub enum Kind { + Slack, + Discord, + Telegram, + Teams, + /// Mac「訊息」(iMessage / SMS). + IMessage, + /// Mail on this Mac. + Email, + /// 專屬號碼: texts to the person's Twilio number. + Sms, + /// X: @mentions, answered as replies. + X, +} + +impl Kind { + pub fn label(self) -> &'static str { + match self { + Kind::Slack => "Slack", + Kind::Discord => "Discord", + Kind::Telegram => "Telegram", + Kind::Teams => "Teams", + Kind::IMessage => "訊息", + Kind::Email => "郵件", + Kind::Sms => "簡訊(Twilio)", + Kind::X => "X", + } + } + pub fn token_hint(self) -> &'static str { + match self { + Kind::Slack => "Bot User OAuth Token(xoxb-…)。在 api.slack.com/apps 建立 App,加上 channels:history、groups:history、im:history、chat:write 權限並安裝到工作區,再把 App 邀請進頻道。", + Kind::Discord => "Bot Token。在 discord.com/developers 建立 Application → Bot,打開 Message Content Intent,再用 OAuth2 把 Bot 加進你的伺服器。", + Kind::Telegram => "跟 @BotFather 說 /newbot 拿到的 Token(123456:ABC…)。", + Kind::IMessage => "不用 Token:用這台 Mac 的「訊息」。讀新訊息要給 AnyBot「完全取用磁碟」權限(系統設定 → 隱私權與安全性),傳訊息時 macOS 會問一次能否控制「訊息」。", + Kind::Email => "不用 Token:用這台 Mac 的「郵件」收信和回信。macOS 會問一次能否控制「郵件」。", + Kind::Sms => "「Account SID:Auth Token」(在 console.twilio.com 首頁)。先在 Twilio 買一個號碼;Bot 會回覆傳到這個號碼的簡訊,也能用它打電話和傳簡訊(每次都先問你)。", + Kind::X => "X 的使用者存取權杖(OAuth 2.0,需要 tweet.read、tweet.write、users.read 權限;在 developer.x.com 建立 App 取得)。Bot 會回覆提到你的貼文,也能用你的帳號發文(每次都先問你)。讀取提及需要 X API 付費方案。", + Kind::Teams => "不用 Token:填你的 Microsoft Entra 應用程式(用戶端)ID,按「登入 Microsoft」用裝置碼登入。應用程式要在「驗證」打開「允許公用用戶端流程」,並加上 Chat.ReadWrite、ChannelMessage.Read.All、ChannelMessage.Send 權限(頻道訊息可能要管理員同意)。", + } + } + pub fn target_hint(self) -> &'static str { + match self { + Kind::Slack => "頻道 ID(例如 C0123ABCD,在頻道資訊最下面)", + Kind::Discord => "頻道 ID(開發者模式下對頻道按右鍵 → 複製頻道 ID)", + Kind::Telegram => "只回這個聊天 ID(留空 = 任何人私訊 Bot 都回)", + Kind::IMessage => "只回這個電話或 Apple ID(例如 +886912345678;留空 = 任何人)", + Kind::Email => "只看這個信箱名稱(留空 = 收件匣)", + Kind::Sms => "你的 Twilio 號碼(例如 +15551234567)", + Kind::X => "(留空)", + Kind::Teams => "chat:<聊天 ID>,或 <團隊 ID>/<頻道 ID>(在 Teams 對聊天或頻道「取得連結」可以看到)", + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Channel { + pub id: u64, + pub kind: Kind, + pub name: String, + /// The AnyBot bot that answers. + pub bot_id: u64, + pub token: String, + /// Slack / Discord channel id; Telegram chat id (empty = any). + #[serde(default)] + pub target: String, + #[serde(default = "yes")] + pub enabled: bool, + /// Teams: the Entra app (client) id and tenant (empty = organizations); `token` is the refresh token. + #[serde(default)] + pub client_id: String, + #[serde(default)] + pub tenant: String, +} + +fn yes() -> bool { + true +} + +/// Where an answer goes back. +#[derive(Clone, Debug, PartialEq)] +pub struct ReplyTo { + pub kind: Kind, + pub token: String, + pub target: String, + /// Slack thread ts / Discord message id / Telegram message id. + pub thread: String, + pub channel_name: String, +} + +#[derive(Clone, Debug)] +pub struct Incoming { + pub bot_id: u64, + pub from: String, + pub text: String, + pub reply: ReplyTo, +} + +#[derive(Clone, Default, Debug)] +pub struct Status { + pub ok: bool, + pub note: String, + pub received: u64, +} + +pub fn statuses() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn set_status(id: u64, ok: bool, note: &str, got: u64) { + let mut m = statuses().lock().unwrap(); + let s = m.entry(id).or_default(); + s.ok = ok; + s.note = note.to_string(); + s.received += got; +} + +fn agent(timeout: u64) -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(timeout))).http_status_as_error(false).build().into() +} + +fn get_json(url: &str, auth: Option<&str>, timeout: u64) -> Result { + let mut r = agent(timeout).get(url); + if let Some(a) = auth { + r = r.header("Authorization", a); + } + let mut resp = r.call().map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + if status == 429 { + return Err("rate".into()); + } + if !(200..300).contains(&status) { + return Err(format!("HTTP {status}:{}", text.chars().take(160).collect::())); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +fn post_json(url: &str, auth: Option<&str>, body: &Value) -> Result { + let mut r = agent(30).post(url).header("Content-Type", "application/json; charset=utf-8"); + if let Some(a) = auth { + r = r.header("Authorization", a); + } + let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + if !(200..300).contains(&status) { + return Err(format!("HTTP {status}:{}", text.chars().take(160).collect::())); + } + Ok(serde_json::from_str(&text).unwrap_or(Value::Null)) +} + +/// Split long answers on line breaks so each piece fits. +pub fn split(text: &str, max: usize) -> Vec { + let mut out = vec![]; + let mut cur = String::new(); + for line in text.split_inclusive('\n') { + if cur.chars().count() + line.chars().count() > max && !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + if line.chars().count() > max { + let chars: Vec = line.chars().collect(); + for c in chars.chunks(max) { + out.push(c.iter().collect()); + } + } else { + cur.push_str(line); + } + } + if !cur.trim().is_empty() { + out.push(cur); + } + out +} + +/// Telegram's address (a test server can stand in for it). +fn tg() -> String { + std::env::var("ANYBOT_TELEGRAM_API").unwrap_or_else(|_| "https://api.telegram.org".into()) +} + +/// Check a token and say who it is. +pub fn test(kind: Kind, token: &str) -> Result { + let t = token.trim(); + match kind { + Kind::Slack => { + let v = get_json("https://slack.com/api/auth.test", Some(&format!("Bearer {t}")), 15)?; + if v["ok"].as_bool() == Some(true) { + Ok(format!("已連上 Slack:{}({})", v["user"].as_str().unwrap_or(""), v["team"].as_str().unwrap_or(""))) + } else { + Err(format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))) + } + } + Kind::Discord => { + let v = get_json("https://discord.com/api/v10/users/@me", Some(&format!("Bot {t}")), 15)?; + Ok(format!("已連上 Discord:{}", v["username"].as_str().unwrap_or(""))) + } + Kind::Teams => Err("Teams 請用「登入 Microsoft」".into()), + Kind::Sms => crate::phone::twilio_test(t), + Kind::X => crate::phone::x_me(t).map(|(_, u)| format!("已連上 X:@{u}")), + Kind::IMessage => imessage_rows(0).map(|_| "可以讀「訊息」了。".to_string()), + Kind::Email => { + let (o, ok) = crate::macapps::run("mac_mail_accounts", "{}"); + if ok { Ok("可以用「郵件」了。".into()) } else { Err(o) } + } + Kind::Telegram => { + let v = get_json(&format!("{}/bot{t}/getMe", tg()), None, 15)?; + if v["ok"].as_bool() == Some(true) { + Ok(format!("已連上 Telegram:@{}", v["result"]["username"].as_str().unwrap_or(""))) + } else { + Err(format!("Telegram 說:{}", v["description"].as_str().unwrap_or("不明錯誤"))) + } + } + } +} + +/// Post the bot's answer back where the message came from. +pub fn reply(to: &ReplyTo, text: &str) -> Result<(), String> { + let t = to.token.trim(); + match to.kind { + // `thread` carries our own number (texts) or the post being answered (X). + Kind::Sms => crate::phone::send_sms(t, &to.thread, &to.target, text)?, + Kind::X => { + let mut reply_to = to.thread.clone(); + for part in split(text, crate::phone::X_MAX - 20) { + let url = crate::phone::x_post(t, &part, &reply_to)?; + reply_to = url.rsplit('/').next().unwrap_or("").to_string(); + } + } + Kind::IMessage => { + let (o, ok) = crate::macapps::run(crate::macapps::SEND_MESSAGE, &json!({"to": to.target, "text": text}).to_string()); + if !ok { + return Err(o); + } + } + Kind::Email => { + // `thread` carries the subject. + let subject = if to.thread.to_lowercase().starts_with("re:") { to.thread.clone() } else { format!("Re: {}", to.thread) }; + let (o, ok) = crate::macapps::run("mac_send_mail", &json!({"to": [to.target], "subject": subject, "body": text}).to_string()); + if !ok { + return Err(o); + } + } + Kind::Teams => { + // `token` carries the access token for Teams replies. + for part in split(text, 25_000) { + crate::teams::send(t, &to.target, &to.thread, &part)?; + } + } + Kind::Slack => { + for part in split(text, SLACK_MAX) { + let v = post_json("https://slack.com/api/chat.postMessage", Some(&format!("Bearer {t}")), &json!({"channel": to.target, "text": part, "thread_ts": to.thread}))?; + if v["ok"].as_bool() != Some(true) { + return Err(format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))); + } + } + } + Kind::Discord => { + for (i, part) in split(text, DISCORD_MAX).into_iter().enumerate() { + let mut body = json!({"content": part, "allowed_mentions": {"parse": []}}); + if i == 0 && !to.thread.is_empty() { + body["message_reference"] = json!({"message_id": to.thread, "fail_if_not_exists": false}); + } + post_json(&format!("https://discord.com/api/v10/channels/{}/messages", to.target), Some(&format!("Bot {t}")), &body)?; + } + } + Kind::Telegram => { + for (i, part) in split(text, TELEGRAM_MAX).into_iter().enumerate() { + let mut body = json!({"chat_id": to.target, "text": part}); + if i == 0 && !to.thread.is_empty() { + body["reply_to_message_id"] = json!(to.thread.parse::().unwrap_or(0)); + } + let v = post_json(&format!("{}/bot{t}/sendMessage", tg()), None, &body)?; + if v["ok"].as_bool() == Some(false) { + return Err(format!("Telegram 說:{}", v["description"].as_str().unwrap_or(""))); + } + } + } + } + Ok(()) +} + +// ---------- reading new messages ---------- + +/// Slack: messages newer than `cursor` (a ts), oldest first, people only. Returns (new cursor, items). +pub fn slack_new(history: &Value, cursor: &str, own_user: &str) -> (String, Vec<(String, String, String)>) { + let mut items: Vec<(String, String, String)> = vec![]; + let mut newest = cursor.to_string(); + if let Some(msgs) = history["messages"].as_array() { + for m in msgs.iter().rev() { + let ts = m["ts"].as_str().unwrap_or("").to_string(); + if ts.parse::().unwrap_or(0.0) <= cursor.parse::().unwrap_or(0.0) { + continue; + } + if ts.parse::().unwrap_or(0.0) > newest.parse::().unwrap_or(0.0) { + newest = ts.clone(); + } + let is_bot = m.get("bot_id").is_some() || m["subtype"].is_string() || m["user"].as_str() == Some(own_user); + if is_bot { + continue; + } + let text = m["text"].as_str().unwrap_or("").replace(&format!("<@{own_user}>"), "").trim().to_string(); + if !text.is_empty() { + items.push((m["user"].as_str().unwrap_or("someone").to_string(), text, m["thread_ts"].as_str().unwrap_or(&ts).to_string())); + } + } + } + (newest, items) +} + +/// Discord: messages after `cursor` (a snowflake), oldest first, people only. +pub fn discord_new(list: &Value, cursor: &str) -> (String, Vec<(String, String, String)>) { + let mut items = vec![]; + let mut newest = cursor.to_string(); + let n = |s: &str| s.parse::().unwrap_or(0); + if let Some(msgs) = list.as_array() { + let mut msgs: Vec<&Value> = msgs.iter().collect(); + msgs.sort_by_key(|m| n(m["id"].as_str().unwrap_or("0"))); + for m in msgs { + let id = m["id"].as_str().unwrap_or("0").to_string(); + if n(&id) <= n(cursor) { + continue; + } + if n(&id) > n(&newest) { + newest = id.clone(); + } + if m["author"]["bot"].as_bool() == Some(true) { + continue; + } + let text = m["content"].as_str().unwrap_or("").trim().to_string(); + if !text.is_empty() { + let who = m["author"]["global_name"].as_str().or(m["author"]["username"].as_str()).unwrap_or("someone").to_string(); + items.push((who, text, id)); + } + } + } + (newest, items) +} + +/// Telegram: (next offset, [(from, text, chat id, message id)]). +pub fn telegram_new(updates: &Value, offset: i64, only_chat: &str) -> (i64, Vec<(String, String, String, String)>) { + let mut next = offset; + let mut items = vec![]; + if let Some(us) = updates["result"].as_array() { + for u in us { + next = next.max(u["update_id"].as_i64().unwrap_or(0) + 1); + let m = &u["message"]; + let chat = m["chat"]["id"].as_i64().map(|c| c.to_string()).unwrap_or_default(); + if chat.is_empty() || (!only_chat.trim().is_empty() && only_chat.trim() != chat) { + continue; + } + if let Some(text) = m["text"].as_str() { + let who = m["from"]["first_name"].as_str().or(m["from"]["username"].as_str()).unwrap_or("someone").to_string(); + items.push((who, text.to_string(), chat, m["message_id"].as_i64().unwrap_or(0).to_string())); + } + } + } + (next, items) +} + +/// "王小明 " → "ming@example.com" +pub fn address_of(sender: &str) -> String { + match (sender.rfind('<'), sender.rfind('>')) { + (Some(a), Some(b)) if b > a => sender[a + 1..b].trim().to_string(), + _ => sender.trim().to_string(), + } +} + +fn chat_db() -> String { + dirs::home_dir().unwrap_or_default().join("Library/Messages/chat.db").display().to_string() +} + +/// Messages after `after` (ROWID): (id, text, handle, from me). +pub fn imessage_rows(after: i64) -> Result, String> { + let q = format!("SELECT m.ROWID AS id, m.text AS text, h.id AS handle, m.is_from_me AS me FROM message m LEFT JOIN handle h ON m.handle_id = h.ROWID WHERE m.ROWID > {after} ORDER BY m.ROWID DESC LIMIT 50"); + let out = std::process::Command::new("sqlite3").args(["-readonly", "-json", &chat_db(), &q]).output().map_err(|e| e.to_string())?; + if !out.status.success() { + let e = String::from_utf8_lossy(&out.stderr).to_string(); + return Err(if e.contains("authorization denied") || e.contains("unable to open") { + "讀不到「訊息」:請給 AnyBot(或「終端機」)「完全取用磁碟」權限。".into() + } else { + e + }); + } + let v: Value = serde_json::from_slice(&out.stdout).unwrap_or(json!([])); + let mut rows: Vec<(i64, String, String, bool)> = v + .as_array() + .into_iter() + .flatten() + .map(|r| (r["id"].as_i64().unwrap_or(0), r["text"].as_str().unwrap_or("").to_string(), r["handle"].as_str().unwrap_or("").to_string(), r["me"].as_i64() == Some(1))) + .collect(); + rows.reverse(); + Ok(rows) +} + +// ---------- the pollers ---------- + +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// (Re)start one poller per enabled channel; older pollers stop by themselves. +pub fn restart(channels: Vec, tx: Sender, repaint: Arc) { + let gen = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + // The bot's phone and X tools use the same accounts. + { + let mut a = crate::phone::accounts().lock().unwrap(); + a.twilio = channels.iter().find(|c| c.enabled && c.kind == Kind::Sms && !c.token.trim().is_empty()).map(|c| (c.token.trim().to_string(), c.target.trim().to_string())); + a.x = channels.iter().find(|c| c.enabled && c.kind == Kind::X && !c.token.trim().is_empty()).map(|c| c.token.trim().to_string()); + } + for c in channels.into_iter().filter(|c| c.enabled && !c.token.trim().is_empty()) { + let tx = tx.clone(); + let repaint = repaint.clone(); + std::thread::spawn(move || poll(c, gen, tx, repaint)); + } +} + +fn alive(gen: u64) -> bool { + GENERATION.load(Ordering::SeqCst) == gen +} + +fn poll(c: Channel, gen: u64, tx: Sender, repaint: Arc) { + let token = c.token.trim().to_string(); + let deliver = |from: String, text: String, target: String, thread: String| { + let _ = tx.send(Incoming { bot_id: c.bot_id, from, text, reply: ReplyTo { kind: c.kind, token: token.clone(), target, thread, channel_name: c.name.clone() } }); + set_status(c.id, true, "收到新訊息", 1); + repaint(); + }; + match c.kind { + Kind::Slack => { + let auth = format!("Bearer {token}"); + let own = get_json("https://slack.com/api/auth.test", Some(&auth), 15).ok().and_then(|v| v["user_id"].as_str().map(String::from)).unwrap_or_default(); + let mut cursor = String::new(); + while alive(gen) { + let url = format!("https://slack.com/api/conversations.history?channel={}&limit=20{}", c.target.trim(), if cursor.is_empty() { String::new() } else { format!("&oldest={cursor}") }); + match get_json(&url, Some(&auth), 20) { + Ok(v) if v["ok"].as_bool() == Some(true) => { + if cursor.is_empty() { + // Start from now: older messages aren't answered. + cursor = v["messages"][0]["ts"].as_str().unwrap_or("0").to_string(); + set_status(c.id, true, "已連上,等待新訊息", 0); + } else { + let (next, items) = slack_new(&v, &cursor, &own); + cursor = next; + for (from, text, thread) in items { + deliver(from, text, c.target.trim().to_string(), thread); + } + } + } + Ok(v) => set_status(c.id, false, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤")), 0), + Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)), + Err(e) => set_status(c.id, false, &e, 0), + } + std::thread::sleep(Duration::from_secs(POLL_SECS)); + } + } + Kind::Discord => { + let auth = format!("Bot {token}"); + let mut cursor = String::new(); + while alive(gen) { + let url = if cursor.is_empty() { + format!("https://discord.com/api/v10/channels/{}/messages?limit=1", c.target.trim()) + } else { + format!("https://discord.com/api/v10/channels/{}/messages?after={cursor}&limit=50", c.target.trim()) + }; + match get_json(&url, Some(&auth), 20) { + Ok(v) => { + if cursor.is_empty() { + cursor = v[0]["id"].as_str().unwrap_or("0").to_string(); + set_status(c.id, true, "已連上,等待新訊息", 0); + } else { + let (next, items) = discord_new(&v, &cursor); + cursor = next; + for (from, text, id) in items { + deliver(from, text, c.target.trim().to_string(), id); + } + } + } + Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(10)), + Err(e) => set_status(c.id, false, &e, 0), + } + std::thread::sleep(Duration::from_secs(POLL_SECS)); + } + } + Kind::Teams => { + let mut cursor = String::new(); + let mut me = String::new(); + while alive(gen) { + match crate::teams::access(c.id, &c.client_id, &c.tenant, &token) { + Ok(access) => { + if me.is_empty() { + me = crate::teams::graph_get(&access, "/me").ok().and_then(|v| v["id"].as_str().map(String::from)).unwrap_or_default(); + } + match crate::teams::graph_get(&access, &crate::teams::messages_path(&c.target)) { + Ok(v) => { + if cursor.is_empty() { + cursor = crate::teams::new_messages(&v, "", "").0; + if cursor.is_empty() { + cursor = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + } + set_status(c.id, true, "已連上,等待新訊息", 0); + } else { + let (next, items) = crate::teams::new_messages(&v, &cursor, &me); + cursor = next; + for (from, text, id) in items { + let _ = tx.send(Incoming { bot_id: c.bot_id, from, text, reply: ReplyTo { kind: Kind::Teams, token: access.clone(), target: c.target.trim().to_string(), thread: if c.target.starts_with("chat:") { String::new() } else { id }, channel_name: c.name.clone() } }); + set_status(c.id, true, "收到新訊息", 1); + repaint(); + } + } + } + Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)), + Err(e) => set_status(c.id, false, &e, 0), + } + } + Err(e) => set_status(c.id, false, &e, 0), + } + std::thread::sleep(Duration::from_secs(POLL_SECS + 2)); + } + } + Kind::IMessage => { + let mut cursor: Option = None; + while alive(gen) { + match imessage_rows(cursor.unwrap_or(0)) { + Ok(rows) => { + let newest = rows.iter().map(|r| r.0).max(); + match cursor { + None => { + cursor = Some(newest.unwrap_or(0)); + set_status(c.id, true, "已連上,等待新訊息", 0); + } + Some(_) => { + for (id, text, handle, me) in rows { + cursor = Some(id.max(cursor.unwrap_or(0))); + if me || text.trim().is_empty() || (!c.target.trim().is_empty() && handle != c.target.trim()) { + continue; + } + deliver(handle.clone(), text, handle, String::new()); + } + } + } + } + Err(e) => set_status(c.id, false, &e, 0), + } + std::thread::sleep(Duration::from_secs(POLL_SECS)); + } + } + Kind::Email => { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut first = true; + while alive(gen) { + let (o, ok) = crate::macapps::run("mac_read_mail", &json!({"mailbox": c.target.trim(), "unreadOnly": true, "days": 3, "limit": 20}).to_string()); + if ok { + let list: Value = serde_json::from_str(o.trim_start_matches("郵件:")).unwrap_or(json!([])); + for m in list.as_array().into_iter().flatten() { + let id = m["id"].as_str().unwrap_or("").to_string(); + if !seen.insert(id.clone()) || first { + continue; + } + // Read the whole message to answer it. + let (full, ok) = crate::macapps::run("mac_read_mail", &json!({"id": id}).to_string()); + let body: Value = if ok { serde_json::from_str(&full).unwrap_or(Value::Null) } else { Value::Null }; + let from = m["from"].as_str().unwrap_or("").to_string(); + let subject = m["subject"].as_str().unwrap_or("").to_string(); + let text = format!("主旨:{subject}\n{}", body["body"].as_str().unwrap_or("")); + deliver(from.clone(), text, address_of(&from), subject); + } + if first { + set_status(c.id, true, "已連上,等待新郵件", 0); + } + first = false; + } else { + set_status(c.id, false, &o, 0); + } + std::thread::sleep(Duration::from_secs(30)); + } + } + Kind::Sms => { + let number = c.target.trim().to_string(); + let mut seen = std::collections::HashSet::new(); + let mut first = true; + while alive(gen) { + let path = format!("Messages.json?To={}&PageSize=20", number.replace('+', "%2B")); + match crate::phone::twilio_get(&token, &path) { + Ok(v) => { + let items = crate::phone::sms_new(&v, &mut seen); + if first { + // Older texts aren't answered. + first = false; + set_status(c.id, true, "已連上,等待簡訊", 0); + } else { + for (_, from, body) in items { + deliver(from.clone(), body, from, number.clone()); + } + } + } + Err(e) => set_status(c.id, false, &e, 0), + } + std::thread::sleep(Duration::from_secs(POLL_SECS)); + } + } + Kind::X => { + let me = match crate::phone::x_me(&token) { + Ok(m) => m, + Err(e) => { + set_status(c.id, false, &e, 0); + return; + } + }; + let mut since = String::new(); + let mut first = true; + while alive(gen) { + match crate::phone::x_mentions(&token, &me.0, &since) { + Ok((newest, items)) => { + since = newest; + if first { + first = false; + set_status(c.id, true, &format!("已連上 @{},等待提及", me.1), 0); + } else { + for (id, who, text) in items { + deliver(format!("@{who}"), text, who, id); + } + } + } + Err(e) => set_status(c.id, false, &e, 0), + } + // X's free and basic plans allow few reads: once a minute. + for _ in 0..60 { + if !alive(gen) { + break; + } + std::thread::sleep(Duration::from_secs(1)); + } + } + } + Kind::Telegram => { + // Skip what's waiting from before: start after the latest update. + let mut offset = get_json(&format!("{}/bot{token}/getUpdates?offset=-1", tg()), None, 15) + .ok() + .and_then(|v| v["result"][0]["update_id"].as_i64()) + .map(|u| u + 1) + .unwrap_or(0); + set_status(c.id, true, "已連上,等待新訊息", 0); + while alive(gen) { + let url = format!("{}/bot{token}/getUpdates?offset={offset}&timeout={TELEGRAM_WAIT_SECS}", tg()); + match get_json(&url, None, TELEGRAM_WAIT_SECS + 10) { + Ok(v) => { + let (next, items) = telegram_new(&v, offset, &c.target); + offset = next; + if !alive(gen) { + break; + } + for (from, text, chat, mid) in items { + deliver(from, text, chat, mid); + } + } + Err(e) => { + set_status(c.id, false, &e, 0); + std::thread::sleep(Duration::from_secs(5)); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slack_skips_bots_and_old_messages() { + let v = json!({"ok": true, "messages": [ + {"ts": "1700000003.000", "user": "UBOT", "text": "my own"}, + {"ts": "1700000002.500", "bot_id": "B1", "text": "other bot"}, + {"ts": "1700000002.000", "user": "U1", "text": "<@UBOT> 幫我查天氣", "thread_ts": "1700000001.000"}, + {"ts": "1700000001.500", "user": "U2", "text": "嗨"}, + {"ts": "1700000000.000", "user": "U3", "text": "old"} + ]}); + let (cur, items) = slack_new(&v, "1700000001.000", "UBOT"); + assert_eq!(cur, "1700000003.000"); + assert_eq!(items, vec![("U2".into(), "嗨".into(), "1700000001.500".into()), ("U1".into(), "幫我查天氣".into(), "1700000001.000".into())]); + } + + #[test] + fn discord_orders_and_skips_bots() { + let v = json!([ + {"id": "30", "content": "second", "author": {"username": "amy"}}, + {"id": "25", "content": "bot", "author": {"username": "b", "bot": true}}, + {"id": "20", "content": "first", "author": {"username": "bob", "global_name": "Bob"}} + ]); + let (cur, items) = discord_new(&v, "10"); + assert_eq!(cur, "30"); + assert_eq!(items.iter().map(|i| i.1.as_str()).collect::>(), vec!["first", "second"]); + assert_eq!(items[0].0, "Bob"); + } + + #[test] + fn telegram_filters_chat() { + let v = json!({"ok": true, "result": [ + {"update_id": 7, "message": {"message_id": 1, "chat": {"id": 42}, "from": {"first_name": "小明"}, "text": "hi"}}, + {"update_id": 8, "message": {"message_id": 2, "chat": {"id": 99}, "from": {"first_name": "x"}, "text": "no"}} + ]}); + let (next, items) = telegram_new(&v, 0, "42"); + assert_eq!(next, 9); + assert_eq!(items, vec![("小明".into(), "hi".into(), "42".into(), "1".into())]); + assert_eq!(telegram_new(&v, 0, "").1.len(), 2); + } + + /// A whole round trip against a stand-in Telegram server: a message arrives, the answer goes back. + #[test] + fn telegram_round_trip() { + use std::io::{Read, Write}; + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = server.local_addr().unwrap().port(); + std::env::set_var("ANYBOT_TELEGRAM_API", format!("http://127.0.0.1:{port}")); + let sent = Arc::new(Mutex::new(Vec::::new())); + let s2 = sent.clone(); + std::thread::spawn(move || { + let mut served = 0; + for conn in server.incoming().flatten() { + let mut c = conn; + // Read the whole request: head, then as much body as Content-Length says. + c.set_read_timeout(Some(Duration::from_secs(3))).ok(); + let mut got = vec![]; + let mut buf = vec![0u8; 16384]; + loop { + let n = c.read(&mut buf).unwrap_or(0); + got.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&got).to_string(); + if let Some(h) = text.find("\r\n\r\n") { + let len = text[..h].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if got.len() >= h + 4 + len { + break; + } + } + if n == 0 { + break; + } + } + let req = String::from_utf8_lossy(&got).to_string(); + let body = if req.contains("offset=-1") { + r#"{"ok":true,"result":[{"update_id":100,"message":{"message_id":1,"chat":{"id":42},"text":"old"}}]}"#.to_string() + } else if req.contains("getUpdates") { + served += 1; + if served == 1 { + r#"{"ok":true,"result":[{"update_id":101,"message":{"message_id":5,"chat":{"id":42},"from":{"first_name":"小明"},"text":"明天天氣?"}}]}"#.to_string() + } else { + std::thread::sleep(Duration::from_millis(300)); + r#"{"ok":true,"result":[]}"#.to_string() + } + } else { + s2.lock().unwrap().push(req.clone()); + r#"{"ok":true,"result":{}}"#.to_string() + }; + let _ = c.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()); + } + }); + let (tx, rx) = std::sync::mpsc::channel(); + let ch = Channel { id: 1, kind: Kind::Telegram, name: "測試".into(), bot_id: 7, token: "T0K".into(), target: String::new(), enabled: true, client_id: String::new(), tenant: String::new() }; + restart(vec![ch], tx, Arc::new(|| {})); + let got = rx.recv_timeout(Duration::from_secs(10)).expect("message arrives"); + GENERATION.fetch_add(1, Ordering::SeqCst); + assert_eq!((got.bot_id, got.from.as_str(), got.text.as_str(), got.reply.target.as_str(), got.reply.thread.as_str()), (7, "小明", "明天天氣?", "42", "5")); + reply(&got.reply, "明天晴天").unwrap(); + let reqs = sent.lock().unwrap().clone(); + assert!(reqs.iter().any(|r| r.contains("/botT0K/sendMessage") && r.contains("明天晴天") && r.contains("\"reply_to_message_id\":5")), "{reqs:?}"); + std::env::remove_var("ANYBOT_TELEGRAM_API"); + } + + #[test] + fn email_addresses_are_found() { + assert_eq!(address_of("王小明 "), "ming@example.com"); + assert_eq!(address_of("ming@example.com"), "ming@example.com"); + } + + #[test] + fn long_answers_are_split() { + let text = "a\n".repeat(3000); + let parts = split(&text, DISCORD_MAX); + assert!(parts.len() >= 3 && parts.iter().all(|p| p.chars().count() <= DISCORD_MAX)); + assert_eq!(parts.concat(), text); + } +} diff --git a/src/cloudbox.rs b/src/cloudbox.rs new file mode 100644 index 0000000..cccb5bd --- /dev/null +++ b/src/cloudbox.rs @@ -0,0 +1,1465 @@ +// 「Bot 的電腦」: AnyBot's own Linux computer that every bot shares, like Grok Bot's cloud "box". +// +// What the original does (Grok Bot 0.78.1, main-app.cjs dev-box control plane + local-exec-daemon): +// - The box is a container with an X11 desktop driven by xdotool (ComputerUseDriver XDOTOOL / +// NATIVE_X11), its size read with xrandr, watched through noVNC websockify on port 6080 +// (`Ap.primaryNovncWebsockify:6080`). +// - Run states: ABSENT → 「建立中」, HIBERNATED → 「喚醒中」, STARTING → 「連線中」, RUNNING. +// - Health: GET /health with a 3 s timeout (QQn=3e3) that also says whether it's busy. +// - Store flush before a rebuild: POST /api/snapshotBoxStoreNow, 30 s (VQn=3e4). +// - Three rebuilds, one at a time (`isRecreateInFlight`): +// update (keep data, refused while busy): backing-up → creating → moving +// reset (wipe): cleaning-up → wiping → creating +// restore (force, from the durable store): cleaning-up → creating, then wait up to 90 s +// (zQn=9e4) polling every 2 s (HQn=2e3) for the copy-in to say "hydrated" +// - recreate-resume.json replays a rebuild cut short by a restart if it is under 10 minutes old +// ($Qn=10*6e4) and the box isn't healthy. +// Here the box is a local Docker container (or one on another Docker host, `ssh://me@server`), +// the durable store is a snapshot of the bot's home folder kept on this Mac, and xdotool runs +// through `docker exec`. + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime}; + +pub const CONTAINER: &str = "anybot-box"; +pub const IMAGE: &str = "anybot-box:1"; +/// The bot's files live here and survive an update (the original's mounted sand-data tree). +pub const VOLUME: &str = "anybot-box-home"; +pub const HOME: &str = "/home/bot"; +pub const DISPLAY: &str = ":1"; +/// noVNC inside the box (Ap.primaryNovncWebsockify). +pub const NOVNC_PORT: u16 = 6080; +/// Where noVNC is published on this Mac (estimated; any free port works). +pub const HOST_PORT: u16 = 16080; +/// Desktop size (estimated; the original reads it with xrandr). +pub const SCREEN: &str = "1280x800x24"; +/// Health probe timeout (QQn). +pub const HEALTH_TIMEOUT_MS: u64 = 3_000; +/// Snapshot ("flush the durable store") timeout (VQn). +pub const SNAPSHOT_TIMEOUT_MS: u64 = 30_000; +/// Restore copy-in: longest wait and poll interval (zQn, HQn). +pub const COPY_IN_WAIT_MS: u64 = 90_000; +pub const COPY_IN_POLL_MS: u64 = 2_000; +/// A rebuild marker older than this is cleared instead of replayed ($Qn). +pub const RESUME_FRESH_MS: u64 = 600_000; +/// Boot: longest wait for the desktop to come up (estimated). +pub const BOOT_WAIT_MS: u64 = 60_000; +/// Idle time before the box goes to sleep (estimated; the original parks an idle box). +pub const IDLE_SLEEP_SECS: u64 = 900; +/// Secret names: an upper-case letter, then A–Z, 0–9, _ (original rule); size limits estimated. +pub const SECRET_MAX_VALUE_BYTES: usize = 32_768; +pub const SECRET_MAX_COUNT: usize = 100; +/// xdotool timing, matching the Mac helper where it applies (CUActuator). +const CLICK_REPEAT_MS: u64 = 80; +const TYPE_DELAY_MS: u64 = 12; + +const DOCKERFILE: &str = include_str!("../box/Dockerfile"); +const START_SH: &str = include_str!("../box/start.sh"); +const FLUXBOX_INIT: &str = include_str!("../box/fluxbox-init"); +const OVERLAY: &str = include_str!("../box/overlay"); +const XRESOURCES: &str = include_str!("../box/Xresources"); +const MENU: &str = include_str!("../box/menu"); + +// ---------- state ---------- + +#[derive(Clone, Debug, PartialEq)] +pub enum Run { + /// Docker isn't installed or isn't running. + NoDocker(String), + /// Not made yet (ABSENT). + Absent, + /// Asleep (HIBERNATED): paused or stopped. + Asleep, + /// Being made, woken or connected (the transient states). + Starting(&'static str), + Running, + /// A rebuild is under way: (phase, detail). + Rebuilding(String, String), + Failed(String), +} + +impl Run { + pub fn label(&self) -> String { + match self { + Run::NoDocker(_) => "沒有 Docker".into(), + Run::Absent => "尚未建立".into(), + Run::Asleep => "休眠中".into(), + Run::Starting(s) => (*s).into(), + Run::Running => "執行中".into(), + Run::Rebuilding(p, _) => phase_label(p).into(), + Run::Failed(_) => "發生問題".into(), + } + } + pub fn is_running(&self) -> bool { + *self == Run::Running + } +} + +pub fn phase_label(p: &str) -> &'static str { + match p { + "backing-up" => "正在備份", + "creating" => "正在建立", + "moving" => "正在搬移資料", + "cleaning-up" => "正在拆除舊電腦", + "wiping" => "正在清除資料", + "restoring" => "正在從快照還原", + "done" => "完成", + "failed" => "失敗", + _ => "處理中", + } +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct Config { + /// Empty = this Mac's Docker; otherwise a Docker host such as `ssh://me@server`. + #[serde(default)] + pub docker_host: String, + /// 排程更新: local "HH:MM" to update when a newer computer is available (None = off). + #[serde(default)] + pub auto_update_at: Option, + /// The day a scheduled update last ran (so it runs once). + #[serde(default)] + pub auto_update_done: String, + /// 網路流量改走你的電腦 (new connections only). + #[serde(default)] + pub egress_via_mac: bool, +} + +/// A scheduled update that was missed may still run for this long (the original: one hour). +pub const SCHEDULE_GRACE_MINUTES: i64 = 60; + +struct Shared { + run: Run, + last_used: Instant, + rebuilding: bool, + log: Vec, + /// Screenshot pixel → desktop pixel (for the bot's coordinates). + scale: Option<(f64, f64)>, + tunnel: Option, + host_port: u16, +} + +fn shared() -> &'static Mutex { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| Mutex::new(Shared { run: Run::Absent, last_used: Instant::now(), rebuilding: false, log: vec![], scale: None, tunnel: None, host_port: HOST_PORT })) +} + +fn config() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(load_config())) +} + +static BUSY: AtomicUsize = AtomicUsize::new(0); +/// The person took control in the viewer: the bot's screen actions wait. +pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false); + +/// Marks the box as in use while alive (keeps it awake; an update waits for it). +pub struct Busy(bool); +impl Busy { + pub fn new(on: bool) -> Self { + if on { + BUSY.fetch_add(1, Ordering::SeqCst); + touch(); + } + Busy(on) + } +} +impl Drop for Busy { + fn drop(&mut self) { + if self.0 { + BUSY.fetch_sub(1, Ordering::SeqCst); + touch(); + } + } +} + +pub fn is_busy() -> bool { + BUSY.load(Ordering::SeqCst) > 0 +} + +pub fn touch() { + shared().lock().unwrap().last_used = Instant::now(); +} + +pub fn run_state() -> Run { + shared().lock().unwrap().run.clone() +} + +fn set_run(r: Run) { + shared().lock().unwrap().run = r; +} + +pub fn log_lines() -> Vec { + shared().lock().unwrap().log.clone() +} + +fn log(line: &str) { + let mut s = shared().lock().unwrap(); + s.log.push(format!("{} {line}", chrono::Local::now().format("%H:%M:%S"))); + let n = s.log.len(); + if n > 200 { + s.log.drain(..n - 200); + } +} + +// ---------- files on this Mac ---------- + +fn data_dir() -> PathBuf { + crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("box") +} + +fn config_path() -> PathBuf { + data_dir().join("config.json") +} + +fn load_config() -> Config { + std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() +} + +pub fn docker_host() -> String { + config().lock().unwrap().docker_host.clone() +} + +pub fn set_docker_host(h: &str) { + let h = h.trim().to_string(); + let mut c = config().lock().unwrap(); + if c.docker_host == h { + return; + } + c.docker_host = h; + let _ = std::fs::create_dir_all(data_dir()); + let _ = std::fs::write(config_path(), serde_json::to_string_pretty(&*c).unwrap_or_default()); + drop(c); + if let Some(mut t) = shared().lock().unwrap().tunnel.take() { + let _ = t.kill(); + } + set_run(Run::Absent); + refresh(); +} + +fn save_config(c: &Config) { + let _ = std::fs::create_dir_all(data_dir()); + let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default()); +} + +pub fn egress_via_mac() -> bool { + config().lock().unwrap().egress_via_mac +} + +/// Turn routing through this Mac on or off. Only this Mac's Docker can reach it. +pub fn set_egress_via_mac(on: bool) -> Result<(), String> { + if on { + if !docker_host().is_empty() { + return Err("Bot 的電腦在別的主機上時,不能改走這台 Mac。".into()); + } + crate::egress::start()?; + } + let mut c = config().lock().unwrap(); + c.egress_via_mac = on; + save_config(&c); + Ok(()) +} + +/// Proxy settings for new commands in the box when routing through this Mac. +fn egress_env() -> String { + if !egress_via_mac() || !docker_host().is_empty() || !crate::policy::read_egress().allows() { + return String::new(); + } + match crate::egress::start() { + Ok(port) => { + let url = format!("http://host.docker.internal:{port}"); + ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"].iter().map(|k| format!(" -e {k}={url}")).collect::() + " -e NO_PROXY=localhost,127.0.0.1 -e no_proxy=localhost,127.0.0.1" + } + Err(_) => String::new(), + } +} + +pub fn auto_update_at() -> Option { + config().lock().unwrap().auto_update_at.clone() +} + +pub fn set_auto_update_at(at: Option) { + let mut c = config().lock().unwrap(); + c.auto_update_at = at; + save_config(&c); +} + +/// The computer version baked into this AnyBot (the Dockerfile's label). +pub fn wanted_version() -> String { + DOCKERFILE.lines().find_map(|l| l.trim().strip_prefix("LABEL anybot.box.version=")).map(|v| v.trim_matches('"').to_string()).unwrap_or_default() +} + +/// The running computer is older than the one this AnyBot carries. +pub fn update_available() -> bool { + match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", CONTAINER]) { + Ok(v) => v.trim() != wanted_version(), + Err(_) => false, + } +} + +/// Is `now` inside the window [at, at + grace)? (`at` is "HH:MM"). +pub fn in_schedule_window(at: &str, now: chrono::NaiveTime) -> bool { + let Ok(t) = chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M") else { return false }; + let mins = (now - t).num_minutes().rem_euclid(24 * 60); + mins < SCHEDULE_GRACE_MINUTES +} + +fn maybe_scheduled_update() { + let (at, done) = { + let c = config().lock().unwrap(); + (c.auto_update_at.clone(), c.auto_update_done.clone()) + }; + let Some(at) = at else { return }; + let now = chrono::Local::now(); + let today = now.format("%Y-%m-%d").to_string(); + if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() { + return; + } + log(&format!("排程更新({at})開始。")); + if rebuild(Rebuild::Update).is_ok() { + let mut c = config().lock().unwrap(); + c.auto_update_done = today; + save_config(&c); + } +} + +pub fn snapshot_path() -> PathBuf { + data_dir().join("snapshot.tar.gz") +} + +/// When the last snapshot was saved. +pub fn snapshot_time() -> Option> { + std::fs::metadata(snapshot_path()).ok()?.modified().ok().map(chrono::DateTime::::from) +} + +fn resume_path() -> PathBuf { + data_dir().join("recreate-resume.json") +} + +/// Where docker and ssh live when the app is started from Finder (no shell PATH). +pub fn tool_path() -> String { + let base = std::env::var("PATH").unwrap_or_default(); + let mut parts: Vec = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect(); + let home = dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default(); + for extra in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", &format!("{home}/.orbstack/bin"), "/Applications/Docker.app/Contents/Resources/bin"] { + if !parts.iter().any(|p| p == extra) { + parts.push(extra.to_string()); + } + } + parts.join(":") +} + +fn docker() -> Command { + let mut c = Command::new("docker"); + c.env("PATH", tool_path()); + let host = docker_host(); + if !host.is_empty() { + c.env("DOCKER_HOST", host); + } + c.stdin(Stdio::null()); + c +} + +/// The `docker …` prefix for shell strings (used by the command tools). +fn docker_prefix() -> String { + let host = docker_host(); + if host.is_empty() { + "docker".into() + } else { + format!("DOCKER_HOST={} docker", crate::tools::sh_quote(&host)) + } +} + +/// Run docker; Ok(stdout) or Err(stderr). +fn dk(args: &[&str]) -> Result { + let out = docker().args(args).output().map_err(|e| format!("找不到 docker:{e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +fn dk_timeout(args: &[&str], ms: u64) -> Result, String> { + use std::io::Read; + let mut child = docker().args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("找不到 docker:{e}"))?; + // Drain both pipes while waiting, or a big screenshot fills the pipe and stalls docker. + let mut so = child.stdout.take().ok_or("沒有輸出")?; + let mut se = child.stderr.take().ok_or("沒有輸出")?; + let out_t = std::thread::spawn(move || { + let mut v = vec![]; + let _ = so.read_to_end(&mut v); + v + }); + let err_t = std::thread::spawn(move || { + let mut v = vec![]; + let _ = se.read_to_end(&mut v); + v + }); + let start = Instant::now(); + let status = loop { + if let Ok(Some(st)) = child.try_wait() { + break st; + } + if start.elapsed() > Duration::from_millis(ms) { + let _ = child.kill(); + let _ = child.wait(); + return Err("逾時".into()); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let out = out_t.join().unwrap_or_default(); + let err = err_t.join().unwrap_or_default(); + if status.success() { + Ok(out) + } else { + Err(String::from_utf8_lossy(&err).trim().to_string()) + } +} + +// ---------- status ---------- + +/// Ask Docker what state the box is in (doesn't change anything). +pub fn probe() -> Run { + if let Err(e) = dk(&["version", "--format", "{{.Server.Version}}"]) { + return Run::NoDocker(if e.contains("找不到") && !cfg!(target_os = "macos") { + "這台電腦沒有安裝 Docker。安裝 Docker Engine(Ubuntu / Debian:sudo apt install docker.io),再把自己加進 docker 群組(sudo usermod -aG docker $USER,然後重新登入),Bot 就會有自己的完整電腦。".into() + } else if e.contains("找不到") { + "這台 Mac 沒有安裝 Docker。安裝 OrbStack 或 Docker Desktop 後,Bot 就會有自己的完整電腦。".into() + } else if e.contains("permission denied") && !cfg!(target_os = "macos") { + "Docker 不讓 AnyBot 使用:請執行 sudo usermod -aG docker $USER,然後登出再登入。".into() + } else { + format!("Docker 沒有在執行:{}", e.lines().last().unwrap_or("")) + }); + } + match dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]) { + Err(_) => Run::Absent, + Ok(s) => match s.as_str() { + "running" => { + // The health probe (3 s, like the original's GET /health). + if dk_timeout(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() { + Run::Running + } else { + Run::Starting("連線中") + } + } + "paused" | "exited" | "created" => Run::Asleep, + "restarting" => Run::Starting("連線中"), + other => Run::Failed(format!("容器狀態:{other}")), + }, + } +} + +/// Refresh the shown state in the background (unless a rebuild or start owns it). +pub fn refresh() { + std::thread::spawn(|| { + let r = probe(); + let mut s = shared().lock().unwrap(); + if s.rebuilding || matches!(s.run, Run::Starting(_)) && !matches!(r, Run::Running) { + return; + } + s.run = r; + }); +} + +pub fn available() -> bool { + !matches!(run_state(), Run::NoDocker(_)) +} + +// ---------- image ---------- + +fn image_exists() -> bool { + dk(&["image", "inspect", IMAGE]).is_ok() +} + +/// Build the box image from the files baked into AnyBot. +fn build_image(pull: bool) -> Result<(), String> { + let dir = std::env::temp_dir().join(format!("anybot-box-build-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + for (name, body) in [("Dockerfile", DOCKERFILE), ("start.sh", START_SH), ("fluxbox-init", FLUXBOX_INIT), ("overlay", OVERLAY), ("Xresources", XRESOURCES), ("menu", MENU)] { + std::fs::write(dir.join(name), body).map_err(|e| e.to_string())?; + } + log("正在建立電腦的映像檔(第一次要幾分鐘)…"); + let mut args = vec!["build", "-q", "-t", IMAGE]; + if pull { + args.push("--pull"); + } + let d = dir.display().to_string(); + args.push(&d); + let r = dk(&args); + let _ = std::fs::remove_dir_all(&dir); + r.map(|_| log("映像檔完成。")).map_err(|e| format!("建立映像檔失敗:{}", tail(&e, 600))) +} + +fn tail(s: &str, n: usize) -> String { + let c: Vec = s.chars().collect(); + if c.len() <= n { + s.to_string() + } else { + c[c.len() - n..].iter().collect() + } +} + +// ---------- lifecycle ---------- + +fn free_port(start: u16) -> u16 { + if !docker_host().is_empty() { + return start; + } + (start..start + 50).find(|p| std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok()).unwrap_or(start) +} + +fn create_container() -> Result<(), String> { + if !image_exists() { + build_image(false)?; + } + let port = free_port(HOST_PORT); + let publish = format!("127.0.0.1:{port}:{NOVNC_PORT}"); + let vol = format!("{VOLUME}:{HOME}"); + let env = format!("SCREEN_SIZE={SCREEN}"); + log("正在開一台新的電腦…"); + // host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it). + dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; + Ok(()) +} + +fn wait_ready(ms: u64) -> Result<(), String> { + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(ms) { + if dk(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"]).is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(250)); + } + Err("電腦開機逾時".into()) +} + +static START_LOCK: Mutex<()> = Mutex::new(()); + +/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready. +pub fn ensure_running() -> Result<(), String> { + let _g = START_LOCK.lock().unwrap(); + if shared().lock().unwrap().rebuilding { + return Err("Bot 的電腦正在更新,請等它完成再試。".into()); + } + touch(); + let r = probe(); + let result = match r { + Run::Running => Ok(()), + Run::NoDocker(e) => Err(e), + Run::Absent => { + set_run(Run::Starting("建立中")); + create_container().and_then(|_| wait_ready(BOOT_WAIT_MS)) + } + Run::Asleep => { + set_run(Run::Starting("喚醒中")); + log("喚醒電腦…"); + let st = dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]).unwrap_or_default(); + let woke = if st == "paused" { dk(&["unpause", CONTAINER]) } else { dk(&["start", CONTAINER]) }; + woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS)) + } + Run::Starting(_) => { + set_run(Run::Starting("連線中")); + wait_ready(BOOT_WAIT_MS) + } + Run::Rebuilding(..) => Err("Bot 的電腦正在更新,請等它完成再試。".into()), + Run::Failed(e) => { + // A broken container: start it again. + log(&format!("電腦狀態異常({e}),重新啟動…")); + set_run(Run::Starting("連線中")); + dk(&["restart", CONTAINER]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS)) + } + }; + match &result { + Ok(()) => { + set_run(Run::Running); + let _ = dk(&["exec", CONTAINER, "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]); + } + Err(e) => { + log(e); + set_run(match probe() { + Run::NoDocker(x) => Run::NoDocker(x), + _ => Run::Failed(e.clone()), + }); + } + } + touch(); + result +} + +/// Put the box to sleep now (saves a snapshot first, like the original's flush before parking). +pub fn sleep_now() -> Result<(), String> { + if !matches!(probe(), Run::Running) { + return Ok(()); + } + if is_busy() { + return Err("有 Bot 正在用這台電腦,等它做完再休眠。".into()); + } + let _ = save_snapshot(); + log("電腦進入休眠。"); + dk(&["pause", CONTAINER]).map(|_| set_run(Run::Asleep)) +} + +/// Started once: puts an idle box to sleep, and replays a rebuild cut short by a restart. +pub fn start_background() { + static STARTED: AtomicBool = AtomicBool::new(false); + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + std::thread::spawn(|| { + set_run(probe()); + resume_interrupted(); + loop { + std::thread::sleep(Duration::from_secs(20)); + let (idle, running, rebuilding) = { + let s = shared().lock().unwrap(); + (s.last_used.elapsed().as_secs(), s.run.is_running(), s.rebuilding) + }; + if running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && idle >= IDLE_SLEEP_SECS && !viewer_open() { + let _ = sleep_now(); + } + if !rebuilding { + refresh(); + maybe_scheduled_update(); + } + } + }); +} + +static VIEWER_OPEN: AtomicBool = AtomicBool::new(false); +pub fn set_viewer_open(on: bool) { + VIEWER_OPEN.store(on, Ordering::SeqCst); + if on { + touch(); + } +} +fn viewer_open() -> bool { + VIEWER_OPEN.load(Ordering::SeqCst) +} + +// ---------- snapshots (the durable store) ---------- + +/// Save the bot's home folder to this Mac. Returns its size in bytes. +pub fn save_snapshot() -> Result { + let _ = std::fs::create_dir_all(data_dir()); + let tmp = data_dir().join("snapshot.tmp"); + let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?; + let mut child = docker() + .args(["exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "-czf", "-", "."]) + .stdout(Stdio::from(file)) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let start = Instant::now(); + let ok = loop { + if let Ok(Some(st)) = child.try_wait() { + break st.success(); + } + if start.elapsed() > Duration::from_millis(SNAPSHOT_TIMEOUT_MS * 4) { + let _ = child.kill(); + break false; + } + std::thread::sleep(Duration::from_millis(50)); + }; + if !ok { + let _ = std::fs::remove_file(&tmp); + return Err("快照沒有存成功".into()); + } + std::fs::rename(&tmp, snapshot_path()).map_err(|e| e.to_string())?; + let n = std::fs::metadata(snapshot_path()).map(|m| m.len()).unwrap_or(0); + log(&format!("已儲存快照({})。", human(n))); + Ok(n) +} + +fn copy_in_snapshot() -> Result<(), String> { + use std::io::Write; + let data = std::fs::read(snapshot_path()).map_err(|_| "還沒有快照可以還原".to_string())?; + let mut child = docker() + .args(["exec", "-i", CONTAINER, "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + child.stdin.take().ok_or("寫不進電腦")?.write_all(&data).map_err(|e| e.to_string())?; + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(format!("還原失敗:{}", String::from_utf8_lossy(&out.stderr).trim())); + } + // Like readCopyInOutcome: wait for the copy-in to report "hydrated". + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(COPY_IN_WAIT_MS) { + if let Ok(s) = dk(&["exec", CONTAINER, "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) { + if s.contains("outcome=hydrated") { + return Ok(()); + } + } + std::thread::sleep(Duration::from_millis(COPY_IN_POLL_MS)); + } + Err("還原沒有完成:copy-in outcome=unknown".into()) +} + +fn home_has_files() -> bool { + dk(&["exec", CONTAINER, "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false) +} + +pub fn human(b: u64) -> String { + match b { + b if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64), + b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), + b if b >= 1 << 10 => format!("{:.0} KB", b as f64 / 1024.0), + b => format!("{b} B"), + } +} + +// ---------- rebuilds ---------- + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Rebuild { + /// 重建電腦: keep files and logins, remove installed apps and packages. + Update, + /// 重設: a brand-new computer, nothing kept. + Reset, + /// 復原: start again from the last saved snapshot. + Restore, +} + +impl Rebuild { + pub fn title(self) -> &'static str { + match self { + Rebuild::Update => "重建電腦", + Rebuild::Reset => "重設電腦", + Rebuild::Restore => "從快照復原", + } + } + /// The original's confirmation wording. + pub fn warning(self) -> &'static str { + match self { + Rebuild::Update => "你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。正在工作的 Bot 會先停下來,聊天會保留。", + Rebuild::Reset => "重新建立電腦並重新連線。電腦裡的所有檔案、登入資訊和已安裝的應用程式都會被清除(你的 Bot 和聊天不受影響)。", + Rebuild::Restore => "從你上次儲存的快照重新啟動它。最近的變更可能會遺失,已安裝的 App 會被移除。你的 Bot、檔案和登入資訊都是安全的。", + } + } +} + +#[derive(Serialize, Deserialize)] +struct ResumeMarker { + #[serde(rename = "resumeKind")] + kind: Rebuild, + #[serde(rename = "writtenAtMs")] + written_at_ms: u64, +} + +fn now_ms() -> u64 { + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +/// Start a rebuild in the background. Err = refused (already running, busy, no snapshot…). +pub fn rebuild(kind: Rebuild) -> Result<(), String> { + { + let mut s = shared().lock().unwrap(); + if s.rebuilding { + return Err("已經有一個更新在進行中。".into()); + } + if kind == Rebuild::Update && BUSY.load(Ordering::SeqCst) > 0 { + return Err("有 Bot 正在用這台電腦,等它做完再重建(或先停止它)。".into()); + } + if kind == Rebuild::Restore && !snapshot_path().exists() { + return Err("還沒有儲存過快照,沒有東西可以還原。".into()); + } + s.rebuilding = true; + } + std::thread::spawn(move || { + let _ = std::fs::create_dir_all(data_dir()); + let _ = std::fs::write(resume_path(), serde_json::to_string(&ResumeMarker { kind, written_at_ms: now_ms() }).unwrap_or_default()); + let phase = |p: &str, d: &str| { + log(&format!("{}:{d}", phase_label(p))); + shared().lock().unwrap().run = Run::Rebuilding(p.into(), d.into()); + }; + let r = run_rebuild(kind, &phase); + let _ = std::fs::remove_file(resume_path()); + match r { + Ok(()) => { + log(&format!("{}完成。", kind.title())); + let mut s = shared().lock().unwrap(); + s.rebuilding = false; + s.run = Run::Running; + s.last_used = Instant::now(); + } + Err(e) => { + log(&format!("{}失敗:{e}", kind.title())); + let mut s = shared().lock().unwrap(); + s.rebuilding = false; + s.run = Run::Failed(e); + } + } + }); + Ok(()) +} + +fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> { + match kind { + Rebuild::Update => { + let had = matches!(probe(), Run::Running); + phase("backing-up", "儲存一份快照"); + if had { + if let Err(e) = save_snapshot() { + log(&format!("快照沒有存成功({e}),改以掛載的資料為準。")); + } + } + let had_files = had && home_has_files(); + phase("creating", "更新電腦的映像檔"); + build_image(true)?; + phase("creating", "換上一台新的電腦"); + let _ = dk(&["rm", "-f", CONTAINER]); + create_container()?; + wait_ready(BOOT_WAIT_MS)?; + phase("moving", "確認保留的檔案和新電腦的連線"); + if had_files && !home_has_files() { + return Err("重建後找不到原本的檔案".into()); + } + Ok(()) + } + Rebuild::Reset => { + phase("cleaning-up", "拆掉舊的電腦"); + let _ = dk(&["rm", "-f", CONTAINER]); + phase("wiping", "清除電腦裡的資料"); + let _ = dk(&["volume", "rm", "-f", VOLUME]); + phase("creating", "開一台全新的電腦"); + if !image_exists() { + build_image(false)?; + } + create_container()?; + wait_ready(BOOT_WAIT_MS) + } + Rebuild::Restore => { + phase("cleaning-up", "拆掉舊的電腦和它的資料"); + let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["volume", "rm", "-f", VOLUME]); + phase("creating", "從快照開一台新的電腦"); + if !image_exists() { + build_image(false)?; + } + create_container()?; + wait_ready(BOOT_WAIT_MS)?; + phase("restoring", "把快照裡的檔案放回去"); + copy_in_snapshot() + } + } +} + +/// resumeInterruptedRecreate: replay a fresh marker when the box isn't healthy. +fn resume_interrupted() { + let Some(m) = std::fs::read_to_string(resume_path()).ok().and_then(|s| serde_json::from_str::(&s).ok()) else { return }; + if now_ms().saturating_sub(m.written_at_ms) >= RESUME_FRESH_MS { + log("清掉一個太舊的未完成更新紀錄(超過十分鐘)。"); + let _ = std::fs::remove_file(resume_path()); + return; + } + if matches!(probe(), Run::Running) { + log("上次的更新被中斷,但電腦目前正常,所以沒有重做。需要的話請再執行一次。"); + let _ = std::fs::remove_file(resume_path()); + return; + } + log(&format!("接續上次被中斷的「{}」。", m.kind.title())); + let _ = rebuild(m.kind); +} + +// ---------- disk ---------- + +/// Disk use in the box, for the settings page and the clean-up request. +pub fn disk_report() -> Result { + ensure_running()?; + dk(&["exec", "-u", "bot", CONTAINER, "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")]) +} + +// ---------- secrets (write-only) ---------- + +fn secrets_path() -> PathBuf { + data_dir().join("secrets.json") +} + +fn env_file_path() -> PathBuf { + data_dir().join("secrets.env") +} + +fn read_secrets() -> std::collections::BTreeMap { + std::fs::read_to_string(secrets_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() +} + +fn write_secrets(m: &std::collections::BTreeMap) -> Result<(), String> { + let _ = std::fs::create_dir_all(data_dir()); + let write = |p: PathBuf, body: String| -> Result<(), String> { + std::fs::write(&p, body).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) + }; + write(secrets_path(), serde_json::to_string(m).unwrap_or_default())?; + // docker --env-file: one NAME=value per line (no newlines allowed in values). + write(env_file_path(), m.iter().map(|(k, v)| format!("{k}={}\n", v.replace('\n', "\\n"))).collect()) +} + +/// Names only: values can be written, never read back. +pub fn secret_names() -> Vec { + read_secrets().into_keys().collect() +} + +pub fn valid_secret_name(n: &str) -> bool { + let mut c = n.chars(); + matches!(c.next(), Some('A'..='Z')) && c.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_') +} + +pub fn set_secret(name: &str, value: &str) -> Result<(), String> { + let name = name.trim(); + if !valid_secret_name(name) { + return Err("名稱要大寫字母開頭,只能用 A–Z、0–9 和 _,例如 CRM_API_TOKEN".into()); + } + if value.is_empty() { + return Err("請填入值".into()); + } + if value.len() > SECRET_MAX_VALUE_BYTES { + return Err(format!("值太長了(最多 {} KB)", SECRET_MAX_VALUE_BYTES / 1024)); + } + let mut m = read_secrets(); + if !m.contains_key(name) && m.len() >= SECRET_MAX_COUNT { + return Err(format!("最多 {SECRET_MAX_COUNT} 個密鑰")); + } + m.insert(name.to_string(), value.to_string()); + write_secrets(&m) +} + +pub fn remove_secret(name: &str) { + let mut m = read_secrets(); + if m.remove(name).is_some() { + let _ = write_secrets(&m); + } +} + +// ---------- commands ---------- + +/// The bot's own folder inside the box. +pub fn bot_dir(bot_id: u64) -> String { + format!("{HOME}/bot-{bot_id}") +} + +/// A local shell string that runs `cmd` in the box as the bot user, in `dir`, with the secrets. +pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool) -> String { + use crate::tools::sh_quote; + let inner = format!("mkdir -p {d} && cd {d} && {cmd}", d = sh_quote(dir)); + let env = env_file_path(); + let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() }; + format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner)) +} + +/// Put a file into a bot's folder on the box (only when the box is already running). +pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result { + if !run_state().is_running() { + return Err("Bot 的電腦沒有在執行".into()); + } + let dir = format!("{}/附件", bot_dir(bot_id)); + dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?; + let dst = format!("{dir}/{name}"); + dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?; + let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]); + Ok(dst) +} + +/// Copy a file out of the box to this Mac (for showing the person). Returns the local path. +pub fn copy_out(path_in_box: &str) -> Result { + ensure_running()?; + let name = std::path::Path::new(path_in_box).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into()); + let dir = std::env::temp_dir().join(format!("anybot-from-box-{}", chrono::Utc::now().timestamp_millis())); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let local = dir.join(&name); + dk(&["cp", &format!("{CONTAINER}:{path_in_box}"), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?; + Ok(local) +} + +// ---------- the box's screen (xdotool) ---------- + +fn xdo(args: &[&str]) -> Result { + let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "xdotool"]; + a.extend_from_slice(args); + dk(&a).map_err(|e| format!("xdotool:{e}")) +} + +/// The desktop as an image (full size). +pub fn grab() -> Result { + let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?; + image::load_from_memory(&png).map(|i| i.to_rgb8()).map_err(|e| format!("截圖讀不出來:{e}")) +} + +/// The latest picture of the box's screen, small (for the 電腦 card). While the box runs it is +/// taken now (and kept on disk); while it sleeps the last one taken is shown. +pub fn thumbnail(width: u32) -> Option { + let path = data_dir().join("last-screen.png"); + if let Some(img) = frame() { + let h = (img.height() as f32 * width as f32 / img.width().max(1) as f32).round() as u32; + let small = image::imageops::resize(&img, width, h.max(1), image::imageops::FilterType::Triangle); + let _ = std::fs::create_dir_all(data_dir()); + let _ = small.save(&path); + return Some(small); + } + image::open(&path).ok().map(|i| i.to_rgba8()) +} + +/// A frame for the viewer (no waking: returns None when the box isn't running). +pub fn frame() -> Option { + if !run_state().is_running() { + return None; + } + let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; + image::load_from_memory(&png).ok().map(|i| i.to_rgba8()) +} + +fn key_to_x(name: &str) -> Option { + let n = name.trim().to_lowercase(); + let s = match n.as_str() { + "return" | "enter" => "Return", + "tab" => "Tab", + "space" => "space", + "delete" | "backspace" => "BackSpace", + "forwarddelete" | "del" => "Delete", + "escape" | "esc" => "Escape", + "home" => "Home", + "end" => "End", + "pageup" => "Prior", + "pagedown" => "Next", + "up" | "arrowup" => "Up", + "down" | "arrowdown" => "Down", + "left" | "arrowleft" => "Left", + "right" | "arrowright" => "Right", + "-" | "minus" => "minus", + "=" | "equal" => "equal", + "[" => "bracketleft", + "]" => "bracketright", + ";" => "semicolon", + "'" => "apostrophe", + "," | "comma" => "comma", + "." | "period" => "period", + "/" | "slash" => "slash", + "\\" | "backslash" => "backslash", + "`" | "grave" => "grave", + f if f.starts_with('f') && f[1..].parse::().map(|x| (1..=12).contains(&x)).unwrap_or(false) => return Some(f.to_uppercase()), + c if c.chars().count() == 1 && c.chars().all(|ch| ch.is_ascii_alphanumeric()) => return Some(c.to_string()), + _ => return None, + }; + Some(s.into()) +} + +fn modifier_to_x(m: &str) -> Option<&'static str> { + // On Linux the shortcuts people mean by cmd (copy, paste, select all) use ctrl. + match m.trim().to_lowercase().as_str() { + "cmd" | "command" | "meta" | "control" | "ctrl" => Some("ctrl"), + "shift" => Some("shift"), + "option" | "alt" | "opt" => Some("alt"), + "super" | "win" | "windows" => Some("super"), + _ => None, + } +} + +/// "cmd+shift+t" → "ctrl+shift+t" +pub fn combo_to_x(combo: &str) -> Result { + let parts: Vec<&str> = combo.split('+').map(str::trim).filter(|p| !p.is_empty()).collect(); + let Some((key, mods)) = parts.split_last() else { return Err("請給要按的鍵".into()) }; + let mut out: Vec = vec![]; + for m in mods { + out.push(modifier_to_x(m).ok_or_else(|| format!("不認得「{m}」這個修飾鍵"))?.to_string()); + } + out.push(key_to_x(key).ok_or_else(|| format!("不認得「{key}」這個鍵"))?); + Ok(out.join("+")) +} + +fn mods_of(v: &Value) -> Vec { + v["modifier_keys"].as_array().map(|a| a.iter().filter_map(|m| m.as_str().and_then(modifier_to_x)).map(String::from).collect()).unwrap_or_default() +} + +fn button_no(b: &str) -> &'static str { + match b { + "right" => "3", + "middle" => "2", + _ => "1", + } +} + +/// Run a computer_use tool on the box. Returns (text, screenshot as base64 JPEG). +pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), String> { + ensure_running()?; + let _busy = Busy::new(true); + if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" { + return Err("使用者正在自己操作 Bot 的電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into()); + } + let scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); + let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), String> { + let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) }; + Ok((format!("{}", (x * s.0).round() as i64), format!("{}", (y * s.1).round() as i64))) + }; + let with_mods = |mods: &[String], f: &dyn Fn() -> Result| -> Result { + for m in mods { + xdo(&["keydown", m])?; + } + let r = f(); + for m in mods.iter().rev() { + let _ = xdo(&["keyup", m]); + } + r + }; + match name { + "computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)), + "computer_use_screenshot" => { + let img = grab()?; + let (w, h) = img.dimensions(); + let (fw, fh, resize) = crate::computer::fit(w, h); + let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; + let jpeg = crate::computer::encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?; + shared().lock().unwrap().scale = Some((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64)); + Ok((format!("Bot 的電腦的螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg)))) + } + "computer_use_click" => { + let (x, y) = pt(v, scale()?)?; + let n = v["count"].as_u64().unwrap_or(1).clamp(1, 3).to_string(); + let b = button_no(v["button"].as_str().unwrap_or("left")); + let delay = CLICK_REPEAT_MS.to_string(); + with_mods(&mods_of(v), &|| xdo(&["mousemove", "--sync", &x, &y, "click", "--repeat", &n, "--delay", &delay, b]))?; + Ok((format!("已{}。要確認結果可以再截圖。", crate::computer::describe(name, &v.to_string())), None)) + } + "computer_use_mouse_move" => { + let (x, y) = pt(v, scale()?)?; + xdo(&["mousemove", "--sync", &x, &y])?; + Ok(("已移動滑鼠。".into(), None)) + } + "computer_use_mouse_button" => { + let b = button_no(v["button"].as_str().unwrap_or("left")); + let action = v["action"].as_str().unwrap_or("press"); + if !v["x"].is_null() { + let (x, y) = pt(v, scale()?)?; + xdo(&["mousemove", "--sync", &x, &y])?; + } + xdo(&[if action == "release" { "mouseup" } else { "mousedown" }, b])?; + Ok((if action == "release" { "已放開滑鼠鍵。" } else { "已按住滑鼠鍵。" }.into(), None)) + } + "computer_use_scroll" => { + let amount = crate::computer::scroll_amount(v["amount"].as_i64()); + let dir = v["direction"].as_str().unwrap_or("").trim().to_lowercase(); + let b = match dir.as_str() { + "up" => "4", + "down" => "5", + "left" => "6", + "right" => "7", + _ => return Err(format!("方向「{dir}」要是 up、down、left 或 right")), + }; + if !v["x"].is_null() { + let (x, y) = pt(v, scale()?)?; + xdo(&["mousemove", "--sync", &x, &y])?; + } + let n = amount.to_string(); + with_mods(&mods_of(v), &|| xdo(&["click", "--repeat", &n, "--delay", "20", b]))?; + Ok((format!("已捲動 {dir} ×{amount}。"), None)) + } + "computer_use_drag" => { + let s = scale()?; + let path: Vec<(String, String)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default(); + if path.len() < 2 { + return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); + } + let b = button_no(v["button"].as_str().unwrap_or("left")); + with_mods(&mods_of(v), &|| { + xdo(&["mousemove", "--sync", &path[0].0, &path[0].1])?; + xdo(&["mousedown", b])?; + std::thread::sleep(Duration::from_millis(40)); + for (x, y) in &path[1..] { + xdo(&["mousemove", "--sync", x, y])?; + std::thread::sleep(Duration::from_millis(8)); + } + xdo(&["mouseup", b]) + })?; + Ok((format!("已拖曳({} 個點)。", path.len()), None)) + } + "computer_use_typing" => { + let text = v["value"].as_str().unwrap_or(""); + if text.is_empty() { + return Err("請給要打的字".into()); + } + type_text(text)?; + Ok((format!("已打字({} 個字)。", text.chars().count()), None)) + } + "computer_use_press_key" => { + let keys = v["key"].as_str().unwrap_or(""); + let combos: Vec = keys.split_whitespace().map(combo_to_x).collect::>()?; + if combos.is_empty() { + return Err("請給要按的鍵".into()); + } + let hold = v["hold_duration_ms"].as_u64().unwrap_or(0).min(5_000); + for c in &combos { + if hold > 0 { + xdo(&["keydown", c])?; + std::thread::sleep(Duration::from_millis(hold)); + xdo(&["keyup", c])?; + } else { + xdo(&["key", "--clearmodifiers", c])?; + } + std::thread::sleep(Duration::from_millis(20)); + } + Ok((format!("已按 {keys}。"), None)) + } + other => Err(format!("沒有「{other}」這個工具")), + } +} + +/// Type text. Plain ASCII goes key by key; anything else (Chinese, emoji) is pasted, because +/// X keyboards can't type characters they have no key for in every program. +pub fn type_text(text: &str) -> Result<(), String> { + if text.is_ascii() { + let d = TYPE_DELAY_MS.to_string(); + xdo(&["type", "--clearmodifiers", "--delay", &d, "--", text])?; + return Ok(()); + } + use std::io::Write; + // Both selections hold the text; xclip keeps serving it after docker exec returns (setsid). + let mut child = docker() + .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt /dev/null 2>&1 & done; sleep 0.15"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string())?; + child.stdin.take().ok_or("寫不進剪貼簿")?.write_all(text.as_bytes()).map_err(|e| e.to_string())?; + let _ = child.wait(); + let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase(); + let paste = if class.contains("term") { "shift+Insert" } else { "ctrl+v" }; + xdo(&["key", "--clearmodifiers", paste])?; + Ok(()) +} + +// ---------- the viewer's own input (when the person takes control) ---------- + +pub fn viewer_click(x: i64, y: i64, button: &str, count: u32) { + let (x, y, n, b) = (x.to_string(), y.to_string(), count.clamp(1, 3).to_string(), button_no(button)); + let _ = xdo(&["mousemove", &x, &y, "click", "--repeat", &n, "--delay", "80", b]); + touch(); +} + +pub fn viewer_move(x: i64, y: i64) { + let _ = xdo(&["mousemove", &x.to_string(), &y.to_string()]); +} + +pub fn viewer_scroll(x: i64, y: i64, down: bool, steps: u32) { + let _ = xdo(&["mousemove", &x.to_string(), &y.to_string(), "click", "--repeat", &steps.clamp(1, 10).to_string(), if down { "5" } else { "4" }]); +} + +pub fn viewer_key(combo: &str) { + let _ = xdo(&["key", "--clearmodifiers", combo]); + touch(); +} + +pub fn viewer_text(text: &str) { + let _ = type_text(text); + touch(); +} + +// ---------- the browser viewer (noVNC) ---------- + +fn published_port() -> u16 { + dk(&["port", CONTAINER, &NOVNC_PORT.to_string()]) + .ok() + .and_then(|s| s.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.trim().parse().ok())) + .unwrap_or(HOST_PORT) +} + +/// The noVNC address on this Mac (through an SSH tunnel when the box is on another host). +pub fn viewer_url(control: bool) -> Result { + ensure_running()?; + let port = published_port(); + let host = docker_host(); + let local_port = if let Some(target) = host.strip_prefix("ssh://") { + let mut s = shared().lock().unwrap(); + let alive = s.tunnel.as_mut().map(|c| matches!(c.try_wait(), Ok(None))).unwrap_or(false); + if !alive { + let lp = free_port(HOST_PORT + 100); + let child = Command::new("ssh") + .env("PATH", tool_path()) + .args(["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-L", &format!("{lp}:127.0.0.1:{port}"), target]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("開不了 SSH 通道:{e}"))?; + s.tunnel = Some(child); + s.host_port = lp; + drop(s); + std::thread::sleep(Duration::from_millis(800)); + lp + } else { + s.host_port + } + } else { + port + }; + Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1{}", if control { "" } else { "&view_only=1" })) +} + +pub fn shutdown() { + if let Some(mut t) = shared().lock().unwrap().tunnel.take() { + let _ = t.kill(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_names_follow_the_original_rule() { + assert!(valid_secret_name("CRM_API_TOKEN")); + assert!(valid_secret_name("A1")); + assert!(!valid_secret_name("crm_token")); + assert!(!valid_secret_name("1ABC")); + assert!(!valid_secret_name("_ABC")); + assert!(!valid_secret_name("AB-C")); + } + + #[test] + fn shortcuts_become_x_keys() { + assert_eq!(combo_to_x("cmd+c").unwrap(), "ctrl+c"); + assert_eq!(combo_to_x("cmd+shift+t").unwrap(), "ctrl+shift+t"); + assert_eq!(combo_to_x("return").unwrap(), "Return"); + assert_eq!(combo_to_x("option+f4").unwrap(), "alt+F4"); + assert!(combo_to_x("hyper+x").is_err()); + } + + /// Uses the real Docker: `cargo test --release -- --ignored box_end_to_end --nocapture`. + #[test] + #[ignore] + fn box_end_to_end() { + use crate::tools::{run_on, Place, RunIds, Target}; + let cancel = AtomicBool::new(false); + let ids = RunIds::default(); + ensure_running().expect("box starts"); + assert!(run_state().is_running()); + let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(999) } }; + let (out, ok) = run_on("shell", r#"{"command":"pwd && whoami && echo $DISPLAY"}"#, &t, &cancel, &ids); + println!("shell: {out}"); + assert!(ok && out.contains("/home/bot/bot-999") && out.contains("bot") && out.contains(":1")); + let (out, ok) = run_on("edit_file", r#"{"path":"notes/hello.txt","old_text":"","new_text":"第一行\nhello world\n"}"#, &t, &cancel, &ids); + assert!(ok, "{out}"); + let (out, ok) = run_on("edit_file", r#"{"path":"notes/hello.txt","old_text":"hello world","new_text":"你好 世界"}"#, &t, &cancel, &ids); + assert!(ok, "{out}"); + let (out, ok) = run_on("read_file", r#"{"path":"notes/hello.txt"}"#, &t, &cancel, &ids); + println!("read: {out}"); + assert!(ok && out.contains("你好 世界") && !out.contains("hello world")); + let (out, ok) = run_on("search", r#"{"pattern":"世界","path":"."}"#, &t, &cancel, &ids); + assert!(ok && out.contains("hello.txt"), "{out}"); + // secrets reach commands, never shown by the app + set_secret("ANYBOT_TEST_TOKEN", "s3cret-value").unwrap(); + let (out, ok) = run_on("shell", r#"{"command":"test \"$ANYBOT_TEST_TOKEN\" = s3cret-value && echo has-secret"}"#, &t, &cancel, &ids); + remove_secret("ANYBOT_TEST_TOKEN"); + assert!(ok && out.contains("has-secret"), "{out}"); + // background command takes input + let (out, ok) = run_on("shell", r#"{"command":"read -p 'name? ' n; echo got:$n","timeout_ms":1500}"#, &t, &cancel, &ids); + assert!(ok, "{out}"); + let id: u64 = out.split('#').nth(1).and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next()).and_then(|s| s.parse().ok()).expect("terminal id"); + let (out, ok) = run_on("write_terminal", &format!(r#"{{"id":{id},"input":"daniel\n"}}"#), &t, &cancel, &ids); + println!("write_terminal: {out}"); + assert!(ok && out.contains("got:daniel"), "{out}"); + // screen + let (txt, img) = computer_run("computer_use_screenshot", &serde_json::json!({})).unwrap(); + println!("{txt}"); + assert!(img.is_some()); + run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids); + computer_run("computer_use_click", &serde_json::json!({"x": 200, "y": 150})).unwrap(); + computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap(); + computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap(); + std::thread::sleep(Duration::from_millis(800)); + let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids); + println!("typed: {out}"); + assert!(out.contains("中文輸入OK"), "{out}"); + // snapshot + restore + save_snapshot().unwrap(); + run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids); + rebuild(Rebuild::Restore).unwrap(); + let start = Instant::now(); + while shared().lock().unwrap().rebuilding && start.elapsed() < Duration::from_secs(180) { + std::thread::sleep(Duration::from_millis(500)); + } + println!("after restore: {:?}", run_state()); + let (out, ok) = run_on("read_file", r#"{"path":"notes/hello.txt"}"#, &t, &cancel, &ids); + assert!(ok && out.contains("你好 世界"), "{out}"); + let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids); + } + + /// `cargo test --release -- --ignored box_traffic_via_mac --nocapture` + #[test] + #[ignore] + fn box_traffic_via_mac() { + use crate::tools::{run_on, Place, RunIds, Target}; + let cancel = AtomicBool::new(false); + ensure_running().unwrap(); + set_egress_via_mac(true).unwrap(); + let before = crate::egress::CONNECTIONS.load(Ordering::SeqCst); + let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(997) } }; + let (out, ok) = run_on("shell", r#"{"command":"curl -sS -o /dev/null -w '%{http_code}' https://example.com"}"#, &t, &cancel, &RunIds::default()); + set_egress_via_mac(false).unwrap(); + println!("{out} via mac: {}", crate::egress::CONNECTIONS.load(Ordering::SeqCst) - before); + assert!(ok && out.contains("200"), "{out}"); + assert!(crate::egress::CONNECTIONS.load(Ordering::SeqCst) > before); + } + + /// `cargo test --release -- --ignored box_update_keeps_files --nocapture` + #[test] + #[ignore] + fn box_update_keeps_files() { + use crate::tools::{run_on, Place, RunIds, Target}; + let cancel = AtomicBool::new(false); + let ids = RunIds::default(); + ensure_running().unwrap(); + let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(998) } }; + let (o, ok) = run_on("shell", r#"{"command":"echo keep-me > kept.txt && sudo apt-get update -qq >/dev/null 2>&1 && sudo apt-get install -y -qq cowsay >/dev/null 2>&1; ls /usr/games/cowsay","timeout_ms":180000}"#, &t, &cancel, &ids); + assert!(ok, "{o}"); + rebuild(Rebuild::Update).unwrap(); + assert!(rebuild(Rebuild::Reset).is_err(), "only one rebuild at a time"); + let start = Instant::now(); + while shared().lock().unwrap().rebuilding && start.elapsed() < Duration::from_secs(600) { + std::thread::sleep(Duration::from_millis(500)); + } + for l in log_lines() { + println!("{l}"); + } + assert!(run_state().is_running(), "{:?}", run_state()); + let (out, ok) = run_on("shell", r#"{"command":"cat kept.txt; ls /usr/games/cowsay 2>&1 || echo no-cowsay; cat /etc/anybot/menu | head -1"}"#, &t, &cancel, &ids); + println!("{out}"); + assert!(ok && out.contains("keep-me") && out.contains("no-cowsay") && out.contains("Bot 的電腦")); + let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids); + } + + #[test] + fn schedule_window_is_one_hour() { + let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap(); + assert!(in_schedule_window("03:00", t("03:00"))); + assert!(in_schedule_window("03:00", t("03:59"))); + assert!(!in_schedule_window("03:00", t("04:00"))); + assert!(!in_schedule_window("03:00", t("02:59"))); + assert!(in_schedule_window("23:30", t("00:10"))); + assert!(!in_schedule_window("bad", t("00:10"))); + assert_eq!(wanted_version(), "3"); + } + + #[test] + fn wrapper_runs_as_the_bot_in_its_folder() { + let w = exec_wrapper("/home/bot/bot-3", "ls -la", false); + assert!(w.contains("exec -u bot")); + assert!(w.contains(CONTAINER)); + assert!(w.contains("/home/bot/bot-3")); + } +} diff --git a/src/computer.rs b/src/computer.rs new file mode 100644 index 0000000..031469d --- /dev/null +++ b/src/computer.rs @@ -0,0 +1,1064 @@ +// Screen control on 你的電腦 (computer use), like Grok Bot's Computer Use helper: the bot looks at +// the screen and uses the mouse and keyboard. Same tool names and arguments as the original's +// local daemon (`computer_use_screenshot`, `computer_use_click` {method, x, y, button, count, +// modifier_keys}, `computer_use_scroll`, `computer_use_mouse_move`, `computer_use_drag`, +// `computer_use_typing` {value}, `computer_use_press_key` {key, hold_duration_ms}). +// +// Screenshot sizing (local-exec-daemon main.cjs): `zct` fits the long edge to 1024 (Gct) keeping +// both edges at least 8 (Hs); images stay under 1 MiB (mg = 1024*1024); JPEG is tried at quality +// 85, 70, 55 ($3r), taking the first that fits, else the smallest. + +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::Mutex; + +pub const NAMES: [&str; 9] = ["computer_use_screenshot", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_mouse_button", "computer_use_check_permissions"]; + +/// The person took control back from the bot (the floating bar): screen actions wait. +pub static HELD_BY_PERSON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +/// When the bot last moved the mouse or typed on this Mac (for the floating bar), and who. +pub static LAST_ACTION: Mutex> = Mutex::new(None); +/// Longest screenshot edge sent to the model (Gct). +pub const MAX_LONG_EDGE: u32 = 1024; +/// Shortest edge never goes below this (Hs). +pub const MIN_EDGE: u32 = 8; +/// Largest screenshot sent (mg = 1024*1024). +pub const MAX_IMAGE_BYTES: usize = 1048576; +/// JPEG qualities tried in order ($3r). +pub const JPEG_QUALITIES: [u8; 3] = [85, 70, 55]; +/// Screenshots kept in what's sent to the model; older ones become a note (estimated). +pub const KEEP_SCREENSHOTS: usize = 3; +/// Most UTF-16 units per typed chunk; characters are never split (CUTypedCharacters.utf16Chunks +/// @0x1000beb00: capacity 0x14, a new chunk when the next character would pass 20). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub const TYPE_CHUNK_UNITS: usize = 20; +/// Virtual key the typed text rides on (postChunks @0x1000beebc: CGEventCreateKeyboardEvent(_, 0x80, _)). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const TYPE_KEY_CODE: u16 = 128; +/// Key down → key up for typed chunks and pressed keys (postChunks / handlePressKey: sleep 0.035 s). +pub const KEY_HOLD_SECS: f64 = 0.035; +/// Between typed chunks and between keys of one press (postChunks / handlePressKey: sleep 0.02 s). +pub const KEY_GAP_SECS: f64 = 0.02; +/// Mouse down → up when no hold is given (CUActuator.click @0x100010120: hold > 0 ? hold : 0.03). +pub const CLICK_HOLD_SECS: f64 = 0.03; +/// Between the clicks of a double/triple click (CUActuator.click: sleep 0.08 s). +pub const CLICK_REPEAT_GAP_SECS: f64 = 0.08; +/// Interpolated dragged events per path segment (handleDrag @0x10009dee4 passes steps: 6). +pub const DRAG_STEPS: usize = 6; +/// Hold after pressing, before moving (handleDrag @0x10009dee4 passes holdSeconds: 0.04). +pub const DRAG_HOLD_SECS: f64 = 0.04; + +/// The dragged-event points: each segment split into `DRAG_STEPS` equal parts, ending on its end +/// point (CURemoteEventPoster.drag @0x10008c34c: start + delta * i / steps, i = 1…steps). +pub fn drag_points(path: &[(f64, f64)]) -> Vec<(f64, f64)> { + path.windows(2) + .flat_map(|w| { + let ((ax, ay), (bx, by)) = (w[0], w[1]); + (1..=DRAG_STEPS).map(move |i| { + let t = i as f64 / DRAG_STEPS as f64; + (ax + (bx - ax) * t, ay + (by - ay) * t) + }) + }) + .collect() +} + +/// Scroll steps when none is given, and the allowed range (handleScroll @0x10009e200: default 3, +/// max(amount, 1), min(…, 20)). +pub const SCROLL_DEFAULT_AMOUNT: i64 = 3; +pub const SCROLL_MAX_AMOUNT: i64 = 20; +/// Pixels per scroll step (handleScroll @0x10009e200: (amount + amount) << 3). +pub const SCROLL_PIXELS_PER_STEP: i32 = 16; + +pub fn scroll_amount(amount: Option) -> i32 { + amount.unwrap_or(SCROLL_DEFAULT_AMOUNT).clamp(1, SCROLL_MAX_AMOUNT) as i32 +} + +/// (vertical, horizontal) pixel deltas: up and left positive, down and right negative. +pub fn scroll_pixels(direction: &str, amount: i32) -> Option<(i32, i32)> { + let px = amount * SCROLL_PIXELS_PER_STEP; + match direction { + "up" => Some((px, 0)), + "down" => Some((-px, 0)), + "left" => Some((0, px)), + "right" => Some((0, -px)), + _ => None, + } +} + +/// Pause between mouse move / drag steps (estimated). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const EVENT_GAP_MS: u64 = 8; +/// Longest key hold allowed (estimated). +const MAX_HOLD_MS: u64 = 5000; + +/// utf16Chunks: split text into chunks of at most `TYPE_CHUNK_UNITS` UTF-16 units without +/// splitting a character (a character longer than that gets its own chunk). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn utf16_chunks(text: &str) -> Vec> { + let mut out: Vec> = vec![]; + let mut cur: Vec = Vec::with_capacity(TYPE_CHUNK_UNITS); + for c in text.chars() { + let mut buf = [0u16; 2]; + let units = c.encode_utf16(&mut buf); + if !cur.is_empty() && cur.len() + units.len() > TYPE_CHUNK_UNITS { + out.push(std::mem::take(&mut cur)); + } + cur.extend_from_slice(units); + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// "cmd+a delete" → each key of one press, in order (handlePressKey presses a list of keys). +pub fn parse_keys(keys: &str) -> Result, String> { + let list: Vec<&str> = keys.split_whitespace().collect(); + if list.is_empty() { + return Err("請給要按的鍵".into()); + } + list.into_iter().map(parse_combo).collect() +} + +/// Screen points per screenshot pixel, from the last screenshot (x, y). +static SCALE: Mutex> = Mutex::new(None); + +pub fn is_computer_tool(name: &str) -> bool { + NAMES.contains(&name) +} + +pub fn label(name: &str) -> &'static str { + match name { + "computer_use_screenshot" => "看你的螢幕", + "computer_use_click" => "點一下", + "computer_use_typing" => "打字", + "computer_use_press_key" => "按鍵", + "computer_use_scroll" => "捲動", + "computer_use_mouse_move" => "移動滑鼠", + "computer_use_drag" => "拖曳", + "computer_use_mouse_button" => "按住或放開滑鼠", + "computer_use_check_permissions" => "檢查螢幕權限", + _ => "操作畫面", + } +} + +fn args_of(args: &str) -> Value { + serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null) +} + +pub fn describe(name: &str, args: &str) -> String { + let v = args_of(args); + let xy = || format!("({}, {})", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)); + match name { + "computer_use_screenshot" => "螢幕截圖".into(), + "computer_use_click" => { + let b = v["button"].as_str().unwrap_or("left"); + let n = v["count"].as_u64().unwrap_or(1); + format!("{}{}{} {}", if b == "right" { "右鍵" } else { "" }, if n >= 2 { "連點" } else { "點" }, if n >= 2 { format!(" {n} 下") } else { String::new() }, xy()) + } + "computer_use_typing" => { + let t = v["value"].as_str().unwrap_or(""); + let short: String = t.chars().take(60).collect(); + format!("「{short}{}」", if t.chars().count() > 60 { "…" } else { "" }) + } + "computer_use_press_key" => v["key"].as_str().unwrap_or("").to_string(), + "computer_use_scroll" => format!("{} {} 格,在 {}", v["direction"].as_str().unwrap_or("down"), v["amount"].as_i64().unwrap_or(3), xy()), + "computer_use_mouse_move" => xy(), + "computer_use_drag" => format!("{} 個點", v["path"].as_array().map(|a| a.len()).unwrap_or(0)), + "computer_use_mouse_button" => format!("{}{}", if v["action"].as_str() == Some("release") { "放開" } else { "按住" }, if v["x"].is_null() { String::new() } else { format!(" {}", xy()) }), + "computer_use_check_permissions" => "螢幕錄製、輔助使用".into(), + _ => String::new(), + } +} + +#[allow(dead_code)] +pub fn specs() -> Vec { + specs_for(&[crate::model::THIS_COMPUTER_NAME.to_string()], crate::model::THIS_COMPUTER_NAME) +} + +/// The screen tools, with a `computer` choice when the bot has its own desktop too. +pub fn specs_for(computers: &[String], default: &str) -> Vec { + let mods = json!({"type": "array", "items": {"type": "string", "enum": ["cmd", "shift", "option", "control"]}}); + let mut v = vec![ + json!({"name": "computer_use_screenshot", "description": "Take a screenshot of a computer's screen to see what's on it. Coordinates for the other computer_use tools are pixels of the latest screenshot of that computer. Take one before clicking, and again to check the result. Needs a model that can see images.", + "parameters": {"type": "object", "properties": {}}}), + json!({"name": "computer_use_click", "description": "Click on the screen at x, y (pixels of the latest screenshot). background=true clicks a window on the person's Mac without bringing it to the front.", + "parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods, "background": {"type": "boolean"}}, "required": ["x", "y"]}}), + json!({"name": "computer_use_typing", "description": "Type text where the cursor is (any language).", + "parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}}), + json!({"name": "computer_use_press_key", "description": "Press a key or shortcut, e.g. return, tab, escape, delete, up, cmd+c, cmd+shift+t, cmd+space. Several keys in order: separate with spaces, e.g. \"cmd+a delete\". On the bot's Linux computer cmd means ctrl.", + "parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}}, "required": ["key"]}}), + json!({"name": "computer_use_scroll", "description": "Scroll at x, y (give both or neither; neither = where the pointer is).", + "parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "description": "Scroll steps of 16 pixels, 1-20, default 3"}, "modifier_keys": mods}, "required": ["direction"]}}), + json!({"name": "computer_use_mouse_move", "description": "Move the mouse to x, y (e.g. to show a hover menu).", + "parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, "required": ["x", "y"]}}), + json!({"name": "computer_use_drag", "description": "Drag with the mouse along a path of points (first = press, last = release).", + "parameters": {"type": "object", "properties": {"path": {"type": "array", "items": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, "required": ["x", "y"]}}, "button": {"type": "string", "enum": ["left", "right"]}, "modifier_keys": mods}, "required": ["path"]}}), + json!({"name": "computer_use_mouse_button", "description": "Press and hold, or release, a mouse button (optionally moving to x, y first). For long presses and custom drags.", + "parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["press", "release"]}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "x": {"type": "number"}, "y": {"type": "number"}}, "required": ["action"]}}), + json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.", + "parameters": {"type": "object", "properties": {}}}), + ]; + if computers.len() > 1 { + for t in v.iter_mut() { + t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, + "description": format!("Whose screen. Default: {default}. {} is the bot's own Linux desktop; {} is the person's Mac (use it for the person's own apps).", crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME)}); + } + } + v +} + +/// zct: the size to send. Long edge down to `MAX_LONG_EDGE`, never an edge under `MIN_EDGE`. +pub fn fit(w: u32, h: u32) -> (u32, u32, bool) { + if w < 1 || h < 1 { + return (w, h, false); + } + let long = w.max(h); + if long <= MAX_LONG_EDGE { + return (w, h, false); + } + let short = w.min(h); + if short < MIN_EDGE { + return (w, h, false); + } + let mut i = MAX_LONG_EDGE as f64 / long as f64; + let mut o = ((w as f64 * i).round() as u32).max(1); + let mut a = ((h as f64 * i).round() as u32).max(1); + if o < MIN_EDGE || a < MIN_EDGE { + i = MIN_EDGE as f64 / short as f64; + o = ((w as f64 * i).round() as u32).max(MIN_EDGE); + a = ((h as f64 * i).round() as u32).max(MIN_EDGE); + } + (o, a, o != w || a != h) +} + +/// j3r: JPEG at 85, 70, 55: the first within the budget, else the smallest if it's under the cap. +pub fn encode_jpeg(img: &image::RgbImage) -> Option> { + let mut best: Option> = None; + for q in JPEG_QUALITIES { + let mut out = vec![]; + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, q); + if img.write_with_encoder(enc).is_err() { + continue; + } + if out.len() <= MAX_IMAGE_BYTES { + return Some(out); + } + if best.as_ref().map_or(true, |b| out.len() < b.len()) { + best = Some(out); + } + } + best.filter(|b| b.len() <= MAX_IMAGE_BYTES) +} + +/// Screenshot pixel → screen point. +pub fn to_screen(x: f64, y: f64, scale: (f64, f64)) -> (f64, f64) { + (x * scale.0, y * scale.1) +} + +/// Key name → (key code, needs shift). US layout codes. +pub fn key_code(name: &str) -> Option { + let n = name.trim().to_lowercase(); + let named = match n.as_str() { + "return" | "enter" => 36, + "tab" => 48, + "space" => 49, + "delete" | "backspace" => 51, + "escape" | "esc" => 53, + "forwarddelete" | "forward_delete" | "del" => 117, + "home" => 115, + "end" => 119, + "pageup" | "page_up" => 116, + "pagedown" | "page_down" => 121, + "left" | "arrowleft" => 123, + "right" | "arrowright" => 124, + "down" | "arrowdown" => 125, + "up" | "arrowup" => 126, + "f1" => 122, + "f2" => 120, + "f3" => 99, + "f4" => 118, + "f5" => 96, + "f6" => 97, + "f7" => 98, + "f8" => 100, + "f9" => 101, + "f10" => 109, + "f11" => 103, + "f12" => 111, + _ => u16::MAX, + }; + if named != u16::MAX { + return Some(named); + } + let mut cs = n.chars(); + let (Some(c), None) = (cs.next(), cs.next()) else { return None }; + const LETTERS: [u16; 26] = [0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6]; + const DIGITS: [u16; 10] = [29, 18, 19, 20, 21, 23, 22, 26, 28, 25]; + match c { + 'a'..='z' => Some(LETTERS[(c as u8 - b'a') as usize]), + '0'..='9' => Some(DIGITS[(c as u8 - b'0') as usize]), + '-' => Some(27), + '=' => Some(24), + '[' => Some(33), + ']' => Some(30), + ';' => Some(41), + '\'' => Some(39), + ',' => Some(43), + '.' => Some(47), + '/' => Some(44), + '\\' => Some(42), + '`' => Some(50), + _ => None, + } +} + +pub const FLAG_SHIFT: u64 = 1 << 17; +pub const FLAG_CONTROL: u64 = 1 << 18; +pub const FLAG_OPTION: u64 = 1 << 19; +pub const FLAG_CMD: u64 = 1 << 20; + +pub fn modifier_flag(m: &str) -> Option { + match m.trim().to_lowercase().as_str() { + "cmd" | "command" | "meta" | "super" | "⌘" => Some(FLAG_CMD), + "shift" => Some(FLAG_SHIFT), + "option" | "alt" | "opt" => Some(FLAG_OPTION), + "control" | "ctrl" => Some(FLAG_CONTROL), + _ => None, + } +} + +/// "cmd+shift+t" → (key code, flags). +pub fn parse_combo(combo: &str) -> Result<(u16, u64), String> { + let parts: Vec<&str> = combo.split('+').map(str::trim).filter(|p| !p.is_empty()).collect(); + let Some((key, mods)) = parts.split_last() else { return Err("請給要按的鍵".into()) }; + let mut flags = 0; + for m in mods { + flags |= modifier_flag(m).ok_or_else(|| format!("不認得「{m}」這個修飾鍵"))?; + } + let code = key_code(key).ok_or_else(|| format!("不認得「{key}」這個鍵"))?; + Ok((code, flags)) +} + +fn flags_of(v: &Value) -> u64 { + v["modifier_keys"].as_array().map(|a| a.iter().filter_map(|m| m.as_str().and_then(modifier_flag)).fold(0, |f, x| f | x)).unwrap_or(0) +} + +/// Run a computer_use tool. Returns (text, ok, screenshot as base64 JPEG). +#[allow(dead_code)] +pub fn run(name: &str, args: &str) -> (String, bool, Option) { + run_by(name, args, "") +} + +/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar). +pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option) { + if name != "computer_use_screenshot" && name != "computer_use_check_permissions" { + *LAST_ACTION.lock().unwrap() = Some((std::time::Instant::now(), who.to_string())); + } + let r = run_inner(name, &args_of(args)); + match r { + Ok((t, img)) => (t, true, img), + Err(e) => (e, false, None), + } +} + +fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> { + if !cfg!(any(target_os = "macos", target_os = "linux")) { + return Err("操作畫面只支援 macOS 和 Linux".into()); + } + #[cfg(target_os = "linux")] + if !crate::desk::available() { + return Err(crate::desk::describe().into()); + } + if name == "computer_use_screenshot" { + return screenshot().map(|(t, img)| (t, Some(img))); + } + #[cfg(target_os = "linux")] + if name == "computer_use_check_permissions" { + return Ok((crate::desk::describe().into(), None)); + } + if name == "computer_use_check_permissions" { + let see = mac::screen_capture_preflight(); + let control = crate::axread::trusted(false); + return Ok((format!("螢幕錄製:{}\n輔助使用(滑鼠和鍵盤):{}{}", if see { "已允許" } else { "還沒允許" }, if control { "已允許" } else { "還沒允許" }, if see && control { "" } else { "\n請使用者到「系統設定 → 隱私權與安全性」打開 AnyBot(或「終端機」)。" }), None)); + } + if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) { + return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into()); + } + if cfg!(target_os = "macos") && !crate::axread::trusted(true) { + let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn(); + return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); + } + let scale = || SCALE.lock().unwrap().ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); + let pt = |v: &Value, s: (f64, f64)| -> Result<(f64, f64), String> { + let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) }; + Ok(to_screen(x, y, s)) + }; + match name { + "computer_use_click" => { + let (x, y) = pt(v, scale()?)?; + let button = v["button"].as_str().unwrap_or("left"); + let count = v["count"].as_u64().unwrap_or(1).clamp(1, 3) as i64; + if v["background"].as_bool().unwrap_or(false) { + let (app, pid) = mac::window_at(x, y).ok_or("那個位置沒有視窗")?; + mac::click_in_background(pid, x, y, button, count, flags_of(v)); + return Ok((format!("已在背景點「{app}」的 ({}, {}),沒有把它拉到前面。要確認結果可以再截圖。", v["x"], v["y"]), None)); + } + mac::click(x, y, button, count, flags_of(v)); + Ok((format!("已{}。要確認結果可以再截圖。", describe(name, &v.to_string())), None)) + } + "computer_use_mouse_move" => { + let (x, y) = pt(v, scale()?)?; + mac::move_to(x, y); + Ok(("已移動滑鼠。".into(), None)) + } + "computer_use_scroll" => { + // CURemoteToolService.handleScroll @0x10009e200 / closure @0x10009e998. + let (has_x, has_y) = (!v["x"].is_null(), !v["y"].is_null()); + if has_x != has_y { + return Err("x 和 y 要一起給".into()); + } + let at = if has_x { Some(pt(v, scale()?)?) } else { None }; + if !v["amount"].is_null() && v["amount"].as_i64().is_none() { + return Err("amount 要是整數".into()); + } + let amount = scroll_amount(v["amount"].as_i64()); + let dir = v["direction"].as_str().unwrap_or("").trim().to_lowercase(); + let (dy, dx) = scroll_pixels(&dir, amount).ok_or_else(|| format!("方向「{dir}」要是 up、down、left 或 right"))?; + mac::scroll(at, dy, dx, flags_of(v)); + Ok((format!("已捲動 {dir} ×{amount}。"), None)) + } + "computer_use_drag" => { + let s = scale()?; + let path: Vec<(f64, f64)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default(); + if path.len() < 2 { + return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); + } + let button = v["button"].as_str().unwrap_or("left"); + mac::drag(&path, button, flags_of(v)); + // Like the original's "Dragged from (x, y) to (x, y) with left button." + let pts = v["path"].as_array().cloned().unwrap_or_default(); + let at = |p: &Value| format!("({:.0}, {:.0})", p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)); + let side = match button { + "right" => "右鍵", + "middle" => "中鍵", + _ => "左鍵", + }; + Ok((format!("已從 {} 拖曳到 {}({side})。", pts.first().map(at).unwrap_or_default(), pts.last().map(at).unwrap_or_default()), None)) + } + "computer_use_mouse_button" => { + let button = v["button"].as_str().unwrap_or("left"); + let at = if v["x"].is_null() { None } else { Some(pt(v, scale()?)?) }; + let release = v["action"].as_str() == Some("release"); + mac::mouse_button(at, button, !release); + Ok((if release { "已放開滑鼠鍵。" } else { "已按住滑鼠鍵(記得之後放開)。" }.into(), None)) + } + "computer_use_typing" => { + let text = v["value"].as_str().unwrap_or(""); + if text.is_empty() { + return Err("請給要打的字".into()); + } + mac::type_text(text); + Ok((format!("已打字({} 個字)。", text.chars().count()), None)) + } + "computer_use_press_key" => { + let keys = parse_keys(v["key"].as_str().unwrap_or(""))?; + let hold = v["hold_duration_ms"].as_u64().map(|ms| ms.min(MAX_HOLD_MS) as f64 / 1000.0).filter(|s| *s > 0.0).unwrap_or(KEY_HOLD_SECS); + for (i, (code, flags)) in keys.iter().enumerate() { + if i > 0 { + std::thread::sleep(std::time::Duration::from_secs_f64(KEY_GAP_SECS)); + } + mac::press(*code, *flags, hold); + } + Ok((format!("已按 {}。", v["key"].as_str().unwrap_or("")), None)) + } + other => Err(format!("沒有「{other}」這個工具")), + } +} + +/// The app a screen action will land in: the window under the point for clicks, else the +/// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」). +pub fn target_app(name: &str, args: &str) -> Option { + let v = args_of(args); + let s = (*SCALE.lock().unwrap())?; + let point = match name { + "computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" | "computer_use_mouse_button" if !v["x"].is_null() => Some(to_screen(v["x"].as_f64()?, v["y"].as_f64()?, s)), + "computer_use_drag" => v["path"].as_array().and_then(|a| a.first()).and_then(|p| Some(to_screen(p["x"].as_f64()?, p["y"].as_f64()?, s))), + _ => None, + }; + match point { + Some((x, y)) => mac::window_at(x, y).map(|w| w.0), + None => mac::front_app().map(|w| w.0), + } +} + +/// Screenshot pixels per screen point (for the demonstration recorder). +pub fn points_to_shot() -> f64 { + let (pw, _) = mac::screen_points(); + let (px, py) = mac::screen_pixels(); + let (fw, _, _) = fit(px.max(1), py.max(1)); + if pw > 0.0 { fw as f64 / pw } else { 1.0 } +} + +/// The person's own clicks and keys can be recorded (教它一項任務): macOS, Linux on X11. +/// Wayland doesn't let apps watch other apps' input. +pub fn demo_supported() -> bool { + #[cfg(target_os = "linux")] + { + crate::desk::kind() == crate::desk::Kind::X11 + } + #[cfg(not(target_os = "linux"))] + { + cfg!(target_os = "macos") + } +} + +/// This system's screen can be seen and used (macOS; Linux on X11 or Wayland). +pub fn screen_supported() -> bool { + #[cfg(target_os = "linux")] + { + crate::desk::available() + } + #[cfg(not(target_os = "linux"))] + { + cfg!(target_os = "macos") + } +} + +pub fn screen_capture_ok() -> bool { + mac::screen_capture_preflight() +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn frontmost_app() -> Option { + mac::front_app().map(|w| w.0) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn app_at_point(x: f64, y: f64) -> Option { + mac::window_at(x, y).map(|w| w.0) +} + +fn screenshot() -> Result<(String, String), String> { + if !mac::screen_capture_allowed() { + let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture").spawn(); + return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); + } + let img = capture_screen()?; + let (w, h) = img.dimensions(); + let (fw, fh, resize) = fit(w, h); + let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; + let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?; + let (pw, ph) = mac::screen_points(); + let scale = (if fw > 0 { pw / fw as f64 } else { 1.0 }, if fh > 0 { ph / fh as f64 } else { 1.0 }); + *SCALE.lock().unwrap() = Some(scale); + let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg); + Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64)) +} + +/// The whole screen as RGB (backing pixels). +pub fn capture_screen() -> Result { + #[cfg(target_os = "linux")] + return crate::desk::capture(); + #[allow(unreachable_code)] + { + let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}.png", std::process::id(), crate::model::now_ms())); + let st = std::process::Command::new("screencapture").args(["-x", "-m", "-t", "png"]).arg(&file).status().map_err(|e| e.to_string())?; + let bytes = std::fs::read(&file); + let _ = std::fs::remove_file(&file); + if !st.success() { + return Err("截圖失敗".into()); + } + Ok(image::load_from_memory(&bytes.map_err(|e| format!("截圖失敗:{e}"))?).map_err(|e| format!("截圖讀不出來:{e}"))?.to_rgb8()) + } +} + +#[cfg(target_os = "linux")] +use crate::desk as mac; + +#[cfg(target_os = "macos")] +mod mac { + use super::*; + use std::ffi::c_void; + use std::time::Duration; + + #[repr(C)] + #[derive(Clone, Copy)] + struct CGPoint { + x: f64, + y: f64, + } + #[repr(C)] + #[derive(Clone, Copy)] + struct CGSize { + width: f64, + height: f64, + } + #[repr(C)] + #[derive(Clone, Copy)] + struct CGRect { + origin: CGPoint, + size: CGSize, + } + type Ev = *mut c_void; + + type Src = *mut c_void; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGMainDisplayID() -> u32; + fn CGDisplayBounds(d: u32) -> CGRect; + fn CGPreflightScreenCaptureAccess() -> bool; + fn CGRequestScreenCaptureAccess() -> bool; + fn CGEventSourceCreate(state: i32) -> Src; + fn CGEventSourceSetLocalEventsSuppressionInterval(src: Src, secs: f64); + fn CGEventCreateMouseEvent(src: Src, kind: u32, at: CGPoint, button: u32) -> Ev; + fn CGEventCreateKeyboardEvent(src: Src, key: u16, down: bool) -> Ev; + fn CGEventKeyboardSetUnicodeString(e: Ev, len: usize, s: *const u16); + fn CGEventSetFlags(e: Ev, flags: u64); + fn CGEventSetIntegerValueField(e: Ev, field: u32, v: i64); + fn CGEventPost(tap: u32, e: Ev); + fn CGEventCreateScrollWheelEvent(src: Src, units: u32, count: u32, wheel1: i32, ...) -> Ev; + fn CGEventPostToPid(pid: i32, e: Ev); + fn CGDisplayPixelsWide(d: u32) -> usize; + fn CGDisplayPixelsHigh(d: u32) -> usize; + fn CGWindowListCopyWindowInfo(option: u32, relative_to: u32) -> *const c_void; + fn CGRectMakeWithDictionaryRepresentation(dict: *const c_void, rect: *mut CGRect) -> bool; + static kCGWindowOwnerName: *const c_void; + static kCGWindowOwnerPID: *const c_void; + static kCGWindowBounds: *const c_void; + static kCGWindowLayer: *const c_void; + } + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFArrayGetCount(a: *const c_void) -> isize; + fn CFArrayGetValueAtIndex(a: *const c_void, i: isize) -> *const c_void; + fn CFDictionaryGetValue(d: *const c_void, k: *const c_void) -> *const c_void; + fn CFNumberGetValue(n: *const c_void, kind: isize, out: *mut c_void) -> bool; + fn CFStringGetCString(s: *const c_void, buf: *mut std::ffi::c_char, len: isize, enc: u32) -> bool; + } + + /// On-screen windows front to back: (owner app, pid, bounds), normal layer only. + fn windows() -> Vec<(String, i32, CGRect)> { + let mut out = vec![]; + unsafe { + // kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements + let list = CGWindowListCopyWindowInfo(1 | 16, 0); + if list.is_null() { + return out; + } + for i in 0..CFArrayGetCount(list) { + let d = CFArrayGetValueAtIndex(list, i); + let mut layer: i32 = -1; + let l = CFDictionaryGetValue(d, kCGWindowLayer); + if l.is_null() || !CFNumberGetValue(l, 3, &mut layer as *mut i32 as *mut c_void) || layer != 0 { + continue; + } + let mut pid: i32 = 0; + let p = CFDictionaryGetValue(d, kCGWindowOwnerPID); + if !p.is_null() { + CFNumberGetValue(p, 3, &mut pid as *mut i32 as *mut c_void); + } + let mut name = String::new(); + let n = CFDictionaryGetValue(d, kCGWindowOwnerName); + if !n.is_null() { + let mut buf = [0u8; 256]; + if CFStringGetCString(n, buf.as_mut_ptr() as *mut std::ffi::c_char, buf.len() as isize, 0x0800_0100) { + let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); + name = String::from_utf8_lossy(&buf[..end]).to_string(); + } + } + let mut r = CGRect { origin: CGPoint { x: 0.0, y: 0.0 }, size: CGSize { width: 0.0, height: 0.0 } }; + let b = CFDictionaryGetValue(d, kCGWindowBounds); + if !b.is_null() { + CGRectMakeWithDictionaryRepresentation(b, &mut r); + } + out.push((name, pid, r)); + } + CFRelease(list); + } + out + } + + /// The app whose window is under a screen point. + pub fn window_at(x: f64, y: f64) -> Option<(String, i32)> { + windows().into_iter().find(|(_, _, r)| x >= r.origin.x && y >= r.origin.y && x < r.origin.x + r.size.width && y < r.origin.y + r.size.height).map(|(n, p, _)| (n, p)) + } + + /// The frontmost app (its frontmost normal window). + pub fn front_app() -> Option<(String, i32)> { + windows().into_iter().find(|(n, _, r)| !n.is_empty() && r.size.width > 1.0).map(|(n, p, _)| (n, p)) + } + + pub fn screen_capture_preflight() -> bool { + unsafe { CGPreflightScreenCaptureAccess() } + } + + pub fn screen_pixels() -> (u32, u32) { + let d = unsafe { CGMainDisplayID() }; + let (pw, ph) = screen_points(); + // CGDisplayPixelsWide reports points on Retina; screencapture gives backing pixels. + let (w, h) = unsafe { (CGDisplayPixelsWide(d) as f64, CGDisplayPixelsHigh(d) as f64) }; + let scale = backing_scale(); + let _ = (w, h); + ((pw * scale) as u32, (ph * scale) as u32) + } + + fn backing_scale() -> f64 { + static S: std::sync::OnceLock = std::sync::OnceLock::new(); + *S.get_or_init(|| { + let file = std::env::temp_dir().join(format!("anybot-scale-{}.png", std::process::id())); + let ok = std::process::Command::new("screencapture").args(["-x", "-m", "-t", "png", "-R0,0,10,10"]).arg(&file).status().map(|s| s.success()).unwrap_or(false); + let w = if ok { image::image_dimensions(&file).map(|d| d.0).unwrap_or(10) } else { 10 }; + let _ = std::fs::remove_file(&file); + (w as f64 / 10.0).max(1.0) + }) + } + + /// A click delivered straight to an app without moving the pointer or raising its window + /// (the original's "skylight-no-raise" path does this with a private API). + pub fn click_in_background(pid: i32, x: f64, y: f64, button: &str, count: i64, flags: u64) { + let (down, up, _, b) = kinds(button); + let src = Source::new(); + for n in 1..=count { + if n > 1 { + sleep_secs(CLICK_REPEAT_GAP_SECS); + } + for (i, kind) in [down, up].into_iter().enumerate() { + if i == 1 { + sleep_secs(CLICK_HOLD_SECS); + } + let e = unsafe { CGEventCreateMouseEvent(src.0, kind, CGPoint { x, y }, b) }; + if e.is_null() { + continue; + } + unsafe { + CGEventSetIntegerValueField(e, CLICK_STATE, n); + CGEventSetFlags(e, flags); + CGEventPostToPid(pid, e); + CFRelease(e); + } + } + } + } + + /// Press or release a mouse button where the pointer is (or at a point). + pub fn mouse_button(at: Option<(f64, f64)>, button: &str, press: bool) { + let (down, up, _, b) = kinds(button); + let p = match at { + Some((x, y)) => { + move_to(x, y); + gap(); + CGPoint { x, y } + } + None => current_point(), + }; + let src = Source::new(); + let e = unsafe { CGEventCreateMouseEvent(src.0, if press { down } else { up }, p, b) }; + if !e.is_null() { + unsafe { CGEventSetIntegerValueField(e, CLICK_STATE, 1) }; + } + post(e, 0); + } + + fn current_point() -> CGPoint { + extern "C" { + fn CGEventCreate(src: Src) -> Ev; + fn CGEventGetLocation(e: Ev) -> CGPoint; + } + unsafe { + let e = CGEventCreate(std::ptr::null_mut()); + if e.is_null() { + return CGPoint { x: 0.0, y: 0.0 }; + } + let p = CGEventGetLocation(e); + CFRelease(e); + p + } + } + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(x: *const c_void); + } + + const HID_TAP: u32 = 0; + /// kCGEventSourceStateHIDSystemState, as CUActuator uses (CGEventSourceCreate(1)). + const HID_STATE: i32 = 1; + const CLICK_STATE: u32 = 1; + const MOVED: u32 = 5; + /// kCGScrollEventUnitPixel, as CGEventCreateScrollWheelEvent2(_, 0, 2, …) in the original. + const SCROLL_PIXEL: u32 = 0; + + /// An event source like the original's: HID system state, local-event suppression 0 s, so + /// the person's own mouse and keys aren't frozen after each synthetic event. + struct Source(Src); + impl Source { + fn new() -> Self { + let s = unsafe { CGEventSourceCreate(HID_STATE) }; + if !s.is_null() { + unsafe { CGEventSourceSetLocalEventsSuppressionInterval(s, 0.0) }; + } + Source(s) + } + } + impl Drop for Source { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { CFRelease(self.0 as *const c_void) } + } + } + } + + fn post(e: Ev, flags: u64) { + if e.is_null() { + return; + } + unsafe { + CGEventSetFlags(e, flags); + CGEventPost(HID_TAP, e); + CFRelease(e); + } + } + + fn gap() { + std::thread::sleep(Duration::from_millis(EVENT_GAP_MS)); + } + + fn sleep_secs(s: f64) { + std::thread::sleep(Duration::from_secs_f64(s)); + } + + /// (down, up, dragged) event kinds and button number. + fn kinds(button: &str) -> (u32, u32, u32, u32) { + match button { + "right" => (3, 4, 7, 1), + "middle" => (25, 26, 27, 2), + _ => (1, 2, 6, 0), + } + } + + pub fn screen_capture_allowed() -> bool { + unsafe { CGPreflightScreenCaptureAccess() || CGRequestScreenCaptureAccess() } + } + + pub fn screen_points() -> (f64, f64) { + let r = unsafe { CGDisplayBounds(CGMainDisplayID()) }; + (r.size.width, r.size.height) + } + + pub fn move_to(x: f64, y: f64) { + let src = Source::new(); + post(unsafe { CGEventCreateMouseEvent(src.0, MOVED, CGPoint { x, y }, 0) }, 0); + } + + /// CUActuator.click: press, hold 0.03 s, release; 0.08 s between the clicks of a multi-click. + pub fn click(x: f64, y: f64, button: &str, count: i64, flags: u64) { + let (down, up, _, b) = kinds(button); + let at = CGPoint { x, y }; + move_to(x, y); + gap(); + let src = Source::new(); + for n in 1..=count { + if n > 1 { + sleep_secs(CLICK_REPEAT_GAP_SECS); + } + for (i, kind) in [down, up].into_iter().enumerate() { + if i == 1 { + sleep_secs(CLICK_HOLD_SECS); + } + let e = unsafe { CGEventCreateMouseEvent(src.0, kind, at, b) }; + if !e.is_null() { + unsafe { CGEventSetIntegerValueField(e, CLICK_STATE, n) }; + } + post(e, flags); + } + } + } + + /// CURemoteEventPoster.drag(path:button:steps:holdSeconds:): press at the first point (click + /// state 1), hold, then walk each segment in `DRAG_STEPS` interpolated dragged events, then + /// release at the last point. + pub fn drag(path: &[(f64, f64)], button: &str, flags: u64) { + let (down, up, dragged, b) = kinds(button); + let (x0, y0) = path[0]; + move_to(x0, y0); + gap(); + let src = Source::new(); + let event = |kind: u32, x: f64, y: f64| { + let e = unsafe { CGEventCreateMouseEvent(src.0, kind, CGPoint { x, y }, b) }; + if !e.is_null() { + unsafe { CGEventSetIntegerValueField(e, CLICK_STATE, 1) }; + } + post(e, flags); + }; + event(down, x0, y0); + sleep_secs(DRAG_HOLD_SECS); + for (x, y) in drag_points(path) { + gap(); + event(dragged, x, y); + } + let (xn, yn) = path[path.len() - 1]; + event(up, xn, yn); + } + + /// handleScroll closure @0x10009e998: a mouse-moved event at the spot (the pointer's own + /// position when no x/y), then one pixel-unit scroll event with 2 wheels at that spot. + pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) { + if let Some((x, y)) = at { + move_to(x, y); + gap(); + } + let src = Source::new(); + post(unsafe { CGEventCreateScrollWheelEvent(src.0, SCROLL_PIXEL, 2, dy, dx) }, flags); + } + + /// CUTypedCharacters.postChunks: each chunk on key 128, down, 0.035 s, up; 0.02 s between chunks. + pub fn type_text(text: &str) { + let src = Source::new(); + let chunks = utf16_chunks(text); + for (i, chunk) in chunks.iter().enumerate() { + if i > 0 { + sleep_secs(KEY_GAP_SECS); + } + for down in [true, false] { + if !down { + sleep_secs(KEY_HOLD_SECS); + } + let e = unsafe { CGEventCreateKeyboardEvent(src.0, TYPE_KEY_CODE, down) }; + if !e.is_null() { + unsafe { CGEventKeyboardSetUnicodeString(e, chunk.len(), chunk.as_ptr()) }; + } + post(e, 0); + } + } + } + + /// handlePressKey: key down, hold (0.035 s unless told), key up. + pub fn press(code: u16, flags: u64, hold_secs: f64) { + let src = Source::new(); + post(unsafe { CGEventCreateKeyboardEvent(src.0, code, true) }, flags); + sleep_secs(hold_secs); + post(unsafe { CGEventCreateKeyboardEvent(src.0, code, false) }, flags); + } +} + + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod mac { + pub fn screen_capture_allowed() -> bool { + false + } + pub fn screen_points() -> (f64, f64) { + (0.0, 0.0) + } + pub fn move_to(_: f64, _: f64) {} + pub fn click(_: f64, _: f64, _: &str, _: i64, _: u64) {} + pub fn drag(_: &[(f64, f64)], _: &str, _: u64) {} + pub fn scroll(_: Option<(f64, f64)>, _: i32, _: i32, _: u64) {} + pub fn type_text(_: &str) {} + pub fn press(_: u16, _: u64, _: f64) {} + pub fn window_at(_: f64, _: f64) -> Option<(String, i32)> { + None + } + pub fn front_app() -> Option<(String, i32)> { + None + } + pub fn screen_capture_preflight() -> bool { + false + } + pub fn screen_pixels() -> (u32, u32) { + (0, 0) + } + pub fn click_in_background(_: i32, _: f64, _: f64, _: &str, _: i64, _: u64) {} + pub fn mouse_button(_: Option<(f64, f64)>, _: &str, _: bool) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn screenshots_fit_like_the_original() { + assert_eq!(fit(2940, 1912), (1024, 666, true)); + assert_eq!(fit(1024, 640), (1024, 640, false)); + assert_eq!(fit(800, 600), (800, 600, false)); + assert_eq!(fit(4000, 7), (4000, 7, false)); + // A very wide strip keeps its short edge at 8. + assert_eq!(fit(4000, 10), (3200, 8, true)); + } + + #[test] + fn jpeg_stays_under_the_cap() { + let img = image::RgbImage::from_fn(1024, 640, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8])); + let j = encode_jpeg(&img).unwrap(); + assert!(j.len() <= MAX_IMAGE_BYTES && j.starts_with(&[0xFF, 0xD8])); + } + + #[test] + fn shortcuts_are_parsed() { + assert_eq!(parse_combo("cmd+c").unwrap(), (8, FLAG_CMD)); + assert_eq!(parse_combo("cmd+shift+t").unwrap(), (17, FLAG_CMD | FLAG_SHIFT)); + assert_eq!(parse_combo("Return").unwrap(), (36, 0)); + assert_eq!(parse_combo("ctrl+option+space").unwrap(), (49, FLAG_CONTROL | FLAG_OPTION)); + assert!(parse_combo("hyper+x").is_err()); + assert!(parse_combo("").is_err()); + } + + #[test] + fn typed_text_is_chunked_like_the_original() { + let c = utf16_chunks(&"a".repeat(45)); + assert_eq!(c.iter().map(Vec::len).collect::>(), vec![20, 20, 5]); + // An emoji (2 UTF-16 units) is never split across chunks. + let c = utf16_chunks(&format!("{}😀b", "a".repeat(19))); + assert_eq!(c.iter().map(Vec::len).collect::>(), vec![19, 3]); + assert_eq!(utf16_chunks("你好").len(), 1); + assert!(utf16_chunks("").is_empty()); + } + + #[test] + fn scrolls_like_the_original() { + assert_eq!(scroll_amount(None), 3); + assert_eq!(scroll_amount(Some(0)), 1); + assert_eq!(scroll_amount(Some(99)), 20); + assert_eq!(scroll_pixels("down", 3), Some((-48, 0))); + assert_eq!(scroll_pixels("left", 2), Some((0, 32))); + assert_eq!(scroll_pixels("sideways", 1), None); + } + + #[test] + fn drags_walk_six_steps_per_segment() { + let pts = drag_points(&[(0.0, 0.0), (60.0, 0.0), (60.0, 30.0)]); + assert_eq!(pts.len(), 2 * DRAG_STEPS); + assert_eq!(pts[0], (10.0, 0.0)); + assert_eq!(pts[DRAG_STEPS - 1], (60.0, 0.0)); + assert_eq!(*pts.last().unwrap(), (60.0, 30.0)); + } + + #[test] + fn a_press_can_be_several_keys() { + assert_eq!(parse_keys("cmd+a delete").unwrap(), vec![(0, FLAG_CMD), (51, 0)]); + assert!(parse_keys(" ").is_err()); + } + + #[test] + fn screenshot_pixels_map_to_screen_points() { + // A 1470-point-wide screen sent as a 1024-wide picture. + let s = (1470.0 / 1024.0, 956.0 / 666.0); + let (x, y) = to_screen(512.0, 333.0, s); + assert!((x - 735.0).abs() < 0.01 && (y - 478.0).abs() < 0.01); + } +} + +#[cfg(test)] +mod window_tests { + /// `cargo test --release -- --ignored finds_apps_on_screen --nocapture` + #[test] + #[ignore] + fn finds_apps_on_screen() { + let front = super::frontmost_app(); + println!("front: {front:?}"); + println!("at (200,200): {:?}", super::app_at_point(200.0, 200.0)); + println!("points→shot: {}", super::points_to_shot()); + assert!(front.is_some()); + } +} diff --git a/src/desk.rs b/src/desk.rs new file mode 100644 index 0000000..c0113ee --- /dev/null +++ b/src/desk.rs @@ -0,0 +1,314 @@ +// Linux: one front for seeing and using the screen, whatever the desktop is. +// X11 (Xorg sessions) x11.rs +// Wayland, wlroots (sway, Hyprland…) wl.rs – the compositor's own protocols, no dialog +// Wayland, GNOME / KDE portal.rs – the desktop asks the person once +// The functions match computer.rs's macOS part, so the computer_use tools work the same way. + +use std::sync::OnceLock; +use std::time::Duration; + +use crate::x11; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kind { + X11, + Wlr, + Portal, + None, +} + +pub fn wayland() -> bool { + std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) +} + +/// Which way this session works (decided once). +pub fn kind() -> Kind { + static K: OnceLock = OnceLock::new(); + *K.get_or_init(|| { + if std::env::var("ANYBOT_DESK").as_deref() == Ok("x11") { + return if x11::available() { Kind::X11 } else { Kind::None }; + } + if wayland() { + if crate::wl::available() { + Kind::Wlr + } else if crate::portal::available() { + Kind::Portal + } else { + Kind::None + } + } else if x11::available() { + Kind::X11 + } else { + Kind::None + } + }) +} + +pub fn available() -> bool { + kind() != Kind::None +} + +/// A sentence for the bot and the settings page. +pub fn describe() -> &'static str { + match kind() { + Kind::X11 => "Linux(X11):看畫面和操作滑鼠鍵盤都可以用,不需要另外允許。", + Kind::Wlr => "Linux(Wayland):看畫面和操作滑鼠鍵盤都可以用,不需要另外允許。", + Kind::Portal => "Linux(Wayland):第一次看畫面時,系統會問要不要讓 App 截圖,選「允許」;第一次操作時會跳出「遠端桌面」視窗,打開「允許遠端操作」再按「分享」。", + Kind::None if wayland() => "這個 Wayland 桌面沒有提供讓 App 操作畫面的方式(找不到 xdg-desktop-portal 的遠端桌面功能)。請安裝 xdg-desktop-portal-gnome 或 xdg-desktop-portal-kde,或在登入畫面改選 Xorg 工作階段。", + Kind::None => "這台 Linux 沒有桌面畫面(找不到顯示器),所以沒辦法看畫面和操作。", + } +} + +pub fn capture() -> Result { + match kind() { + Kind::X11 => x11::capture(), + Kind::Wlr => crate::wl::capture(), + Kind::Portal => crate::portal::capture(), + Kind::None => Err(describe().into()), + } +} + +pub fn screen_points() -> (f64, f64) { + match kind() { + Kind::X11 => x11::screen_points(), + Kind::Wlr => crate::wl::screen_points(), + Kind::Portal => crate::portal::screen_points(), + Kind::None => (0.0, 0.0), + } +} + +pub fn screen_pixels() -> (u32, u32) { + match kind() { + Kind::X11 => x11::screen_pixels(), + Kind::Wlr => crate::wl::screen_pixels(), + Kind::Portal => crate::portal::screen_pixels(), + Kind::None => (0, 0), + } +} + +pub fn screen_capture_allowed() -> bool { + available() +} + +pub fn screen_capture_preflight() -> bool { + available() +} + +// ---------- the Wayland primitives, and actions built from them ---------- + +fn motion(x: f64, y: f64) { + match kind() { + Kind::Wlr => crate::wl::motion(x, y), + Kind::Portal => crate::portal::motion(x, y), + _ => {} + } +} + +fn button(b: u8, press: bool) { + match kind() { + Kind::Wlr => crate::wl::button(b, press), + Kind::Portal => crate::portal::button(b, press), + _ => {} + } +} + +fn key(sym: u32, press: bool) { + match kind() { + Kind::Wlr => crate::wl::key(sym, press), + Kind::Portal => crate::portal::key(sym, press), + _ => {} + } +} + +fn wheel(up: i32, left: i32) { + match kind() { + Kind::Wlr => crate::wl::wheel(up, left), + Kind::Portal => crate::portal::wheel(up, left), + _ => {} + } +} + +fn sleep_secs(s: f64) { + std::thread::sleep(Duration::from_secs_f64(s)); +} + +fn gap() { + std::thread::sleep(Duration::from_millis(8)); +} + +fn button_no(button: &str) -> u8 { + match button { + "right" => 3, + "middle" => 2, + _ => 1, + } +} + +fn with_mods(flags: u64, f: impl FnOnce()) { + let mods = x11::modifier_keysyms(flags); + for m in &mods { + key(*m, true); + } + if !mods.is_empty() { + gap(); + } + f(); + for m in mods.iter().rev() { + key(*m, false); + } +} + +pub fn move_to(px: f64, py: f64) { + if kind() == Kind::X11 { + return x11::move_to(px, py); + } + motion(px, py); +} + +pub fn click(px: f64, py: f64, which: &str, count: i64, flags: u64) { + if kind() == Kind::X11 { + return x11::click(px, py, which, count, flags); + } + motion(px, py); + gap(); + let b = button_no(which); + with_mods(flags, || { + for n in 1..=count { + if n > 1 { + sleep_secs(crate::computer::CLICK_REPEAT_GAP_SECS); + } + button(b, true); + sleep_secs(crate::computer::CLICK_HOLD_SECS); + button(b, false); + } + }); +} + +/// Linux has no "click without raising": a normal click. +pub fn click_in_background(_pid: i32, px: f64, py: f64, which: &str, count: i64, flags: u64) { + click(px, py, which, count, flags); +} + +pub fn mouse_button(at: Option<(f64, f64)>, which: &str, press: bool) { + if kind() == Kind::X11 { + return x11::mouse_button(at, which, press); + } + if let Some((x, y)) = at { + motion(x, y); + gap(); + } + button(button_no(which), press); +} + +pub fn drag(path: &[(f64, f64)], which: &str, flags: u64) { + if kind() == Kind::X11 { + return x11::drag(path, which, flags); + } + let (x0, y0) = path[0]; + motion(x0, y0); + gap(); + let b = button_no(which); + with_mods(flags, || { + button(b, true); + sleep_secs(crate::computer::DRAG_HOLD_SECS); + for (x, y) in crate::computer::drag_points(path) { + gap(); + motion(x, y); + } + button(b, false); + }); +} + +pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) { + if kind() == Kind::X11 { + return x11::scroll(at, dy, dx, flags); + } + if let Some((x, y)) = at { + motion(x, y); + gap(); + } + let step = crate::computer::SCROLL_PIXELS_PER_STEP; + with_mods(flags, || { + // One wheel click at a time, like X11's buttons 4–7. + let (vy, vx) = (dy / step, dx / step); + for _ in 0..vy.abs() { + wheel(vy.signum(), 0); + std::thread::sleep(Duration::from_millis(12)); + } + for _ in 0..vx.abs() { + wheel(0, vx.signum()); + std::thread::sleep(Duration::from_millis(12)); + } + }); +} + +pub fn press(code: u16, flags: u64, hold_secs: f64) { + if kind() == Kind::X11 { + return x11::press(code, flags, hold_secs); + } + let Some(sym) = x11::keysym_of_mac_code(code) else { return }; + with_mods(flags, || { + key(sym, true); + sleep_secs(hold_secs); + key(sym, false); + }); +} + +pub fn type_text(text: &str) { + match kind() { + Kind::X11 => x11::type_text(text), + Kind::Wlr => crate::wl::type_text(text), + Kind::Portal => crate::portal::type_text(text), + Kind::None => {} + } +} + +// ---------- windows ---------- + +/// The app under a point. Wayland doesn't tell apps where other windows are, so only X11 (and +/// XWayland windows) can answer. +pub fn window_at(px: f64, py: f64) -> Option<(String, i32)> { + match kind() { + Kind::X11 => x11::window_at(px, py), + _ => None, + } +} + +pub fn front_app() -> Option<(String, i32)> { + match kind() { + Kind::X11 => x11::front_app(), + Kind::Wlr => crate::wl::front_app(), + _ => None, + } +} + +// ---------- idle and screensaver ---------- + +fn dbus_call(dest: &str, path: &str, iface: &str, method: &str) -> Option { + let conn = zbus::blocking::Connection::session().ok()?; + let reply = conn.call_method(Some(dest), path, Some(iface), method, &()).ok()?; + reply.body().deserialize::().ok() +} + +/// Seconds since the last keyboard or mouse input. +pub fn idle_secs() -> Option { + if !wayland() { + return x11::idle_secs(); + } + // GNOME: Mutter's idle monitor (milliseconds). + if let Some(ms) = dbus_call::("org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core", "org.gnome.Mutter.IdleMonitor", "GetIdletime") { + return Some(ms as f64 / 1000.0); + } + // wlroots, KDE and others: ext-idle-notify. + crate::wl::idle_secs() +} + +/// The screensaver (or lock screen) is on. +pub fn screensaver_on() -> bool { + if !wayland() { + return x11::screensaver_on(); + } + dbus_call::("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", "org.gnome.ScreenSaver", "GetActive") + .or_else(|| dbus_call::("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive")) + .unwrap_or(false) +} diff --git a/src/diagram.rs b/src/diagram.rs new file mode 100644 index 0000000..6304db1 --- /dev/null +++ b/src/diagram.rs @@ -0,0 +1,86 @@ +// Mermaid diagrams in chat (```mermaid fences), like the original's own Mermaid-compatible +// engine: pure Rust (mermaid-rs-renderer → SVG), drawn with resvg using AnyBot's fonts so +// Chinese labels show. Flowcharts, sequence, class, state, ER, gantt, pie, mindmap, timeline… + +use std::sync::OnceLock; + +/// The original refuses diagrams over 50 000 characters. +pub const MAX_SOURCE: usize = 50_000; + +fn fontdb() -> std::sync::Arc { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let mut db = resvg::usvg::fontdb::Database::new(); + db.load_font_data(crate::HUNINN.to_vec()); + db.load_system_fonts(); + // Every family the diagram asks for falls back to 粉圓體 (it has Latin and CJK). + let found: Option = db.faces().find(|f| f.families.iter().any(|(n, _)| n.to_lowercase().contains("huninn") || n.contains("粉圓"))).and_then(|f| f.families.first().map(|(n, _)| n.clone())); + if let Some(name) = found { + db.set_sans_serif_family(name.clone()); + db.set_serif_family(name); + } + std::sync::Arc::new(db) + }) + .clone() +} + +/// Mermaid source → SVG text. +pub fn svg(source: &str, dark: bool) -> Result { + if source.len() > MAX_SOURCE { + return Err("圖表太大".into()); + } + let mut opts = mermaid_rs_renderer::RenderOptions::default(); + if dark { + opts.theme = mermaid_rs_renderer::Theme::dark(); + } + opts.theme.font_family = "sans-serif".into(); + // A panic inside the layout must not take the window down. + let src = source.to_string(); + std::panic::catch_unwind(move || mermaid_rs_renderer::render_strict(&src, opts).map_err(|e| e.to_string())) + .map_err(|_| "圖表排版失敗".to_string())? +} + +/// Mermaid source → RGBA pixels at `scale` (2.0 for sharp text on Retina). +pub fn render(source: &str, dark: bool, scale: f32) -> Result { + let svg = svg(source, dark)?; + let mut opt = resvg::usvg::Options::default(); + opt.fontdb = fontdb(); + opt.font_family = "sans-serif".into(); + let tree = resvg::usvg::Tree::from_str(&svg, &opt).map_err(|e| e.to_string())?; + let size = tree.size(); + let (w, h) = ((size.width() * scale).ceil().max(1.0) as u32, (size.height() * scale).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 40_000_000 { + return Err("圖表太大".into()); + } + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).ok_or("圖表太大")?; + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut()); + // tiny-skia is premultiplied; egui wants straight alpha. + let mut px = pm.take(); + for c in px.chunks_exact_mut(4) { + let a = c[3] as u32; + if a > 0 && a < 255 { + for v in &mut c[..3] { + *v = ((*v as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + image::RgbaImage::from_raw(w, h, px).ok_or_else(|| "圖表轉換失敗".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flowchart_and_errors() { + let img = render("flowchart TD\n A[開始] --> B{要不要出門?}\n B -->|要| C[帶傘]\n B -->|不要| D[在家看書]\n C --> E[結束]\n D --> E", false, 1.0).unwrap(); + assert!(img.width() > 100 && img.height() > 100); + // Some ink in the picture (not all transparent). + assert!(img.pixels().filter(|p| p.0[3] > 0).count() > 1000); + if let Ok(out) = std::env::var("ANYBOT_DIAGRAM_OUT") { + img.save(out).unwrap(); + } + assert!(render("this is not mermaid at all {{{", false, 1.0).is_err()); + assert!(render(&"x".repeat(MAX_SOURCE + 1), false, 1.0).is_err()); + } +} diff --git a/src/egress.rs b/src/egress.rs new file mode 100644 index 0000000..d261100 --- /dev/null +++ b/src/egress.rs @@ -0,0 +1,197 @@ +// 「網路流量改走你的電腦」: the bot's computer can send its new connections out through this Mac +// (the original: 可以改走你的本機電腦,而不是雲端 — 「僅對新連線生效」, and an admin can turn it off; +// it runs an egress tunnel, Ap.egressConnectProxy 8791). Here: a small HTTP/HTTPS proxy on this +// Mac that the box's commands use through HTTP(S)_PROXY. + +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}; +use std::time::Duration; + +/// The original's egress CONNECT proxy port; a free one nearby is used when it's taken. +pub const PORT: u16 = 8791; +const HEAD_MAX: usize = 64 * 1024; + +static RUNNING: AtomicBool = AtomicBool::new(false); +static BOUND: AtomicU16 = AtomicU16::new(0); +pub static CONNECTIONS: AtomicU64 = AtomicU64::new(0); + +/// Start the proxy once (on 127.0.0.1). Returns its port. +pub fn start() -> Result { + if RUNNING.load(Ordering::SeqCst) { + return Ok(BOUND.load(Ordering::SeqCst)); + } + // macOS (Docker Desktop / OrbStack) forwards host.docker.internal to this machine's + // 127.0.0.1. Linux Docker reaches the host through the bridge (172.17.0.1…), so listen on all + // addresses there and only serve this machine and its containers (see `allowed_peer`). + let host = if cfg!(target_os = "linux") { "0.0.0.0" } else { "127.0.0.1" }; + let listener = (PORT..PORT + 20).find_map(|p| TcpListener::bind((host, p)).ok()).ok_or("找不到可用的連接埠")?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + BOUND.store(port, Ordering::SeqCst); + RUNNING.store(true, Ordering::SeqCst); + std::thread::spawn(move || { + for conn in listener.incoming().flatten() { + if !conn.peer_addr().map(|a| allowed_peer(a.ip())).unwrap_or(false) { + let _ = conn.shutdown(Shutdown::Both); + continue; + } + std::thread::spawn(move || { + let _ = handle(conn); + }); + } + }); + Ok(port) +} + +/// Who may use the proxy: this machine, and Docker's own networks (172.16.0.0/12, where the +/// bridge and compose networks live) — never the rest of the local network. +pub fn allowed_peer(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && (16..=31).contains(&v.octets()[1])), + std::net::IpAddr::V6(v) => v.is_loopback(), + } +} + +pub fn port() -> Option { + RUNNING.load(Ordering::SeqCst).then(|| BOUND.load(Ordering::SeqCst)) +} + +/// Where the request goes and what to send there first. +#[derive(Debug, PartialEq)] +pub struct Route { + pub host: String, + pub port: u16, + /// Bytes to send to the server first (empty for CONNECT). + pub preamble: Vec, + pub tunnel: bool, +} + +/// Parse a proxy request head: `CONNECT host:443` or `GET http://host[:port]/path HTTP/1.1`. +pub fn route(head: &[u8]) -> Result { + let text = String::from_utf8_lossy(head); + let first = text.lines().next().unwrap_or(""); + let mut parts = first.split_whitespace(); + let (method, target, version) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or("HTTP/1.1")); + if method.eq_ignore_ascii_case("CONNECT") { + let (h, p) = target.rsplit_once(':').ok_or("CONNECT 要有連接埠")?; + return Ok(Route { host: h.trim_matches(['[', ']']).to_string(), port: p.parse().map_err(|_| "連接埠不對")?, preamble: vec![], tunnel: true }); + } + let rest = target.strip_prefix("http://").ok_or("只支援 http:// 或 CONNECT")?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) => (h.to_string(), p.parse().unwrap_or(80)), + _ => (authority.to_string(), 80), + }; + let mut pre = format!("{method} {path} {version}\r\n").into_bytes(); + let body_start = text.find("\r\n").map(|i| i + 2).unwrap_or(text.len()); + // Drop hop-by-hop proxy headers. + for line in text[body_start..].split_inclusive("\r\n") { + let l = line.to_ascii_lowercase(); + if l.starts_with("proxy-connection:") || l.starts_with("proxy-authorization:") { + continue; + } + pre.extend_from_slice(line.as_bytes()); + } + Ok(Route { host, port, preamble: pre, tunnel: false }) +} + +fn handle(mut client: TcpStream) -> Result<(), String> { + client.set_read_timeout(Some(Duration::from_secs(30))).ok(); + let mut buf = vec![]; + let mut chunk = [0u8; 4096]; + let head_end = loop { + let n = client.read(&mut chunk).map_err(|e| e.to_string())?; + if n == 0 { + return Ok(()); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break i + 4; + } + if buf.len() > HEAD_MAX { + return Err("標頭太大".into()); + } + }; + let r = match route(&buf[..head_end]) { + Ok(r) => r, + Err(e) => { + let _ = client.write_all(format!("HTTP/1.1 400 Bad Request\r\nContent-Length: {}\r\n\r\n{e}", e.len()).as_bytes()); + return Err(e); + } + }; + let mut server = match TcpStream::connect((r.host.as_str(), r.port)) { + Ok(s) => s, + Err(e) => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); + return Err(e.to_string()); + } + }; + CONNECTIONS.fetch_add(1, Ordering::Relaxed); + client.set_read_timeout(None).ok(); + if r.tunnel { + client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").map_err(|e| e.to_string())?; + } else { + server.write_all(&r.preamble).map_err(|e| e.to_string())?; + } + // Anything the client sent after the head. + if head_end < buf.len() { + server.write_all(&buf[head_end..]).map_err(|e| e.to_string())?; + } + let (mut c2, mut s2) = (client.try_clone().map_err(|e| e.to_string())?, server.try_clone().map_err(|e| e.to_string())?); + let up = std::thread::spawn(move || { + let _ = std::io::copy(&mut c2, &mut s2); + let _ = s2.shutdown(Shutdown::Write); + }); + let _ = std::io::copy(&mut server, &mut client); + let _ = client.shutdown(Shutdown::Write); + let _ = up.join(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routes_connect_and_plain_http() { + let r = route(b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n").unwrap(); + assert_eq!((r.host.as_str(), r.port, r.tunnel), ("example.com", 443, true)); + let r = route(b"GET http://example.com:8080/a?b=1 HTTP/1.1\r\nHost: example.com\r\nProxy-Connection: keep-alive\r\n\r\n").unwrap(); + assert_eq!((r.host.as_str(), r.port, r.tunnel), ("example.com", 8080, false)); + let pre = String::from_utf8(r.preamble).unwrap(); + assert!(pre.starts_with("GET /a?b=1 HTTP/1.1\r\n") && !pre.to_lowercase().contains("proxy-connection")); + assert!(route(b"GET /x HTTP/1.1\r\n\r\n").is_err()); + } + + #[test] + fn proxies_a_real_request() { + // A tiny origin server on this machine. + let origin = TcpListener::bind("127.0.0.1:0").unwrap(); + let op = origin.local_addr().unwrap().port(); + std::thread::spawn(move || { + if let Ok((mut s, _)) = origin.accept() { + let mut b = [0u8; 1024]; + let _ = s.read(&mut b); + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"); + } + }); + let port = start().unwrap(); + let mut c = TcpStream::connect(("127.0.0.1", port)).unwrap(); + c.write_all(format!("GET http://127.0.0.1:{op}/ HTTP/1.1\r\nHost: x\r\n\r\n").as_bytes()).unwrap(); + let mut out = String::new(); + c.read_to_string(&mut out).unwrap(); + assert!(out.ends_with("hello"), "{out}"); + } + + #[test] + fn only_this_machine_and_containers() { + assert!(super::allowed_peer("127.0.0.1".parse().unwrap())); + assert!(super::allowed_peer("172.17.0.2".parse().unwrap())); + assert!(super::allowed_peer("172.31.255.1".parse().unwrap())); + assert!(!super::allowed_peer("172.32.0.1".parse().unwrap())); + assert!(!super::allowed_peer("192.168.1.20".parse().unwrap())); + } +} diff --git a/src/httpd.rs b/src/httpd.rs new file mode 100644 index 0000000..37141f9 --- /dev/null +++ b/src/httpd.rs @@ -0,0 +1,300 @@ +// (Shared with relay/, which uses the parts the app doesn't.) +#![allow(dead_code)] +// A very small HTTP/1.1 server (one thread per connection), shared by the phone page on this +// computer and by the relay (the thin backend). No keep-alive: every answer closes the connection. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::time::Duration; + +/// Largest request body accepted (a phone photo fits). +pub const MAX_BODY: usize = 30 * 1024 * 1024; + +#[derive(Clone, Debug, Default)] +pub struct Req { + pub method: String, + pub path: String, + pub query: HashMap, + /// Header names in lower case. + pub headers: HashMap, + pub body: Vec, + pub peer: String, +} + +impl Req { + pub fn header(&self, name: &str) -> &str { + self.headers.get(&name.to_ascii_lowercase()).map(|s| s.as_str()).unwrap_or("") + } + pub fn bearer(&self) -> String { + let a = self.header("authorization"); + if let Some(t) = a.strip_prefix("Bearer ") { + return t.trim().to_string(); + } + // EventSource / can't send headers: a token in the query works too. + self.query.get("t").cloned().unwrap_or_default() + } + pub fn json(&self) -> serde_json::Value { + serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null) + } + /// Path segments, e.g. "/api/bots/3/send" → ["api", "bots", "3", "send"]. + pub fn parts(&self) -> Vec<&str> { + self.path.split('/').filter(|s| !s.is_empty()).collect() + } +} + +#[derive(Clone, Debug)] +pub struct Resp { + pub status: u16, + pub ctype: String, + pub body: Vec, + pub headers: Vec<(String, String)>, +} + +impl Resp { + pub fn json(v: &serde_json::Value) -> Resp { + Resp { status: 200, ctype: "application/json; charset=utf-8".into(), body: v.to_string().into_bytes(), headers: vec![] } + } + pub fn bytes(ctype: &str, body: Vec) -> Resp { + Resp { status: 200, ctype: ctype.into(), body, headers: vec![] } + } + pub fn error(status: u16, msg: &str) -> Resp { + Resp { status, ..Resp::json(&serde_json::json!({ "error": msg })) } + } + pub fn empty(status: u16) -> Resp { + Resp { status, ctype: "text/plain".into(), body: vec![], headers: vec![] } + } + pub fn with_header(mut self, k: &str, v: &str) -> Resp { + self.headers.push((k.into(), v.into())); + self + } +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 409 => "Conflict", + 413 => "Payload Too Large", + 429 => "Too Many Requests", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + 504 => "Gateway Timeout", + _ => "OK", + } +} + +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < b.len() => { + if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(v); + i += 2; + } else { + out.push(b'%'); + } + } + c => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +pub fn parse_query(q: &str) -> HashMap { + q.split('&').filter(|p| !p.is_empty()).map(|p| { + let (k, v) = p.split_once('=').unwrap_or((p, "")); + (decode(k), decode(v)) + }).collect() +} + +/// Read one request (head, then exactly Content-Length bytes of body). +pub fn read_request(c: &mut TcpStream) -> Result { + c.set_read_timeout(Some(Duration::from_secs(30))).ok(); + let mut got: Vec = vec![]; + let mut buf = [0u8; 16384]; + let head_end = loop { + if let Some(p) = got.windows(4).position(|w| w == b"\r\n\r\n") { + break p; + } + if got.len() > 64 * 1024 { + return Err(400); + } + let n = c.read(&mut buf).map_err(|_| 400u16)?; + if n == 0 { + return Err(400); + } + got.extend_from_slice(&buf[..n]); + }; + let head = String::from_utf8_lossy(&got[..head_end]).to_string(); + let mut lines = head.split("\r\n"); + let first = lines.next().unwrap_or(""); + let mut fp = first.split_whitespace(); + let method = fp.next().unwrap_or("").to_string(); + let target = fp.next().unwrap_or("/").to_string(); + let headers: HashMap = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect(); + let len: usize = headers.get("content-length").and_then(|v| v.parse().ok()).unwrap_or(0); + if len > MAX_BODY { + return Err(413); + } + let mut body = got[head_end + 4..].to_vec(); + while body.len() < len { + let n = c.read(&mut buf).map_err(|_| 400u16)?; + if n == 0 { + break; + } + body.extend_from_slice(&buf[..n]); + } + body.truncate(len); + let (path, q) = target.split_once('?').unwrap_or((&target, "")); + Ok(Req { method, path: decode(path), query: parse_query(q), headers, body, peer: c.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default() }) +} + +pub fn write_response(c: &mut TcpStream, r: &Resp) { + let mut head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n", + r.status, + reason(r.status), + r.ctype, + r.body.len() + ); + for (k, v) in &r.headers { + head.push_str(&format!("{k}: {v}\r\n")); + } + head.push_str("\r\n"); + let _ = c.write_all(head.as_bytes()); + let _ = c.write_all(&r.body); + let _ = c.flush(); +} + +pub type Handler = Arc Resp + Send + Sync>; + +/// Answer every connection on this listener with `handler` (each on its own thread). +pub fn serve(listener: TcpListener, handler: Handler, alive: Arc bool + Send + Sync>) { + listener.set_nonblocking(true).ok(); + loop { + if !alive() { + return; + } + match listener.accept() { + Ok((mut c, _)) => { + c.set_nonblocking(false).ok(); + let h = handler.clone(); + std::thread::spawn(move || match read_request(&mut c) { + Ok(req) => { + let resp = h(req); + write_response(&mut c, &resp); + } + Err(code) => write_response(&mut c, &Resp::error(code, "bad request")), + }); + } + Err(_) => std::thread::sleep(Duration::from_millis(50)), + } + } +} + +// ---------- secrets ---------- + +pub fn random_hex(bytes: usize) -> String { + use ring::rand::SecureRandom; + let mut b = vec![0u8; bytes]; + ring::rand::SystemRandom::new().fill(&mut b).expect("random"); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +pub fn sha256_hex(s: &str) -> String { + ring::digest::digest(&ring::digest::SHA256, s.as_bytes()).as_ref().iter().map(|x| format!("{x:02x}")).collect() +} + +const PBKDF2_ROUNDS: u32 = 120_000; + +/// A password stored as "salt$hash" (PBKDF2-HMAC-SHA256). +pub fn hash_password(pw: &str) -> String { + let salt = random_hex(16); + format!("{salt}${}", pbkdf2_hex(pw, &salt)) +} + +fn pbkdf2_hex(pw: &str, salt: &str) -> String { + let mut out = [0u8; 32]; + ring::pbkdf2::derive(ring::pbkdf2::PBKDF2_HMAC_SHA256, std::num::NonZeroU32::new(PBKDF2_ROUNDS).unwrap(), salt.as_bytes(), pw.as_bytes(), &mut out); + out.iter().map(|x| format!("{x:02x}")).collect() +} + +pub fn check_password(pw: &str, stored: &str) -> bool { + let Some((salt, hash)) = stored.split_once('$') else { return false }; + let mine = pbkdf2_hex(pw, salt); + // Compare without stopping at the first difference. + mine.len() == hash.len() && mine.bytes().zip(hash.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0 +} + +/// A 256-bit key from a password (for the encrypted backup kept by the relay). +pub fn derive_key(pw: &str, user: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + ring::pbkdf2::derive(ring::pbkdf2::PBKDF2_HMAC_SHA256, std::num::NonZeroU32::new(PBKDF2_ROUNDS).unwrap(), format!("anybot-backup:{user}").as_bytes(), pw.as_bytes(), &mut out); + out +} + +/// AES-256-GCM; the 12-byte nonce goes first. +pub fn seal(key: &[u8; 32], plain: &[u8]) -> Vec { + use ring::aead::*; + use ring::rand::SecureRandom; + let k = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).expect("key")); + let mut nonce = [0u8; 12]; + ring::rand::SystemRandom::new().fill(&mut nonce).expect("random"); + let mut data = plain.to_vec(); + k.seal_in_place_append_tag(Nonce::assume_unique_for_key(nonce), Aad::empty(), &mut data).expect("seal"); + let mut out = nonce.to_vec(); + out.extend(data); + out +} + +pub fn open(key: &[u8; 32], sealed: &[u8]) -> Option> { + use ring::aead::*; + if sealed.len() < 12 + 16 { + return None; + } + let k = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).ok()?); + let nonce = Nonce::try_assume_unique_for_key(&sealed[..12]).ok()?; + let mut data = sealed[12..].to_vec(); + let plain = k.open_in_place(nonce, Aad::empty(), &mut data).ok()?; + Some(plain.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn passwords_and_sealing() { + let h = hash_password("貓咪 123"); + assert!(check_password("貓咪 123", &h) && !check_password("貓咪 124", &h)); + let k = derive_key("pw", "daniel"); + let s = seal(&k, "秘密".as_bytes()); + assert_eq!(open(&k, &s).unwrap(), "秘密".as_bytes()); + assert!(open(&derive_key("pw2", "daniel"), &s).is_none()); + assert_eq!(parse_query("a=1&b=%E4%BD%A0+x")["b"], "你 x"); + } + + #[test] + fn serves_a_request() { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let h: Handler = Arc::new(|r: Req| Resp::json(&serde_json::json!({"p": r.path, "n": r.body.len(), "q": r.query.get("x")}))); + std::thread::spawn(move || serve(l, h, Arc::new(|| true))); + let body = vec![7u8; 100_000]; + let mut resp = ureq::post(&format!("http://127.0.0.1:{port}/a/b?x=1")).send(&body[..]).unwrap(); + let v: serde_json::Value = serde_json::from_str(&resp.body_mut().read_to_string().unwrap()).unwrap(); + assert_eq!(v["n"], 100_000); + assert_eq!(v["p"], "/a/b"); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..f736652 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,230 @@ +// 語言: the menus' language and the language bots answer in (the original: "選擇 Grok Bot 選單 +// 和訊息使用的語言", 37 languages + automatic). +// +// The window is written in Traditional Chinese. 简体中文 is converted from it by macOS +// (ICU "Hant-Hans"), so every string is covered. English and 日本語 use the tables below for the +// window's chrome (sidebar, header, composer, side panel, settings); the rest stays in Chinese. +// Other languages show the menus in English and make the bots answer in that language. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock, RwLock}; + +/// The original's 37 languages: (tag, endonym, English name). +pub const LANGS: [(&str, &str, &str); 37] = [ + ("af", "Afrikaans", "Afrikaans"), + ("ar", "العربية", "Arabic"), + ("bn", "বাংলা", "Bengali"), + ("cs", "Čeština", "Czech"), + ("da", "Dansk", "Danish"), + ("de", "Deutsch", "German"), + ("el", "Ελληνικά", "Greek"), + ("en", "English", "English"), + ("es", "Español", "Spanish"), + ("fi", "Suomi", "Finnish"), + ("fr", "Français", "French"), + ("ha", "Hausa", "Hausa"), + ("he", "עברית", "Hebrew"), + ("hi", "हिन्दी", "Hindi"), + ("hu", "Magyar", "Hungarian"), + ("id", "Bahasa Indonesia", "Indonesian"), + ("it", "Italiano", "Italian"), + ("ja", "日本語", "Japanese"), + ("ko", "한국어", "Korean"), + ("mr", "मराठी", "Marathi"), + ("nb", "Norsk bokmål", "Norwegian Bokmål"), + ("nl", "Nederlands", "Dutch"), + ("pl", "Polski", "Polish"), + ("pt-PT", "Português (Portugal)", "Portuguese (Portugal)"), + ("pt", "Português (Brasil)", "Portuguese (Brazil)"), + ("ru", "Русский", "Russian"), + ("sv", "Svenska", "Swedish"), + ("sw", "Kiswahili", "Swahili"), + ("ta", "தமிழ்", "Tamil"), + ("te", "తెలుగు", "Telugu"), + ("th", "ไทย", "Thai"), + ("tr", "Türkçe", "Turkish"), + ("uk", "Українська", "Ukrainian"), + ("ur", "اردو", "Urdu"), + ("vi", "Tiếng Việt", "Vietnamese"), + ("zh-CN", "简体中文", "Simplified Chinese"), + ("zh-TW", "繁體中文", "Traditional Chinese"), +]; + +fn current() -> &'static RwLock { + static L: OnceLock> = OnceLock::new(); + L.get_or_init(|| RwLock::new("zh-TW".into())) +} + +/// "auto" follows the system language. +pub fn set(tag: &str) { + let t = if tag == "auto" || tag.is_empty() { system() } else { tag.to_string() }; + *current().write().unwrap() = t; +} + +pub fn lang() -> String { + current().read().unwrap().clone() +} + +/// The Mac's first preferred language, as one of LANGS (Traditional Chinese when unknown). +pub fn system() -> String { + if !cfg!(target_os = "macos") { + // Linux: LANGUAGE (a list), then LC_ALL / LC_MESSAGES / LANG ("zh_TW.UTF-8"). + let first = ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"] + .iter() + .filter_map(|k| std::env::var(k).ok()) + .map(|v| v.split(':').next().unwrap_or("").split('.').next().unwrap_or("").to_string()) + .find(|v| !v.is_empty() && v != "C" && v != "POSIX") + .unwrap_or_else(|| "zh-Hant-TW".into()); + return match_tag(&first); + } + let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); + let first = out.split('"').nth(1).unwrap_or("zh-Hant-TW").to_string(); + match_tag(&first) +} + +pub fn match_tag(code: &str) -> String { + let c = code.replace('_', "-"); + if c.starts_with("zh-Hans") || c == "zh-CN" || c == "zh-SG" { + return "zh-CN".into(); + } + if c.starts_with("zh") { + return "zh-TW".into(); + } + if c.starts_with("pt-PT") { + return "pt-PT".into(); + } + let base = c.split('-').next().unwrap_or(""); + LANGS.iter().find(|l| l.0 == base).map(|l| l.0.to_string()).unwrap_or_else(|| "zh-TW".into()) +} + +pub fn english_name(tag: &str) -> &'static str { + LANGS.iter().find(|l| l.0 == tag).map(|l| l.2).unwrap_or("Traditional Chinese") +} + +pub fn endonym(tag: &str) -> &'static str { + LANGS.iter().find(|l| l.0 == tag).map(|l| l.1).unwrap_or("繁體中文") +} + +/// Added to every bot's instructions: answer in the chosen language ("auto" = the person's). +pub fn reply_instruction(setting: &str) -> String { + if setting == "auto" || setting.is_empty() { + return "回答時使用使用者所用的語言。".into(); + } + match setting { + "zh-TW" => "一律用繁體中文回答(台灣用語)。".into(), + "zh-CN" => "一律用简体中文回答。".into(), + t => format!("Always answer in {} ({}), whatever language the instructions are written in.", english_name(t), endonym(t)), + } +} + +fn cache() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A window string in the chosen language. +pub fn t(s: &str) -> String { + crate::platform::localize(&t_raw(s)) +} + +fn t_raw(s: &str) -> String { + let l = lang(); + if l == "zh-TW" || s.is_empty() { + return s.to_string(); + } + let key = format!("{l}\u{1}{s}"); + if let Some(v) = cache().lock().unwrap().get(&key) { + return v.clone(); + } + let v = match l.as_str() { + "zh-CN" => to_simplified(s), + "ja" => table(s, 2).or_else(|| table(s, 1)).unwrap_or_else(|| s.to_string()), + _ => table(s, 1).unwrap_or_else(|| s.to_string()), + }; + cache().lock().unwrap().insert(key, v.clone()); + v +} + +fn table(s: &str, col: usize) -> Option { + static M: OnceLock> = OnceLock::new(); + let m = M.get_or_init(|| crate::i18n_table::ROWS.iter().map(|r| (r.0, (r.1, r.2))).collect()); + m.get(s).map(|(en, ja)| if col == 2 { ja.to_string() } else { en.to_string() }).filter(|v| !v.is_empty()) +} + +/// Traditional → Simplified with macOS's own converter (ICU transform "Hant-Hans"). +#[cfg(target_os = "macos")] +pub fn to_simplified(s: &str) -> String { + use std::ffi::{c_char, c_void, CString}; + type CFTypeRef = *const c_void; + #[repr(C)] + struct CFRange { + location: isize, + length: isize, + } + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFStringCreateWithCString(alloc: CFTypeRef, s: *const c_char, enc: u32) -> CFTypeRef; + fn CFStringCreateMutableCopy(alloc: CFTypeRef, max: isize, s: CFTypeRef) -> CFTypeRef; + fn CFStringTransform(s: CFTypeRef, range: *mut CFRange, transform: CFTypeRef, reverse: u8) -> u8; + fn CFStringGetLength(s: CFTypeRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(len: isize, enc: u32) -> isize; + fn CFStringGetCString(s: CFTypeRef, buf: *mut c_char, size: isize, enc: u32) -> bool; + fn CFRelease(o: CFTypeRef); + } + const UTF8: u32 = 0x0800_0100; + let (Ok(c), Ok(tr)) = (CString::new(s), CString::new("Hant-Hans")) else { return s.to_string() }; + unsafe { + let src = CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8); + let rule = CFStringCreateWithCString(std::ptr::null(), tr.as_ptr(), UTF8); + if src.is_null() || rule.is_null() { + return s.to_string(); + } + let m = CFStringCreateMutableCopy(std::ptr::null(), 0, src); + let ok = CFStringTransform(m, std::ptr::null_mut(), rule, 0) != 0; + let size = CFStringGetMaximumSizeForEncoding(CFStringGetLength(m), UTF8) + 1; + let mut buf = vec![0 as c_char; size as usize]; + let got = CFStringGetCString(m, buf.as_mut_ptr(), size, UTF8); + CFRelease(m); + CFRelease(src); + CFRelease(rule); + if !ok || !got { + return s.to_string(); + } + std::ffi::CStr::from_ptr(buf.as_ptr()).to_string_lossy().to_string() + } +} + +/// Traditional → Simplified elsewhere: zhconv (OpenCC and MediaWiki tables, pure Rust). +#[cfg(not(target_os = "macos"))] +pub fn to_simplified(s: &str) -> String { + zhconv::zhconv(s, zhconv::Variant::ZhHans) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn languages_and_tables() { + assert_eq!(LANGS.len(), 37); + assert_eq!(match_tag("zh-Hant-TW"), "zh-TW"); + assert_eq!(match_tag("zh-Hans-CN"), "zh-CN"); + assert_eq!(match_tag("ja-JP"), "ja"); + assert_eq!(match_tag("pt-PT"), "pt-PT"); + assert_eq!(to_simplified("設定電腦與權限"), "设定电脑与权限"); + set("en"); + assert_eq!(t("一般"), "General"); + set("ja"); + assert_eq!(t("一般"), "一般"); + assert_eq!(t("權限"), "権限"); + set("de"); + assert_eq!(t("權限"), "Permissions"); + set("zh-TW"); + assert_eq!(t("權限"), "權限"); + assert!(reply_instruction("ko").contains("Korean")); + // Every row has an English translation. + for r in crate::i18n_table::ROWS { + assert!(!r.1.is_empty() && !r.2.is_empty(), "{}", r.0); + } + } +} diff --git a/src/i18n_table.rs b/src/i18n_table.rs new file mode 100644 index 0000000..a554317 --- /dev/null +++ b/src/i18n_table.rs @@ -0,0 +1,601 @@ +// English and 日本語 for the window's chrome: (繁體中文, English, 日本語). +// Keys are the Chinese source strings exactly as written in the code. +pub const ROWS: &[(&str, &str, &str)] = &[ + ("群組聊天(好幾個 Bot 一起回答)", "Group chat (several bots answer together)", "グループチャット(複数の Bot がまとめて回答)"), + ("從範本建立", "Create from template", "テンプレートから作成"), + ("搜尋 Bot", "Search bots", "Bot を検索"), + ("沒有相符的 Bot", "No matching bots", "一致する Bot はありません"), + ("設定", "Settings", "設定"), + ("設定(⌘,)", "Settings (⌘,)", "設定(⌘,)"), + ("編輯 Bot…", "Edit bot…", "Bot を編集…"), + ("重新啟動這個 Bot", "Restart this bot", "この Bot を再起動"), + ("卡住時用:停止它目前的工作和背景指令,聊天記錄和檔案都會保留", "Use when it's stuck: stops its current work and background commands; chats and files are kept", "固まったときに:現在の作業とバックグラウンドのコマンドを止めます。チャットとファイルは残ります"), + ("設為主要 Bot", "Make primary bot", "メインの Bot にする"), + ("開始新對話", "Start a new chat", "新しいチャットを開始"), + ("清除聊天記錄", "Clear chat history", "チャット履歴を消去"), + ("匯出 Bot(分享給別人)…", "Export bot (share with others)…", "Bot を書き出す(共有)…"), + ("匯入 Bot…", "Import bot…", "Bot を読み込む…"), + ("匯出文字記錄…", "Export transcript…", "会話記録を書き出す…"), + ("錄會議…", "Record a meeting…", "会議を録音…"), + ("用麥克風錄下會議,結束後轉成文字記錄交給這個 Bot 整理", "Record a meeting with the microphone; afterwards it becomes a transcript for this bot to summarize", "マイクで会議を録音し、終了後に文字起こしをこの Bot に渡してまとめさせます"), + ("刪除 Bot", "Delete bot", "Bot を削除"), + ("未選取 Bot", "No bot selected", "Bot が選択されていません"), + ("詳細資訊", "Details", "詳細"), + ("新增描述…", "Add a description…", "説明を追加…"), + ("更多", "More", "その他"), + ("結束通話", "End call", "通話を終了"), + ("通話:用說的跟 Bot 對話,你講完它就回答並唸出來", "Call: talk to the bot; when you finish speaking it answers out loud", "通話:話しかけると、話し終えたあと Bot が声で答えます"), + ("● 正在錄會議 · 結束", "● Recording meeting · Stop", "● 会議を録音中 · 終了"), + ("停止錄音,轉成文字記錄", "Stop recording and make a transcript", "録音を止めて文字起こし"), + ("● 需要你允許", "● Needs your approval", "● 承認が必要です"), + ("打開請求視窗", "Open the request", "リクエストを開く"), + ("工作中…", "Working…", "作業中…"), + ("還沒有回覆", "No reply yet", "まだ返信はありません"), + ("📝 較早的對話已整理成摘要", "📝 Earlier messages were summarized", "📝 以前の会話は要約されました"), + ("移除這個回應", "Remove this reaction", "このリアクションを外す"), + ("在下一則訊息引用這段", "Quote this in the next message", "次のメッセージで引用"), + ("拒絕", "Deny", "拒否"), + ("⚡ 全部允許,之後直接操作(像 Grok Bot 一樣)", "⚡ Allow everything and act directly from now on (like Grok Bot)", "⚡ すべて許可し、以後は直接操作(Grok Bot と同じ)"), + ("打開「直接操作」:Bot 可以直接使用這台電腦和瀏覽器,不再一個個問你。危險指令、密碼檔和你的「一律詢問」規則仍會擋下來。可在設定 → 電腦關掉。", "Turn on Direct mode: bots use this computer and the browser directly without asking each time. Dangerous commands, password files and your “always ask” rules are still stopped. You can turn it off in Settings → Permissions.", "「直接操作」をオン:Bot はこのコンピューターとブラウザを毎回確認せずに使います。危険なコマンド、パスワードファイル、「常に確認」ルールは引き続き止められます。設定 → 権限でオフにできます。"), + ("先關掉這個視窗;聊天裡的卡片還在,Bot 會一直等你", "Close this window for now; the card stays in the chat and the bot keeps waiting", "このウィンドウをいったん閉じます。カードはチャットに残り、Bot は待ち続けます"), + ("傳訊息給 {name}", "Message {name}", "{name} にメッセージ"), + ("附加檔案或圖片(也可以拖進來,或 ⌘V 貼上圖片)", "Attach files or pictures (you can also drag them in, or paste a picture with ⌘V)", "ファイルや画像を添付(ドラッグ、または ⌘V で画像を貼り付けも可)"), + ("停止錄音並轉成文字", "Stop recording and transcribe", "録音を止めて文字起こし"), + ("轉成文字…", "Transcribing…", "文字起こし中…"), + ("語音輸入(用你的 OpenAI 金鑰轉成文字)", "Voice input (transcribed with your OpenAI key)", "音声入力(あなたの OpenAI キーで文字起こし)"), + ("在 Bot 設定裡改成員", "Change members in the bot's settings", "メンバーは Bot の設定で変更"), + ("需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。", "AnyBot's own browser needs Chrome, Brave, Edge or Chromium.", "AnyBot 専用ブラウザには Chrome、Brave、Edge、Chromium のいずれかが必要です。"), + ("使用中", "In use", "使用中"), + ("你平常的 Chrome,切到哪個設定檔就讀哪個", "Your usual Chrome; it reads whichever profile you switch to", "いつもの Chrome。切り替えたプロファイルを読み取ります"), + ("✓ 輔助使用已打開", "✓ Accessibility is on", "✓ アクセシビリティはオンです"), + ("打開輔助使用", "Turn on Accessibility", "アクセシビリティをオンにする"), + ("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗", "macOS needs your permission once before AnyBot can see your browser windows", "AnyBot がブラウザのウィンドウを見るには、macOS で一度許可が必要です"), + ("開啟視窗", "Open window", "ウィンドウを開く"), + ("打開這個設定檔的瀏覽器視窗,在裡面登入網站", "Open this profile's browser window and sign in to sites there", "このプロファイルのブラウザウィンドウを開き、そこでサイトにログインします"), + ("確定移除(登入也會刪掉)", "Remove (its logins are deleted too)", "削除する(ログイン情報も消えます)"), + ("取消", "Cancel", "キャンセル"), + ("移除", "Remove", "削除"), + ("新設定檔名稱,例如 工作", "New profile name, e.g. Work", "新しいプロファイル名(例:仕事)"), + ("選「你的瀏覽器」:Bot 像 Grok Bot 一樣從畫面讀你平常的 Chrome(macOS 會請你打開一次「輔助使用」),你在 Chrome 切到哪個設定檔,它就看哪個;讀之前會先問你。其他的是 AnyBot 自己的視窗,各自登入一次就好。", "Choose “Your browser”: like Grok Bot, the bot reads your usual Chrome from the screen (macOS asks you to turn on Accessibility once) and sees whichever profile you switch to; it asks before reading. The others are AnyBot's own windows; sign in to each once.", "「あなたのブラウザ」を選ぶと、Grok Bot と同じように画面からいつもの Chrome を読み取ります(macOS でアクセシビリティを一度オン)。切り替えたプロファイルを見ます。読み取る前に確認します。それ以外は AnyBot 専用のウィンドウで、それぞれ一度ログインすれば OK です。"), + ("的例行任務", "'s routines", "のルーティン"), + ("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。", "Routines are tasks this bot repeats on a schedule. Ask it in the chat to set one up.", "ルーティンは、この Bot がスケジュールに従って繰り返す作業です。チャットで設定を頼んでください。"), + ("已暫停", "Paused", "一時停止中"), + ("刪除", "Delete", "削除"), + ("選擇這個 Bot 用來思考的模型", "Choose the model this bot thinks with", "この Bot が考えるときに使うモデルを選択"), + ("其他模型…", "Other model…", "その他のモデル…"), + ("Bot 名稱", "Bot name", "Bot の名前"), + ("它負責做什麼?", "What does it do?", "何を担当しますか?"), + ("模型名稱", "Model name", "モデル名"), + ("前往設定", "Go to Settings", "設定へ"), + ("讓這個 Bot 查看、修改檔案和執行指令", "Let this bot look at and change files and run commands", "この Bot にファイルの閲覧・変更とコマンド実行を許可"), + ("聊天時說「在我的 Mac 上」就會換", "Say “on my Mac” in the chat to switch", "チャットで「私の Mac で」と言うと切り替わります"), + ("在你的電腦上,例如 ~/Documents/專案", "On your computer, e.g. ~/Documents/Project", "あなたのコンピューター上、例:~/Documents/プロジェクト"), + ("用到 70% 時會把舊對話整理成摘要", "Older messages are summarized at 70%", "70% に達すると古い会話を要約します"), + ("已設定「一律允許」", "“Always allow” set", "「常に許可」を設定済み"), + ("移除這個允許", "Remove this permission", "この許可を取り消す"), + ("例如:用繁體中文回答,簡短一點。", "e.g. Answer in English, keep it short.", "例:日本語で、短めに答えて。"), + ("忘記", "Forget", "忘れる"), + ("釘選到側邊欄", "Pin to sidebar", "サイドバーにピン留め"), + ("尚未登入帳號 · 手機、帳號、團隊、備份", "Not signed in · phone, account, team, backup", "未ログイン · スマホ、アカウント、チーム、バックアップ"), + ("自動偵測", "Detect automatically", "自動検出"), + ("看不懂這個時區,請用像 Asia/Taipei 這樣的名稱", "Unknown time zone; use a name like Asia/Taipei", "タイムゾーンを認識できません。Asia/Tokyo のような名前を使ってください"), + ("有更新的版本:", "A newer version is available: ", "新しいバージョンがあります:"), + ("已是最新版本", "Up to date", "最新です"), + ("允許在「你的電腦」上執行", "Allow running on “Your computer”", "「あなたのコンピューター」での実行を許可"), + ("🔒 由你的管理員設定,無法變更", "🔒 Set by your administrator; can't be changed", "🔒 管理者が設定しているため変更できません"), + ("名稱,例如 工作站", "Name, e.g. Workstation", "名前(例:ワークステーション)"), + ("一般", "General", "一般"), + ("打開直接操作", "Turn on Direct mode", "直接操作をオン"), + ("開啟自動審核", "Turn on auto-review", "自動レビューをオン"), + ("查看來源 ↗", "View source ↗", "出典を見る ↗"), + ("關閉(Esc)", "Close (Esc)", "閉じる(Esc)"), + ("AnyBot 瀏覽器設定檔", "AnyBot browser profiles", "AnyBot ブラウザのプロファイル"), + ("頭像", "Avatar", "アバター"), + ("AI 供應商與模型", "AI provider and model", "AI プロバイダーとモデル"), + ("電腦操作", "Computer use", "コンピューター操作"), + ("指示", "Instructions", "指示"), + ("群組成員", "Group members", "グループのメンバー"), + ("記得的事", "Memories", "覚えていること"), + ("在這台電腦上執行", "Run on this computer", "このコンピューターで実行"), + ("其他電腦", "Other computers", "ほかのコンピューター"), + ("⚡ 直接操作(像 Grok Bot 一樣)", "⚡ Direct mode (like Grok Bot)", "⚡ 直接操作(Grok Bot と同じ)"), + ("自動審核", "Auto-review", "自動レビュー"), + ("帳戶", "Account", "アカウント"), + ("主題", "Theme", "テーマ"), + ("強調色", "Accent color", "アクセントカラー"), + ("字型", "Font", "フォント"), + ("語言", "Language", "言語"), + ("通知", "Notifications", "通知"), + ("時區", "Time zone", "タイムゾーン"), + ("資料", "Data", "データ"), + ("版本", "Version", "バージョン"), + ("自動更新", "Automatic updates", "自動アップデート"), + ("重新啟動", "Restart", "再起動"), + ("外觀", "Appearance", "外観"), + ("系統", "System", "システム"), + ("AnyBot 更新", "AnyBot updates", "AnyBot のアップデート"), + ("保險庫", "Vault", "保管庫"), + ("訊息管道", "Messaging channels", "メッセージチャネル"), + ("連接器", "Connectors", "コネクタ"), + ("例如:「每個工作日早上 9 點整理新聞給我」", "e.g. “Every weekday at 9 am, send me a news summary”", "例:「平日の毎朝 9 時にニュースをまとめて」"), + ("要新增或修改,直接在聊天裡告訴 Bot。", "To add or change one, just tell the bot in the chat.", "追加や変更は、チャットで Bot に伝えるだけです。"), + ("在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", "On the bot's computer it can work freely (it can't see your files); on your computer, changing files and commands that change things ask you first. Password and key files are never touched.", "Bot のコンピューターでは自由に作業できます(あなたのファイルは見えません)。あなたのコンピューターでは、ファイルの変更や変更を伴うコマンドは先に確認します。パスワードや鍵のファイルには一切触れません。"), + ("還沒有。跟 Bot 說「記住我喜歡靠窗的座位」試試看。", "Nothing yet. Try telling the bot “remember I like window seats”.", "まだありません。Bot に「窓側の席が好きだと覚えて」と言ってみてください。"), + ("選單會用英文顯示;Bot 會用你選的語言回答。", "Menus are shown in English; bots answer in the language you chose.", "メニューは英語で表示されます。Bot は選んだ言語で答えます。"), + ("視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。登入帳號後也會推播到手機。", "You're notified when the window isn't in front, or after 90 s without keyboard or mouse. Once signed in, your phone is notified too.", "ウィンドウが前面にないとき、または 90 秒間操作がないときに通知します。ログインするとスマホにも通知されます。"), + ("螢幕鎖定或螢幕保護程式開著、10 分鐘沒動作、而且沒有 Bot 在工作時,才會自己換成新版本。", "It switches to the new version only when the screen is locked or the screensaver is on, after 10 minutes without activity, and while no bot is working.", "画面ロックまたはスクリーンセーバー中で、10 分間操作がなく、作業中の Bot がないときだけ新しいバージョンに切り替えます。"), + ("Bot 可以用這裡的登入資訊替你登入網站,但永遠看不到密碼。", "Bots can use these logins to sign in to sites for you, but never see the passwords.", "Bot はここのログイン情報でサイトにログインできますが、パスワードは決して見えません。"), + ("打開後,改檔案和會改變東西的指令仍會先問你(或依自動審核規則)。密碼和金鑰資料夾一律碰不到。", "When on, changing files and commands that change things still ask you first (or follow auto-review rules). Password and key folders are never reachable.", "オンにしても、ファイルの変更や変更を伴うコマンドは先に確認します(または自動レビューのルールに従います)。パスワードや鍵のフォルダーには決して触れられません。"), + ("仍會擋下:危險指令(sudo、格式化…)、密碼和金鑰檔、AnyBot 自己的資料,以及你在「自動審核」寫的「一律詢問」規則(預設:刪除檔案、付款、用你的帳號發文或傳訊息)。macOS 第一次讓 AnyBot 控制瀏覽器時還是會自己問一次,按「好」就行。", "Still stopped: dangerous commands (sudo, formatting…), password and key files, AnyBot's own data, and your “always ask” rules in Auto-review (by default: deleting files, paying, posting or messaging as you). The first time AnyBot controls your browser, macOS still asks once; just click OK.", "引き続き止められるもの:危険なコマンド(sudo、フォーマット…)、パスワードや鍵のファイル、AnyBot 自身のデータ、そして自動レビューの「常に確認」ルール(既定:ファイルの削除、支払い、あなたのアカウントでの投稿やメッセージ送信)。AnyBot が初めてブラウザを操作するとき macOS が一度確認するので、「OK」を押してください。"), + ("為每種操作寫一條簡短的自然語言規則。規則衝突時,「先詢問」優先。這些規則只對你生效。內建的安全檢查一律有效。", "Write a short plain-language rule for each kind of action. When rules conflict, “ask first” wins. These rules apply only to you. Built-in safety checks always apply.", "操作の種類ごとに短いルールを自然な言葉で書きます。ルールが衝突したら「先に確認」が優先です。これらのルールはあなたにだけ適用されます。組み込みの安全チェックは常に有効です。"), + ("自動審核關閉時不會使用規則", "Rules aren't used while auto-review is off", "自動レビューがオフのときはルールを使いません"), + ("預設電腦", "Default computer", "既定のコンピューター"), + ("起始資料夾", "Starting folder", "開始フォルダー"), + ("模型可讀長度", "Context length", "読み取れる長さ"), + ("Bot 描述", "Bot description", "Bot の説明"), + ("API 金鑰", "API key", "API キー"), + ("服務網址", "Service URL", "サービス URL"), + ("新 Bot 預設模型", "Default model for new bots", "新しい Bot の既定モデル"), + ("立即執行", "Run now", "今すぐ実行"), + ("管理", "Manage", "管理"), + ("用這台電腦的", "Use this computer's", "このコンピューターのものを使う"), + ("在 Finder 中顯示", "Show in Finder", "Finder で表示"), + ("新增", "Add", "追加"), + ("新增規則", "Add rule", "ルールを追加"), + ("測試連線並取得模型清單", "Test connection and fetch models", "接続をテストしてモデル一覧を取得"), + ("重新傳送", "Send again", "再送信"), + ("複製", "Copy", "コピー"), + ("引用", "Quote", "引用"), + ("重新產生", "Regenerate", "再生成"), + ("前往聊天", "Go to chat", "チャットへ"), + ("稍後再決定", "Decide later", "あとで決める"), + ("知道了", "Got it", "わかりました"), + ("收合側邊欄", "Collapse sidebar", "サイドバーを閉じる"), + ("新 Bot(按右鍵從範本建立)", "New bot (right-click to start from a template)", "新しい Bot(右クリックでテンプレートから作成)"), + ("展開側邊欄", "Expand sidebar", "サイドバーを開く"), + ("新 Bot", "New bot", "新しい Bot"), + ("刪除 Bot?", "Delete bot?", "Bot を削除しますか?"), + ("每個設定檔是一個獨立的瀏覽器視窗,有自己的登入(例如工作和私人帳號)。Bot 會用「使用中」的那個,你也可以在聊天裡叫它換。", "Each profile is a separate browser window with its own logins (for example work and personal accounts). Bots use the one “in use”; you can also tell it to switch in the chat.", "プロファイルはそれぞれ独立したブラウザウィンドウで、ログインも別々です(仕事用と個人用など)。Bot は「使用中」のものを使います。チャットで切り替えを頼むこともできます。"), + ("Bot 應該記得的事、說話風格…", "What the bot should keep in mind, how it talks…", "Bot が覚えておくこと、話し方…"), + ("你傳訊息後,勾選的 Bot 依序回答(每個用自己的模型和設定),後面的會看到前面的回答。這個群組自己的模型不會用到。", "After you send a message, the checked bots answer in turn (each with its own model and settings); later ones see the earlier answers. This group's own model isn't used.", "メッセージを送ると、チェックした Bot が順番に答えます(それぞれ自分のモデルと設定で)。後の Bot は前の回答を見られます。このグループ自体のモデルは使われません。"), + ("你在聊天裡請 Bot 記住的事(「記住…」),每次聊天都會帶著。", "Things you asked the bot to remember in the chat (“remember…”); they come along in every chat.", "チャットで Bot に覚えてもらったこと(「覚えて…」)。毎回の会話で使われます。"), + ("為 AnyBot 選擇淺色或深色外觀", "Choose a light or dark look for AnyBot", "AnyBot の外観をライトかダークから選択"), + ("用 Bot 的顏色為 1:1 Bot 訊息和卡片上色,或保持黑色", "Color 1:1 bot messages and cards with the bot's color, or keep them black", "1:1 の Bot メッセージとカードを Bot の色にするか、黒のままにします"), + ("AnyBot 畫面使用的字型", "The font AnyBot's screens use", "AnyBot の画面で使うフォント"), + ("選擇 AnyBot 選單和 Bot 回答使用的語言", "Choose the language for AnyBot's menus and the bots' answers", "AnyBot のメニューと Bot の回答に使う言語を選択"), + ("Bot 完成任務或等你處理時通知你", "Notify you when a bot finishes a task or needs you", "Bot が作業を終えたとき、または対応が必要なときに通知"), + ("設定 Bot 用於排程和提醒的時區", "Set the time zone bots use for schedules and reminders", "Bot がスケジュールやリマインダーに使うタイムゾーンを設定"), + ("聊天記錄和金鑰只存在這台電腦上", "Chats and keys are stored only on this computer", "チャット履歴とキーはこのコンピューターにだけ保存されます"), + ("在你離開時自動換成新版本,你的 Bot 和工作都會保留", "Switch to new versions while you're away; your bots and work are kept", "離席中に新しいバージョンへ切り替えます。Bot と作業は保持されます"), + ("App 不太對勁時用:進行中的工作會停下,聊天記錄都會保留", "Use when the app misbehaves: running work stops; chats are kept", "アプリの調子が悪いときに:進行中の作業は止まり、チャットは残ります"), + ("允許 Bot 在這台電腦上執行任務,而不只是在它自己的電腦上", "Allow bots to run tasks on this computer, not only on their own", "Bot が自分のコンピューターだけでなく、このコンピューターでも作業できるようにします"), + ("透過 SSH 讓 Bot 在你的其他電腦上做事(需要先設好免密碼登入)", "Let bots work on your other computers over SSH (passwordless login must be set up first)", "SSH でほかのコンピューターでも作業させます(パスワードなしのログイン設定が必要)"), + ("一個開關:讓 Bot 直接使用這台電腦和你的瀏覽器,不再一個個問你", "One switch: bots use this computer and your browser directly, without asking each time", "ひとつのスイッチで、Bot がこのコンピューターとブラウザを毎回確認せずに直接使います"), + ("依照自訂規則審核 Bot 的操作", "Review bots' actions with your own rules", "独自のルールで Bot の操作をレビュー"), + ("模型", "Models", "モデル"), + ("整合", "Integrations", "連携"), + ("電腦", "Computer", "コンピューター"), + ("權限", "Permissions", "権限"), + ("更新", "Updates", "アップデート"), + ("跟隨系統", "System", "システムに合わせる"), + ("深色", "Dark", "ダーク"), + ("淺色", "Light", "ライト"), + ("黑色", "Black", "黒"), + ("Bot 顏色", "Bot color", "Bot の色"), + ("系統字型", "System font", "システムフォント"), + ("粉圓體", "Huninn (rounded)", "粉円体(丸ゴシック)"), + ("主要 Bot", "Primary bot", "メインの Bot"), + ("已釘選的 Bot", "Pinned bots", "ピン留めした Bot"), + ("Bot", "Bots", "Bot"), + ("開啟", "Open", "開く"), + ("例如 客服群", "e.g. Support group", "例:サポートグループ"), + ("Entra 應用程式(用戶端)ID", "Entra application (client) ID", "Entra アプリケーション(クライアント)ID"), + ("留空 = 公司或學校帳號", "Leave empty = work or school account", "空欄 = 職場または学校のアカウント"), + ("貼上 Bot Token", "Paste the bot token", "Bot トークンを貼り付け"), + ("例如:查詢客戶資料時先用 search_customers", "e.g. Use search_customers first when looking up customers", "例:顧客情報を調べるときはまず search_customers を使う"), + ("已加入", "Added", "追加済み"), + ("例如 github", "e.g. github", "例:github"), + ("npx -y @modelcontextprotocol/server-github 或 https://…/mcp", "npx -y @modelcontextprotocol/server-github or https://…/mcp", "npx -y @modelcontextprotocol/server-github または https://…/mcp"), + ("GITHUB_TOKEN=…(多個用空白分開,本機伺服器用)", "GITHUB_TOKEN=… (space-separated, for local servers)", "GITHUB_TOKEN=…(複数はスペース区切り、ローカルサーバー用)"), + ("Authorization: Bearer …(遠端伺服器用)", "Authorization: Bearer … (for remote servers)", "Authorization: Bearer …(リモートサーバー用)"), + ("新增管道", "Add a channel", "チャネルを追加"), + ("連接器(MCP)", "Connectors (MCP)", "コネクタ(MCP)"), + ("可以用的工具", "Available tools", "使えるツール"), + ("技能", "Skills", "スキル"), + ("連接器市集", "Connector marketplace", "コネクタマーケット"), + ("新增連接器", "Add a connector", "コネクタを追加"), + ("Microsoft Teams 用你自己的 Microsoft 帳號登入(裝置碼),不需要公開網址;Bot 會以你的身分在聊天或頻道裡回覆。", "Microsoft Teams signs in with your own Microsoft account (device code) and needs no public address; the bot replies in chats or channels as you.", "Microsoft Teams はあなたの Microsoft アカウントでサインインし(デバイスコード)、公開アドレスは不要です。Bot はあなたとしてチャットやチャネルで返信します。"), + ("本機伺服器填指令(例如 npx -y @modelcontextprotocol/server-filesystem ~/Documents);遠端伺服器填網址(Streamable HTTP),需要時加上 Authorization 標頭。", "For a local server enter the command (e.g. npx -y @modelcontextprotocol/server-filesystem ~/Documents); for a remote server enter its address (Streamable HTTP), adding an Authorization header when needed.", "ローカルサーバーはコマンド(例:npx -y @modelcontextprotocol/server-filesystem ~/Documents)を、リモートサーバーはアドレス(Streamable HTTP)を入力し、必要なら Authorization ヘッダーを付けます。"), + ("還沒有技能。", "No skills yet.", "スキルはまだありません。"), + ("名稱", "Name", "名前"), + ("回覆的 Bot", "Answering bot", "返信する Bot"), + ("應用程式 ID", "Application ID", "アプリケーション ID"), + ("租用戶", "Tenant", "テナント"), + ("指令或網址", "Command or address", "コマンドまたはアドレス"), + ("環境變數", "Environment variables", "環境変数"), + ("標頭", "Headers", "ヘッダー"), + ("給 Bot 的使用說明(選填)", "Notes for the bot (optional)", "Bot への使い方メモ(任意)"), + ("加入", "Add", "追加"), + ("讓 Bot 在 Slack、Discord、Telegram、Microsoft Teams、Mac 的「訊息」或「郵件」回覆別人。收到訊息時 Bot 會像收到你的訊息一樣開始工作,做完後把回答傳回去(Slack 回在討論串,Discord 和 Telegram 引用原訊息)。AnyBot 開著時才會收訊息。", "Let bots answer people on Slack, Discord, Telegram, Microsoft Teams, and the Mac's Messages or Mail. A message starts the bot working as if you sent it, and the answer goes back when done (in a Slack thread; Discord and Telegram quote the message). Messages arrive only while AnyBot is open.", "Slack、Discord、Telegram、Microsoft Teams、Mac の「メッセージ」や「メール」で Bot に返信させます。メッセージを受け取ると、あなたからのメッセージと同じように作業を始め、終わったら回答を返します(Slack はスレッド、Discord と Telegram は元メッセージを引用)。AnyBot が開いている間だけ受信します。"), + ("接上 Model Context Protocol 伺服器,Bot 就能用它的工具(例如 GitHub、Notion、資料庫、你公司的內部系統)。每個連接器第一次被使用時會先問你。", "Connect Model Context Protocol servers so bots can use their tools (e.g. GitHub, Notion, databases, your company's systems). Each connector asks you the first time it's used.", "Model Context Protocol サーバーを接続すると、Bot がそのツールを使えます(GitHub、Notion、データベース、社内システムなど)。各コネクタは初めて使うときに確認します。"), + ("所有 Bot 共用的做法說明。在聊天裡教 Bot 一套做法並請它「存成技能」;Bot 需要時會先讀取再照做。", "How-tos shared by every bot. Teach a bot a procedure in the chat and ask it to “save as a skill”; bots read it before following it.", "すべての Bot で共有する手順書です。チャットで手順を教えて「スキルとして保存」と頼むと、Bot は必要なときに読んでから実行します。"), + ("常用的連接器,按「加入」就能用。需要金鑰的會填好下面的表單,補上金鑰再按「新增」。本機的需要先裝 Node.js(npx)或 uv(uvx)。", "Popular connectors; click “Add” to use one. Ones that need a key fill in the form below; add the key and click “Add”. Local ones need Node.js (npx) or uv (uvx).", "よく使うコネクタです。「追加」を押すだけで使えます。キーが必要なものは下のフォームに入力されるので、キーを足して「追加」を押してください。ローカルのものには Node.js(npx)か uv(uvx)が必要です。"), + ("這台電腦在手機上叫", "This computer's name on the phone", "スマホでのこのコンピューターの名前"), + ("每天自動備份到帳號(加密,金鑰由你的密碼產生,伺服器打不開)", "Back up to the account every day (encrypted with a key from your password; the server can't open it)", "毎日アカウントにバックアップ(パスワードから作る鍵で暗号化。サーバーは開けません)"), + ("登出帳號", "Sign out", "ログアウト"), + ("https://anybot.你的網域", "https://anybot.your-domain", "https://anybot.あなたのドメイン"), + ("建立新帳號", "Create a new account", "新しいアカウントを作成"), + ("伺服器設定了才需要", "Only if the server requires one", "サーバーで設定されている場合のみ"), + ("把這台電腦的 Bot 換成帳號裡最新的備份", "Replace this computer's bots with the account's newest backup", "このコンピューターの Bot をアカウントの最新バックアップに置き換えます"), + ("讓手機直接連這台電腦", "Let phones connect to this computer directly", "スマホからこのコンピューターに直接接続"), + ("至少 8 個字", "At least 8 characters", "8 文字以上"), + ("從手機看這台電腦的螢幕並操作(點、打字、按鍵)", "View and operate this computer's screen from the phone (click, type, keys)", "スマホからこのコンピューターの画面を見て操作(クリック、入力、キー)"), + ("裝置連結:讓帳號裡其他的 AnyBot(例如放在伺服器上的)操作這台電腦", "Device link: let the account's other AnyBots (e.g. one on a server) operate this computer", "デバイス連携:アカウント内のほかの AnyBot(サーバー上のものなど)にこのコンピューターを操作させる"), + ("登出", "Sign out", "ログアウト"), + ("對方的帳號", "Their account", "相手のアカウント"), + ("管理員", "Admin", "管理者"), + ("重新整理", "Refresh", "更新"), + ("手機與帳號", "Phone & account", "スマホとアカウント"), + ("AnyBot 帳號", "AnyBot account", "AnyBot アカウント"), + ("同一個網路直接連", "Connect directly on the same network", "同じネットワークで直接接続"), + ("手機可以做什麼", "What the phone can do", "スマホでできること"), + ("直接連線的裝置", "Directly connected devices", "直接接続したデバイス"), + ("分享給團隊", "Share with your team", "チームと共有"), + ("帳號裡的電腦", "Computers in the account", "アカウント内のコンピューター"), + ("兩種連法:同一個網路(或 Tailscale)直接連這台電腦;或登入帳號,透過你的帳號伺服器從任何地方連,這台電腦不用開放任何連接埠。", "Two ways in: connect directly on the same network (or Tailscale), or sign in to your account and connect from anywhere through your account server; this computer opens no ports.", "接続方法は 2 つ:同じネットワーク(または Tailscale)で直接接続するか、アカウントにログインしてアカウントサーバー経由でどこからでも接続します。このコンピューターはポートを開きません。"), + ("密碼至少 8 個字。帳號伺服器在 relay/ 資料夾,可以放在任何一台小主機(說明見 relay/README.md)。", "Passwords need at least 8 characters. The account server is in the relay/ folder and can run on any small host (see relay/README.md).", "パスワードは 8 文字以上です。アカウントサーバーは relay/ フォルダーにあり、小さなサーバーならどこでも動きます(relay/README.md を参照)。"), + ("兩次輸入的密碼不一樣", "The two passwords don't match", "2 つのパスワードが一致しません"), + ("不要把這個連接埠開放到網際網路上;從外面連請用上面的帳號,或 Tailscale。", "Don't expose this port to the internet; to connect from outside, use the account above or Tailscale.", "このポートをインターネットに公開しないでください。外から接続するには上のアカウントか Tailscale を使います。"), + ("裝置連結開著時,伺服器上的 Bot 可以把「你的電腦」的螢幕、檔案、指令和 Mac App 工具交給這台電腦執行。每個動作仍照那邊的權限規則詢問你(手機上也能核准)。", "With device link on, bots on the server can hand “Your computer” screen, file, command and Mac app tools to this computer. Each action still asks you under that side's rules (you can approve on the phone too).", "デバイス連携がオンのとき、サーバー上の Bot は「あなたのコンピューター」の画面・ファイル・コマンド・Mac アプリのツールをこのコンピューターに任せられます。各操作はそちらの権限ルールに従って確認されます(スマホでも承認できます)。"), + ("還沒有", "None yet", "まだありません"), + ("成員:可以和 Bot 聊天、附加檔案;Bot 會知道是誰在說話。管理員:另外可以核准 Bot 要做的事、停止、看和操作電腦畫面。需要你允許的事只通知你和管理員。", "Members: chat with the bots and attach files; the bot knows who's talking. Admins: can also approve what bots do, stop them, and view and operate computer screens. Approval requests go only to you and admins.", "メンバー:Bot とチャットし、ファイルを添付できます。Bot は誰が話しているか分かります。管理者:さらに Bot の操作の承認、停止、画面の閲覧と操作ができます。承認のリクエストはあなたと管理者にだけ届きます。"), + ("帳號伺服器", "Account server", "アカウントサーバー"), + ("帳號", "Account", "アカウント"), + ("密碼", "Password", "パスワード"), + ("邀請碼", "Invite code", "招待コード"), + ("再輸入一次", "Enter it again", "もう一度入力"), + ("從帳號備份還原", "Restore from account backup", "アカウントのバックアップから復元"), + ("儲存密碼", "Save password", "パスワードを保存"), + ("分享", "Share", "共有"), + ("在手機上和 Bot 聊天、核准它要做的事、看著它的電腦。", "Chat with bots on your phone, approve what they want to do, and watch their computer.", "スマホで Bot とチャットし、やろうとしていることを承認し、Bot のコンピューターを見られます。"), + ("帳號伺服器保管帳號、裝置清單和加密備份,手機從外面連進來也靠它轉送。", "The account server keeps the account, the device list and encrypted backups, and relays the phone's connection from outside.", "アカウントサーバーはアカウント、デバイス一覧、暗号化バックアップを保管し、外からのスマホの接続を中継します。"), + ("手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", "Use when the phone and this computer are on the same Wi-Fi, or both have Tailscale.", "スマホとこのコンピューターが同じ Wi-Fi にあるか、両方に Tailscale があるときに使います。"), + ("聊天、核准、看 Bot 的電腦一律可以。下面這些會碰到這台電腦本身,預設關閉。", "Chatting, approving and watching the bot's computer are always allowed. The ones below touch this computer itself and are off by default.", "チャット、承認、Bot のコンピューターの閲覧はいつでもできます。以下はこのコンピューター自体に関わるため、既定でオフです。"), + ("用手機密碼登入過的手機和瀏覽器", "Phones and browsers that signed in with the phone password", "スマホ用パスワードでログインしたスマホとブラウザ"), + ("讓帳號伺服器上的其他帳號也能用這台 AnyBot 的 Bot。", "Let other accounts on the account server use this AnyBot's bots too.", "アカウントサーバーのほかのアカウントも、この AnyBot の Bot を使えるようにします。"), + ("登入同一個帳號的 AnyBot。開了裝置連結的電腦,Bot 可以操作它。", "AnyBots signed in to the same account. Bots can operate the ones with device link on.", "同じアカウントにログインしている AnyBot。デバイス連携がオンのものは Bot が操作できます。"), + ("打開", "Open", "開く"), + ("關閉右側面板", "Close the side panel", "右側のパネルを閉じる"), + ("已把對話複製到剪貼簿", "Copied the chat to the clipboard", "会話をクリップボードにコピーしました"), + ("預設", "Default", "既定"), + ("Bot 的電腦", "Bot's computer", "Bot のコンピューター"), + ("可以用的電腦", "Computers it can use", "使えるコンピューター"), + ("會議記錄", "Meeting notes", "会議の記録"), + ("在這個對話裡附加的照片和檔案會列在這裡。", "Photos and files attached in this chat are listed here.", "このチャットで添付した写真やファイルがここに表示されます。"), + ("這台 Mac 沒有 Docker:裝 OrbStack 或 Docker Desktop,Bot 就有自己的電腦。", "This Mac has no Docker: install OrbStack or Docker Desktop and bots get their own computer.", "この Mac には Docker がありません。OrbStack か Docker Desktop を入れると Bot 専用のコンピューターができます。"), + ("用麥克風錄下會議,結束後轉成文字記錄,交給 Bot 整理重點、決定和待辦。", "Record a meeting with the microphone; afterwards the transcript goes to the bot to list key points, decisions and to-dos.", "マイクで会議を録音し、終了後に文字起こしを Bot に渡して、要点・決定事項・ToDo をまとめさせます。"), + ("還沒有會議記錄。", "No meeting notes yet.", "会議の記録はまだありません。"), + ("群組裡的 Bot 會依序回答。", "Bots in the group answer in turn.", "グループの Bot は順番に答えます。"), + ("編輯 Bot", "Edit bot", "Bot を編集"), + ("匯出文字記錄", "Export transcript", "会話記録を書き出す"), + ("看畫面", "View screen", "画面を見る"), + ("叫醒", "Wake up", "起こす"), + ("電腦設定", "Computer settings", "コンピューターの設定"), + ("編輯成員", "Edit members", "メンバーを編集"), + ("例行任務", "Routines", "ルーティン"), + ("媒體", "Media", "メディア"), + ("會議", "Meetings", "会議"), + ("成員", "Members", "メンバー"), + ("已允許", "Allowed", "許可済み"), + ("看 Bot 的電腦的螢幕,也可以取得控制權自己操作", "Watch the bot's computer screen; you can also take control", "Bot のコンピューターの画面を見ます。操作を代わることもできます"), + ("用 noVNC 全螢幕觀看", "Watch full screen with noVNC", "noVNC で全画面表示"), + ("把 Bot 的檔案備份到這台 Mac;休眠前也會自動存一份", "Back up the bot's files to this Mac; a copy is also saved before it sleeps", "Bot のファイルをこの Mac にバックアップします。スリープ前にも自動で保存します"), + ("有新版的 Bot 的電腦", "A newer bot computer is available", "新しい Bot のコンピューターがあります"), + ("有新版本時,自動在每天", "When there's a new version, automatically every day at", "新しいバージョンがあれば、毎日自動で"), + ("請用 時:分,例如 03:00", "Use hour:minute, e.g. 03:00", "時:分 で入力(例:03:00)"), + ("磁碟", "Disk", "ディスク"), + ("交給主要 Bot:檢查磁碟用量並提出安全的清理方案,等你核准", "Ask the primary bot: check disk usage and propose a safe cleanup, waiting for your approval", "メインの Bot に依頼:ディスク使用量を調べ、安全な整理案を出して承認を待ちます"), + ("取代", "Replace", "置き換え"), + ("名稱,例如 CRM_API_TOKEN", "Name, e.g. CRM_API_TOKEN", "名前(例:CRM_API_TOKEN)"), + ("值", "Value", "値"), + ("這台 Mac(預設)", "This Mac (default)", "この Mac(既定)"), + ("網路流量改走這台 Mac", "Route network traffic through this Mac", "ネットワーク通信をこの Mac 経由にする"), + ("(沒有紀錄)", "(Nothing logged)", "(記録なし)"), + ("Bot 可以操作的 App", "Apps bots may operate", "Bot が操作できるアプリ"), + ("有 Bot 正在用這台電腦,等它做完再重建。", "A bot is using this computer; rebuild after it finishes.", "Bot がこのコンピューターを使用中です。終わってから再構築してください。"), + ("● Bot 正在操作", "● A bot is operating", "● Bot が操作中"), + ("● 你正在操作,Bot 已暫停", "● You're in control; the bot is paused", "● あなたが操作中、Bot は一時停止中"), + ("關閉", "Close", "閉じる"), + ("在瀏覽器用 noVNC 全螢幕開啟", "Open full screen in the browser with noVNC", "ブラウザの noVNC で全画面で開く"), + ("停止", "Stop", "停止"), + ("網站,例如 github.com", "Site, e.g. github.com", "サイト(例:github.com)"), + ("這個任務叫什麼,例如 匯出本月報表", "What is this task called, e.g. Export this month's report", "この作業の名前(例:今月のレポートを書き出す)"), + ("播放錄影", "Play recording", "録画を再生"), + ("步驟", "Steps", "ステップ"), + ("畫面", "Screen", "画面"), + ("打開這一步當時的畫面", "Open the screen from this step", "このステップの画面を開く"), + ("完成", "Done", "完了"), + ("快照與更新", "Snapshots and updates", "スナップショットとアップデート"), + ("終端機與環境密鑰", "Terminal and environment secrets", "ターミナルと環境シークレット"), + ("在哪裡執行", "Where it runs", "実行場所"), + ("macOS 權限", "macOS permissions", "macOS の権限"), + ("登入資料", "Logins", "ログイン情報"), + ("示範教學", "Demonstrations", "操作のお手本"), + ("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。Bot 在上面執行指令、安裝套件、開瀏覽器,不會碰到你的檔案。閒置一陣子會自動休眠,需要時自動喚醒。", "A Linux computer shared by all your bots, each with its own folder. Bots run commands, install packages and open a browser there without touching your files. It sleeps when idle and wakes up when needed.", "すべての Bot が共有する Linux コンピューターで、Bot ごとにフォルダーがあります。コマンド実行、パッケージのインストール、ブラウザの起動をここで行い、あなたのファイルには触れません。しばらく使わないとスリープし、必要なときに起動します。"), + ("在那之前,Bot 的電腦是一個封閉的資料夾(沒有桌面)。", "Until then, the bot's computer is a closed folder (no desktop).", "それまでは、Bot のコンピューターは閉じたフォルダーです(デスクトップなし)。"), + ("錯過時間的話,一小時內還會補做;超過就等隔天。開始後就不能取消;有 Bot 在用電腦時會等它做完。", "If the time is missed, it still runs within an hour; after that it waits for the next day. Once started it can't be cancelled; if a bot is using the computer it waits for it to finish.", "時刻を過ぎても 1 時間以内なら実行し、それ以降は翌日まで待ちます。開始後は取り消せません。Bot が使用中なら終わるまで待ちます。"), + ("換位置後,Bot 的電腦會在新的地方重新建立;舊的檔案可以用「儲存快照」再「從快照復原」搬過去。", "After moving it, the bot's computer is rebuilt in the new place; move old files with “Save snapshot” then “Restore from snapshot”.", "場所を変えると、Bot のコンピューターは新しい場所で作り直されます。古いファイルは「スナップショットを保存」してから「スナップショットから復元」で移せます。"), + ("按下開始後照平常的方式操作;做完按畫面上方的「完成」。會記錄點擊、打字和捲動,並且每 0.5 秒拍一張螢幕畫面(需要「螢幕與系統錄音」權限),完成後合成錄影;每一步會留一張當時的畫面給 Bot 看。不會擋住或改變你的操作。", "After you press Start, work as usual; press “Done” at the top of the screen when finished. Clicks, typing and scrolling are recorded, with a screen picture every 0.5 s (needs the “Screen & System Audio Recording” permission), combined into a video at the end; each step keeps a picture of the moment for the bot. It never blocks or changes what you do.", "開始を押したら普段どおり操作し、終わったら画面上部の「完了」を押します。クリック、入力、スクロールを記録し、0.5 秒ごとに画面を撮影して(「画面とシステムオーディオの収録」の権限が必要)、最後に動画にまとめます。各ステップにはその時点の画面が Bot 用に残ります。あなたの操作を妨げたり変えたりしません。"), + ("下載 OrbStack", "Download OrbStack", "OrbStack をダウンロード"), + ("開啟畫面", "Open screen", "画面を開く"), + ("現在更新", "Update now", "今すぐアップデート"), + ("套用", "Apply", "適用"), + ("喚醒", "Wake", "起動"), + ("存", "Save", "保存"), + ("開始錄製", "Start recording", "録画を開始"), + ("打開設定", "Open Settings", "設定を開く"), + ("重新檢查", "Check again", "再確認"), + ("休眠", "Sleep", "スリープ"), + ("啟動", "Start", "開始"), + ("在瀏覽器開啟", "Open in browser", "ブラウザで開く"), + ("儲存快照", "Save snapshot", "スナップショットを保存"), + ("請 Bot 提出清理方案", "Ask the bot for a cleanup plan", "Bot に整理案を頼む"), + ("檢查用量", "Check usage", "使用量を確認"), + ("全螢幕", "Full screen", "全画面"), + ("給指令使用的帳密,例如 CRM_API_TOKEN。Bot 執行指令時會變成環境變數;值只能寫入,不能讀出。", "Credentials for commands, e.g. CRM_API_TOKEN. They become environment variables when bots run commands; values can be written but never read back.", "コマンド用の認証情報(例:CRM_API_TOKEN)。Bot がコマンドを実行するとき環境変数になります。値は書き込めますが読み出せません。"), + ("預設在這台 Mac 的 Docker 裡。也可以放到雲端或公司伺服器:填 Docker 主機,例如 ssh://me@server(Tailscale 的機器名稱也可以)。", "By default it's in this Mac's Docker. It can also live in the cloud or on a company server: enter a Docker host such as ssh://me@server (a Tailscale machine name works too).", "既定ではこの Mac の Docker 上にあります。クラウドや社内サーバーにも置けます:ssh://me@server のような Docker ホストを入力します(Tailscale のマシン名も可)。"), + ("Bot 要看你的螢幕、動滑鼠和鍵盤時需要。App 會打開對應的系統設定面板,把 AnyBot(或「終端機」)打開即可。", "Needed for bots to see your screen and move the mouse and keyboard. The app opens the matching System Settings pane; turn on AnyBot (or Terminal) there.", "Bot が画面を見たり、マウスやキーボードを動かしたりするのに必要です。対応するシステム設定が開くので、AnyBot(または「ターミナル」)をオンにしてください。"), + ("Bot 要在 AnyBot 瀏覽器登入網站時,AnyBot 幫它把帳號密碼填進去,Bot 看不到密碼;每次都會先問你。存在 macOS 鑰匙圈;也會找鑰匙圈裡原有的網站密碼,以及 1Password(裝了 op 指令並登入時)。", "When a bot signs in to a site in the AnyBot browser, AnyBot fills in the username and password for it; the bot never sees the password, and it asks you every time. Stored in the macOS Keychain; it also finds site passwords already in the Keychain, and 1Password (when the op command is installed and signed in).", "Bot が AnyBot ブラウザでサイトにログインするとき、AnyBot がユーザー名とパスワードを入力します。Bot はパスワードを見られず、毎回あなたに確認します。macOS のキーチェーンに保存し、キーチェーンにある既存のパスワードや、1Password(op コマンドがインストール済みでログイン中のとき)も探します。"), + ("新增分區", "New section", "新しいセクション"), + ("移至新分區", "Move to new section", "新しいセクションへ移動"), + ("建立分區", "Create section", "セクションを作成"), + ("分區操作", "Section actions", "セクションの操作"), + ("將 Bot 拖曳到這裡", "Drag bots here", "ここに Bot をドラッグ"), + ("拖曳到此處以釘選", "Drag here to pin", "ここにドラッグしてピン留め"), + ("其中的 Bot 會移至 未分區,不會刪除任何 Bot。", "Its bots move to Unsorted. No bots are deleted.", "中の Bot は「未分類」に移動します。Bot は削除されません。"), + ("新項目", "New item", "新しい項目"), + ("登入資訊", "Logins", "ログイン情報"), + ("卡", "Card", "カード"), + ("卡片", "Cards", "カード"), + ("聯絡資訊", "Contact info", "連絡先情報"), + ("匯入", "Import", "インポート"), + ("全部", "All", "すべて"), + ("搜尋保險庫", "Search vault", "保管庫を検索"), + ("清除搜尋", "Clear search", "検索をクリア"), + ("讀不到保險庫", "Can’t read the vault", "保管庫を読み込めません"), + ("Bot 可以看到登入資訊和聯絡資訊,以便替你填寫", "Bots can see logins and contact info so they can fill them in for you", "Bot は代わりに入力できるよう、ログイン情報と連絡先情報を見ることができます"), + ("Bot 絕對看不到已儲存卡片的卡號或安全碼。它可以看到登入資訊和聯絡資訊,以便替你填寫。", "Bots never see a saved card’s number or security code. They can see logins and contact info so they can fill them in for you.", "Bot は保存したカードの番号やセキュリティコードを見ることはありません。ログイン情報と連絡先情報は入力のために見ることができます。"), + ("新登入資訊", "New login", "新しいログイン情報"), + ("新卡片", "New card", "新しいカード"), + ("新增聯絡資訊", "New contact info", "連絡先情報を追加"), + ("編輯聯絡資訊", "Edit contact info", "連絡先情報を編集"), + ("網站", "Websites", "Web サイト"), + ("新增網站", "Add website", "Web サイトを追加"), + ("使用者名稱", "Username", "ユーザー名"), + ("使用者名稱或電子郵件(選填)", "Username or email (optional)", "ユーザー名またはメール(任意)"), + ("提示", "Hint", "ヒント"), + ("提示(選填)", "Hint (optional)", "ヒント(任意)"), + ("提示:", "Hint: ", "ヒント:"), + ("告訴 Bot 何時使用此項目", "Tell bots when to use this item", "この項目をいつ使うか Bot に伝えます"), + ("已儲存的密碼,已隱藏", "Saved password, hidden", "保存済みのパスワード(非表示)"), + ("儲存登入資訊", "Save login", "ログイン情報を保存"), + ("儲存卡片", "Save card", "カードを保存"), + ("儲存聯絡資訊", "Save contact info", "連絡先情報を保存"), + ("卡號", "Card number", "カード番号"), + ("有效期限", "Expiry", "有効期限"), + ("月 / 年", "MM / YY", "月 / 年"), + ("安全碼(選填)", "Security code (optional)", "セキュリティコード(任意)"), + ("3 或 4 位數字", "3 or 4 digits", "3〜4 桁"), + ("顯示安全碼", "Show security code", "セキュリティコードを表示"), + ("隱藏安全碼", "Hide security code", "セキュリティコードを隠す"), + ("持卡人姓名(選填)", "Name on card (optional)", "カード名義人(任意)"), + ("暱稱(選填)", "Nickname (optional)", "ニックネーム(任意)"), + ("例如:旅行", "e.g. Travel", "例:旅行"), + ("帳單地址", "Billing address", "請求先住所"), + ("無帳單地址", "No billing address", "請求先住所なし"), + ("新增帳單地址", "Add billing address", "請求先住所を追加"), + ("輸入地址", "Enter address", "住所を入力"), + ("已鎖定", "Locked", "ロック済み"), + ("若要變更卡號,請移除此卡片後重新新增", "To change the number, remove this card and add it again", "番号を変更するには、このカードを削除して追加し直してください"), + ("請輸入有效的卡號", "Enter a valid card number", "有効なカード番号を入力してください"), + ("姓名", "Name", "氏名"), + ("街道地址", "Street address", "番地"), + ("公寓、套房或單位(選填)", "Apt, suite or unit (optional)", "建物名・部屋番号(任意)"), + ("城市", "City", "市区町村"), + ("州/省/地區", "State / province / region", "都道府県・州"), + ("郵遞區號", "Postal code", "郵便番号"), + ("國家/地區", "Country / region", "国・地域"), + ("手機", "Phone", "電話"), + ("新增手機號碼", "Add phone number", "電話番号を追加"), + ("電子郵件地址", "Email address", "メールアドレス"), + ("新增電子郵件", "Add email", "メールを追加"), + ("設為主要", "Make primary", "メインにする"), + ("上方已列出", "Already listed above", "上に記載済み"), + ("標籤(選填)", "Label (optional)", "ラベル(任意)"), + ("例如:住家、公司", "e.g. Home, Work", "例:自宅、勤務先"), + ("請輸入有效的電話號碼", "Enter a valid phone number", "有効な電話番号を入力してください"), + ("請輸入有效的電子郵件地址", "Enter a valid email address", "有効なメールアドレスを入力してください"), + ("要移除這筆登入資訊嗎?", "Remove this login?", "このログイン情報を削除しますか?"), + ("Bot 將無法再使用這張卡片付款", "Bots will no longer be able to pay with this card", "Bot はこのカードで支払えなくなります"), + ("Bot 將無法再使用此聯絡資訊", "Bots will no longer be able to use this contact info", "Bot はこの連絡先情報を使えなくなります"), + ("無法移除。請重試。", "Couldn’t remove. Please try again.", "削除できませんでした。もう一度お試しください。"), + ("從 CSV 匯入", "Import from CSV", "CSV からインポート"), + ("選擇 CSV 檔案", "Choose CSV file", "CSV ファイルを選択"), + ("選擇其他檔案", "Choose another file", "別のファイルを選択"), + ("停止匯入", "Stop import", "インポートを停止"), + ("此檔案中的項目", "Items in this file", "このファイルの項目"), + ("已就緒", "Ready", "準備完了"), + ("已在你的保險庫中", "Already in your vault", "保管庫にあります"), + ("從 1Password、Chrome、Edge、Safari、Apple Passwords、Firefox、Bitwarden 或 LastPass 匯出的 CSV 檔案匯入登入資訊", "Import logins from a CSV exported from 1Password, Chrome, Edge, Safari, Apple Passwords, Firefox, Bitwarden or LastPass", "1Password、Chrome、Edge、Safari、Apple パスワード、Firefox、Bitwarden、LastPass から書き出した CSV でログイン情報をインポート"), + ("從 LastPass 匯出的檔案會一併匯入卡片", "LastPass exports also bring in cards", "LastPass の書き出しファイルはカードもインポートします"), + ("檔案只會在這部裝置上讀取,絕不會上傳", "The file is read only on this device and never uploaded", "ファイルはこのデバイス上でのみ読み込まれ、アップロードされません"), + ("正在等你選檔案…", "Waiting for you to pick a file…", "ファイルの選択を待っています…"), + ("該檔案太大。請選擇小於 5 MB 的 CSV 檔案。", "That file is too big. Choose a CSV under 5 MB.", "ファイルが大きすぎます。5 MB 未満の CSV を選んでください。"), + ("無法讀取該檔案。請再試一次", "Couldn’t read that file. Try again", "ファイルを読み込めませんでした。もう一度お試しください"), + ("看畫面和操作", "Seeing and using the screen", "画面の表示と操作"), + ("Bot 要看你的螢幕、動滑鼠和鍵盤時需要。", "Needed when a bot looks at your screen or uses the mouse and keyboard.", "Bot が画面を見たり、マウスやキーボードを使ったりするときに必要です。"), + ("可以使用", "Ready", "使用できます"), + ("X11 桌面:可以截圖、點擊、打字,也能錄製示範", "X11 desktop: screenshots, clicks, typing, and recording demonstrations", "X11 デスクトップ:スクリーンショット、クリック、入力、操作の録画ができます"), + ("Wayland 桌面:可以截圖、點擊、打字(錄製示範需要 Xorg 工作階段)", "Wayland desktop: screenshots, clicks and typing (recording a demonstration needs an Xorg session)", "Wayland デスクトップ:スクリーンショット、クリック、入力ができます(操作の録画には Xorg セッションが必要です)"), + ("Wayland 桌面:第一次使用時系統會跳出視窗,打開「允許遠端操作」再按「分享」(錄製示範需要 Xorg 工作階段)", "Wayland desktop: the first time, the system shows a window — turn on “Allow remote interaction”, then press “Share” (recording a demonstration needs an Xorg session)", "Wayland デスクトップ:初回はシステムのウインドウが出ます。「リモート操作を許可」をオンにしてから「共有」を押してください(操作の録画には Xorg セッションが必要です)"), + ("這個桌面不支援", "This desktop isn’t supported", "このデスクトップには対応していません"), + ("找不到 xdg-desktop-portal 的遠端桌面功能,請安裝 xdg-desktop-portal-gnome 或 -kde", "No xdg-desktop-portal remote desktop found; install xdg-desktop-portal-gnome or -kde", "xdg-desktop-portal のリモートデスクトップがありません。xdg-desktop-portal-gnome または -kde をインストールしてください"), + ("沒有桌面畫面", "No desktop", "デスクトップがありません"), + ("這台電腦沒有顯示器", "This computer has no display", "このコンピュータにはディスプレイがありません"), + ("展開摘要", "Show summary", "要約を表示"), + ("編輯群組", "Edit group", "グループを編集"), + ("輔助使用已打開", "Accessibility is on", "アクセシビリティはオンです"), + ("由你的管理員設定,無法變更", "Set by your administrator; can’t be changed", "管理者が設定しているため変更できません"), + ("直接操作(像 Grok Bot 一樣)", "Act directly (like Grok Bot)", "直接操作(Grok Bot と同様)"), + ("全部允許,之後直接操作(像 Grok Bot 一樣)", "Allow all and act directly from now on (like Grok Bot)", "すべて許可し、以後は直接操作(Grok Bot と同様)"), + ("需要你允許", "Needs your OK", "許可が必要です"), + ("正在錄會議 · 結束", "Recording a meeting · Stop", "会議を録音中・終了"), + ("Bot 正在操作", "The bot is working", "Bot が操作中"), + ("你正在操作,Bot 已暫停", "You’re in control; the bot is paused", "操作中のため Bot は一時停止中"), + ("觸發", "Trigger", "トリガー"), + ("來自其他 Bot", "From another bot", "ほかの Bot から"), + ("頁", "pages", "ページ"), + ("正在打開 PDF…", "Opening the PDF…", "PDF を開いています…"), + ("第", "Page", "ページ"), + ("只顯示前", "Showing the first", "最初の"), + ("頁,完整內容請用其他 App 打開", "pages; open it in another app for the rest", "ページのみ表示。全体はほかのアプリで開いてください"), + ("暫停", "Pause", "一時停止"), + ("播放", "Play", "再生"), + ("播放影片", "Play video", "動画を再生"), + ("影片會用電腦的播放器播放", "Videos play in your computer’s player", "動画はコンピューターのプレーヤーで再生されます"), + ("找不到可以播放聲音的裝置", "No sound output device found", "音声を再生できるデバイスが見つかりません"), + ("這種音訊格式沒辦法在 AnyBot 裡播放,可以用其他 App 打開。", "AnyBot can’t play this audio format; open it in another app.", "この音声形式は AnyBot で再生できません。ほかのアプリで開いてください。"), + ("下面是網頁的內容(版面以瀏覽器為準)", "The page’s content below (the browser shows the real layout)", "以下はページの内容です(実際のレイアウトはブラウザーで)"), + ("打開所在資料夾", "Open containing folder", "保存先フォルダーを開く"), + ("讀不到檔案", "Can’t read the file", "ファイルを読み込めません"), + ("電腦工具", "Computer tools", "コンピューターツール"), + ("記憶", "Memories", "記憶"), + ("過去的對話", "Past chats", "過去の会話"), + ("已關閉", "Off", "オフ"), + ("已開啟", "On", "オン"), + ("使用的模型", "Model", "モデル"), + ("在右側面板中顯示", "Show in side panel", "右側のパネルに表示"), + ("執行中", "Running", "実行中"), + ("休眠中", "Asleep", "スリープ中"), + ("尚未建立", "Not created yet", "未作成"), + ("沒有 Docker", "No Docker", "Docker なし"), + ("發生問題", "Problem", "問題あり"), + ("重新啟動 AnyBot", "Restart AnyBot", "AnyBot を再起動"), + ("正在重新啟動…", "Restarting…", "再起動中…"), + ("開始錄會議", "Start recording a meeting", "会議の録音を開始"), + ("● 結束錄音,轉成文字記錄", "● Stop and make a transcript", "● 録音を終了して文字起こし"), + ("「無需詢問直接允許」規則", "“Allow without asking” rules", "「確認せずに許可」ルール"), + ("「一律詢問」規則", "“Always ask” rules", "「常に確認」ルール"), + ("操作符合這些規則時,自動繼續", "When an action matches these rules, it continues automatically", "操作がこれらのルールに一致すると、自動で続行します"), + ("操作符合這些規則時,停下來詢問", "When an action matches these rules, it stops and asks", "操作がこれらのルールに一致すると、止まって確認します"), + ("例如:在工作資料夾裡建立文字檔", "e.g. Create text files in the work folder", "例:作業フォルダーにテキストファイルを作成"), + ("例如:刪除任何檔案", "e.g. Delete any file", "例:ファイルの削除"), + ("釘選", "Pin", "ピン留め"), + ("取消釘選", "Unpin", "ピン留めを外す"), + ("移至", "Move to", "移動"), + ("標示為未讀", "Mark as unread", "未読にする"), + ("標示為已讀", "Mark as read", "既読にする"), + ("錄會議", "Record a meeting", "会議を録音"), + ("分享與匯出", "Share & export", "共有と書き出し"), + ("複製對話 ID", "Copy chat ID", "チャット ID をコピー"), + ("上移", "Move up", "上へ移動"), + ("下移", "Move down", "下へ移動"), + ("群組聊天", "Group chat", "グループチャット"), + ("未設定金鑰", "no key yet", "キー未設定"), + ("新增 Bot", "New bot", "新しい Bot"), + ("新增群組", "New group", "新しいグループ"), + ("新群組", "New group", "新しいグループ"), + ("重新命名", "Rename", "名前を変更"), + ("刪除群組(Bot 移到未分區)", "Delete group (bots move to Uncategorized)", "グループを削除(Bot は未分類へ)"), + ("放到這裡", "Drop here", "ここにドロップ"), + ("把 Bot 拖進來", "Drag bots here", "Bot をここにドラッグ"), + ("移到群組", "Move to group", "グループに移動"), + ("新群組名稱", "New group name", "新しいグループ名"), + ("輸入框操作", "Input actions", "入力の操作"), + ("附加檔案", "Attach files", "ファイルを添付"), + ("教它一項任務", "Teach it a task", "タスクを教える"), + ("開始語音輸入", "Start dictation", "音声入力を開始"), + ("發起語音聊天", "Start voice chat", "ボイスチャットを開始"), + ("傳送訊息", "Send message", "メッセージを送信"), + ("引用:", "Quoting: ", "引用:"), + ("成員:", "Members: ", "メンバー:"), + ("連結應用程式", "Connect apps", "アプリを連携"), + ("移到資料夾", "Move to folder", "フォルダーに移動"), + ("未分區", "Uncategorized", "未分類"), + ("新資料夾名稱", "New folder name", "新しいフォルダー名"), + ("正在工作…", "Working…", "作業中…"), + ("示範", "Demo", "お手本"), + ("重新啟動以更新", "Restart to update", "再起動してアップデート"), + ("新增表情回應", "Add reaction", "リアクションを追加"), + ("回覆", "Reply", "返信"), + ("更多訊息操作", "More actions", "その他の操作"), + ("更多表情符號", "More emoji", "その他の絵文字"), + ("顯示步驟", "Show steps", "ステップを表示"), + ("隱藏步驟", "Hide steps", "ステップを隠す"), + ("思考過程", "Thinking", "思考の過程"), + ("Bot 的表情回應", "The bot's reaction", "Bot のリアクション"), + ("點一下預覽", "click to preview", "クリックでプレビュー"), + ("列", "rows", "行"), + ("下載檔案", "Download file", "ファイルをダウンロード"), + ("關閉預覽", "Close preview", "プレビューを閉じる"), + ("用其他 App 打開", "Open in another app", "ほかのアプリで開く"), + ("網頁的文字內容:", "The page's text:", "ページのテキスト:"), + ("這張圖片打不開", "This picture can't be opened", "この画像は開けません"), + ("上午", "AM ", "午前"), + ("下午", "PM ", "午後"), + ("週一", "Mon", "(月)"), + ("週二", "Tue", "(火)"), + ("週三", "Wed", "(水)"), + ("週四", "Thu", "(木)"), + ("週五", "Fri", "(金)"), + ("週六", "Sat", "(土)"), + ("週日", "Sun", "(日)"), + ("正在仔細思考", "Thinking it through", "じっくり考えています"), + ("正在琢磨", "Mulling it over", "考えています"), + ("正在整理思緒", "Gathering thoughts", "考えを整理しています"), + ("正在完成", "Finishing up", "仕上げています"), + ("正在推進", "Making progress", "進めています"), + ("正在處理", "Working on it", "処理しています"), + ("正在搞定", "Getting it done", "片付けています"), + ("正在一點一點推進", "Chipping away", "少しずつ進めています"), + ("正在讀取檔案", "Reading files", "ファイルを読んでいます"), + ("正在記下來", "Writing it down", "書き留めています"), + ("正在執行幾個指令", "Running a few commands", "いくつかコマンドを実行しています"), + ("正在等待結果", "Waiting for results", "結果を待っています"), + ("正在閱讀資料", "Reading", "資料を読んでいます"), + ("正在逐頁瀏覽", "Browsing", "ページを見ています"), + ("正在詢問另一個 Bot", "Asking another bot", "別の Bot に聞いています"), + ("正在傳送訊息", "Sending a message", "メッセージを送っています"), + ("正在發布", "Publishing", "公開しています"), + ("正在四處點擊", "Clicking around", "あちこちクリックしています"), + ("正在你的電腦上工作", "Working on your computer", "あなたのコンピューターで作業中"), + ("正在四處查找", "Looking around", "調べています"), + ("正在已連結的應用程式中工作", "Working in connected apps", "連携アプリで作業中"), + ("需要你的輸入", "Needs your input", "入力が必要です"), + ("動態", "Activity", "アクティビティ"), + ("資料庫", "Library", "ライブラリ"), + ("建議", "Suggestions", "おすすめ"), + ("新增至 Slack", "Add to Slack", "Slack に追加"), + ("建立例行工作", "Create a routine", "ルーティンを作成"), + ("加入會議", "Join a meeting", "会議に参加"), + ("結束會議錄音", "Stop meeting recording", "会議の録音を終了"), + ("語音聊天", "Voice chat", "ボイスチャット"), + ("語音", "Voice", "音声"), + ("速度", "Speed", "速度"), + ("自動", "Auto", "自動"), + ("自動(依語言)", "Auto (by language)", "自動(言語に合わせる)"), + ("朗讀每個回答", "Read every answer aloud", "すべての回答を読み上げ"), + ("進行中", "In progress", "進行中"), + ("本週", "This week", "今週"), + ("更早", "Earlier", "それ以前"), + ("展開", "Show more", "もっと見る"), + ("收合", "Show less", "閉じる"), + ("這個對話裡的檔案會出現在這裡", "Files from this chat appear here", "このチャットのファイルがここに表示されます"), + ("編輯 Bot 頭像", "Edit bot avatar", "Bot のアバターを編集"), + ("查看對話詳細資訊", "View chat details", "チャットの詳細を表示"), + ("關閉對話詳細資訊", "Close chat details", "チャットの詳細を閉じる"), + ("中文", "Chinese", "中国語"), + ("發起語音聊天:你講完它就回答並唸出來,再繼續聽", "Start voice chat: when you finish speaking it answers out loud, then keeps listening", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"), + ("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"), + ("你好,我是你的 Bot。", "Hi, I'm your bot.", "こんにちは、あなたの Bot です。"), +]; diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..4a5a2a9 --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,320 @@ +// Icons: one free set, Lucide (ISC licence, icons/lucide/LICENSE), drawn from its SVG files so +// every icon in the app has the same 2 px rounded line on a 24 px grid — no emoji anywhere. +// Each SVG is rendered once per size (white), kept as a texture and tinted when painted. + +use eframe::egui::{self, vec2, Color32, Painter, Rect}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +#[allow(dead_code)] +pub enum Icon { + SidebarCollapse, + SidebarExpand, + Plus, + Search, + Gear, + More, + ArrowUp, + Stop, + Monitor, + Mic, + Speaker, + Sparkle, + Chat, + Shield, + Vault, + Key, + CreditCard, + Person, + Eye, + CloudDownload, + PanelRight, + Phone, + Clock, + Calendar, + Wave, + Close, + Download, + Smile, + Reply, + Apps, + Paperclip, + Record, + Pin, + Folder, + Bell, + Pencil, + Copy, + EyeOff, + Trash, + ArrowDown, + Refresh, + Star, + Share, + Upload, + NewChat, + History, + Eraser, + Info, + Bot, + Users, + Template, + Check, + FileSheet, + FileText, + FileCode, + FileImage, + FileVideo, + FileAudio, + File, + Lock, + Update, + Keyboard, + Globe, + ChevronRight, + ChevronDown, + Zap, + Alert, + Summary, + Plane, + MapPin, + CloudSun, + ShoppingBag, + ExternalLink, + Message, + Alarm, + Mail, + CircleCheck, + CircleX, + Minus, + Loader, + Play, + Pause, + FileJson, + FileWord, +} + +impl Icon { + pub fn from_name(n: &str) -> Option { + Some(match n { + "◧" => Icon::SidebarCollapse, + "◨" => Icon::SidebarExpand, + "+" => Icon::Plus, + "🔍" => Icon::Search, + "⚙" => Icon::Gear, + "⋯" => Icon::More, + "↑" => Icon::ArrowUp, + "■" => Icon::Stop, + "monitor" => Icon::Monitor, + "mic" => Icon::Mic, + _ => return None, + }) + } + + /// The Lucide file. + fn svg(self) -> &'static str { + match self { + Icon::SidebarCollapse => include_str!("../icons/lucide/panel-left-close.svg"), + Icon::SidebarExpand => include_str!("../icons/lucide/panel-left-open.svg"), + Icon::Plus => include_str!("../icons/lucide/plus.svg"), + Icon::Search => include_str!("../icons/lucide/search.svg"), + Icon::Gear => include_str!("../icons/lucide/settings.svg"), + Icon::More => include_str!("../icons/lucide/ellipsis.svg"), + Icon::ArrowUp => include_str!("../icons/lucide/arrow-up.svg"), + Icon::Stop => include_str!("../icons/lucide/square.svg"), + Icon::Monitor => include_str!("../icons/lucide/monitor.svg"), + Icon::Mic => include_str!("../icons/lucide/mic.svg"), + Icon::Speaker => include_str!("../icons/lucide/volume-2.svg"), + Icon::Sparkle => include_str!("../icons/lucide/sparkles.svg"), + Icon::Chat => include_str!("../icons/lucide/messages-square.svg"), + Icon::Shield => include_str!("../icons/lucide/shield-check.svg"), + Icon::Vault => include_str!("../icons/lucide/vault.svg"), + Icon::Key => include_str!("../icons/lucide/key-round.svg"), + Icon::CreditCard => include_str!("../icons/lucide/credit-card.svg"), + Icon::Person => include_str!("../icons/lucide/user-round.svg"), + Icon::Eye => include_str!("../icons/lucide/eye.svg"), + Icon::CloudDownload => include_str!("../icons/lucide/cloud-download.svg"), + Icon::PanelRight => include_str!("../icons/lucide/panel-right.svg"), + Icon::Phone => include_str!("../icons/lucide/phone.svg"), + Icon::Clock => include_str!("../icons/lucide/clock.svg"), + Icon::Calendar => include_str!("../icons/lucide/calendar.svg"), + Icon::Wave => include_str!("../icons/lucide/audio-lines.svg"), + Icon::Close => include_str!("../icons/lucide/x.svg"), + Icon::Download => include_str!("../icons/lucide/download.svg"), + Icon::Smile => include_str!("../icons/lucide/smile-plus.svg"), + Icon::Reply => include_str!("../icons/lucide/reply.svg"), + Icon::Apps => include_str!("../icons/lucide/layout-grid.svg"), + Icon::Paperclip => include_str!("../icons/lucide/paperclip.svg"), + Icon::Record => include_str!("../icons/lucide/circle-dot.svg"), + Icon::Pin => include_str!("../icons/lucide/pin.svg"), + Icon::Folder => include_str!("../icons/lucide/folder.svg"), + Icon::Bell => include_str!("../icons/lucide/bell-dot.svg"), + Icon::Pencil => include_str!("../icons/lucide/pencil.svg"), + Icon::Copy => include_str!("../icons/lucide/copy.svg"), + Icon::EyeOff => include_str!("../icons/lucide/eye-off.svg"), + Icon::Trash => include_str!("../icons/lucide/trash-2.svg"), + Icon::ArrowDown => include_str!("../icons/lucide/arrow-down.svg"), + Icon::Refresh => include_str!("../icons/lucide/refresh-cw.svg"), + Icon::Star => include_str!("../icons/lucide/star.svg"), + Icon::Share => include_str!("../icons/lucide/share.svg"), + Icon::Upload => include_str!("../icons/lucide/upload.svg"), + Icon::NewChat => include_str!("../icons/lucide/square-pen.svg"), + Icon::History => include_str!("../icons/lucide/history.svg"), + Icon::Eraser => include_str!("../icons/lucide/eraser.svg"), + Icon::Info => include_str!("../icons/lucide/info.svg"), + Icon::Bot => include_str!("../icons/lucide/bot.svg"), + Icon::Users => include_str!("../icons/lucide/users.svg"), + Icon::Template => include_str!("../icons/lucide/layout-template.svg"), + Icon::Check => include_str!("../icons/lucide/check.svg"), + Icon::FileSheet => include_str!("../icons/lucide/file-spreadsheet.svg"), + Icon::FileText => include_str!("../icons/lucide/file-text.svg"), + Icon::FileCode => include_str!("../icons/lucide/file-code.svg"), + Icon::FileImage => include_str!("../icons/lucide/image.svg"), + Icon::FileVideo => include_str!("../icons/lucide/file-video.svg"), + Icon::FileAudio => include_str!("../icons/lucide/file-music.svg"), + Icon::File => include_str!("../icons/lucide/file.svg"), + Icon::Lock => include_str!("../icons/lucide/lock.svg"), + Icon::Update => include_str!("../icons/lucide/circle-arrow-up.svg"), + Icon::Keyboard => include_str!("../icons/lucide/keyboard.svg"), + Icon::Globe => include_str!("../icons/lucide/globe.svg"), + Icon::ChevronRight => include_str!("../icons/lucide/chevron-right.svg"), + Icon::ChevronDown => include_str!("../icons/lucide/chevron-down.svg"), + Icon::Zap => include_str!("../icons/lucide/zap.svg"), + Icon::Alert => include_str!("../icons/lucide/circle-alert.svg"), + Icon::Summary => include_str!("../icons/lucide/notebook-pen.svg"), + Icon::Plane => include_str!("../icons/lucide/plane.svg"), + Icon::MapPin => include_str!("../icons/lucide/map-pin.svg"), + Icon::CloudSun => include_str!("../icons/lucide/cloud-sun.svg"), + Icon::ShoppingBag => include_str!("../icons/lucide/shopping-bag.svg"), + Icon::ExternalLink => include_str!("../icons/lucide/external-link.svg"), + Icon::Message => include_str!("../icons/lucide/message-circle.svg"), + Icon::Alarm => include_str!("../icons/lucide/alarm-clock.svg"), + Icon::Mail => include_str!("../icons/lucide/mail.svg"), + Icon::CircleCheck => include_str!("../icons/lucide/circle-check.svg"), + Icon::CircleX => include_str!("../icons/lucide/circle-x.svg"), + Icon::Minus => include_str!("../icons/lucide/minus.svg"), + Icon::Loader => include_str!("../icons/lucide/loader.svg"), + Icon::Play => include_str!("../icons/lucide/play.svg"), + Icon::Pause => include_str!("../icons/lucide/pause.svg"), + Icon::FileJson => include_str!("../icons/lucide/file-json.svg"), + Icon::FileWord => include_str!("../icons/lucide/file-type.svg"), + } + } + + /// Filled shapes (stop is a solid square, like a media stop button). + fn filled(self) -> bool { + matches!(self, Icon::Stop | Icon::Play | Icon::Pause) + } +} + +/// The icon for a file name (cards, previews, the phone page). +pub fn for_file(name: &str) -> Icon { + match crate::preview::kind_of(name) { + "sheet" => Icon::FileSheet, + "image" => Icon::FileImage, + "video" => Icon::FileVideo, + "audio" => Icon::FileAudio, + "text" | "html" => Icon::FileCode, + "json" => Icon::FileJson, + "docx" | "doc" => Icon::FileWord, + "markdown" | "pdf" => Icon::FileText, + _ => Icon::File, + } +} + +type Cache = Mutex>; + +fn cache() -> &'static Cache { + static C: OnceLock = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Render the SVG white at `px` × `px`. +fn rasterize(icon: Icon, px: u32) -> Option { + let mut svg = icon.svg().replace("currentColor", "#ffffff"); + if icon.filled() { + svg = svg.replace("fill=\"none\"", "fill=\"#ffffff\""); + } + let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default()).ok()?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(px, px)?; + let size = tree.size(); + let scale = px as f32 / size.width().max(size.height()); + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut()); + // tiny-skia keeps premultiplied RGBA, which is what egui wants. + let data: Vec = pixmap.pixels().iter().map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())).collect(); + Some(egui::ColorImage { size: [px as usize, px as usize], pixels: data, source_size: vec2(px as f32, px as f32) }) +} + +/// Draw `icon` filling `rect` (square), in `col`. +pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) { + let ppp = p.ctx().pixels_per_point(); + let side = rect.width().min(rect.height()); + let px = (side * ppp).round().max(8.0) as u32; + let tex = { + let mut c = cache().lock().unwrap(); + match c.get(&(icon, px)) { + Some(t) => t.clone(), + None => { + let Some(img) = rasterize(icon, px) else { return }; + let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR); + c.insert((icon, px), t.clone()); + t + } + } + }; + let r = Rect::from_center_size(rect.center(), vec2(side, side)); + p.image(tex.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), col); +} + +/// An icon followed by words, on one line. +pub fn label(ui: &mut egui::Ui, icon: Icon, text: &str, size: f32, col: Color32) -> egui::Response { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(size + 1.0, size + 2.0), egui::Sense::hover()); + paint(ui.painter(), Rect::from_center_size(r.center(), vec2(size, size)), icon, col); + ui.label(egui::RichText::new(text).size(size).color(col)); + }) + .response +} + +/// A rounded button with an icon and words (pills like 「需要你允許」). +pub fn pill(ui: &mut egui::Ui, icon: Icon, text: &str, fill: Color32, fg: Color32, height: f32) -> egui::Response { + let font = egui::FontId::proportional(13.0); + let galley = ui.painter().layout_no_wrap(text.to_string(), font, fg); + let icon_s = 14.0; + let w = 12.0 + icon_s + 6.0 + galley.size().x + 14.0; + let (rect, r) = ui.allocate_exact_size(vec2(w, height), egui::Sense::click()); + let f = if r.hovered() { fill.gamma_multiply(0.88) } else { fill }; + ui.painter().rect_filled(rect, height / 2.0, f); + paint(ui.painter(), Rect::from_center_size(egui::pos2(rect.left() + 12.0 + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, fg); + ui.painter().galley(egui::pos2(rect.left() + 12.0 + icon_s + 6.0, rect.center().y - galley.size().y / 2.0), galley, fg); + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A borderless icon button (hover shows a soft rounded square). +pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, col: Color32, hover_fill: Color32) -> egui::Response { + let (rect, r) = ui.allocate_exact_size(vec2(size, size), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 7.0, hover_fill); + } + let inner = Rect::from_center_size(rect.center(), vec2(16.0, 16.0)); + paint(ui.painter(), inner, icon, col); + r.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_icon_renders() { + for icon in [Icon::Plus, Icon::Gear, Icon::Stop, Icon::FileSheet, Icon::Trash, Icon::Wave, Icon::Summary, Icon::Lock, Icon::Update, Icon::Play, Icon::Pause, Icon::FileJson, Icon::FileWord] { + let img = rasterize(icon, 32).expect("renders"); + assert_eq!(img.size, [32, 32]); + assert!(img.pixels.iter().any(|p| p.a() > 0), "{icon:?} is empty"); + } + assert_eq!(for_file("a.xlsx"), Icon::FileSheet); + assert_eq!(for_file("a.png"), Icon::FileImage); + } +} diff --git a/src/macapps.rs b/src/macapps.rs new file mode 100644 index 0000000..80c717f --- /dev/null +++ b/src/macapps.rs @@ -0,0 +1,417 @@ +// The Mac's own apps as tools, like Grok Bot's device tools (local-exec-daemon): +// ios_read_reminders / ios_write_reminders, mobile_read_calendar / mobile_write_calendar, +// mobile_read_contacts, mac_read_notes / mac_write_notes, mac_read_mail / mac_draft_mail / +// mac_send_mail / mac_mail_accounts, and sending an iMessage. +// Rules taken from the original: +// - a note or an email is read up to 20,000 characters (zlt=2e4); +// - contacts are fetched 50 at a time (Dlt=50); +// - device calls time out after 30 s (g4r=3e4); +// - writing (reminders, calendar, notes, mail drafts) needs consent; sending mail is only ever +// approved one message at a time (recordLocalToolApproval refuses a standing grant for it); +// - mac_write_notes appends when given id + currentTitle, else creates (needs a title); +// mac_draft_mail needs to (or replyToId) and a body. +// The original runs them through its signed helper; here they go through osascript (JXA), so +// macOS asks once per app under 「系統設定 → 隱私權與安全性 → 自動化」. + +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; + +pub const MAX_CHARS: usize = 20_000; +pub const CONTACT_BATCH: usize = 50; +pub const CALL_TIMEOUT_MS: u64 = 30_000; +/// Messages scanned when searching mail (estimated; Mail scripting is slow on big inboxes). +const MAIL_SCAN: usize = 300; + +pub const NAMES: [&str; 11] = [ + "ios_read_reminders", + "ios_write_reminders", + "mobile_read_calendar", + "mobile_write_calendar", + "mobile_read_contacts", + "mac_read_notes", + "mac_write_notes", + "mac_read_mail", + "mac_draft_mail", + "mac_send_mail", + "mac_mail_accounts", +]; +pub const SEND_MESSAGE: &str = "mac_send_message"; + +pub fn is_mac_tool(name: &str) -> bool { + NAMES.contains(&name) || name == SEND_MESSAGE +} + +/// The app a tool uses (for the consent wording and the per-app grant). +pub fn app_of(name: &str) -> &'static str { + match name { + "ios_read_reminders" | "ios_write_reminders" => "提醒事項", + "mobile_read_calendar" | "mobile_write_calendar" => "行事曆", + "mobile_read_contacts" => "聯絡人", + "mac_read_notes" | "mac_write_notes" => "備忘錄", + SEND_MESSAGE => "訊息", + _ => "郵件", + } +} + +pub fn is_write(name: &str) -> bool { + matches!(name, "ios_write_reminders" | "mobile_write_calendar" | "mac_write_notes" | "mac_draft_mail") +} + +/// Each one is approved on its own, never "always". +pub fn is_send(name: &str) -> bool { + name == "mac_send_mail" || name == SEND_MESSAGE +} + +pub fn label(name: &str) -> &'static str { + match name { + "ios_read_reminders" => "查看提醒事項", + "ios_write_reminders" => "修改提醒事項", + "mobile_read_calendar" => "查看行事曆", + "mobile_write_calendar" => "修改行事曆", + "mobile_read_contacts" => "查聯絡人", + "mac_read_notes" => "讀備忘錄", + "mac_write_notes" => "寫備忘錄", + "mac_read_mail" => "讀郵件", + "mac_draft_mail" => "起草郵件", + "mac_send_mail" => "寄出郵件", + "mac_mail_accounts" => "查看郵件帳號", + SEND_MESSAGE => "傳訊息", + _ => "Mac App", + } +} + +fn s(v: &Value, k: &str) -> String { + v.get(k).and_then(Value::as_str).unwrap_or("").trim().to_string() +} + +fn list(v: &Value, k: &str) -> Vec { + match v.get(k) { + Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect(), + Some(Value::String(x)) => x.split([',', ';']).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), + _ => vec![], + } +} + +pub fn describe(name: &str, args: &str) -> String { + let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); + match name { + "ios_write_reminders" => format!("{}「{}」", match s(&v, "action").as_str() { "complete" => "完成", "delete" => "刪除", "update" => "修改", _ => "新增" }, s(&v, "title")), + "mobile_write_calendar" => format!("{}「{}」{}", match s(&v, "action").as_str() { "delete" => "刪除", "update" => "修改", _ => "新增" }, s(&v, "title"), s(&v, "start")), + "mobile_read_calendar" => format!("{} ~ {}", s(&v, "from"), s(&v, "to")), + "mobile_read_contacts" | "mac_read_notes" | "mac_read_mail" => s(&v, "query"), + "mac_write_notes" => if s(&v, "id").is_empty() { format!("新備忘錄「{}」", s(&v, "title")) } else { format!("加到「{}」", s(&v, "currentTitle")) }, + "mac_draft_mail" | "mac_send_mail" => format!("給 {}:{}", list(&v, "to").join("、"), s(&v, "subject")), + SEND_MESSAGE => { + let t: String = s(&v, "text").chars().take(40).collect(); + format!("給 {}:{t}", s(&v, "to")) + } + _ => String::new(), + } +} + +pub fn specs() -> Vec { + let strs = json!({"type": "array", "items": {"type": "string"}}); + vec![ + json!({"name": "ios_read_reminders", "description": "Read the person's Reminders on this Mac. Optional list name; completed ones only when asked.", + "parameters": {"type": "object", "properties": {"list": {"type": "string"}, "includeCompleted": {"type": "boolean"}, "query": {"type": "string"}, "limit": {"type": "integer"}}}}), + json!({"name": "ios_write_reminders", "description": "Add, update, complete or delete a reminder in the person's Reminders. due: ISO date-time in the person's time zone.", + "parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["add", "update", "complete", "delete"]}, "id": {"type": "string"}, "title": {"type": "string"}, "notes": {"type": "string"}, "due": {"type": "string"}, "list": {"type": "string"}}, "required": ["action"]}}), + json!({"name": "mobile_read_calendar", "description": "Read events from the person's Calendar between from and to (ISO dates).", + "parameters": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}, "calendar": {"type": "string"}}, "required": ["from", "to"]}}), + json!({"name": "mobile_write_calendar", "description": "Add, update or delete an event in the person's Calendar. start/end: ISO date-times.", + "parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["add", "update", "delete"]}, "id": {"type": "string"}, "title": {"type": "string"}, "start": {"type": "string"}, "end": {"type": "string"}, "location": {"type": "string"}, "notes": {"type": "string"}, "calendar": {"type": "string"}, "allDay": {"type": "boolean"}}, "required": ["action"]}}), + json!({"name": "mobile_read_contacts", "description": "Find people in the person's Contacts by name, email or phone.", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["query"]}}), + json!({"name": "mac_read_notes", "description": "Search the person's Notes (query, folder), or read one note by id (up to 20,000 characters).", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "query": {"type": "string"}, "folder": {"type": "string"}, "limit": {"type": "integer"}}}}), + json!({"name": "mac_write_notes", "description": "Create a note (title + body, optional folder), or append to one (id + its currentTitle + body).", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "currentTitle": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}, "folder": {"type": "string"}}, "required": ["body"]}}), + json!({"name": "mac_read_mail", "description": "Search the person's Mail (query, mailbox, unreadOnly, days, limit), or read one message by id (up to 20,000 characters).", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "query": {"type": "string"}, "mailbox": {"type": "string"}, "unreadOnly": {"type": "boolean"}, "days": {"type": "integer"}, "limit": {"type": "integer"}}}}), + json!({"name": "mac_draft_mail", "description": "Open a draft in Mail for the person to review (to + subject + body, or replyToId + body). It is not sent.", + "parameters": {"type": "object", "properties": {"to": strs, "cc": strs, "subject": {"type": "string"}, "body": {"type": "string"}, "replyToId": {"type": "string"}}, "required": ["body"]}}), + json!({"name": "mac_send_mail", "description": "Send an email from the person's Mail. The person approves every message.", + "parameters": {"type": "object", "properties": {"to": strs, "cc": strs, "subject": {"type": "string"}, "body": {"type": "string"}, "account": {"type": "string"}}, "required": ["to", "subject", "body"]}}), + json!({"name": "mac_mail_accounts", "description": "List the person's Mail accounts and their addresses.", + "parameters": {"type": "object", "properties": {}}}), + json!({"name": SEND_MESSAGE, "description": "Send an iMessage/SMS from the person's Messages app to a phone number or email. The person approves every message.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}}, "required": ["to", "text"]}}), + ] +} + +/// Run a JXA script with the arguments as JSON (argv[0]). Returns its printed result. +fn jxa(script: &str, args: &Value) -> Result { + use std::io::Read; + let mut child = std::process::Command::new("osascript") + .args(["-l", "JavaScript", "-e", script, "--", &args.to_string()]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("無法執行 osascript:{e}"))?; + let mut so = child.stdout.take().unwrap(); + let mut se = child.stderr.take().unwrap(); + let ot = std::thread::spawn(move || { + let mut v = String::new(); + let _ = so.read_to_string(&mut v); + v + }); + let et = std::thread::spawn(move || { + let mut v = String::new(); + let _ = se.read_to_string(&mut v); + v + }); + let start = Instant::now(); + let st = loop { + if let Ok(Some(st)) = child.try_wait() { + break st; + } + if start.elapsed() > Duration::from_millis(CALL_TIMEOUT_MS) { + let _ = child.kill(); + return Err("Mac 的 App 沒有在 30 秒內回應。".into()); + } + std::thread::sleep(Duration::from_millis(20)); + }; + let out = ot.join().unwrap_or_default(); + let err = et.join().unwrap_or_default(); + if st.success() { + Ok(out.trim().to_string()) + } else { + Err(err.trim().to_string()) + } +} + +/// The original's wording when macOS says no (a4e), pointed at AnyBot. +fn permission_hint(app: &str, err: &str) -> String { + let e = err.to_lowercase(); + if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") { + let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn(); + return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。"); + } + format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err)) +} + +const JS_HELPERS: &str = r#" +function clip(t, n) { t = String(t || ''); return t.length > n ? t.slice(0, n) + '\n…(後面省略 ' + (t.length - n) + ' 個字)' : t; } +function iso(d) { try { return d ? d.toISOString() : null; } catch (e) { return null; } } +function has(hay, q) { return !q || String(hay || '').toLowerCase().indexOf(q.toLowerCase()) >= 0; } +"#; + +fn script(body: &str) -> String { + format!("{JS_HELPERS}\nfunction run(argv) {{ const a = JSON.parse(argv[0] || '{{}}'); {body} }}") +} + +pub fn run(name: &str, args: &str) -> (String, bool) { + if !cfg!(target_os = "macos") { + return ("這個工具只支援 macOS".into(), false); + } + let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(json!({})); + let r = run_inner(name, &v); + match r { + Ok(s) => (s, true), + Err(e) => (e, false), + } +} + +fn run_inner(name: &str, v: &Value) -> Result { + let app = app_of(name); + let call = |body: &str, a: &Value| jxa(&script(body), a).map_err(|e| permission_hint(app, &e)); + match name { + "ios_read_reminders" => { + let a = json!({"list": s(v, "list"), "done": v["includeCompleted"].as_bool().unwrap_or(false), "q": s(v, "query"), "limit": v["limit"].as_u64().unwrap_or(50).min(200)}); + call(r#"const R = Application('Reminders'); const out = []; + const lists = a.list ? R.lists.whose({name: a.list})() : R.lists(); + for (const l of lists) { const rs = a.done ? l.reminders() : l.reminders.whose({completed: false})(); + for (const r of rs) { if (out.length >= a.limit) break; const n = r.name(); if (!has(n + ' ' + (r.body() || ''), a.q)) continue; + out.push({id: r.id(), title: n, list: l.name(), due: iso(r.dueDate()), completed: r.completed(), notes: clip(r.body(), 500)}); } } + return JSON.stringify(out);"#, &a) + .map(|o| format!("提醒事項:{o}")) + } + "ios_write_reminders" => { + let action = s(v, "action"); + if action.is_empty() { + return Err("請給 action(add、update、complete、delete)".into()); + } + if action != "add" && s(v, "id").is_empty() { + return Err("修改、完成或刪除要給 id(先用 ios_read_reminders 查)".into()); + } + if action == "add" && s(v, "title").is_empty() { + return Err("新的提醒事項要有 title".into()); + } + let a = json!({"action": action, "id": s(v, "id"), "title": s(v, "title"), "notes": s(v, "notes"), "due": s(v, "due"), "list": s(v, "list")}); + call(r#"const R = Application('Reminders'); + if (a.action === 'add') { const l = a.list ? R.lists.byName(a.list) : R.defaultList(); + const props = {name: a.title}; if (a.notes) props.body = a.notes; if (a.due) props.dueDate = new Date(a.due); + const r = R.Reminder(props); l.reminders.push(r); return JSON.stringify({action: 'added', id: r.id(), title: a.title}); } + const r = R.reminders.byId(a.id); const t = r.name(); + if (a.action === 'complete') { r.completed = true; return JSON.stringify({action: 'completed', title: t}); } + if (a.action === 'delete') { R.delete(r); return JSON.stringify({action: 'deleted', title: t}); } + if (a.title) r.name = a.title; if (a.notes) r.body = a.notes; if (a.due) r.dueDate = new Date(a.due); + return JSON.stringify({action: 'updated', title: r.name()});"#, &a) + } + "mobile_read_calendar" => { + if s(v, "from").is_empty() || s(v, "to").is_empty() { + return Err("請給 from 和 to(例如 2026-11-04 和 2026-11-11)".into()); + } + let a = json!({"from": s(v, "from"), "to": s(v, "to"), "cal": s(v, "calendar")}); + call(r#"const C = Application('Calendar'); const from = new Date(a.from), to = new Date(a.to); const out = []; + const cals = a.cal ? C.calendars.whose({name: a.cal})() : C.calendars(); + for (const c of cals) { const evs = c.events.whose({_and: [{startDate: {_greaterThan: from}}, {startDate: {_lessThan: to}}]})(); + for (const e of evs) out.push({id: e.uid(), calendar: c.name(), title: e.summary(), start: iso(e.startDate()), end: iso(e.endDate()), allDay: e.alldayEvent(), location: e.location() || ''}); } + out.sort((x, y) => x.start < y.start ? -1 : 1); return JSON.stringify(out.slice(0, 200));"#, &a) + .map(|o| format!("行事曆:{o}")) + } + "mobile_write_calendar" => { + let action = s(v, "action"); + if action != "add" && s(v, "id").is_empty() { + return Err("修改或刪除要給 id(先用 mobile_read_calendar 查)".into()); + } + if action == "add" && (s(v, "title").is_empty() || s(v, "start").is_empty()) { + return Err("新的行程要有 title 和 start".into()); + } + let a = json!({"action": action, "id": s(v, "id"), "title": s(v, "title"), "start": s(v, "start"), "end": s(v, "end"), "loc": s(v, "location"), "notes": s(v, "notes"), "cal": s(v, "calendar"), "allDay": v["allDay"].as_bool().unwrap_or(false)}); + call(r#"const C = Application('Calendar'); + function find(id) { for (const c of C.calendars()) { const m = c.events.whose({uid: id})(); if (m.length) return m[0]; } return null; } + if (a.action === 'add') { const cal = a.cal ? C.calendars.byName(a.cal) : C.calendars.whose({writable: true})()[0]; + const s = new Date(a.start); const e = a.end ? new Date(a.end) : new Date(s.getTime() + 3600000); + const props = {summary: a.title, startDate: s, endDate: e, alldayEvent: a.allDay}; if (a.loc) props.location = a.loc; if (a.notes) props.description = a.notes; + const ev = C.Event(props); cal.events.push(ev); return JSON.stringify({action: 'added', id: ev.uid(), calendar: cal.name()}); } + const ev = find(a.id); if (!ev) throw new Error('那個行程已經不在這台 Mac 上了'); const t = ev.summary(); + if (a.action === 'delete') { C.delete(ev); return JSON.stringify({action: 'deleted', title: t}); } + if (a.title) ev.summary = a.title; if (a.start) ev.startDate = new Date(a.start); if (a.end) ev.endDate = new Date(a.end); if (a.loc) ev.location = a.loc; if (a.notes) ev.description = a.notes; + return JSON.stringify({action: 'updated', title: ev.summary()});"#, &a) + } + "mobile_read_contacts" => { + let q = s(v, "query"); + if q.is_empty() { + return Err("請給要找的名字、電子郵件或電話".into()); + } + let a = json!({"q": q, "limit": v["limit"].as_u64().unwrap_or(10).min(CONTACT_BATCH as u64)}); + call(r#"const P = Application('Contacts'); const out = []; + let ps = P.people.whose({name: {_contains: a.q}})(); + if (!ps.length) ps = P.people().filter(p => p.emails().some(e => has(e.value(), a.q)) || p.phones().some(ph => has(String(ph.value()).replace(/\D/g, ''), a.q.replace(/\D/g, '')) && a.q.replace(/\D/g, '').length >= 4)); + for (const p of ps.slice(0, a.limit)) out.push({id: p.id(), name: p.name(), organization: p.organization() || '', emails: p.emails().map(e => e.value()), phones: p.phones().map(x => x.value())}); + return JSON.stringify(out);"#, &a) + .map(|o| format!("聯絡人:{o}")) + } + "mac_read_notes" => { + let a = json!({"id": s(v, "id"), "q": s(v, "query"), "folder": s(v, "folder"), "limit": v["limit"].as_u64().unwrap_or(20).min(100), "max": MAX_CHARS}); + call(r#"const N = Application('Notes'); + if (a.id) { const n = N.notes.byId(a.id); return JSON.stringify({id: n.id(), title: n.name(), modified: iso(n.modificationDate()), body: clip(n.plaintext(), a.max)}); } + const src = a.folder ? N.folders.byName(a.folder).notes : N.notes; + const ns = a.q ? src.whose({_or: [{name: {_contains: a.q}}, {plaintext: {_contains: a.q}}]})() : src(); + return JSON.stringify(ns.slice(0, a.limit).map(n => ({id: n.id(), title: n.name(), modified: iso(n.modificationDate()), preview: clip(n.plaintext(), 200)})));"#, &a) + } + "mac_write_notes" => { + let body = s(v, "body"); + if body.is_empty() { + return Err("mac_write_notes: body is required".into()); + } + let id = s(v, "id"); + if !id.is_empty() && v.get("currentTitle").and_then(Value::as_str).is_none() { + return Err("mac_write_notes: currentTitle is required with id".into()); + } + if id.is_empty() && s(v, "title").is_empty() { + return Err("mac_write_notes: a new note needs a title".into()); + } + let a = json!({"id": id, "cur": s(v, "currentTitle"), "title": s(v, "title"), "body": body, "folder": s(v, "folder")}); + call(r#"const N = Application('Notes'); + function html(t) { return t.split('\n').map(l => '
' + (l.replace(/&/g, '&').replace(//g, '>') || '
') + '
').join(''); } + if (a.id) { const n = N.notes.byId(a.id); if (n.name() !== a.cur) throw new Error('那則備忘錄的標題已經變了(現在是「' + n.name() + '」),請重新讀一次'); + n.body = n.body() + html(a.body); return JSON.stringify({action: 'appended', id: n.id(), title: n.name()}); } + const target = a.folder ? N.folders.byName(a.folder) : N.defaultAccount().defaultFolder(); + const n = N.Note({body: '

' + html(a.title).slice(5, -6) + '

' + html(a.body)}); target.notes.push(n); + return JSON.stringify({action: 'created', id: n.id(), title: a.title});"#, &a) + } + "mac_read_mail" => { + let a = json!({"id": s(v, "id"), "q": s(v, "query"), "mailbox": s(v, "mailbox"), "unread": v["unreadOnly"].as_bool().unwrap_or(false), "days": v["days"].as_u64().unwrap_or(0), "limit": v["limit"].as_u64().unwrap_or(20).min(100), "scan": MAIL_SCAN, "max": MAX_CHARS}); + call(r#"const M = Application('Mail'); + if (a.id) { for (const acc of M.accounts()) for (const mb of acc.mailboxes()) { const m = mb.messages.whose({id: Number(a.id)})(); if (m.length) { const x = m[0]; + return JSON.stringify({id: String(x.id()), subject: x.subject(), from: x.sender(), to: x.toRecipients().map(r => r.address()), date: iso(x.dateReceived()), body: clip(x.content(), a.max)}); } } + const m = M.inbox.messages.whose({id: Number(a.id)})(); if (!m.length) throw new Error('那封郵件已經不在這台 Mac 上了'); } + let box = M.inbox; if (a.mailbox) { for (const acc of M.accounts()) { const f = acc.mailboxes.whose({name: a.mailbox})(); if (f.length) { box = f[0]; break; } } } + const since = a.days ? new Date(Date.now() - a.days * 86400000) : null; + const msgs = box.messages(); const out = []; + for (let i = 0; i < Math.min(msgs.length, a.scan) && out.length < a.limit; i++) { const x = msgs[i]; + if (a.unread && x.readStatus()) continue; const d = x.dateReceived(); if (since && d < since) continue; + const subj = x.subject(), from = x.sender(); if (a.q && !has(subj + ' ' + from, a.q)) continue; + out.push({id: String(x.id()), subject: subj, from: from, date: iso(d), read: x.readStatus()}); } + return JSON.stringify(out);"#, &a) + .map(|o| format!("郵件:{o}")) + } + "mac_draft_mail" => { + let body = s(v, "body"); + if body.is_empty() { + return Err("mac_draft_mail: body is required".into()); + } + let to = list(v, "to"); + if to.is_empty() && s(v, "replyToId").is_empty() { + return Err("mac_draft_mail: a new email needs to".into()); + } + let a = json!({"to": to, "cc": list(v, "cc"), "subject": s(v, "subject"), "body": body, "reply": s(v, "replyToId")}); + call(r#"const M = Application('Mail'); M.activate(); let m; + if (a.reply) { let orig = null; for (const acc of M.accounts()) { for (const mb of acc.mailboxes()) { const f = mb.messages.whose({id: Number(a.reply)})(); if (f.length) { orig = f[0]; break; } } if (orig) break; } + if (!orig) throw new Error('找不到要回覆的郵件'); m = M.reply(orig, {openingWindow: true}); m.content = a.body + '\n\n' + m.content(); } + else { m = M.OutgoingMessage({subject: a.subject, content: a.body, visible: true}); M.outgoingMessages.push(m); + for (const t of a.to) m.toRecipients.push(M.Recipient({address: t})); for (const c of a.cc) m.ccRecipients.push(M.CcRecipient({address: c})); } + return JSON.stringify({action: 'drafted', subject: m.subject(), note: '草稿已在「郵件」打開,使用者檢查後可以自己寄出。'});"#, &a) + } + "mac_send_mail" => { + let to = list(v, "to"); + if to.is_empty() || s(v, "body").is_empty() { + return Err("寄信要有 to 和 body".into()); + } + let a = json!({"to": to, "cc": list(v, "cc"), "subject": s(v, "subject"), "body": s(v, "body"), "account": s(v, "account")}); + call(r#"const M = Application('Mail'); + const m = M.OutgoingMessage({subject: a.subject, content: a.body, visible: false}); M.outgoingMessages.push(m); + if (a.account) m.sender = a.account; + for (const t of a.to) m.toRecipients.push(M.Recipient({address: t})); for (const c of a.cc) m.ccRecipients.push(M.CcRecipient({address: c})); + m.send(); return JSON.stringify({action: 'sent', to: a.to, subject: a.subject});"#, &a) + } + "mac_mail_accounts" => call(r#"const M = Application('Mail'); return JSON.stringify(M.accounts().map(x => ({name: x.name(), addresses: x.emailAddresses(), enabled: x.enabled()})));"#, &json!({})), + SEND_MESSAGE => { + let (to, text) = (s(v, "to"), s(v, "text")); + if to.is_empty() || text.is_empty() { + return Err("要給 to(電話或電子郵件)和 text".into()); + } + let a = json!({"to": to, "text": text}); + call(r#"const Msg = Application('Messages'); + let svc = Msg.services.whose({serviceType: 'iMessage'})(); if (!svc.length) svc = Msg.services(); + const buddy = svc[0].buddies.byName(a.to); + Msg.send(a.text, {to: buddy}); return JSON.stringify({action: 'sent', to: a.to});"#, &a) + } + other => Err(format!("This Mac has no \"{other}\" tool.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_and_sends_are_told_apart() { + assert!(is_write("mac_write_notes") && is_write("mac_draft_mail") && is_write("ios_write_reminders")); + assert!(!is_write("mac_read_mail")); + assert!(is_send("mac_send_mail") && is_send(SEND_MESSAGE)); + assert_eq!(app_of("mobile_read_contacts"), "聯絡人"); + assert_eq!(specs().len(), NAMES.len() + 1); + } + + #[test] + fn input_rules_match_the_original() { + assert!(run_inner("mac_write_notes", &json!({"body": "x"})).unwrap_err().contains("needs a title")); + assert!(run_inner("mac_write_notes", &json!({"id": "x", "body": "x"})).unwrap_err().contains("currentTitle")); + assert!(run_inner("mac_draft_mail", &json!({"body": "x"})).unwrap_err().contains("needs to")); + assert!(run_inner("mac_draft_mail", &json!({"to": ["a@b.c"]})).unwrap_err().contains("body is required")); + } + + /// `cargo test --release -- --ignored mac_apps_read --nocapture` (macOS asks for Automation once). + #[test] + #[ignore] + fn mac_apps_read() { + for (n, a) in [("ios_read_reminders", "{}"), ("mobile_read_calendar", r#"{"from":"2026-11-01","to":"2026-11-30"}"#), ("mac_read_notes", r#"{"limit":3}"#), ("mac_mail_accounts", "{}")] { + let (o, ok) = run(n, a); + println!("{n}: ok={ok} {}", o.chars().take(300).collect::()); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..98dc1c2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,233 @@ +mod agent; +mod app; +mod appctl; +mod attach; +mod axread; +mod avatar; +mod botbrowser; +mod browser; +mod cloudbox; +mod channels; +mod computer; +mod diagram; +mod platform; +mod mic; +#[cfg(target_os = "linux")] +mod x11; +#[cfg(target_os = "linux")] +mod wl; +#[cfg(target_os = "linux")] +mod portal; +#[cfg(target_os = "linux")] +mod desk; +mod egress; +mod httpd; +mod i18n; +mod i18n_table; +mod icons; +mod macapps; +mod mcp; +mod memory; +mod model; +mod notify; +mod phone; +mod policy; +mod preview; +mod providers; +mod remote; +mod routines; +mod teach; +mod share; +mod teams; +mod theme; +mod tools; +mod triggers; +mod vault; +mod vault_store; +mod voice; +mod web; + +use eframe::egui; +use std::path::PathBuf; + +/// PingFang ships as a downloadable system asset on macOS; its folder name varies per Mac. +fn pingfang_path() -> Option { + let root = std::path::Path::new("/System/Library/AssetsV2"); + for set in std::fs::read_dir(root).ok()?.flatten() { + if !set.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { + continue; + } + for asset in std::fs::read_dir(set.path()).into_iter().flatten().flatten() { + let p = asset.path().join("AssetData/PingFang.ttc"); + if p.exists() { + return Some(p); + } + } + } + None +} + +/// PingFang TC Regular inside PingFang.ttc. +const PINGFANG_TC_INDEX: u32 = 2; + +/// The window's first size (screenshot mode can ask for another, e.g. ANYBOT_SHOT_SIZE=760x560). +fn shot_size() -> [f32; 2] { + std::env::var("ANYBOT_SHOT_SIZE").ok().and_then(|v| { + let (w, h) = v.split_once('x')?; + Some([w.parse().ok()?, h.parse().ok()?]) + }).unwrap_or([1180.0, 780.0]) +} + +/// 粉圓體, bundled. +pub const HUNINN: &[u8] = include_bytes!("../fonts/jf-openhuninn-2.1.ttf"); + +/// "huninn" (粉圓體, the default) or "system". +pub fn font_choice() -> String { + model::State::path().parent().and_then(|d| std::fs::read_to_string(d.join("font.txt")).ok()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "huninn".into()) +} + +pub fn load_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + // One font for Chinese *and* English: mixing egui's built-in Latin font with a Chinese + // fallback put the two on different baselines and sizes ("Bot 設定" looked broken). + // So a CJK font that has good Latin letters goes first; egui's own fonts stay behind it + // only for symbols and emoji it lacks. + let mut candidates: Vec<(PathBuf, u32)> = pingfang_path().map(|p| vec![(p, PINGFANG_TC_INDEX)]).unwrap_or_default(); + candidates.extend( + [ + ("/System/Library/Fonts/Hiragino Sans GB.ttc", 0), + ("/System/Library/Fonts/STHeiti Medium.ttc", 0), + ("/System/Library/Fonts/Supplemental/Arial Unicode.ttf", 0), + ("C:\\Windows\\Fonts\\msjh.ttc", 0), + ("C:\\Windows\\Fonts\\msyh.ttc", 0), + // Linux: Noto Sans CJK (face 3 = Traditional Chinese) on Debian/Ubuntu, Arch, Fedora; then WenQuanYi. + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 3), + ("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 3), + ("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 3), + ("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0), + ] + .into_iter() + .map(|(p, i)| (PathBuf::from(p), i)), + ); + // Code: the original's `--cursor-font-family-mono` list is ui-monospace, SFMono-Regular, Menlo… + for path in ["/System/Library/Fonts/SFNSMono.ttf", "/System/Library/Fonts/Menlo.ttc"] { + if let Ok(bytes) = std::fs::read(path) { + fonts.font_data.insert("mono".into(), egui::FontData::from_owned(bytes).into()); + fonts.families.entry(egui::FontFamily::Monospace).or_default().insert(0, "mono".into()); + break; + } + } + for (path, index) in candidates { + if let Ok(bytes) = std::fs::read(&path) { + let mut data = egui::FontData::from_owned(bytes); + data.index = index; + fonts.font_data.insert("cjk".into(), data.into()); + fonts.families.entry(egui::FontFamily::Proportional).or_default().insert(0, "cjk".into()); + // Code stays in the monospace font; Chinese inside code falls back to the same font. + fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk".into()); + break; + } + } + // 粉圓體 (jf open 粉圓, SIL OFL 1.1, fonts/OFL.txt) goes first everywhere it has the letter; + // the system font above fills in what it lacks (some simplified characters, symbols). + if font_choice() == "huninn" { + fonts.font_data.insert("huninn".into(), egui::FontData::from_static(HUNINN).into()); + fonts.families.entry(egui::FontFamily::Proportional).or_default().insert(0, "huninn".into()); + let mono = fonts.families.entry(egui::FontFamily::Monospace).or_default(); + let at = mono.iter().position(|f| f == "cjk").unwrap_or(mono.len()); + mono.insert(at, "huninn".into()); + } + ctx.set_fonts(fonts); +} + +/// The window icon (Linux taskbars and window switchers; macOS uses the app bundle's). +fn app_icon() -> egui::IconData { + let svg = include_str!("../icons/anybot.svg"); + let px = 256u32; + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("icon svg"); + let mut pm = resvg::tiny_skia::Pixmap::new(px, px).expect("icon pixmap"); + let s = px as f32 / tree.size().width(); + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(s, s), &mut pm.as_mut()); + // tiny-skia is premultiplied; IconData wants straight alpha. + let mut rgba = pm.take(); + for c in rgba.chunks_exact_mut(4) { + let a = c[3] as u32; + if a > 0 && a < 255 { + for v in &mut c[..3] { + *v = ((*v as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + egui::IconData { rgba, width: px, height: px } +} + +/// `anybot --write-icon out.png [size]`: the app icon as a PNG (for Linux packages). +fn write_icon(out: &str, px: u32) -> Result<(), String> { + let icon = app_icon(); + let img = image::RgbaImage::from_raw(icon.width, icon.height, icon.rgba).ok_or("icon")?; + let img = if px != icon.width { image::imageops::resize(&img, px, px, image::imageops::FilterType::Lanczos3) } else { img }; + img.save(out).map_err(|e| e.to_string()) +} + +fn main() -> eframe::Result<()> { + let args: Vec = std::env::args().collect(); + let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from); + if args.get(1).map(String::as_str) == Some("--write-icon") { + let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into()); + let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256); + if let Err(e) = write_icon(&out, px) { + eprintln!("{e}"); + std::process::exit(1); + } + return Ok(()); + } + // For testing on a new system: `anybot --tool computer_use_screenshot '{}' computer_use_click + // '{"x":10,"y":20}' …` runs computer tools in order (one process, so the screenshot's scale + // carries over), prints what the bot would hear, and saves screenshots as anybot-tool-N.jpg + // in the temp folder. + if args.get(1).map(String::as_str) == Some("--tool") { + let mut all_ok = true; + for (i, pair) in args[2..].chunks(2).enumerate() { + let json = pair.get(1).cloned().unwrap_or_else(|| "{}".into()); + let (text, ok, img) = computer::run(&pair[0], &json); + println!("{} {}: {text}", pair[0], if ok { "ok" } else { "failed" }); + if let Some(b64) = img { + use base64::Engine; + let out = std::env::temp_dir().join(format!("anybot-tool-{i}.jpg")); + let _ = std::fs::write(&out, base64::engine::general_purpose::STANDARD.decode(b64).unwrap_or_default()); + println!(" saved {}", out.display()); + } + all_ok &= ok; + std::thread::sleep(std::time::Duration::from_millis(300)); + } + std::process::exit(if all_ok { 0 } else { 1 }); + } + if args.get(1).map(String::as_str) == Some("--ask") { + let text = args[2..].join(" "); + let ctx = egui::Context::default(); + app::App::ask_cli(&ctx, &text); + } + // No window: keep the bots working and answer the phone (always-on Mac or server). + if args.iter().any(|a| a == "--serve") { + let ctx = egui::Context::default(); + app::App::serve(&ctx); + } + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("AnyBot") + .with_inner_size(shot_size()) + .with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0]) + .with_app_id("anybot") + .with_icon(app_icon()), + ..Default::default() + }; + eframe::run_native( + "AnyBot", + options, + Box::new(move |cc| { + load_fonts(&cc.egui_ctx); + Ok(Box::new(app::App::new(&cc.egui_ctx, screenshot))) + }), + ) +} diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..3fd4f9d --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,437 @@ +// MCP connectors: the bot can use the tools of Model Context Protocol servers (the original's +// local-exec-daemon has an MCP client with its own permission layer: shouldBlockMcp / +// isMcpFullyAllowlisted, and per-server custom instructions). Here: stdio servers (a command +// AnyBot starts) and HTTP servers (Streamable HTTP, JSON or SSE answers). + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{channel, Receiver}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +pub const PROTOCOL: &str = "2025-06-18"; +/// Longest wait for one answer (estimated). +pub const CALL_TIMEOUT_SECS: u64 = 120; +/// Tool names the model sees: mcp____, at most 64 characters. +pub const PREFIX: &str = "mcp__"; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Server { + pub name: String, + /// stdio: the command; empty when `url` is used. + #[serde(default)] + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: BTreeMap, + /// HTTP (Streamable HTTP) address. + #[serde(default)] + pub url: String, + /// e.g. "Authorization: Bearer …" for HTTP servers. + #[serde(default)] + pub headers: BTreeMap, + #[serde(default = "yes")] + pub enabled: bool, + /// Notes for the bot on how to use this server (the original's MCP custom instructions). + #[serde(default)] + pub instructions: String, +} + +fn yes() -> bool { + true +} + +#[derive(Clone, Debug)] +pub struct ToolInfo { + pub server: String, + pub name: String, + pub description: String, + pub schema: Value, +} + +enum Link { + Stdio { child: Child, stdin: ChildStdin, rx: Receiver }, + Http { url: String, headers: BTreeMap, session: Option }, +} + +struct Conn { + link: Link, + next_id: u64, + tools: Vec, + config: Server, +} + +fn conns() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn safe(s: &str) -> String { + s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect() +} + +/// A server's part of tool names: its name when it's plain ASCII, else a short stable code. +pub fn server_key(server: &str) -> String { + if !server.is_empty() && server.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ') { + return safe(server); + } + let h = server.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)); + format!("s{:06x}", h & 0xff_ffff) +} + +pub fn tool_name(server: &str, tool: &str) -> String { + let n = format!("{PREFIX}{}__{}", server_key(server), safe(tool)); + n.chars().take(64).collect() +} + +pub fn is_mcp_tool(name: &str) -> bool { + name.starts_with(PREFIX) +} + +fn send_stdio(stdin: &mut ChildStdin, msg: &Value) -> Result<(), String> { + let mut line = msg.to_string(); + line.push('\n'); + stdin.write_all(line.as_bytes()).and_then(|_| stdin.flush()).map_err(|e| format!("送不出去:{e}")) +} + +/// Read the answer with this id (skipping notifications and requests from the server). +fn wait_id(rx: &Receiver, id: u64, secs: u64) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(secs); + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return Err("MCP 伺服器沒有回應".into()); + } + match rx.recv_timeout(left) { + Ok(v) if v["id"].as_u64() == Some(id) && v.get("method").is_none() => { + if let Some(e) = v.get("error") { + return Err(format!("MCP 錯誤:{}", e["message"].as_str().unwrap_or(&e.to_string()))); + } + return Ok(v["result"].clone()); + } + Ok(_) => continue, + Err(_) => return Err("MCP 伺服器沒有回應或已經結束".into()), + } + } +} + +/// Answers from Streamable HTTP: plain JSON, or SSE with `data:` lines. +pub fn parse_http_answer(body: &str, id: u64) -> Result { + let candidates: Vec = if body.trim_start().starts_with('{') || body.trim_start().starts_with('[') { + match serde_json::from_str::(body) { + Ok(Value::Array(a)) => a, + Ok(v) => vec![v], + Err(e) => return Err(e.to_string()), + } + } else { + body.lines().filter_map(|l| l.strip_prefix("data:")).filter_map(|d| serde_json::from_str(d.trim()).ok()).collect() + }; + for v in candidates { + if v["id"].as_u64() == Some(id) { + if let Some(e) = v.get("error") { + return Err(format!("MCP 錯誤:{}", e["message"].as_str().unwrap_or(&e.to_string()))); + } + return Ok(v["result"].clone()); + } + } + Err("MCP 伺服器沒有給答案".into()) +} + +impl Conn { + fn request(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + match &mut self.link { + Link::Stdio { stdin, rx, .. } => { + send_stdio(stdin, &msg)?; + wait_id(rx, id, CALL_TIMEOUT_SECS) + } + Link::Http { url, headers, session } => { + let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(CALL_TIMEOUT_SECS))).http_status_as_error(false).build().into(); + let mut r = agent.post(url.as_str()).header("Content-Type", "application/json").header("Accept", "application/json, text/event-stream").header("MCP-Protocol-Version", PROTOCOL); + for (k, v) in headers.iter() { + r = r.header(k.as_str(), v.as_str()); + } + if let Some(s) = session.as_ref() { + r = r.header("Mcp-Session-Id", s.as_str()); + } + let mut resp = r.send(msg.to_string().as_bytes()).map_err(|e| format!("連不上 MCP 伺服器:{e}"))?; + if let Some(s) = resp.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()) { + *session = Some(s.to_string()); + } + let st = resp.status().as_u16(); + let body = resp.body_mut().read_to_string().unwrap_or_default(); + if !(200..300).contains(&st) { + return Err(format!("MCP 伺服器回 HTTP {st}:{}", body.chars().take(200).collect::())); + } + parse_http_answer(&body, id) + } + } + } + + fn notify(&mut self, method: &str) { + let msg = json!({"jsonrpc": "2.0", "method": method}); + match &mut self.link { + Link::Stdio { stdin, .. } => { + let _ = send_stdio(stdin, &msg); + } + Link::Http { url, headers, session } => { + let mut r = ureq::post(url.as_str()).header("Content-Type", "application/json").header("Accept", "application/json, text/event-stream"); + for (k, v) in headers.iter() { + r = r.header(k.as_str(), v.as_str()); + } + if let Some(s) = session.as_ref() { + r = r.header("Mcp-Session-Id", s.as_str()); + } + let _ = r.send(msg.to_string().as_bytes()); + } + } + } +} + +fn connect(s: &Server) -> Result { + let link = if !s.url.trim().is_empty() { + Link::Http { url: s.url.trim().to_string(), headers: s.headers.clone(), session: None } + } else { + if s.command.trim().is_empty() { + return Err("請給指令或網址".into()); + } + let mut child = Command::new(s.command.trim()) + .args(&s.args) + .envs(&s.env) + .env("PATH", crate::cloudbox::tool_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("無法啟動「{}」:{e}", s.command))?; + let stdin = child.stdin.take().ok_or("沒有標準輸入")?; + let stdout = child.stdout.take().ok_or("沒有標準輸出")?; + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Ok(v) = serde_json::from_str::(&line) { + if tx.send(v).is_err() { + break; + } + } + } + }); + Link::Stdio { child, stdin, rx } + }; + let mut c = Conn { link, next_id: 1, tools: vec![], config: s.clone() }; + c.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?; + c.notify("notifications/initialized"); + let mut tools = vec![]; + let mut cursor: Option = None; + for _ in 0..20 { + let r = c.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?; + for t in r["tools"].as_array().into_iter().flatten() { + tools.push(ToolInfo { server: s.name.clone(), name: t["name"].as_str().unwrap_or("").into(), description: t["description"].as_str().unwrap_or("").into(), schema: t["inputSchema"].clone() }); + } + cursor = r["nextCursor"].as_str().map(String::from); + if cursor.is_none() { + break; + } + } + c.tools = tools; + Ok(c) +} + +/// Start (or keep) the enabled servers; stop the rest. Returns errors per server. +pub fn sync(servers: &[Server]) -> Vec<(String, String)> { + let mut errs = vec![]; + let mut map = conns().lock().unwrap(); + let wanted: Vec<&Server> = servers.iter().filter(|s| s.enabled).collect(); + map.retain(|name, c| { + let keep = wanted.iter().any(|s| &s.name == name && **s == c.config); + if !keep { + if let Link::Stdio { child, .. } = &mut c.link { + let _ = child.kill(); + } + } + keep + }); + for s in wanted { + if map.contains_key(&s.name) { + continue; + } + match connect(s) { + Ok(c) => { + map.insert(s.name.clone(), c); + } + Err(e) => errs.push((s.name.clone(), e)), + } + } + errs +} + +/// The connected servers' tools. +pub fn tools() -> Vec { + conns().lock().unwrap().values().flat_map(|c| c.tools.clone()).collect() +} + +pub fn status() -> HashMap { + conns().lock().unwrap().iter().map(|(k, c)| (k.clone(), c.tools.len())).collect() +} + +/// Tool specs for the model. +pub fn specs() -> Vec { + tools() + .into_iter() + .map(|t| { + let schema = if t.schema.is_object() { t.schema.clone() } else { json!({"type": "object", "properties": {}}) }; + json!({"name": tool_name(&t.server, &t.name), "description": format!("[{}] {}", t.server, t.description.chars().take(900).collect::()), "parameters": schema}) + }) + .collect() +} + +/// Instructions the servers' owners wrote for the bot. +pub fn instructions(servers: &[Server]) -> String { + servers.iter().filter(|s| s.enabled && !s.instructions.trim().is_empty()).map(|s| format!("- {}:{}", s.name, s.instructions.trim())).collect::>().join("\n") +} + +/// Which server and tool a model-side name means. +pub fn resolve(name: &str) -> Option<(String, String)> { + tools().into_iter().find(|t| tool_name(&t.server, &t.name) == name).map(|t| (t.server, t.name)) +} + +/// Call a tool. Returns (text, ok, first picture as base64). +pub fn call(name: &str, args: &str) -> (String, bool, Option) { + let Some((server, tool)) = resolve(name) else { return (format!("沒有「{name}」這個 MCP 工具(伺服器可能已關閉)"), false, None) }; + let arguments: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(json!({})); + let mut map = conns().lock().unwrap(); + let Some(c) = map.get_mut(&server) else { return ("MCP 伺服器沒有連線".into(), false, None) }; + match c.request("tools/call", json!({"name": tool, "arguments": arguments})) { + Ok(r) => { + let mut text = String::new(); + let mut image = None; + for part in r["content"].as_array().into_iter().flatten() { + match part["type"].as_str() { + Some("text") => { + text.push_str(part["text"].as_str().unwrap_or("")); + text.push('\n'); + } + Some("image") if image.is_none() => image = part["data"].as_str().map(String::from), + Some("resource") => { + text.push_str(part["resource"]["text"].as_str().unwrap_or("")); + text.push('\n'); + } + _ => {} + } + } + if text.trim().is_empty() && r.get("structuredContent").is_some() { + text = r["structuredContent"].to_string(); + } + let ok = r["isError"].as_bool() != Some(true); + (crate::tools::head_tail(text.trim()), ok, image) + } + Err(e) => (e, false, None), + } +} + +pub fn shutdown() { + for (_, c) in conns().lock().unwrap().iter_mut() { + if let Link::Stdio { child, .. } = &mut c.link { + let _ = child.kill(); + } + } +} + +/// 連接器市集: (name, what it does, command or address, key to fill in — empty when none). +pub const MARKET: [(&str, &str, &str, &str); 8] = [ + ("files", "讀寫你指定資料夾裡的檔案", "npx -y @modelcontextprotocol/server-filesystem ~/Documents", ""), + ("memory", "長期記憶:把人、事、關係記成知識圖", "npx -y @modelcontextprotocol/server-memory", ""), + ("thinking", "一步步拆解難題", "npx -y @modelcontextprotocol/server-sequential-thinking", ""), + ("fetch", "抓網頁內容轉成文字", "uvx mcp-server-fetch", ""), + ("git", "查看和操作 Git 版本庫", "uvx mcp-server-git", ""), + ("time", "時間和時區換算", "uvx mcp-server-time", ""), + ("playwright", "用瀏覽器自動化操作網頁", "npx -y @playwright/mcp@latest", ""), + ("github", "GitHub 的 Issue、PR、程式碼(官方遠端伺服器)", "https://api.githubcopilot.com/mcp/", "Authorization: Bearer 你的 GitHub Token"), +]; + +/// A marketplace entry as a server (home folder spelled out). +pub fn market_server(name: &str, cmd: &str) -> Server { + let parts: Vec = cmd.split_whitespace().map(|w| if w == "~/Documents" { crate::tools::expand_home(w).display().to_string() } else { w.to_string() }).collect(); + Server { name: name.to_string(), command: parts[0].clone(), args: parts[1..].to_vec(), ..serde_json::from_str("{\"name\":\"x\"}").unwrap() } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAKE_SERVER: &str = r#" +import sys, json +for line in sys.stdin: + m = json.loads(line) + if "id" not in m: + continue + if m["method"] == "initialize": + r = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "fake"}} + elif m["method"] == "tools/list": + r = {"tools": [{"name": "add", "description": "Add two numbers", "inputSchema": {"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}}}]} + elif m["method"] == "tools/call": + a = m["params"]["arguments"] + r = {"content": [{"type": "text", "text": "sum=%d" % (a["a"] + a["b"])}]} + else: + r = {} + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": m["id"], "result": r}) + "\n") + sys.stdout.flush() +"#; + + #[test] + fn stdio_server_round_trip() { + let dir = std::env::temp_dir().join(format!("anybot-mcp-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("server.py"); + std::fs::write(&script, FAKE_SERVER).unwrap(); + let s = Server { name: "計算機".into(), command: "python3".into(), args: vec!["-I".into(), script.display().to_string()], env: BTreeMap::new(), url: String::new(), headers: BTreeMap::new(), enabled: true, instructions: "用來算數學".into() }; + let errs = sync(std::slice::from_ref(&s)); + assert!(errs.is_empty(), "{errs:?}"); + let name = tool_name("計算機", "add"); + assert!(name.starts_with("mcp__s") && name.ends_with("__add"), "{name}"); + assert_eq!(tool_name("github", "list issues"), "mcp__github__list_issues"); + assert!(specs().iter().any(|t| t["name"] == name.as_str())); + let (out, ok, _) = call(&name, r#"{"a": 2, "b": 40}"#); + assert!(ok && out.contains("sum=42"), "{out}"); + assert!(instructions(std::slice::from_ref(&s)).contains("用來算數學")); + sync(&[]); + assert!(tools().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn http_answers_json_or_sse() { + assert_eq!(parse_http_answer(r#"{"jsonrpc":"2.0","id":3,"result":{"ok":1}}"#, 3).unwrap()["ok"], 1); + let sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"tools\":[]}}\n\n"; + assert!(parse_http_answer(sse, 4).unwrap()["tools"].is_array()); + assert!(parse_http_answer(r#"{"jsonrpc":"2.0","id":5,"error":{"message":"nope"}}"#, 5).unwrap_err().contains("nope")); + } + + /// Downloads and starts every marketplace connector that needs no key (needs the network, + /// Node.js and uv): cargo test --release -- --ignored marketplace --nocapture + #[test] + #[ignore] + fn marketplace_connectors_start() { + let servers: Vec = MARKET.iter().filter(|m| m.3.is_empty()).map(|m| market_server(m.0, m.2)).collect(); + let errors = sync(&servers); + let st = status(); + for s in &servers { + println!("{:<12} {:?}", s.name, st.get(&s.name)); + } + assert!(errors.is_empty(), "{errors:?}"); + for s in &servers { + assert!(st.get(&s.name).copied().unwrap_or(0) > 0, "{} has no tools", s.name); + } + // And one real call through the bot's tool name. + let (out, ok, _) = call(&tool_name("time", "get_current_time"), r#"{"timezone":"Asia/Taipei"}"#); + println!("time: {out}"); + assert!(ok && out.contains("Asia/Taipei"), "{out}"); + shutdown(); + } +} diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 0000000..9600cec --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,107 @@ +// 記憶、技能、範本 (the original: user bot memories, agent skills and template skills/bots). +// - Memories belong to a bot: facts it was asked to keep across chats. +// - Skills are shared, named procedures; the bot sees their names and loads one when it needs it. +// - Templates start a new bot with a ready role and instructions. + +use serde::{Deserialize, Serialize}; + +/// Most memories a bot keeps; the oldest go first (estimated). +pub const MAX_MEMORIES: usize = 200; +/// Longest memory (estimated). +pub const MAX_MEMORY_CHARS: usize = 500; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Skill { + pub name: String, + pub description: String, + pub instructions: String, +} + +pub struct Template { + pub name: &'static str, + pub description: &'static str, + pub instructions: &'static str, + pub shape: &'static str, + pub color: &'static str, +} + +pub const TEMPLATES: [Template; 5] = [ + Template { name: "研究助理", description: "上網找資料、整理重點、附上來源", instructions: "先釐清問題,再用 web_fetch 或瀏覽器找至少兩個可靠來源。回答先給結論,再列重點,每個重點附來源網址。不確定的地方直接說不確定。", shape: "circle", color: "green" }, + Template { name: "寫作夥伴", description: "幫你構思、改寫和潤稿", instructions: "先問清楚讀者和語氣。改稿時保留作者的意思,列出改了什麼和為什麼。用繁體中文、台灣用語。", shape: "cloud", color: "purple" }, + Template { name: "電腦助手", description: "在 Bot 的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在「Bot 的電腦」上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" }, + Template { name: "客服助理", description: "在 Slack、Discord、Telegram 或 Teams 回覆客人", instructions: "回答簡短、有禮貌。不確定的事不要編,說會請同事確認。不要透露內部資料或使用者的私人資訊。", shape: "pill", color: "orange" }, + Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" }, +]; + +/// Add a memory (no duplicates; the oldest drop off past the limit). +pub fn remember(list: &mut Vec, fact: &str) -> Result { + let f: String = fact.trim().chars().take(MAX_MEMORY_CHARS).collect(); + if f.is_empty() { + return Err("要記住什麼?".into()); + } + if list.iter().any(|m| m == &f) { + return Ok(format!("已經記得:{f}")); + } + list.push(f.clone()); + let n = list.len(); + if n > MAX_MEMORIES { + list.drain(..n - MAX_MEMORIES); + } + Ok(format!("記住了:{f}")) +} + +/// Forget by number (1-based) or by text it contains. +pub fn forget(list: &mut Vec, what: &str) -> Result { + let w = what.trim(); + let idx = match w.parse::() { + Ok(n) if n >= 1 && n <= list.len() => Some(n - 1), + _ => list.iter().position(|m| !w.is_empty() && m.contains(w)), + }; + match idx { + Some(i) => Ok(format!("忘記了:{}", list.remove(i))), + None => Err(format!("沒有記得「{w}」這件事")), + } +} + +pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String { + let mut s = String::new(); + if !memories.is_empty() { + s.push_str("\n\n你記得的事(使用者要你記住的;用 remember 新增、forget 刪除):\n"); + for (i, m) in memories.iter().enumerate() { + s.push_str(&format!("{}. {m}\n", i + 1)); + } + } else { + s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。"); + } + if !skills.is_empty() { + s.push_str("\n\n可用的技能(做法說明;需要時先用 use_skill 讀取完整內容再照做):\n"); + for k in skills { + s.push_str(&format!("- {}:{}\n", k.name, k.description)); + } + } + s.push_str("\n使用者教你一套可以重複用的做法時,可以用 save_skill 存成技能,所有 Bot 都能用。"); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remember_and_forget() { + let mut m = vec![]; + assert!(remember(&mut m, "我喜歡靠窗的座位").unwrap().starts_with("記住了")); + assert!(remember(&mut m, "我喜歡靠窗的座位").unwrap().starts_with("已經記得")); + remember(&mut m, "週五不開會").unwrap(); + assert_eq!(forget(&mut m, "1").unwrap(), "忘記了:我喜歡靠窗的座位"); + assert!(forget(&mut m, "不存在").is_err()); + assert_eq!(forget(&mut m, "週五").unwrap(), "忘記了:週五不開會"); + for i in 0..(MAX_MEMORIES + 5) { + remember(&mut m, &format!("事 {i}")).unwrap(); + } + assert_eq!(m.len(), MAX_MEMORIES); + assert_eq!(m[0], "事 5"); + let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into() }]); + assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度")); + } +} diff --git a/src/mic.rs b/src/mic.rs new file mode 100644 index 0000000..012b794 --- /dev/null +++ b/src/mic.rs @@ -0,0 +1,174 @@ +// The microphone on Linux (and other systems without macOS's recorder): cpal reads the default +// input, mixes it to mono, and writes a 16-bit WAV. The same two ways as voice.rs's macOS +// scripts: record until told to stop, or listen for one spoken turn (stops after a pause, gives +// up when nobody speaks). + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Listening rules: speech louder than `speech_db`; `quiet_ticks` × 0.1 s of quiet after speech +/// ends the turn; nothing heard for `wait_ticks` × 0.1 s gives up. +#[derive(Clone, Copy)] +pub struct Listen { + pub speech_db: f64, + pub quiet_ticks: u32, + pub wait_ticks: u32, +} + +pub struct Recorder { + stop: Arc, + handle: Option>, +} + +/// Start recording into `file` (WAV). The thread answers like the macOS script: "ok", +/// "nothing" (no speech heard), "fail" (no microphone). +pub fn start(file: PathBuf, listen: Option, max_secs: u64) -> Recorder { + let stop = Arc::new(AtomicBool::new(false)); + let s = stop.clone(); + let handle = std::thread::spawn(move || run(file, listen, max_secs, s)); + Recorder { stop, handle: Some(handle) } +} + +impl Recorder { + pub fn done(&self) -> bool { + self.handle.as_ref().map(|h| h.is_finished()).unwrap_or(true) + } + + /// Stop and wait for the file (at most 4 s). + pub fn finish(&mut self) -> Option<&'static str> { + self.stop.store(true, Ordering::SeqCst); + let t = std::time::Instant::now(); + while !self.done() && t.elapsed() < Duration::from_secs(4) { + std::thread::sleep(Duration::from_millis(30)); + } + if self.done() { + self.handle.take().and_then(|h| h.join().ok()) + } else { + None + } + } +} + +/// Loudness of a block of samples in dBFS. +pub fn db(samples: &[f32]) -> f64 { + if samples.is_empty() { + return -160.0; + } + let rms = (samples.iter().map(|s| (*s as f64) * (*s as f64)).sum::() / samples.len() as f64).sqrt(); + if rms <= 1e-8 { -160.0 } else { 20.0 * rms.log10() } +} + +fn run(file: PathBuf, listen: Option, max_secs: u64, stop: Arc) -> &'static str { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + let host = cpal::default_host(); + let Some(device) = host.default_input_device() else { return "fail" }; + let Ok(cfg) = device.default_input_config() else { return "fail" }; + let rate = cfg.sample_rate(); + let channels = cfg.channels().max(1) as usize; + let buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let b = buf.clone(); + let mono = move |data: &[f32]| { + let mut g = b.lock().unwrap(); + for frame in data.chunks(channels) { + g.push(frame.iter().sum::() / frame.len() as f32); + } + }; + let err = |_| {}; + let stream = match cfg.sample_format() { + cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mono(d), err, None), + cpal::SampleFormat::I16 => { + let m = mono; + device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| m(&d.iter().map(|s| *s as f32 / 32768.0).collect::>()), err, None) + } + cpal::SampleFormat::I32 => { + let m = mono; + device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| m(&d.iter().map(|s| *s as f32 / 2_147_483_648.0).collect::>()), err, None) + } + cpal::SampleFormat::U8 => { + let m = mono; + device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| m(&d.iter().map(|s| (*s as f32 - 128.0) / 128.0).collect::>()), err, None) + } + _ => return "fail", + }; + let Ok(stream) = stream else { return "fail" }; + if stream.play().is_err() { + return "fail"; + } + let tick = (rate as usize / 10).max(1); + let (mut heard, mut quiet, mut seen) = (false, 0u32, 0usize); + let mut ticks = 0u32; + let mut outcome = "ok"; + while !stop.load(Ordering::SeqCst) && (ticks as u64) < max_secs * 10 { + std::thread::sleep(Duration::from_millis(100)); + ticks += 1; + if let Some(l) = listen { + let g = buf.lock().unwrap(); + while seen + tick <= g.len() { + let level = db(&g[seen..seen + tick]); + seen += tick; + if level > l.speech_db { + heard = true; + quiet = 0; + } else if heard { + quiet += 1; + } + } + drop(g); + if heard && quiet >= l.quiet_ticks { + break; + } + if !heard && ticks >= l.wait_ticks { + outcome = "nothing"; + break; + } + } + } + drop(stream); + let samples = std::mem::take(&mut *buf.lock().unwrap()); + if listen.is_some() && !heard { + outcome = "nothing"; + } + if std::fs::write(&file, wav(&samples, rate)).is_err() { + return "fail"; + } + outcome +} + +/// 16-bit mono PCM WAV. +pub fn wav(samples: &[f32], rate: u32) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut v = Vec::with_capacity(44 + data_len as usize); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + data_len).to_le_bytes()); + v.extend_from_slice(b"WAVEfmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&rate.to_le_bytes()); + v.extend_from_slice(&(rate * 2).to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&16u16.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + v.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()); + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wav_and_loudness() { + let w = wav(&[0.0, 0.5, -0.5], 16000); + assert_eq!(&w[0..4], b"RIFF"); + assert_eq!(w.len(), 44 + 6); + assert_eq!(u32::from_le_bytes([w[24], w[25], w[26], w[27]]), 16000); + assert!(db(&[0.0; 100]) < -100.0); + assert!((db(&[0.5; 100]) - (-6.02)).abs() < 0.1); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..05175e9 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,617 @@ +// Data kept between runs: bots, their chats, provider settings and look. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::providers::ProviderKind; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Default)] +pub enum Role { + #[default] + User, + Assistant, + /// The result of one tool call, sent back to the model. + Tool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Default)] +pub enum ToolStatus { + Waiting, + Running, + #[default] + Done, + Denied, + Failed, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct ToolCall { + pub id: String, + pub name: String, + /// The arguments as the model wrote them (JSON text). + pub args: String, + #[serde(default)] + pub status: ToolStatus, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct Message { + pub role: Role, + pub text: String, + #[serde(default)] + pub thinking: String, + /// Which provider and model wrote this reply (shown under it). + #[serde(default)] + pub via: String, + #[serde(default)] + pub error: bool, + /// Tools the model asked to run in this reply. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, + /// For Role::Tool: which call this answers. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub tool_call_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub tool_name: String, + /// Screenshots (base64 JPEG) shown to the model with this message. Not saved to disk. + #[serde(skip)] + pub images: Vec, + /// A summary standing in for older messages (context compaction). + #[serde(default)] + pub summary: bool, + /// Claude's own content blocks (thinking signatures, tool_use), replayed unchanged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocks: Vec, + /// A routine (or another bot) woke the bot up: starts a turn like a person's message. + #[serde(default)] + pub wake: bool, + /// Sent while the bot was working: steers the running turn instead of starting a new one. + #[serde(default)] + pub steer: bool, + /// In a group chat: which bot wrote this answer (0 = the chat's own bot). + #[serde(default, skip_serializing_if = "is_zero")] + pub author: u64, + /// 表情回應 the person put on this message. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reactions: Vec, + /// Files and pictures the person attached, or files the bot sent (kept on disk). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attachments: Vec, + /// When it was written (ms since 1970; 0 for messages from before this was kept). + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub at: i64, +} + +fn is_zero_i64(n: &i64) -> bool { + *n == 0 +} + +pub fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +/// How a turn ended: the original's outcomes `["success","cancelled","error"]` +/// (node-agent-coordinator `wl`, read back by `k6` as {turnId, outcome, settledAtMs}). +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum TurnOutcome { + Success, + Cancelled, + Error, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct TurnSettlement { + pub turn_id: String, + pub outcome: TurnOutcome, + pub settled_at_ms: i64, +} + +/// Another of the person's computers, reached over SSH. The other two computers are built in: +/// the bot's own (a sealed folder, like the original's cloud "box") and this Mac (only when +/// 在這台電腦上執行 is on). +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct RemoteComputer { + pub name: String, + /// e.g. `me@studio.local` + pub ssh_target: String, + #[serde(default)] + pub dir: String, +} + +fn is_zero(n: &u64) -> bool { + *n == 0 +} + +pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦"; +pub const THIS_COMPUTER_NAME: &str = "你的電腦"; + +impl Message { + pub fn user(text: &str) -> Self { + Message { role: Role::User, text: text.to_string(), at: now_ms(), ..Default::default() } + } + pub fn assistant(text: &str, via: &str) -> Self { + Message { role: Role::Assistant, text: text.to_string(), via: via.to_string(), at: now_ms(), ..Default::default() } + } + pub fn tool_result(id: &str, name: &str, output: &str) -> Self { + Message { role: Role::Tool, text: output.to_string(), tool_call_id: id.to_string(), tool_name: name.to_string(), at: now_ms(), ..Default::default() } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Bot { + pub id: u64, + pub name: String, + #[serde(default)] + pub description: String, + /// What the bot should keep in mind (sent as the system prompt). + #[serde(default)] + pub instructions: String, + pub shape: String, + pub color: String, + pub provider: ProviderKind, + pub model: String, + #[serde(default)] + pub pinned: bool, + /// 資料夾 in the sidebar (empty = 未分區), like the original's groups. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub folder: String, + #[serde(default)] + pub messages: Vec, + #[serde(default)] + pub unread: bool, + /// Let this bot search, read and edit files and run commands in `workdir`. + #[serde(default)] + pub tools_enabled: bool, + #[serde(default)] + pub workdir: String, + /// "Always allow" grants, e.g. `edit_file` or `shell:git commit`. + #[serde(default)] + pub allow_rules: Vec, + /// 群組聊天: the bots that answer here, in order (empty = an ordinary bot). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec, + /// 討論串: earlier conversations put aside with 「開始新對話」 (title, messages). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub threads: Vec<(String, Vec)>, + /// 記憶: facts this bot keeps across chats. + #[serde(default)] + pub memories: Vec, + /// 語音聊天: read this bot's answers aloud. + #[serde(default)] + pub speak_replies: bool, + /// 語音聊天 › 語音 (a macOS voice; empty = chosen by language), 速度 (1.0 = normal) and + /// 語言 (spoken language for speech-to-text; empty = detect). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub voice: String, + #[serde(default = "one_f32")] + pub voice_rate: f32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub voice_lang: String, + /// 示範教學: tasks the person showed this bot on the Mac. + #[serde(default)] + pub demos: Vec, + /// How much the model can read at once, for deciding when to summarize. + #[serde(default = "default_context_tokens")] + pub context_tokens: u64, + #[serde(default)] + pub routines: Vec, + #[serde(default)] + pub last_turn: Option, + /// Name of the computer tools use unless told otherwise. + #[serde(default = "default_computer")] + pub default_computer: String, +} + +fn one_f32() -> f32 { + 1.0 +} + +pub fn default_computer() -> String { + BOT_COMPUTER_NAME.into() +} + +pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)"; + +impl Bot { + /// A turn that was still running when the app closed can't continue: mark its empty + /// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat + /// can be retried instead of looking stuck. + pub fn repair_interrupted(&mut self) -> bool { + let mut changed = false; + for m in self.messages.iter_mut().filter(|m| m.role == Role::Assistant && !m.summary) { + for c in m.tool_calls.iter_mut() { + if matches!(c.status, ToolStatus::Waiting | ToolStatus::Running) { + c.status = ToolStatus::Denied; + changed = true; + } + } + if m.text.trim().is_empty() && m.tool_calls.is_empty() { + m.text = INTERRUPTED_NOTE.into(); + m.error = true; + changed = true; + } + } + if changed && self.last_turn.as_ref().map(|t| t.outcome != TurnOutcome::Error).unwrap_or(true) { + self.last_turn = Some(TurnSettlement { turn_id: "interrupted".into(), outcome: TurnOutcome::Error, settled_at_ms: chrono::Utc::now().timestamp_millis() }); + } + changed + } +} + +pub fn default_context_tokens() -> u64 { + crate::agent::DEFAULT_CONTEXT_TOKENS +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +pub enum Theme { + Light, + Dark, + System, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +pub enum Accent { + BotColor, + Black, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct ProviderSettings { + #[serde(default)] + pub api_key: String, + /// Empty means the provider's usual address. + #[serde(default)] + pub base_url: String, + /// Empty means the provider's suggested model. + #[serde(default)] + pub default_model: String, + /// Models fetched from the provider's model list. + #[serde(default)] + pub known_models: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct State { + pub bots: Vec, + pub primary_bot: u64, + pub selected: u64, + pub next_id: u64, + pub theme: Theme, + pub accent: Accent, + #[serde(default)] + pub providers: std::collections::BTreeMap, + #[serde(default)] + pub sidebar_collapsed: bool, + #[serde(default = "default_sidebar_width")] + pub sidebar_width: f32, + /// 允許在這台電腦上執行任務,而不只是在它自己的電腦上 (setting `local-execution`, ids fuUvu2/Hd1KB+). + #[serde(default)] + pub local_execution: bool, + #[serde(default)] + pub remote_computers: Vec, + /// 自動審核 with the person's natural-language rules ("先詢問" wins when they conflict). + #[serde(default)] + pub auto_review: bool, + #[serde(default)] + pub review_allow_rules: Vec, + #[serde(default)] + pub review_ask_rules: Vec, + /// Time zone for schedules and reminders (setting `timezone`, ids 40Gx0U/TO/JdP). + #[serde(default = "crate::routines::local_timezone")] + pub timezone: String, + #[serde(default = "first_routine_id")] + pub next_routine_id: u64, + /// 「Bot 完成任務或等你處理時通知你」(ttdOeI): banners when away. + #[serde(default = "yes")] + pub notify: bool, + /// 直接操作: one switch like Grok Bot's local-execution + auto-review; only safety checks and + /// "always ask" rules stop the bot. + #[serde(default)] + pub direct_mode: bool, + /// 「在你離開時自動更新用戶端」(4vprnG); on by default like the original. + #[serde(default = "yes")] + pub auto_update_when_away: bool, + /// 訊息管道: Slack / Discord / Telegram channels a bot answers. + #[serde(default)] + pub channels: Vec, + #[serde(default = "first_routine_id")] + pub next_channel_id: u64, + /// MCP connectors the bots can use. + #[serde(default)] + pub mcp_servers: Vec, + /// 技能 every bot can load. + #[serde(default)] + pub skills: Vec, + /// 手機與帳號: phone access, the relay account and backups. + #[serde(default)] + pub remote: crate::remote::Settings, + /// 分區 (sidebar sections) in their order, including empty ones. + #[serde(default)] + pub folders: Vec, + /// 你的電腦's label in 設定 → 電腦 (empty = the computer's own name). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub machine_label: String, + /// 在這台電腦上執行 = 永不允許 (bots never ask; local tools are refused). + #[serde(default)] + pub local_never: bool, + /// 訊息: bots may send iMessages / SMS from this Mac. + #[serde(default = "yes")] + pub messages_send: bool, + /// 訊息: send without the approval card each time. + #[serde(default)] + pub messages_no_ask: bool, + /// 已釘選的 Bot, in the order of the grid at the top of the sidebar. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pin_order: Vec, + /// Where 未分區 sits among the 分區 (None = last). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loose_index: Option, + /// Groups folded shut in the sidebar ("" = 未分區). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub folded: Vec, + /// 語言: "auto" (follow the Mac) or one of i18n::LANGS. + #[serde(default = "auto_lang")] + pub language: String, +} + +fn auto_lang() -> String { + "auto".into() +} + +/// "Always ask" rules that direct mode starts with (own wording). +pub const DEFAULT_ASK_RULES: [&str; 3] = ["刪除檔案或資料夾", "付款、購買或轉帳", "用我的帳號發文、留言,或傳訊息給別人"]; + +fn yes() -> bool { + true +} + +fn first_routine_id() -> u64 { + 1 +} + +fn default_sidebar_width() -> f32 { + crate::app::SIDEBAR_DEFAULT_WIDTH +} + +impl State { + /// Every computer a bot can be told to use: its own, this Mac, then the SSH ones. + pub fn computer_names(&self) -> Vec { + let mut v = vec![BOT_COMPUTER_NAME.to_string(), THIS_COMPUTER_NAME.to_string()]; + v.extend(self.remote_computers.iter().map(|c| c.name.clone())); + v + } + + pub fn routine_owner(&self, routine_id: u64) -> Option { + self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id) + } +} + +impl State { + pub fn path() -> PathBuf { + // A server (or a second copy for testing) can keep its data elsewhere. + if let Ok(d) = std::env::var("ANYBOT_DATA_DIR") { + if !d.trim().is_empty() { + return PathBuf::from(d).join("state.json"); + } + } + let base = dirs::data_dir().unwrap_or_else(std::env::temp_dir); + base.join("AnyBot").join("state.json") + } + + pub fn load() -> State { + let mut s = std::fs::read_to_string(Self::path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .filter(|s| !s.bots.is_empty()) + .unwrap_or_else(State::first_run); + for b in s.bots.iter_mut() { + b.repair_interrupted(); + } + s + } + + pub fn save(&self) { + let p = Self::path(); + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(s) = serde_json::to_string_pretty(self) { + let tmp = p.with_extension("json.tmp"); + if std::fs::write(&tmp, s).is_ok() { + let _ = std::fs::rename(&tmp, &p); + } + } + } + + pub fn provider(&self, kind: ProviderKind) -> ProviderSettings { + self.providers.get(kind.key()).cloned().unwrap_or_default() + } + + pub fn provider_mut(&mut self, kind: ProviderKind) -> &mut ProviderSettings { + self.providers.entry(kind.key().to_string()).or_default() + } + + pub fn bot(&self, id: u64) -> Option<&Bot> { + self.bots.iter().find(|b| b.id == id) + } + + pub fn bot_mut(&mut self, id: u64) -> Option<&mut Bot> { + self.bots.iter_mut().find(|b| b.id == id) + } + + fn sample_bots() -> Vec { + let mk = |id, name: &str, desc: &str, shape: &str, color: &str, provider: ProviderKind, pinned| Bot { + id, + name: name.into(), + description: desc.into(), + instructions: String::new(), + shape: shape.into(), + color: color.into(), + provider, + model: provider.suggested_model().into(), + pinned, + messages: vec![], + unread: false, + tools_enabled: false, + workdir: String::new(), + allow_rules: vec![], + demos: vec![], + speak_replies: false, + voice: String::new(), + voice_rate: 1.0, + voice_lang: String::new(), + folder: String::new(), + memories: vec![], + members: vec![], + threads: vec![], + context_tokens: default_context_tokens(), + routines: vec![], + last_turn: None, + default_computer: default_computer(), + }; + let mut helper = mk(5, "電腦助手", "幫你整理檔案、跑指令。動手前會先問你。", "gem", "blue", ProviderKind::Anthropic, true); + helper.tools_enabled = true; + helper.workdir = default_workdir(); + vec![ + mk(1, "Bot", "你的主要聯絡窗口,什麼都可以問。", "squircle", "black", ProviderKind::Xai, false), + mk(2, "研究助理", "幫你查資料、整理重點。", "blob", "green", ProviderKind::Anthropic, true), + mk(3, "寫作夥伴", "改稿、翻譯、想標題。", "cloud", "purple", ProviderKind::OpenAi, true), + helper, + mk(4, "本機模型", "在你自己的電腦上跑,不用連網。", "hex", "brown", ProviderKind::Ollama, false), + ] + } + + pub fn first_run() -> State { + State { + bots: Self::sample_bots(), + primary_bot: 1, + selected: 1, + next_id: 6, + theme: Theme::Light, + accent: Accent::Black, + providers: Default::default(), + sidebar_collapsed: false, + sidebar_width: default_sidebar_width(), + local_execution: false, + remote_computers: vec![], + auto_review: false, + review_allow_rules: vec![], + review_ask_rules: vec![], + timezone: crate::routines::local_timezone(), + next_routine_id: 1, + notify: true, + direct_mode: false, + auto_update_when_away: true, + channels: vec![], + next_channel_id: 1, + mcp_servers: vec![], + skills: vec![], + remote: Default::default(), + language: auto_lang(), + folders: vec![], + pin_order: vec![], + machine_label: String::new(), + local_never: false, + messages_send: true, + messages_no_ask: false, + loose_index: None, + folded: vec![], + } + } + + /// A filled-in state for the --screenshot picture (never saved). + pub fn demo() -> State { + let mut s = State::first_run(); + let via = format!("{} · {}", ProviderKind::Xai.label(), ProviderKind::Xai.suggested_model()); + s.bots[0].messages = vec![ + Message::user("幫我規劃這週末去台南兩天一夜的行程"), + Message { + role: Role::Assistant, + text: "好的!這是一個輕鬆的兩天一夜台南行程:\n\n# 第一天\n- 上午:赤崁樓、祀典武廟\n- 中午:國華街小吃(牛肉湯、碗粿)\n- 下午:神農街散步、林百貨\n- 晚上:花園夜市(週四、六、日)\n\n# 第二天\n- 上午:安平古堡、安平樹屋\n- 下午:四草綠色隧道搭船\n\n需要我幫你排交通和住宿嗎?".into(), + thinking: "使用者想要週末兩天一夜,台南景點集中在中西區與安平。".into(), + via: via.clone(), + ..Default::default() + }, + Message::user("換成用 Claude 回答看看,有什麼不同建議?"), + Message::assistant("如果想避開人潮,可以這樣調整:\n\n- 第一天早上先去安平,避開中午的觀光團\n- 晚餐改到保安路,選擇比夜市多\n- 第二天加一站奇美博物館,下雨也不怕", "Claude · claude-opus-5-5"), + ]; + s.bots[0].provider = ProviderKind::Anthropic; + s.bots[0].model = ProviderKind::Anthropic.suggested_model().into(); + s.bots[1].unread = true; + // Talking to the bot sets up a routine. + let schedule = crate::routines::Schedule { + frequency: crate::routines::Frequency::Weekly, + interval: 1, + days_of_week: crate::routines::WORKWEEK.iter().map(|d| d.to_string()).collect(), + month_day: None, + weekday_of_month: None, + month: None, + time: "09:00".into(), + timezone: s.timezone.clone(), + once: false, + flexible: false, + }; + let created = crate::routines::now(); + s.bots[1].routines = vec![crate::routines::Routine { + id: 1, + title: "AI 新聞早報".into(), + prompt: "整理過去一天最重要的三則 AI 新聞,每則兩句話。".into(), + next_run: crate::routines::next_run(&schedule, created, created), + schedule, + paused: false, + created_at: created, + last_run: None, + computer: String::new(), + trigger: None, + }]; + s.next_routine_id = 2; + s.bots[1].messages = vec![ + Message::user("每個工作日早上 9 點幫我整理 AI 新聞"), + Message { + role: Role::Assistant, + via: "Claude · claude-opus-5-5".into(), + tool_calls: vec![ToolCall { id: "r1".into(), name: "create_routine".into(), args: r#"{"title":"AI 新聞早報"}"#.into(), status: ToolStatus::Done }], + ..Default::default() + }, + Message::tool_result("r1", "create_routine", "已建立例行任務 #1「AI 新聞早報」:每個工作日 09:00"), + Message::assistant("好,已經設定好了:每個工作日 09:00 我會整理三則重點給你。", "Claude · claude-opus-5-5"), + ]; + s.bots[2].messages = vec![Message::user("幫我想五個標題"), Message::assistant("1. 慢慢走,才看得見風景…", "OpenAI · gpt-5")]; + // The computer helper mid-task: two tools done, one waiting for approval. + let via = "Claude · claude-opus-5-5"; + let call = |id: &str, name: &str, args: &str, status| ToolCall { id: id.into(), name: name.into(), args: args.into(), status }; + s.bots[3].messages = vec![ + Message::user("幫我把下載資料夾裡的照片依照月份整理好"), + Message { + role: Role::Assistant, + text: "我先看看有哪些照片。".into(), + via: via.into(), + tool_calls: vec![call("c1", "list_dir", r#"{"path":"~/Downloads"}"#, ToolStatus::Done)], + ..Default::default() + }, + Message::tool_result("c1", "list_dir", "IMG_0412.jpg 2.1 MB\nIMG_0533.jpg 3.4 MB\nIMG_0601.heic 1.8 MB\n… 共 48 個檔案"), + Message { + role: Role::Assistant, + text: "找到 48 張照片,拍攝時間在 4 到 6 月。我會建立三個資料夾,再把照片搬進去。".into(), + via: via.into(), + tool_calls: vec![call("c2", "shell", r#"{"command":"mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/"}"#, ToolStatus::Waiting)], + ..Default::default() + }, + ]; + s.bots[3].default_computer = THIS_COMPUTER_NAME.into(); + s.local_execution = true; + s.selected = 5; + s + } +} + +/// Where a bot starts on this Mac (你的電腦) unless told otherwise. +pub fn default_workdir() -> String { + dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default() +} + +/// The sealed folder that is the bot's own computer. +pub fn bot_computer_dir(bot_id: u64) -> PathBuf { + dirs::home_dir().unwrap_or_else(std::env::temp_dir).join("AnyBot 工作區").join(format!("bot-{bot_id}")) +} diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..a344f18 --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,157 @@ +// Getting the person's attention when a bot needs them (or finished) while they're away. +// +// From the original: +// - sand_notification_settings.node `getNotificationSettings` (0x35b4): needs macOS 10.14+ and a real +// .app bundle, asks UNUserNotificationCenter and waits at most 3 s (dispatch_time 0xb2d05e00 ns). +// authorizationStatus 0..4 = notDetermined/denied/authorized/provisional/ephemeral (else "unknown"); +// alertSetting 0..2 = notSupported/disabled/enabled (else "unknown"). +// - main-app.cjs `hBt`/`mur` (@3183391): authorized/provisional/ephemeral → granted, denied → denied, +// notDetermined → undetermined. `fBt`: banners only when authorized and alerts enabled. +// - `K4`/`lBt` (@3180700): the desktop counts as present only when not locked, not idle for +// `Z_e = 9e4` ms (90 s) and the window is alive; checked every `uBt = 1e4` ms (10 s). + +use std::process::{Command, Stdio}; + +/// Away after this much keyboard/mouse silence (main-app.cjs `Z_e=9e4`). +pub const IDLE_THRESHOLD_SECS: f64 = 90.0; +/// How often presence is re-read (main-app.cjs `uBt=1e4`). +pub const PRESENCE_POLL_SECS: f64 = 10.0; + +/// Seconds since the last keyboard or mouse input (macOS `HIDIdleTime`, in nanoseconds). +pub fn parse_hid_idle(ioreg: &str) -> Option { + let line = ioreg.lines().find(|l| l.contains("\"HIDIdleTime\""))?; + let n: f64 = line.rsplit('=').next()?.trim().parse().ok()?; + Some(n / 1e9) +} + +pub fn system_idle_secs() -> Option { + #[cfg(target_os = "linux")] + return crate::desk::idle_secs(); + #[allow(unreachable_code)] + if !cfg!(target_os = "macos") { + return None; + } + let out = Command::new("ioreg").args(["-c", "IOHIDSystem", "-d", "4"]).stderr(Stdio::null()).output().ok()?; + parse_hid_idle(&String::from_utf8_lossy(&out.stdout)) +} + +/// Notify unless the person is looking at AnyBot right now: the window is in front and they +/// were active within the last 90 s. +pub fn should_notify(window_focused: bool, idle_secs: Option) -> bool { + !window_focused || idle_secs.map(|s| s >= IDLE_THRESHOLD_SECS).unwrap_or(false) +} + +// ---------- applying a new version while you're away ---------- +// main-app.cjs @3365216: `uQt` = autoUpdateWhenIdle && updateStaged && `phr` && `uhr`, where +// `phr` = (screenLocked || screensaverActive) && systemIdleSeconds >= `pQt` (600), and `uhr` = +// host idle confirmed (no agent work). `getAutoUpdateWhenIdle() ?? true` (on by default). +// Checks: first `bhr = 3e4` ms after launch, then every `khr = 3600*1e3` ms plus up to +// `Ihr = 300*1e3` ms of random jitter (@3372407, `nextPeriodicDelayMs`). + +pub const AUTO_UPDATE_IDLE_SECS: f64 = 600.0; +pub const UPDATE_FIRST_CHECK_SECS: u64 = 30; +pub const UPDATE_CHECK_INTERVAL_SECS: u64 = 3600; +pub const UPDATE_CHECK_JITTER_SECS: u64 = 300; + +/// Every input of the original's rule, in one place. +pub fn should_apply_update_now(enabled: bool, update_ready: bool, screen_locked: bool, screensaver: bool, idle_secs: Option, bots_idle: bool) -> bool { + enabled && update_ready && (screen_locked || screensaver) && idle_secs.map(|s| s >= AUTO_UPDATE_IDLE_SECS).unwrap_or(false) && bots_idle +} + +/// The screen is locked (Electron's `getSystemIdleState` "locked"; here the session flag). +pub fn screen_locked() -> bool { + if !cfg!(target_os = "macos") { + // systemd-logind knows when the session is locked. + let id = std::env::var("XDG_SESSION_ID").unwrap_or_default(); + let mut c = Command::new("loginctl"); + c.arg("show-session"); + if !id.is_empty() { + c.arg(&id); + } + return c.args(["-p", "LockedHint"]).stderr(Stdio::null()).output().map(|o| String::from_utf8_lossy(&o.stdout).trim() == "LockedHint=yes").unwrap_or(false); + } + Command::new("ioreg").args(["-n", "Root", "-d1"]).stderr(Stdio::null()).output().map(|o| String::from_utf8_lossy(&o.stdout).contains("\"CGSSessionScreenIsLocked\" = Yes")).unwrap_or(false) +} + +/// The screensaver is running: the original's `NRn` runs `pgrep -x ScreenSaverEngine` (2 s timeout). +pub fn screensaver_active() -> bool { + #[cfg(target_os = "linux")] + return crate::desk::screensaver_on(); + #[allow(unreachable_code)] + if !cfg!(target_os = "macos") { + return false; + } + let Ok(mut c) = Command::new("pgrep").args(["-x", "ScreenSaverEngine"]).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { return false }; + let start = std::time::Instant::now(); + while start.elapsed() < std::time::Duration::from_secs(2) { + if let Ok(Some(st)) = c.try_wait() { + return st.success(); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let _ = c.kill(); + false +} + +/// Next periodic check: one hour plus a random part of five minutes. +pub fn next_update_check_secs(random_0_1: f64) -> u64 { + UPDATE_CHECK_INTERVAL_SECS + (random_0_1.clamp(0.0, 1.0) * UPDATE_CHECK_JITTER_SECS as f64).floor() as u64 +} + +fn applescript_quote(s: &str) -> String { + let clean: String = s.chars().filter(|c| !c.is_control() || *c == ' ').collect(); + format!("\"{}\"", clean.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// The AppleScript line for a banner (title + message), with text safely quoted. +pub fn banner_script(title: &str, body: &str) -> String { + let body: String = body.chars().take(180).collect(); + format!("display notification {} with title {} sound name \"Glass\"", applescript_quote(&body), applescript_quote(title)) +} + +/// Show a macOS banner. AnyBot isn't a signed .app bundle when run from a build folder, so it can't +/// ask the notification center itself (the original's module needs a bundle); macOS's own +/// `display notification` is used instead. Doesn't wait. +pub fn banner(title: &str, body: &str) { + if !cfg!(target_os = "macos") { + // The desktop's notification service (GNOME, KDE, XFCE…) through notify-send. + let body: String = body.chars().take(180).collect(); + let _ = Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); + return; + } + let _ = Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presence_rule() { + assert!(!should_notify(true, Some(5.0))); + assert!(should_notify(true, Some(90.0))); + assert!(should_notify(false, Some(1.0))); + assert!(!should_notify(true, None)); + let sample = " | | \"HIDIdleTime\" = 123000000000\n"; + assert_eq!(parse_hid_idle(sample), Some(123.0)); + } + + #[test] + fn update_applies_only_when_truly_away() { + assert!(should_apply_update_now(true, true, true, false, Some(600.0), true)); + assert!(should_apply_update_now(true, true, false, true, Some(900.0), true)); + assert!(!should_apply_update_now(true, true, false, false, Some(900.0), true), "screen in use"); + assert!(!should_apply_update_now(true, true, true, false, Some(599.0), true), "not idle long enough"); + assert!(!should_apply_update_now(true, true, true, false, Some(900.0), false), "a bot is working"); + assert!(!should_apply_update_now(false, true, true, false, Some(900.0), true), "turned off"); + assert!(!should_apply_update_now(true, false, true, false, Some(900.0), true), "nothing to apply"); + assert_eq!(next_update_check_secs(0.0), 3600); + assert_eq!(next_update_check_secs(0.999), 3899); + } + + #[test] + fn banner_text_is_quoted() { + let s = banner_script("電腦助手 需要你允許", "執行 \"rm\" \\ 指令\n下一行"); + assert_eq!(s, "display notification \"執行 \\\"rm\\\" \\\\ 指令下一行\" with title \"電腦助手 需要你允許\" sound name \"Glass\""); + } +} diff --git a/src/phone.rs b/src/phone.rs new file mode 100644 index 0000000..5155559 --- /dev/null +++ b/src/phone.rs @@ -0,0 +1,311 @@ +// 專屬號碼、打電話、X — with the person's own accounts (the original uses its own phone and X +// services; here: a Twilio number and an X developer app). +// +// • 簡訊 (Twilio): the bot answers texts sent to the number (polled, no public address needed) and +// replies from it. +// • 打電話 (Twilio): the bot can call someone and read out a message (asked every time). +// • X: the bot answers @mentions as replies, and can post (posting is asked every time). +// +// Test servers can stand in: ANYBOT_TWILIO_API and ANYBOT_X_API. + +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +pub const SMS_MAX: usize = 1_600; +pub const X_MAX: usize = 280; + +fn twilio() -> String { + std::env::var("ANYBOT_TWILIO_API").unwrap_or_else(|_| "https://api.twilio.com".into()) +} + +fn x_api() -> String { + std::env::var("ANYBOT_X_API").unwrap_or_else(|_| "https://api.x.com".into()) +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into() +} + +/// "AccountSID:AuthToken" → (sid, Basic header). +pub fn twilio_auth(token: &str) -> Result<(String, String), String> { + let (sid, secret) = token.trim().split_once(':').ok_or("Twilio 金鑰要填「Account SID:Auth Token」")?; + Ok((sid.trim().to_string(), format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sid.trim(), secret.trim()))))) +} + +fn form(pairs: &[(&str, &str)]) -> String { + fn enc(s: &str) -> String { + s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() + } + pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::>().join("&") +} + +fn read(mut r: ureq::http::Response) -> Result { + let st = r.status().as_u16(); + let text = r.body_mut().read_to_string().unwrap_or_default(); + let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if !(200..300).contains(&st) { + let msg = v["message"].as_str().or(v["detail"].as_str()).or(v["title"].as_str()).map(String::from).unwrap_or_else(|| text.chars().take(160).collect()); + return Err(format!("HTTP {st}:{msg}")); + } + Ok(v) +} + +fn twilio_post(token: &str, path: &str, pairs: &[(&str, &str)]) -> Result { + let (sid, auth) = twilio_auth(token)?; + let r = agent() + .post(&format!("{}/2010-04-01/Accounts/{sid}/{path}", twilio())) + .header("Authorization", &auth) + .header("Content-Type", "application/x-www-form-urlencoded") + .send(form(pairs).as_bytes()) + .map_err(|e| format!("連不到 Twilio:{e}"))?; + read(r) +} + +pub fn twilio_get(token: &str, path_query: &str) -> Result { + let (sid, auth) = twilio_auth(token)?; + let r = agent().get(&format!("{}/2010-04-01/Accounts/{sid}/{path_query}", twilio())).header("Authorization", &auth).call().map_err(|e| format!("連不到 Twilio:{e}"))?; + read(r) +} + +pub fn twilio_test(token: &str) -> Result { + let v = twilio_get(token, ".json")?; + Ok(format!("已連上 Twilio:{}", v["friendly_name"].as_str().unwrap_or("帳號"))) +} + +pub fn send_sms(token: &str, from: &str, to: &str, text: &str) -> Result<(), String> { + for part in crate::channels::split(text, SMS_MAX) { + twilio_post(token, "Messages.json", &[("From", from), ("To", to), ("Body", &part)])?; + } + Ok(()) +} + +/// New texts to `number` (oldest first), skipping ones already seen. (sid, from, body) +pub fn sms_new(list: &Value, seen: &mut std::collections::HashSet) -> Vec<(String, String, String)> { + let mut v: Vec<(String, String, String)> = list["messages"] + .as_array() + .into_iter() + .flatten() + .filter(|m| m["direction"].as_str() == Some("inbound")) + .filter_map(|m| Some((m["sid"].as_str()?.to_string(), m["from"].as_str().unwrap_or("").to_string(), m["body"].as_str().unwrap_or("").to_string()))) + .filter(|(sid, _, _)| seen.insert(sid.clone())) + .collect(); + // Twilio lists newest first. + v.reverse(); + v +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Call `to` from the Twilio number and read `message` aloud (Traditional Chinese voice). +pub fn call(token: &str, from: &str, to: &str, message: &str) -> Result { + let twiml = format!("{}{}", xml_escape(message), xml_escape(message)); + let v = twilio_post(token, "Calls.json", &[("From", from), ("To", to), ("Twiml", &twiml)])?; + Ok(format!("已撥給 {to}(通話編號 {},對方接起後會聽到兩次)。", v["sid"].as_str().unwrap_or("?"))) +} + +// ---------- X ---------- + +fn x_get(token: &str, path: &str) -> Result { + let r = agent().get(&format!("{}{path}", x_api())).header("Authorization", &format!("Bearer {}", token.trim())).call().map_err(|e| format!("連不到 X:{e}"))?; + read(r) +} + +pub fn x_me(token: &str) -> Result<(String, String), String> { + let v = x_get(token, "/2/users/me")?; + Ok((v["data"]["id"].as_str().unwrap_or("").to_string(), v["data"]["username"].as_str().unwrap_or("").to_string())) +} + +/// Post (optionally as a reply). Returns the new post's address. +pub fn x_post(token: &str, text: &str, reply_to: &str) -> Result { + let mut body = json!({"text": text.chars().take(X_MAX).collect::()}); + if !reply_to.is_empty() { + body["reply"] = json!({"in_reply_to_tweet_id": reply_to}); + } + let r = agent() + .post(&format!("{}/2/tweets", x_api())) + .header("Authorization", &format!("Bearer {}", token.trim())) + .header("Content-Type", "application/json") + .send(body.to_string().as_bytes()) + .map_err(|e| format!("連不到 X:{e}"))?; + let v = read(r)?; + Ok(format!("https://x.com/i/status/{}", v["data"]["id"].as_str().unwrap_or(""))) +} + +/// Mentions newer than `since_id`, oldest first: (id, author username, text). Returns the newest id. +pub fn x_mentions(token: &str, user_id: &str, since_id: &str) -> Result<(String, Vec<(String, String, String)>), String> { + let q = if since_id.is_empty() { String::new() } else { format!("&since_id={since_id}") }; + let v = x_get(token, &format!("/2/users/{user_id}/mentions?max_results=20&expansions=author_id&user.fields=username{q}"))?; + Ok(mentions_new(&v, since_id)) +} + +pub fn mentions_new(v: &Value, since_id: &str) -> (String, Vec<(String, String, String)>) { + let names: std::collections::HashMap = v["includes"]["users"].as_array().into_iter().flatten().map(|u| (u["id"].as_str().unwrap_or("").to_string(), u["username"].as_str().unwrap_or("").to_string())).collect(); + let newest = v["meta"]["newest_id"].as_str().unwrap_or(since_id).to_string(); + let mut items: Vec<(String, String, String)> = v["data"] + .as_array() + .into_iter() + .flatten() + .map(|t| (t["id"].as_str().unwrap_or("").to_string(), names.get(t["author_id"].as_str().unwrap_or("")).cloned().unwrap_or_default(), t["text"].as_str().unwrap_or("").to_string())) + .collect(); + items.reverse(); + (newest, items) +} + +// ---------- what bots can use ---------- + +/// The configured accounts (set when the channels start): (Twilio token, Twilio number), X token. +#[derive(Clone, Default)] +pub struct Accounts { + pub twilio: Option<(String, String)>, + pub x: Option, +} + +pub fn accounts() -> &'static Mutex { + static A: OnceLock> = OnceLock::new(); + A.get_or_init(|| Mutex::new(Accounts::default())) +} + +pub fn specs() -> Vec { + let a = accounts().lock().unwrap().clone(); + let mut v = vec![]; + if a.twilio.is_some() { + v.push(json!({"name": "place_call", "description": "Phone someone from the person's own number and read a short message aloud (Traditional Chinese voice). The person is asked every time.", + "parameters": {"type": "object", "properties": {"to": {"type": "string", "description": "Phone number in international format, e.g. +886912345678"}, "message": {"type": "string"}}, "required": ["to", "message"]}})); + v.push(json!({"name": "send_sms", "description": "Send a text message from the person's own number. The person is asked every time.", + "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}}, "required": ["to", "text"]}})); + } + if a.x.is_some() { + v.push(json!({"name": "post_to_x", "description": "Post on X (Twitter) as the person, optionally as a reply. At most 280 characters. The person is asked every time.", + "parameters": {"type": "object", "properties": {"text": {"type": "string"}, "reply_to": {"type": "string", "description": "Post id to reply to (optional)"}}, "required": ["text"]}})); + } + v +} + +pub fn is_phone_tool(name: &str) -> bool { + matches!(name, "place_call" | "send_sms" | "post_to_x") +} + +/// What the person is asked. +pub fn describe(name: &str, args: &str) -> String { + let v = crate::tools::args_of(args); + let s = |k: &str| v[k].as_str().unwrap_or("").to_string(); + match name { + "place_call" => format!("打電話給 {},唸:「{}」", s("to"), s("message")), + "send_sms" => format!("傳簡訊給 {}:「{}」", s("to"), s("text")), + "post_to_x" => format!("用你的帳號在 X 發文{}:「{}」", if s("reply_to").is_empty() { String::new() } else { "(回覆)".into() }, s("text")), + _ => String::new(), + } +} + +pub fn run(name: &str, args: &str) -> (String, bool) { + let v = crate::tools::args_of(args); + let s = |k: &str| v[k].as_str().unwrap_or("").trim().to_string(); + let a = accounts().lock().unwrap().clone(); + let r = match (name, &a.twilio, &a.x) { + ("place_call", Some((t, from)), _) => call(t, from, &s("to"), &s("message")), + ("send_sms", Some((t, from)), _) => send_sms(t, from, &s("to"), &s("text")).map(|_| format!("已傳簡訊給 {}。", s("to"))), + ("post_to_x", _, Some(t)) => x_post(t, &s("text"), &s("reply_to")).map(|u| format!("已發文:{u}")), + _ => Err("這個帳號還沒設定(設定 › 訊息管道)".into()), + }; + match r { + Ok(t) => (t, true), + Err(e) => (e, false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + /// A stand-in for Twilio and X: records requests, answers like they do. + fn fake() -> (u16, std::sync::Arc>>) { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let log = std::sync::Arc::new(Mutex::new(vec![])); + let lg = log.clone(); + std::thread::spawn(move || { + for c in l.incoming().flatten() { + let mut c = c; + let mut got = vec![]; + let mut buf = [0u8; 8192]; + loop { + let n = c.read(&mut buf).unwrap_or(0); + got.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&got).to_string(); + if let Some((h, b)) = t.split_once("\r\n\r\n") { + let len: usize = h.lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap_or(0))).unwrap_or(0); + if b.len() >= len || n == 0 { + break; + } + } + if n == 0 { + break; + } + } + let t = String::from_utf8_lossy(&got).to_string(); + let (head, body) = t.split_once("\r\n\r\n").unwrap_or((&t, "")); + let line = head.lines().next().unwrap_or("").to_string(); + let auth = head.lines().find_map(|l| l.strip_prefix("Authorization: ").or(l.strip_prefix("authorization: "))).unwrap_or("").to_string(); + lg.lock().unwrap().push((line.clone(), auth, body.to_string())); + let resp = if line.contains("/Messages.json") && line.starts_with("GET") { + json!({"messages": [{"sid": "SM2", "direction": "inbound", "from": "+886900000002", "body": "第二則"}, {"sid": "SM9", "direction": "outbound-api", "from": "+1555", "body": "我發的"}, {"sid": "SM1", "direction": "inbound", "from": "+886900000001", "body": "第一則"}]}) + } else if line.contains("/Calls.json") { + json!({"sid": "CA123"}) + } else if line.contains("/users/me") { + json!({"data": {"id": "42", "username": "daniel"}}) + } else if line.contains("/tweets") { + json!({"data": {"id": "777", "text": "x"}}) + } else { + json!({"sid": "SMx", "friendly_name": "My account"}) + }; + let b = resp.to_string(); + let _ = c.write_all(format!("HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()).as_bytes()); + } + }); + (port, log) + } + + #[test] + fn twilio_and_x_requests_look_right() { + let (port, log) = fake(); + std::env::set_var("ANYBOT_TWILIO_API", format!("http://127.0.0.1:{port}")); + std::env::set_var("ANYBOT_X_API", format!("http://127.0.0.1:{port}")); + let tok = "AC123:secret"; + assert!(twilio_test(tok).unwrap().contains("My account")); + send_sms(tok, "+15550001", "+886912345678", "你好 & 再見").unwrap(); + let out = call(tok, "+15550001", "+886912345678", "會議改到 <三點>").unwrap(); + assert!(out.contains("CA123")); + let mut seen = Default::default(); + let list = twilio_get(tok, "Messages.json?To=%2B15550001&PageSize=20").unwrap(); + let new = sms_new(&list, &mut seen); + assert_eq!(new.iter().map(|m| m.2.as_str()).collect::>(), vec!["第一則", "第二則"]); + assert!(sms_new(&list, &mut seen).is_empty()); + assert_eq!(x_me("xtok").unwrap().1, "daniel"); + assert_eq!(x_post("xtok", "嗨", "555").unwrap(), "https://x.com/i/status/777"); + let log = log.lock().unwrap(); + let sms = log.iter().find(|r| r.0.starts_with("POST") && r.0.contains("Messages.json")).unwrap(); + assert!(sms.0.contains("/2010-04-01/Accounts/AC123/Messages.json")); + assert_eq!(sms.1, format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("AC123:secret"))); + assert!(sms.2.contains("To=%2B886912345678") && sms.2.contains("Body=%E4%BD%A0%E5%A5%BD%20%26%20")); + let callr = log.iter().find(|r| r.0.contains("Calls.json")).unwrap(); + assert!(callr.2.contains("Twiml=") && callr.2.contains("%26lt%3B"), "TwiML must be escaped: {}", callr.2); + let tw = log.iter().find(|r| r.0.contains("/2/tweets")).unwrap(); + assert_eq!(tw.1, "Bearer xtok"); + let body: Value = serde_json::from_str(&tw.2).unwrap(); + assert_eq!(body["reply"]["in_reply_to_tweet_id"], "555"); + } + + #[test] + fn mentions_come_oldest_first_with_names() { + let v = json!({"data": [{"id": "9", "author_id": "1", "text": "@daniel 新的"}, {"id": "8", "author_id": "2", "text": "@daniel 舊的"}], "includes": {"users": [{"id": "1", "username": "amy"}, {"id": "2", "username": "bob"}]}, "meta": {"newest_id": "9"}}); + let (newest, items) = mentions_new(&v, "7"); + assert_eq!(newest, "9"); + assert_eq!(items[0], ("8".into(), "bob".into(), "@daniel 舊的".into())); + assert_eq!(mentions_new(&json!({"meta": {"result_count": 0}}), "9").0, "9"); + } +} diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..cf11594 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,116 @@ +// The system AnyBot runs on. Most words in the app were written for the Mac ("這台 Mac", +// "this Mac"); on Linux (and later Windows) the same sentences say "這台電腦" / "this computer". +// `localize` is applied to every translated label (i18n::t) and to what the bot is told (its +// instructions and tool descriptions), so the wording follows the system in one place. + +/// A screen the person sits at (not a server with no display). +pub fn has_desktop() -> bool { + if cfg!(target_os = "linux") { + std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) || std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) + } else { + true + } +} + +const PAIRS: &[(&str, &str)] = &[ + ("使用者的這台 Mac", "使用者的這台電腦"), + ("這台 Mac", "這台電腦"), + ("你的 Mac", "你的電腦"), + ("使用者的 Mac", "使用者的電腦"), + ("我的 Mac", "我的電腦"), + ("macOS 鑰匙圈", "系統鑰匙圈"), + ("在 Finder 中顯示", "打開所在資料夾"), + ("Finder", "檔案管理員"), + ("この Mac", "このコンピューター"), + ("the person's Mac", "the person's computer"), + ("person's Mac", "person's computer"), + ("this Mac", "this computer"), + ("This Mac", "This computer"), + ("your Mac", "your computer"), + ("my Mac", "my computer"), + ("the Mac", "the computer"), + ("macOS Keychain", "system keyring"), +]; + +/// Mac wording → this system's wording (unchanged on macOS). +pub fn localize(s: &str) -> String { + if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("Finder") { + return s.to_string(); + } + let mut out = s.to_string(); + for (a, b) in PAIRS { + out = out.replace(a, b); + } + out +} + +/// Every string inside a JSON value (tool descriptions). +#[cfg_attr(target_os = "macos", allow(dead_code))] +pub fn localize_value(v: &mut serde_json::Value) { + if cfg!(target_os = "macos") { + return; + } + match v { + serde_json::Value::String(s) => *s = localize(s), + serde_json::Value::Array(a) => a.iter_mut().for_each(localize_value), + serde_json::Value::Object(o) => o.values_mut().for_each(localize_value), + _ => {} + } +} + +#[cfg(test)] +mod tests { + #[test] + fn mac_words_follow_the_system() { + let s = super::localize("檔案在這台 Mac;on this Mac"); + if cfg!(target_os = "macos") { + assert_eq!(s, "檔案在這台 Mac;on this Mac"); + } else { + assert_eq!(s, "檔案在這台電腦;on this computer"); + } + } +} + +// ---------- file dialogs on Linux: zenity (GNOME and most desktops) or kdialog (KDE) ---------- + +fn dialog(zenity: &[&str], kdialog: &[&str]) -> Option { + for (cmd, args) in [("zenity", zenity), ("kdialog", kdialog)] { + if let Ok(o) = std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() { + // Exit code 1 = the person pressed Cancel: don't try the other one. + return o.status.success().then(|| String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string()); + } + } + None +} + +/// Pick files to open (several at once). +pub fn pick_files_linux(title: &str) -> Vec { + dialog(&["--file-selection", "--multiple", "--separator=\n", &format!("--title={title}")], &["--getopenfilename", ".", "--multiple", "--separate-output", "--title", title]) + .map(|s| s.lines().map(str::trim).filter(|l| !l.is_empty()).map(std::path::PathBuf::from).collect()) + .unwrap_or_default() +} + +/// Pick where to save (starting in Downloads with the given name). +pub fn choose_save_linux(title: &str, default_name: &str) -> Option { + let start = dirs::download_dir().or_else(dirs::home_dir).unwrap_or_default().join(default_name); + let start = start.display().to_string(); + dialog(&["--file-selection", "--save", "--confirm-overwrite", &format!("--filename={start}"), &format!("--title={title}")], &["--getsavefilename", &start, "--title", title]) + .filter(|p| !p.is_empty()) + .map(std::path::PathBuf::from) +} + +/// A picture on the clipboard as PNG bytes (wl-paste on Wayland, xclip on X11). +pub fn clipboard_png_linux() -> Option> { + let wayland = std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false); + let (types, get): (Vec<&str>, Vec<&str>) = if wayland { + (vec!["wl-paste", "--list-types"], vec!["wl-paste", "--no-newline", "--type", "image/png"]) + } else { + (vec!["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], vec!["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]) + }; + let list = std::process::Command::new(types[0]).args(&types[1..]).stderr(std::process::Stdio::null()).output().ok()?; + if !String::from_utf8_lossy(&list.stdout).lines().any(|l| l.trim() == "image/png") { + return None; + } + let o = std::process::Command::new(get[0]).args(&get[1..]).stderr(std::process::Stdio::null()).output().ok()?; + (o.status.success() && o.stdout.starts_with(b"\x89PNG")).then_some(o.stdout) +} diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..a04f3ab --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,96 @@ +// Admin locks for the permission switches, read the way the original reads its managed policy +// (main-core.cjs @727949, `EZ`/`TZ`/`Ak`): +// - macOS: user defaults (`systemPreferences.getUserDefault(key, "string")`), which MDM profiles can +// force; Linux: a JSON file in the home folder (`~/.cursor/policy.json` there); Windows: the native +// `managed_policy_value_sync(productName, valueName)` (sand_notification_settings.node 0x1870, +// which on macOS just checks its two string arguments and returns null). +// - `Ak`/`mZ`: missing or blank values = "unmanaged"; a valid value = "pinned"; anything else = +// "invalid", which the original refuses to use. Here an invalid value locks the switch off. + +use std::process::{Command, Stdio}; + +/// The macOS defaults domain AnyBot reads (`defaults write com.anybot.AnyBot AllowDirectMode false`). +pub const DOMAIN: &str = "com.anybot.AnyBot"; +pub const KEY_DIRECT: &str = "AllowDirectMode"; +pub const KEY_LOCAL: &str = "AllowLocalExecution"; +/// 「網路流量改走你的電腦」 (the original lets admins turn it off). +pub const KEY_EGRESS: &str = "AllowBoxTrafficViaThisMac"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Policy { + Unmanaged, + Pinned(bool), + Invalid, +} + +impl Policy { + /// May the person turn this on? + pub fn allows(self) -> bool { + match self { + Policy::Unmanaged => true, + Policy::Pinned(b) => b, + Policy::Invalid => false, + } + } +} + +/// Blank or missing → unmanaged (the original's `mZ`); true/false words → pinned; else invalid. +pub fn parse(v: Option<&str>) -> Policy { + match v.map(|s| s.trim().to_ascii_lowercase()) { + None => Policy::Unmanaged, + Some(s) if s.is_empty() => Policy::Unmanaged, + Some(s) if ["true", "1", "yes"].contains(&s.as_str()) => Policy::Pinned(true), + Some(s) if ["false", "0", "no"].contains(&s.as_str()) => Policy::Pinned(false), + Some(_) => Policy::Invalid, + } +} + +fn read_raw(key: &str) -> Option { + if cfg!(target_os = "macos") { + let out = Command::new("defaults").args(["read", DOMAIN, key]).stderr(Stdio::null()).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + let path = dirs::home_dir()?.join(".anybot").join("policy.json"); + let text = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + v.get(key).map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Managed { + pub direct: Policy, + pub local: Policy, + pub egress: Policy, +} + +impl Default for Managed { + fn default() -> Self { + Managed { direct: Policy::Unmanaged, local: Policy::Unmanaged, egress: Policy::Unmanaged } + } +} + +/// Just the traffic switch (checked when a command starts). +pub fn read_egress() -> Policy { + parse(read_raw(KEY_EGRESS).as_deref()) +} + +pub fn read() -> Managed { + Managed { direct: parse(read_raw(KEY_DIRECT).as_deref()), local: parse(read_raw(KEY_LOCAL).as_deref()), egress: parse(read_raw(KEY_EGRESS).as_deref()) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_follow_the_original_kinds() { + assert_eq!(parse(None), Policy::Unmanaged); + assert_eq!(parse(Some(" ")), Policy::Unmanaged); + assert_eq!(parse(Some("0")), Policy::Pinned(false)); + assert_eq!(parse(Some("TRUE")), Policy::Pinned(true)); + assert_eq!(parse(Some("maybe")), Policy::Invalid); + assert!(Policy::Unmanaged.allows() && Policy::Pinned(true).allows()); + assert!(!Policy::Pinned(false).allows() && !Policy::Invalid.allows()); + } +} diff --git a/src/portal.rs b/src/portal.rs new file mode 100644 index 0000000..47c5506 --- /dev/null +++ b/src/portal.rs @@ -0,0 +1,332 @@ +// Linux Wayland on GNOME and KDE: the desktop portals, the way these desktops let an app see the +// screen and use the mouse and keyboard. +// - RemoteDesktop (+ ScreenCast for the screens): the desktop asks the person once +// ("AnyBot wants to control your screen"); the answer is remembered with a restore token +// kept in AnyBot's folder, so later sessions start without asking (portal version 2+; +// older desktops such as Ubuntu 22.04 ask once each time AnyBot starts). +// - Screenshot: a still picture of the screen, without a dialog. +// Characters the keyboard layout has are sent as keysyms; others (Chinese…) go through the +// clipboard and Ctrl+V, because these desktops only press keys their layout has. + +use std::sync::mpsc::{channel, Sender}; +use std::sync::Mutex; +use std::time::Duration; + +use ashpd::desktop::remote_desktop::{Axis, DeviceType, KeyState, RemoteDesktop, SelectDevicesOptions}; +use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType}; +use ashpd::desktop::PersistMode; + +#[derive(Debug)] +enum Cmd { + Motion(f64, f64), + Button(i32, bool), + Keysym(u32, bool), + Wheel(Axis, i32), +} + +/// A screen the session can point at: its PipeWire node and logical place. +#[derive(Clone, Copy, Debug, Default)] +pub struct Screen { + node: u32, + x: i32, + y: i32, + w: i32, + h: i32, +} + +struct Live { + tx: Sender, + screens: Vec, +} + +static LIVE: Mutex> = Mutex::new(None); +static LAST_ERROR: Mutex = Mutex::new(String::new()); + +fn token_file() -> std::path::PathBuf { + crate::model::State::path().parent().map(|d| d.join("wayland-remote-token.txt")).unwrap_or_default() +} + +/// The portal offers remote control here (doesn't ask the person anything). +pub fn available() -> bool { + futures_lite::future::block_on(async { + match RemoteDesktop::new().await { + Ok(rd) => rd.version() >= 1, + Err(_) => false, + } + }) +} + +/// Why the last start failed (for the bot's message). +pub fn last_error() -> String { + LAST_ERROR.lock().unwrap().clone() +} + +/// Start the session if it isn't running (the first time, the desktop asks the person). +fn ensure() -> Option> { + if let Some(l) = LIVE.lock().unwrap().as_ref() { + return Some(l.screens.clone()); + } + let (ready_tx, ready_rx) = channel::, String>>(); + let (tx, rx) = channel::(); + std::thread::spawn(move || { + futures_lite::future::block_on(async move { + let started = async { + let rd = RemoteDesktop::new().await.map_err(|e| e.to_string())?; + let session = rd.create_session(Default::default()).await.map_err(|e| e.to_string())?; + let token = std::fs::read_to_string(token_file()).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + // Version 2 can remember the person's answer (older desktops ask every time AnyBot starts). + let mut devices = SelectDevicesOptions::default().set_devices(DeviceType::Keyboard | DeviceType::Pointer); + if rd.version() >= 2 { + devices = devices.set_persist_mode(PersistMode::ExplicitlyRevoked).set_restore_token(token.as_deref()); + } + rd.select_devices(&session, devices) + .await + .map_err(|e| e.to_string())?; + let sc = Screencast::new().await.map_err(|e| e.to_string())?; + sc.select_sources(&session, SelectSourcesOptions::default().set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor)).set_multiple(true)) + .await + .map_err(|e| e.to_string())?; + let res = rd.start(&session, None, Default::default()).await.map_err(|e| e.to_string())?.response().map_err(|e| match e { + ashpd::Error::Response(_) => "你在系統視窗裡沒有允許 AnyBot 操作畫面".to_string(), + e => e.to_string(), + })?; + // GNOME's dialog has an 「允許遠端操作」 switch that starts off: sharing alone lets + // AnyBot see but not use the screen. + if !res.devices().contains(DeviceType::Pointer) || !res.devices().contains(DeviceType::Keyboard) { + let _ = session.close().await; + return Err("系統視窗裡的「允許遠端操作」沒有打開,所以 AnyBot 只能看、不能操作。請再試一次,這次先打開「允許遠端操作」再按「分享」".to_string()); + } + if let Some(t) = res.restore_token() { + let _ = std::fs::write(token_file(), t); + } + let screens: Vec = res + .streams() + .iter() + .map(|s| { + let (x, y) = s.position().unwrap_or((0, 0)); + let (w, h) = s.size().unwrap_or((0, 0)); + Screen { node: s.pipe_wire_node_id(), x, y, w, h } + }) + .collect(); + if screens.is_empty() { + return Err("系統沒有給 AnyBot 任何螢幕".to_string()); + } + Ok::<_, String>((rd, session, screens)) + }; + let (rd, session, screens) = match started.await { + Ok(v) => v, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; + // GNOME drops the first event of a new virtual keyboard: wake it with a Shift tap + // (no visible effect) before AnyBot types anything. + for p in [KeyState::Pressed, KeyState::Released] { + let _ = rd.notify_keyboard_keysym(&session, 0xffe1, p, Default::default()).await; + } + async_io::Timer::after(Duration::from_millis(150)).await; + let _ = ready_tx.send(Ok(screens.clone())); + // Serve commands until AnyBot stops using the session (or the desktop ends it). + while let Ok(cmd) = rx.recv() { + let r = match cmd { + Cmd::Motion(x, y) => { + let s = screens.iter().find(|s| x >= s.x as f64 && y >= s.y as f64 && x < (s.x + s.w) as f64 && y < (s.y + s.h) as f64).or(screens.first()).copied().unwrap_or_default(); + // Twice: a new virtual pointer can miss its first move, and a click must + // never land where the mouse was before. + let (lx, ly) = ((x - s.x as f64).max(0.0), (y - s.y as f64).max(0.0)); + let _ = rd.notify_pointer_motion_absolute(&session, s.node, lx, ly, Default::default()).await; + rd.notify_pointer_motion_absolute(&session, s.node, lx, ly, Default::default()).await + } + Cmd::Button(b, p) => rd.notify_pointer_button(&session, b, if p { KeyState::Pressed } else { KeyState::Released }, Default::default()).await, + Cmd::Keysym(k, p) => rd.notify_keyboard_keysym(&session, k as i32, if p { KeyState::Pressed } else { KeyState::Released }, Default::default()).await, + Cmd::Wheel(axis, steps) => rd.notify_pointer_axis_discrete(&session, axis, steps, Default::default()).await, + }; + if r.is_err() { + break; + } + } + let _ = session.close().await; + *LIVE.lock().unwrap() = None; + }); + }); + match ready_rx.recv_timeout(Duration::from_secs(120)) { + Ok(Ok(screens)) => { + *LIVE.lock().unwrap() = Some(Live { tx, screens: screens.clone() }); + Some(screens) + } + Ok(Err(e)) => { + *LAST_ERROR.lock().unwrap() = e; + None + } + Err(_) => { + *LAST_ERROR.lock().unwrap() = "等太久了,系統的允許視窗沒有回應".into(); + None + } + } +} + +fn send(cmd: Cmd) { + if ensure().is_none() { + return; + } + let ok = LIVE.lock().unwrap().as_ref().map(|l| l.tx.send(cmd).is_ok()).unwrap_or(false); + if !ok { + *LIVE.lock().unwrap() = None; + } + // Commands are asynchronous; a short gap keeps press/release order sane for the desktop. + std::thread::sleep(Duration::from_millis(4)); +} + +/// The screens' bounding box in logical points. +pub fn screen_points() -> (f64, f64) { + let Some(screens) = ensure() else { return (0.0, 0.0) }; + let w = screens.iter().map(|s| s.x + s.w).max().unwrap_or(0); + let h = screens.iter().map(|s| s.y + s.h).max().unwrap_or(0); + (w as f64, h as f64) +} + +static PIXELS: Mutex<(u32, u32)> = Mutex::new((0, 0)); + +pub fn screen_pixels() -> (u32, u32) { + let p = *PIXELS.lock().unwrap(); + if p.0 > 0 { + return p; + } + let (w, h) = screen_points(); + (w as u32, h as u32) +} + +/// A `file://` URI's path. +pub fn file_path_of(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + Some(std::path::PathBuf::from(String::from_utf8(out).ok()?)) +} + +pub fn capture() -> Result { + if ensure().is_none() { + return Err(format!("AnyBot 還不能看這台電腦的畫面:{}。", last_error())); + } + let uri = futures_lite::future::block_on(async { + let req = ashpd::desktop::screenshot::Screenshot::request().interactive(false).modal(false).send().await.map_err(|e| e.to_string())?; + req.response().map(|s| s.uri().as_str().to_string()).map_err(|e| e.to_string()) + }) + .map_err(|e| format!("Wayland 截圖失敗:{e}"))?; + let path = file_path_of(&uri).ok_or("截圖的位置看不懂")?; + let img = image::open(&path).map_err(|e| e.to_string()); + // The desktop saved it (e.g. in 圖片/Screenshots); it was only for the bot. + let _ = std::fs::remove_file(&path); + let img = img?.to_rgb8(); + *PIXELS.lock().unwrap() = img.dimensions(); + Ok(img) +} + +pub fn motion(x: f64, y: f64) { + send(Cmd::Motion(x, y)); +} + +/// 1 left, 2 middle, 3 right → Linux button codes. +pub fn button(b: u8, press: bool) { + let code = match b { + 3 => 0x111, + 2 => 0x112, + _ => 0x110, + }; + send(Cmd::Button(code, press)); +} + +pub fn wheel(up: i32, left: i32) { + if up != 0 { + send(Cmd::Wheel(Axis::Vertical, -up)); + } + if left != 0 { + send(Cmd::Wheel(Axis::Horizontal, -left)); + } +} + +pub fn key(sym: u32, press: bool) { + send(Cmd::Keysym(sym, press)); +} + +/// Typed directly: plain keyboard characters. The rest go through the clipboard. +pub fn typable(c: char) -> bool { + c == '\n' || c == '\t' || (' '..='~').contains(&c) +} + +fn tap(sym: u32) { + key(sym, true); + std::thread::sleep(Duration::from_millis(8)); + key(sym, false); + std::thread::sleep(Duration::from_millis(8)); +} + +pub fn type_text(text: &str) { + let mut rest = String::new(); + let flush_paste = |s: &mut String| { + if s.is_empty() { + return; + } + paste(s); + s.clear(); + }; + for c in text.chars() { + if typable(c) { + flush_paste(&mut rest); + tap(crate::x11::keysym_for_char(c)); + } else { + rest.push(c); + } + } + flush_paste(&mut rest); +} + +/// Put text on the clipboard, press Ctrl+V, then put back what was there. +fn paste(text: &str) { + use std::io::Write; + use std::process::{Command, Stdio}; + let old = Command::new("wl-paste").args(["-n", "-t", "text"]).stderr(Stdio::null()).output().ok().filter(|o| o.status.success()).map(|o| o.stdout); + let set = |bytes: &[u8]| -> bool { + let Ok(mut child) = Command::new("wl-copy").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { return false }; + let ok = child.stdin.take().map(|mut s| s.write_all(bytes).is_ok()).unwrap_or(false); + ok && child.wait().map(|s| s.success()).unwrap_or(false) + }; + if !set(text.as_bytes()) { + *LAST_ERROR.lock().unwrap() = "要打中文需要 wl-clipboard(sudo apt install wl-clipboard)".into(); + return; + } + std::thread::sleep(Duration::from_millis(60)); + key(0xffe3, true); + tap(0x76); + key(0xffe3, false); + std::thread::sleep(Duration::from_millis(250)); + if let Some(old) = old { + set(&old); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uri_paths_and_typing() { + assert_eq!(file_path_of("file:///home/a/Pictures/Screenshot%20From%202026.png").unwrap(), std::path::PathBuf::from("/home/a/Pictures/Screenshot From 2026.png")); + assert_eq!(file_path_of("file:///tmp/%E5%9C%96.png").unwrap(), std::path::PathBuf::from("/tmp/圖.png")); + assert!(file_path_of("https://x").is_none()); + assert!(typable('a') && typable('\n') && !typable('你') && !typable('é')); + } +} diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..0402a2d --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,734 @@ +// 檔案預覽: what a file in the chat looks like when opened (the original's full-screen viewer: +// spreadsheets as a grid with row numbers, documents rendered, pictures shown). + +use std::path::Path; + +/// Rows and columns read from a spreadsheet (the rest is left to the real app). +pub const MAX_ROWS: usize = 2_000; +pub const MAX_COLS: usize = 60; +/// Characters of a text file shown. +pub const MAX_TEXT: usize = 200_000; + +#[derive(Clone, Debug, PartialEq)] +pub enum Preview { + /// Sheets: (name, rows). The first row is shown as the header. + Table(Vec<(String, Vec>)>), + /// Markdown, and Word documents turned into Markdown (headings, lists, tables, bold). + Markdown(String), + /// A web page as Markdown (its headings, lists, tables and links), plus "open in the browser". + Html(String), + Image, + /// A PDF: its pages are drawn in the viewer (hayro, pure Rust, so the same on every system). + Pdf, + /// Played inside AnyBot (rodio). + Audio, + /// A video: its first frame when ffmpeg is around, and a play button for the system player. + Video, + /// Plain text, code, and JSON (pretty-printed). + Text(String), + /// Something we can't show inside AnyBot. + Other(String), +} + +/// What kind of file this is, with the same groups as the original's viewer (image, video, +/// audio, pdf, markdown, html, text, json, table, docx, unknown). +pub fn kind_of(name: &str) -> &'static str { + let lower = name.to_lowercase(); + let file = lower.rsplit(['/', '\\']).next().unwrap_or(""); + if ["dockerfile", "makefile", "readme", "license", "gemfile", "procfile"].contains(&file) { + return "text"; + } + match file.rsplit_once('.').map(|(_, e)| e).unwrap_or("") { + "xlsx" | "xlsm" | "xls" | "xlsb" | "ods" | "csv" | "tsv" => "sheet", + "md" | "markdown" | "mdx" => "markdown", + "html" | "htm" | "xhtml" => "html", + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg" | "avif" | "heic" | "heif" | "tif" | "tiff" => "image", + "pdf" => "pdf", + "mp4" | "mov" | "m4v" | "webm" | "ogv" | "mkv" | "avi" => "video", + "mp3" | "m4a" | "wav" | "aac" | "flac" | "ogg" | "oga" | "opus" | "weba" | "aif" | "aiff" | "caf" => "audio", + "docx" => "docx", + "doc" | "rtf" | "pages" | "odt" => "doc", + "json" => "json", + e if TEXT_EXT.contains(&e) => "text", + _ => "other", + } +} + +/// The original's list of files it shows as text. +const TEXT_EXT: &[&str] = &[ + "txt", "text", "log", "rst", "adoc", "tex", "jsonc", "json5", "ndjson", "jsonl", "xml", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", + "properties", "plist", "gradle", "css", "scss", "sass", "less", "js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts", "py", "pyi", "rb", "go", + "rs", "java", "kt", "kts", "c", "h", "cc", "cpp", "cxx", "hpp", "hh", "cs", "php", "swift", "scala", "dart", "lua", "pl", "pm", "r", "sql", + "graphql", "gql", "proto", "vue", "svelte", "astro", "sh", "bash", "zsh", "fish", "bat", "ps1", "tf", "tfvars", "dockerfile", "diff", "patch", + "srt", "vtt", "m", "mm", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "groovy", "cmake", "mk", "lock", "gitignore", "editorconfig", +]; + +/// The label under a file card ("XLSX", "Markdown"…). +pub fn kind_label(name: &str) -> String { + let ext = name.rsplit('.').next().unwrap_or("").to_uppercase(); + match kind_of(name) { + "markdown" => "Markdown".into(), + "html" => "HTML".into(), + _ if ext.len() <= 5 && !ext.is_empty() && name.contains('.') => ext, + _ => "檔案".into(), + } +} + +pub fn load(path: &Path, name: &str) -> Preview { + match kind_of(name) { + "sheet" => load_sheet(path, name).unwrap_or_else(Preview::Other), + "markdown" => read_text(path).map(Preview::Markdown).unwrap_or_else(Preview::Other), + "html" => read_text(path).map(|h| Preview::Html(html_markdown(&h))).unwrap_or_else(Preview::Other), + "image" => Preview::Image, + "pdf" => Preview::Pdf, + "audio" => Preview::Audio, + "video" => Preview::Video, + "json" => read_text(path) + .map(|t| serde_json::from_str::(&t).ok().and_then(|v| serde_json::to_string_pretty(&v).ok()).map(clip).unwrap_or(t)) + .map(Preview::Text) + .unwrap_or_else(Preview::Other), + "text" => read_text(path).map(Preview::Text).unwrap_or_else(Preview::Other), + "docx" => docx_markdown(path).map(Preview::Markdown).unwrap_or_else(Preview::Other), + "doc" => crate::attach::read_text(&path.display().to_string(), name) + .map(|t| Preview::Text(clip(t))) + .unwrap_or_else(|| Preview::Other("這種文件在 AnyBot 裡看不到內容,可以用其他 App 打開;存成 .docx 或 PDF 就能直接預覽。".into())), + _ => read_text(path).ok().filter(|t| !t.trim().is_empty()).map(Preview::Text).unwrap_or_else(|| Preview::Other("這種檔案在 AnyBot 裡沒辦法預覽,可以用其他 App 打開。".into())), + } +} + +/// The command that opens a file (or a web address) with the computer's own app: `open` on +/// macOS, the shell's `start` on Windows, `xdg-open` on Linux. +pub fn system_open(target: impl AsRef) -> std::process::Command { + #[cfg(target_os = "macos")] + let mut c = std::process::Command::new("open"); + #[cfg(target_os = "windows")] + let mut c = { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", ""]); + c + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut c = std::process::Command::new("xdg-open"); + c.arg(target); + c +} + +pub fn open_with_system(target: &str) { + if let Err(e) = system_open(target).spawn() { + eprintln!("anybot: couldn't open {target}: {e}"); + } +} + +/// Show a file in the file manager (Finder selects it; elsewhere its folder opens). +pub fn reveal(path: &Path) { + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg("-R").arg(path).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("explorer").arg(format!("/select,{}", path.display())).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let _ = system_open(path.parent().unwrap_or(path)).spawn(); +} + +fn clip(t: String) -> String { + if t.chars().count() > MAX_TEXT { + t.chars().take(MAX_TEXT).collect::() + "\n…" + } else { + t + } +} + +fn read_text(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("讀不到檔案:{e}"))?; + if bytes.iter().take(8000).any(|b| *b == 0) { + return Err("這是二進位檔案,沒辦法當文字顯示。".into()); + } + Ok(clip(String::from_utf8_lossy(&bytes).to_string())) +} + +/// A picture file as RGBA pixels: PNG, JPEG, GIF, WebP, BMP, TIFF and ICO are decoded in +/// AnyBot itself and SVG is drawn with resvg, so this works the same on macOS, Windows and +/// Linux. HEIC and AVIF have no pure-Rust decoder: macOS converts them with `sips`; elsewhere +/// they open in another app. +pub fn decode_image(path: &Path) -> Option { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + if ext == "svg" { + let data = std::fs::read(path).ok()?; + let tree = resvg::usvg::Tree::from_data(&data, &resvg::usvg::Options::default()).ok()?; + let size = tree.size(); + let scale = (1600.0 / size.width().max(size.height())).clamp(0.1, 8.0); + let (w, h) = ((size.width() * scale).ceil() as u32, (size.height() * scale).ceil() as u32); + let mut pm = resvg::tiny_skia::Pixmap::new(w.max(1), h.max(1))?; + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut()); + // tiny-skia keeps premultiplied pixels. + let mut px = pm.take(); + for c in px.chunks_exact_mut(4) { + let a = c[3] as u32; + if a > 0 && a < 255 { + for v in &mut c[..3] { + *v = ((*v as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + return image::RgbaImage::from_raw(w.max(1), h.max(1), px); + } + if let Ok(img) = image::ImageReader::open(path).ok()?.with_guessed_format().ok()?.decode() { + return Some(img.to_rgba8()); + } + // HEIC, AVIF and the rest: macOS's sips, else ffmpeg (or ImageMagick) when installed. + let tmp = std::env::temp_dir().join(format!("anybot-view-{}-{}.png", std::process::id(), crate::model::now_ms())); + let tries: Vec = { + let mut v = vec![]; + if cfg!(target_os = "macos") { + let mut c = std::process::Command::new("sips"); + c.args(["-s", "format", "png"]).arg(path).arg("--out").arg(&tmp); + v.push(c); + } + let mut f = std::process::Command::new("ffmpeg"); + f.env("PATH", crate::cloudbox::tool_path()).args(["-loglevel", "error", "-y", "-i"]).arg(path).args(["-frames:v", "1"]).arg(&tmp).stdin(std::process::Stdio::null()); + v.push(f); + let mut m = std::process::Command::new("convert"); + m.arg(format!("{}[0]", path.display())).arg(&tmp); + v.push(m); + let mut h = std::process::Command::new("heif-convert"); + h.arg(path).arg(&tmp); + v.push(h); + v + }; + for mut c in tries { + if c.output().map(|o| o.status.success()).unwrap_or(false) { + if let Ok(i) = image::open(&tmp) { + let _ = std::fs::remove_file(&tmp); + return Some(i.to_rgba8()); + } + } + } + let _ = std::fs::remove_file(&tmp); + None +} + +/// Pages drawn from a PDF, one at a time (the viewer calls this from a worker thread). +/// `width` is the pixel width each page is drawn at. +pub fn pdf_pages(path: &Path, width: f32, max_pages: usize, mut each: impl FnMut(usize, usize, image::RgbaImage) -> bool) -> Result<(), String> { + let data = std::fs::read(path).map_err(|e| format!("讀不到檔案:{e}"))?; + let pdf = hayro::hayro_syntax::Pdf::new(data).map_err(|e| format!("這個 PDF 打不開({e:?})"))?; + let pages = pdf.pages(); + let total = pages.len(); + if total == 0 { + return Err("這個 PDF 沒有頁面".into()); + } + let cache = hayro::RenderCache::new(); + let interp = hayro::hayro_interpret::InterpreterSettings::default(); + let render = hayro::RenderSettings::default(); + for (i, page) in pages.iter().enumerate().take(max_pages) { + let (pw, ph) = page.render_dimensions(); + let scale = (width / pw.max(1.0)).min(4000.0 / ph.max(1.0)).clamp(0.2, 4.0); + let pm = hayro::render(page, &cache, &interp, &render, &hayro::PixmapSettings { x_scale: scale, y_scale: scale, bg_color: hayro::vello_cpu::color::palette::css::WHITE }); + let (w, h) = (pm.width() as u32, pm.height() as u32); + let mut px = pm.take_rgba8(hayro::vello_cpu::peniko::ImageAlphaType::AlphaPremultiplied); + // Paper is opaque: composite onto white (premultiplied: add what alpha left uncovered). + for c in px.chunks_exact_mut(4) { + let rest = 255 - c[3]; + c[0] = c[0].saturating_add(rest); + c[1] = c[1].saturating_add(rest); + c[2] = c[2].saturating_add(rest); + c[3] = 255; + } + let Some(img) = image::RgbaImage::from_raw(w, h, px) else { continue }; + if !each(i, total, img) { + break; + } + } + Ok(()) +} + +/// A Word document (.docx) as Markdown: headings, bulleted and numbered lists, tables, bold +/// and italics. Read straight from the file's XML, so no Word or LibreOffice is needed. +pub fn docx_markdown(path: &Path) -> Result { + use std::io::Read; + let f = std::fs::File::open(path).map_err(|e| format!("讀不到檔案:{e}"))?; + let mut zip = zip::ZipArchive::new(f).map_err(|_| "這個 Word 檔打不開(不是有效的 .docx)".to_string())?; + let mut xml = String::new(); + zip.by_name("word/document.xml").map_err(|_| "這個 Word 檔裡找不到內文".to_string())?.read_to_string(&mut xml).map_err(|e| e.to_string())?; + // Which numbering ids are numbered (not bullets). + let mut numbered = std::collections::HashSet::new(); + if let Ok(mut n) = zip.by_name("word/numbering.xml") { + let mut s = String::new(); + let _ = n.read_to_string(&mut s); + numbered = numbered_lists(&s); + } + Ok(clip(docx_xml_markdown(&xml, &numbered))) +} + +/// numIds whose first level is a number format (decimal, letters…), not a bullet. +fn numbered_lists(numbering: &str) -> std::collections::HashSet { + let mut abs_numbered = std::collections::HashSet::new(); + for chunk in numbering.split(" Option { + let i = tag.find(&format!("{name}=\""))? + name.len() + 2; + let end = tag[i..].find('"')? + i; + Some(tag[i..end].to_string()) +} + +fn unescape(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("'", "'").replace(" ", " ").replace("&", "&") +} + +fn docx_xml_markdown(xml: &str, numbered: &std::collections::HashSet) -> String { + let mut out = String::new(); + // Paragraph state. + let mut para = String::new(); + let mut style = String::new(); + let mut num: Option<(String, usize)> = None; + // The biggest font size in the paragraph (half-points): big text without a heading style is a heading. + let mut big = 0u32; + let (mut bold, mut italic, mut in_rpr, mut in_text) = (false, false, false, false); + let mut counters: std::collections::HashMap<(String, usize), usize> = Default::default(); + // Tables: a stack of (rows, current row, current cell). + let mut table: Vec> = vec![]; + let mut row: Vec = vec![]; + let mut tdepth = 0usize; + let mut i = 0; + let b = xml.as_bytes(); + while i < xml.len() { + if b[i] == b'<' { + let end = match xml[i..].find('>') { + Some(e) => i + e, + None => break, + }; + let tag = &xml[i + 1..end]; + let closing = tag.starts_with('/'); + let name = tag.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or(""); + match (name, closing) { + ("w:p", false) if !tag.ends_with('/') => { + para.clear(); + style.clear(); + num = None; + big = 0; + } + ("w:sz", _) if in_rpr => big = big.max(attr(tag, "w:val").and_then(|v| v.parse().ok()).unwrap_or(0)), + ("w:pStyle", _) => style = attr(tag, "w:val").unwrap_or_default().to_lowercase(), + ("w:numId", _) => { + let id = attr(tag, "w:val").unwrap_or_default(); + let lvl = num.as_ref().map(|n| n.1).unwrap_or(0); + if id != "0" { + num = Some((id, lvl)); + } + } + ("w:ilvl", _) => { + let l = attr(tag, "w:val").and_then(|v| v.parse().ok()).unwrap_or(0); + num = Some((num.map(|n| n.0).unwrap_or_default(), l)); + } + ("w:rPr", false) => in_rpr = !tag.ends_with('/'), + ("w:rPr", true) => in_rpr = false, + ("w:b", _) if in_rpr => bold = attr(tag, "w:val").map(|v| v != "0" && v != "false").unwrap_or(true), + ("w:i", _) if in_rpr => italic = attr(tag, "w:val").map(|v| v != "0" && v != "false").unwrap_or(true), + ("w:r", false) => { + bold = false; + italic = false; + } + ("w:t", false) => in_text = !tag.ends_with('/'), + ("w:t", true) => in_text = false, + ("w:tab", _) => para.push('\t'), + ("w:br", _) | ("w:cr", _) => para.push_str(if tdepth > 0 { " " } else { " \n" }), + ("w:p", true) => { + let mut text = para.trim().to_string(); + // Lists typed as "•item". + if let Some(rest) = text.strip_prefix('•').or_else(|| text.strip_prefix('·')).or_else(|| text.strip_prefix('▪')) { + text = format!("- {}", rest.trim_start()); + } + if tdepth > 0 { + if let Some(c) = row.last_mut() { + if !c.is_empty() && !text.is_empty() { + c.push(' '); + } + c.push_str(&text); + } + } else if !text.is_empty() { + let heading = style.strip_prefix("heading").and_then(|n| n.parse::().ok()).or(if style == "title" { Some(1) } else if style == "subtitle" { Some(2) } else { None }) + .or(if text.chars().count() <= 80 && !text.starts_with("- ") { + match big { + 40.. => Some(1), + 32..=39 => Some(2), + 28..=31 => Some(3), + _ => None, + } + } else { + None + }); + if let Some(h) = heading { + out.push_str(&format!("{} {}\n\n", "#".repeat(h.clamp(1, 3)), text.replace("**", ""))); + } else if style.starts_with("listbullet") || style.starts_with("清單項目符號") { + out.push_str(&format!("- {text}\n")); + } else if style.starts_with("listnumber") || style.starts_with("清單號碼") { + let c = counters.entry((style.clone(), 0)).or_insert(0); + *c += 1; + out.push_str(&format!("{}. {text}\n", c)); + } else if text.starts_with("- ") { + out.push_str(&text); + out.push('\n'); + } else if let Some((id, lvl)) = num.clone().filter(|n| !n.0.is_empty()) { + let indent = " ".repeat(lvl); + if numbered.contains(&id) { + let c = counters.entry((id, lvl)).or_insert(0); + *c += 1; + out.push_str(&format!("{indent}{}. {text}\n", c)); + } else { + out.push_str(&format!("{indent}- {text}\n")); + } + } else { + if out.ends_with('\n') && !out.ends_with("\n\n") && !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + out.push_str("\n\n"); + } + } + para.clear(); + } + ("w:tbl", false) => { + tdepth += 1; + if tdepth == 1 { + table.clear(); + } + } + ("w:tbl", true) => { + tdepth = tdepth.saturating_sub(1); + if tdepth == 0 && !table.is_empty() { + out.push('\n'); + out.push_str(&md_table(&table)); + out.push('\n'); + } + } + ("w:tr", false) if tdepth == 1 => row.clear(), + ("w:tr", true) if tdepth == 1 => table.push(std::mem::take(&mut row)), + ("w:tc", false) if tdepth == 1 => row.push(String::new()), + _ => {} + } + i = end + 1; + } else { + let next = xml[i..].find('<').map(|n| i + n).unwrap_or(xml.len()); + if in_text { + let t = unescape(&xml[i..next]); + if bold && !t.trim().is_empty() { + para.push_str(&format!("**{t}**")); + } else if italic && !t.trim().is_empty() { + para.push_str(&format!("*{t}*")); + } else { + para.push_str(&t); + } + } + i = next; + } + } + out.replace("****", "").trim().to_string() +} + +/// Rows → a Markdown table (the first row as header). +fn md_table(rows: &[Vec]) -> String { + let n = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); + let line = |r: &Vec| { + let mut cells: Vec = r.iter().map(|c| c.replace('|', "\\|").replace('\n', " ")).collect(); + cells.resize(n, String::new()); + format!("| {} |\n", cells.join(" | ")) + }; + let mut s = line(&rows[0]); + s.push_str(&format!("|{}\n", " --- |".repeat(n))); + for r in &rows[1..] { + s.push_str(&line(r)); + } + s +} + +/// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)"). +/// Scripts, styles and the are dropped. +pub fn html_markdown(html: &str) -> String { + let mut s = html.to_string(); + for tag in ["head", "script", "style", "noscript", "svg", "template"] { + loop { + let low = s.to_lowercase(); + let Some(a) = low.find(&format!("<{tag}")).filter(|a| low[a + tag.len() + 1..].starts_with(|c: char| c == '>' || c.is_whitespace())) else { break }; + let end = low[a..].find(&format!("")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len()); + s.replace_range(a..end, ""); + } + } + let mut out = String::new(); + let mut lists: Vec<(bool, usize)> = vec![]; + let mut table: Vec> = vec![]; + let mut row: Vec = vec![]; + let mut in_table = 0usize; + let mut cell = String::new(); + let mut href: Option = None; + let mut pre = false; + let mut i = 0; + let push = |out: &mut String, cell: &mut String, in_table: usize, t: &str| { + if in_table > 0 { + cell.push_str(t) + } else { + out.push_str(t) + } + }; + while i < s.len() { + if s.as_bytes()[i] == b'<' { + let Some(e) = s[i..].find('>') else { break }; + let tag = s[i + 1..i + e].to_string(); + i += e + 1; + if tag.starts_with('!') { + continue; + } + let closing = tag.starts_with('/'); + let name = tag.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_lowercase(); + match (name.as_str(), closing) { + ("h1" | "h2" | "h3" | "h4" | "h5" | "h6", false) => { + let n: usize = name[1..].parse().unwrap_or(1); + out.push_str(&format!("\n\n{} ", "#".repeat(n.min(3)))); + } + ("h1" | "h2" | "h3" | "h4" | "h5" | "h6", true) => out.push_str("\n\n"), + ("p" | "div" | "section" | "article" | "header" | "footer" | "main" | "nav" | "blockquote" | "figure", _) => { + if in_table > 0 { + cell.push(' ') + } else { + out.push_str("\n\n") + } + } + ("br", _) => push(&mut out, &mut cell, in_table, if in_table > 0 { " " } else { "\n" }), + ("hr", _) => out.push_str("\n\n---\n\n"), + ("ul", false) => lists.push((false, 0)), + ("ol", false) => lists.push((true, 0)), + ("ul" | "ol", true) => { + lists.pop(); + out.push('\n'); + } + ("li", false) => { + let depth = lists.len().saturating_sub(1); + let marker = match lists.last_mut() { + Some((true, n)) => { + *n += 1; + format!("{n}. ") + } + _ => "- ".into(), + }; + out.push_str(&format!("\n{}{marker}", " ".repeat(depth))); + } + ("b" | "strong", _) => push(&mut out, &mut cell, in_table, "**"), + ("pre", false) => { + pre = true; + out.push_str("\n\n```\n"); + } + ("pre", true) => { + pre = false; + out.push_str("\n```\n\n"); + } + ("a", false) => href = attr(&tag.replace('\'', "\""), "href").filter(|h| h.starts_with("http")), + ("a", true) => { + if let Some(h) = href.take() { + push(&mut out, &mut cell, in_table, &format!(" ({h})")); + } + } + ("img", _) => { + if let Some(alt) = attr(&tag, "alt").filter(|a| !a.trim().is_empty()) { + push(&mut out, &mut cell, in_table, &format!("[圖片:{alt}]")); + } + } + ("table", false) => { + in_table += 1; + if in_table == 1 { + table.clear(); + } + } + ("table", true) => { + in_table = in_table.saturating_sub(1); + if in_table == 0 && !table.is_empty() { + out.push_str("\n\n"); + out.push_str(&md_table(&table)); + out.push('\n'); + } + } + ("tr", false) if in_table == 1 => row.clear(), + ("tr", true) if in_table == 1 => { + if !row.is_empty() { + table.push(std::mem::take(&mut row)); + } + } + ("td" | "th", false) if in_table == 1 => cell.clear(), + ("td" | "th", true) if in_table == 1 => row.push(unescape(&std::mem::take(&mut cell)).split_whitespace().collect::>().join(" ")), + _ => {} + } + } else { + let next = s[i..].find('<').map(|n| i + n).unwrap_or(s.len()); + let raw = &s[i..next]; + let t = if pre { unescape(raw) } else { unescape(&raw.split_whitespace().collect::>().join(" ")) }; + let t = if !pre && raw.starts_with(char::is_whitespace) && !t.is_empty() { format!(" {t}") } else { t }; + let t = if !pre && raw.ends_with(char::is_whitespace) && !t.trim().is_empty() { format!("{t} ") } else { t }; + push(&mut out, &mut cell, in_table, &t); + i = next; + } + } + let mut lines: Vec = out.lines().map(|l| l.trim_end().to_string()).map(|l| if l.trim_start().starts_with(['-', '#', '|']) || l.trim_start().chars().next().is_some_and(|c| c.is_ascii_digit()) { l } else { l.trim_start().to_string() }).collect(); + lines.dedup_by(|a, b| a.is_empty() && b.is_empty()); + clip(lines.join("\n").replace("** **", " ").replace("****", "").trim().to_string()) +} + +fn load_sheet(path: &Path, name: &str) -> Result { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + if ext == "csv" || ext == "tsv" { + let text = read_text(path)?; + let sep = if ext == "tsv" { '\t' } else { ',' }; + let rows: Vec> = text.lines().take(MAX_ROWS).map(|l| split_csv(l, sep).into_iter().take(MAX_COLS).collect()).collect(); + return Ok(Preview::Table(vec![("工作表".into(), rows)])); + } + use calamine::{open_workbook_auto, Data, Reader}; + let mut wb = open_workbook_auto(path).map_err(|e| format!("打不開這個試算表:{e}"))?; + let mut sheets = vec![]; + for sheet in wb.sheet_names().to_vec() { + let Ok(range) = wb.worksheet_range(&sheet) else { continue }; + let rows: Vec> = range + .rows() + .take(MAX_ROWS) + .map(|r| { + r.iter() + .take(MAX_COLS) + .map(|c| match c { + Data::Empty => String::new(), + Data::Float(f) if f.fract() == 0.0 && f.abs() < 1e15 => format!("{}", *f as i64), + Data::DateTime(d) => excel_date(d.as_f64()), + other => other.to_string(), + }) + .collect() + }) + .collect(); + sheets.push((sheet, rows)); + } + if sheets.is_empty() { + return Err("這個試算表是空的".into()); + } + Ok(Preview::Table(sheets)) +} + +/// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13". +pub fn excel_date(v: f64) -> String { + let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap(); + let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64); + if v.fract().abs() < 1e-9 { + t.format("%Y-%m-%d").to_string() + } else { + t.format("%Y-%m-%d %H:%M").to_string() + } +} + +/// One CSV line → cells (quotes and doubled quotes handled). +pub fn split_csv(line: &str, sep: char) -> Vec { + let mut out = vec![]; + let mut cur = String::new(); + let mut quoted = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + c if c == sep && !quoted => out.push(std::mem::take(&mut cur)), + c => cur.push(c), + } + } + out.push(cur); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csv_html_and_kinds() { + assert_eq!(split_csv(r#"a,"b, c","say ""hi""",d"#, ','), vec!["a", "b, c", "say \"hi\"", "d"]); + let t = html_markdown("x

標題

第一段&更多

  • 一
  • 二
  1. 甲
AB
12
"); + assert!(t.contains("# 標題") && t.contains("第一段&**更多**") && t.contains("- 一") && t.contains("1. 甲") && t.contains("| A | B |") && t.contains("| 1 | 2 |") && !t.contains("x()"), "{t}"); + assert_eq!(kind_of("報表.XLSX"), "sheet"); + for (f, k) in [("a.svg", "image"), ("a.heic", "image"), ("a.webm", "video"), ("a.opus", "audio"), ("a.docx", "docx"), ("a.json", "json"), ("a.mdx", "markdown"), ("Dockerfile", "text"), ("a.tsx", "text"), ("a.zip", "other")] { + assert_eq!(kind_of(f), k, "{f}"); + } + let docx = r#"報告內容 重點第一項步驟名稱數量蘋果 & 梨3"#; + let numbering = r#""#; + let md = docx_xml_markdown(docx, &numbered_lists(numbering)); + assert!(md.contains("# 報告") && md.contains("內容 **重點**") && md.contains("- 第一項") && md.contains("1. 步驟") && md.contains("| 名稱 | 數量 |") && md.contains("| 蘋果 & 梨 | 3 |"), "{md}"); + assert_eq!(excel_date(46326.0), "2026-10-31"); + assert_eq!(kind_label("notes.md"), "Markdown"); + let dir = std::env::temp_dir().join(format!("anybot-preview-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("a.csv"); + std::fs::write(&csv, "名稱,數量\n蘋果,3\n").unwrap(); + match load(&csv, "a.csv") { + Preview::Table(s) => assert_eq!(s[0].1[1], vec!["蘋果", "3"]), + other => panic!("{other:?}"), + } + let md = dir.join("n.md"); + std::fs::write(&md, "# 你好").unwrap(); + assert_eq!(load(&md, "n.md"), Preview::Markdown("# 你好".into())); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Every sample file in $ANYBOT_FIXTURES opens: `cargo test --release fixtures -- --ignored --nocapture`. + #[test] + #[ignore] + fn fixtures() { + let dir = std::env::var("ANYBOT_FIXTURES").expect("ANYBOT_FIXTURES"); + let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).collect(); + names.sort(); + let mut bad = vec![]; + for path in names { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let started = std::time::Instant::now(); + let what = match load(&path, &name) { + Preview::Image => decode_image(&path).map(|i| format!("image {}x{}", i.width(), i.height())).unwrap_or_else(|| "IMAGE FAILED".into()), + Preview::Pdf => { + let words = crate::attach::read_text(&path.display().to_string(), &name).map(|t| t.chars().filter(|c| !c.is_whitespace()).take(12).collect::()).unwrap_or_else(|| "NO TEXT".into()); + println!(" pdf text for the bot: {words}"); + let mut n = (0, 0, 0); + match pdf_pages(&path, 1400.0, 300, |_, total, img| { + n = (n.0 + 1, total, img.height()); + true + }) { + Ok(()) => format!("pdf {} of {} pages, {}px tall", n.0, n.1, n.2), + Err(e) => format!("PDF FAILED {e}"), + } + } + Preview::Audio => { + let f = std::fs::File::open(&path).unwrap(); + match rodio::Decoder::try_from(f) { + Ok(d) => format!("audio {:?}", rodio::Source::total_duration(&d)), + Err(e) => format!("AUDIO FAILED {e}"), + } + } + Preview::Other(m) => format!("OTHER {m}"), + Preview::Table(t) => format!("table {} rows", t[0].1.len()), + Preview::Markdown(m) | Preview::Html(m) => format!("doc {:?} / bot reads: {}", m.chars().take(50).collect::(), crate::attach::read_text(&path.display().to_string(), &name).map(|t| t.chars().count()).unwrap_or(0)), + Preview::Text(t) => format!("text {:?}", t.chars().take(40).collect::()), + Preview::Video => "video".into(), + }; + if what.contains("FAILED") || what.starts_with("OTHER") { + bad.push(name.clone()); + } + println!("{name:10} {:>5}ms {what}", started.elapsed().as_millis()); + } + println!("not opening: {bad:?}"); + } +} diff --git a/src/providers.rs b/src/providers.rs new file mode 100644 index 0000000..a888237 --- /dev/null +++ b/src/providers.rs @@ -0,0 +1,1253 @@ +// The switchable AI providers and how to talk to each one. +// Every provider streams its reply piece by piece over Server-Sent Events. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; + +use std::time::Duration; + +use crate::model::{Message, Role, ToolCall, ToolStatus}; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)] +pub enum ProviderKind { + Xai, + OpenAi, + Anthropic, + Gemini, + OpenRouter, + OpenCodeGo, + Ollama, + Custom, +} + +pub const ALL_PROVIDERS: [ProviderKind; 8] = [ + ProviderKind::Xai, + ProviderKind::OpenAi, + ProviderKind::Anthropic, + ProviderKind::Gemini, + ProviderKind::OpenRouter, + ProviderKind::OpenCodeGo, + ProviderKind::Ollama, + ProviderKind::Custom, +]; + +/// How long to wait for the first bytes before giving up +/// (the original shows "模型未能及時開始回應" in that case; its deadline is server-side: estimated). +pub const FIRST_RESPONSE_TIMEOUT_SECS: u64 = 120; +/// Connect deadline: main-core.cjs @1082927, `$D=15e3` (gateway-sse-connect). +pub const CONNECT_TIMEOUT_MS: u64 = 15_000; +/// Mid-stream silence that counts as a stall: main-core.cjs @1082927, `VD=35e3` (gateway-sse-stall idleMs). +pub const STREAM_IDLE_STALL_MS: u64 = 35_000; +/// Retry policy: main-core.cjs @1082927, `D3={maxAttempts:3,initialDelayMs:1e3,maxDelayMs:4e3,backoffFactor:2}`. +pub const RETRY_MAX_ATTEMPTS: u32 = 3; +pub const RETRY_INITIAL_DELAY_MS: u64 = 1_000; +pub const RETRY_MAX_DELAY_MS: u64 = 4_000; +pub const RETRY_BACKOFF_FACTOR: u64 = 2; +/// Longest server-paced Retry-After we honour (SAND-E0408 backendCapacityDeferred): estimated cap. +pub const RETRY_AFTER_CAP_MS: u64 = 30_000; +pub const MAX_OUTPUT_TOKENS_ANTHROPIC: u64 = 64000; +pub const ANTHROPIC_VERSION: &str = "2026-10-21"; +/// OpenCode Go (opencode.ai/docs/go): one subscription, many open models. Each model is served +/// in its own API style — the public models list says which (`chat-completions` → {base}/openai/v1, +/// `messages` → {base}/anthropic/v1); every request carries a stable `x-opencode-session`. +pub const OPENCODE_GO_BASE: &str = "https://opencode.ai/inference/go"; + +impl ProviderKind { + pub fn key(self) -> &'static str { + match self { + ProviderKind::Xai => "xai", + ProviderKind::OpenAi => "openai", + ProviderKind::Anthropic => "anthropic", + ProviderKind::Gemini => "gemini", + ProviderKind::OpenRouter => "openrouter", + ProviderKind::OpenCodeGo => "opencode-go", + ProviderKind::Ollama => "ollama", + ProviderKind::Custom => "custom", + } + } + + pub fn label(self) -> &'static str { + match self { + ProviderKind::Xai => "xAI Grok", + ProviderKind::OpenAi => "OpenAI", + ProviderKind::Anthropic => "Claude", + ProviderKind::Gemini => "Google Gemini", + ProviderKind::OpenRouter => "OpenRouter", + ProviderKind::OpenCodeGo => "OpenCode Go", + ProviderKind::Ollama => "Ollama(本機)", + ProviderKind::Custom => "自訂(OpenAI 相容)", + } + } + + pub fn default_base_url(self) -> &'static str { + match self { + ProviderKind::Xai => "https://api.x.ai/v1", + ProviderKind::OpenAi => "https://api.openai.com/v1", + ProviderKind::Anthropic => "https://api.anthropic.com/v1", + ProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai", + ProviderKind::OpenRouter => "https://openrouter.ai/api/v1", + // The models list is {base}/v1/models; chat goes to {base}/openai/v1 or {base}/anthropic/v1. + ProviderKind::OpenCodeGo => OPENCODE_GO_BASE, + ProviderKind::Ollama => "http://localhost:11434/v1", + ProviderKind::Custom => "http://localhost:8080/v1", + } + } + + pub fn suggested_model(self) -> &'static str { + match self { + ProviderKind::Xai => "grok-4", + ProviderKind::OpenAi => "gpt-5", + ProviderKind::Anthropic => "claude-opus-5-5", + ProviderKind::Gemini => "gemini-2.5-flash", + ProviderKind::OpenRouter => "openrouter/auto", + ProviderKind::OpenCodeGo => "kimi-k3", + ProviderKind::Ollama => "llama3.2", + ProviderKind::Custom => "local-model", + } + } + + /// A few names to pick from before the real list is fetched. + pub fn starter_models(self) -> &'static [&'static str] { + match self { + ProviderKind::Xai => &["grok-4", "grok-4-fast", "grok-3-mini"], + ProviderKind::OpenAi => &["gpt-5", "gpt-5-mini", "gpt-4.1"], + ProviderKind::Anthropic => &["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"], + ProviderKind::Gemini => &["gemini-2.5-flash", "gemini-2.5-pro"], + ProviderKind::OpenRouter => &["openrouter/auto"], + ProviderKind::OpenCodeGo => &["kimi-k3", "glm-5.3", "deepseek-v4-pro", "qwen3.8-max", "minimax-m3", "mimo-v2.6-pro"], + ProviderKind::Ollama => &["llama3.2", "qwen2.5", "gemma3"], + ProviderKind::Custom => &[], + } + } + + pub fn needs_key(self) -> bool { + !matches!(self, ProviderKind::Ollama | ProviderKind::Custom) + } + + pub fn env_var(self) -> Option<&'static str> { + match self { + ProviderKind::Xai => Some("XAI_API_KEY"), + ProviderKind::OpenAi => Some("OPENAI_API_KEY"), + ProviderKind::Anthropic => Some("ANTHROPIC_API_KEY"), + ProviderKind::Gemini => Some("GEMINI_API_KEY"), + ProviderKind::OpenRouter => Some("OPENROUTER_API_KEY"), + ProviderKind::OpenCodeGo => Some("OPENCODE_API_KEY"), + _ => None, + } + } + + pub fn key_help(self) -> &'static str { + match self { + ProviderKind::Xai => "在 console.x.ai 取得 API 金鑰", + ProviderKind::OpenAi => "在 platform.openai.com 取得 API 金鑰", + ProviderKind::Anthropic => "在 console.anthropic.com 取得 API 金鑰", + ProviderKind::Gemini => "在 aistudio.google.com 取得 API 金鑰", + ProviderKind::OpenRouter => "在 openrouter.ai 取得 API 金鑰", + ProviderKind::OpenCodeGo => "在 opencode.ai 的 Console 訂閱 Go,再複製 API 金鑰", + ProviderKind::Ollama => "先安裝並開啟 Ollama,不需要金鑰", + ProviderKind::Custom => "任何 OpenAI 相容的服務(LM Studio、vLLM…)", + } + } +} + +/// Everything one model call needs, copied so the worker thread owns it. +#[derive(Clone)] +pub struct ChatRequest { + pub kind: ProviderKind, + pub base_url: String, + pub api_key: String, + pub model: String, + pub system: String, + pub history: Vec, + /// Tool specs ({name, description, parameters}); empty for plain chat. + pub tools: Vec, + /// One id per conversation (OpenCode Go routes and caches by it). + pub session: String, +} + +pub enum StreamEvent { + Text(String), + Thinking(String), + /// Any line from the provider (keeps the stall watchdog quiet). + Alive, + /// A retry is about to happen; the text says why. + Retrying(String), + /// The model asked for a tool; shown at once, run after the step ends. + ToolCall(ToolCall), + /// A tool's state changed (waiting for approval, running, done…). + ToolStatus(String, ToolStatus), + /// Ask the person before running a tool; answer on the sender. + NeedApproval(Approval), + /// One model step finished: replace the streaming reply with the full message. + StepDone(Message), + /// A tool result to append to the chat. + ToolResult(Message), + /// The next model step starts: add a fresh reply bubble. + NextStep(String), + /// The oldest `n` messages were folded into this summary. + Compacted(usize, Message), + /// A message the person sent mid-turn was handed to the model (add it to the chat). + Steered(Message), + /// Do something in the app (routines, bots, settings) and answer on the sender. + AppAction(AppCall), + /// 表情回應: the bot put an emoji on the person's latest message. + React(String), + /// The bot sent a file into the chat (a file card under its reply). + Attach(crate::attach::Attachment), + Done, + Error(String), +} + +pub struct Approval { + pub call_id: String, + pub tool: String, + pub reason: String, + /// What "always allow" would remember; empty when it can't be remembered. + pub grant: String, + pub reply: Sender, + pub kind: ApprovalKind, +} + +/// What the person is asked. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum ApprovalKind { + /// One action (run this command, edit this file…). + #[default] + Action, + /// This bot's computer tools are off: let it use them? ("always" turns them on for the bot.) + EnableTools, + /// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.) + EnableThisComputer, +} + +pub struct AppCall { + pub call_id: String, + pub name: String, + pub args: String, + /// (result text, ok) + pub reply: Sender<(String, bool)>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ApprovalAnswer { + Once, + Always(String), + Reject, +} + +/// What one model call produced. +#[derive(Default, Debug)] +pub struct StepOutput { + pub text: String, + pub thinking: String, + pub tool_calls: Vec, + /// Claude's raw content blocks, kept to replay thinking signatures unchanged. + pub blocks: Vec, +} + +/// Why a request failed, sorted the way the original's error catalog sorts them +/// (main-core.cjs @689060..692409, SAND-E0401..E0418). +pub struct Failure { + pub msg: String, + pub retryable: bool, + pub retry_after_ms: Option, +} + +impl Failure { + fn retry(msg: String) -> Self { + Failure { msg, retryable: true, retry_after_ms: None } + } + fn fatal(msg: String) -> Self { + Failure { msg, retryable: false, retry_after_ms: None } + } +} + +/// Delay before retry `n` (1-based): 1 s, 2 s, 4 s, capped at 4 s. +pub fn backoff_delay_ms(retry: u32) -> u64 { + (RETRY_INITIAL_DELAY_MS * RETRY_BACKOFF_FACTOR.pow(retry.saturating_sub(1))).min(RETRY_MAX_DELAY_MS) +} + +fn agent() -> ureq::Agent { + let config = ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_connect(Some(Duration::from_millis(CONNECT_TIMEOUT_MS))) + .timeout_recv_response(Some(Duration::from_secs(FIRST_RESPONSE_TIMEOUT_SECS))) + .build(); + config.into() +} + +pub fn resolve_key(kind: ProviderKind, stored: &str) -> String { + if !stored.trim().is_empty() { + return stored.trim().to_string(); + } + kind.env_var().and_then(|v| std::env::var(v).ok()).unwrap_or_default() +} + +pub fn resolve_base(kind: ProviderKind, stored: &str) -> String { + let b = if stored.trim().is_empty() { kind.default_base_url() } else { stored.trim() }; + b.trim_end_matches('/').to_string() +} + +/// Claude models that take adaptive thinking (older ones reject it). +fn claude_adaptive(model: &str) -> bool { + ["claude-opus-5", "claude-sonnet-5", "claude-haiku-5", "claude-fable-5", "claude-mythos-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-sonnet-4-6"] + .iter() + .any(|p| model.starts_with(p)) +} + +/// Claude models where the server can retry a declined request on another model. +fn claude_fallbacks(model: &str) -> bool { + ["claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5"].contains(&model) +} + +fn http_error(status: u16, body: &str, retry_after: Option) -> Failure { + let detail = serde_json::from_str::(body) + .ok() + .and_then(|v| { + v.pointer("/error/message") + .or_else(|| v.pointer("/message")) + .or_else(|| v.get("error")) + .map(|m| m.as_str().map(str::to_string).unwrap_or_else(|| m.to_string())) + }) + .unwrap_or_else(|| body.chars().take(300).collect()); + let hint = match status { + 401 | 403 => "金鑰無效或沒有權限,請到「設定 → AI 供應商」檢查。", + 404 => "找不到這個模型或網址,請檢查模型名稱。", + 429 => "模型供應商已超載或用量已達上限。通常是暫時的,請稍後再試。", + 500..=599 => "模型供應商目前負載過高。通常是暫時的。", + _ => "", + }; + let lower = detail.to_lowercase(); + // SAND-E0404 contextWindowOverflow: a retry would repeat it. + if (status == 400 || status == 413) && ["context", "too long", "too many tokens", "maximum context", "prompt is too long"].iter().any(|k| lower.contains(k)) { + return Failure::fatal(format!("對話太長了,超過這個模型能讀的長度。請清除聊天記錄或換一個模型。\n({detail})")); + } + if status == 400 && lower.contains("tool") && (lower.contains("support") || lower.contains("not available")) { + return Failure::fatal(format!("這個模型不支援工具。請換一個支援工具的模型,或在 Bot 設定裡關掉「電腦操作」。\n({detail})")); + } + let msg = format!("錯誤 {status}:{detail}\n{hint}").trim().to_string(); + // SAND-E0401 providerOverloaded / SAND-E0408 backendCapacityDeferred: retry. + if status == 429 || status == 408 || status == 529 || status >= 500 { + Failure { msg, retryable: true, retry_after_ms: retry_after } + } else { + Failure::fatal(msg) + } +} + +fn retry_after_ms(resp: &ureq::http::Response) -> Option { + let v = resp.headers().get("retry-after")?.to_str().ok()?; + v.trim().parse::().ok().map(|s| (s * 1000.0) as u64).map(|ms| ms.min(RETRY_AFTER_CAP_MS)) +} + +/// One model call with the original's retry rules: retry overloads, resets and unreachable +/// backends up to 3 tries (1 s, 2 s, 4 s or Retry-After), but never once output reached the screen. +pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Result { + // The bot's instructions and tool descriptions in this system's words ("this computer" on Linux). + #[cfg(not(target_os = "macos"))] + let localized = { + let mut r = req.clone(); + r.system = crate::platform::localize(&r.system); + r.tools.iter_mut().for_each(crate::platform::localize_value); + r + }; + #[cfg(not(target_os = "macos"))] + let req = &localized; + if req.kind.needs_key() && req.api_key.is_empty() { + return Err(format!("還沒有設定 {} 的 API 金鑰。請到「設定 → AI 供應商」貼上金鑰。", req.kind.label())); + } + let produced = std::cell::Cell::new(false); + let tracked = |ev: StreamEvent| { + if matches!(ev, StreamEvent::Text(_) | StreamEvent::Thinking(_) | StreamEvent::ToolCall(_)) { + produced.set(true); + } + send(ev); + }; + let mut attempt = 1; + // Steps left without a result (stopped while waiting for the person) are closed off. + let repaired = ChatRequest { history: repair_history(&req.history), ..req.clone() }; + let req = &repaired; + let mut blind_copy: Option = None; + loop { + // A model that can't see pictures gets the request without them. + if !sees_images(req) && blind_copy.is_none() { + blind_copy = Some(without_images(req)); + } + let req = blind_copy.as_ref().unwrap_or(req); + let result = match req.kind { + ProviderKind::Anthropic => stream_anthropic(req, cancel, &tracked), + ProviderKind::OpenCodeGo => { + let mut r = req.clone(); + if opencode_go_style(&req.base_url, &req.model) == "messages" { + r.base_url = format!("{}/anthropic/v1", req.base_url); + stream_anthropic(&r, cancel, &tracked) + } else { + r.base_url = format!("{}/openai/v1", req.base_url); + stream_openai_compatible(&r, cancel, &tracked) + } + } + _ => stream_openai_compatible(req, cancel, &tracked), + }; + match result { + Ok(out) => return Ok(out), + Err(f) if image_refused(&f.msg) && sees_images(req) && !produced.get() && !cancel.load(Ordering::Relaxed) => { + mark_blind(req); + send(StreamEvent::Retrying("這個模型不能看圖片,改成不附圖片再試一次…".into())); + } + Err(f) if f.retryable && !produced.get() && attempt < RETRY_MAX_ATTEMPTS && !cancel.load(Ordering::Relaxed) => { + let wait = f.retry_after_ms.unwrap_or_else(|| backoff_delay_ms(attempt)); + send(StreamEvent::Retrying(format!("{}\n{:.0} 秒後重試(第 {} 次,共 {} 次)…", f.msg, wait as f64 / 1000.0, attempt + 1, RETRY_MAX_ATTEMPTS))); + let until = std::time::Instant::now() + Duration::from_millis(wait); + while std::time::Instant::now() < until { + if cancel.load(Ordering::Relaxed) { + return Ok(StepOutput::default()); + } + std::thread::sleep(Duration::from_millis(100)); + } + attempt += 1; + } + Err(f) => return Err(f.msg), + } + } +} + +fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> { + let reader = BufReader::new(body.into_reader()); + let mut event = String::new(); + for line in reader.lines() { + if cancel.load(Ordering::Relaxed) { + return Ok(()); + } + // SAND-E0403 streamReset: retryable. + let line = line.map_err(|e| Failure::retry(format!("連線中斷:{e}")))?; + send(StreamEvent::Alive); + if let Some(ev) = line.strip_prefix("event:") { + event = ev.trim().to_string(); + } else if let Some(data) = line.strip_prefix("data:") { + if !on_data(&event, data.trim()) { + return Ok(()); + } + } else if line.is_empty() { + event.clear(); + } + } + Ok(()) +} + +/// Summaries become part of the system prompt; everything else stays in order. +fn system_with_summaries(req: &ChatRequest) -> String { + let mut s = req.system.clone(); + for m in req.history.iter().filter(|m| m.summary) { + s.push_str("\n\n先前對話的摘要:\n"); + s.push_str(&m.text); + } + s +} + +/// Which messages still show their screenshots: the newest `KEEP_SCREENSHOTS`. +fn kept_images(history: &[Message]) -> Vec { + let mut idx: Vec = history.iter().enumerate().filter(|(_, m)| !m.images.is_empty() && !m.summary && m.role == Role::Tool).map(|(i, _)| i).collect(); + let drop = idx.len().saturating_sub(crate::computer::KEEP_SCREENSHOTS); + idx.drain(..drop); + idx +} + +const OLD_SCREENSHOT_NOTE: &str = "(較早的截圖已省略)"; +/// Said in place of a screenshot when the model can't take pictures. +const BLIND_SCREENSHOT_NOTE: &str = "(截圖已拍好,但這個模型看不到圖片,所以沒有附上。請改用文字方式確認畫面,例如讀取視窗或網頁的文字。)"; +const BLIND_ATTACHMENT_NOTE: &str = "(使用者附了圖片,但這個模型看不到圖片。)"; + +/// Models (base url + name) that refused a picture: later requests leave pictures out. +static NO_VISION: std::sync::Mutex>> = std::sync::Mutex::new(None); + +fn vision_key(req: &ChatRequest) -> String { + format!("{}|{}", req.base_url, req.model) +} + +pub fn sees_images(req: &ChatRequest) -> bool { + !NO_VISION.lock().unwrap().as_ref().map(|s| s.contains(&vision_key(req))).unwrap_or(false) +} + +fn mark_blind(req: &ChatRequest) { + NO_VISION.lock().unwrap().get_or_insert_with(Default::default).insert(vision_key(req)); +} + +/// The provider said this model can't take images ("At most 0 image(s)…", "does not support +/// image input", "unknown variant `image_url`"…). +pub fn image_refused(msg: &str) -> bool { + let l = msg.to_lowercase(); + let about_images = l.contains("image") || l.contains("vision") || l.contains("multimodal") || l.contains("圖片") || l.contains("图片"); + about_images && ["at most 0", "not support", "unsupported", "doesn't support", "does not support", "no vision", "not a vision", "not multimodal", "unknown variant", "invalid type", "only text", "text-only", "不支援", "不支持"].iter().any(|k| l.contains(k)) +} + +/// Every tool call needs its result right after it (providers refuse the conversation +/// otherwise). A step stopped or refused while it waited for the person has none: give it one. +/// Results whose call is gone are dropped. +pub fn repair_history(history: &[Message]) -> Vec { + let mut out: Vec = Vec::with_capacity(history.len()); + let mut i = 0; + while i < history.len() { + let m = &history[i]; + if m.role == Role::Tool { + // A result with no call right before it. + i += 1; + continue; + } + out.push(m.clone()); + i += 1; + if m.role == Role::Assistant && !m.tool_calls.is_empty() { + let ids: Vec<&str> = m.tool_calls.iter().map(|c| c.id.as_str()).collect(); + let mut seen = vec![]; + while i < history.len() && history[i].role == Role::Tool { + let r = &history[i]; + if ids.contains(&r.tool_call_id.as_str()) && !seen.contains(&r.tool_call_id) { + seen.push(r.tool_call_id.clone()); + out.push(r.clone()); + } + i += 1; + } + for c in &m.tool_calls { + if !seen.contains(&c.id) { + let why = if c.status == ToolStatus::Denied { "(使用者沒有允許這個步驟,所以沒有執行。)" } else { "(這個步驟被中止了,沒有執行完。)" }; + out.push(Message::tool_result(&c.id, &c.name, why)); + } + } + } + } + out +} + +/// The same request with pictures replaced by a short note. +fn without_images(req: &ChatRequest) -> ChatRequest { + let mut r = req.clone(); + for m in r.history.iter_mut().filter(|m| !m.images.is_empty()) { + m.images.clear(); + let note = if m.role == Role::Tool { BLIND_SCREENSHOT_NOTE } else { BLIND_ATTACHMENT_NOTE }; + m.text = if m.text.is_empty() { note.to_string() } else { format!("{}\n{note}", m.text) }; + } + r +} + +pub fn openai_messages(req: &ChatRequest) -> Vec { + let mut messages = vec![]; + let system = system_with_summaries(req); + if !system.trim().is_empty() { + messages.push(json!({"role": "system", "content": system})); + } + let kept = kept_images(&req.history); + // Tool messages carry text only; their screenshots follow as one user message after the run + // of tool results (a user message between tool results would break the call/answer pairs). + let mut pending: Vec = vec![]; + let flush = |messages: &mut Vec, pending: &mut Vec| { + if !pending.is_empty() { + let mut parts = vec![json!({"type": "text", "text": "(上面工具的螢幕截圖)"})]; + parts.append(pending); + messages.push(json!({"role": "user", "content": parts})); + } + }; + for (i, m) in req.history.iter().enumerate().filter(|(_, m)| !m.summary) { + if m.role != Role::Tool { + flush(&mut messages, &mut pending); + } + match m.role { + Role::User if !m.images.is_empty() => { + // Attached pictures go with the person's words. + let mut parts = vec![json!({"type": "text", "text": if m.text.is_empty() { "(附件)".to_string() } else { m.text.clone() }})]; + parts.extend(m.images.iter().map(|b| json!({"type": "image_url", "image_url": {"url": format!("data:image/jpeg;base64,{b}")}}))); + messages.push(json!({"role": "user", "content": parts})); + } + Role::User => { + if !m.text.is_empty() { + messages.push(json!({"role": "user", "content": m.text})); + } + } + Role::Assistant => { + if m.error && m.tool_calls.is_empty() { + continue; + } + if m.tool_calls.is_empty() { + if !m.text.is_empty() { + messages.push(json!({"role": "assistant", "content": m.text})); + } + } else { + let calls: Vec = m.tool_calls.iter().map(|c| json!({"id": c.id, "type": "function", "function": {"name": c.name, "arguments": if c.args.trim().is_empty() { "{}" } else { &c.args }}})).collect(); + messages.push(json!({"role": "assistant", "content": if m.text.is_empty() { Value::Null } else { json!(m.text) }, "tool_calls": calls})); + } + } + Role::Tool => { + let shown = kept.contains(&i); + let text = if !m.images.is_empty() && !shown { format!("{}\n{OLD_SCREENSHOT_NOTE}", m.text) } else { m.text.clone() }; + messages.push(json!({"role": "tool", "tool_call_id": m.tool_call_id, "content": text})); + if shown { + pending.extend(m.images.iter().map(|b| json!({"type": "image_url", "image_url": {"url": format!("data:image/jpeg;base64,{b}")}}))); + } + } + } + } + flush(&mut messages, &mut pending); + messages +} + +fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Result { + let mut body = json!({"model": req.model, "messages": openai_messages(req), "stream": true}); + if !req.tools.is_empty() { + body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::>()); + } + let url = format!("{}/chat/completions", req.base_url); + let mut r = agent().post(&url).header("Content-Type", "application/json"); + if !req.api_key.is_empty() { + r = r.header("Authorization", &format!("Bearer {}", req.api_key)); + } + if req.kind == ProviderKind::OpenRouter { + r = r.header("X-Title", "AnyBot"); + } + if req.kind == ProviderKind::OpenCodeGo { + r = r.header("x-opencode-session", &opencode_session(req)); + } + // SAND-E0418 turnBackendUnreachable: retryable. + let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?; + let status = resp.status().as_u16(); + if status >= 400 { + let ra = retry_after_ms(&resp); + let text = resp.into_body().read_to_string().unwrap_or_default(); + return Err(http_error(status, &text, ra)); + } + let mut out = StepOutput::default(); + // Tool calls arrive in pieces keyed by index. + let mut pending: Vec<(String, String, String)> = vec![]; + let mut error = None; + read_sse(resp.into_body(), cancel, send, |_ev, data| { + if data == "[DONE]" { + return false; + } + if let Ok(v) = serde_json::from_str::(data) { + if let Some(e) = v.get("error") { + error = Some(e.get("message").and_then(Value::as_str).unwrap_or("未知錯誤").to_string()); + return false; + } + let delta = &v["choices"][0]["delta"]; + for k in ["reasoning_content", "reasoning"] { + if let Some(t) = delta.get(k).and_then(Value::as_str) { + if !t.is_empty() { + out.thinking.push_str(t); + send(StreamEvent::Thinking(t.to_string())); + } + } + } + if let Some(t) = delta.get("content").and_then(Value::as_str) { + if !t.is_empty() { + out.text.push_str(t); + send(StreamEvent::Text(t.to_string())); + } + } + if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) { + for c in calls { + let i = c["index"].as_u64().unwrap_or(pending.len() as u64) as usize; + while pending.len() <= i { + pending.push(Default::default()); + } + if let Some(id) = c["id"].as_str() { + pending[i].0 = id.to_string(); + } + if let Some(n) = c["function"]["name"].as_str() { + pending[i].1.push_str(n); + } + if let Some(a) = c["function"]["arguments"].as_str() { + pending[i].2.push_str(a); + } + } + } + } + true + })?; + if let Some(e) = error { + return Err(Failure::fatal(e)); + } + for (i, (id, name, args)) in pending.into_iter().enumerate() { + if name.is_empty() { + continue; + } + let id = if id.is_empty() { format!("call_{i}") } else { id }; + let call = ToolCall { id, name, args, status: ToolStatus::Waiting }; + send(StreamEvent::ToolCall(call.clone())); + out.tool_calls.push(call); + } + Ok(out) +} + +pub fn anthropic_messages(req: &ChatRequest) -> Vec { + let mut messages: Vec = vec![]; + let mut push = |role: &str, blocks: Vec| { + if blocks.is_empty() { + return; + } + // The API wants user/assistant turns to alternate; merge repeats. + if let Some(last) = messages.last_mut() { + if last["role"] == role { + last["content"].as_array_mut().unwrap().extend(blocks); + return; + } + } + messages.push(json!({"role": role, "content": blocks})); + }; + let kept = kept_images(&req.history); + for (i, m) in req.history.iter().enumerate().filter(|(_, m)| !m.summary) { + match m.role { + Role::Tool if !m.images.is_empty() => { + let content = if kept.contains(&i) { + let mut c = vec![json!({"type": "text", "text": m.text})]; + c.extend(m.images.iter().map(|b| json!({"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": b}}))); + json!(c) + } else { + json!(format!("{}\n{OLD_SCREENSHOT_NOTE}", m.text)) + }; + push("user", vec![json!({"type": "tool_result", "tool_use_id": m.tool_call_id, "content": content})]); + } + Role::User if !m.images.is_empty() => { + let mut b = vec![json!({"type": "text", "text": if m.text.is_empty() { "(附件)".to_string() } else { m.text.clone() }})]; + b.extend(m.images.iter().map(|x| json!({"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": x}}))); + push("user", b); + } + Role::User if !m.text.is_empty() => push("user", vec![json!({"type": "text", "text": m.text})]), + Role::User => {} + Role::Assistant => { + if !m.blocks.is_empty() { + push("assistant", m.blocks.clone()); + } else if !m.error || !m.tool_calls.is_empty() { + let mut b = vec![]; + if !m.text.is_empty() { + b.push(json!({"type": "text", "text": m.text})); + } + for c in &m.tool_calls { + b.push(json!({"type": "tool_use", "id": c.id, "name": c.name, "input": serde_json::from_str::(&c.args).unwrap_or(json!({}))})); + } + push("assistant", b); + } + } + Role::Tool => push("user", vec![json!({"type": "tool_result", "tool_use_id": m.tool_call_id, "content": m.text})]), + } + } + messages +} + +fn stream_anthropic(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Result { + let mut body = json!({ + "model": req.model, + "max_tokens": MAX_OUTPUT_TOKENS_ANTHROPIC, + "messages": anthropic_messages(req), + "stream": true, + }); + let system = system_with_summaries(req); + if !system.trim().is_empty() { + body["system"] = json!(system); + } + if claude_adaptive(&req.model) { + body["thinking"] = json!({"type": "adaptive", "display": "summarized"}); + } + if !req.tools.is_empty() { + body["tools"] = json!(req.tools.iter().map(|t| json!({"name": t["name"], "description": t["description"], "input_schema": t["parameters"]})).collect::>()); + } + let mut r = agent() + .post(&format!("{}/messages", req.base_url)) + .header("Content-Type", "application/json") + .header("x-api-key", &req.api_key) + .header("anthropic-version", ANTHROPIC_VERSION); + if req.kind == ProviderKind::OpenCodeGo { + // Its Anthropic-style door takes the key as a bearer token too. + r = r.header("Authorization", &format!("Bearer {}", req.api_key)).header("x-opencode-session", &opencode_session(req)); + } + if claude_fallbacks(&req.model) { + body["fallbacks"] = json!("default"); + r = r.header("anthropic-beta", "server-side-fallback-2026-07-01"); + } + // SAND-E0418 turnBackendUnreachable: retryable. + let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?; + let status = resp.status().as_u16(); + if status >= 400 { + let ra = retry_after_ms(&resp); + let text = resp.into_body().read_to_string().unwrap_or_default(); + return Err(http_error(status, &text, ra)); + } + let mut out = StepOutput::default(); + let mut blocks: Vec = vec![]; + let mut partial_json: Vec = vec![]; + let mut error = None; + read_sse(resp.into_body(), cancel, send, |ev, data| { + let Ok(v) = serde_json::from_str::(data) else { return true }; + let kind = if ev.is_empty() { v["type"].as_str().unwrap_or("") } else { ev }; + match kind { + "content_block_start" => { + let i = v["index"].as_u64().unwrap_or(blocks.len() as u64) as usize; + while blocks.len() <= i { + blocks.push(Value::Null); + partial_json.push(String::new()); + } + blocks[i] = v["content_block"].clone(); + } + "content_block_delta" => { + let i = v["index"].as_u64().unwrap_or(0) as usize; + let d = &v["delta"]; + let block = blocks.get_mut(i); + match d["type"].as_str() { + Some("text_delta") => { + let t = d["text"].as_str().unwrap_or(""); + out.text.push_str(t); + if let Some(b) = block { + let joined = format!("{}{}", b["text"].as_str().unwrap_or(""), t); + b["text"] = json!(joined); + } + send(StreamEvent::Text(t.to_string())); + } + Some("thinking_delta") => { + let t = d["thinking"].as_str().unwrap_or(""); + out.thinking.push_str(t); + if let Some(b) = block { + let joined = format!("{}{}", b["thinking"].as_str().unwrap_or(""), t); + b["thinking"] = json!(joined); + } + send(StreamEvent::Thinking(t.to_string())); + } + Some("signature_delta") => { + if let Some(b) = block { + let joined = format!("{}{}", b["signature"].as_str().unwrap_or(""), d["signature"].as_str().unwrap_or("")); + b["signature"] = json!(joined); + } + } + Some("input_json_delta") => { + if let Some(p) = partial_json.get_mut(i) { + p.push_str(d["partial_json"].as_str().unwrap_or("")); + } + } + _ => {} + } + } + "content_block_stop" => { + let i = v["index"].as_u64().unwrap_or(0) as usize; + if let Some(b) = blocks.get_mut(i) { + if b["type"] == "tool_use" { + let raw = partial_json.get(i).cloned().unwrap_or_default(); + let input: Value = if raw.trim().is_empty() { json!({}) } else { serde_json::from_str(&raw).unwrap_or(json!({})) }; + b["input"] = input.clone(); + let call = ToolCall { id: b["id"].as_str().unwrap_or("").into(), name: b["name"].as_str().unwrap_or("").into(), args: input.to_string(), status: ToolStatus::Waiting }; + send(StreamEvent::ToolCall(call.clone())); + out.tool_calls.push(call); + } + } + } + "message_delta" => { + if v["delta"]["stop_reason"] == "refusal" { + out.text.push_str("\n\n(模型拒絕回答這個要求。)"); + send(StreamEvent::Text("\n\n(模型拒絕回答這個要求。)".into())); + } else if v["delta"]["stop_reason"] == "max_tokens" { + out.text.push_str("\n\n(回答太長,已達長度上限。)"); + send(StreamEvent::Text("\n\n(回答太長,已達長度上限。)".into())); + } + } + "message_stop" => return false, + "error" => { + error = Some(v["error"]["message"].as_str().unwrap_or("未知錯誤").to_string()); + return false; + } + _ => {} + } + true + })?; + if let Some(e) = error { + return Err(Failure::fatal(e)); + } + out.blocks = blocks.into_iter().filter(|b| !b.is_null()).collect(); + Ok(out) +} + +fn connect_error(kind: ProviderKind, e: &str) -> String { + if kind == ProviderKind::Ollama { + format!("連不上 Ollama:{e}\n請先開啟 Ollama,並用 `ollama pull` 下載模型。") + } else { + format!("連不上 {}:{e}", kind.label()) + } +} + +/// OpenCode Go: which API style each model speaks, from the public models list (fetched once). +static OPENCODE_STYLES: std::sync::Mutex>> = std::sync::Mutex::new(None); + +fn remember_opencode_styles(_base: &str, list: &Value) { + let map = opencode_styles_of(list); + if !map.is_empty() { + *OPENCODE_STYLES.lock().unwrap() = Some(map); + } +} + +/// {"data":[{"id":"qwen3.8-max","endpoint":{"messages":{…}}}, …]} → id → "messages" | "chat-completions" | … +pub fn opencode_styles_of(list: &Value) -> std::collections::HashMap { + list["data"] + .as_array() + .into_iter() + .flatten() + .filter_map(|m| { + let id = m["id"].as_str()?; + let ep = m["endpoint"].as_object()?; + // A model with a chat-completions door uses it (AnyBot's best-tested path); else the first. + let style = if ep.contains_key("chat-completions") { "chat-completions" } else if ep.contains_key("messages") { "messages" } else { ep.keys().next()? }; + Some((id.to_string(), style.to_string())) + }) + .collect() +} + +/// The API style for a model: "messages" (Anthropic-style) or "chat-completions" (everything +/// else; OpenCode translates for models whose own style is Responses or Gemini). +pub fn opencode_go_style(base: &str, model: &str) -> String { + let known = OPENCODE_STYLES.lock().unwrap().as_ref().and_then(|m| m.get(model).cloned()); + let style = known.or_else(|| { + let v: Value = agent().get(&format!("{base}/v1/models")).call().ok()?.into_body().read_json().ok()?; + remember_opencode_styles(base, &v); + OPENCODE_STYLES.lock().unwrap().as_ref().and_then(|m| m.get(model).cloned()) + }); + style.unwrap_or_else(|| { + // Offline guess from the published list: Claude, Qwen and MiniMax M3 are Anthropic-style. + let m = model.to_lowercase(); + if m.starts_with("claude") || m.starts_with("qwen") || m == "minimax-m3" { "messages".into() } else { "chat-completions".into() } + }) +} + +fn opencode_session(req: &ChatRequest) -> String { + if req.session.is_empty() { "anybot".into() } else { req.session.clone() } +} + +/// Ask the provider which models it offers. +pub fn list_models(kind: ProviderKind, base_url: &str, api_key: &str) -> Result, String> { + if kind.needs_key() && api_key.is_empty() { + return Err("請先貼上 API 金鑰".into()); + } + let url = if kind == ProviderKind::OpenCodeGo { format!("{base_url}/v1/models") } else { format!("{base_url}/models") }; + let mut r = agent().get(&url); + if kind == ProviderKind::Anthropic { + r = r.header("x-api-key", api_key).header("anthropic-version", ANTHROPIC_VERSION); + } else if !api_key.is_empty() { + r = r.header("Authorization", &format!("Bearer {api_key}")); + } + let resp = r.call().map_err(|e| connect_error(kind, &e.to_string()))?; + let status = resp.status().as_u16(); + let text = resp.into_body().read_to_string().map_err(|e| e.to_string())?; + if status >= 400 { + return Err(http_error(status, &text, None).msg); + } + let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + if kind == ProviderKind::OpenCodeGo { + remember_opencode_styles(base_url, &v); + } + let list = v["data"].as_array().or_else(|| v["models"].as_array()).cloned().unwrap_or_default(); + let mut ids: Vec = list + .iter() + .filter_map(|m| m["id"].as_str().or_else(|| m["name"].as_str())) + .map(|s| s.trim_start_matches("models/").to_string()) + .collect(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { + return Err("供應商沒有回傳任何模型".into()); + } + Ok(ids) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Serve one canned SSE reply on a local port and return its base URL. + pub fn fake_server(body: &'static str) -> String { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let (mut s, _) = l.accept().unwrap(); + // Read the whole request (headers + body) before answering. + let mut req = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = text.find("\r\n\r\n") { + let len = text[..end] + .lines() + .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))) + .unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"; + let _ = s.write_all(head.as_bytes()); + let _ = s.write_all(body.as_bytes()); + }); + format!("http://127.0.0.1:{port}/v1") + } + + fn run(kind: ProviderKind, body: &'static str) -> StepOutput { + let req = ChatRequest { + kind, + base_url: fake_server(body), + api_key: "test".into(), + model: "m".into(), + system: "s".into(), + history: vec![Message::user("hi")], + tools: crate::tools::specs(), + session: String::new(), + }; + let cancel = AtomicBool::new(false); + match run_step(&req, &cancel, &|_| {}) { + Ok(o) => o, + Err(e) => panic!("{e}"), + } + } + + #[test] + fn backoff_matches_original() { + assert_eq!([backoff_delay_ms(1), backoff_delay_ms(2), backoff_delay_ms(3)], [1000, 2000, 4000]); + assert!(http_error(429, "{}", None).retryable); + assert!(http_error(503, "{}", Some(2000)).retry_after_ms == Some(2000)); + assert!(!http_error(401, "{}", None).retryable); + assert!(!http_error(400, "{\"error\":{\"message\":\"prompt is too long\"}}", None).retryable); + } + + #[test] + fn openai_style_stream() { + let body = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"hm\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"!\"}}]}\n\ndata: [DONE]\n\n"; + let o = run(ProviderKind::Xai, body); + assert_eq!(o.text, "你好!"); + assert_eq!(o.thinking, "hm"); + } + + #[test] + fn openai_tool_calls_in_pieces() { + let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"pa\"}}]}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n"; + let o = run(ProviderKind::OpenAi, body); + assert_eq!(o.tool_calls.len(), 1); + assert_eq!(o.tool_calls[0].name, "read_file"); + assert_eq!(o.tool_calls[0].args, "{\"path\":\"a.txt\"}"); + } + + #[test] + fn claude_style_stream() { + let body = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ok\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig\"}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tu1\",\"name\":\"list_dir\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\": \\\"x\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"; + let o = run(ProviderKind::Anthropic, body); + assert_eq!(o.text, "Hello"); + assert_eq!(o.thinking, "ok"); + assert_eq!(o.tool_calls[0].name, "list_dir"); + assert_eq!(o.blocks.len(), 3); + assert_eq!(o.blocks[0]["signature"], "sig"); + assert_eq!(o.blocks[2]["input"]["path"], "x"); + } + + #[test] + fn history_keeps_tool_turns() { + let mut a = Message::assistant("", "x"); + a.tool_calls = vec![ToolCall { id: "c1".into(), name: "list_dir".into(), args: "{}".into(), status: ToolStatus::Done }]; + let mut s = Message::assistant("old stuff", ""); + s.summary = true; + let req = ChatRequest { + kind: ProviderKind::Anthropic, + base_url: String::new(), + api_key: String::new(), + model: "m".into(), + system: "sys".into(), + history: vec![s, Message::user("hi"), a, Message::tool_result("c1", "list_dir", "a.txt"), Message::user("next")], + tools: vec![], + session: String::new(), + }; + let m = anthropic_messages(&req); + assert_eq!(m.len(), 3); + assert_eq!(m[1]["content"][0]["type"], "tool_use"); + assert_eq!(m[2]["content"][0]["type"], "tool_result"); + assert_eq!(m[2]["content"][1]["text"], "next"); + let o = openai_messages(&ChatRequest { kind: ProviderKind::OpenAi, ..req }); + assert!(o[0]["content"].as_str().unwrap().contains("old stuff")); + assert_eq!(o[3]["role"], "tool"); + } + + #[test] + fn screenshots_reach_the_model_and_old_ones_drop() { + let n = crate::computer::KEEP_SCREENSHOTS + 1; + let mut history = vec![Message::user("look")]; + for i in 0..n { + let mut a = Message::assistant("", "x"); + a.tool_calls = vec![ToolCall { id: format!("s{i}"), name: "computer_use_screenshot".into(), args: "{}".into(), status: ToolStatus::Done }]; + history.push(a); + let mut r = Message::tool_result(&format!("s{i}"), "computer_use_screenshot", "shot"); + r.images = vec![format!("IMG{i}")]; + history.push(r); + } + let req = ChatRequest { kind: ProviderKind::Anthropic, base_url: String::new(), api_key: String::new(), model: "m".into(), system: String::new(), history, tools: vec![], session: String::new() }; + let m = anthropic_messages(&req); + let all = serde_json::to_string(&m).unwrap(); + assert!(!all.contains("IMG0") && all.contains(OLD_SCREENSHOT_NOTE)); + assert!(all.contains(&format!("IMG{}", n - 1)) && all.contains("\"media_type\":\"image/jpeg\"")); + let o = openai_messages(&ChatRequest { kind: ProviderKind::OpenAi, ..req }); + // Each tool answer is followed by its picture as a user message (never between tool results). + for w in o.windows(2) { + if w[0]["role"] == "user" && w[0]["content"].is_array() && w[1]["role"] == "tool" { + panic!("picture between tool results"); + } + } + let pics: Vec<&Value> = o.iter().filter(|x| x["role"] == "user" && x["content"].is_array()).collect(); + assert_eq!(pics.len(), crate::computer::KEEP_SCREENSHOTS); + assert!(pics.last().unwrap()["content"][1]["image_url"]["url"].as_str().unwrap().starts_with("data:image/jpeg;base64,IMG")); + } + + /// A server that records the request it gets, then answers with `body`. + fn recording_server(body: &'static str) -> (String, std::sync::mpsc::Receiver) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut s, _) = l.accept().unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = text.find("\r\n\r\n") { + let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let _ = tx.send(String::from_utf8_lossy(&req).to_string()); + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + let _ = s.write_all(body.as_bytes()); + }); + (format!("http://127.0.0.1:{port}"), rx) + } + + #[test] + fn opencode_go_routes_each_model_its_way() { + // The public list's shape (trimmed). + let list: Value = serde_json::from_str(r#"{"data":[ + {"id":"kimi-k3","endpoint":{"chat-completions":{"base_url":"https://opencode.ai/inference/go/openai/v1"}}}, + {"id":"qwen3.8-max","endpoint":{"messages":{"base_url":"https://opencode.ai/inference/go/anthropic/v1"}}}, + {"id":"grok-4.7","endpoint":{"responses":{"base_url":"https://opencode.ai/inference/go/openai/v1"}}}]}"#).unwrap(); + let styles = opencode_styles_of(&list); + assert_eq!(styles["kimi-k3"], "chat-completions"); + assert_eq!(styles["qwen3.8-max"], "messages"); + assert_eq!(styles["grok-4.7"], "responses"); + remember_opencode_styles("", &list); + + let cancel = AtomicBool::new(false); + let ask = |model: &str, body: &'static str| { + let (base, rx) = recording_server(body); + let req = ChatRequest { kind: ProviderKind::OpenCodeGo, base_url: base, api_key: "sk-go".into(), model: model.into(), system: "s".into(), history: vec![Message::user("hi")], tools: vec![], session: "anybot-bot-7".into() }; + let out = run_step(&req, &cancel, &|_| {}).unwrap(); + (out, rx.recv().unwrap()) + }; + let (out, seen) = ask("kimi-k3", "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: [DONE]\n\n"); + assert_eq!(out.text, "你好"); + assert!(seen.starts_with("POST /openai/v1/chat/completions "), "{seen}"); + let low = seen.to_lowercase(); + assert!(low.contains("authorization: bearer sk-go") && low.contains("x-opencode-session: anybot-bot-7"), "{seen}"); + + let (out, seen) = ask("qwen3.8-max", "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"嗨\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"); + assert_eq!(out.text, "嗨"); + assert!(seen.starts_with("POST /anthropic/v1/messages "), "{seen}"); + let low = seen.to_lowercase(); + assert!(low.contains("x-api-key: sk-go") && low.contains("x-opencode-session: anybot-bot-7"), "{seen}"); + + // Responses-style models go through OpenCode's chat-completions translation. + let (_, seen) = ask("grok-4.7", "data: [DONE]\n\n"); + assert!(seen.starts_with("POST /openai/v1/chat/completions "), "{seen}"); + } + + /// Network: OpenCode Go's real model list (public) and the style of a few models. + #[test] + #[ignore] + fn opencode_go_live_list() { + // A wrong key is refused (so pasting a bad key shows 金鑰無效 right away). + assert!(list_models(ProviderKind::OpenCodeGo, OPENCODE_GO_BASE, "sk-wrong").unwrap_err().contains("401")); + // The styles come from the public list (no key). + assert_eq!(opencode_go_style(OPENCODE_GO_BASE, "qwen3.8-max"), "messages"); + assert_eq!(opencode_go_style(OPENCODE_GO_BASE, "glm-5.3"), "chat-completions"); + } + + #[test] + fn a_model_that_cannot_see_gets_no_pictures() { + assert!(image_refused("錯誤 400:At most 0 image(s) may be provided in one prompt. (parameter=image)")); + assert!(image_refused("This model does not support image input")); + assert!(image_refused("unknown variant `image_url`, expected `text`")); + assert!(!image_refused("錯誤 400:Invalid credential")); + // The first request fails like the user's model did; the retry has no picture. + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let (tx, rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + for (n, s) in l.incoming().flatten().enumerate() { + let mut s = s; + let mut req = Vec::new(); + let mut buf = [0u8; 65536]; + loop { + let k = s.read(&mut buf).unwrap_or(0); + if k == 0 { + break; + } + req.extend_from_slice(&buf[..k]); + let text = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = text.find("\r\n\r\n") { + let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let _ = tx.send(String::from_utf8_lossy(&req).to_string()); + if n == 0 { + let body = r#"{"error":{"message":"At most 0 image(s) may be provided in one prompt. (parameter=image)"}}"#; + let _ = write!(s, "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + } else { + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"); + } + } + }); + let mut shot = Message::tool_result("c1", "computer_use_screenshot", "你的螢幕截圖"); + shot.images = vec!["SHOTDATA".into()]; + let mut asked = Message::assistant("", ""); + asked.tool_calls = vec![ToolCall { id: "c1".into(), name: "computer_use_screenshot".into(), args: "{}".into(), status: ToolStatus::Done }]; + let req = ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "blind-model".into(), system: String::new(), history: vec![Message::user("看一下畫面"), asked, shot], tools: vec![], session: String::new() }; + let cancel = AtomicBool::new(false); + let out = run_step(&req, &cancel, &|_| {}).unwrap(); + assert_eq!(out.text, "ok"); + let first = rx.recv().unwrap(); + let second = rx.recv().unwrap(); + assert!(first.contains("SHOTDATA") && !second.contains("SHOTDATA") && second.contains("看不到圖片"), "{second}"); + assert!(!sees_images(&req)); + } + + #[test] + fn steps_without_results_are_closed() { + let mut a = Message::assistant("", ""); + a.tool_calls = vec![ + ToolCall { id: "t1".into(), name: "shell".into(), args: "{}".into(), status: ToolStatus::Denied }, + ToolCall { id: "t2".into(), name: "browser_fill_login".into(), args: "{}".into(), status: ToolStatus::Waiting }, + ]; + let h = vec![Message::user("a"), a, Message::tool_result("t2", "browser_fill_login", "ok"), Message::tool_result("zz", "x", "stray"), Message::user("再一次")]; + let r = repair_history(&h); + let roles: Vec = r.iter().map(|m| format!("{:?}:{}", m.role, m.tool_call_id)).collect(); + assert_eq!(roles, vec!["User:", "Assistant:", "Tool:t2", "Tool:t1", "User:"]); + assert!(r[3].text.contains("沒有允許")); + // The OpenAI-style body pairs every call with a result. + let req = ChatRequest { kind: ProviderKind::Custom, base_url: String::new(), api_key: String::new(), model: "m".into(), system: String::new(), history: r, tools: vec![], session: String::new() }; + let msgs = openai_messages(&req); + let i = msgs.iter().position(|m| m["tool_calls"].is_array()).unwrap(); + assert_eq!(msgs[i + 1]["role"], "tool"); + assert_eq!(msgs[i + 2]["role"], "tool"); + } +} diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 0000000..ed25cc2 --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,911 @@ +// 手機遙控: use AnyBot from a phone (or any browser). +// +// Two ways in, one API: +// • 區域網路/Tailscale: this AnyBot answers on port 8793 itself (password login). +// • 帳號(薄後端): this AnyBot keeps an outgoing connection to the relay (relay/ in this repo, +// run on a small server). The phone signs in to the relay with the account, and the relay hands +// each request to this AnyBot. No open port, works from anywhere, and the relay also keeps the +// account, the device list and an encrypted backup of AnyBot's data. +// +// The original keeps its brain in its cloud and reaches devices from there (its device tools wait +// 30 s for the device). Here the brain stays in AnyBot — on this Mac, or on an always-on server +// started with `anybot --serve` — and the relay only passes requests along. + +use crate::httpd::{self, Req, Resp}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +pub const LAN_PORT: u16 = 8793; +/// How long the phone's "anything new?" request waits. +pub const LONG_POLL_SECS: u64 = 20; +/// Wrong passwords allowed before logins slow down. +const MAX_FAILS: u32 = 5; + +#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Device { + pub name: String, + pub token_hash: String, + pub created: i64, + #[serde(default)] + pub last_seen: i64, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Settings { + /// Answer phones on the local network (and Tailscale) on LAN_PORT. + #[serde(default)] + pub lan: bool, + /// The phone password, stored as salt$hash. + #[serde(default)] + pub password: String, + /// Phones and browsers signed in directly. + #[serde(default)] + pub devices: Vec, + /// The phone may see and operate this Mac's own screen (not just the bot's computer). + #[serde(default)] + pub mac_from_phone: bool, + /// Other AnyBots in the same account may operate this Mac (裝置連結). Each action still follows + /// this Mac's rules and asks the person on the brain's side. + #[serde(default)] + pub device_link: bool, + /// The relay (thin backend), e.g. https://anybot.example.com + #[serde(default)] + pub relay_url: String, + #[serde(default)] + pub relay_user: String, + /// This AnyBot's own key at the relay (a "host"). + #[serde(default)] + pub host_id: String, + #[serde(default)] + pub host_token: String, + /// What this AnyBot is called on the phone. + #[serde(default = "default_host_name")] + pub host_name: String, + /// Key for the encrypted backup (made from the account password; never sent). + #[serde(default)] + pub backup_key: String, + #[serde(default)] + pub auto_backup: bool, + #[serde(default)] + pub last_backup: i64, +} + +impl Default for Settings { + fn default() -> Self { + Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0 } + } +} + +pub fn default_host_name() -> String { + if !cfg!(target_os = "macos") { + let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); + let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty }; + if !name.is_empty() { + return name; + } + } + let out = std::process::Command::new("scutil").args(["--get", "ComputerName"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); + if !out.is_empty() { + return out; + } + std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "AnyBot".into()) +} + +/// What the phone asks AnyBot to do; the app carries these out on its own thread. +#[derive(Clone, Debug, PartialEq)] +pub enum Cmd { + Send { bot: u64, text: String, attachments: Vec }, + Stop { bot: u64 }, + Answer { bot: u64, answer: String }, + Read { bot: u64 }, + NewThread { bot: u64 }, + /// A new phone signed in (to remember), or one was signed out. + DeviceAdded(Device), + DeviceSeen(String), +} + +/// What the phone can see, refreshed by the app when something changes. +#[derive(Default, Clone)] +pub struct View { + pub host: String, + pub bots: Vec, + pub approvals: Vec, + pub messages: HashMap>, + pub version: u64, +} + +pub struct Hub { + view: Mutex, + changed: Condvar, + cmds: Mutex>, + repaint: Mutex>>, + settings: Mutex, + fails: Mutex<(u32, Option)>, + relay_status: Mutex, +} + +pub fn hub() -> &'static Hub { + static H: OnceLock = OnceLock::new(); + H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new((0, None)), relay_status: Mutex::new(String::new()) }) +} + +static VERSION: AtomicU64 = AtomicU64::new(1); + +impl Hub { + pub fn publish(&self, mut v: View) { + v.version = VERSION.fetch_add(1, Ordering::SeqCst) + 1; + *self.view.lock().unwrap() = v; + self.changed.notify_all(); + } + pub fn take_cmds(&self) -> Vec { + std::mem::take(&mut *self.cmds.lock().unwrap()) + } + fn push(&self, c: Cmd) { + self.cmds.lock().unwrap().push(c); + if let Some(r) = self.repaint.lock().unwrap().as_ref() { + r(); + } + } + pub fn set_repaint(&self, f: Arc) { + *self.repaint.lock().unwrap() = Some(f); + } + pub fn set_settings(&self, s: &Settings) { + *self.settings.lock().unwrap() = s.clone(); + } + pub fn settings(&self) -> Settings { + self.settings.lock().unwrap().clone() + } + pub fn relay_status(&self) -> String { + self.relay_status.lock().unwrap().clone() + } + fn set_relay_status(&self, s: &str) { + *self.relay_status.lock().unwrap() = s.to_string(); + } + /// The view once it is newer than `since` (or after the wait). + fn wait_newer(&self, since: u64, secs: u64) -> View { + let g = self.view.lock().unwrap(); + let (g, _) = self.changed.wait_timeout_while(g, Duration::from_secs(secs), |v| v.version <= since).unwrap(); + g.clone() + } +} + +// ---------- shaping the chat for the phone ---------- + +pub fn tool_label(name: &str) -> &'static str { + if crate::appctl::is_app_tool(name) { + crate::appctl::label(name) + } else if crate::macapps::is_mac_tool(name) { + crate::macapps::label(name) + } else if crate::computer::is_computer_tool(name) { + crate::computer::label(name) + } else if crate::web::is_web_tool(name) { + crate::web::label(name) + } else { + crate::tools::label(name) + } +} + +fn tool_detail(name: &str, args: &str) -> String { + let d = if crate::appctl::is_app_tool(name) { + crate::appctl::describe(name, args) + } else if crate::computer::is_computer_tool(name) { + crate::computer::describe(name, args) + } else if crate::web::is_web_tool(name) { + crate::web::describe(name, args) + } else { + crate::tools::describe(name, args) + }; + d.chars().take(120).collect() +} + +/// One message as the phone shows it (tool results are folded away like in the window). +pub fn message_json(m: &crate::model::Message, names: &HashMap) -> Option { + use crate::model::{Role, ToolStatus}; + if m.role == Role::Tool { + return None; + } + let calls: Vec = m + .tool_calls + .iter() + .map(|c| { + let st = match c.status { + ToolStatus::Waiting => "waiting", + ToolStatus::Running => "running", + ToolStatus::Done => "done", + ToolStatus::Denied => "denied", + ToolStatus::Failed => "failed", + }; + if c.name == "show_card" { + json!({"card": serde_json::from_str::(&c.args).unwrap_or(Value::Null), "status": st}) + } else { + json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st}) + } + }) + .collect(); + Some(json!({ + "role": match m.role { Role::User => "user", _ => "assistant" }, + "text": m.text, + "via": m.via, + "error": m.error, + "summary": m.summary, + "wake": m.wake, + "author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() }, + "reactions": m.reactions, + "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image})).collect::>(), + "tools": calls, + })) +} + +/// Newest messages kept in the phone's view of each chat. +pub const PHONE_TAIL: usize = 120; + +// ---------- the API (both the local server and the relay call this) ---------- + +fn devices_ok(token: &str) -> Option { + if token.is_empty() { + return None; + } + let h = httpd::sha256_hex(token); + hub().settings.lock().unwrap().devices.iter().find(|d| d.token_hash == h).map(|d| d.token_hash.clone()) +} + +/// `via_relay`: the relay already checked the account, so no phone password is needed. +pub fn handle(req: Req, via_relay: bool) -> Resp { + let parts = req.parts(); + let path: Vec<&str> = parts.iter().map(|s| *s).collect(); + match (req.method.as_str(), path.as_slice()) { + ("GET", []) | ("GET", ["index.html"]) => return page(), + ("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), + ("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), + ("GET", ["api", "hello"]) => { + let s = hub().settings(); + return Resp::json(&json!({"app": "anybot", "host": s.host_name, "relay": via_relay, "needs_password": !via_relay})); + } + ("POST", ["api", "login"]) if !via_relay => return login(&req), + _ => {} + } + if !via_relay { + match devices_ok(&req.bearer()) { + Some(h) => hub().push(Cmd::DeviceSeen(h)), + None => return Resp::error(401, "請先登入"), + } + } + let s = hub().settings(); + // Direct phones are the owner's. Through the relay, the relay says who is asking. + let role = if via_relay { Some(req.header("x-anybot-role")).filter(|r| !r.is_empty()).unwrap_or("owner").to_string() } else { "owner".to_string() }; + if !role_allows(&role, &req.method, &path) { + return Resp::error(403, "你在這台 AnyBot 的角色不能做這件事(請擁有者處理)"); + } + match (req.method.as_str(), path.as_slice()) { + ("GET", ["api", "state"]) => { + let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); + let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS }); + Resp::json(&json!({ + "version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals, + "box": crate::cloudbox::run_state().label(), + "box_available": crate::cloudbox::available(), + "mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member", + "role": role, + })) + } + ("GET", ["api", "bots", id]) => { + let id: u64 = id.parse().unwrap_or(0); + let v = hub().view.lock().unwrap(); + match v.messages.get(&id) { + Some(m) => Resp::json(&json!({"version": v.version, "messages": m})), + None => Resp::error(404, "找不到這個 Bot"), + } + } + ("POST", ["api", "bots", id, action]) => { + let bot: u64 = id.parse().unwrap_or(0); + let b = req.json(); + let cmd = match *action { + "send" => { + let text = b["text"].as_str().unwrap_or("").trim().to_string(); + let attachments: Vec = b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(); + if text.is_empty() && attachments.is_empty() { + return Resp::error(400, "訊息是空的"); + } + // 團隊: the bot knows who is talking when it isn't the owner. + let who = req.header("x-anybot-user"); + let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text }; + Cmd::Send { bot, text, attachments } + } + "stop" => Cmd::Stop { bot }, + "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, + "read" => Cmd::Read { bot }, + "new" => Cmd::NewThread { bot }, + "upload" => return upload(bot, &req), + _ => return Resp::error(404, "沒有這個動作"), + }; + hub().push(cmd); + Resp::json(&json!({"ok": true})) + } + ("GET", ["api", "screen", which]) => screen(which, &s), + ("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s), + ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay), + ("POST", ["api", "logout"]) if !via_relay => { + let h = httpd::sha256_hex(&req.bearer()); + hub().push(Cmd::DeviceSeen(format!("-{h}"))); + Resp::json(&json!({"ok": true})) + } + _ => Resp::error(404, "沒有這個網址"), + } +} + +/// 團隊角色: what each role may do. "member" chats and attaches files; "admin" also approves, +/// stops, starts new chats and uses the screens; only "device" (another AnyBot of the owner's +/// account) may run tools here. +pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool { + let device = matches!(path, ["api", "device", "run"]); + match role { + "device" => device, + "owner" | "admin" => !device, + "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), + _ => false, + } +} + +fn login(req: &Req) -> Resp { + { + let mut f = hub().fails.lock().unwrap(); + if let (n, Some(t)) = *f { + if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) { + return Resp::error(429, "密碼錯太多次,請一分鐘後再試"); + } + if t.elapsed() >= Duration::from_secs(60) { + *f = (0, None); + } + } + } + let b = req.json(); + let pw = b["password"].as_str().unwrap_or(""); + let stored = hub().settings().password; + if stored.is_empty() || !httpd::check_password(pw, &stored) { + let mut f = hub().fails.lock().unwrap(); + *f = (f.0 + 1, Some(Instant::now())); + // Slow guessing down a little more. + std::thread::sleep(Duration::from_millis(600)); + return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" }); + } + *hub().fails.lock().unwrap() = (0, None); + let token = httpd::random_hex(32); + let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::(); + let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() }; + // Known at once (the app saves it a moment later). + hub().settings.lock().unwrap().devices.push(d.clone()); + hub().push(Cmd::DeviceAdded(d)); + Resp::json(&json!({"token": token})) +} + +fn upload(bot: u64, req: &Req) -> Resp { + let name = req.query.get("name").cloned().unwrap_or_else(|| "手機上傳".into()); + let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0')).take(80).collect(); + if req.body.is_empty() { + return Resp::error(400, "檔案是空的"); + } + let dir = std::env::temp_dir().join(format!("anybot-phone-{}", httpd::random_hex(6))); + let _ = std::fs::create_dir_all(&dir); + let tmp = dir.join(if name.is_empty() { "upload".to_string() } else { name }); + if let Err(e) = std::fs::write(&tmp, &req.body) { + return Resp::error(500, &e.to_string()); + } + let r = crate::attach::store(bot, &tmp); + let _ = std::fs::remove_dir_all(&dir); + match r { + Ok(a) => Resp::json(&json!({"path": a.path, "name": a.name, "image": a.image})), + Err(e) => Resp::error(400, &e), + } +} + +/// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG. +fn screen(which: &str, s: &Settings) -> Resp { + match which { + "box" => { + crate::cloudbox::touch(); + match crate::cloudbox::frame() { + Some(img) => { + let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8(); + match crate::computer::encode_jpeg(&rgb) { + Some(j) => Resp::bytes("image/jpeg", j).with_header("X-Size", &format!("{}x{}", rgb.width(), rgb.height())), + None => Resp::error(500, "畫面編碼失敗"), + } + } + None => Resp::error(503, "Bot 的電腦在休息,按「叫醒」"), + } + } + "mac" if s.mac_from_phone => { + let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機"); + match (ok, img) { + (true, Some(b)) => { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default(); + let size = image::load_from_memory(&bytes).map(|i| format!("{}x{}", i.width(), i.height())).unwrap_or_default(); + Resp::bytes("image/jpeg", bytes).with_header("X-Size", &size) + } + _ => Resp::error(503, &text), + } + } + "mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"), + _ => Resp::error(404, "沒有這個畫面"), + } +} + +/// The phone taps, types or scrolls on a screen (coordinates in the frame's pixels). +fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { + let kind = v["kind"].as_str().unwrap_or(""); + if which == "box" && kind == "wake" { + return match crate::cloudbox::ensure_running() { + Ok(()) => Resp::json(&json!({"ok": true})), + Err(e) => Resp::error(500, &e), + }; + } + let (name, args) = match kind { + "click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})), + "type" => ("computer_use_typing", json!({"text": v["text"]})), + "key" => ("computer_use_press_key", json!({"key": v["key"]})), + "scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})), + _ => return Resp::error(400, "不認得的操作"), + }; + match which { + "box" => { + crate::cloudbox::touch(); + match crate::cloudbox::computer_run(name, &args) { + Ok(_) => Resp::json(&json!({"ok": true})), + Err(e) => Resp::error(500, &e), + } + } + "mac" if s.mac_from_phone => { + let (t, ok, _) = crate::computer::run_by(name, &args.to_string(), "手機"); + if ok { + Resp::json(&json!({"ok": true})) + } else { + Resp::error(500, &t) + } + } + _ => Resp::error(403, "這台 Mac 沒有開放從手機操作"), + } +} + +/// 裝置連結: another AnyBot in this account (e.g. the one on the server) runs a screen tool on +/// this Mac. Only through the relay, and only when this Mac allows it. +fn device_run(v: &Value, s: &Settings, via_relay: bool) -> Resp { + if !via_relay || !s.device_link { + return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作"); + } + let tool = v["tool"].as_str().unwrap_or(""); + let args = v["args"].as_str().unwrap_or("{}"); + let who = v["who"].as_str().unwrap_or("遠端 AnyBot"); + if crate::computer::is_computer_tool(tool) { + let (text, ok, img) = crate::computer::run_by(tool, args, who); + return Resp::json(&json!({"text": text, "ok": ok, "image": img})); + } + if crate::macapps::is_mac_tool(tool) && cfg!(target_os = "macos") { + let (text, ok) = crate::macapps::run(tool, args); + return Resp::json(&json!({"text": text, "ok": ok})); + } + if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") { + let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir: crate::tools::expand_home(v["workdir"].as_str().unwrap_or("~")) } }; + let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) }; + let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids); + return Resp::json(&json!({"text": text, "ok": ok})); + } + Resp::error(400, "這個工具不能遠端使用") +} + +// ---------- the local server ---------- + +static LAN_GEN: AtomicU64 = AtomicU64::new(0); +static LAN_BOUND: Mutex> = Mutex::new(None); + +pub fn lan_port() -> Option { + *LAN_BOUND.lock().unwrap() +} + +/// Start (or stop) answering phones on the local network. +pub fn set_lan(on: bool) -> Result { + let gen = LAN_GEN.fetch_add(1, Ordering::SeqCst) + 1; + *LAN_BOUND.lock().unwrap() = None; + if !on { + return Ok(0); + } + let port_env: Option = std::env::var("ANYBOT_PHONE_PORT").ok().and_then(|p| p.parse().ok()); + let start = port_env.unwrap_or(LAN_PORT); + // The old listener notices the new generation within 50 ms; give it the port back. + let listener = (0..40) + .find_map(|_| { + let l = (start..start + 10).find_map(|p| std::net::TcpListener::bind(("0.0.0.0", p)).ok()); + if l.is_none() { + std::thread::sleep(Duration::from_millis(60)); + } + l + }) + .ok_or("找不到可用的連接埠")?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + *LAN_BOUND.lock().unwrap() = Some(port); + std::thread::spawn(move || httpd::serve(listener, Arc::new(|r| handle(r, false)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen))); + Ok(port) +} + +/// Addresses a phone can open (local network and Tailscale). +pub fn addresses(port: u16) -> Vec { + // macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …"). + let mut out = std::process::Command::new("ifconfig").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); + if out.trim().is_empty() { + out = std::process::Command::new("ip").args(["-4", "-o", "addr"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); + } + let mut v: Vec = out + .lines() + .filter_map(|l| l.split("inet ").nth(1)) + .filter_map(|l| l.split_whitespace().next()) + .map(|ip| ip.split('/').next().unwrap_or(ip)) + .filter(|ip| !ip.starts_with("127.") && !ip.starts_with("169.254.")) + .map(|ip| format!("http://{ip}:{port}")) + .collect(); + v.dedup(); + v +} + +// ---------- the relay connection (this AnyBot as a "host") ---------- + +static RELAY_GEN: AtomicU64 = AtomicU64::new(0); + +fn agent(secs: u64) -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into() +} + +pub fn relay_post(url: &str, bearer: &str, body: &Value) -> Result { + let mut r = agent(30).post(url).header("Content-Type", "application/json"); + if !bearer.is_empty() { + r = r.header("Authorization", &format!("Bearer {bearer}")); + } + let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| format!("連不到帳號伺服器:{e}"))?; + let st = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if !(200..300).contains(&st) { + return Err(v["error"].as_str().map(String::from).unwrap_or(format!("HTTP {st}"))); + } + Ok(v) +} + +fn base(url: &str) -> String { + url.trim().trim_end_matches('/').to_string() +} + +/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts. +/// Returns the settings to keep. +pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result { + let host_name = base_settings.host_name.as_str(); + let url = base(url); + // Plain http only inside one machine or one Docker network (e.g. http://relay:8080). + let host = url.strip_prefix("http://").map(|r| r.split(['/', ':']).next().unwrap_or("")).unwrap_or(""); + // Tailscale (100.64.0.0/10 and *.ts.net) is already encrypted end to end. + let tailscale = host.ends_with(".ts.net") || host.split('.').collect::>().as_slice().first().map(|a| *a == "100").unwrap_or(false) && host.split('.').nth(1).and_then(|b| b.parse::().ok()).map(|b| (64..128).contains(&b)).unwrap_or(false); + let internal = host == "127.0.0.1" || host == "localhost" || tailscale || (!host.is_empty() && !host.contains('.')); + if !url.starts_with("https://") && !internal { + return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into()); + } + if create { + relay_post(&format!("{url}/relay/signup"), "", &json!({"user": user, "password": password, "invite": invite}))?; + } + let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?; + let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string(); + let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?; + // The desktop doesn't need to stay signed in as a person; only the host key is kept. + let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); + let mut s = base_settings.clone(); + s.relay_url = url; + s.relay_user = user.to_string(); + s.host_id = h["id"].as_str().unwrap_or("").to_string(); + s.host_token = h["token"].as_str().unwrap_or("").to_string(); + s.backup_key = httpd::derive_key(password, user).iter().map(|b| format!("{b:02x}")).collect(); + if s.host_id.is_empty() || s.host_token.is_empty() { + return Err("帳號伺服器沒有給這台電腦的金鑰".into()); + } + Ok(s) +} + +/// Keep the outgoing connection to the relay: wait for the phone's requests and answer them. +pub fn relay_restart(s: &Settings) { + let gen = RELAY_GEN.fetch_add(1, Ordering::SeqCst) + 1; + if s.relay_url.is_empty() || s.host_token.is_empty() { + hub().set_relay_status(""); + return; + } + let (url, token) = (s.relay_url.clone(), s.host_token.clone()); + hub().set_relay_status("連線中…"); + // A few workers, so a slow request (a long-poll) doesn't block the others. + for _ in 0..4 { + let (url, token) = (url.clone(), token.clone()); + std::thread::spawn(move || relay_worker(&url, &token, gen)); + } +} + +fn alive(gen: u64) -> bool { + RELAY_GEN.load(Ordering::SeqCst) == gen +} + +fn relay_worker(url: &str, token: &str, gen: u64) { + let mut backoff = 1u64; + while alive(gen) { + let r = agent(40).get(&format!("{url}/relay/host/next")).header("Authorization", &format!("Bearer {token}")).call(); + let mut resp = match r { + Ok(r) => r, + Err(e) => { + hub().set_relay_status(&format!("連不上帳號伺服器:{e}")); + std::thread::sleep(Duration::from_secs(backoff)); + backoff = (backoff * 2).min(30); + continue; + } + }; + let st = resp.status().as_u16(); + if st == 401 { + hub().set_relay_status("這台電腦的金鑰失效了,請重新登入帳號"); + return; + } + if st == 204 { + hub().set_relay_status("已連線"); + backoff = 1; + continue; + } + let text = resp.body_mut().read_to_string().unwrap_or_default(); + let Ok(job) = serde_json::from_str::(&text) else { + std::thread::sleep(Duration::from_secs(backoff)); + continue; + }; + hub().set_relay_status("已連線"); + backoff = 1; + let id = job["id"].as_str().unwrap_or("").to_string(); + let req = req_from_json(&job); + // Answer on a separate thread so this worker can pick up the next request at once. + let (url, token) = (url.to_string(), token.to_string()); + std::thread::spawn(move || { + let resp = handle(req, true); + let _ = agent(60).post(&format!("{url}/relay/host/reply/{id}")).header("Authorization", &format!("Bearer {token}")).header("Content-Type", "application/json").send(resp_to_json(&resp).to_string().as_bytes()); + }); + } +} + +pub fn req_from_json(job: &Value) -> Req { + use base64::Engine; + Req { + method: job["method"].as_str().unwrap_or("GET").to_string(), + path: job["path"].as_str().unwrap_or("/").to_string(), + query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), + // Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結). + headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner"))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + body: base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(), + peer: "relay".into(), + } +} + +pub fn resp_to_json(r: &Resp) -> Value { + use base64::Engine; + json!({"status": r.status, "ctype": r.ctype, "body": base64::engine::general_purpose::STANDARD.encode(&r.body), "headers": r.headers}) +} + +/// Upload an encrypted copy of AnyBot's data to the relay. +pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result { + let key = key_of(&s.backup_key).ok_or("還沒登入帳號")?; + let sealed = httpd::seal(&key, state_json); + let mut resp = agent(120) + .put(&format!("{}/relay/backup", s.relay_url)) + .header("Authorization", &format!("Bearer {}", s.host_token)) + .header("Content-Type", "application/octet-stream") + .send(&sealed[..]) + .map_err(|e| format!("備份上傳失敗:{e}"))?; + let st = resp.status().as_u16(); + if !(200..300).contains(&st) { + return Err(format!("備份上傳失敗:{}", resp.body_mut().read_to_string().unwrap_or_default())); + } + Ok(sealed.len()) +} + +/// Download and open the newest backup of this account (for a new computer). +pub fn restore(url: &str, user: &str, password: &str) -> Result, String> { + let url = base(url); + let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?; + let session = v["token"].as_str().unwrap_or("").to_string(); + let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?; + let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); + if resp.status().as_u16() == 404 { + return Err("這個帳號還沒有備份".into()); + } + let sealed = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())?; + httpd::open(&httpd::derive_key(password, user), &sealed).ok_or_else(|| "備份打不開(密碼不對,或備份壞了)".to_string()) +} + +fn key_of(hex: &str) -> Option<[u8; 32]> { + if hex.len() != 64 { + return None; + } + let mut k = [0u8; 32]; + for i in 0..32 { + k[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; + } + Some(k) +} + +/// 裝置連結: run a tool on another AnyBot of this account (through the relay). +pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result { + let s = hub().settings(); + if s.relay_url.is_empty() { + return Err("沒有登入帳號,連不到其他裝置".into()); + } + let url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url); + let mut resp = agent(45) + .post(&url) + .header("Authorization", &format!("Bearer {}", s.host_token)) + .header("Content-Type", "application/json") + .send(json!({"tool": tool, "args": args, "who": who}).to_string().as_bytes()) + .map_err(|e| format!("連不到那台電腦:{e}"))?; + let st = resp.status().as_u16(); + let text = resp.body_mut().with_config().limit(40 * 1024 * 1024).read_to_string().unwrap_or_default(); + let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if !(200..300).contains(&st) { + return Err(v["error"].as_str().map(String::from).unwrap_or(format!("那台電腦沒有回應(HTTP {st})"))); + } + Ok(v) +} + +/// A notification on the person's phones (through the relay; phones that turned them on). +pub fn notify(title: &str, body: &str, bot: u64) { + let s = hub().settings(); + if s.relay_url.is_empty() || s.host_token.is_empty() { + return; + } + let (title, body) = (title.to_string(), body.to_string()); + std::thread::spawn(move || { + let _ = relay_post(&format!("{}/relay/host/notify", s.relay_url), &s.host_token, &json!({"title": title, "body": body, "bot": bot})); + }); +} + +/// 發布網站: upload a folder (or one page) to the account server; returns the public address. +pub fn publish_site(name: &str, local: &std::path::Path) -> Result { + use base64::Engine; + let s = hub().settings(); + if s.relay_url.is_empty() || s.host_token.is_empty() { + return Err("要先在 設定 › 手機與帳號 登入帳號,才能發布網站".into()); + } + let mut files = vec![]; + let mut total = 0u64; + if local.is_file() { + let bytes = std::fs::read(local).map_err(|e| e.to_string())?; + let is_html = local.extension().map(|e| e == "html" || e == "htm").unwrap_or(false); + let n = if is_html { "index.html".to_string() } else { local.file_name().unwrap_or_default().to_string_lossy().to_string() }; + if bytes.len() > 25 * 1024 * 1024 { + return Err("網頁太大了(最多 25 MB)".into()); + } + files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)})); + } else { + let mut stack = vec![local.to_path_buf()]; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() { + let p = e.path(); + let fname = e.file_name().to_string_lossy().to_string(); + if fname.starts_with('.') || fname == "node_modules" { + continue; + } + if p.is_dir() { + stack.push(p); + } else if let Ok(bytes) = std::fs::read(&p) { + total += bytes.len() as u64; + if files.len() >= 500 || total > 25 * 1024 * 1024 { + return Err("網站太大了(最多 500 個檔案、25 MB)".into()); + } + let rel = p.strip_prefix(local).unwrap_or(&p).to_string_lossy().replace('\\', "/"); + files.push(json!({"path": rel, "data": base64::engine::general_purpose::STANDARD.encode(bytes)})); + } + } + } + } + if !files.iter().any(|f| f["path"] == "index.html") { + return Err("找不到 index.html(網站的首頁)".into()); + } + let v = relay_post(&format!("{}/relay/host/sites/{name}", s.relay_url), &s.host_token, &json!({"files": files}))?; + let url = v["url"].as_str().filter(|u| !u.is_empty()).map(String::from).unwrap_or_else(|| format!("{}{}", s.relay_url, v["path"].as_str().unwrap_or(""))); + Ok(url) +} + +/// 團隊: who else this AnyBot is shared with (user, role). +pub fn members(s: &Settings) -> Result, String> { + let mut r = agent(10).get(&format!("{}/relay/host/members", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| e.to_string())?; + let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + Ok(v["members"].as_array().map(|a| a.iter().map(|m| (m["user"].as_str().unwrap_or("").to_string(), m["role"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default()) +} + +/// Share with another account ("member" / "admin"), or stop sharing ("remove"). +pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> { + relay_post(&format!("{}/relay/host/members", s.relay_url), &s.host_token, &json!({"user": user, "role": role})).map(|_| ()) +} + +/// The other AnyBots signed in to this account (for 裝置連結). +pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { + if s.relay_url.is_empty() { + return vec![]; + } + let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] }; + let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default() +} + +// ---------- the phone page ---------- + +const PAGE: &str = include_str!("../mobile/index.html"); +pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##; +pub const ICON: &str = r##""##; + +pub fn page() -> Resp { + Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phone_login_and_api() { + let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() }; + s.host_name = "測試 Mac".into(); + hub().set_settings(&s); + // Not signed in: refused. + let r = handle(Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }, false); + assert_eq!(r.status, 401); + let bad = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), ..Default::default() }, false); + assert_eq!(bad.status, 401); + let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123","device":"iPhone"}"#.to_vec(), ..Default::default() }, false); + assert_eq!(ok.status, 200); + let token = serde_json::from_slice::(&ok.body).unwrap()["token"].as_str().unwrap().to_string(); + hub().publish(View { host: "測試 Mac".into(), bots: vec![json!({"id": 1, "name": "小助理"})], ..Default::default() }); + let mut req = Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }; + req.headers.insert("authorization".into(), format!("Bearer {token}")); + let st = handle(req.clone(), false); + assert_eq!(st.status, 200, "{}", String::from_utf8_lossy(&st.body)); + let v: Value = serde_json::from_slice(&st.body).unwrap(); + assert_eq!(v["bots"][0]["name"], "小助理"); + // Sending queues a command for the app. + let mut send = Req { method: "POST".into(), path: "/api/bots/1/send".into(), body: r#"{"text":"你好"}"#.as_bytes().to_vec(), ..Default::default() }; + send.headers = req.headers.clone(); + assert_eq!(handle(send, false).status, 200); + let cmds = hub().take_cmds(); + assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone"))); + assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] })); + // The Mac's own screen stays closed unless allowed. + let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() }; + mac.headers = req.headers.clone(); + assert_eq!(handle(mac, false).status, 403); + // Device link needs the relay and the switch. + assert_eq!(handle(Req { method: "POST".into(), path: "/api/device/run".into(), body: br#"{"tool":"shell"}"#.to_vec(), ..Default::default() }, true).status, 403); + } + + #[test] + fn team_roles() { + assert!(role_allows("member", "POST", &["api", "bots", "3", "send"])); + assert!(role_allows("member", "GET", &["api", "state"])); + for (m, p) in [("POST", &["api", "bots", "3", "answer"][..]), ("POST", &["api", "bots", "3", "stop"][..]), ("GET", &["api", "screen", "box"][..]), ("POST", &["api", "device", "run"][..])] { + assert!(!role_allows("member", m, p), "{p:?}"); + } + assert!(role_allows("admin", "POST", &["api", "bots", "3", "answer"])); + assert!(!role_allows("admin", "POST", &["api", "device", "run"])); + assert!(role_allows("device", "POST", &["api", "device", "run"]) && !role_allows("device", "GET", &["api", "state"])); + assert!(!role_allows("stranger", "GET", &["api", "state"])); + // The relay's word reaches handle() as headers. + let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/1/answer", "user": "amy", "role": "member", "body": ""})); + assert_eq!(handle(req, true).status, 403); + } + + #[test] + fn relay_messages_round_trip() { + let r = Resp::bytes("image/jpeg", vec![1, 2, 3]).with_header("X-Size", "2x2"); + let j = resp_to_json(&r); + assert_eq!(j["status"], 200); + let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/2/send", "query": {"since": "4"}, "body": "eyJ0ZXh0IjoiaGkifQ=="})); + assert_eq!(req.json()["text"], "hi"); + assert_eq!(req.query["since"], "4"); + } +} diff --git a/src/routines.rs b/src/routines.rs new file mode 100644 index 0000000..8ae24e2 --- /dev/null +++ b/src/routines.rs @@ -0,0 +1,419 @@ +// Routines: tasks a bot repeats on a schedule. The schedule has the same shape as the original's +// (index.eager-app @1508626, `Prt` / `Irt`): frequency daily|weekly|monthly|yearly, an interval, +// weekdays MO..SU (`yA`), a day of the month or "the Nth weekday", a month, a time "HH:MM" and a +// time zone. Descriptions use the original's zh-TW wording (ids UdWx3W, orHQyz, W+dYrs, Y8bJts, +// kToBIG, kfbKQI, h3xQKP, ni0dzI, 1HiPWu, hlCyZb, kGmiCr, GGSF1N, gjpnJh, jjNxA6). + +use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone, Utc, Weekday}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; + +/// The original's weekday order (`yA=["MO","TU","WE","TH","FR","SA","SU"]`). +pub const WEEKDAYS: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; +pub const WORKWEEK: [&str; 5] = ["MO", "TU", "WE", "TH", "FR"]; +const WEEKDAY_ZH: [&str; 7] = ["週一", "週二", "週三", "週四", "週五", "週六", "週日"]; +const WEEKDAY_ZH_LONG: [&str; 7] = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]; +/// How far ahead to look for the next run (estimated). +const SEARCH_DAYS: i64 = 366 * 5; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum Frequency { + /// 每 N 分鐘 (counted from when it was made). + Minutely, + /// 每小時 / 每 N 小時, at the minute of `time`. + Hourly, + Daily, + Weekly, + Monthly, + Yearly, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct WeekdayOfMonth { + /// 1..=4, or -1 for the last one. + pub week: i32, + pub day: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Schedule { + pub frequency: Frequency, + #[serde(default = "one")] + pub interval: u32, + #[serde(default, rename = "daysOfWeek", skip_serializing_if = "Vec::is_empty")] + pub days_of_week: Vec, + #[serde(default, rename = "monthDay", skip_serializing_if = "Option::is_none")] + pub month_day: Option, + #[serde(default, rename = "weekdayOfMonth", skip_serializing_if = "Option::is_none")] + pub weekday_of_month: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub month: Option, + /// "HH:MM", 24-hour. + pub time: String, + pub timezone: String, + /// 僅一次: runs once, then stops. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub once: bool, + /// 「左右」: a flexible time, anywhere in ±FLEX_MINUTES (the original's flexible mode). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub flexible: bool, +} + +/// How far a flexible ("左右") time may move either way (estimated; the original has a window). +pub const FLEX_MINUTES: i64 = 15; +/// Shortest repeat for 每 N 分鐘 (estimated). +pub const MIN_MINUTES: u32 = 5; + +fn one() -> u32 { + 1 +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Routine { + pub id: u64, + pub title: String, + /// What the bot is asked to do each time. + pub prompt: String, + pub schedule: Schedule, + #[serde(default)] + pub paused: bool, + /// Seconds since 1970 (UTC). + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub last_run: Option, + #[serde(default)] + pub next_run: Option, + /// Which computer to work on ("" = the bot's default). + #[serde(default)] + pub computer: String, + /// Run when something happens instead of on the schedule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger: Option, +} + +pub fn local_timezone() -> String { + iana_time_zone::get_timezone().unwrap_or_else(|_| "Asia/Taipei".into()) +} + +fn tz_of(name: &str) -> Tz { + name.parse::().or_else(|_| local_timezone().parse::()).unwrap_or(chrono_tz::Asia::Taipei) +} + +fn weekday_code(w: Weekday) -> &'static str { + WEEKDAYS[w.num_days_from_monday() as usize] +} + +fn weekday_index(code: &str) -> Option { + WEEKDAYS.iter().position(|w| w.eq_ignore_ascii_case(code)) +} + +pub fn parse_time(s: &str) -> Option { + NaiveTime::parse_from_str(s.trim(), "%H:%M").ok() +} + +/// Check a schedule a model wrote, and tidy it. +pub fn validate(mut s: Schedule) -> Result { + if s.interval == 0 { + s.interval = 1; + } + if parse_time(&s.time).is_none() { + return Err(format!("時間「{}」看不懂,請用 24 小時制的 HH:MM,例如 09:00", s.time)); + } + if s.timezone.trim().is_empty() || s.timezone.parse::().is_err() { + s.timezone = local_timezone(); + } + s.days_of_week = s.days_of_week.iter().filter_map(|d| weekday_index(d).map(|i| WEEKDAYS[i].to_string())).collect(); + s.days_of_week.sort_by_key(|d| weekday_index(d)); + s.days_of_week.dedup(); + if s.frequency == Frequency::Minutely && s.interval < MIN_MINUTES { + return Err(format!("每 N 分鐘最少 {MIN_MINUTES} 分鐘")); + } + match s.frequency { + Frequency::Weekly if s.days_of_week.is_empty() => return Err("每週的例行任務要指定星期幾(daysOfWeek,例如 [\"MO\",\"WE\"])".into()), + Frequency::Monthly if s.month_day.is_none() && s.weekday_of_month.is_none() => s.month_day = Some(1), + Frequency::Yearly if s.month.is_none() => s.month = Some(1), + _ => {} + } + if let Some(d) = s.month_day { + if !(1..=31).contains(&d) { + return Err("每月的日期要在 1 到 31 之間".into()); + } + } + Ok(s) +} + +/// "每週的週一、週三 09:00" in the original's wording. +pub fn describe(s: &Schedule) -> String { + let n = s.interval.max(1); + if s.frequency == Frequency::Minutely { + return format!("每 {n} 分鐘{}", if s.once { "(僅一次)" } else { "" }); + } + if s.frequency == Frequency::Hourly { + let minute = parse_time(&s.time).map(|t| chrono::Timelike::minute(&t)).unwrap_or(0); + let base = if n > 1 { format!("每 {n} 小時") } else { "每小時".into() }; + return format!("{base}的 {minute:02} 分{}{}", if s.flexible { "左右" } else { "" }, if s.once { "(僅一次)" } else { "" }); + } + let unit = match s.frequency { + Frequency::Minutely | Frequency::Hourly => unreachable!(), + Frequency::Daily => "天", + Frequency::Weekly => "週", + Frequency::Monthly => "個月", + Frequency::Yearly => "年", + }; + let duration = format!("{n} {unit}"); + let repeats = match s.frequency { + Frequency::Minutely | Frequency::Hourly => unreachable!(), + Frequency::Daily => if n > 1 { format!("每{duration}") } else { "每天".into() }, + Frequency::Weekly => { + let all_work = s.days_of_week.len() == WORKWEEK.len() && WORKWEEK.iter().all(|w| s.days_of_week.iter().any(|d| d == w)); + if n == 1 && all_work { + "每個工作日".into() + } else { + let names: Vec<&str> = s.days_of_week.iter().filter_map(|d| weekday_index(d)).map(|i| WEEKDAY_ZH[i]).collect(); + let names = names.join("、"); + if n > 1 { format!("每{duration}的{names}") } else { format!("每週的{names}") } + } + } + Frequency::Monthly => match &s.weekday_of_month { + None => { + let d = s.month_day.unwrap_or(1); + if n > 1 { format!("每{duration}的{d}日") } else { format!("每月{d}日") } + } + Some(w) => { + let week = if w.week < 0 { "最後一個".to_string() } else { format!("第{}個", w.week) }; + let day = weekday_index(&w.day).map(|i| WEEKDAY_ZH_LONG[i]).unwrap_or(""); + if n > 1 { format!("每{duration}的{week}{day}") } else { format!("每月的{week}{day}") } + } + }, + Frequency::Yearly => { + let date = format!("{}月{}日", s.month.unwrap_or(1), s.month_day.unwrap_or(1)); + if n > 1 { format!("每{duration}的{date}") } else { format!("每年{date}") } + } + }; + let sentence = format!("{}{repeats} {}{}", if s.once { "僅一次:" } else { "" }, s.time, if s.flexible { "左右" } else { "" }); + if s.timezone == local_timezone() { sentence } else { format!("{sentence}({})", s.timezone) } +} + +fn months_between(a: NaiveDate, b: NaiveDate) -> i64 { + (b.year() as i64 - a.year() as i64) * 12 + b.month() as i64 - a.month() as i64 +} + +fn nth_weekday_matches(d: NaiveDate, w: &WeekdayOfMonth) -> bool { + let Some(i) = weekday_index(&w.day) else { return false }; + if d.weekday().num_days_from_monday() as usize != i { + return false; + } + if w.week < 0 { + (d + Duration::days(7)).month() != d.month() + } else { + ((d.day() - 1) / 7 + 1) as i32 == w.week + } +} + +fn day_matches(s: &Schedule, anchor: NaiveDate, d: NaiveDate) -> bool { + let n = s.interval.max(1) as i64; + match s.frequency { + Frequency::Minutely | Frequency::Hourly => true, + Frequency::Daily => (d - anchor).num_days().rem_euclid(n) == 0, + Frequency::Weekly => { + let week0 = anchor - Duration::days(anchor.weekday().num_days_from_monday() as i64); + let weeks = (d - week0).num_days().div_euclid(7); + weeks.rem_euclid(n) == 0 && s.days_of_week.iter().any(|w| w == weekday_code(d.weekday())) + } + Frequency::Monthly => { + months_between(anchor, d).rem_euclid(n) == 0 + && match &s.weekday_of_month { + Some(w) => nth_weekday_matches(d, w), + // A day past the month's end runs on its last day. + None => { + let want = s.month_day.unwrap_or(1); + let last = (d + Duration::days(1)).month() != d.month(); + d.day() == want || (last && d.day() < want) + } + } + } + Frequency::Yearly => { + (d.year() - anchor.year()) as i64 % n == 0 && d.month() == s.month.unwrap_or(1) && d.day() == s.month_day.unwrap_or(1) + } + } +} + +/// The first run strictly after `after`, counting intervals from `created`. A flexible time moves +/// by a steady amount within ±FLEX_MINUTES; a one-time schedule has no run after its first. +pub fn next_run(s: &Schedule, created: i64, after: i64) -> Option { + if s.once { + let first = exact_next(s, created, created - 1)?; + let first = flex(s, created, first); + return (first > after).then_some(first); + } + // Look a little earlier so a time pulled forward by "左右" isn't skipped. + let pad = if s.flexible { FLEX_MINUTES * 60 } else { 0 }; + let mut from = after - pad; + for _ in 0..4 { + let t = exact_next(s, created, from)?; + let f = flex(s, created, t); + if f > after { + return Some(f); + } + from = t; + } + None +} + +fn flex(s: &Schedule, created: i64, t: i64) -> i64 { + if !s.flexible { + return t; + } + // A steady shift per run (same routine + same slot → same moment). + let h = (created as u64 ^ (t as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)).wrapping_mul(0xBF58_476D_1CE4_E5B9) >> 33; + t + (h % (2 * FLEX_MINUTES as u64 * 60 + 1)) as i64 - FLEX_MINUTES * 60 +} + +fn exact_next(s: &Schedule, created: i64, after: i64) -> Option { + let n = s.interval.max(1) as i64; + if s.frequency == Frequency::Minutely { + let step = n * 60; + let k = ((after - created).div_euclid(step) + 1).max(1); + return Some(created + k * step); + } + if s.frequency == Frequency::Hourly { + let minute = parse_time(&s.time).map(|t| chrono::Timelike::minute(&t) as i64).unwrap_or(0); + let hour0 = created.div_euclid(3600); + let mut h = after.div_euclid(3600); + for _ in 0..(n * 2 + 2) { + let ts = h * 3600 + minute * 60; + if ts > after && (h - hour0).rem_euclid(n) == 0 { + return Some(ts); + } + h += 1; + } + return None; + } + let tz = tz_of(&s.timezone); + let time = parse_time(&s.time)?; + let anchor = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz).date_naive(); + let start = Utc.timestamp_opt(after, 0).single()?.with_timezone(&tz).date_naive(); + for i in 0..SEARCH_DAYS { + let d = start + Duration::days(i); + if !day_matches(s, anchor, d) { + continue; + } + // Skipped or doubled clock times (summer time) take the earliest valid moment. + let Some(at) = tz.from_local_datetime(&d.and_time(time)).earliest() else { continue }; + let ts = at.timestamp(); + if ts > after { + return Some(ts); + } + } + None +} + +/// "今天 09:00", "明天 09:00" or "11月5日 09:00" in the routine's time zone. +pub fn describe_when(ts: i64, timezone: &str) -> String { + let tz = tz_of(timezone); + let Some(t) = Utc.timestamp_opt(ts, 0).single().map(|t| t.with_timezone(&tz)) else { return String::new() }; + let today = Utc::now().with_timezone(&tz).date_naive(); + let day = match (t.date_naive() - today).num_days() { + 0 => "今天".to_string(), + 1 => "明天".to_string(), + _ => format!("{}月{}日", t.month(), t.day()), + }; + format!("{day} {}", t.format("%H:%M")) +} + +pub fn now() -> i64 { + Utc::now().timestamp() +} + +pub fn now_in(timezone: &str) -> DateTime { + Utc::now().with_timezone(&tz_of(timezone)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(y: i32, m: u32, d: u32, h: u32, mi: u32) -> i64 { + chrono_tz::Asia::Taipei.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap().timestamp() + } + + /// Drop the "(Asia/Taipei)" a computer in another timezone adds. + fn here(s: String) -> String { + s.trim_end_matches("(Asia/Taipei)").to_string() + } + + fn sched(f: Frequency) -> Schedule { + Schedule { frequency: f, interval: 1, days_of_week: vec![], month_day: None, weekday_of_month: None, month: None, time: "09:00".into(), timezone: "Asia/Taipei".into(), once: false, flexible: false } + } + + #[test] + fn hourly_minutes_once_and_flexible() { + let mut h = sched(Frequency::Hourly); + h.time = "00:15".into(); + assert_eq!(next_run(&h, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 20)), Some(ts(2026, 11, 3, 9, 15))); + h.interval = 3; + assert_eq!(next_run(&h, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 20)), Some(ts(2026, 11, 3, 11, 15))); + assert_eq!(describe(&h), "每 3 小時的 15 分"); + let mut m = sched(Frequency::Minutely); + m.interval = 30; + assert_eq!(next_run(&m, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 31)), Some(ts(2026, 11, 3, 9, 0))); + m.interval = 2; + assert!(validate(m).is_err()); + let mut o = sched(Frequency::Daily); + o.once = true; + assert_eq!(next_run(&o, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 3, 9, 0))); + assert_eq!(next_run(&o, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 9, 0)), None); + let mut f = sched(Frequency::Daily); + f.flexible = true; + let t = next_run(&f, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)).unwrap(); + assert!((t - ts(2026, 11, 3, 9, 0)).abs() <= FLEX_MINUTES * 60); + assert_eq!(next_run(&f, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(t)); + assert!(here(describe(&f)).ends_with("09:00左右")); + let t2 = next_run(&f, ts(2026, 11, 3, 8, 0), t).unwrap(); + assert!(t2 > t && (t2 - ts(2026, 11, 4, 9, 0)).abs() <= FLEX_MINUTES * 60); + } + + #[test] + fn daily_and_weekly() { + let s = sched(Frequency::Daily); + // 2026-11-03 is a Tuesday. + assert_eq!(next_run(&s, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 3, 9, 0))); + assert_eq!(next_run(&s, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 9, 0)), Some(ts(2026, 11, 4, 9, 0))); + let mut w = sched(Frequency::Weekly); + w.days_of_week = vec!["MO".into(), "FR".into()]; + assert_eq!(next_run(&w, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 6, 9, 0))); + w.interval = 2; + assert_eq!(next_run(&w, ts(2026, 11, 3, 8, 0), ts(2026, 11, 7, 8, 0)), Some(ts(2026, 11, 16, 9, 0))); + assert_eq!(here(describe(&w)), "每2 週的週一、週五 09:00"); + w.interval = 1; + w.days_of_week = WORKWEEK.iter().map(|s| s.to_string()).collect(); + assert_eq!(here(describe(&w)), "每個工作日 09:00"); + } + + #[test] + fn monthly_rules() { + let mut m = sched(Frequency::Monthly); + m.month_day = Some(31); + // November has 30 days: runs on the 30th. + assert_eq!(next_run(&m, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 30, 9, 0))); + m.month_day = None; + m.weekday_of_month = Some(WeekdayOfMonth { week: -1, day: "FR".into() }); + assert_eq!(next_run(&m, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 27, 9, 0))); + assert_eq!(here(describe(&m)), "每月的最後一個星期五 09:00"); + } + + #[test] + fn validation() { + let mut w = sched(Frequency::Weekly); + assert!(validate(w.clone()).is_err()); + w.days_of_week = vec!["fr".into(), "MO".into(), "MO".into()]; + w.time = "7:30".into(); + let v = validate(w).unwrap(); + assert_eq!(v.days_of_week, vec!["MO", "FR"]); + let mut bad = sched(Frequency::Daily); + bad.time = "noon".into(); + assert!(validate(bad).is_err()); + } +} diff --git a/src/share.rs b/src/share.rs new file mode 100644 index 0000000..f7280c9 --- /dev/null +++ b/src/share.rs @@ -0,0 +1,137 @@ +// 分享 Bot: the original shares team bots through its servers (members, roles). Here a bot is +// shared as a file: its look, provider/model, instructions, routines and demonstrations — never +// the chat, API keys, tokens or the person's always-allow grants. + +use crate::model::Bot; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const FORMAT: &str = "anybot-bot"; +pub const VERSION: u32 = 1; + +#[derive(Serialize, Deserialize)] +pub struct Shared { + pub format: String, + pub version: u32, + pub bot: Value, +} + +/// Token-like fields removed from routine triggers before sharing. +const SECRET_KEYS: [&str; 4] = ["token", "apiKey", "secret", "api_key"]; + +fn scrub(v: &mut Value) { + match v { + Value::Object(m) => { + for k in SECRET_KEYS { + if m.contains_key(k) { + m.insert(k.to_string(), Value::String(String::new())); + } + } + for (_, x) in m.iter_mut() { + scrub(x); + } + } + Value::Array(a) => a.iter_mut().for_each(scrub), + _ => {} + } +} + +/// The bot as a shareable file. +pub fn export(b: &Bot) -> String { + let mut v = serde_json::to_value(b).unwrap_or(Value::Null); + if let Value::Object(m) = &mut v { + for k in ["messages", "allow_rules", "unread", "last_turn", "workdir", "pinned", "id"] { + m.remove(k); + } + if let Some(Value::Array(routines)) = m.get_mut("routines") { + for r in routines.iter_mut() { + if let Value::Object(rm) = r { + for k in ["last_run", "next_run"] { + rm.remove(k); + } + } + } + } + // Demonstrations keep their steps; the pictures and video stay on this Mac. + if let Some(Value::Array(demos)) = m.get_mut("demos") { + for d in demos.iter_mut() { + if let Value::Object(dm) = d { + dm.insert("video".into(), Value::String(String::new())); + dm.insert("shots".into(), Value::Array(vec![])); + } + } + } + } + scrub(&mut v); + serde_json::to_string_pretty(&Shared { format: FORMAT.into(), version: VERSION, bot: v }).unwrap_or_default() +} + +/// A bot from a shared file, with a new id and fresh routine ids (`next_routine` is advanced). +pub fn import(text: &str, new_id: u64, next_routine: &mut u64, now: i64) -> Result { + let s: Shared = serde_json::from_str(text).map_err(|_| "這不是 AnyBot 的 Bot 檔案".to_string())?; + if s.format != FORMAT { + return Err("這不是 AnyBot 的 Bot 檔案".into()); + } + if s.version > VERSION { + return Err("這個檔案來自較新的 AnyBot,請先更新".into()); + } + let mut v = s.bot; + if let Value::Object(m) = &mut v { + m.insert("id".into(), Value::from(new_id)); + m.insert("messages".into(), Value::Array(vec![])); + } + let mut b: Bot = serde_json::from_value(v).map_err(|e| format!("檔案內容不完整:{e}"))?; + for r in b.routines.iter_mut() { + r.id = *next_routine; + *next_routine += 1; + r.created_at = now; + r.last_run = None; + // Triggers need the new owner's own tokens: start paused until they're filled in. + if r.trigger.is_some() { + r.paused = true; + } + r.next_run = if r.trigger.is_some() { None } else { crate::routines::next_run(&r.schedule, now, now) }; + } + Ok(b) +} + +/// Finder dialogs (macOS). +pub fn choose_save(default_name: &str) -> Option { + if !cfg!(target_os = "macos") { + return crate::platform::choose_save_linux(&crate::i18n::t("儲存"), default_name); + } + let script = r#"function run(argv) { const app = Application.currentApplication(); app.includeStandardAdditions = true; + try { return app.chooseFileName({withPrompt: '匯出 Bot', defaultName: argv[0]}).toString(); } catch (e) { return ''; } }"#; + let o = std::process::Command::new("osascript").args(["-l", "JavaScript", "-e", script, "--", default_name]).output().ok()?; + let p = String::from_utf8_lossy(&o.stdout).trim().to_string(); + (!p.is_empty()).then(|| std::path::PathBuf::from(p)) +} + +pub fn choose_open() -> Option { + crate::attach::pick_files().into_iter().next() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_bots_leave_secrets_and_chat_behind() { + let mut st = crate::model::State::demo(); + let mut b = st.bots[1].clone(); + b.allow_rules = vec!["shell:git".into()]; + b.messages.push(crate::model::Message::user("私人對話")); + b.routines[0].trigger = Some(crate::triggers::Trigger::Github { repo: "me/app".into(), token: "ghp_SECRET".into(), events: vec![], users: vec![], branch: String::new(), pr: 0 }); + b.demos.push(crate::teach::Demo { name: "報表".into(), created: String::new(), steps: vec!["點 (1, 2)".into()], video: "/Users/x/v.mp4".into(), shots: vec!["/Users/x/1.jpg".into()] }); + let text = export(&b); + assert!(!text.contains("ghp_SECRET") && !text.contains("私人對話") && !text.contains("shell:git") && !text.contains("/Users/x")); + let mut next = 500; + let got = import(&text, 99, &mut next, crate::routines::now()).unwrap(); + assert_eq!((got.id, got.name.clone(), got.messages.len()), (99, b.name.clone(), 0)); + assert_eq!(got.routines[0].id, 500); + assert!(got.routines[0].paused, "a trigger waits for the new owner's token"); + assert_eq!(got.demos[0].steps, vec!["點 (1, 2)".to_string()]); + assert!(import("{}", 1, &mut next, 0).is_err()); + st.bots.clear(); + } +} diff --git a/src/teach.rs b/src/teach.rs new file mode 100644 index 0000000..f674b7a --- /dev/null +++ b/src/teach.rs @@ -0,0 +1,595 @@ +// 示範教學: record the person doing a task on this Mac so a bot can learn the steps and do it +// again later. The original: 「錄下你執行任務的過程。{Bot} 會學習這些步驟,之後可以自行再次執行。」 +// and 「{Bot} 正在觀看並學習」 (it records the screen with ffmpeg plus an input-event log; see +// local-exec-daemon saveRecordingDataPackage {inputEvents, displayWidth, apiWidth…}). +// Here a listen-only event tap collects clicks, keys and scrolls (nothing is blocked or changed), +// and they're written up as numbered steps with screenshot coordinates. + +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use std::time::Instant; + +/// Two clicks closer than this (and near each other) are a double click (estimated). +const DOUBLE_CLICK_MS: u128 = 400; +/// Scrolls closer than this are one step (estimated). +const SCROLL_MERGE_MS: u128 = 1500; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct Demo { + pub name: String, + pub created: String, + pub steps: Vec, + /// The screen recording (mp4), like the original's ffmpeg recording; empty when none. + #[serde(default)] + pub video: String, + /// The screen at each step (a JPEG per step; empty when not captured). + #[serde(default)] + pub shots: Vec, +} + +#[derive(Clone, Debug)] +enum Raw { + Click { x: f64, y: f64, right: bool, app: String, at: Instant }, + Key { text: String, code: i64, flags: u64, app: String }, + Scroll { dy: i64, app: String, at: Instant }, +} + +static EVENTS: Mutex> = Mutex::new(Vec::new()); +/// The frame number showing on screen when each event happened (same order as EVENTS). +static EVENT_FRAMES: Mutex> = Mutex::new(Vec::new()); +static FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static FRAME_STOP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static REC_DIR: Mutex> = Mutex::new(None); +/// Screen pictures per second while recording (estimated; the original records video). +pub const FRAMES_PER_SEC: u64 = 2; +/// Width of the recorded pictures (the original's render proxy is 1080p; smaller here). +pub const FRAME_WIDTH: u32 = 1280; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +static RUNLOOP: Mutex = Mutex::new(0); +pub static RECORDING: Mutex> = Mutex::new(None); + +pub fn recording() -> Option<(String, Instant)> { + RECORDING.lock().unwrap().clone() +} + +/// Start recording for a bot. Err when macOS hasn't allowed listening to input yet. +pub fn start(bot_name: &str) -> Result<(), String> { + if RECORDING.lock().unwrap().is_some() { + return Err("已經在錄了".into()); + } + EVENTS.lock().unwrap().clear(); + EVENT_FRAMES.lock().unwrap().clear(); + mac::start()?; + *RECORDING.lock().unwrap() = Some((bot_name.to_string(), Instant::now())); + start_frames(); + Ok(()) +} + +fn demos_dir() -> std::path::PathBuf { + crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("demos") +} + +/// Pictures of the screen while recording: frame_00000.jpg, frame_00001.jpg… +fn start_frames() { + let dir = demos_dir().join(chrono::Local::now().format("%Y%m%d-%H%M%S").to_string()); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + *REC_DIR.lock().unwrap() = Some(dir.clone()); + FRAMES.store(0, std::sync::atomic::Ordering::SeqCst); + FRAME_STOP.store(false, std::sync::atomic::Ordering::SeqCst); + std::thread::spawn(move || { + while !FRAME_STOP.load(std::sync::atomic::Ordering::SeqCst) { + let t0 = Instant::now(); + if crate::computer::screen_supported() && crate::computer::screen_capture_ok() { + if let Ok(img) = crate::computer::capture_screen() { + { + let (w, h) = img.dimensions(); + let small = if w > FRAME_WIDTH { image::imageops::resize(&img, FRAME_WIDTH, (h as u64 * FRAME_WIDTH as u64 / w.max(1) as u64) as u32, image::imageops::FilterType::Triangle) } else { img }; + let n = FRAMES.load(std::sync::atomic::Ordering::SeqCst); + if small.save(dir.join(format!("frame_{n:05}.jpg"))).is_ok() { + FRAMES.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + } + } + let wait = std::time::Duration::from_millis(1000 / FRAMES_PER_SEC); + if let Some(rest) = wait.checked_sub(t0.elapsed()) { + std::thread::sleep(rest); + } + } + }); +} + +/// Stop the pictures and turn them into a video when ffmpeg is there. Returns (dir, video). +fn stop_frames() -> Option<(std::path::PathBuf, String)> { + FRAME_STOP.store(true, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(700)); + let dir = REC_DIR.lock().unwrap().take()?; + let video = make_video(&dir).unwrap_or_default(); + Some((dir, video)) +} + +/// frame_*.jpg → recording.mp4 (H.264, like the original's render proxy). +pub fn make_video(dir: &std::path::Path) -> Option { + if !dir.join("frame_00000.jpg").exists() { + return None; + } + let out = dir.join("recording.mp4"); + let ok = std::process::Command::new("ffmpeg") + .env("PATH", crate::cloudbox::tool_path()) + .args(["-hide_banner", "-loglevel", "error", "-y", "-framerate", &FRAMES_PER_SEC.to_string(), "-i"]) + .arg(dir.join("frame_%05d.jpg")) + .args(["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart"]) + .arg(&out) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + ok.then(|| out.display().to_string()) +} + +/// Stop and write up the steps. +pub fn finish(name: &str) -> Demo { + mac::stop(); + *RECORDING.lock().unwrap() = None; + let raw = std::mem::take(&mut *EVENTS.lock().unwrap()); + let frames_at = std::mem::take(&mut *EVENT_FRAMES.lock().unwrap()); + let compiled = compile(&raw, crate::computer::points_to_shot()); + let rec = stop_frames(); + let mut shots = vec![]; + let mut video = String::new(); + if let Some((dir, v)) = &rec { + video = v.clone(); + for (i, (_, first)) in compiled.iter().enumerate() { + // The picture just before the step happened (what the person was looking at). + let f = frames_at.get(*first).copied().unwrap_or(0); + let src = dir.join(format!("frame_{f:05}.jpg")); + let dst = dir.join(format!("step_{:02}.jpg", i + 1)); + shots.push(if std::fs::copy(&src, &dst).is_ok() { dst.display().to_string() } else { String::new() }); + } + // The video holds the rest. + if !video.is_empty() { + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + if e.file_name().to_string_lossy().starts_with("frame_") { + let _ = std::fs::remove_file(e.path()); + } + } + } + } + } + Demo { name: name.trim().to_string(), created: chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(), steps: compiled.into_iter().map(|c| c.0).collect(), video, shots } +} + +pub fn cancel() { + mac::stop(); + *RECORDING.lock().unwrap() = None; + EVENTS.lock().unwrap().clear(); + EVENT_FRAMES.lock().unwrap().clear(); + if let Some((dir, _)) = stop_frames() { + let _ = std::fs::remove_dir_all(dir); + } +} + +/// The view_demo_step tool: the screen at one step of a demonstration, for the bot to look at. +pub fn view_step_spec() -> serde_json::Value { + serde_json::json!({"name": "view_demo_step", "description": "See what the person's screen looked like at one step of a demonstration they recorded for you (a picture), to find the right buttons when you repeat it.", + "parameters": {"type": "object", "properties": {"demo": {"type": "string", "description": "The demonstration's name"}, "step": {"type": "integer", "description": "Step number, from 1"}}, "required": ["demo", "step"]}}) +} + +/// Returns (text, ok, picture as base64 JPEG). +pub fn view_step(demos: &[Demo], args: &str) -> (String, bool, Option) { + use base64::Engine; + let v: serde_json::Value = serde_json::from_str(args).unwrap_or_default(); + let name = v["demo"].as_str().unwrap_or("").trim(); + let Some(d) = demos.iter().find(|d| d.name == name).or_else(|| demos.iter().find(|d| d.name.contains(name) && !name.is_empty())) else { + return (format!("沒有叫「{name}」的示範。有:{}", demos.iter().map(|d| d.name.as_str()).collect::>().join("、")), false, None); + }; + let n = v["step"].as_u64().unwrap_or(0) as usize; + if n == 0 || n > d.steps.len() { + return (format!("「{}」有 {} 個步驟,請給 1 到 {}。", d.name, d.steps.len(), d.steps.len()), false, None); + } + let text = format!("「{}」第 {n} 步:{}", d.name, d.steps[n - 1]); + let Some(path) = d.shots.get(n - 1).filter(|p| !p.is_empty()) else { + return (format!("{text}\n(這一步沒有存到畫面)"), true, None); + }; + match std::fs::read(path).ok().and_then(|b| image::load_from_memory(&b).ok()) { + Some(img) => { + let img = img.to_rgb8(); + let (w, h) = img.dimensions(); + let (fw, fh, resize) = crate::computer::fit(w, h); + let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; + match crate::computer::encode_jpeg(&img) { + Some(j) => (format!("{text}\n這是使用者做這一步之前的畫面({fw}×{fh},座標和步驟裡的一樣)。"), true, Some(base64::engine::general_purpose::STANDARD.encode(j))), + None => (format!("{text}\n(畫面太大,沒辦法送出)"), true, None), + } + } + None => (format!("{text}\n(畫面檔案不見了)"), true, None), + } +} + +fn key_name(code: i64) -> Option<&'static str> { + Some(match code { + 36 => "return", + 48 => "tab", + 49 => "space", + 51 => "delete", + 53 => "escape", + 117 => "forwarddelete", + 123 => "left", + 124 => "right", + 125 => "down", + 126 => "up", + 115 => "home", + 119 => "end", + 116 => "pageup", + 121 => "pagedown", + _ => return None, + }) +} + +const CMD: u64 = 1 << 20; +const SHIFT: u64 = 1 << 17; +const CTRL: u64 = 1 << 18; +const OPT: u64 = 1 << 19; + +/// Raw events → steps a bot can follow. +fn compile(raw: &[Raw], to_shot: f64) -> Vec<(String, usize)> { + let mut steps: Vec<(String, usize)> = vec![]; + let mut typed_from = 0usize; + let mut typed = String::new(); + let mut typed_app = String::new(); + let mut last_app = String::new(); + let flush = |steps: &mut Vec<(String, usize)>, typed: &mut String, app: &str, from: usize| { + if !typed.is_empty() { + steps.push((format!("在「{app}」輸入「{typed}」"), from)); + typed.clear(); + } + }; + let mut i = 0; + while i < raw.len() { + match &raw[i] { + Raw::Click { x, y, right, app, at } => { + flush(&mut steps, &mut typed, &typed_app, typed_from); + let start = i; + let mut count = 1; + while let Some(Raw::Click { x: x2, y: y2, right: r2, at: t2, .. }) = raw.get(i + 1) { + if r2 == right && (x2 - x).abs() < 6.0 && (y2 - y).abs() < 6.0 && t2.duration_since(*at).as_millis() < DOUBLE_CLICK_MS * count as u128 { + count += 1; + i += 1; + } else { + break; + } + } + let verb = match (right, count) { + (true, _) => "按右鍵".to_string(), + (false, 1) => "點".to_string(), + (false, n) => format!("連點 {n} 下"), + }; + let switch = if *app != last_app && !app.is_empty() { format!("切到「{app}」,") } else { String::new() }; + steps.push((format!("{switch}在「{app}」{verb} ({:.0}, {:.0})", x * to_shot, y * to_shot), start)); + last_app = app.clone(); + } + Raw::Key { text, code, flags, app } => { + let mods: Vec<&str> = [(CMD, "cmd"), (CTRL, "control"), (OPT, "option")].iter().filter(|(f, _)| flags & f != 0).map(|(_, n)| *n).collect(); + if mods.is_empty() && *code == 51 && !typed.is_empty() { + typed.pop(); + } else if mods.is_empty() && key_name(*code).is_none() && !text.is_empty() && !text.chars().any(char::is_control) { + if typed.is_empty() { + typed_app = app.clone(); + typed_from = i; + } + typed.push_str(text); + } else { + flush(&mut steps, &mut typed, &typed_app, typed_from); + let key = key_name(*code).map(String::from).unwrap_or_else(|| text.to_lowercase()); + if key.is_empty() { + i += 1; + continue; + } + let mut combo: Vec = mods.iter().map(|m| m.to_string()).collect(); + if flags & SHIFT != 0 && !mods.is_empty() { + combo.push("shift".into()); + } + combo.push(key); + steps.push((format!("按 {}", combo.join("+")), i)); + } + last_app = app.clone(); + } + Raw::Scroll { dy, app, at } => { + flush(&mut steps, &mut typed, &typed_app, typed_from); + let start = i; + let mut total = *dy; + let mut last = *at; + while let Some(Raw::Scroll { dy: d2, at: t2, .. }) = raw.get(i + 1) { + if t2.duration_since(last).as_millis() < SCROLL_MERGE_MS && d2.signum() == dy.signum() { + total += d2; + last = *t2; + i += 1; + } else { + break; + } + } + steps.push((format!("在「{app}」往{}捲動", if total < 0 { "下" } else { "上" }), start)); + } + } + i += 1; + } + flush(&mut steps, &mut typed, &typed_app, typed_from); + steps +} + +/// The steps as told to the bot. +pub fn prompt_section(demos: &[Demo]) -> String { + if demos.is_empty() { + return String::new(); + } + let mut s = String::from("\n\n你學過的示範(使用者在他的 Mac 上做給你看的步驟)。使用者要你做同樣的事時,照著在「你的電腦」上做:每一步先截圖確認,座標是錄製時截圖上的位置,畫面不同就找對應的按鈕;輸入的內容可以依新的需求調整。想看某一步當時的畫面,用 view_demo_step。"); + for d in demos { + s.push_str(&format!("\n### {}({})\n", d.name, d.created)); + for (i, st) in d.steps.iter().enumerate() { + s.push_str(&format!("{}. {st}\n", i + 1)); + } + } + s +} + +fn push(e: Raw) { + // AnyBot's own window (its Stop button, the bar) isn't part of the task. + let own = match &e { + Raw::Click { app, .. } | Raw::Key { app, .. } | Raw::Scroll { app, .. } => app.to_lowercase().contains("anybot"), + }; + if !own { + EVENTS.lock().unwrap().push(e); + // The last picture taken before this moment. + EVENT_FRAMES.lock().unwrap().push(FRAMES.load(std::sync::atomic::Ordering::SeqCst).saturating_sub(1)); + } +} + +#[cfg(target_os = "macos")] +mod mac { + use super::*; + use std::ffi::c_void; + + #[repr(C)] + #[derive(Clone, Copy)] + struct CGPoint { + x: f64, + y: f64, + } + type Cb = extern "C" fn(*mut c_void, u32, *mut c_void, *mut c_void) -> *mut c_void; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGEventTapCreate(tap: u32, place: u32, options: u32, mask: u64, cb: Cb, user: *mut c_void) -> *mut c_void; + fn CGEventTapEnable(tap: *mut c_void, enable: bool); + fn CGEventGetLocation(e: *mut c_void) -> CGPoint; + fn CGEventGetFlags(e: *mut c_void) -> u64; + fn CGEventGetIntegerValueField(e: *mut c_void, field: u32) -> i64; + fn CGEventKeyboardGetUnicodeString(e: *mut c_void, max: usize, actual: *mut usize, buf: *mut u16); + } + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFMachPortCreateRunLoopSource(alloc: *const c_void, port: *mut c_void, order: isize) -> *mut c_void; + fn CFRunLoopGetCurrent() -> *mut c_void; + fn CFRunLoopAddSource(rl: *mut c_void, src: *mut c_void, mode: *const c_void); + fn CFRunLoopRun(); + fn CFRunLoopStop(rl: *mut c_void); + static kCFRunLoopCommonModes: *const c_void; + } + + static TAP: Mutex = Mutex::new(0); + + extern "C" fn callback(_proxy: *mut c_void, kind: u32, e: *mut c_void, _user: *mut c_void) -> *mut c_void { + unsafe { + match kind { + 1 | 3 => { + let p = CGEventGetLocation(e); + let app = crate::computer::app_at_point(p.x, p.y).unwrap_or_default(); + push(Raw::Click { x: p.x, y: p.y, right: kind == 3, app, at: Instant::now() }); + } + 10 => { + let mut buf = [0u16; 8]; + let mut n = 0usize; + CGEventKeyboardGetUnicodeString(e, buf.len(), &mut n, buf.as_mut_ptr()); + let text = String::from_utf16_lossy(&buf[..n.min(buf.len())]); + let code = CGEventGetIntegerValueField(e, 9); + let app = crate::computer::frontmost_app().unwrap_or_default(); + push(Raw::Key { text, code, flags: CGEventGetFlags(e), app }); + } + 22 => { + let dy = CGEventGetIntegerValueField(e, 11); + if dy != 0 { + let p = CGEventGetLocation(e); + let app = crate::computer::app_at_point(p.x, p.y).unwrap_or_default(); + push(Raw::Scroll { dy, app, at: Instant::now() }); + } + } + // The system turned the tap off (slow callback): turn it back on. + 0xFFFF_FFFE | 0xFFFF_FFFF => { + let t = *TAP.lock().unwrap(); + if t != 0 { + CGEventTapEnable(t as *mut c_void, true); + } + } + _ => {} + } + } + e + } + + pub fn start() -> Result<(), String> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || unsafe { + let mask: u64 = (1 << 1) | (1 << 3) | (1 << 10) | (1 << 22); + // kCGSessionEventTap, head insert, listen only. + let tap = CGEventTapCreate(1, 0, 1, mask, callback, std::ptr::null_mut()); + if tap.is_null() { + let _ = tx.send(false); + return; + } + *TAP.lock().unwrap() = tap as usize; + let src = CFMachPortCreateRunLoopSource(std::ptr::null(), tap, 0); + let rl = CFRunLoopGetCurrent(); + CFRunLoopAddSource(rl, src, kCFRunLoopCommonModes); + CGEventTapEnable(tap, true); + *RUNLOOP.lock().unwrap() = rl as usize; + let _ = tx.send(true); + CFRunLoopRun(); + CGEventTapEnable(tap, false); + *TAP.lock().unwrap() = 0; + }); + match rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(true) => Ok(()), + _ => { + let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent").spawn(); + Err("macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。".into()) + } + } + } + + pub fn stop() { + let rl = std::mem::take(&mut *RUNLOOP.lock().unwrap()); + if rl != 0 { + unsafe { CFRunLoopStop(rl as *mut c_void) }; + } + } +} + +/// Linux: the X RECORD extension shows every click and key (nothing is blocked or changed). +#[cfg(target_os = "linux")] +mod mac { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + static STOP: AtomicBool = AtomicBool::new(false); + + pub fn start() -> Result<(), String> { + if crate::desk::kind() != crate::desk::Kind::X11 { + return Err(if crate::desk::wayland() { + "在 Wayland 上,系統不讓 App 看到你按了哪裡、打了什麼,所以沒辦法錄製示範(Bot 還是可以操作畫面)。要錄示範,請在登入畫面改選 Xorg 工作階段。" + } else { + "錄製示範需要桌面畫面(X11)。" + } + .into()); + } + STOP.store(false, Ordering::SeqCst); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let tx2 = tx.clone(); + let r = crate::x11::record( + |input| push(to_raw(input)), + move || { + let _ = tx2.send(Ok(())); + !STOP.load(Ordering::SeqCst) + }, + ); + if let Err(e) = r { + let _ = tx.send(Err(e)); + } + }); + rx.recv_timeout(std::time::Duration::from_secs(3)).unwrap_or(Err("沒辦法開始錄製(X11 沒有回應)".into())) + } + + /// X input → the same events the macOS tap gives (key codes and flags in macOS terms, so + /// the steps read the same; Linux's Ctrl shows as "control"). + pub fn to_raw(i: crate::x11::Input) -> Raw { + let app = |x: f64, y: f64| crate::x11::window_at(x, y).map(|a| a.0).or_else(|| crate::x11::front_app().map(|a| a.0)).unwrap_or_default(); + match i { + crate::x11::Input::Click { x, y, right } => Raw::Click { x, y, right, app: app(x, y), at: Instant::now() }, + crate::x11::Input::Scroll { dy } => Raw::Scroll { dy, app: crate::x11::front_app().map(|a| a.0).unwrap_or_default(), at: Instant::now() }, + crate::x11::Input::Key { text, keysym, mods } => { + let code = crate::x11::keysym_name(keysym) + .and_then(crate::computer::key_code) + .or_else(|| crate::x11::char_of_keysym(keysym).and_then(|c| crate::computer::key_code(&c.to_lowercase().to_string()))) + .map(|c| c as i64) + .unwrap_or(-1); + let mut flags = 0; + if mods & 1 != 0 { + flags |= SHIFT; + } + if mods & 4 != 0 { + flags |= CTRL; + } + if mods & 8 != 0 { + flags |= OPT; + } + if mods & 64 != 0 { + flags |= CMD; + } + let text = if keysym == 0xff0d { "\r".into() } else { text }; + Raw::Key { text, code, flags, app: crate::x11::front_app().map(|a| a.0).unwrap_or_default() } + } + } + } + + pub fn stop() { + STOP.store(true, Ordering::SeqCst); + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod mac { + pub fn start() -> Result<(), String> { + Err("這個系統還不能錄製示範".into()) + } + pub fn stop() {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_become_a_video() { + let dir = std::env::temp_dir().join(format!("anybot-demo-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..4u8 { + image::RgbImage::from_pixel(64, 40, image::Rgb([i * 60, 100, 200])).save(dir.join(format!("frame_{i:05}.jpg"))).unwrap(); + } + let v = make_video(&dir); + if std::process::Command::new("ffmpeg").env("PATH", crate::cloudbox::tool_path()).arg("-version").output().is_ok() { + let v = v.expect("video made"); + assert!(std::fs::metadata(&v).unwrap().len() > 0); + } + let demo = Demo { name: "報表".into(), created: String::new(), steps: vec!["點 (1, 2)".into()], video: String::new(), shots: vec![dir.join("frame_00002.jpg").display().to_string()] }; + let (t, ok, img) = view_step(&[demo.clone()], r#"{"demo":"報表","step":1}"#); + assert!(ok && img.is_some() && t.contains("第 1 步"), "{t}"); + assert!(!view_step(&[demo], r#"{"demo":"報表","step":5}"#).1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn steps_read_like_instructions() { + let t0 = Instant::now(); + let ms = |n: u64| t0 + std::time::Duration::from_millis(n); + let raw = vec![ + Raw::Click { x: 100.0, y: 50.0, right: false, app: "Safari".into(), at: ms(0) }, + Raw::Key { text: "h".into(), code: 4, flags: 0, app: "Safari".into() }, + Raw::Key { text: "i".into(), code: 34, flags: 0, app: "Safari".into() }, + Raw::Key { text: "x".into(), code: 7, flags: 0, app: "Safari".into() }, + Raw::Key { text: "".into(), code: 51, flags: 0, app: "Safari".into() }, + Raw::Key { text: "\r".into(), code: 36, flags: 0, app: "Safari".into() }, + Raw::Key { text: "c".into(), code: 8, flags: CMD, app: "Safari".into() }, + Raw::Click { x: 300.0, y: 200.0, right: false, app: "備忘錄".into(), at: ms(2000) }, + Raw::Click { x: 301.0, y: 201.0, right: false, app: "備忘錄".into(), at: ms(2200) }, + Raw::Scroll { dy: -3, app: "備忘錄".into(), at: ms(3000) }, + Raw::Scroll { dy: -2, app: "備忘錄".into(), at: ms(3300) }, + ]; + let s: Vec = compile(&raw, 0.5).into_iter().map(|c| c.0).collect(); + assert_eq!( + s, + vec![ + "切到「Safari」,在「Safari」點 (50, 25)", + "在「Safari」輸入「hi」", + "按 return", + "按 cmd+c", + "切到「備忘錄」,在「備忘錄」連點 2 下 (150, 100)", + "在「備忘錄」往下捲動", + ] + ); + } +} diff --git a/src/teams.rs b/src/teams.rs new file mode 100644 index 0000000..479fae7 --- /dev/null +++ b/src/teams.rs @@ -0,0 +1,271 @@ +// Microsoft Teams through Microsoft Graph (the original: a Teams app answering in channels and +// chats). Sign-in uses the device-code flow, so no public address is needed: AnyBot shows a code, +// the person enters it at microsoft.com/devicelogin, and the refresh token is kept. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +pub const SCOPES: &str = "offline_access User.Read Chat.ReadWrite ChannelMessage.Read.All ChannelMessage.Send"; +const GRAPH: &str = "https://graph.microsoft.com/v1.0"; +/// Access tokens last about an hour; refresh a little early (estimated). +const ACCESS_REFRESH_SECS: u64 = 50 * 60; + +fn login_base() -> String { + std::env::var("ANYBOT_MS_LOGIN").unwrap_or_else(|_| "https://login.microsoftonline.com".into()) +} +fn graph_base() -> String { + std::env::var("ANYBOT_MS_GRAPH").unwrap_or_else(|_| GRAPH.into()) +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into() +} + +fn form(url: &str, fields: &[(&str, &str)]) -> Result { + let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, urlencode(v))).collect::>().join("&"); + let mut resp = agent().post(url).header("Content-Type", "application/x-www-form-urlencoded").send(body.as_bytes()).map_err(|e| e.to_string())?; + let text = resp.body_mut().read_to_string().unwrap_or_default(); + serde_json::from_str(&text).map_err(|_| text.chars().take(200).collect()) +} + +pub fn urlencode(s: &str) -> String { + s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() +} + +/// Step 1: ask for a code. Returns (user code, address, device code, poll seconds). +pub fn device_code(client_id: &str, tenant: &str) -> Result<(String, String, String, u64), String> { + let v = form(&format!("{}/{}/oauth2/v2.0/devicecode", login_base(), tenant_or_default(tenant)), &[("client_id", client_id.trim()), ("scope", SCOPES)])?; + match (v["user_code"].as_str(), v["verification_uri"].as_str(), v["device_code"].as_str()) { + (Some(u), Some(a), Some(d)) => Ok((u.into(), a.into(), d.into(), v["interval"].as_u64().unwrap_or(5))), + _ => Err(v["error_description"].as_str().unwrap_or("拿不到登入代碼").lines().next().unwrap_or("").to_string()), + } +} + +/// Step 2: wait until the person signed in. Returns the refresh token. +pub fn wait_for_login(client_id: &str, tenant: &str, device: &str, interval: u64, max: Duration) -> Result { + let start = Instant::now(); + while start.elapsed() < max { + std::thread::sleep(Duration::from_secs(interval.max(1))); + let v = form(&format!("{}/{}/oauth2/v2.0/token", login_base(), tenant_or_default(tenant)), &[("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("client_id", client_id.trim()), ("device_code", device)])?; + if let Some(r) = v["refresh_token"].as_str() { + return Ok(r.to_string()); + } + match v["error"].as_str().unwrap_or("") { + "authorization_pending" | "slow_down" => continue, + e => return Err(format!("登入沒有完成:{}", v["error_description"].as_str().unwrap_or(e).lines().next().unwrap_or(e))), + } + } + Err("等太久了,請再登入一次".into()) +} + +fn tenant_or_default(t: &str) -> String { + if t.trim().is_empty() { "organizations".into() } else { t.trim().into() } +} + +struct Cached { + access: String, + at: Instant, +} + +fn cache() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// New refresh tokens (Microsoft rotates them), for the window to save: channel id → token. +pub fn rotated() -> &'static Mutex> { + static R: OnceLock>> = OnceLock::new(); + R.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// An access token for a channel's refresh token. +pub fn access(channel_id: u64, client_id: &str, tenant: &str, refresh: &str) -> Result { + let key = format!("{channel_id}"); + if let Some(c) = cache().lock().unwrap().get(&key) { + if c.at.elapsed().as_secs() < ACCESS_REFRESH_SECS { + return Ok(c.access.clone()); + } + } + let current = rotated().lock().unwrap().get(&channel_id).cloned().unwrap_or_else(|| refresh.to_string()); + let v = form(&format!("{}/{}/oauth2/v2.0/token", login_base(), tenant_or_default(tenant)), &[("grant_type", "refresh_token"), ("client_id", client_id.trim()), ("refresh_token", ¤t), ("scope", SCOPES)])?; + let a = v["access_token"].as_str().ok_or_else(|| format!("Microsoft 登入過期了,請重新登入({})", v["error"].as_str().unwrap_or("")))?.to_string(); + if let Some(r) = v["refresh_token"].as_str() { + rotated().lock().unwrap().insert(channel_id, r.to_string()); + } + cache().lock().unwrap().insert(key, Cached { access: a.clone(), at: Instant::now() }); + Ok(a) +} + +pub fn graph_get(access: &str, path: &str) -> Result { + let mut resp = agent().get(&format!("{}{path}", graph_base())).header("Authorization", &format!("Bearer {access}")).call().map_err(|e| e.to_string())?; + let st = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + if st == 429 { + return Err("rate".into()); + } + if !(200..300).contains(&st) { + return Err(format!("Teams:HTTP {st} {}", text.chars().take(150).collect::())); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +pub fn graph_post(access: &str, path: &str, body: &Value) -> Result { + let mut resp = agent().post(&format!("{}{path}", graph_base())).header("Authorization", &format!("Bearer {access}")).header("Content-Type", "application/json").send(body.to_string().as_bytes()).map_err(|e| e.to_string())?; + let st = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + if !(200..300).contains(&st) { + return Err(format!("Teams:HTTP {st} {}", text.chars().take(150).collect::())); + } + Ok(serde_json::from_str(&text).unwrap_or(Value::Null)) +} + +/// Where a target points: a chat ("chat:") or a channel ("/"). +pub fn messages_path(target: &str) -> String { + match target.trim().strip_prefix("chat:") { + Some(chat) => format!("/chats/{chat}/messages?$top=20"), + None => { + let (team, ch) = target.trim().split_once('/').unwrap_or((target.trim(), "")); + format!("/teams/{team}/channels/{ch}/messages?$top=20") + } + } +} + +pub fn reply_path(target: &str, thread: &str) -> String { + match target.trim().strip_prefix("chat:") { + Some(chat) => format!("/chats/{chat}/messages"), + None => { + let (team, ch) = target.trim().split_once('/').unwrap_or((target.trim(), "")); + if thread.is_empty() { format!("/teams/{team}/channels/{ch}/messages") } else { format!("/teams/{team}/channels/{ch}/messages/{thread}/replies") } + } + } +} + +/// Teams message HTML → plain text. +pub fn plain(html: &str) -> String { + let mut out = String::new(); + let mut in_tag = false; + for c in html.replace("
", "\n").replace("

", "\n").chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + out.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").trim().to_string() +} + +/// New messages (by created time) from people other than `me`, oldest first: (who, text, id). +pub fn new_messages(list: &Value, cursor: &str, me: &str) -> (String, Vec<(String, String, String)>) { + let mut newest = cursor.to_string(); + let mut items = vec![]; + let mut msgs: Vec<&Value> = list["value"].as_array().map(|a| a.iter().collect()).unwrap_or_default(); + msgs.sort_by(|a, b| a["createdDateTime"].as_str().unwrap_or("").cmp(b["createdDateTime"].as_str().unwrap_or(""))); + for m in msgs { + let t = m["createdDateTime"].as_str().unwrap_or("").to_string(); + if t.as_str() <= cursor { + continue; + } + if t > newest { + newest = t.clone(); + } + if m["messageType"].as_str().unwrap_or("message") != "message" || m["from"]["user"]["id"].as_str() == Some(me) || m["from"]["application"].is_object() { + continue; + } + let text = plain(m["body"]["content"].as_str().unwrap_or("")); + if !text.is_empty() { + items.push((m["from"]["user"]["displayName"].as_str().unwrap_or("someone").to_string(), text, m["id"].as_str().unwrap_or("").to_string())); + } + } + (newest, items) +} + +pub fn send(access: &str, target: &str, thread: &str, text: &str) -> Result<(), String> { + let html = text.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "
"); + graph_post(access, &reply_path(target, thread), &json!({"body": {"contentType": "html", "content": html}})).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sign in with a device code, get an access token, read new chat messages and answer, + /// against a stand-in for Microsoft's servers. + #[test] + fn teams_round_trip() { + use std::io::{Read, Write}; + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = server.local_addr().unwrap().port(); + std::env::set_var("ANYBOT_MS_LOGIN", format!("http://127.0.0.1:{port}/login")); + std::env::set_var("ANYBOT_MS_GRAPH", format!("http://127.0.0.1:{port}/graph")); + let posted = std::sync::Arc::new(Mutex::new(Vec::::new())); + let p2 = posted.clone(); + std::thread::spawn(move || { + let mut polls = 0; + for conn in server.incoming().flatten() { + let mut c = conn; + c.set_read_timeout(Some(Duration::from_secs(3))).ok(); + let mut got = vec![]; + let mut buf = [0u8; 8192]; + loop { + let n = c.read(&mut buf).unwrap_or(0); + got.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&got).to_string(); + if let Some(h) = t.find("\r\n\r\n") { + let len = t[..h].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if got.len() >= h + 4 + len { break; } + } + if n == 0 { break; } + } + let req = String::from_utf8_lossy(&got).to_string(); + let body = if req.contains("/devicecode") { + r#"{"user_code":"ABCD-1234","verification_uri":"https://microsoft.com/devicelogin","device_code":"DEV","interval":1}"#.to_string() + } else if req.contains("device_code") { + polls += 1; + if polls < 2 { r#"{"error":"authorization_pending"}"#.into() } else { r#"{"refresh_token":"R1","access_token":"A0"}"#.into() } + } else if req.contains("grant_type=refresh_token") { + r#"{"access_token":"A1","refresh_token":"R2"}"#.into() + } else if req.starts_with("GET /graph/chats/") { + r#"{"value":[{"id":"m5","createdDateTime":"2026-11-04T02:00:00Z","messageType":"message","from":{"user":{"id":"U9","displayName":"阿德"}},"body":{"content":"

今天會議幾點?

"}}]}"#.into() + } else { + p2.lock().unwrap().push(req.clone()); + "{}".into() + }; + let _ = c.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()); + } + }); + let (code, _url, device, interval) = device_code("CLIENT", "").unwrap(); + assert_eq!(code, "ABCD-1234"); + let refresh = wait_for_login("CLIENT", "", &device, interval, Duration::from_secs(10)).unwrap(); + assert_eq!(refresh, "R1"); + let a = access(77, "CLIENT", "", &refresh).unwrap(); + assert_eq!(a, "A1"); + assert_eq!(rotated().lock().unwrap().get(&77).cloned(), Some("R2".into())); + let list = graph_get(&a, &messages_path("chat:C1")).unwrap(); + let (_, items) = new_messages(&list, "2026-11-04T01:00:00Z", "ME"); + assert_eq!(items[0].1, "今天會議幾點?"); + send(&a, "chat:C1", "", "下午 3 點 <在 101>").unwrap(); + let reqs = posted.lock().unwrap().clone(); + assert!(reqs.iter().any(|r| r.starts_with("POST /graph/chats/C1/messages") && r.contains("Bearer A1") && r.contains("<在 101>")), "{reqs:?}"); + std::env::remove_var("ANYBOT_MS_LOGIN"); + std::env::remove_var("ANYBOT_MS_GRAPH"); + } + + #[test] + fn paths_and_messages() { + assert_eq!(messages_path("chat:19:abc@thread.v2"), "/chats/19:abc@thread.v2/messages?$top=20"); + assert_eq!(reply_path("T1/C1", "m9"), "/teams/T1/channels/C1/messages/m9/replies"); + assert_eq!(reply_path("chat:X", "m9"), "/chats/X/messages"); + let v = json!({"value": [ + {"id": "3", "createdDateTime": "2026-11-04T01:00:03Z", "messageType": "message", "from": {"user": {"id": "ME", "displayName": "我"}}, "body": {"content": "mine"}}, + {"id": "2", "createdDateTime": "2026-11-04T01:00:02Z", "messageType": "message", "from": {"user": {"id": "U2", "displayName": "小美"}}, "body": {"content": "

週報 好了嗎?

"}}, + {"id": "1", "createdDateTime": "2026-11-04T01:00:01Z", "messageType": "message", "from": {"user": {"id": "U3", "displayName": "old"}}, "body": {"content": "old"}} + ]}); + let (cur, items) = new_messages(&v, "2026-11-04T01:00:01Z", "ME"); + assert_eq!(cur, "2026-11-04T01:00:03Z"); + assert_eq!(items, vec![("小美".to_string(), "週報 好了嗎?".to_string(), "2".to_string())]); + assert_eq!(urlencode("a b/c"), "a%20b%2Fc"); + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..7ec60e8 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,76 @@ +// Colours. Light values come from the original's stylesheet +// (index CSS, ":root,.sand-1vbcnu7" block). Dark values come from the renderer's appearance primitives. + +use eframe::egui::Color32; + +#[derive(Clone, Copy)] +pub struct Palette { + pub pane: Color32, + #[allow(dead_code)] + pub chrome: Color32, + pub sidebar: Color32, + pub elevated: Color32, + pub hover: Color32, + pub active: Color32, + pub primary: Color32, + pub text: Color32, + pub text2: Color32, + pub text3: Color32, + pub on_primary: Color32, + pub bubble_user: Color32, + #[allow(dead_code)] + pub bubble_agent: Color32, + pub border: Color32, + pub focus: Color32, + pub accent: Color32, + pub danger: Color32, +} + +const fn hexa(r: u8, g: u8, b: u8, a: u8) -> Color32 { + Color32::from_rgba_unmultiplied_const(r, g, b, a) +} + +pub const LIGHT: Palette = Palette { + pane: hexa(0xff, 0xff, 0xff, 0xff), + chrome: hexa(0xfc, 0xfc, 0xfc, 0xff), + sidebar: hexa(0xf5, 0xf5, 0xf5, 0xff), + elevated: hexa(0xff, 0xff, 0xff, 0xff), + hover: hexa(0x1c, 0x1c, 0x1c, 0x0f), + active: hexa(0x1c, 0x1c, 0x1c, 0x14), + primary: hexa(0x1c, 0x1c, 0x1c, 0xff), + text: hexa(0x1c, 0x1c, 0x1c, 0xff), + text2: hexa(0x1c, 0x1c, 0x1c, 0xad), + text3: hexa(0x1c, 0x1c, 0x1c, 0x8f), + on_primary: hexa(0xff, 0xff, 0xff, 0xff), + bubble_user: hexa(0x1c, 0x1c, 0x1c, 0xff), + bubble_agent: hexa(0x1c, 0x1c, 0x1c, 0x0f), + border: hexa(0x1c, 0x1c, 0x1c, 0x1f), + focus: hexa(0x1e, 0x86, 0xfc, 0xff), + accent: hexa(0x16, 0x80, 0xf3, 0xff), + danger: hexa(0xcd, 0x28, 0x34, 0xff), +}; + +// From the original's renderer (index.eager-common: appearance primitives dark neutral/0..6 and the +// g_ alpha table): pane #1f1f1f, elevated #161616, chrome #121212, sidebar #0e0e0e, ink #f5f5f5, +// primary hover #dbdbdb; translucent tokens are the ink at hover 6%, active 8%, agent bubble 4%, +// user bubble 18%, text 66/54%, border 10%. Accent blue/0 #1884fc, danger red/0 #fc2c3e. +// Measured from the original's dark window: chat rgb 19, sidebar 16, bubbles/cards 31–33. +pub const DARK: Palette = Palette { + pane: hexa(0x13, 0x13, 0x13, 0xff), + chrome: hexa(0x13, 0x13, 0x13, 0xff), + sidebar: hexa(0x10, 0x10, 0x10, 0xff), + elevated: hexa(0x1f, 0x1f, 0x1f, 0xff), + hover: hexa(0xf5, 0xf5, 0xf5, 0x0f), + active: hexa(0xf5, 0xf5, 0xf5, 0x14), + primary: hexa(0xf5, 0xf5, 0xf5, 0xff), + text: hexa(0xf5, 0xf5, 0xf5, 0xff), + text2: hexa(0xf5, 0xf5, 0xf5, 0xa8), + text3: hexa(0xf5, 0xf5, 0xf5, 0x8a), + on_primary: hexa(0x1f, 0x1f, 0x1f, 0xff), + bubble_user: hexa(0xf5, 0xf5, 0xf5, 0x2e), + bubble_agent: hexa(0xf5, 0xf5, 0xf5, 0x0a), + border: hexa(0xf5, 0xf5, 0xf5, 0x1a), + focus: hexa(0x18, 0x84, 0xfc, 0xff), + accent: hexa(0x18, 0x84, 0xfc, 0xff), + danger: hexa(0xfc, 0x2c, 0x3e, 0xff), +}; diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..44d8d16 --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,1236 @@ +// What a bot may do on this computer: list, search, read and edit files, run commands +// (long ones move to the background), and the rules that decide when to ask first. +// +// Limits come from the original's local tool runner (dist/local-exec-daemon/main.cjs): +// search: MAIN_TIMEOUT_MS=25e3, HARD_MAX_OUTPUT_LINES=1e4 (@2983388), client limit Jut=2e3 (@2966784) +// read: at most 8388608 bytes (gut, @2951190) +// shell: on timeout a background-able command keeps running and is handed off (Mye, @3010873/@3016213) +// Permission order follows the neighbouring investigation's design notes (fnd_8): +// deny > ignore > allow > ask, with chained shell commands split into parts first. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +pub const SEARCH_TIMEOUT_MS: u64 = 25_000; +pub const SEARCH_HARD_MAX_LINES: usize = 10_000; +pub const SEARCH_CLIENT_LIMIT_LINES: usize = 2_000; +pub const READ_MAX_BYTES: u64 = 8_388_608; +/// Lines returned by one read_file call when the model gives no limit (estimated). +pub const READ_DEFAULT_LINES: usize = 2_000; +/// Foreground time before a command is moved to the background (estimated; the original's comes per call). +pub const SHELL_DEFAULT_TIMEOUT_MS: u64 = 30_000; +pub const SHELL_MAX_TIMEOUT_MS: u64 = 600_000; +/// Output kept from a command: its start and its end, with the middle left out (sizes estimated; +/// the original reports output_head / output_tail / elided_chars). +pub const OUTPUT_HEAD_CHARS: usize = 6_000; +pub const OUTPUT_TAIL_CHARS: usize = 6_000; + +/// Names the model sees, with JSON-schema parameters. +pub fn specs() -> Vec { + vec![ + json!({"name": "list_dir", "description": "List the files and folders in a folder (relative to the work folder unless absolute).", + "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder to list; empty means the work folder."}}, "required": []}}), + json!({"name": "search", "description": "Find text in files under a folder. Returns matching lines as path:line:text.", + "parameters": {"type": "object", "properties": { + "pattern": {"type": "string", "description": "Text to look for (regular expression when ripgrep is installed)."}, + "path": {"type": "string", "description": "Folder or file to search; empty means the work folder."}, + "files_only": {"type": "boolean", "description": "Only list the files that match."}}, "required": ["pattern"]}}), + json!({"name": "read_file", "description": "Read a text file. Lines come back numbered.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string"}, + "offset": {"type": "integer", "description": "First line to read (1-based)."}, + "limit": {"type": "integer", "description": "How many lines to read."}}, "required": ["path"]}}), + json!({"name": "edit_file", "description": "Change a file. Replaces old_text (which must appear exactly once) with new_text. With empty old_text the file is created or overwritten with new_text.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "new_text"]}}), + json!({"name": "shell", "description": "Run a shell command in the work folder. If it runs longer than timeout_ms it keeps running in the background and you get a terminal id to check with read_terminal.", + "parameters": {"type": "object", "properties": { + "command": {"type": "string"}, + "timeout_ms": {"type": "integer", "description": "How long to wait before moving it to the background (default 30000)."}}, "required": ["command"]}}), + json!({"name": "read_terminal", "description": "Read the latest output of a command that was moved to the background.", + "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}), + json!({"name": "write_terminal", "description": "Send input to a command running in the background, as if typed into its terminal (e.g. answer a prompt: \"y\\n\"). close=true ends its input (like Ctrl-D).", + "parameters": {"type": "object", "properties": {"id": {"type": "integer"}, "input": {"type": "string", "description": "Text to send; include \\n to press return."}, "close": {"type": "boolean"}}, "required": ["id"]}}), + ] +} + +pub fn label(name: &str) -> &'static str { + match name { + "list_dir" => "查看資料夾", + "search" => "搜尋檔案", + "read_file" => "讀取檔案", + "edit_file" => "修改檔案", + "shell" => "執行指令", + "read_terminal" => "查看背景指令", + "write_terminal" => "輸入到背景指令", + "open_for_person" => "打開給你看", + "publish_site" => "發布網站", + "place_call" => "打電話", + "send_sms" => "傳簡訊", + "post_to_x" => "在 X 發文", + "show_card" => "顯示卡片", + "vault_list" => "查看保險庫", + "browser_fill_login" => "用保險庫登入", + "browser_fill_card" => "填入卡片", + "browser_fill_contact" => "填入聯絡資訊", + _ => "工具", + } +} + +pub fn args_of(args: &str) -> Value { + serde_json::from_str(args).unwrap_or(Value::Null) +} + +fn arg_str(v: &Value, k: &str) -> String { + v.get(k).and_then(Value::as_str).unwrap_or("").to_string() +} + +/// One short line describing a call, for the chat and the approval card. +pub fn describe(name: &str, args: &str) -> String { + let v = args_of(args); + match name { + "shell" => arg_str(&v, "command"), + "search" => format!("「{}」 {}", arg_str(&v, "pattern"), arg_str(&v, "path")).trim().to_string(), + "read_terminal" => format!("背景指令 #{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), + "write_terminal" => { + let input: String = arg_str(&v, "input").chars().take(40).collect(); + format!("背景指令 #{}:{}{}", v.get("id").and_then(Value::as_u64).unwrap_or(0), input.escape_debug(), if v.get("close").and_then(Value::as_bool).unwrap_or(false) { "(結束輸入)" } else { "" }) + } + "vault_list" => match arg_str(&v, "kind").as_str() { + "login" => "登入資訊".into(), + "card" => "卡片".into(), + "contact" => "聯絡資訊".into(), + _ => "全部項目".into(), + }, + "browser_fill_login" => { + let site = arg_str(&v, "site"); + if site.is_empty() { "目前的網站".into() } else { site } + } + "browser_fill_card" | "browser_fill_contact" => "目前的網頁".into(), + _ => { + let p = arg_str(&v, "path"); + if p.is_empty() { "工作資料夾".into() } else { p } + } + } +} + +// ---------- paths ---------- + +pub fn expand_home(p: &str) -> PathBuf { + if let Some(rest) = p.strip_prefix("~/") { + if let Some(h) = dirs::home_dir() { + return h.join(rest); + } + } else if p == "~" { + if let Some(h) = dirs::home_dir() { + return h; + } + } + PathBuf::from(p) +} + +/// Make a path absolute and tidy (no `.` or `..`) without touching the disk. +pub fn resolve(workdir: &Path, p: &str) -> PathBuf { + let raw = if p.trim().is_empty() { workdir.to_path_buf() } else { + let e = expand_home(p.trim()); + if e.is_absolute() { e } else { workdir.join(e) } + }; + let mut out = PathBuf::new(); + for c in raw.components() { + match c { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other), + } + } + out +} + +fn inside(workdir: &Path, p: &Path) -> bool { + p.starts_with(workdir) +} + +/// Files a bot never reads or writes: secrets and version-control internals +/// (the original blocks reads/writes by ignore files and repo-block globs), +/// plus this app's own data folder, where API keys are saved. +pub fn is_ignored(p: &Path) -> bool { + if let Some(d) = crate::model::State::path().parent() { + if p.starts_with(d) { + return true; + } + } + let s = p.to_string_lossy().replace('\\', "/"); + let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + s.contains("/.git/") || s.ends_with("/.git") || s.contains("/.ssh/") || s.contains("/.gnupg/") || s.contains("/Library/Keychains") + || name == ".env" || name.starts_with(".env.") || name.ends_with(".pem") || name.ends_with(".key") || name.starts_with("id_rsa") || name.starts_with("id_ed25519") + || name == ".netrc" || name == "credentials" +} + +// ---------- permissions ---------- + +#[derive(Debug, PartialEq, Clone)] +pub enum Decision { + Allow, + Ask { reason: String, grant: String }, + Deny(String), +} + +/// Commands that only look at things; chains made only of these run without asking. +const READ_ONLY_COMMANDS: [&str; 24] = [ + "ls", "pwd", "cat", "head", "tail", "wc", "echo", "rg", "grep", "find", "file", "stat", "du", "df", "date", "which", "tree", "sort", "uniq", "diff", + "git status", "git diff", "git log", "git show", +]; + +/// Always refused. +const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"]; + +/// Split `a && b | c; d` into its commands, keeping quoted text together. +/// Returns None when the command hides other commands ($(), backticks, eval). +pub fn split_commands(cmd: &str) -> Option> { + let mut parts = vec![]; + let mut cur = String::new(); + let (mut sq, mut dq) = (false, false); + let chars: Vec = cmd.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let next = chars.get(i + 1).copied(); + if c == '\'' && !dq { + sq = !sq; + } else if c == '"' && !sq { + dq = !dq; + } else if !sq && (c == '`' || (c == '$' && next == Some('('))) { + return None; + } else if !sq && !dq && (c == ';' || c == '\n' || c == '|' || c == '&') { + if !cur.trim().is_empty() { + parts.push(cur.trim().to_string()); + } + cur.clear(); + if (c == '|' || c == '&') && next == Some(c) { + i += 1; + } + i += 1; + continue; + } + cur.push(c); + i += 1; + } + if sq || dq { + return None; + } + if !cur.trim().is_empty() { + parts.push(cur.trim().to_string()); + } + if parts.iter().any(|p| { let w = p.split_whitespace().next().unwrap_or(""); w == "eval" || w == "exec" || w == "source" || w == "." }) { + return None; + } + Some(parts) +} + +fn words(cmd: &str) -> Vec<&str> { + cmd.split_whitespace().collect() +} + +fn starts_with_words(cmd: &str, prefix: &str) -> bool { + let (c, p) = (words(cmd), words(prefix)); + !p.is_empty() && c.len() >= p.len() && c[..p.len()] == p[..] +} + +/// The "always allow" grant for a command: its first word, or first two for git/npm/cargo-style tools. +pub fn shell_grant(part: &str) -> String { + let w = words(part); + let two = ["git", "npm", "pnpm", "yarn", "cargo", "brew", "pip", "pip3", "python3", "docker", "go"]; + let key = if w.len() >= 2 && two.contains(&w[0]) { format!("{} {}", w[0], w[1]) } else { w.first().map(|s| s.to_string()).unwrap_or_default() }; + format!("shell:{key}") +} + +fn writes_files(part: &str) -> bool { + part.contains('>') +} + +pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Decision { + let v = args_of(args); + let granted = |g: &str| grants.iter().any(|x| x == g); + match name { + "list_dir" | "search" | "read_file" => { + let p = resolve(workdir, &arg_str(&v, "path")); + if is_ignored(&p) { + return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display())); + } + if inside(workdir, &p) || granted("read:anywhere") { + Decision::Allow + } else { + Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() } + } + } + "read_terminal" | "write_terminal" => Decision::Allow, + "edit_file" => { + let p = resolve(workdir, &arg_str(&v, "path")); + if is_ignored(&p) { + return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能修改。", p.display())); + } + if !inside(workdir, &p) { + return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() }; + } + if granted("edit_file") { + Decision::Allow + } else { + Decision::Ask { reason: format!("修改檔案:{}", p.display()), grant: "edit_file".into() } + } + } + "shell" => { + let cmd = arg_str(&v, "command"); + if cmd.trim().is_empty() { + return Decision::Deny("沒有指令".into()); + } + let Some(parts) = split_commands(&cmd) else { + return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant: String::new() }; + }; + // deny + for part in &parts { + if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(part, d)) { + return Decision::Deny(format!("「{d}」這類指令一律不允許。")); + } + } + // ignore: commands that point at secret files + for part in &parts { + for w in words(part).iter().skip(1) { + if is_ignored(&resolve(workdir, w.trim_matches(['"', '\'']))) { + return Decision::Deny(format!("指令碰到私密或系統檔案:{w}")); + } + } + } + // allow: every part is read-only or already granted + let ok = |part: &String| !writes_files(part) && (READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) || granted(&shell_grant(part))); + let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect(); + if unapproved.is_empty() { + return Decision::Allow; + } + let grant = if unapproved.len() == 1 && !writes_files(unapproved[0]) { shell_grant(unapproved[0]) } else { String::new() }; + Decision::Ask { reason: format!("執行指令:{cmd}"), grant } + } + other => Decision::Deny(format!("沒有「{other}」這個工具")), + } +} + +// ---------- process trees ---------- +// From cursor_proclist.node (the original's native process scanner): +// getDescendantPids @0x6748: breadth-first over children by parent pid (proc_listpids PROC_PPID_ONLY), +// de-duplicated, stops once 0xfff (4095) pids are collected → at most 4096 processes. +// ProclistScanWorker::Execute @0x3d00: scans the roots plus all descendants. +// getProcessInformation @0x5234: memoryMB = resident bytes >> 20 (lsr #0x14); +// cpuTimeMs = (user + system time, in Mach ticks × timebase) / 1 000 000; +// each process is tied to its agent by CONVERSATION_ID= / REQUEST_ID= in its environment. + +pub const MAX_TRACKED_PROCESSES: usize = 4096; +/// Our own names for the tags the original reads (it uses its vendor prefix). +pub const ENV_CONVERSATION_ID: &str = "ANYBOT_CONVERSATION_ID"; +pub const ENV_REQUEST_ID: &str = "ANYBOT_REQUEST_ID"; + +#[derive(Clone, Copy, Debug)] +pub struct ProcRow { + pub pid: u32, + pub ppid: u32, + pub rss_kb: u64, + pub cpu_ms: u64, +} + +/// `[[dd-]hh:]mm:ss[.cc]` as printed by ps, in milliseconds. +pub fn parse_cpu_time(s: &str) -> u64 { + let (days, rest) = match s.split_once('-') { + Some((d, r)) => (d.parse::().unwrap_or(0), r), + None => (0, s), + }; + let mut secs = 0f64; + for part in rest.split(':') { + secs = secs * 60.0 + part.parse::().unwrap_or(0.0); + } + days * 86_400_000 + (secs * 1000.0).round() as u64 +} + +pub fn proc_table() -> Vec { + let Ok(out) = Command::new("ps").args(["-A", "-o", "pid=,ppid=,rss=,time="]).output() else { return vec![] }; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| { + let f: Vec<&str> = l.split_whitespace().collect(); + (f.len() >= 4).then(|| ProcRow { pid: f[0].parse().unwrap_or(0), ppid: f[1].parse().unwrap_or(0), rss_kb: f[2].parse().unwrap_or(0), cpu_ms: parse_cpu_time(f[3]) }) + }) + .filter(|r| r.pid > 0) + .collect() +} + +/// The roots and everything they started, breadth-first, without repeats, at most 4096. +pub fn descendants(table: &[ProcRow], roots: &[u32]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = vec![]; + let mut queue: std::collections::VecDeque = roots.iter().copied().filter(|p| *p > 0).collect(); + while let Some(p) = queue.pop_front() { + if !seen.insert(p) { + continue; + } + out.push(p); + if out.len() >= MAX_TRACKED_PROCESSES { + break; + } + queue.extend(table.iter().filter(|r| r.ppid == p).map(|r| r.pid)); + } + out +} + +/// (processes, cpu ms, memory MB) for a command and everything it started. +pub fn tree_stats(root: u32) -> (usize, u64, u64) { + let table = proc_table(); + let pids = descendants(&table, &[root]); + let rows: Vec<&ProcRow> = table.iter().filter(|r| pids.contains(&r.pid)).collect(); + (rows.len(), rows.iter().map(|r| r.cpu_ms).sum(), rows.iter().map(|r| (r.rss_kb * 1024) >> 20).sum()) +} + +/// Stop a command and everything it started (newest first), not just the shell. +pub fn kill_tree(root: u32) { + let table = proc_table(); + let mut pids = descendants(&table, &[root]); + pids.reverse(); + if pids.is_empty() { + return; + } + let _ = Command::new("kill").arg("-TERM").args(pids.iter().map(|p| p.to_string())).stderr(Stdio::null()).status(); +} + +// ---------- sandbox ---------- +// From sand-op-launcher `_main`: before exec it applies a sandbox profile +// "(version 1)(allow default)(deny file-read* (with no-report) (subpath (param …))))" that hides +// one private folder, builds a fresh environment, and refuses invocations outside an exact list. +// Here: commands can't read or write the app's own data (where API keys are saved) or the +// usual secret folders, and API keys are removed from their environment. + +const KEY_ENV_VARS: [&str; 6] = ["XAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; + +pub fn protected_dirs() -> Vec { + let mut v = vec![]; + if let Some(d) = crate::model::State::path().parent() { + v.push(d.to_path_buf()); + } + if let Some(h) = dirs::home_dir() { + for s in [".ssh", ".gnupg", "Library/Keychains", ".aws", ".config/gcloud"] { + v.push(h.join(s)); + } + } + v +} + +fn sandbox_exec() -> Option<&'static str> { + let p = "/usr/bin/sandbox-exec"; + (cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p) +} + +fn real(p: &Path) -> String { + // The sandbox matches real paths, so resolve links like /var → /private/var. + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display().to_string() +} + +/// The shell command, wrapped in the sandbox when the system has one. +#[cfg(test)] +pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command { + build_command_sealed(cmd, workdir, protected, None, conversation, request) +} + +/// `seal = Some(home)`: the bot's own computer. Nothing under the person's home folder is +/// visible or writable except the bot's folder (`workdir`), like a separate machine. +pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command { + let mut c = match sandbox_exec() { + Some(se) if !protected.is_empty() || seal.is_some() => { + let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect(); + let mut profile = format!("(version 1)(allow default)(deny file-read* file-write* (with no-report){rules})"); + if seal.is_some() { + profile.push_str("(deny file-read* file-write* (with no-report) (require-all (subpath (param \"HOME\")) (require-not (subpath (param \"ROOT\")))))"); + } + let mut c = Command::new(se); + for (i, p) in protected.iter().enumerate() { + c.arg("-D").arg(format!("P{i}={}", real(p))); + } + if let Some(home) = seal { + c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir))); + } + c.arg("-p").arg(profile).arg("/bin/sh").arg("-c").arg(cmd); + c + } + _ => { + let mut c = Command::new("/bin/sh"); + c.arg("-c").arg(cmd); + c + } + }; + for k in KEY_ENV_VARS { + c.env_remove(k); + } + c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + c.current_dir(workdir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + c +} + +/// A command AnyBot itself wraps around the bot's (ssh / docker exec): no sandbox, because the +/// wrapper needs the person's SSH keys and Docker settings; the bot's part runs on the other machine. +pub fn build_wrapper_command(cmd: &str, conversation: &str, request: &str) -> Command { + let mut c = Command::new("/bin/sh"); + c.arg("-c").arg(cmd); + for k in KEY_ENV_VARS { + c.env_remove(k); + } + c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request).env("PATH", crate::cloudbox::tool_path()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + c.current_dir(std::env::temp_dir()).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + c +} + +// ---------- running tools ---------- + +/// Which chat and which tool call a command belongs to. +#[derive(Clone, Default)] +pub struct RunIds { + pub conversation: String, + pub request: String, +} + +struct Terminal { + command: String, + stdin: Arc>>, + output: Arc>, + child: Arc>, + exit: Arc>>, + root: u32, + /// The bot it belongs to (the ANYBOT_CONVERSATION_ID tag). + conversation: String, +} + +fn terminals() -> &'static Mutex> { + static T: OnceLock>> = OnceLock::new(); + T.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn next_terminal_id() -> u64 { + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + N.fetch_add(1, Ordering::Relaxed) +} + +/// Keep the start and the end of long output (the original's output_head / output_tail). +pub fn head_tail(s: &str) -> String { + let n = s.chars().count(); + if n <= OUTPUT_HEAD_CHARS + OUTPUT_TAIL_CHARS { + return s.to_string(); + } + let head: String = s.chars().take(OUTPUT_HEAD_CHARS).collect(); + let tail: String = s.chars().skip(n - OUTPUT_TAIL_CHARS).collect(); + format!("{head}\n…(中間省略 {} 個字)…\n{tail}", n - OUTPUT_HEAD_CHARS - OUTPUT_TAIL_CHARS) +} + +fn timeout_of(v: &Value) -> u64 { + v.get("timeout_ms").and_then(Value::as_u64).unwrap_or(SHELL_DEFAULT_TIMEOUT_MS).min(SHELL_MAX_TIMEOUT_MS) +} + +// ---------- computers ---------- +// The original runs a bot's work on "Bot 的電腦" (its own cloud box) and only runs on this Mac when +// the person turns on 在這台電腦上執行 (setting `local-execution`, ids fuUvu2 / Hd1KB+). Permission +// questions name the machine ("允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?", eQViQE). +// Here the bot's own computer is a sealed folder, this Mac is the person's computer, and other +// computers are reached over SSH. + +#[derive(Clone, Debug)] +pub enum Place { + /// The bot's own computer when Docker isn't there: everything outside `root` in the home + /// folder is invisible. + Sealed { root: PathBuf }, + /// The bot's own computer: AnyBot's Linux box (Docker), starting in `dir` inside it. + Box { dir: String }, + /// The person's computer, starting in `workdir`. + Local { workdir: PathBuf }, + /// Another computer: `ssh target`, starting in `dir` (empty = its home). + Ssh { target: String, dir: String }, + /// 裝置連結: another AnyBot of the same account (through the relay), by its host id. + Device { host: String }, +} + +#[derive(Clone, Debug)] +pub struct Target { + pub name: String, + pub place: Place, +} + +/// The tools with a `computer` choice added to each, listing the computers this bot can use. +pub fn specs_for(computers: &[String], default: &str) -> Vec { + let mut v = specs(); + for t in v.iter_mut() { + if t["name"] == "read_terminal" || t["name"] == "write_terminal" { + continue; + } + t["parameters"]["properties"]["computer"] = json!({ + "type": "string", + "enum": computers, + "description": format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME) + }); + } + v +} + +pub fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +fn ssh_command(target: &str, dir: &str, cmd: &str) -> String { + let inner = if dir.trim().is_empty() { cmd.to_string() } else { format!("cd {} && {}", sh_quote(dir), cmd) }; + format!("ssh -o BatchMode=yes -o ConnectTimeout=15 {} {}", sh_quote(target), sh_quote(&inner)) +} + +/// The local wrapper that runs `cmd` on a remote computer (an SSH host or the bot's box). +fn wrapper_for(place: &Place, cmd: &str, interactive: bool) -> Option { + match place { + Place::Ssh { target, dir } => Some(ssh_command(target, dir, cmd)), + Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive)), + _ => None, + } +} + +pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> Decision { + let v = args_of(args); + let denied = |cmd: &str| -> Option { + for part in split_commands(cmd)? { + if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(&part, d)) { + return Some(Decision::Deny(format!("「{d}」這類指令一律不允許。"))); + } + } + None + }; + match &target.place { + Place::Local { workdir } => decide(name, args, workdir, grants), + Place::Sealed { root } => { + if name == "shell" { + // Its own computer: the sandbox keeps it inside its folder. + return denied(&arg_str(&v, "command")).unwrap_or(Decision::Allow); + } + if name == "read_terminal" || name == "write_terminal" { + return Decision::Allow; + } + let p = resolve(root, &arg_str(&v, "path")); + if !inside(root, &p) { + return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理你的檔案,請用「{}」。", target.name, p.display(), crate::model::THIS_COMPUTER_NAME)); + } + Decision::Allow + } + // Its own computer: everything is allowed (it can be rebuilt), like the original's box. + Place::Box { .. } => { + if name == "shell" { + if let Some(d) = denied(&arg_str(&v, "command")) { + return d; + } + } + Decision::Allow + } + Place::Ssh { .. } | Place::Device { .. } => match name { + "list_dir" | "read_file" | "search" | "read_terminal" | "write_terminal" => Decision::Allow, + "edit_file" => { + if grants.iter().any(|g| g == "edit_file") { + Decision::Allow + } else { + Decision::Ask { reason: format!("修改 {} 上的檔案:{}", target.name, arg_str(&v, "path")), grant: "edit_file".into() } + } + } + _ => { + let cmd = arg_str(&v, "command"); + match decide("shell", args, Path::new("/nonexistent-remote"), grants) { + Decision::Ask { grant, .. } => Decision::Ask { reason: format!("在 {} 上執行指令:{cmd}", target.name), grant }, + other => other, + } + } + }, + } +} + +/// Run a remote command and give back its output (no background hand-off). +fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result { + use std::io::Write; + let w = wrapper_for(place, cmd, stdin.is_some()).ok_or("不是遠端電腦")?; + let mut c = build_wrapper_command(&w, "", ""); + let mut child = c.spawn().map_err(|e| format!("無法執行:{e}"))?; + if let Some(data) = stdin { + if let Some(mut si) = child.stdin.take() { + si.write_all(data).map_err(|e| format!("寫入失敗:{e}"))?; + } + } else { + drop(child.stdin.take()); + } + let out = child.wait_with_output().map_err(|e| e.to_string())?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + if out.status.success() { + Ok(text) + } else { + Err(format!("{}{}", String::from_utf8_lossy(&out.stderr).trim(), if text.trim().is_empty() { String::new() } else { format!("\n{}", text.trim()) })) + } +} + +/// Run an allowed tool on a computer. +pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) { + let v = args_of(args); + let done = |r: Result| match r { + Ok(s) => (s, true), + Err(e) => (e, false), + }; + match &target.place { + Place::Local { workdir } => run(name, args, workdir, cancel, ids), + Place::Device { host } => match crate::remote::run_on_device(host, name, args, "") { + Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false)), + Err(e) => (e, false), + }, + Place::Sealed { root } => { + let _ = std::fs::create_dir_all(root); + if name == "shell" { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/Users")); + return done(shell(&arg_str(&v, "command"), root, timeout_of(&v), cancel, ids, Some(&home))); + } + run(name, args, root, cancel, ids) + } + place @ (Place::Ssh { .. } | Place::Box { .. }) => { + if let Place::Box { .. } = place { + if let Err(e) = crate::cloudbox::ensure_running() { + return (e, false); + } + } + let _busy = crate::cloudbox::Busy::new(matches!(place, Place::Box { .. })); + let path = arg_str(&v, "path"); + let p = if path.is_empty() { ".".to_string() } else { path.clone() }; + if v.is_null() && !args.trim().is_empty() { + return ("參數不是有效的 JSON".into(), false); + } + let remote = match name { + "shell" if arg_str(&v, "command").trim().is_empty() => return ("請給要執行的指令".into(), false), + "shell" => arg_str(&v, "command"), + "list_dir" => format!("ls -la {}", sh_quote(&p)), + "read_file" => { + let from = v.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1); + let n = v.get("limit").and_then(Value::as_u64).unwrap_or(READ_DEFAULT_LINES as u64); + format!("test -f {q} || {{ echo '找不到檔案:'{q} >&2; exit 1; }}; sed -n '{from},{}p' {q} | cat -n", from + n - 1, q = sh_quote(&p)) + } + "search" => { + let flag = if v.get("files_only").and_then(Value::as_bool).unwrap_or(false) { "l" } else { "n" }; + format!("(command -v rg >/dev/null && rg -{flag} --no-heading -- {pat} {p} || grep -rI{flag} -- {pat} {p}) | head -n {SEARCH_CLIENT_LIMIT_LINES}", pat = sh_quote(&arg_str(&v, "pattern")), p = sh_quote(&p)) + } + "edit_file" => { + if path.is_empty() { + return ("請給檔案路徑".into(), false); + } + let old = arg_str(&v, "old_text"); + let new = arg_str(&v, "new_text"); + let q = sh_quote(&path); + let content = if old.is_empty() { + new + } else { + let cur = match remote_capture(place, &format!("cat {q}"), None) { + Ok(c) => c, + Err(e) => return (format!("讀不到 {path}:{e}"), false), + }; + match cur.matches(old.as_str()).count() { + 0 => return ("找不到 old_text,檔案沒有改。請先讀檔確認原文。".into(), false), + 1 => cur.replacen(old.as_str(), &new, 1), + n => return (format!("old_text 出現了 {n} 次,請多給一點前後文讓它只出現一次。"), false), + } + }; + let write = format!("mkdir -p \"$(dirname {q})\" && cat > {q}"); + return match remote_capture(place, &write, Some(content.as_bytes())) { + Ok(_) => (format!("已寫入 {path}({} 行)。", content.lines().count()), true), + Err(e) => (format!("寫不進 {path}:{e}"), false), + }; + } + "read_terminal" | "write_terminal" => return run(name, args, Path::new("/"), cancel, ids), + other => return (format!("沒有「{other}」這個工具"), false), + }; + let wrapper = wrapper_for(place, &remote, name == "shell").unwrap_or_default(); + done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids)) + } + } +} + +/// Run a tool that the rules already allowed. Returns (output, ok). +pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) { + let v = args_of(args); + if v.is_null() && !args.trim().is_empty() { + return ("參數不是有效的 JSON".into(), false); + } + let _ = std::fs::create_dir_all(workdir); + let r = match name { + "list_dir" => list_dir(&resolve(workdir, &arg_str(&v, "path"))), + "search" => search(&arg_str(&v, "pattern"), &resolve(workdir, &arg_str(&v, "path")), v.get("files_only").and_then(Value::as_bool).unwrap_or(false)), + "read_file" => read_file( + &resolve(workdir, &arg_str(&v, "path")), + v.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1) as usize, + v.get("limit").and_then(Value::as_u64).map(|l| l as usize).unwrap_or(READ_DEFAULT_LINES), + ), + "edit_file" => edit_file(&resolve(workdir, &arg_str(&v, "path")), &arg_str(&v, "old_text"), &arg_str(&v, "new_text")), + "shell" => shell(&arg_str(&v, "command"), workdir, timeout_of(&v), cancel, ids, None), + "read_terminal" => read_terminal(v.get("id").and_then(Value::as_u64).unwrap_or(0)), + "write_terminal" => write_terminal(v.get("id").and_then(Value::as_u64).unwrap_or(0), &arg_str(&v, "input"), v.get("close").and_then(Value::as_bool).unwrap_or(false)), + other => Err(format!("沒有「{other}」這個工具")), + }; + match r { + Ok(s) => (s, true), + Err(e) => (e, false), + } +} + +fn list_dir(p: &Path) -> Result { + let rd = std::fs::read_dir(p).map_err(|e| format!("無法開啟資料夾 {}:{e}", p.display()))?; + let mut rows: Vec<(bool, String, u64)> = rd + .filter_map(|e| e.ok()) + .filter(|e| !is_ignored(&e.path())) + .map(|e| { + let md = e.metadata().ok(); + (md.as_ref().map(|m| m.is_dir()).unwrap_or(false), e.file_name().to_string_lossy().to_string(), md.map(|m| m.len()).unwrap_or(0)) + }) + .collect(); + rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase()))); + let total = rows.len(); + let mut out: Vec = rows.into_iter().take(SEARCH_CLIENT_LIMIT_LINES).map(|(d, n, s)| if d { format!("{n}/") } else { format!("{n} {}", human_size(s)) }).collect(); + if total > SEARCH_CLIENT_LIMIT_LINES { + out.push(format!("…還有 {} 項", total - SEARCH_CLIENT_LIMIT_LINES)); + } + if out.is_empty() { + out.push("(空資料夾)".into()); + } + Ok(format!("{}\n{}", p.display(), out.join("\n"))) +} + +fn human_size(b: u64) -> String { + match b { + 0..=1023 => format!("{b} B"), + 1024..=1_048_575 => format!("{:.1} KB", b as f64 / 1024.0), + _ => format!("{:.1} MB", b as f64 / 1_048_576.0), + } +} + +fn search(pattern: &str, root: &Path, files_only: bool) -> Result { + if pattern.is_empty() { + return Err("請給要搜尋的文字".into()); + } + let deadline = Instant::now() + Duration::from_millis(SEARCH_TIMEOUT_MS); + let mut lines: Vec = vec![]; + let mut timed_out = false; + // Prefer ripgrep when it's installed; fall back to a plain walk with substring matching. + let rg = Command::new("rg") + .args(["--no-heading", "--line-number", "--color", "never", "--max-columns", "300", "-g", "!.git", "-g", "!.env*", "-g", "!*.pem", "-g", "!*.key"]) + .args(if files_only { vec!["-l"] } else { vec![] }) + .arg("--") + .arg(pattern) + .arg(root) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut child) = rg { + let mut out = child.stdout.take().unwrap(); + let buf = Arc::new(Mutex::new(Vec::new())); + let b2 = buf.clone(); + let reader = std::thread::spawn(move || { + let mut chunk = [0u8; 8192]; + while let Ok(n) = out.read(&mut chunk) { + if n == 0 { + break; + } + let mut b = b2.lock().unwrap(); + b.extend_from_slice(&chunk[..n]); + if b.iter().filter(|c| **c == b'\n').count() > SEARCH_HARD_MAX_LINES { + break; + } + } + }); + loop { + if child.try_wait().ok().flatten().is_some() || reader.is_finished() { + break; + } + if Instant::now() > deadline { + timed_out = true; + let _ = child.kill(); + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = child.kill(); + let _ = reader.join(); + let text = String::from_utf8_lossy(&buf.lock().unwrap()).to_string(); + lines = text.lines().map(|l| l.to_string()).collect(); + } else { + let needle = pattern.to_lowercase(); + let mut stack = vec![root.to_path_buf()]; + 'walk: while let Some(p) = stack.pop() { + if Instant::now() > deadline { + timed_out = true; + break; + } + if is_ignored(&p) { + continue; + } + if p.is_dir() { + if let Ok(rd) = std::fs::read_dir(&p) { + stack.extend(rd.filter_map(|e| e.ok()).map(|e| e.path()).filter(|q| q.file_name().map(|n| n != "node_modules" && n != "target").unwrap_or(true))); + } + continue; + } + let Ok(text) = std::fs::read_to_string(&p) else { continue }; + for (i, l) in text.lines().enumerate() { + if l.to_lowercase().contains(&needle) { + lines.push(if files_only { p.display().to_string() } else { format!("{}:{}:{}", p.display(), i + 1, l.chars().take(300).collect::()) }); + if files_only { + break; + } + if lines.len() >= SEARCH_HARD_MAX_LINES { + break 'walk; + } + } + } + } + } + let total = lines.len(); + let mut out: Vec = lines.into_iter().take(SEARCH_CLIENT_LIMIT_LINES).collect(); + if total > SEARCH_CLIENT_LIMIT_LINES { + out.push(format!("…結果太多,只列出前 {SEARCH_CLIENT_LIMIT_LINES} 行(共 {total} 行以上)")); + } + if timed_out { + out.push(format!("…搜尋超過 {} 秒,結果可能不完整", SEARCH_TIMEOUT_MS / 1000)); + } + if out.is_empty() { + out.push("沒有找到".into()); + } + Ok(out.join("\n")) +} + +fn read_file(p: &Path, offset: usize, limit: usize) -> Result { + let md = std::fs::metadata(p).map_err(|e| format!("找不到 {}:{e}", p.display()))?; + if md.is_dir() { + return Err(format!("{} 是資料夾,請用 list_dir", p.display())); + } + if md.len() > READ_MAX_BYTES { + return Err(format!("{} 太大了({}),最多讀 8 MB", p.display(), human_size(md.len()))); + } + let bytes = std::fs::read(p).map_err(|e| format!("無法讀取 {}:{e}", p.display()))?; + if bytes.iter().take(8000).any(|b| *b == 0) { + return Err(format!("{} 不是文字檔({})", p.display(), human_size(md.len()))); + } + let text = String::from_utf8_lossy(&bytes); + let all: Vec<&str> = text.lines().collect(); + let end = (offset - 1 + limit).min(all.len()); + let mut out: Vec = all.iter().enumerate().skip(offset - 1).take(end.saturating_sub(offset - 1)).map(|(i, l)| format!("{:>5} {l}", i + 1)).collect(); + if end < all.len() { + out.push(format!("…還有 {} 行(用 offset={} 繼續讀)", all.len() - end, end + 1)); + } + if out.is_empty() { + out.push("(空檔案)".into()); + } + Ok(out.join("\n")) +} + +fn edit_file(p: &Path, old: &str, new: &str) -> Result { + if old.is_empty() { + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("無法建立資料夾:{e}"))?; + } + let existed = p.exists(); + std::fs::write(p, new).map_err(|e| format!("無法寫入 {}:{e}", p.display()))?; + return Ok(format!("{} {}({} 行)", if existed { "已覆寫" } else { "已建立" }, p.display(), new.lines().count())); + } + let text = std::fs::read_to_string(p).map_err(|e| format!("無法讀取 {}:{e}", p.display()))?; + match text.matches(old).count() { + 0 => Err(format!("在 {} 裡找不到要替換的文字", p.display())), + 1 => { + std::fs::write(p, text.replacen(old, new, 1)).map_err(|e| format!("無法寫入 {}:{e}", p.display()))?; + Ok(format!("已修改 {}", p.display())) + } + n => Err(format!("要替換的文字在 {} 出現了 {n} 次,請多給一些前後文讓它只出現一次", p.display())), + } +} + +fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result { + run_command(build_command_sealed(cmd, workdir, &protected_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids) +} + +/// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output. +pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result { + run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids) +} + +fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result { + let mut child = command.spawn().map_err(|e| format!("無法執行:{e}"))?; + let stdin = Arc::new(Mutex::new(child.stdin.take())); + let root = child.id(); + let output = Arc::new(Mutex::new(String::new())); + for stream in [child.stdout.take().map(|s| Box::new(s) as Box), child.stderr.take().map(|s| Box::new(s) as Box)].into_iter().flatten() { + let out = output.clone(); + std::thread::spawn(move || { + let mut stream = stream; + let mut chunk = [0u8; 4096]; + while let Ok(n) = stream.read(&mut chunk) { + if n == 0 { + break; + } + out.lock().unwrap().push_str(&String::from_utf8_lossy(&chunk[..n])); + } + }); + } + let start = Instant::now(); + loop { + if let Ok(Some(status)) = child.try_wait() { + std::thread::sleep(Duration::from_millis(30)); + let out = head_tail(&output.lock().unwrap()); + let code = status.code().unwrap_or(-1); + return if code == 0 { Ok(if out.trim().is_empty() { "(完成,沒有輸出)".into() } else { out }) } else { Err(format!("結束代碼 {code}\n{out}")) }; + } + if cancel.load(Ordering::Relaxed) { + kill_tree(root); + let _ = child.kill(); + return Err("已停止(連同它啟動的程式)".into()); + } + if start.elapsed() >= Duration::from_millis(timeout_ms) { + // Hand off to the background instead of killing it. + let id = next_terminal_id(); + let child = Arc::new(Mutex::new(child)); + let exit = Arc::new(Mutex::new(None)); + let (c2, e2) = (child.clone(), exit.clone()); + std::thread::spawn(move || loop { + if let Ok(Some(st)) = c2.lock().unwrap().try_wait() { + *e2.lock().unwrap() = Some(st.code().unwrap_or(-1)); + break; + } + std::thread::sleep(Duration::from_millis(200)); + }); + let so_far = head_tail(&output.lock().unwrap()); + terminals().lock().unwrap().insert(id, Terminal { command: cmd.to_string(), stdin, output, child, exit, root, conversation: ids.conversation.clone() }); + return Ok(format!("指令跑了超過 {} 秒,已移到背景繼續執行(背景指令 #{id})。用 read_terminal 查看進度。\n目前的輸出:\n{so_far}", timeout_ms / 1000)); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn read_terminal(id: u64) -> Result { + let t = terminals().lock().unwrap(); + let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; + let out = head_tail(&term.output.lock().unwrap()); + let state = match *term.exit.lock().unwrap() { + Some(c) => format!("已結束(代碼 {c})"), + None => { + let (n, cpu, mem) = tree_stats(term.root); + format!("還在執行:{n} 個程式,CPU 時間 {:.1} 秒,記憶體 {mem} MB", cpu as f64 / 1000.0) + } + }; + Ok(format!("背景指令 #{id}:{}\n狀態:{state}\n{out}", term.command)) +} + +/// Send input to a background command (like typing into its terminal). `close` ends its input. +fn write_terminal(id: u64, input: &str, close: bool) -> Result { + use std::io::Write; + let t = terminals().lock().unwrap(); + let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; + if term.exit.lock().unwrap().is_some() { + return Err(format!("背景指令 #{id} 已經結束了")); + } + let mut slot = term.stdin.lock().unwrap(); + if !input.is_empty() { + let w = slot.as_mut().ok_or("這個指令的輸入已經關閉了")?; + w.write_all(input.as_bytes()).and_then(|_| w.flush()).map_err(|e| format!("送不進去:{e}"))?; + } + if close { + *slot = None; + } + drop(slot); + drop(t); + std::thread::sleep(Duration::from_millis(400)); + let out = read_terminal(id)?; + Ok(format!("已送出{}{}。\n{out}", if input.is_empty() { String::new() } else { format!(" {} 個字", input.chars().count()) }, if close { ",並結束輸入" } else { "" })) +} + +/// Stop every background command and what it started (when the app quits). +pub fn stop_all() { + for t in terminals().lock().unwrap().values() { + if t.exit.lock().unwrap().is_none() { + kill_tree(t.root); + let _ = t.child.lock().unwrap().kill(); + } + } +} + +/// Stop one bot's background commands (and what they started). Returns how many were running. +pub fn stop_for(conversation: &str) -> usize { + let mut n = 0; + for t in terminals().lock().unwrap().values().filter(|t| t.conversation == conversation) { + if t.exit.lock().unwrap().is_none() { + kill_tree(t.root); + let _ = t.child.lock().unwrap().kill(); + n += 1; + } + } + n +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wd() -> PathBuf { + let d = std::env::temp_dir().join(format!("anybot-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn splits_chains() { + assert_eq!(split_commands("ls -la && git status | head; echo 'a;b'").unwrap(), vec!["ls -la", "git status", "head", "echo 'a;b'"]); + assert!(split_commands("echo $(whoami)").is_none()); + assert!(split_commands("eval ls").is_none()); + } + + #[test] + fn layered_rules() { + let w = wd(); + assert_eq!(decide("shell", r#"{"command":"ls && git status"}"#, &w, &[]), Decision::Allow); + assert!(matches!(decide("shell", r#"{"command":"ls && sudo rm x"}"#, &w, &[]), Decision::Deny(_))); + assert!(matches!(decide("shell", r#"{"command":"cat .env"}"#, &w, &[]), Decision::Deny(_))); + assert!(matches!(decide("shell", r#"{"command":"npm install"}"#, &w, &[]), Decision::Ask { ref grant, .. } if grant == "shell:npm install")); + assert_eq!(decide("shell", r#"{"command":"npm install && ls"}"#, &w, &["shell:npm install".into()]), Decision::Allow); + assert!(matches!(decide("shell", r#"{"command":"echo hi > out.txt"}"#, &w, &[]), Decision::Ask { .. })); + assert_eq!(decide("read_file", r#"{"path":"a.txt"}"#, &w, &[]), Decision::Allow); + assert!(matches!(decide("read_file", r#"{"path":"/etc/hosts"}"#, &w, &[]), Decision::Ask { .. })); + assert!(matches!(decide("read_file", r#"{"path":"../../.ssh/id_rsa"}"#, &w, &[]), Decision::Deny(_) | Decision::Ask { .. })); + assert!(matches!(decide("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &w, &[]), Decision::Ask { .. })); + assert_eq!(decide("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &w, &["edit_file".into()]), Decision::Allow); + } + + #[test] + fn edit_read_search() { + let w = wd(); + let c = AtomicBool::new(false); + assert!(run("edit_file", r#"{"path":"n/a.txt","new_text":"hello\nworld\n"}"#, &w, &c, &RunIds::default()).1); + assert!(run("edit_file", r#"{"path":"n/a.txt","old_text":"world","new_text":"there"}"#, &w, &c, &RunIds::default()).1); + let (r, ok) = run("read_file", r#"{"path":"n/a.txt"}"#, &w, &c, &RunIds::default()); + assert!(ok && r.contains("there"), "{r}"); + let (s, ok) = run("search", r#"{"pattern":"there"}"#, &w, &c, &RunIds::default()); + assert!(ok && s.contains("a.txt"), "{s}"); + assert!(!run("edit_file", r#"{"path":"n/a.txt","old_text":"missing","new_text":"x"}"#, &w, &c, &RunIds::default()).1); + } + + #[test] + fn shell_moves_to_background() { + let w = wd(); + let c = AtomicBool::new(false); + let (r, ok) = run("shell", r#"{"command":"echo start; sleep 1; echo end","timeout_ms":200}"#, &w, &c, &RunIds::default()); + assert!(ok && r.contains("背景指令 #"), "{r}"); + let id: u64 = r.split("背景指令 #").nth(1).unwrap().split(')').next().unwrap().parse().unwrap(); + std::thread::sleep(Duration::from_millis(1500)); + let (t, _) = run("read_terminal", &format!(r#"{{"id":{id}}}"#), &w, &c, &RunIds::default()); + assert!(t.contains("end") && t.contains("已結束"), "{t}"); + let (q, ok) = run("shell", r#"{"command":"echo quick"}"#, &w, &c, &RunIds::default()); + assert!(ok && q.contains("quick")); + } + + #[test] + fn head_tail_trims_middle() { + let s = "a".repeat(OUTPUT_HEAD_CHARS) + &"b".repeat(5000) + &"c".repeat(OUTPUT_TAIL_CHARS); + let t = head_tail(&s); + assert!(t.contains("中間省略 5000 個字")); + } + + #[test] + fn cpu_time_and_tree_walk() { + assert_eq!(parse_cpu_time("0:01.50"), 1500); + assert_eq!(parse_cpu_time("01:02:03"), 3_723_000); + assert_eq!(parse_cpu_time("1-00:00:00"), 86_400_000); + let t = [ + ProcRow { pid: 10, ppid: 1, rss_kb: 2048, cpu_ms: 5 }, + ProcRow { pid: 11, ppid: 10, rss_kb: 1024, cpu_ms: 7 }, + ProcRow { pid: 12, ppid: 11, rss_kb: 0, cpu_ms: 0 }, + ProcRow { pid: 13, ppid: 1, rss_kb: 0, cpu_ms: 0 }, + ]; + assert_eq!(descendants(&t, &[10]), vec![10, 11, 12]); + assert_eq!(descendants(&t, &[10, 11]), vec![10, 11, 12]); + } + + #[test] + fn commands_are_tagged_and_keys_hidden() { + let w = wd(); + let ids = RunIds { conversation: "7".into(), request: "call_x".into() }; + let (r, ok) = run("shell", r#"{"command":"echo $ANYBOT_CONVERSATION_ID/$ANYBOT_REQUEST_ID/${OPENAI_API_KEY:-none}"}"#, &w, &AtomicBool::new(false), &ids); + assert!(ok && r.contains("7/call_x/none"), "{r}"); + } + + #[test] + fn sandbox_blocks_protected_folders() { + if sandbox_exec().is_none() { + return; + } + let w = wd(); + let secret = std::env::temp_dir().join(format!("anybot-secret-{}", std::process::id())); + std::fs::create_dir_all(&secret).unwrap(); + std::fs::write(secret.join("k.txt"), "top-secret").unwrap(); + let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[secret.clone()], "1", "r").output().unwrap(); + assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret")); + let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret")); + } + + #[test] + fn bot_computer_is_sealed() { + if sandbox_exec().is_none() { + return; + } + let Some(home) = dirs::home_dir() else { return }; + let root = home.join("AnyBot 工作區").join(format!("test-seal-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let t = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: root.clone() } }; + let c = AtomicBool::new(false); + let (r, ok) = run_on("shell", r#"{"command":"echo hi > mine.txt && cat mine.txt"}"#, &t, &c, &RunIds::default()); + assert!(ok && r.contains("hi"), "{r}"); + let (r, _) = run_on("shell", r#"{"command":"ls ~/Documents >/dev/null 2>&1 && echo SEEN || echo HIDDEN"}"#, &t, &c, &RunIds::default()); + assert!(r.contains("HIDDEN"), "{r}"); + assert!(matches!(decide_on("read_file", r#"{"path":"~/Documents/x.txt"}"#, &t, &[]), Decision::Deny(_))); + assert_eq!(decide_on("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &t, &[]), Decision::Allow); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn ssh_commands_are_quoted() { + assert_eq!(ssh_command("me@box", "/srv/app", "echo 'hi'"), "ssh -o BatchMode=yes -o ConnectTimeout=15 'me@box' 'cd '\\''/srv/app'\\'' && echo '\\''hi'\\'''"); + let t = Target { name: "工作站".into(), place: Place::Ssh { target: "me@box".into(), dir: String::new() } }; + assert!(matches!(decide_on("shell", r#"{"command":"npm test"}"#, &t, &[]), Decision::Ask { ref reason, .. } if reason.contains("工作站"))); + assert_eq!(decide_on("shell", r#"{"command":"ls"}"#, &t, &[]), Decision::Allow); + let s = specs_for(&["A".into(), "B".into()], "A"); + assert_eq!(s[0]["parameters"]["properties"]["computer"]["enum"][1], "B"); + } + + #[test] + fn stop_ends_the_whole_tree() { + let w = wd(); + let cancel = Arc::new(AtomicBool::new(false)); + let c2 = cancel.clone(); + let marker = format!("anybot-sleep-{}", std::process::id()); + let cmd = format!(r#"{{"command":"sh -c 'exec -a {marker} sleep 30' & sleep 30","timeout_ms":20000}}"#); + let h = std::thread::spawn(move || run("shell", &cmd, &wd(), &c2, &RunIds::default())); + std::thread::sleep(Duration::from_millis(800)); + cancel.store(true, Ordering::Relaxed); + let (r, ok) = h.join().unwrap(); + assert!(!ok && r.contains("已停止"), "{r}"); + std::thread::sleep(Duration::from_millis(300)); + let ps = Command::new("ps").args(["-A", "-o", "command="]).output().unwrap(); + assert!(!String::from_utf8_lossy(&ps.stdout).contains(&marker), "child still running"); + let _ = w; + } +} diff --git a/src/triggers.rs b/src/triggers.rs new file mode 100644 index 0000000..f9d0267 --- /dev/null +++ b/src/triggers.rs @@ -0,0 +1,854 @@ +// Event-triggered routines (the original's trigger kinds, main-app.cjs routine trigger schema: +// cron, slack, github, origin, microsoftTeams, linear, sentry, pagerduty, email, webhook, group). +// The original's servers receive those events; here AnyBot checks for them with the person's own +// tokens while it runs (the original also keeps event triggers running locally when it can't +// sync: 「事件觸發的例行任務在安全的情況下會繼續在本機執行」), and a webhook address is served on +// this Mac. When one fires, the routine's bot is woken with what happened. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +/// GitHub asks clients to poll events at most every 60 s (X-Poll-Interval). +pub const GITHUB_POLL_SECS: u64 = 60; +/// Linear, Sentry, PagerDuty, Mail (estimated). +pub const SERVICE_POLL_SECS: u64 = 60; +/// Calendar checks (estimated). +pub const CALENDAR_POLL_SECS: u64 = 30; +/// Slack channel checks (estimated). +pub const SLACK_POLL_SECS: u64 = 10; +/// The webhook server on this Mac (estimated; next to the egress proxy's 8791). +pub const WEBHOOK_PORT: u16 = 8792; + +pub const GITHUB_EVENTS: [&str; 15] = [ + "pr_opened", "pr_updated", "pr_merged", "pr_closed", "pr_comment", "ci_passed", "ci_failed", "review_approved", "review_changes_requested", "review_comment", "thread_resolved", "thread_reopened", + "issue_assigned", "issue_opened", "push", +]; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Trigger { + Github { + repo: String, + #[serde(default)] + token: String, + #[serde(default)] + events: Vec, + /// Only these people's actions (empty = anyone). + #[serde(default)] + users: Vec, + /// Only this branch (CI / pushes); empty = any. + #[serde(default)] + branch: String, + /// Only this PR number (0 = any). + #[serde(default)] + pr: u64, + }, + Slack { + channel: String, + /// Empty = the token of a Slack 訊息管道. + #[serde(default)] + token: String, + /// any | mention | keyword | reaction + #[serde(default = "any", rename = "match")] + matcher: String, + /// The keyword or the reaction name. + #[serde(default)] + value: String, + }, + Linear { + #[serde(rename = "apiKey")] + api_key: String, + /// issue_created | status_changed + #[serde(default)] + events: Vec, + #[serde(default)] + team: String, + }, + Sentry { + token: String, + org: String, + #[serde(default)] + project: String, + /// issue_created | issue_resolved | issue_unresolved | issue_ignored | issue_assigned | issue_changed + #[serde(default)] + events: Vec, + }, + Pagerduty { + token: String, + #[serde(default)] + service: String, + /// triggered | acknowledged | resolved | changed + #[serde(default)] + events: Vec, + }, + Email { + /// Sender contains (empty = anyone). + #[serde(default)] + from: String, + #[serde(default)] + subject: String, + }, + Webhook { + /// Callers send `Authorization: Bearer `. + secret: String, + }, + /// 跟著行事曆: when a tracked Calendar event starts, wherever it has been moved to. + Calendar { + /// Only this calendar (empty = all). + #[serde(default)] + calendar: String, + /// Only events whose title contains this (empty = all). + #[serde(default)] + title: String, + /// Fire this many minutes before the start (0 = at the start). + #[serde(default, rename = "minutesBefore")] + minutes_before: i64, + }, + Group { + triggers: Vec, + }, +} + +fn any() -> String { + "any".into() +} + +impl Trigger { + pub fn describe(&self) -> String { + match self { + Trigger::Github { repo, events, .. } => format!("GitHub {repo}:{}", if events.is_empty() { "任何事件".into() } else { events.join("、") }), + Trigger::Slack { channel, matcher, value, .. } => format!( + "Slack {channel}:{}", + match matcher.as_str() { + "mention" => "被 @ 時".to_string(), + "keyword" => format!("提到「{value}」時"), + "reaction" => format!("有人加上 :{value}: 時"), + _ => "任何訊息".to_string(), + } + ), + Trigger::Linear { team, events, .. } => format!("Linear{}:{}", if team.is_empty() { String::new() } else { format!(" {team}") }, if events.is_empty() { "議題建立或狀態變更".into() } else { events.join("、") }), + Trigger::Sentry { org, project, events, .. } => format!("Sentry {org}/{}:{}", if project.is_empty() { "*" } else { project }, if events.is_empty() { "任何議題變化".into() } else { events.join("、") }), + Trigger::Pagerduty { service, events, .. } => format!("PagerDuty{}:{}", if service.is_empty() { String::new() } else { format!(" {service}") }, if events.is_empty() { "任何事故變化".into() } else { events.join("、") }), + Trigger::Email { from, subject } => format!("新郵件{}{}", if from.is_empty() { String::new() } else { format!(",寄件人含「{from}」") }, if subject.is_empty() { String::new() } else { format!(",主旨含「{subject}」") }), + Trigger::Webhook { .. } => "Webhook 呼叫".into(), + Trigger::Calendar { calendar, title, minutes_before } => format!( + "行事曆{}{}活動{}", + if calendar.is_empty() { String::new() } else { format!("「{calendar}」") }, + if title.is_empty() { String::new() } else { format!("中標題含「{title}」的") }, + if *minutes_before > 0 { format!("開始前 {minutes_before} 分鐘") } else { "開始時".into() } + ), + Trigger::Group { triggers } => format!("任一:{}", triggers.iter().map(|t| t.describe()).collect::>().join(";")), + } + } + + /// Check what can be checked before saving. + pub fn validate(&self) -> Result<(), String> { + match self { + Trigger::Github { repo, events, .. } => { + if repo.split('/').filter(|p| !p.is_empty()).count() != 2 { + return Err("repo 要寫成 owner/name".into()); + } + if let Some(e) = events.iter().find(|e| !GITHUB_EVENTS.contains(&e.as_str())) { + return Err(format!("不認得 GitHub 事件「{e}」,可以用:{}", GITHUB_EVENTS.join("、"))); + } + } + Trigger::Slack { channel, matcher, .. } => { + if channel.trim().is_empty() { + return Err("Slack 要給頻道 ID".into()); + } + if !["any", "mention", "keyword", "reaction"].contains(&matcher.as_str()) { + return Err("match 要是 any、mention、keyword 或 reaction".into()); + } + } + Trigger::Linear { api_key, .. } if api_key.trim().is_empty() => return Err("Linear 要給 API key".into()), + Trigger::Sentry { token, org, .. } if token.trim().is_empty() || org.trim().is_empty() => return Err("Sentry 要給 token 和 org".into()), + Trigger::Pagerduty { token, .. } if token.trim().is_empty() => return Err("PagerDuty 要給 token".into()), + Trigger::Webhook { secret } if secret.trim().len() < 8 => return Err("Webhook 的 secret 至少 8 個字".into()), + Trigger::Group { triggers } => { + if triggers.len() < 2 { + return Err("群組至少要兩個觸發條件".into()); + } + for t in triggers { + t.validate()?; + } + } + _ => {} + } + Ok(()) + } +} + +/// The JSON schema the bot fills in (create_routine / update_routine). +pub fn schema() -> Value { + json!({ + "type": "object", + "description": "Run when something happens instead of on a schedule. type: github | slack | linear | sentry | pagerduty | email | webhook | group.", + "properties": { + "type": {"type": "string", "enum": ["github", "slack", "linear", "sentry", "pagerduty", "email", "webhook", "calendar", "group"]}, + "repo": {"type": "string", "description": "github: owner/name"}, + "token": {"type": "string", "description": "github/sentry/pagerduty token; slack: optional (uses a Slack 訊息管道's token)"}, + "apiKey": {"type": "string", "description": "linear API key"}, + "events": {"type": "array", "items": {"type": "string"}, "description": format!("github: {}; linear: issue_created, status_changed; sentry: issue_created, issue_resolved, issue_unresolved, issue_ignored, issue_assigned, issue_changed; pagerduty: triggered, acknowledged, resolved, changed. Empty = all.", GITHUB_EVENTS.join(", "))}, + "users": {"type": "array", "items": {"type": "string"}, "description": "github: only these logins"}, + "branch": {"type": "string"}, "pr": {"type": "integer"}, + "channel": {"type": "string", "description": "slack channel id"}, + "match": {"type": "string", "enum": ["any", "mention", "keyword", "reaction"]}, + "value": {"type": "string", "description": "slack keyword or reaction name"}, + "team": {"type": "string"}, "org": {"type": "string"}, "project": {"type": "string"}, "service": {"type": "string"}, + "from": {"type": "string", "description": "email: sender contains"}, "subject": {"type": "string", "description": "email: subject contains"}, + "secret": {"type": "string", "description": "webhook: bearer secret (at least 8 characters)"}, + "calendar": {"type": "string", "description": "calendar: only this calendar"}, "title": {"type": "string", "description": "calendar: event title contains"}, "minutesBefore": {"type": "integer", "description": "calendar: fire this many minutes before the event starts"}, + "triggers": {"type": "array", "items": {"type": "object"}, "description": "group: two or more triggers"} + }, + "required": ["type"] + }) +} + +// ---------- reading events (pure, testable) ---------- + +/// One thing that happened: (event kind, who did it, branch, PR/issue number, description). +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + pub kind: String, + pub user: String, + pub branch: String, + pub number: u64, + pub text: String, +} + +/// GitHub repo events newer than `since` (event ids grow), oldest first. +pub fn github_events(list: &Value, since: u64) -> (u64, Vec) { + let mut newest = since; + let mut out = vec![]; + let Some(arr) = list.as_array() else { return (since, out) }; + let mut items: Vec<&Value> = arr.iter().collect(); + items.sort_by_key(|e| e["id"].as_str().and_then(|s| s.parse::().ok()).unwrap_or(0)); + for e in items { + let id = e["id"].as_str().and_then(|s| s.parse::().ok()).unwrap_or(0); + if id <= since { + continue; + } + newest = newest.max(id); + let user = e["actor"]["login"].as_str().unwrap_or("").to_string(); + let p = &e["payload"]; + let action = p["action"].as_str().unwrap_or(""); + let pr = &p["pull_request"]; + let (kind, number, text, branch) = match e["type"].as_str().unwrap_or("") { + "PullRequestEvent" => { + let k = match action { + "opened" | "reopened" => "pr_opened", + "closed" if pr["merged"].as_bool() == Some(true) => "pr_merged", + "closed" => "pr_closed", + "synchronize" | "edited" | "ready_for_review" => "pr_updated", + _ => continue, + }; + (k, pr["number"].as_u64().unwrap_or(0), format!("PR #{} {}", pr["number"], pr["title"].as_str().unwrap_or("")), pr["head"]["ref"].as_str().unwrap_or("").to_string()) + } + "IssueCommentEvent" if p["issue"]["pull_request"].is_object() => ("pr_comment", p["issue"]["number"].as_u64().unwrap_or(0), format!("PR #{} 的留言:{}", p["issue"]["number"], p["comment"]["body"].as_str().unwrap_or("").chars().take(200).collect::()), String::new()), + "PullRequestReviewEvent" => { + let k = match p["review"]["state"].as_str().unwrap_or("").to_lowercase().as_str() { + "approved" => "review_approved", + "changes_requested" => "review_changes_requested", + "commented" => "review_comment", + _ => continue, + }; + (k, pr["number"].as_u64().unwrap_or(0), format!("PR #{} 的審查:{}", pr["number"], p["review"]["body"].as_str().unwrap_or("").chars().take(200).collect::()), pr["head"]["ref"].as_str().unwrap_or("").to_string()) + } + "PullRequestReviewThreadEvent" => { + let k = if action == "resolved" { "thread_resolved" } else if action == "unresolved" { "thread_reopened" } else { continue }; + (k, pr["number"].as_u64().unwrap_or(0), format!("PR #{} 的審查討論串{}", pr["number"], if k == "thread_resolved" { "已解決" } else { "重新開啟" }), String::new()) + } + "IssuesEvent" => { + let k = match action { + "assigned" => "issue_assigned", + "opened" => "issue_opened", + _ => continue, + }; + (k, p["issue"]["number"].as_u64().unwrap_or(0), format!("議題 #{} {}{}", p["issue"]["number"], p["issue"]["title"].as_str().unwrap_or(""), if k == "issue_assigned" { format!("(指派給 {})", p["assignee"]["login"].as_str().unwrap_or("")) } else { String::new() }), String::new()) + } + "PushEvent" => { + let branch = p["ref"].as_str().unwrap_or("").trim_start_matches("refs/heads/").to_string(); + ("push", 0, format!("{} 推送到 {branch}:{} 個 commit", user, p["size"].as_u64().or(p["commits"].as_array().map(|a| a.len() as u64)).unwrap_or(0)), branch) + } + _ => continue, + }; + out.push(Event { kind: kind.into(), user, branch, number, text }); + } + (newest, out) +} + +/// Finished GitHub Actions runs not seen before → ci_passed / ci_failed. +pub fn github_runs(runs: &Value, seen: &mut HashSet, first: bool) -> Vec { + let mut out = vec![]; + for r in runs["workflow_runs"].as_array().into_iter().flatten() { + if r["status"].as_str() != Some("completed") { + continue; + } + let id = r["id"].as_u64().unwrap_or(0); + if !seen.insert(id) || first { + continue; + } + let kind = match r["conclusion"].as_str().unwrap_or("") { + "success" => "ci_passed", + "failure" | "timed_out" => "ci_failed", + _ => continue, + }; + let pr = r["pull_requests"][0]["number"].as_u64().unwrap_or(0); + out.push(Event { + kind: kind.into(), + user: r["actor"]["login"].as_str().unwrap_or("").into(), + branch: r["head_branch"].as_str().unwrap_or("").into(), + number: pr, + text: format!("CI「{}」在 {} {}:{}", r["name"].as_str().unwrap_or(""), r["head_branch"].as_str().unwrap_or(""), if kind == "ci_passed" { "通過" } else { "失敗" }, r["html_url"].as_str().unwrap_or("")), + }); + } + out +} + +/// A list of (id, status, title) compared with the last one: new ids (after the first look) and +/// status changes. Events are `created` and ``. +pub fn status_changes(prev: &mut HashMap, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> { + let mut out = vec![]; + for (id, status, title) in items { + match prev.get(id) { + None if !first => out.push(("created".to_string(), title.clone())), + Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))), + _ => {} + } + prev.insert(id.clone(), status.clone()); + } + out +} + +/// Calendar events that start now (within the last poll) and haven't fired: (key, description). +pub fn calendar_due(events: &Value, now: i64, minutes_before: i64, window_secs: i64, title: &str, fired: &mut HashSet) -> Vec { + let mut out = vec![]; + for e in events.as_array().into_iter().flatten() { + let name = e["title"].as_str().unwrap_or(""); + if !title.is_empty() && !name.to_lowercase().contains(&title.to_lowercase()) { + continue; + } + let Some(start) = e["start"].as_str().and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|d| d.timestamp()) else { continue }; + let at = start - minutes_before * 60; + // The key includes the start: a moved event fires again at its new time. + let key = format!("{}@{start}", e["id"].as_str().unwrap_or(name)); + if at <= now && now - at < window_secs && fired.insert(key) { + out.push(format!("行事曆活動「{name}」{}({})", if minutes_before > 0 { format!("{minutes_before} 分鐘後開始") } else { "開始了".into() }, e["start"].as_str().unwrap_or(""))); + } + } + out +} + +/// Does a Slack message match? +pub fn slack_matches(text: &str, matcher: &str, value: &str, own_user: &str) -> bool { + match matcher { + "mention" => !own_user.is_empty() && text.contains(&format!("<@{own_user}>")), + "keyword" => !value.is_empty() && text.to_lowercase().contains(&value.to_lowercase()), + "reaction" => false, + _ => true, + } +} + +fn wanted(events: &[String], kind: &str) -> bool { + events.is_empty() || events.iter().any(|e| e == kind) +} + +// ---------- running ---------- + +#[derive(Clone, Debug)] +pub struct Fired { + pub bot_id: u64, + pub routine_id: u64, + pub what: String, +} + +#[derive(Clone)] +pub struct Watch { + pub bot_id: u64, + pub routine_id: u64, + pub trigger: Trigger, +} + +static GENERATION: AtomicU64 = AtomicU64::new(0); + +pub fn statuses() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn note(routine: u64, s: &str) { + statuses().lock().unwrap().insert(routine, s.to_string()); +} + +fn webhooks() -> &'static Mutex> { + static W: OnceLock>> = OnceLock::new(); + W.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn agent(secs: u64) -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into() +} + +fn get(url: &str, headers: &[(&str, String)]) -> Result { + let mut r = agent(30).get(url).header("User-Agent", "AnyBot"); + for (k, v) in headers { + r = r.header(*k, v); + } + let mut resp = r.call().map_err(|e| e.to_string())?; + let st = resp.status().as_u16(); + let body = resp.body_mut().read_to_string().unwrap_or_default(); + if !(200..300).contains(&st) { + return Err(format!("HTTP {st}:{}", body.chars().take(150).collect::())); + } + serde_json::from_str(&body).map_err(|e| e.to_string()) +} + +fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result { + let mut r = agent(30).post(url).header("User-Agent", "AnyBot").header("Content-Type", "application/json"); + for (k, v) in headers { + r = r.header(*k, v); + } + let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| e.to_string())?; + let st = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + if !(200..300).contains(&st) { + return Err(format!("HTTP {st}:{}", text.chars().take(150).collect::())); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +fn sleep_alive(gen: u64, secs: u64) -> bool { + for _ in 0..secs * 4 { + if GENERATION.load(Ordering::SeqCst) != gen { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } + GENERATION.load(Ordering::SeqCst) == gen +} + +/// (Re)start every watcher. `slack_token` is used when a Slack trigger has none. +pub fn restart(watches: Vec, slack_token: String, tx: Sender, repaint: Arc) { + let gen = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + webhooks().lock().unwrap().clear(); + for w in watches { + start_one(w.bot_id, w.routine_id, w.trigger, gen, slack_token.clone(), tx.clone(), repaint.clone()); + } +} + +fn start_one(bot_id: u64, routine_id: u64, t: Trigger, gen: u64, slack_token: String, tx: Sender, repaint: Arc) { + let fire = move |what: String| { + let _ = tx.send(Fired { bot_id, routine_id, what }); + repaint(); + }; + match t { + Trigger::Group { triggers } => { + for t in triggers { + let tx2 = fire.clone(); + let (stx, srx) = std::sync::mpsc::channel::(); + start_one(bot_id, routine_id, t, gen, slack_token.clone(), stx, Arc::new(|| {})); + std::thread::spawn(move || { + while let Ok(f) = srx.recv() { + tx2(f.what); + } + }); + } + } + Trigger::Webhook { secret } => { + webhooks().lock().unwrap().insert(routine_id, (secret, bot_id)); + match ensure_webhook_server(fire_router()) { + Ok(port) => note(routine_id, &format!("等待呼叫:POST http://127.0.0.1:{port}/hooks/{routine_id}")), + Err(e) => note(routine_id, &e), + } + // The router delivers through the shared sender set below. + ROUTER_TX.lock().unwrap().get_or_insert_with(HashMap::new).insert(routine_id, Box::new(fire)); + } + other => { + std::thread::spawn(move || watch_loop(routine_id, other, gen, slack_token, fire)); + } + } +} + +type Fire = Box; +static ROUTER_TX: Mutex>> = Mutex::new(None); + +fn fire_router() -> fn(u64, String) { + |id, what| { + if let Some(m) = ROUTER_TX.lock().unwrap().as_ref() { + if let Some(f) = m.get(&id) { + f(what); + } + } + } +} + +fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: impl Fn(String)) { + let mut first = true; + let mut cursor: u64 = 0; + let mut seen: HashSet = HashSet::new(); + let mut prev: HashMap = HashMap::new(); + let mut slack_cursor = String::new(); + let mut slack_own = String::new(); + let mut slack_reacted: HashSet = HashSet::new(); + loop { + let wait = match &t { + Trigger::Github { repo, token, events, users, branch, pr } => { + let mut h = vec![("Accept", "application/vnd.github+json".to_string())]; + if !token.trim().is_empty() { + h.push(("Authorization", format!("Bearer {}", token.trim()))); + } + let ok_ev = |e: &Event| wanted(events, &e.kind) && (users.is_empty() || users.iter().any(|u| u.eq_ignore_ascii_case(&e.user))) && (branch.is_empty() || e.branch.is_empty() || e.branch == *branch) && (*pr == 0 || e.number == *pr); + match get(&format!("https://api.github.com/repos/{repo}/events?per_page=50"), &h) { + Ok(v) => { + let (next, evs) = github_events(&v, cursor); + if !first { + for e in evs.into_iter().filter(ok_ev) { + fire(format!("GitHub {repo}:{}({},by {})", e.text, e.kind, e.user)); + } + } + cursor = next; + note(routine_id, "已連上 GitHub,等待事件"); + } + Err(e) => note(routine_id, &format!("GitHub:{e}")), + } + if wanted(events, "ci_passed") || wanted(events, "ci_failed") { + if let Ok(v) = get(&format!("https://api.github.com/repos/{repo}/actions/runs?per_page=20"), &h) { + for e in github_runs(&v, &mut seen, first).into_iter().filter(ok_ev) { + fire(format!("GitHub {repo}:{}", e.text)); + } + } + } + GITHUB_POLL_SECS + } + Trigger::Slack { channel, token, matcher, value } => { + let tok = if token.trim().is_empty() { slack_token.clone() } else { token.trim().to_string() }; + let auth = vec![("Authorization", format!("Bearer {tok}"))]; + if slack_own.is_empty() { + slack_own = get("https://slack.com/api/auth.test", &auth).ok().and_then(|v| v["user_id"].as_str().map(String::from)).unwrap_or_default(); + } + match get(&format!("https://slack.com/api/conversations.history?channel={channel}&limit=30"), &auth) { + Ok(v) if v["ok"].as_bool() == Some(true) => { + let msgs: Vec<&Value> = v["messages"].as_array().map(|a| a.iter().rev().collect()).unwrap_or_default(); + for m in &msgs { + let ts = m["ts"].as_str().unwrap_or("").to_string(); + let text = m["text"].as_str().unwrap_or(""); + if matcher == "reaction" { + for r in m["reactions"].as_array().into_iter().flatten() { + if r["name"].as_str() == Some(value.as_str()) { + let key = format!("{ts}:{}", r["count"]); + if slack_reacted.insert(key) && !first { + fire(format!("Slack {channel}:有人在訊息「{}」加上 :{value}:", text.chars().take(120).collect::())); + } + } + } + continue; + } + let newer = ts.parse::().unwrap_or(0.0) > slack_cursor.parse::().unwrap_or(0.0); + if newer && !first && m.get("bot_id").is_none() && slack_matches(text, matcher, value, &slack_own) { + fire(format!("Slack {channel} 的 {}:{text}", m["user"].as_str().unwrap_or("someone"))); + } + if newer { + slack_cursor = ts; + } + } + note(routine_id, "已連上 Slack,等待訊息"); + } + Ok(v) => note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))), + Err(e) => note(routine_id, &format!("Slack:{e}")), + } + SLACK_POLL_SECS + } + Trigger::Linear { api_key, events, team } => { + let q = json!({"query": "{ issues(first: 50, orderBy: updatedAt) { nodes { id identifier title state { name } team { key } } } }"}); + match post("https://api.linear.app/graphql", &[("Authorization", api_key.trim().to_string())], &q) { + Ok(v) => { + let items: Vec<(String, String, String)> = v["data"]["issues"]["nodes"] + .as_array() + .into_iter() + .flatten() + .filter(|n| team.is_empty() || n["team"]["key"].as_str() == Some(team.as_str())) + .map(|n| (n["id"].as_str().unwrap_or("").into(), n["state"]["name"].as_str().unwrap_or("").into(), format!("{} {}", n["identifier"].as_str().unwrap_or(""), n["title"].as_str().unwrap_or("")))) + .collect(); + for (ev, title) in status_changes(&mut prev, &items, first) { + let kind = if ev == "created" { "issue_created" } else { "status_changed" }; + if wanted(events, kind) { + fire(format!("Linear:{title}({})", if kind == "issue_created" { "新議題" } else { "狀態變更" })); + } + } + note(routine_id, "已連上 Linear,等待變化"); + } + Err(e) => note(routine_id, &format!("Linear:{e}")), + } + SERVICE_POLL_SECS + } + Trigger::Sentry { token, org, project, events } => { + let url = if project.is_empty() { format!("https://sentry.io/api/0/organizations/{org}/issues/?limit=50&query=") } else { format!("https://sentry.io/api/0/projects/{org}/{project}/issues/?limit=50&query=") }; + match get(&url, &[("Authorization", format!("Bearer {}", token.trim()))]) { + Ok(v) => { + let items: Vec<(String, String, String)> = v + .as_array() + .into_iter() + .flatten() + .map(|i| { + let assigned = if i["assignedTo"].is_null() { "" } else { "+assigned" }; + (i["id"].as_str().unwrap_or("").into(), format!("{}{assigned}", i["status"].as_str().unwrap_or("")), format!("{} {}", i["shortId"].as_str().unwrap_or(""), i["title"].as_str().unwrap_or(""))) + }) + .collect(); + for (ev, title) in status_changes(&mut prev, &items, first) { + let kind = if ev == "created" { + "issue_created" + } else if ev.ends_with("+assigned") && !ev.starts_with("resolved") { + "issue_assigned" + } else if ev.starts_with("resolved") { + "issue_resolved" + } else if ev.starts_with("unresolved") { + "issue_unresolved" + } else if ev.starts_with("ignored") { + "issue_ignored" + } else { + "issue_changed" + }; + if wanted(events, kind) || wanted(events, "issue_changed") && kind != "issue_created" { + fire(format!("Sentry:{title}({kind})")); + } + } + note(routine_id, "已連上 Sentry,等待變化"); + } + Err(e) => note(routine_id, &format!("Sentry:{e}")), + } + SERVICE_POLL_SECS + } + Trigger::Pagerduty { token, service, events } => { + let url = format!("https://api.pagerduty.com/incidents?limit=50&sort_by=created_at:desc{}", if service.is_empty() { String::new() } else { format!("&service_ids[]={service}") }); + match get(&url, &[("Authorization", format!("Token token={}", token.trim())), ("Accept", "application/vnd.pagerduty+json;version=2".into())]) { + Ok(v) => { + let items: Vec<(String, String, String)> = v["incidents"] + .as_array() + .into_iter() + .flatten() + .map(|i| (i["id"].as_str().unwrap_or("").into(), i["status"].as_str().unwrap_or("").into(), format!("#{} {}", i["incident_number"], i["title"].as_str().unwrap_or("")))) + .collect(); + for (ev, title) in status_changes(&mut prev, &items, first) { + let kind = if ev == "created" { "triggered".to_string() } else { ev.clone() }; + if wanted(events, &kind) || wanted(events, "changed") && ev != "created" { + fire(format!("PagerDuty:{title}({kind})")); + } + } + note(routine_id, "已連上 PagerDuty,等待事故"); + } + Err(e) => note(routine_id, &format!("PagerDuty:{e}")), + } + SERVICE_POLL_SECS + } + Trigger::Email { from, subject } => { + let (o, ok) = crate::macapps::run("mac_read_mail", &json!({"limit": 20, "days": 2}).to_string()); + if ok { + let list: Value = serde_json::from_str(o.trim_start_matches("郵件:")).unwrap_or(Value::Null); + let items: Vec<(String, String, String)> = list + .as_array() + .into_iter() + .flatten() + .filter(|m| from.is_empty() || m["from"].as_str().unwrap_or("").to_lowercase().contains(&from.to_lowercase())) + .filter(|m| subject.is_empty() || m["subject"].as_str().unwrap_or("").to_lowercase().contains(&subject.to_lowercase())) + .map(|m| (m["id"].as_str().unwrap_or("").into(), "in".into(), format!("{}:{}(id {})", m["from"].as_str().unwrap_or(""), m["subject"].as_str().unwrap_or(""), m["id"].as_str().unwrap_or("")))) + .collect(); + for (_, title) in status_changes(&mut prev, &items, first) { + fire(format!("新郵件 {title}")); + } + note(routine_id, "正在看「郵件」的收件匣"); + } else { + note(routine_id, &o); + } + SERVICE_POLL_SECS + } + Trigger::Calendar { calendar, title, minutes_before } => { + let now = chrono::Utc::now(); + let from = (now - chrono::Duration::hours(1)).to_rfc3339(); + let to = (now + chrono::Duration::hours(24)).to_rfc3339(); + let (o, ok) = crate::macapps::run("mobile_read_calendar", &json!({"from": from, "to": to, "calendar": calendar}).to_string()); + if ok { + let list: Value = serde_json::from_str(o.trim_start_matches("行事曆:")).unwrap_or(Value::Null); + for what in calendar_due(&list, now.timestamp(), *minutes_before, CALENDAR_POLL_SECS as i64 * 2, title, &mut slack_reacted) { + fire(what); + } + note(routine_id, "正在看「行事曆」"); + } else { + note(routine_id, &o); + } + CALENDAR_POLL_SECS + } + Trigger::Webhook { .. } | Trigger::Group { .. } => return, + }; + first = false; + if !sleep_alive(gen, wait) { + return; + } + } +} + +// ---------- webhook server ---------- + +static WEBHOOK_BOUND: Mutex> = Mutex::new(None); + +pub fn webhook_port() -> Option { + *WEBHOOK_BOUND.lock().unwrap() +} + +/// Parse `POST /hooks/` with a bearer secret. Returns (routine id, secret, body). +pub fn parse_hook(req: &[u8]) -> Option<(u64, String, String)> { + let text = String::from_utf8_lossy(req); + let (head, body) = text.split_once("\r\n\r\n")?; + let first = head.lines().next()?; + let mut parts = first.split_whitespace(); + if parts.next()? != "POST" { + return None; + } + let id = parts.next()?.strip_prefix("/hooks/")?.trim_end_matches('/').parse().ok()?; + let secret = head.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + k.trim().eq_ignore_ascii_case("authorization").then(|| v.trim().strip_prefix("Bearer ").unwrap_or("").to_string()) + })?; + Some((id, secret, body.to_string())) +} + +fn ensure_webhook_server(route: fn(u64, String)) -> Result { + if let Some(p) = webhook_port() { + return Ok(p); + } + let listener = (WEBHOOK_PORT..WEBHOOK_PORT + 20).find_map(|p| std::net::TcpListener::bind(("127.0.0.1", p)).ok()).ok_or("找不到可用的連接埠")?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + *WEBHOOK_BOUND.lock().unwrap() = Some(port); + std::thread::spawn(move || { + for conn in listener.incoming().flatten() { + std::thread::spawn(move || { + let mut c = conn; + c.set_read_timeout(Some(Duration::from_secs(10))).ok(); + let mut got = vec![]; + let mut buf = [0u8; 8192]; + loop { + let n = c.read(&mut buf).unwrap_or(0); + got.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&got).to_string(); + if let Some(h) = t.find("\r\n\r\n") { + let len = t[..h].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if got.len() >= h + 4 + len { + break; + } + } + if n == 0 || got.len() > 1 << 20 { + break; + } + } + let status = match parse_hook(&got) { + Some((id, secret, body)) => match webhooks().lock().unwrap().get(&id).cloned() { + Some((want, _)) if want == secret => { + route(id, format!("Webhook 被呼叫,內容:{}", body.chars().take(4000).collect::())); + "202 Accepted" + } + Some(_) => "401 Unauthorized", + None => "404 Not Found", + }, + None => "400 Bad Request", + }; + let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()); + }); + } + }); + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_events_become_kinds() { + let v = json!([ + {"id": "5", "type": "PushEvent", "actor": {"login": "amy"}, "payload": {"ref": "refs/heads/main", "size": 2}}, + {"id": "3", "type": "PullRequestEvent", "actor": {"login": "bob"}, "payload": {"action": "closed", "pull_request": {"number": 7, "title": "Fix", "merged": true, "head": {"ref": "fix"}}}}, + {"id": "4", "type": "PullRequestReviewEvent", "actor": {"login": "cat"}, "payload": {"review": {"state": "APPROVED", "body": "lgtm"}, "pull_request": {"number": 8, "head": {"ref": "x"}}}}, + {"id": "2", "type": "IssuesEvent", "actor": {"login": "dan"}, "payload": {"action": "assigned", "issue": {"number": 9, "title": "Bug"}, "assignee": {"login": "me"}}}, + {"id": "1", "type": "WatchEvent", "actor": {"login": "x"}, "payload": {}} + ]); + let (cur, evs) = github_events(&v, 1); + assert_eq!(cur, 5); + assert_eq!(evs.iter().map(|e| e.kind.as_str()).collect::>(), vec!["issue_assigned", "pr_merged", "review_approved", "push"]); + assert_eq!(evs[3].branch, "main"); + assert_eq!(github_events(&v, 5).1.len(), 0); + } + + #[test] + fn ci_runs_fire_once_after_the_first_look() { + let v = json!({"workflow_runs": [{"id": 1, "status": "completed", "conclusion": "failure", "name": "CI", "head_branch": "main"}]}); + let mut seen = HashSet::new(); + assert!(github_runs(&v, &mut seen, true).is_empty()); + let v2 = json!({"workflow_runs": [{"id": 2, "status": "completed", "conclusion": "success", "name": "CI", "head_branch": "main"}, {"id": 1, "status": "completed", "conclusion": "failure"}]}); + let e = github_runs(&v2, &mut seen, false); + assert_eq!(e.len(), 1); + assert_eq!(e[0].kind, "ci_passed"); + } + + #[test] + fn status_changes_find_new_and_changed() { + let mut prev = HashMap::new(); + assert!(status_changes(&mut prev, &[("a".into(), "triggered".into(), "A".into())], true).is_empty()); + let c = status_changes(&mut prev, &[("a".into(), "resolved".into(), "A".into()), ("b".into(), "triggered".into(), "B".into())], false); + assert_eq!(c, vec![("resolved".to_string(), "A(triggered → resolved)".to_string()), ("created".to_string(), "B".to_string())]); + } + + #[test] + fn triggers_parse_and_validate() { + let t: Trigger = serde_json::from_value(json!({"type": "github", "repo": "me/app", "events": ["pr_merged", "ci_failed"]})).unwrap(); + assert!(t.validate().is_ok()); + assert!(t.describe().contains("me/app")); + let bad: Trigger = serde_json::from_value(json!({"type": "github", "repo": "app", "events": []})).unwrap(); + assert!(bad.validate().is_err()); + let g: Trigger = serde_json::from_value(json!({"type": "group", "triggers": [{"type": "webhook", "secret": "12345678"}]})).unwrap(); + assert!(g.validate().is_err()); + let s: Trigger = serde_json::from_value(json!({"type": "slack", "channel": "C1", "match": "keyword", "value": "部署"})).unwrap(); + assert!(s.validate().is_ok() && slack_matches("今天要部署嗎", "keyword", "部署", "U1") && !slack_matches("hi", "mention", "", "U1")); + } + + #[test] + fn calendar_fires_once_and_follows_moves() { + let now = chrono::DateTime::parse_from_rfc3339("2026-11-04T09:00:20+08:00").unwrap().timestamp(); + let ev = |start: &str| json!([{"id": "E1", "title": "週會", "start": start}]); + let mut fired = HashSet::new(); + assert_eq!(calendar_due(&ev("2026-11-04T09:00:00+08:00"), now, 0, 60, "", &mut fired).len(), 1); + assert!(calendar_due(&ev("2026-11-04T09:00:00+08:00"), now, 0, 60, "", &mut fired).is_empty()); + // Moved to 10:00: fires again then, not now. + assert!(calendar_due(&ev("2026-11-04T10:00:00+08:00"), now, 0, 60, "", &mut fired).is_empty()); + assert_eq!(calendar_due(&ev("2026-11-04T10:00:00+08:00"), now + 3600, 0, 60, "", &mut fired).len(), 1); + // 10 minutes before, filtered by title. + let mut f2 = HashSet::new(); + assert_eq!(calendar_due(&ev("2026-11-04T09:10:00+08:00"), now, 10, 60, "週會", &mut f2).len(), 1); + assert!(calendar_due(&ev("2026-11-04T09:10:00+08:00"), now, 10, 60, "面試", &mut HashSet::new()).is_empty()); + } + + #[test] + fn webhook_needs_the_secret() { + let (tx, rx) = std::sync::mpsc::channel(); + let watches = vec![Watch { bot_id: 3, routine_id: 41, trigger: Trigger::Webhook { secret: "s3cret-key".into() } }]; + restart(watches, String::new(), tx, Arc::new(|| {})); + let port = webhook_port().unwrap(); + let call = |auth: &str| { + let mut c = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + let body = "{\"deploy\":\"ok\"}"; + c.write_all(format!("POST /hooks/41 HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer {auth}\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes()).unwrap(); + let mut out = String::new(); + c.read_to_string(&mut out).unwrap(); + out + }; + assert!(call("wrong").starts_with("HTTP/1.1 401")); + assert!(call("s3cret-key").starts_with("HTTP/1.1 202")); + let f = rx.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!((f.bot_id, f.routine_id), (3, 41)); + assert!(f.what.contains("deploy")); + } +} diff --git a/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..2246b95 --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,682 @@ +// 保險庫 / 1Password / 安全表單: the bot can sign in to a site in AnyBot's browser without ever +// seeing the password (the original keeps logins in a vault and fills them through a secure +// form; it also connects to 1Password). The login comes from the macOS Keychain (AnyBot's own +// saved logins or any internet password there) or the 1Password CLI (`op`), and is typed into +// the page by AnyBot; the model only hears that it was filled. + +use serde_json::Value; +use std::process::Command; + +pub const TOOL: &str = "browser_fill_login"; +/// Keychain entries AnyBot saves are marked with this label. +const LABEL: &str = "AnyBot 登入"; + +pub const LIST_TOOL: &str = "vault_list"; +pub const CARD_TOOL: &str = "browser_fill_card"; +pub const CONTACT_TOOL: &str = "browser_fill_contact"; + +/// The vault tools the bot gets (with AnyBot's browser). +pub fn specs() -> Vec { + vec![ + serde_json::json!({"name": LIST_TOOL, "description": "List what the person saved in AnyBot's 保險庫 (vault): logins (site, username, notes — never the password), payment cards (nickname, brand, last 4 digits, expiry — never the number or security code) and contact info (name, address, phones, emails). Notes say when to use an item. Use the ids with browser_fill_login / browser_fill_card / browser_fill_contact.", + "parameters": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["all", "login", "card", "contact"]}, "query": {"type": "string", "description": "Words to search for"}}}}), + serde_json::json!({"name": TOOL, "description": "Sign in on the current tab of AnyBot's browser with a login from the person's vault (or the macOS Keychain / 1Password). You never see the password; the person approves each use. Open the login page with browser_open first; after filling, press the page's sign-in button (computer_use or ask the person) and check with browser_read.", + "parameters": {"type": "object", "properties": {"login_id": {"type": "string", "description": "From vault_list (default: the saved login for the current site)"}, "site": {"type": "string", "description": "Domain to use (default: the current tab's)"}, "submit": {"type": "boolean", "description": "Also submit the form"}}}}), + serde_json::json!({"name": CARD_TOOL, "description": "Fill a saved payment card into the checkout form on the current tab of AnyBot's browser (card number, expiry, security code, name). You never see the number or security code; the person approves every use. Card fields inside a payment provider's frame may need the person's help. Never submit a payment without the person saying so.", + "parameters": {"type": "object", "properties": {"card_id": {"type": "string", "description": "From vault_list"}}, "required": ["card_id"]}}), + serde_json::json!({"name": CONTACT_TOOL, "description": "Fill saved contact info (name, address, phone, email) into the form on the current tab of AnyBot's browser, e.g. a shipping or billing address.", + "parameters": {"type": "object", "properties": {"contact_id": {"type": "string", "description": "From vault_list"}}, "required": ["contact_id"]}}), + ] +} + +/// vault_list: what the bot may know (no passwords, card numbers or security codes). +pub fn list_for_bot(args: &str) -> String { + use crate::vault_store::Kind; + let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default(); + let kind = v["kind"].as_str().unwrap_or("all"); + let q = v["query"].as_str().unwrap_or("").trim().to_lowercase(); + let items = match crate::vault_store::list() { + Ok(i) => i, + Err(e) => return format!("讀不到保險庫:{e}"), + }; + let rows: Vec = items + .iter() + .filter(|i| match kind { + "login" => i.kind == Kind::Login, + "card" => i.kind == Kind::Card, + "contact" => i.kind == Kind::Contact, + _ => true, + }) + .filter(|i| q.is_empty() || i.haystack().contains(&q)) + .map(|i| match i.kind { + Kind::Login => serde_json::json!({"id": i.id, "kind": "login", "title": i.display_title(), "sites": i.sites, "username": i.username, "notes": i.notes}), + Kind::Card => serde_json::json!({"id": i.id, "kind": "card", "title": i.display_title(), "brand": i.brand, "last4": i.last4, "expiry": format!("{:02}/{}", i.exp_month, i.exp_year % 100), "holder": i.holder, "billing_address": i.billing.one_line(), "notes": i.notes}), + Kind::Contact => serde_json::json!({"id": i.id, "kind": "contact", "label": i.title, "name": i.name, "address": {"line1": i.address.line1, "line2": i.address.line2, "city": i.address.city, "region": i.address.region, "postal_code": i.address.postal, "country": i.address.country}, "phones": i.phones, "emails": i.emails, "notes": i.notes}), + }) + .collect(); + if rows.is_empty() { + return "保險庫裡沒有符合的項目。請使用者到「設定 → 保險庫」新增,或改問使用者。".into(); + } + serde_json::to_string_pretty(&rows).unwrap_or_default() +} + +/// "https://accounts.example.com/login?x" → "accounts.example.com" +pub fn host_of(url: &str) -> String { + let rest = url.split("://").nth(1).unwrap_or(url); + rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase() +} + +/// accounts.google.com → [accounts.google.com, google.com] +pub fn candidates(host: &str) -> Vec { + let parts: Vec<&str> = host.split('.').collect(); + let mut v = vec![host.to_string()]; + for i in 1..parts.len().saturating_sub(1) { + v.push(parts[i..].join(".")); + } + v +} + +pub struct Login { + pub username: String, + pub password: String, + pub source: &'static str, +} + +// ---------- Linux: the desktop keyring (GNOME Keyring / KWallet) through Secret Service ---------- +// Entries are stored with attributes application=anybot, server=, user=. + +fn secret_tool(args: &[&str], input: Option<&str>) -> Option { + use std::io::Write; + let mut c = Command::new("secret-tool"); + // The label has Chinese in it: make sure secret-tool reads its arguments as UTF-8 (C.UTF-8 + // exists everywhere; the person's own locale might not be installed). + c.env("LC_ALL", "C.UTF-8"); + c.args(args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).stdin(if input.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }); + let mut child = c.spawn().ok()?; + if let (Some(text), Some(mut w)) = (input, child.stdin.take()) { + let _ = w.write_all(text.as_bytes()); + } + child.wait_with_output().ok() +} + +/// `secret-tool search --all` blocks → (attributes, secret). +pub fn parse_secret_search(out: &str) -> Vec<(std::collections::HashMap, String)> { + let mut v = vec![]; + for block in out.split("\n[").map(|b| b.trim_start_matches('[')) { + let mut attrs = std::collections::HashMap::new(); + let mut secret = String::new(); + for l in block.lines() { + if let Some((k, val)) = l.split_once(" = ") { + match k.trim() { + "secret" => secret = val.to_string(), + k if k.starts_with("attribute.") => { + attrs.insert(k.trim_start_matches("attribute.").to_string(), val.to_string()); + } + _ => {} + } + } + } + if !attrs.is_empty() { + v.push((attrs, secret)); + } + } + v +} + +fn linux_find(host: &str) -> Option { + let o = secret_tool(&["search", "--all", "--unlock", "application", "anybot", "server", host], None)?; + // secret-tool prints the matches on stdout, or (older versions) on stderr. + let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + let (attrs, secret) = parse_secret_search(&text).into_iter().find(|(_, s)| !s.is_empty())?; + Some(Login { username: attrs.get("user").cloned().unwrap_or_default(), password: secret, source: "系統鑰匙圈" }) +} + +/// One Keychain login by site and account (bringing old AnyBot logins into the vault file). +pub fn keychain_login(site: &str, account: &str) -> Option { + if !cfg!(target_os = "macos") { + return linux_find(site).filter(|l| l.username == account); + } + let out = Command::new("security").args(["find-internet-password", "-s", site, "-a", account, "-g"]).output().ok()?; + if !out.status.success() { + return None; + } + Some(Login { username: account.to_string(), password: parse_g_password(&String::from_utf8_lossy(&out.stderr))?, source: "鑰匙圈" }) +} + +fn keychain(host: &str) -> Option { + if !cfg!(target_os = "macos") { + return linux_find(host); + } + // `-g` prints the attributes on stdout and the password on stderr, either as `"text"` or, + // when it isn't plain ASCII, as `0x "escaped"` (`-w` gives bare hex then, which + // can't be told apart from a password that looks like hex). + let out = Command::new("security").args(["find-internet-password", "-s", host, "-g"]).output().ok()?; + if !out.status.success() { + return None; + } + let meta = String::from_utf8_lossy(&out.stdout).to_string(); + let account = meta.lines().find(|l| l.contains("\"acct\"")).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default(); + let password = parse_g_password(&String::from_utf8_lossy(&out.stderr))?; + Some(Login { username: account, password, source: "鑰匙圈" }) +} + +/// The password line of `security find-…-password -g`. +pub fn parse_g_password(stderr: &str) -> Option { + let line = stderr.lines().find_map(|l| l.strip_prefix("password: ").or_else(|| (l == "password:").then_some("")))?; + if let Some(hex) = line.strip_prefix("0x") { + let hex: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); + let bytes: Option> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()).collect(); + return String::from_utf8(bytes?).ok(); + } + Some(line.strip_prefix('"').and_then(|l| l.strip_suffix('"')).unwrap_or(line).to_string()) +} + +fn onepassword(host: &str) -> Option { + let path = crate::cloudbox::tool_path(); + let list = Command::new("op").env("PATH", &path).args(["item", "list", "--categories", "Login", "--format", "json"]).output().ok()?; + if !list.status.success() { + return None; + } + let items: Value = serde_json::from_slice(&list.stdout).ok()?; + let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_of(u["href"].as_str().unwrap_or("")).ends_with(host)))?["id"].as_str()?.to_string(); + let got = Command::new("op").env("PATH", &path).args(["item", "get", &id, "--fields", "label=username,label=password", "--reveal", "--format", "json"]).output().ok()?; + let fields: Value = serde_json::from_slice(&got.stdout).ok()?; + let arr = fields.as_array()?; + let get = |l: &str| arr.iter().find(|f| f["label"].as_str() == Some(l)).and_then(|f| f["value"].as_str()).unwrap_or("").to_string(); + Some(Login { username: get("username"), password: get("password"), source: "1Password" }) +} + +pub fn find(host: &str) -> Option { + for h in candidates(host) { + if let Some(l) = keychain(&h) { + return Some(l); + } + } + for h in candidates(host) { + if let Some(l) = onepassword(&h) { + return Some(l); + } + } + None +} + +/// Save a login in the macOS Keychain (the person types it in AnyBot's settings). +/// (Kept for the live tests and older callers.) +#[cfg_attr(not(test), allow(dead_code))] +pub fn save(site: &str, username: &str, password: &str) -> Result<(), String> { + let host = host_of(site); + let host = if host.is_empty() { site.trim().to_lowercase() } else { host }; + if host.is_empty() || password.is_empty() { + return Err("請填網站和密碼".into()); + } + if !cfg!(target_os = "macos") { + let label = format!("{LABEL}:{host}"); + let o = secret_tool(&["store", "--label", &label, "application", "anybot", "server", &host, "user", username], Some(password)) + .ok_or("找不到 secret-tool:請安裝 libsecret-tools(系統鑰匙圈的指令)。")?; + return if o.status.success() { Ok(()) } else { Err(format!("存不進系統鑰匙圈:{}", String::from_utf8_lossy(&o.stderr).trim())) }; + } + let out = Command::new("security").args(["add-internet-password", "-U", "-s", &host, "-a", username, "-l", LABEL, "-w", password]).output().map_err(|e| e.to_string())?; + if out.status.success() { + index_add(&host, username); + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +/// AnyBot's list of the logins it saved: site and account only (the passwords stay in the +/// keychain). macOS prints a non-ASCII label like 「AnyBot 登入」 as hex bytes, so the keychain +/// can't be searched by label reliably; this list is the source of truth for what to show. +fn index_path() -> std::path::PathBuf { + crate::model::State::path().parent().map(|d| d.join("vault-index.json")).unwrap_or_default() +} + +fn read_index() -> Option> { + let mut list: Vec<(String, String)> = std::fs::read_to_string(index_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); + // Once: bring in logins AnyBot saved before the list existed. + let marker = index_path().with_file_name("vault-index-imported"); + if cfg!(target_os = "macos") && !marker.exists() { + for l in legacy_keychain_logins() { + if !list.contains(&l) { + list.push(l); + } + } + write_index(&list); + let _ = std::fs::write(&marker, "1"); + } + Some(list) +} + +fn write_index(list: &[(String, String)]) { + let _ = std::fs::write(index_path(), serde_json::to_string_pretty(list).unwrap_or_default()); +} + +/// (Kept for the live tests and older callers.) +#[cfg_attr(not(test), allow(dead_code))] +fn index_add(site: &str, account: &str) { + let mut list = read_index().unwrap_or_default(); + if !list.iter().any(|(s, a)| s == site && a == account) { + list.push((site.to_string(), account.to_string())); + write_index(&list); + } +} + +/// The keychain has this login (no password is read, so no prompt). +fn keychain_has(site: &str, account: &str) -> bool { + Command::new("security").args(["find-internet-password", "-s", site, "-a", account]).output().map(|o| o.status.success()).unwrap_or(false) +} + +/// Logins saved before the index existed: keychain items with AnyBot's label, which +/// `security dump-keychain` prints either as text or as hex bytes. +fn legacy_keychain_logins() -> Vec<(String, String)> { + let Ok(out) = Command::new("security").args(["dump-keychain"]).output() else { return vec![] }; + let text = String::from_utf8_lossy(&out.stdout); + let hex: String = LABEL.bytes().map(|b| format!("{b:02X}")).collect(); + let mut v = vec![]; + for block in text.split("keychain: ") { + if !block.contains(&format!("=\"{LABEL}\"")) && !block.to_uppercase().contains(&format!("0X{hex}")) { + continue; + } + let get = |k: &str| block.lines().find(|l| l.contains(&format!("\"{k}\""))).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default(); + let (s, a) = (get("srvr"), get("acct")); + if !s.is_empty() { + v.push((s, a)); + } + } + v +} + +/// Sites AnyBot saved (names only). +pub fn saved_sites() -> Vec<(String, String)> { + if !cfg!(target_os = "macos") { + let Some(o) = secret_tool(&["search", "--all", "application", "anybot"], None) else { return vec![] }; + let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + return parse_secret_search(&text).into_iter().filter_map(|(a, _)| Some((a.get("server")?.clone(), a.get("user").cloned().unwrap_or_default()))).collect(); + } + let list = read_index().unwrap_or_default(); + // Drop entries someone deleted in 鑰匙圈存取 meanwhile. + let kept: Vec<_> = list.iter().filter(|(s, a)| keychain_has(s, a)).cloned().collect(); + if kept.len() != list.len() { + write_index(&kept); + } + kept +} + +/// (Kept for the live tests and older callers.) +#[cfg_attr(not(test), allow(dead_code))] +pub fn remove(site: &str, account: &str) { + if !cfg!(target_os = "macos") { + let _ = secret_tool(&["clear", "application", "anybot", "server", site, "user", account], None); + return; + } + let _ = Command::new("security").args(["delete-internet-password", "-s", site, "-a", account]).output(); + if let Some(mut list) = read_index() { + list.retain(|(s, a)| !(s == site && a == account)); + write_index(&list); + } +} + +/// The JavaScript that types the login into the page (as a JSON-encoded string literal). +pub fn fill_script(username: &str, password: &str, submit: bool) -> String { + let u = serde_json::to_string(username).unwrap_or_default(); + let p = serde_json::to_string(password).unwrap_or_default(); + format!( + r#"(() => {{ + const set = (el, v) => {{ const d = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); d.set.call(el, v); el.dispatchEvent(new Event('input', {{bubbles: true}})); el.dispatchEvent(new Event('change', {{bubbles: true}})); }}; + const vis = el => el.offsetParent !== null && !el.disabled; + const pw = [...document.querySelectorAll('input[type=password]')].find(vis); + const users = [...document.querySelectorAll('input[type=email],input[type=text],input[autocomplete=username],input[name*=user i],input[name*=mail i],input[id*=user i],input[id*=mail i]')].filter(vis); + let user = null; + if (pw) {{ for (const el of users) {{ if (el.compareDocumentPosition(pw) & Node.DOCUMENT_POSITION_FOLLOWING) user = el; }} }} else {{ user = users[0] || null; }} + if (user && {u}) set(user, {u}); + if (pw) set(pw, {p}); + let submitted = false; + if ({submit} && (pw || user)) {{ + const field = pw || user; + const f = field.form; + // The form's own button; else (pages without a
, like many shops) the nearest visible + // button that says 登入 / Log in / Sign in / 繼續 / 下一步. + let b = f ? (f.querySelector('button[type=submit],input[type=submit]') || f.querySelector('button')) : null; + if (!b) {{ + const words = /登入|登录|log ?in|sign ?in|繼續|继续|下一步|next|continue/i; + const cands = [...document.querySelectorAll('button,input[type=submit],[role=button],a')].filter(vis).filter(el => words.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim())); + const fr = field.getBoundingClientRect(); + cands.sort((a, c) => {{ const ra = a.getBoundingClientRect(), rc = c.getBoundingClientRect(); return (Math.abs(ra.top - fr.top) + Math.abs(ra.left - fr.left)) - (Math.abs(rc.top - fr.top) + Math.abs(rc.left - fr.left)); }}); + b = cands[0] || null; + }} + if (b && !b.disabled) {{ b.click(); submitted = true; }} + else if (f) {{ f.requestSubmit ? f.requestSubmit() : f.submit(); submitted = true; }} + }} + return JSON.stringify({{user: !!user, password: !!pw, submitted}}); +}})()"# + ) +} + +fn args_of(args: &str) -> Value { + serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default() +} + +/// The current tab's host in AnyBot's browser. +fn current_host() -> Result { + let (url, _) = crate::botbrowser::eval_current(None, "1")?; + Ok(host_of(&url)) +} + +/// What browser_fill_login will use, worked out before asking the person. +pub struct LoginPlan { + pub host: String, + /// A vault login (public fields only). + pub item: Option, + /// The vault login is saved for this site (else the person is warned before allowing it). + pub matched: bool, + /// A Keychain / 1Password login (when the vault has none). + other: Option, +} + +impl LoginPlan { + /// The approval question. + pub fn question(&self) -> String { + match &self.item { + Some(i) if self.matched => format!("用保險庫的登入資訊({}{})登入 {}(Bot 看不到密碼)", i.display_title(), if i.username.is_empty() { String::new() } else { format!(" · {}", i.username) }, self.host), + Some(i) => format!( + "也要在 {} 上使用 {} 嗎?(已為 {} 儲存)\n只有在它與已儲存的網站屬於同一服務時才允許。仿冒網站可能會竊取你的密碼。", + self.host, + i.display_title(), + i.sites.join("、") + ), + None => format!("用{}的登入資料({})登入 {}(Bot 看不到密碼)", self.other.as_ref().map(|l| l.source).unwrap_or("已儲存"), self.other.as_ref().map(|l| l.username.as_str()).unwrap_or(""), self.host), + } + } +} + +pub fn plan_login(args: &str) -> Result { + let v = args_of(args); + let tab = current_host()?; + let host = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()).unwrap_or(tab); + if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) { + let item = crate::vault_store::get(id).filter(|i| i.kind == crate::vault_store::Kind::Login).ok_or("保險庫裡沒有這筆登入資訊(先用 vault_list 看 id)")?; + let matched = item.fills_on(&host); + return Ok(LoginPlan { host, item: Some(item), matched, other: None }); + } + if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() { + return Ok(LoginPlan { host, item: Some(item), matched: true, other: None }); + } + match find(&host) { + Some(l) => Ok(LoginPlan { host, item: None, matched: true, other: Some(l) }), + None => Err(format!("找不到「{host}」的登入資料。請使用者在「設定 → 保險庫」新增一筆登入資訊(或存在 macOS 鑰匙圈/1Password)。")), + } +} + +/// Type the planned login into the page (after the person allowed it). +pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result { + let (username, password, source) = match (&plan.item, &plan.other) { + (Some(i), _) => { + let (u, p) = crate::vault_store::login_secret(&i.id).ok_or("保險庫裡沒有這筆登入資訊")?; + if !plan.matched { + crate::vault_store::add_site(&i.id, &plan.host)?; + } + (u, p, "保險庫") + } + (None, Some(l)) => (l.username.clone(), l.password.clone(), l.source), + _ => return Err("沒有可用的登入資料".into()), + }; + let (_, r) = crate::botbrowser::eval_current(None, &fill_script(&username, &password, submit))?; + let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); + if got["password"].as_bool() != Some(true) && got["user"].as_bool() != Some(true) { + return Err("這一頁找不到帳號或密碼欄位。先打開登入頁(有些網站要先按「登入」或先填帳號、按下一步才出現密碼欄)。".into()); + } + Ok(format!( + "已在 {} 填入{}{}(來自{source},你看不到密碼)。{}", + plan.host, + if got["user"].as_bool() == Some(true) { format!("帳號 {username}") } else { String::new() }, + if got["password"].as_bool() == Some(true) { "和密碼" } else { "(這頁沒有密碼欄,可能要先按下一步)" }, + if submit && got["submitted"].as_bool() == Some(true) { + "也已按下登入,請用 browser_read 確認是否登入成功。如果還在登入頁(常見原因:需要驗證碼、簡訊碼或人機驗證),請使用者在 AnyBot 瀏覽器視窗裡完成,之後會記住登入。" + } else if submit { + "但頁面上找不到登入按鈕,沒有送出。請用 computer_use 按登入按鈕,或請使用者按。" + } else { + "接著按頁面上的登入按鈕。" + } + )) +} + + +/// JavaScript that fills a form's fields by their autocomplete hints and common names. +/// `values` maps a field role to its value; the page gets them as one JSON string literal. +pub fn form_fill_script(values: &Value) -> String { + let data = serde_json::to_string(&values.to_string()).unwrap_or_default(); + format!( + r#"(() => {{ + const V = JSON.parse({data}); + const vis = el => el.offsetParent !== null && !el.disabled && !el.readOnly; + const set = (el, v) => {{ + if (el.tagName === 'SELECT') {{ + const want = String(v).toLowerCase(); + const opt = [...el.options].find(o => o.value.toLowerCase() === want || o.text.trim().toLowerCase() === want || (want.length > 1 && o.text.trim().toLowerCase().startsWith(want))); + if (!opt) return false; + el.value = opt.value; + }} else {{ + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, String(v)); + }} + el.dispatchEvent(new Event('input', {{bubbles: true}})); + el.dispatchEvent(new Event('change', {{bubbles: true}})); + return true; + }}; + const rules = {{ + 'cc-number': [/card.?num|cc.?num|cardnumber|ccnumber|卡號/i], + 'cc-exp': [/^(cc-?)?exp(iry|iration)?(-?date)?$|mm.?\/?.?yy|有效期/i], + 'cc-exp-month': [/exp.*month|cc-?month|card.?month|月/i], + 'cc-exp-year': [/exp.*year|cc-?year|card.?year|年/i], + 'cc-csc': [/cvc|cvv|csc|security.?code|card.?code|安全碼|驗證碼/i], + 'cc-name': [/name.?on.?card|card.?holder|holder.?name|cc-?name|持卡人/i], + 'name': [/^(full.?)?name$|姓名|收件人/i], + 'address-line1': [/address.?(line)?.?1|street|^address$|地址/i], + 'address-line2': [/address.?(line)?.?2|apt|suite|unit/i], + 'address-level2': [/city|town|城市|縣市/i], + 'address-level1': [/state|province|region|county|州|省/i], + 'postal-code': [/zip|postal|postcode|郵遞區號|郵遞/i], + 'country': [/country|國家/i], + 'tel': [/phone|mobile|tel|手機|電話/i], + 'email': [/e-?mail|電子郵件/i], + }}; + const fields = [...document.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=password]),select,textarea')].filter(vis); + const used = new Set(); + const filled = []; + for (const role of Object.keys(V)) {{ + if (V[role] === '' || V[role] == null) continue; + let el = fields.find(f => !used.has(f) && (f.autocomplete || '').toLowerCase().split(/\s+/).includes(role)); + if (!el) {{ + const pats = rules[role] || []; + el = fields.find(f => {{ const ac = (f.autocomplete || '').toLowerCase(); return !used.has(f) && (ac === '' || ac === 'on' || ac === 'off' || ac.startsWith('cc-') === role.startsWith('cc-')) && pats.some(p => p.test([f.type === 'text' ? '' : f.type, f.name, f.id, f.placeholder, f.getAttribute('aria-label') || '', (f.labels && f.labels[0] ? f.labels[0].innerText : '')].join(' '))); }}); + }} + if (el && set(el, V[role])) {{ used.add(el); filled.push(role); }} + }} + const frames = document.querySelectorAll('iframe').length; + return JSON.stringify({{filled, frames}}); +}})()"# + ) +} + +/// Names for the roles, as the bot is told what was filled. +fn role_name(r: &str) -> &'static str { + match r { + "cc-number" => "卡號", + "cc-exp" | "cc-exp-month" | "cc-exp-year" => "有效期限", + "cc-csc" => "安全碼", + "cc-name" => "持卡人姓名", + "name" => "姓名", + "address-line1" | "address-line2" => "地址", + "address-level2" => "城市", + "address-level1" => "州/省/地區", + "postal-code" => "郵遞區號", + "country" => "國家/地區", + "tel" => "電話", + "email" => "電子郵件", + _ => "欄位", + } +} + +fn filled_summary(r: &Value) -> (Vec<&'static str>, u64) { + let mut names: Vec<&'static str> = r["filled"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(role_name).collect(); + names.dedup(); + (names, r["frames"].as_u64().unwrap_or(0)) +} + +/// For the approval question: (current host, card label) without secrets. +pub fn plan_card(args: &str) -> Result<(String, crate::vault_store::Item), String> { + let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string(); + let card = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Card).ok_or("保險庫裡沒有這張卡片(先用 vault_list 看 id)")?; + Ok((current_host()?, card)) +} + +pub fn fill_card(args: &str) -> Result { + let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string(); + let c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?; + let values = serde_json::json!({ + "cc-number": c.number, + "cc-exp": format!("{:02}/{:02}", c.exp_month, c.exp_year % 100), + "cc-exp-month": format!("{:02}", c.exp_month), + "cc-exp-year": c.exp_year.to_string(), + "cc-csc": c.csc, + "cc-name": c.holder, + }); + let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?; + let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); + let (names, frames) = filled_summary(&got); + if names.is_empty() { + return Err(if frames > 0 { + "這一頁的付款欄位在付款服務的框架裡,AnyBot 填不進去。請使用者自己填卡號,或改用網站提供的其他付款方式。".into() + } else { + "這一頁找不到付款欄位。先打開結帳頁的付款步驟。".into() + }); + } + Ok(format!("已填入 {} •••• {} 的{}(你看不到卡號和安全碼)。送出付款前一定要先問使用者。", c.display_title(), c.last4, names.join("、"))) +} + +pub fn plan_contact(args: &str) -> Result<(String, crate::vault_store::Item), String> { + let id = args_of(args)["contact_id"].as_str().unwrap_or("").to_string(); + let c = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Contact).ok_or("保險庫裡沒有這筆聯絡資訊(先用 vault_list 看 id)")?; + Ok((current_host()?, c)) +} + +pub fn fill_contact(args: &str) -> Result { + let (_, c) = plan_contact(args)?; + let values = serde_json::json!({ + "name": c.name, "address-line1": c.address.line1, "address-line2": c.address.line2, "address-level2": c.address.city, + "address-level1": c.address.region, "postal-code": c.address.postal, "country": c.address.country, + "tel": c.phones.first().cloned().unwrap_or_default(), "email": c.emails.first().cloned().unwrap_or_default(), + }); + let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?; + let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); + let (names, _) = filled_summary(&got); + if names.is_empty() { + return Err("這一頁找不到地址或聯絡欄位。".into()); + } + Ok(format!("已填入「{}」的{}。請用 browser_read 確認。", c.display_title(), names.join("、"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts_and_candidates() { + assert_eq!(host_of("https://accounts.Google.com/signin?x=1"), "accounts.google.com"); + assert_eq!(host_of("https://me:pw@example.com:8443/a"), "example.com"); + assert_eq!(candidates("a.b.example.com"), vec!["a.b.example.com", "b.example.com", "example.com"]); + let found = parse_secret_search("[/org/freedesktop/secrets/collection/login/7]\nlabel = AnyBot 登入:x.com\nsecret = hunter2\ncreated = 2026-11-04\nattribute.application = anybot\nattribute.server = x.com\nattribute.user = me\n[/org/freedesktop/secrets/collection/login/8]\nsecret = pw2\nattribute.server = y.com\n"); + assert_eq!(found.len(), 2); + assert_eq!(found[0].1, "hunter2"); + assert_eq!(found[0].0["user"], "me"); + assert_eq!(found[1].0["server"], "y.com"); + } + + #[test] + fn keychain_password_lines() { + assert_eq!(parse_g_password("password: \"hunter2\"\n").unwrap(), "hunter2"); + assert_eq!(parse_g_password("password: \"a\"b\"\n").unwrap(), "a\"b"); + assert_eq!(parse_g_password("password: 0x706122737320E4BDA0E5A5BD \"pa\\\"ss \\344\\275\\240\\345\\245\\275\"\n").unwrap(), "pa\"ss 你好"); + assert_eq!(parse_g_password("password: \n").unwrap(), ""); + assert!(parse_g_password("nothing").is_none()); + } + + #[test] + fn the_script_keeps_the_password_in_a_string() { + let js = fill_script("me@x.com", "p\"w'", true); + assert!(js.contains(r#""p\"w'""#)); + assert!(js.contains("input[type=password]") && js.contains("if (true && (pw || user))")); + } + + /// Needs a keyring: on Linux run under `dbus-run-session` with gnome-keyring unlocked. + #[test] + #[ignore] + fn keyring_live() { + save("https://login.example.org/x", "me@example.org", "s3cret pass").unwrap(); + let l = find("login.example.org").expect("found"); + assert_eq!((l.username.as_str(), l.password.as_str()), ("me@example.org", "s3cret pass")); + assert!(saved_sites().iter().any(|(s, a)| s == "login.example.org" && a == "me@example.org")); + // Logins from before the list existed (label printed as hex by `security`) show up too. + if cfg!(target_os = "macos") { + assert!(saved_sites().iter().any(|(s, _)| s == "legacy.example.org"), "legacy entry: {:?}", saved_sites()); + } + remove("login.example.org", "me@example.org"); + assert!(find("login.example.org").is_none()); + } + + /// The whole bot-side flow on this computer: local pages in AnyBot's browser (a throwaway + /// profile), items in a scratch vault, and the fill tools. Run with ANYBOT_DATA_DIR set to a + /// scratch folder; opens a browser window for a moment. + #[test] + #[ignore] + fn fill_live() { + use crate::vault_store as store; + use std::io::{Read, Write}; + let dir = crate::model::State::path().parent().unwrap().to_path_buf(); + std::env::set_var("ANYBOT_VAULT_DIR", &dir); + std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("vault-test.key")); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for mut c in listener.incoming().flatten() { + let mut buf = [0u8; 2048]; + let n = c.read(&mut buf).unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]); + let page = if req.starts_with("GET /checkout") { + "" + } else { + "
" + }; + let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len()); + } + }); + crate::botbrowser::add("vault-test").unwrap(); + let run = || -> Result<(), String> { + let login = store::put_login(store::LoginInput { id: None, sites: vec!["127.0.0.1".into()], username: "tester@example.org".into(), password: "pa\"ss 你好".into(), notes: String::new() })?; + let card = store::put_card(store::CardInput { id: None, number: "4111111111111111".into(), expiry: "08/39".into(), csc: "123".into(), holder: "WANG XIAOMING".into(), nickname: "測試".into(), billing: store::Address::default(), notes: String::new() })?; + let contact = store::put_contact(store::ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: store::Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec![], notes: String::new() })?; + // What the bot sees has no secrets. + let seen = list_for_bot("{}"); + assert!(seen.contains("tester@example.org") && seen.contains("1111") && !seen.contains("4111111111111111") && !seen.contains("pa\\\"ss") && !seen.contains("123\""), "{seen}"); + crate::botbrowser::open(&format!("http://127.0.0.1:{port}/login"), Some("vault-test"))?; + let plan = plan_login("{}")?; + assert!(plan.matched && plan.item.as_ref().map(|i| i.id == login).unwrap_or(false)); + let said = fill_login(&plan, false)?; + assert!(!said.contains("pa\"ss"), "{said}"); + let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([e.value, p.value])")?; + assert_eq!(got.as_str().unwrap(), r#"["tester@example.org","pa\"ss 你好"]"#); + crate::botbrowser::open(&format!("http://127.0.0.1:{port}/checkout"), Some("vault-test"))?; + let said = fill_card(&format!("{{\"card_id\":\"{card}\"}}"))?; + assert!(!said.contains("4111111111111111") && !said.contains("123"), "{said}"); + fill_contact(&format!("{{\"contact_id\":\"{contact}\"}}"))?; + let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([n.value,a1.value,c.value,z.value,t.value,cc.value,ex.value,cv.value,hn.value])")?; + assert_eq!(got.as_str().unwrap(), r#"["王小明","信義路五段 7 號","台北市","110","0912345678","4111111111111111","08/39","123","WANG XIAOMING"]"#); + Ok(()) + }; + let r = run(); + let _ = crate::botbrowser::remove("vault-test"); + let _ = std::fs::remove_file(dir.join("vault-test.key")); + let _ = std::fs::remove_file(store::file_path()); + r.unwrap(); + } +} diff --git a/src/vault_store.rs b/src/vault_store.rs new file mode 100644 index 0000000..273d418 --- /dev/null +++ b/src/vault_store.rs @@ -0,0 +1,1051 @@ +// 保險庫 storage: logins, cards and contacts in one encrypted file on this computer +// (vault.bin = 12-byte nonce + AES-256-GCM of the JSON). The key is random and lives in the +// system keychain (macOS Keychain / Linux Secret Service); without one, a 0600 key file. +// +// Like the original (whose vault is server-side and never sends secrets back), secrets are +// write-only: `list()` returns items with passwords, card numbers and security codes blanked. +// Only the fill step (vault.rs, typing into AnyBot's browser after the person allows it) reads +// them, through `login_secret` / `card_secret`. + +use ring::aead; +use ring::rand::{SecureRandom, SystemRandom}; +use serde::{Deserialize, Serialize}; +use std::process::Command; + +pub const MAX_SITES: usize = 10; +pub const MAX_NOTES: usize = 2000; +pub const MAX_TITLE: usize = 200; +pub const MAX_USERNAME: usize = 512; +pub const MAX_PASSWORD_BYTES: usize = 4096; +pub const MAX_ROWS: usize = 10; +pub const MAX_IMPORT_BYTES: u64 = 5 * 1024 * 1024; + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Kind { + #[default] + Login, + Card, + Contact, +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] +pub struct Address { + #[serde(default)] + pub line1: String, + #[serde(default)] + pub line2: String, + #[serde(default)] + pub city: String, + #[serde(default)] + pub region: String, + #[serde(default)] + pub postal: String, + #[serde(default)] + pub country: String, +} + +impl Address { + pub fn is_empty(&self) -> bool { + [&self.line1, &self.line2, &self.city, &self.region, &self.postal, &self.country].iter().all(|s| s.trim().is_empty()) + } + pub fn one_line(&self) -> String { + [&self.line1, &self.line2, &self.city, &self.region, &self.postal, &self.country].iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::>().join(", ") + } +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +pub struct Item { + pub id: String, + pub kind: Kind, + /// Login: the site's name; card: 暱稱 or brand; contact: 標籤. + #[serde(default)] + pub title: String, + /// 提示: tells the bot when to use this item. + #[serde(default)] + pub notes: String, + #[serde(default)] + pub created_ms: i64, + #[serde(default)] + pub updated_ms: i64, + // Login + #[serde(default)] + pub sites: Vec, + #[serde(default)] + pub username: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub password: String, + // Card (number and csc are secret; brand/last4 are what anyone sees) + #[serde(default, skip_serializing_if = "String::is_empty")] + pub number: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub csc: String, + #[serde(default)] + pub brand: String, + #[serde(default)] + pub last4: String, + #[serde(default)] + pub exp_month: u32, + #[serde(default)] + pub exp_year: u32, + #[serde(default)] + pub holder: String, + #[serde(default)] + pub nickname: String, + #[serde(default)] + pub billing: Address, + // Contact + #[serde(default)] + pub name: String, + #[serde(default)] + pub address: Address, + #[serde(default)] + pub phones: Vec, + #[serde(default)] + pub emails: Vec, +} + +impl Item { + /// The same item without its secrets. + pub fn public(&self) -> Item { + Item { password: String::new(), number: String::new(), csc: String::new(), ..self.clone() } + } + + /// What a row shows as its name. + pub fn display_title(&self) -> String { + match self.kind { + Kind::Login => { + if !self.title.trim().is_empty() { + self.title.clone() + } else { + self.sites.first().map(|s| registrable_domain(&host_of(s))).unwrap_or_default() + } + } + Kind::Card => { + if !self.nickname.trim().is_empty() { + self.nickname.clone() + } else if !self.title.trim().is_empty() { + self.title.clone() + } else { + self.brand.clone() + } + } + Kind::Contact => { + if !self.title.trim().is_empty() { + self.title.clone() + } else { + self.name.clone() + } + } + } + } + + /// The line under the title. + pub fn subtitle(&self) -> String { + match self.kind { + Kind::Login => self.username.clone(), + Kind::Card => format!("{} •••• {}", self.brand, self.last4).trim().to_string(), + Kind::Contact => [self.name.clone(), self.address.one_line(), self.phones.first().cloned().unwrap_or_default()].into_iter().filter(|s| !s.is_empty()).collect::>().join(" · "), + } + } + + /// Lowercased text the search box matches. + pub fn haystack(&self) -> String { + let mut v = vec![self.display_title(), self.notes.clone(), self.username.clone(), self.name.clone(), self.nickname.clone(), self.holder.clone(), self.brand.clone(), self.last4.clone(), self.address.one_line()]; + v.extend(self.sites.iter().cloned()); + v.extend(self.phones.iter().cloned()); + v.extend(self.emails.iter().cloned()); + v.join("\n").to_lowercase() + } + + /// This login fills on `host` (its sites' registrable domains and their subdomains). + pub fn fills_on(&self, host: &str) -> bool { + let h = host.trim().to_lowercase(); + let h = h.strip_prefix("www.").unwrap_or(&h); + self.sites.iter().any(|s| { + let d = registrable_domain(&host_of(s)); + !d.is_empty() && (h == d || h.ends_with(&format!(".{d}"))) + }) + } +} + +#[derive(Serialize, Deserialize, Default, Clone)] +pub struct Store { + #[serde(default)] + pub items: Vec, + /// AnyBot's old Keychain logins were brought in. + #[serde(default)] + pub imported_keychain: bool, +} + +// ---------- helpers ---------- + +pub fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +pub fn new_id() -> String { + let mut b = [0u8; 8]; + let _ = SystemRandom::new().fill(&mut b); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// "https://Accounts.Example.com/login" or "example.com" → "accounts.example.com". +pub fn host_of(s: &str) -> String { + let s = s.trim(); + let rest = s.split("://").nth(1).unwrap_or(s); + let h = rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase(); + h.strip_prefix("www.").map(String::from).unwrap_or(h) +} + +/// Second-level suffixes where the registrable domain has three labels. +const TWO_PART_SUFFIXES: &[&str] = &[ + "com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk", + "com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn", "github.io", "vercel.app", "netlify.app", +]; + +/// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are. +pub fn registrable_domain(host: &str) -> String { + let h = host.trim().trim_end_matches('.').to_lowercase(); + if h.parse::().is_ok() { + return h; + } + let parts: Vec<&str> = h.split('.').filter(|p| !p.is_empty()).collect(); + if parts.len() <= 2 { + return parts.join("."); + } + let last2 = parts[parts.len() - 2..].join("."); + let n = if TWO_PART_SUFFIXES.contains(&last2.as_str()) { 3 } else { 2 }; + parts[parts.len() - n.min(parts.len())..].join(".") +} + +pub fn digits(s: &str) -> String { + s.chars().filter(|c| c.is_ascii_digit()).collect() +} + +pub fn luhn_ok(number: &str) -> bool { + let d = digits(number); + if d.len() < 12 || d.len() > 19 { + return false; + } + let sum: u32 = d.chars().rev().enumerate().map(|(i, c)| { + let mut n = c.to_digit(10).unwrap_or(0); + if i % 2 == 1 { + n *= 2; + if n > 9 { + n -= 9; + } + } + n + }).sum(); + sum % 10 == 0 +} + +pub fn card_brand(number: &str) -> &'static str { + let d = digits(number); + let p2: u32 = d.get(..2).and_then(|x| x.parse().ok()).unwrap_or(0); + let p4: u32 = d.get(..4).and_then(|x| x.parse().ok()).unwrap_or(0); + if d.starts_with('4') { + "Visa" + } else if (51..=55).contains(&p2) || (2221..=2720).contains(&p4) { + "Mastercard" + } else if p2 == 34 || p2 == 37 { + "American Express" + } else if d.starts_with("6011") || d.starts_with("65") || (644..=649).contains(&(p4 / 10)) { + "Discover" + } else if (3528..=3589).contains(&p4) { + "JCB" + } else if d.starts_with("62") { + "UnionPay" + } else if p2 == 36 || p2 == 38 || (300..=305).contains(&(p4 / 10)) { + "Diners Club" + } else { + "卡片" + } +} + +/// "MM/YY", "MM/YYYY", "MM-YY" → (month, 4-digit year). +pub fn parse_expiry(s: &str) -> Option<(u32, u32)> { + let parts: Vec<&str> = s.split(['/', '-', ' ', '.']).filter(|p| !p.is_empty()).collect(); + let (m, y) = match parts.as_slice() { + [m, y] => (*m, *y), + [one] if one.len() == 4 => (&one[..2], &one[2..]), + _ => return None, + }; + let m: u32 = m.trim().parse().ok()?; + let mut y: u32 = y.trim().parse().ok()?; + if y < 100 { + y += 2000; + } + ((1..=12).contains(&m) && (2000..=2099).contains(&y)).then_some((m, y)) +} + +pub fn expired(month: u32, year: u32) -> bool { + use chrono::Datelike; + let now = chrono::Local::now(); + (year as i32, month) < (now.year(), now.month()) +} + +pub fn valid_email(s: &str) -> bool { + let s = s.trim(); + let Some((a, b)) = s.split_once('@') else { return false }; + !a.is_empty() && b.contains('.') && !b.starts_with('.') && !b.ends_with('.') && !s.contains(' ') +} + +pub fn valid_phone(s: &str) -> bool { + let d = digits(s); + (6..=16).contains(&d.len()) && s.chars().all(|c| c.is_ascii_digit() || " +-().#".contains(c)) +} + +// ---------- the key and the file ---------- + +const KEY_SERVICE: &str = "AnyBot vault key"; + +fn data_dir() -> std::path::PathBuf { + if let Ok(d) = std::env::var("ANYBOT_VAULT_DIR") { + return std::path::PathBuf::from(d); + } + crate::model::State::path().parent().map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir) +} + +pub fn file_path() -> std::path::PathBuf { + data_dir().join("vault.bin") +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex(s: &str) -> Option> { + let s = s.trim(); + (s.len() % 2 == 0).then_some(())?; + (0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()).collect() +} + +/// Read the key from wherever it lives (None = no key yet). +fn read_key() -> Option> { + if let Ok(f) = std::env::var("ANYBOT_VAULT_KEY_FILE") { + return unhex(&std::fs::read_to_string(f).ok()?); + } + #[cfg(target_os = "macos")] + { + let o = Command::new("security").args(["find-generic-password", "-s", KEY_SERVICE, "-a", "anybot", "-w"]).output().ok()?; + if o.status.success() { + return unhex(&String::from_utf8_lossy(&o.stdout)); + } + } + #[cfg(target_os = "linux")] + { + let o = Command::new("secret-tool").env("LC_ALL", "C.UTF-8").args(["lookup", "application", "anybot", "kind", "vault-key"]).output().ok(); + if let Some(o) = o.filter(|o| o.status.success() && !o.stdout.is_empty()) { + return unhex(&String::from_utf8_lossy(&o.stdout)); + } + } + let f = data_dir().join("vault.key"); + unhex(&std::fs::read_to_string(f).ok()?) +} + +fn store_key(key: &[u8]) -> Result<(), String> { + let h = hex(key); + if let Ok(f) = std::env::var("ANYBOT_VAULT_KEY_FILE") { + return std::fs::write(f, h).map_err(|e| e.to_string()); + } + #[cfg(target_os = "macos")] + { + let o = Command::new("security").args(["add-generic-password", "-U", "-s", KEY_SERVICE, "-a", "anybot", "-l", KEY_SERVICE, "-w", &h]).output().map_err(|e| e.to_string())?; + if o.status.success() { + return Ok(()); + } + } + #[cfg(target_os = "linux")] + { + use std::io::Write; + if let Ok(mut c) = Command::new("secret-tool").env("LC_ALL", "C.UTF-8").args(["store", "--label", KEY_SERVICE, "application", "anybot", "kind", "vault-key"]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { + let ok = c.stdin.take().map(|mut s| s.write_all(h.as_bytes()).is_ok()).unwrap_or(false); + if ok && c.wait().map(|s| s.success()).unwrap_or(false) { + return Ok(()); + } + } + } + // No keychain (Windows for now, or a Linux box without one): a file only this user can read. + let f = data_dir().join("vault.key"); + let _ = std::fs::create_dir_all(data_dir()); + std::fs::write(&f, h).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +fn key(create: bool) -> Result { + let k = match read_key() { + Some(k) if k.len() == 32 => k, + _ if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()), + _ => { + let mut k = vec![0u8; 32]; + SystemRandom::new().fill(&mut k).map_err(|_| "產生金鑰失敗")?; + store_key(&k)?; + k + } + }; + let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, &k).map_err(|_| "金鑰格式不對")?; + Ok(aead::LessSafeKey::new(unbound)) +} + +static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// The whole store (decrypted, secrets included) — only for this module and the fill step. +fn load() -> Result { + if let Some(s) = CACHE.lock().unwrap().as_ref() { + return Ok(s.clone()); + } + let path = file_path(); + let store = if !path.exists() { + Store::default() + } else { + let mut bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + if bytes.len() < 12 { + return Err("保險庫檔案損壞".into()); + } + let k = key(false)?; + let nonce = aead::Nonce::try_assume_unique_for_key(&bytes[..12]).map_err(|_| "保險庫檔案損壞")?; + let mut body = bytes.split_off(12); + let plain = k.open_in_place(nonce, aead::Aad::from(b"anybot-vault-v1"), &mut body).map_err(|_| "保險庫解不開(金鑰不符)")?; + serde_json::from_slice(plain).map_err(|e| e.to_string())? + }; + *CACHE.lock().unwrap() = Some(store.clone()); + Ok(store) +} + +fn save(store: &Store) -> Result<(), String> { + let k = key(true)?; + let mut nonce = [0u8; 12]; + SystemRandom::new().fill(&mut nonce).map_err(|_| "產生亂數失敗")?; + let mut body = serde_json::to_vec(store).map_err(|e| e.to_string())?; + k.seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::from(b"anybot-vault-v1"), &mut body).map_err(|_| "加密失敗")?; + let mut out = nonce.to_vec(); + out.extend(body); + let _ = std::fs::create_dir_all(data_dir()); + let tmp = file_path().with_extension("bin.tmp"); + std::fs::write(&tmp, &out).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); + } + std::fs::rename(&tmp, file_path()).map_err(|e| e.to_string())?; + *CACHE.lock().unwrap() = Some(store.clone()); + Ok(()) +} + +fn edit(f: impl FnOnce(&mut Store) -> Result) -> Result { + let mut s = load()?; + let r = f(&mut s)?; + save(&s)?; + Ok(r) +} + +/// Bring in logins AnyBot saved in the Keychain before the vault file existed (once). +fn import_keychain_once() { + let Ok(s) = load() else { return }; + if s.imported_keychain { + return; + } + let old = crate::vault::saved_sites(); + let mut logins = vec![]; + for (site, account) in old { + if let Some(l) = crate::vault::keychain_login(&site, &account) { + logins.push((site, l.username, l.password)); + } + } + let _ = edit(|s| { + for (site, user, pw) in logins { + let dup = s.items.iter().any(|i| i.kind == Kind::Login && i.fills_on(&site) && i.username.eq_ignore_ascii_case(&user)); + if !dup { + let t = now_ms(); + s.items.push(Item { id: new_id(), kind: Kind::Login, title: registrable_domain(&site), sites: vec![site], username: user, password: pw, created_ms: t, updated_ms: t, ..Default::default() }); + } + } + s.imported_keychain = true; + Ok(()) + }); +} + +// ---------- what the window and the bot use ---------- + +/// Every item without secrets, newest first. +pub fn list() -> Result, String> { + import_keychain_once(); + let mut v: Vec = load()?.items.iter().map(Item::public).collect(); + v.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); + Ok(v) +} + +pub fn get(id: &str) -> Option { + load().ok()?.items.iter().find(|i| i.id == id).map(Item::public) +} + +pub struct LoginInput { + pub id: Option, + pub sites: Vec, + pub username: String, + /// Empty when editing = keep the saved password. + pub password: String, + pub notes: String, +} + +/// Check and save a login; returns its id. +pub fn put_login(input: LoginInput) -> Result { + let sites: Vec = input.sites.iter().map(|s| host_of(s)).filter(|s| !s.is_empty()).collect(); + if sites.is_empty() { + return Err("請至少填一個網站".into()); + } + if sites.len() > MAX_SITES { + return Err(format!("網站最多 {MAX_SITES} 個")); + } + if input.username.chars().count() > MAX_USERNAME || input.notes.chars().count() > MAX_NOTES || input.password.len() > MAX_PASSWORD_BYTES { + return Err("某個欄位過長".into()); + } + edit(|s| { + let t = now_ms(); + let title = registrable_domain(&sites[0]); + match input.id.as_ref().and_then(|id| s.items.iter_mut().find(|i| &i.id == id)) { + Some(it) => { + it.sites = sites; + it.username = input.username.trim().to_string(); + if !input.password.is_empty() { + it.password = input.password; + } + it.notes = input.notes; + it.title = title; + it.updated_ms = t; + Ok(it.id.clone()) + } + None => { + if input.password.is_empty() { + return Err("請填密碼".into()); + } + let id = new_id(); + s.items.push(Item { id: id.clone(), kind: Kind::Login, title, sites, username: input.username.trim().to_string(), password: input.password, notes: input.notes, created_ms: t, updated_ms: t, ..Default::default() }); + Ok(id) + } + } + }) +} + +/// Add another site to a login (the person allowed it on a new domain). +pub fn add_site(id: &str, site: &str) -> Result<(), String> { + let host = host_of(site); + edit(|s| { + let it = s.items.iter_mut().find(|i| i.id == id).ok_or("找不到這筆登入資訊")?; + if !it.fills_on(&host) && it.sites.len() < MAX_SITES { + it.sites.push(registrable_domain(&host)); + it.updated_ms = now_ms(); + } + Ok(()) + }) +} + +pub struct CardInput { + pub id: Option, + /// New cards only (the number is locked once saved). + pub number: String, + pub expiry: String, + pub csc: String, + pub holder: String, + pub nickname: String, + pub billing: Address, + pub notes: String, +} + +pub fn put_card(input: CardInput) -> Result { + let (m, y) = parse_expiry(&input.expiry).ok_or("有效期限要寫成 月 / 年,例如 08/29")?; + if input.id.is_none() && expired(m, y) { + return Err("卡片已過期".into()); + } + if input.holder.chars().count() > MAX_TITLE || input.nickname.chars().count() > MAX_TITLE || input.notes.chars().count() > MAX_NOTES { + return Err("某個欄位過長".into()); + } + edit(|s| { + let t = now_ms(); + match input.id.as_ref() { + Some(id) => { + let it = s.items.iter_mut().find(|i| &i.id == id).ok_or("找不到這張卡片")?; + it.exp_month = m; + it.exp_year = y; + it.holder = input.holder.trim().to_string(); + it.nickname = input.nickname.trim().to_string(); + it.billing = input.billing; + it.notes = input.notes; + it.updated_ms = t; + Ok(it.id.clone()) + } + None => { + let number = digits(&input.number); + if !luhn_ok(&number) { + return Err("請輸入有效的卡號".into()); + } + let csc = digits(&input.csc); + if !csc.is_empty() && !(3..=4).contains(&csc.len()) { + return Err("安全碼是 3 或 4 位數字".into()); + } + if s.items.iter().any(|i| i.kind == Kind::Card && i.number == number) { + return Err("這張卡片已在你的保險庫中".into()); + } + let id = new_id(); + let brand = card_brand(&number).to_string(); + s.items.push(Item { + id: id.clone(), + kind: Kind::Card, + title: brand.clone(), + last4: number[number.len() - 4..].to_string(), + brand, + number, + csc, + exp_month: m, + exp_year: y, + holder: input.holder.trim().to_string(), + nickname: input.nickname.trim().to_string(), + billing: input.billing, + notes: input.notes, + created_ms: t, + updated_ms: t, + ..Default::default() + }); + Ok(id) + } + } + }) +} + +pub struct ContactInput { + pub id: Option, + pub label: String, + pub name: String, + pub address: Address, + pub phones: Vec, + pub emails: Vec, + pub notes: String, +} + +pub fn put_contact(input: ContactInput) -> Result { + let phones: Vec = input.phones.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect(); + let emails: Vec = input.emails.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect(); + if let Some(p) = phones.iter().find(|p| !valid_phone(p)) { + return Err(format!("請輸入有效的電話號碼:{p}")); + } + if let Some(e) = emails.iter().find(|e| !valid_email(e)) { + return Err(format!("請輸入有效的電子郵件地址:{e}")); + } + if phones.len() > MAX_ROWS || emails.len() > MAX_ROWS { + return Err(format!("電話和電子郵件各最多 {MAX_ROWS} 個")); + } + if input.name.trim().is_empty() && input.address.is_empty() && phones.is_empty() && emails.is_empty() { + return Err("請至少填姓名、地址、電話或電子郵件其中一項".into()); + } + edit(|s| { + let t = now_ms(); + let it = Item { kind: Kind::Contact, title: input.label.trim().to_string(), name: input.name.trim().to_string(), address: input.address, phones, emails, notes: input.notes, updated_ms: t, ..Default::default() }; + match input.id.as_ref().and_then(|id| s.items.iter_mut().find(|i| &i.id == id)) { + Some(old) => { + *old = Item { id: old.id.clone(), created_ms: old.created_ms, ..it }; + Ok(old.id.clone()) + } + None => { + let id = new_id(); + s.items.push(Item { id: id.clone(), created_ms: t, ..it }); + Ok(id) + } + } + }) +} + +pub fn remove(id: &str) -> Result<(), String> { + edit(|s| { + s.items.retain(|i| i.id != id); + Ok(()) + }) +} + +/// For the fill step only: (username, password). +pub fn login_secret(id: &str) -> Option<(String, String)> { + load().ok()?.items.into_iter().find(|i| i.id == id && i.kind == Kind::Login).map(|i| (i.username, i.password)) +} + +/// For the fill step only: the whole card. +pub fn card_secret(id: &str) -> Option { + load().ok()?.items.into_iter().find(|i| i.id == id && i.kind == Kind::Card) +} + +/// Logins that fill on `host`, newest first (no secrets). +pub fn logins_for(host: &str) -> Vec { + list().unwrap_or_default().into_iter().filter(|i| i.kind == Kind::Login && i.fills_on(host)).collect() +} + +/// For tests: forget the cached copy. +#[cfg(test)] +pub fn reset_cache() { + *CACHE.lock().unwrap() = None; +} + +// ---------- CSV import ---------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CsvFormat { + OnePassword, + Chrome, + Safari, + Firefox, + Bitwarden, + LastPass, +} + +impl CsvFormat { + pub fn banner(self) -> &'static str { + match self { + CsvFormat::OnePassword => "偵測到 1Password 匯出檔案", + CsvFormat::Chrome => "偵測到 Chrome 或 Edge 匯出檔案", + CsvFormat::Safari => "偵測到 Safari 或 Apple Passwords 匯出檔案", + CsvFormat::Firefox => "偵測到 Firefox 匯出檔案", + CsvFormat::Bitwarden => "偵測到 Bitwarden 匯出檔案", + CsvFormat::LastPass => "偵測到 LastPass 匯出檔案", + } + } +} + +/// RFC 4180 CSV: quoted fields, doubled quotes, newlines inside quotes. +pub fn parse_csv(text: &str) -> Vec> { + let text = text.trim_start_matches('\u{feff}'); + let mut rows = vec![]; + let mut row = vec![]; + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + field.push('"'); + chars.next(); + } else { + quoted = false; + } + } else { + field.push(c); + } + continue; + } + match c { + '"' => quoted = true, + ',' => row.push(std::mem::take(&mut field)), + '\r' => {} + '\n' => { + row.push(std::mem::take(&mut field)); + if !(row.len() == 1 && row[0].is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + c => field.push(c), + } + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + rows.push(row); + } + rows +} + +pub fn detect_format(header: &[String]) -> Option { + let h: Vec = header.iter().map(|s| s.trim().to_lowercase()).collect(); + let has = |k: &str| h.iter().any(|x| x == k); + if has("login_uri") && has("login_username") && has("login_password") { + Some(CsvFormat::Bitwarden) + } else if has("url") && has("username") && has("password") && has("extra") && has("name") && has("grouping") { + Some(CsvFormat::LastPass) + } else if has("url") && has("username") && has("password") && (has("httprealm") || has("formactionorigin") || has("guid")) { + Some(CsvFormat::Firefox) + } else if has("title") && has("url") && has("username") && has("password") { + if has("favorite") || has("archived") || has("tags") || (has("notes") && !has("otpauth")) { Some(CsvFormat::OnePassword) } else { Some(CsvFormat::Safari) } + } else if has("name") && has("url") && has("username") && has("password") { + Some(CsvFormat::Chrome) + } else { + None + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RowStatus { + Ready, + AlreadySaved, + Error(&'static str), + Skipped(&'static str), +} + +#[derive(Clone, Debug)] +pub struct ImportRow { + #[allow(dead_code)] + pub line: usize, + pub kind: Kind, + pub label: String, + pub secondary: String, + pub status: RowStatus, + // What would be saved. + pub site: String, + pub username: String, + pub password: String, + pub notes: String, + pub card: Option, +} + +impl Clone for CardInput { + fn clone(&self) -> Self { + CardInput { id: self.id.clone(), number: self.number.clone(), expiry: self.expiry.clone(), csc: self.csc.clone(), holder: self.holder.clone(), nickname: self.nickname.clone(), billing: self.billing.clone(), notes: self.notes.clone() } + } +} + +impl std::fmt::Debug for CardInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "CardInput(•••• {})", digits(&self.number).chars().rev().take(4).collect::().chars().rev().collect::()) + } +} + +pub struct ImportPlan { + pub format: CsvFormat, + pub rows: Vec, +} + +impl ImportPlan { + pub fn count(&self, f: impl Fn(&RowStatus) -> bool) -> usize { + self.rows.iter().filter(|r| f(&r.status)).count() + } +} + +/// A LastPass "extra" field: "NoteType:Credit Card\nNumber:4111…\n…" → key → value. +fn lastpass_fields(extra: &str) -> std::collections::HashMap { + extra.lines().filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect() +} + +/// Read a password-manager export and decide, row by row, what would be imported. +pub fn plan_import(text: &str) -> Result { + let rows = parse_csv(text); + let Some((header, body)) = rows.split_first() else { return Err("這看起來不是密碼匯出檔案。請選擇從支援的應用程式匯出的 CSV 檔案。".into()) }; + let format = detect_format(header).ok_or("這看起來不是密碼匯出檔案。請選擇從支援的應用程式匯出的 CSV 檔案。")?; + let col = |name: &str| header.iter().position(|h| h.trim().eq_ignore_ascii_case(name)); + let (c_url, c_user, c_pw, c_title, c_notes) = match format { + CsvFormat::Bitwarden => (col("login_uri"), col("login_username"), col("login_password"), col("name"), col("notes")), + CsvFormat::LastPass => (col("url"), col("username"), col("password"), col("name"), col("extra")), + CsvFormat::Firefox => (col("url"), col("username"), col("password"), None, None), + CsvFormat::OnePassword | CsvFormat::Safari => (col("url"), col("username"), col("password"), col("title"), col("notes")), + CsvFormat::Chrome => (col("url"), col("username"), col("password"), col("name"), col("note")), + }; + let existing = list().unwrap_or_default(); + let mut seen: std::collections::HashSet = existing + .iter() + .flat_map(|i| match i.kind { + Kind::Login => i.sites.iter().map(|s| format!("{}\n{}", registrable_domain(&host_of(s)), i.username.to_lowercase())).collect::>(), + Kind::Card => vec![format!("{}\n{}\n{}", i.last4, i.exp_month, i.exp_year)], + Kind::Contact => vec![], + }) + .collect(); + let get = |r: &Vec, c: Option| c.and_then(|i| r.get(i)).cloned().unwrap_or_default(); + let mut out = vec![]; + for (n, r) in body.iter().enumerate() { + let line = n + 2; + let url = get(r, c_url); + let title = get(r, c_title); + // LastPass cards: url http://sn and an extra that starts with NoteType:Credit Card. + if format == CsvFormat::LastPass && url.trim() == "http://sn" { + let extra = get(r, c_notes); + if !extra.starts_with("NoteType:Credit Card") { + out.push(ImportRow { line, kind: Kind::Login, label: if title.is_empty() { format!("第 {line} 列") } else { title }, secondary: String::new(), status: RowStatus::Skipped("不是登入資訊或卡片"), site: String::new(), username: String::new(), password: String::new(), notes: String::new(), card: None }); + continue; + } + let f = lastpass_fields(&extra); + let number = digits(f.get("Number").map(String::as_str).unwrap_or("")); + let exp_raw = f.get("Expiration Date").cloned().unwrap_or_default(); + // LastPass writes "January,2029" or "01/2029". + let exp = parse_expiry(&exp_raw).or_else(|| { + let (m, y) = exp_raw.split_once(',')?; + let months = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; + let m = months.iter().position(|x| *x == m.trim().to_lowercase())? as u32 + 1; + Some((m, y.trim().parse().ok()?)) + }); + let csc = digits(f.get("Security Code").map(String::as_str).unwrap_or("")); + let last4: String = number.chars().rev().take(4).collect::().chars().rev().collect(); + let status = if !luhn_ok(&number) { + RowStatus::Error("卡號無效") + } else if exp.is_none() { + RowStatus::Error("有效期限無效") + } else if exp.map(|(m, y)| expired(m, y)).unwrap_or(false) { + RowStatus::Error("卡片已過期") + } else if !csc.is_empty() && !(3..=4).contains(&csc.len()) { + RowStatus::Error("安全碼無效") + } else { + let (m, y) = exp.unwrap_or_default(); + let key = format!("{last4}\n{m}\n{y}"); + if seen.contains(&key) { + RowStatus::AlreadySaved + } else { + seen.insert(key); + RowStatus::Ready + } + }; + let expiry = exp.map(|(m, y)| format!("{m:02}/{}", y % 100)).unwrap_or_default(); + out.push(ImportRow { + line, + kind: Kind::Card, + label: if title.is_empty() { card_brand(&number).to_string() } else { title.clone() }, + secondary: format!("•••• {last4}"), + status, + site: String::new(), + username: String::new(), + password: String::new(), + notes: String::new(), + card: Some(CardInput { id: None, number, expiry, csc, holder: f.get("Name on Card").cloned().unwrap_or_default(), nickname: title, billing: Address::default(), notes: String::new() }), + }); + continue; + } + let user = get(r, c_user); + let pw = get(r, c_pw); + let notes = if format == CsvFormat::LastPass { String::new() } else { get(r, c_notes) }; + let scheme = url.trim().split("://").next().unwrap_or("").to_lowercase(); + let host = host_of(&url); + let label = if !title.trim().is_empty() { title.clone() } else if !host.is_empty() { registrable_domain(&host) } else { format!("第 {line} 列") }; + let status = if url.trim().is_empty() { + RowStatus::Error("缺少網站") + } else if url.contains("://") && scheme != "https" && scheme != "http" { + // android://, ios-app:// and the like. + RowStatus::Skipped("不是登入資訊或卡片") + } else if scheme == "http" { + RowStatus::Error("網站未使用 https") + } else if host.is_empty() || !host.contains('.') { + RowStatus::Error("網站無效") + } else if pw.is_empty() { + RowStatus::Error("缺少密碼") + } else if title.chars().count() > MAX_TITLE || user.chars().count() > MAX_USERNAME || pw.len() > MAX_PASSWORD_BYTES || notes.chars().count() > MAX_NOTES { + RowStatus::Error("某個欄位過長") + } else { + let key = format!("{}\n{}", registrable_domain(&host), user.to_lowercase()); + if seen.contains(&key) { + RowStatus::AlreadySaved + } else { + seen.insert(key); + RowStatus::Ready + } + }; + out.push(ImportRow { line, kind: Kind::Login, label, secondary: if user.is_empty() { "••••••••".into() } else { format!("{user} · ••••••••") }, status, site: host, username: user, password: pw, notes, card: None }); + } + Ok(ImportPlan { format, rows: out }) +} + +/// Save one ready row. +pub fn import_row(row: &ImportRow) -> Result<(), String> { + match row.kind { + Kind::Card => put_card(row.card.clone().ok_or("卡號無效")?).map(|_| ()), + _ => put_login(LoginInput { id: None, sites: vec![row.site.clone()], username: row.username.clone(), password: row.password.clone(), notes: row.notes.clone() }).map(|_| ()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn domains_cards_and_csv() { + assert_eq!(registrable_domain("login.shop.example.co.uk"), "example.co.uk"); + assert_eq!(registrable_domain("www.ctbcbank.com.tw"), "ctbcbank.com.tw"); + assert_eq!(registrable_domain("accounts.google.com"), "google.com"); + assert_eq!(registrable_domain("127.0.0.1"), "127.0.0.1"); + assert_eq!(host_of("https://WWW.Example.com:8443/x?y"), "example.com"); + let it = Item { kind: Kind::Login, sites: vec!["https://example.com/login".into()], ..Default::default() }; + assert!(it.fills_on("app.example.com") && it.fills_on("example.com") && !it.fills_on("example.com.evil.io") && !it.fills_on("notexample.com")); + assert!(luhn_ok("4111 1111 1111 1111") && !luhn_ok("4111 1111 1111 1112")); + assert_eq!(card_brand("5555555555554444"), "Mastercard"); + assert_eq!(card_brand("378282246310005"), "American Express"); + assert_eq!(parse_expiry("08/29"), Some((8, 2029))); + assert_eq!(parse_expiry("8/2031"), Some((8, 2031))); + assert_eq!(parse_expiry("13/29"), None); + assert!(valid_email("a@b.co") && !valid_email("a@b") && valid_phone("+886 912-345-678") && !valid_phone("12ab")); + assert_eq!(parse_csv("a,\"b,\"\"c\"\"\nd\",e\r\n\r\nf,g,h\n"), vec![vec!["a".to_string(), "b,\"c\"\nd".into(), "e".into()], vec!["f".into(), "g".into(), "h".into()]]); + let h = |s: &str| s.split(',').map(String::from).collect::>(); + assert_eq!(detect_format(&h("name,url,username,password,note")), Some(CsvFormat::Chrome)); + assert_eq!(detect_format(&h("Title,URL,Username,Password,Notes,OTPAuth")), Some(CsvFormat::Safari)); + assert_eq!(detect_format(&h("Title,Url,Username,Password,Notes,Favorite,Archived,Tags")), Some(CsvFormat::OnePassword)); + assert_eq!(detect_format(&h("url,username,password,httpRealm,formActionOrigin,guid")), Some(CsvFormat::Firefox)); + assert_eq!(detect_format(&h("folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp")), Some(CsvFormat::Bitwarden)); + assert_eq!(detect_format(&h("url,username,password,totp,extra,name,grouping,fav")), Some(CsvFormat::LastPass)); + assert_eq!(detect_format(&h("a,b,c")), None); + } + + /// The store round trip with a scratch key file and data folder. + #[test] + fn store_keeps_secrets_inside() { + let dir = std::env::temp_dir().join(format!("anybot-vault-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("ANYBOT_VAULT_DIR", &dir); + std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("k")); + reset_cache(); + // Pretend the old Keychain logins were already brought in (no Keychain in this test). + save(&Store { imported_keychain: true, ..Default::default() }).unwrap(); + let id = put_login(LoginInput { id: None, sites: vec!["https://www.github.com/login".into()], username: "me".into(), password: "pa\"ss 你好".into(), notes: "工作用".into() }).unwrap(); + assert!(put_login(LoginInput { id: None, sites: vec![], username: "x".into(), password: "y".into(), notes: String::new() }).is_err()); + let card = put_card(CardInput { id: None, number: "4111 1111 1111 1111".into(), expiry: "12/39".into(), csc: "123".into(), holder: "WANG".into(), nickname: "旅行".into(), billing: Address::default(), notes: String::new() }).unwrap(); + assert!(put_card(CardInput { id: None, number: "4111 1111 1111 1112".into(), expiry: "12/39".into(), csc: String::new(), holder: String::new(), nickname: String::new(), billing: Address::default(), notes: String::new() }).is_err()); + put_contact(ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: Address { line1: "信義路 1 號".into(), city: "台北市".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec!["a@b.co".into()], notes: String::new() }).unwrap(); + // The file is ciphertext. + let raw = std::fs::read(file_path()).unwrap(); + assert!(!String::from_utf8_lossy(&raw).contains("你好") && !String::from_utf8_lossy(&raw).contains("4111")); + // Reading back from disk (not the cache) works, and the list carries no secrets. + reset_cache(); + let items = list().unwrap(); + assert_eq!(items.len(), 3); + assert!(items.iter().all(|i| i.password.is_empty() && i.number.is_empty() && i.csc.is_empty())); + let c = items.iter().find(|i| i.id == card).unwrap(); + assert_eq!((c.brand.as_str(), c.last4.as_str(), c.exp_month, c.exp_year), ("Visa", "1111", 12, 2039)); + assert_eq!(login_secret(&id).unwrap().1, "pa\"ss 你好"); + assert_eq!(logins_for("gist.github.com").len(), 1); + // Editing with an empty password keeps it. + put_login(LoginInput { id: Some(id.clone()), sites: vec!["github.com".into(), "githubusercontent.com".into()], username: "me2".into(), password: String::new(), notes: String::new() }).unwrap(); + assert_eq!(login_secret(&id).unwrap(), ("me2".to_string(), "pa\"ss 你好".to_string())); + add_site(&id, "https://login.example.org").unwrap(); + assert!(get(&id).unwrap().fills_on("example.org")); + // Import: duplicates, http, cards from LastPass. + let csv = "url,username,password,totp,extra,name,grouping,fav\nhttps://github.com,me2,x,,,GitHub,,0\nhttp://old.example.com,u,p,,,Old,,0\nhttps://new.example.net/a,u,p,,,New,,0\nhttp://sn,,,,\"NoteType:Credit Card\nName on Card:WANG\nNumber:5555555555554444\nSecurity Code:321\nExpiration Date:January,2040\",Master,,0\n"; + let plan = plan_import(csv).unwrap(); + assert_eq!(plan.format, CsvFormat::LastPass); + let st: Vec<_> = plan.rows.iter().map(|r| r.status.clone()).collect(); + assert_eq!(st, vec![RowStatus::AlreadySaved, RowStatus::Error("網站未使用 https"), RowStatus::Ready, RowStatus::Ready]); + for r in plan.rows.iter().filter(|r| r.status == RowStatus::Ready) { + import_row(r).unwrap(); + } + assert_eq!(list().unwrap().len(), 5); + remove(&card).unwrap(); + assert!(get(&card).is_none()); + std::env::remove_var("ANYBOT_VAULT_KEY_FILE"); + std::env::remove_var("ANYBOT_VAULT_DIR"); + reset_cache(); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The real keychain key (macOS Keychain / Secret Service). Creates the key item; the + /// caller removes it afterwards. + #[test] + #[ignore] + fn real_keychain_key() { + let dir = std::env::temp_dir().join(format!("anybot-vault-kc-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("ANYBOT_VAULT_DIR", &dir); + reset_cache(); + save(&Store { imported_keychain: true, ..Default::default() }).unwrap(); + put_contact(ContactInput { id: None, label: "x".into(), name: "測試".into(), address: Address::default(), phones: vec![], emails: vec![], notes: String::new() }).unwrap(); + reset_cache(); + assert_eq!(list().unwrap().len(), 1); + assert!(!dir.join("vault.key").exists(), "the key should be in the keychain, not a file"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/voice.rs b/src/voice.rs new file mode 100644 index 0000000..29c0816 --- /dev/null +++ b/src/voice.rs @@ -0,0 +1,472 @@ +// 語音輸入: hold a short recording from the microphone, turn it into text, put it in the message +// box (the original: 聽寫 / transcribeAudio through its server). Here the recording uses macOS's +// own AVAudioRecorder (through osascript, no extra install) and the text comes from an +// OpenAI-compatible /audio/transcriptions endpoint with the person's own key. + +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Longest recording (estimated; the recorder script also stops itself at 10 minutes). +pub const MAX_SECS: u64 = 600; +/// 會議記錄 can run longer: two hours stays under the transcription service's 25 MB limit at this +/// recorder's bitrate (estimated). +pub const MEETING_MAX_SECS: u64 = 7_200; +/// Transcription models tried in order. +pub const MODELS: [&str; 2] = ["gpt-4o-mini-transcribe", "whisper-1"]; + +const RECORDER: &str = r#"ObjC.import('AVFoundation'); +ObjC.import('Foundation'); +function run(argv) { + const url = $.NSURL.fileURLWithPath(argv[0]); + const settings = $.NSDictionary.dictionaryWithObjectsForKeys( + [$.NSNumber.numberWithInt(1633772320), $.NSNumber.numberWithDouble(16000.0), $.NSNumber.numberWithInt(1)], + [$('AVFormatIDKey'), $('AVSampleRateKey'), $('AVNumberOfChannelsKey')]); + const rec = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, null); + if (rec.isNil()) return 'fail'; + if (!rec.record) return 'denied'; + const fm = $.NSFileManager.defaultManager; + for (let i = 0; i < 72000; i++) { if (fm.fileExistsAtPath(argv[1])) break; delay(0.1); } + rec.stop; + return 'ok'; +}"#; + +/// 通話模式: listens until the person stops talking (AVAudioRecorder metering). +/// Speech is louder than SPEECH_DB; SILENCE_TICKS × 0.1 s of quiet after speech ends a turn; +/// nothing heard for WAIT_TICKS × 0.1 s gives up (all estimated). +const SPEECH_DB: f64 = -38.0; +const SILENCE_TICKS: u32 = 12; +const WAIT_TICKS: u32 = 300; +const LISTENER: &str = r#"ObjC.import('AVFoundation'); +ObjC.import('Foundation'); +function run(argv) { + const url = $.NSURL.fileURLWithPath(argv[0]); + const settings = $.NSDictionary.dictionaryWithObjectsForKeys( + [$.NSNumber.numberWithInt(1633772320), $.NSNumber.numberWithDouble(16000.0), $.NSNumber.numberWithInt(1)], + [$('AVFormatIDKey'), $('AVSampleRateKey'), $('AVNumberOfChannelsKey')]); + const rec = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, null); + if (rec.isNil()) return 'fail'; + rec.meteringEnabled = true; + if (!rec.record) return 'denied'; + const fm = $.NSFileManager.defaultManager; + const speech = Number(argv[2]), quietMax = Number(argv[3]), waitMax = Number(argv[4]); + let heard = false, quiet = 0; + for (let i = 0; i < 6000; i++) { + if (fm.fileExistsAtPath(argv[1])) break; + delay(0.1); + rec.updateMeters; + const p = rec.averagePowerForChannel(0); + if (p > speech) { heard = true; quiet = 0; } else if (heard) { quiet++; } + if (heard && quiet >= quietMax) break; + if (!heard && i >= waitMax) { rec.stop; return 'nothing'; } + } + rec.stop; + return heard ? 'ok' : 'nothing'; +}"#; + +/// The recorder: macOS's AVAudioRecorder script, or cpal (mic.rs) elsewhere. +enum Rec { + Script(Child), + Mic(crate::mic::Recorder), +} + +pub struct Recording { + rec: Rec, + pub file: PathBuf, + stop: PathBuf, + pub started: Instant, +} + +pub fn start() -> Result { + if !cfg!(target_os = "macos") { + let file = std::env::temp_dir().join(format!("anybot-voice-{}-{}.wav", std::process::id(), chrono::Utc::now().timestamp_millis())); + let rec = crate::mic::start(file.clone(), None, MEETING_MAX_SECS); + return Ok(Recording { rec: Rec::Mic(rec), stop: file.with_extension("stop"), file, started: Instant::now() }); + } + let base = std::env::temp_dir().join(format!("anybot-voice-{}-{}", std::process::id(), chrono::Utc::now().timestamp_millis())); + let file = base.with_extension("m4a"); + let stop = base.with_extension("stop"); + let child = Command::new("osascript") + .args(["-l", "JavaScript", "-e", RECORDER, "--"]) + .arg(&file) + .arg(&stop) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("無法開始錄音:{e}"))?; + Ok(Recording { rec: Rec::Script(child), file, stop, started: Instant::now() }) +} + +/// Listen for one spoken turn: stops by itself when the person goes quiet. +pub fn listen() -> Result { + if !cfg!(target_os = "macos") { + let file = std::env::temp_dir().join(format!("anybot-call-{}-{}.wav", std::process::id(), chrono::Utc::now().timestamp_millis())); + let rec = crate::mic::start(file.clone(), Some(crate::mic::Listen { speech_db: SPEECH_DB, quiet_ticks: SILENCE_TICKS, wait_ticks: WAIT_TICKS }), MAX_SECS); + return Ok(Recording { rec: Rec::Mic(rec), stop: file.with_extension("stop"), file, started: Instant::now() }); + } + let base = std::env::temp_dir().join(format!("anybot-call-{}-{}", std::process::id(), chrono::Utc::now().timestamp_millis())); + let (file, stop) = (base.with_extension("m4a"), base.with_extension("stop")); + let child = Command::new("osascript") + .args(["-l", "JavaScript", "-e", LISTENER, "--"]) + .arg(&file) + .arg(&stop) + .args([SPEECH_DB.to_string(), SILENCE_TICKS.to_string(), WAIT_TICKS.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("無法開始聽:{e}"))?; + Ok(Recording { rec: Rec::Script(child), file, stop, started: Instant::now() }) +} + +impl Recording { + /// The listener stopped by itself (the person finished, or nobody spoke). + pub fn done(&mut self) -> bool { + match &mut self.rec { + Rec::Script(c) => matches!(c.try_wait(), Ok(Some(_))), + Rec::Mic(m) => m.done(), + } + } + + /// Stop and give back the audio file. + pub fn finish(mut self) -> Result { + let child = match &mut self.rec { + Rec::Script(c) => c, + Rec::Mic(m) => { + return match m.finish() { + Some("fail") => Err("找不到可以用的麥克風。".into()), + Some("nothing") => Err("沒有聽到說話。".into()), + None => Err("錄音沒有正常結束。".into()), + Some(_) if std::fs::metadata(&self.file).map(|m| m.len() > 4000).unwrap_or(false) => Ok(self.file.clone()), + Some(_) => Err("沒有錄到聲音。".into()), + }; + } + }; + let _ = std::fs::write(&self.stop, b""); + let t = Instant::now(); + let status = loop { + if let Ok(Some(_)) = child.try_wait() { + break true; + } + if t.elapsed() > Duration::from_secs(4) { + let _ = child.kill(); + break false; + } + std::thread::sleep(Duration::from_millis(50)); + }; + let _ = std::fs::remove_file(&self.stop); + let mut said = String::new(); + if let Some(mut o) = child.stdout.take() { + use std::io::Read; + let _ = o.read_to_string(&mut said); + } + match said.trim() { + "denied" => { + let _ = Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone").spawn(); + Err("macOS 還沒讓 AnyBot 使用麥克風。我已經打開「系統設定 → 隱私權與安全性 → 麥克風」:把 AnyBot(或「終端機」)打開,再試一次。".into()) + } + "fail" => Err("找不到可以用的麥克風。".into()), + "nothing" => Err("沒有聽到說話。".into()), + _ if !status => Err("錄音沒有正常結束。".into()), + _ => { + let ok = std::fs::metadata(&self.file).map(|m| m.len() > 1000).unwrap_or(false); + if ok { + Ok(self.file.clone()) + } else { + Err("沒有錄到聲音。".into()) + } + } + } + } + + pub fn cancel(mut self) { + let _ = std::fs::write(&self.stop, b""); + match &mut self.rec { + Rec::Script(c) => { + std::thread::sleep(Duration::from_millis(300)); + let _ = c.kill(); + } + Rec::Mic(m) => { + let _ = m.finish(); + } + } + let _ = std::fs::remove_file(&self.stop); + let _ = std::fs::remove_file(&self.file); + } +} + +/// multipart/form-data body for /audio/transcriptions. +#[allow(dead_code)] +pub fn multipart(model: &str, file_name: &str, audio: &[u8], boundary: &str) -> Vec { + multipart_lang(model, file_name, audio, boundary, "") +} + +/// Same, telling the service the spoken language (ISO 639-1, e.g. "zh"; empty = detect). +pub fn multipart_lang(model: &str, file_name: &str, audio: &[u8], boundary: &str, lang: &str) -> Vec { + let mut b = vec![]; + let mut fields = vec![("model", model), ("response_format", "json")]; + if !lang.is_empty() { + fields.push(("language", lang)); + } + for (k, v) in fields { + b.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").as_bytes()); + } + let kind = if file_name.ends_with(".wav") { "audio/wav" } else { "audio/mp4" }; + b.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\nContent-Type: {kind}\r\n\r\n").as_bytes()); + b.extend_from_slice(audio); + b.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + b +} + +/// Audio → text through `{base}/audio/transcriptions`. +#[allow(dead_code)] +pub fn transcribe(base_url: &str, api_key: &str, file: &std::path::Path) -> Result { + transcribe_lang(base_url, api_key, file, "") +} + +/// The spoken languages offered for voice chat (the original: 語言 Auto-detect…): (code, name). +pub const SPOKEN: [(&str, &str); 6] = [("", "自動偵測"), ("zh", "中文"), ("en", "English"), ("ja", "日本語"), ("ko", "한국어"), ("es", "Español")]; + +pub fn transcribe_lang(base_url: &str, api_key: &str, file: &std::path::Path, lang: &str) -> Result { + if api_key.trim().is_empty() { + return Err("語音轉文字要用 OpenAI 的金鑰:請到「設定 → AI 供應商 → OpenAI」填入。".into()); + } + let audio = std::fs::read(file).map_err(|e| e.to_string())?; + let boundary = format!("anybot{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)); + let url = format!("{}/audio/transcriptions", base_url.trim_end_matches('/')); + let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(120))).http_status_as_error(false).build().into(); + let mut last = String::new(); + for model in MODELS { + let name = if file.extension().and_then(|e| e.to_str()) == Some("wav") { "voice.wav" } else { "voice.m4a" }; + let body = multipart_lang(model, name, &audio, &boundary, lang); + let resp = agent + .post(&url) + .header("Authorization", &format!("Bearer {}", api_key.trim())) + .header("Content-Type", &format!("multipart/form-data; boundary={boundary}")) + .send(&body[..]); + match resp { + Ok(mut r) => { + let status = r.status().as_u16(); + let text = r.body_mut().read_to_string().unwrap_or_default(); + if status == 200 { + let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + return Ok(v["text"].as_str().unwrap_or("").trim().to_string()); + } + last = format!("轉文字失敗(HTTP {status}):{}", text.chars().take(200).collect::()); + // An unknown model: try the next one. + if status != 404 && status != 400 { + break; + } + } + Err(e) => { + last = format!("連不上語音轉文字服務:{e}"); + break; + } + } + } + Err(last) +} + +/// The voices installed on this Mac: (name, language code), Chinese and English first. +pub fn voices() -> Vec<(String, String)> { + static V: std::sync::OnceLock> = std::sync::OnceLock::new(); + V.get_or_init(|| { + if !cfg!(target_os = "macos") { + let out = Command::new("espeak-ng").arg("--voices").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); + return parse_espeak_voices(&out); + } + let out = Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); + parse_voices(&out) + }) + .clone() +} + +pub fn parse_voices(out: &str) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = out + .lines() + .filter_map(|l| { + let head = l.split('#').next()?.trim_end(); + let (name, lang) = head.rsplit_once(char::is_whitespace)?; + let lang = lang.trim(); + lang.contains('_').then(|| (name.trim().to_string(), lang.to_string())) + }) + .filter(|(n, _)| !n.is_empty()) + .collect(); + let rank = |l: &str| if l.starts_with("zh_TW") { 0 } else if l.starts_with("zh") { 1 } else if l.starts_with("en") { 2 } else if l.starts_with("ja") { 3 } else { 4 }; + v.sort_by(|a, b| rank(&a.1).cmp(&rank(&b.1)).then(a.0.cmp(&b.0))); + v +} + +/// `espeak-ng --voices` (Linux): "Pty Language Age/Gender VoiceName File Other" → +/// (language code to pass to -v, language as zh_CN / en_US). +pub fn parse_espeak_voices(out: &str) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = out + .lines() + .skip(1) + .filter_map(|l| { + let lang = l.split_whitespace().nth(1)?.to_string(); + let norm = match lang.as_str() { + "cmn" => "zh_CN".to_string(), + "yue" => "zh_HK".to_string(), + "cmn-latn-pinyin" => return None, + l => { + let mut parts = l.splitn(2, '-'); + let a = parts.next().unwrap_or(""); + match parts.next() { + Some(b) => format!("{a}_{}", b.to_uppercase()), + None => format!("{a}_{}", a.to_uppercase()), + } + } + }; + Some((lang, norm)) + }) + .collect(); + let rank = |l: &str| if l.starts_with("zh") { 1 } else if l.starts_with("en") { 2 } else if l.starts_with("ja") { 3 } else { 4 }; + v.sort_by(|a, b| rank(&a.1).cmp(&rank(&b.1)).then(a.0.cmp(&b.0))); + // One entry per language (espeak-ng lists some twice, e.g. yue with and without Jyutping). + v.dedup_by(|a, b| a.0 == b.0); + v +} + +// ---------- 語音聊天: reading answers aloud ---------- + +static SPEAKER: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Markdown → words worth saying. +pub fn speakable(text: &str) -> String { + let mut out = String::new(); + let mut in_code = false; + for line in text.lines() { + let l = line.trim(); + if l.starts_with("```") { + in_code = !in_code; + if in_code { + out.push_str("(程式碼略過)\n"); + } + continue; + } + if in_code { + continue; + } + let l = l.trim_start_matches(['#', '-', '*', '>', ' ']).replace("**", "").replace('`', ""); + let l = l.split("](").next().unwrap_or(&l).replace('[', ""); + if !l.is_empty() { + out.push_str(&l); + out.push('\n'); + } + } + out.trim().to_string() +} + +/// The voice for the text: Taiwanese Mandarin (美佳) when it has Chinese, else the system voice. +pub fn voice_for(text: &str) -> Option<&'static str> { + text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)).then_some(if cfg!(target_os = "macos") { "Meijia" } else { "cmn" }) +} + +/// Read an answer aloud (stops whatever was being read). +#[allow(dead_code)] +pub fn speak(text: &str) { + speak_as(text, "", 1.0); +} + +/// Words per minute of the system voices at 1x (`say`'s usual rate). +pub const BASE_RATE: f32 = 175.0; + +/// The speeds offered (the original: 速度 1x…). +pub const SPEEDS: [f32; 5] = [0.75, 1.0, 1.25, 1.5, 2.0]; + +/// Read aloud with a chosen voice ("" = pick by language) and speed (1.0 = normal). +pub fn speak_as(text: &str, voice: &str, rate: f32) { + stop_speaking(); + let words = speakable(text); + if words.is_empty() { + return; + } + // Linux: espeak-ng takes the same -v (voice) and -s (words per minute, 175 by default). + let mut c = Command::new(if cfg!(target_os = "macos") { "say" } else { "espeak-ng" }); + if !voice.trim().is_empty() { + c.args(["-v", voice.trim()]); + } else if let Some(v) = voice_for(&words) { + c.args(["-v", v]); + } + if (rate - 1.0).abs() > 0.01 { + c.args([if cfg!(target_os = "macos") { "-r" } else { "-s" }, &format!("{}", (BASE_RATE * rate.clamp(0.5, 3.0)).round())]); + } + if let Ok(child) = c.arg(&words).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + *SPEAKER.lock().unwrap() = Some(child); + } +} + +pub fn stop_speaking() { + if let Some(mut c) = SPEAKER.lock().unwrap().take() { + let _ = c.kill(); + } +} + +pub fn speaking() -> bool { + let mut g = SPEAKER.lock().unwrap(); + match g.as_mut().map(|c| c.try_wait()) { + Some(Ok(None)) => true, + _ => { + *g = None; + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + #[test] + fn answers_are_cleaned_for_speaking() { + let t = "## 行程\n- **上午**:赤崁樓\n```\nls -la\n```\n看 [地圖](https://x.y)"; + assert_eq!(speakable(t), "行程\n上午:赤崁樓\n(程式碼略過)\n看 地圖"); + assert_eq!(voice_for("你好"), Some(if cfg!(target_os = "macos") { "Meijia" } else { "cmn" })); + let e = parse_espeak_voices("Pty Language Age/Gender VoiceName File Other Languages\n 5 cmn --/M Chinese_(Mandarin) sit/cmn (zh-cmn 5)(zh 5)\n 2 en-us --/M English_(America) gmw/en-US (en 3)\n"); + assert_eq!(e[0], ("cmn".to_string(), "zh_CN".to_string())); + assert_eq!(e[1], ("en-us".to_string(), "en_US".to_string())); + let v = parse_voices("Samantha en_US # Hello\nMeijia zh_TW # 你好\nGrandma (Chinese (Taiwan)) zh_TW # 你好\n"); + assert_eq!(v[0].1, "zh_TW"); + assert!(v.iter().any(|x| x.0 == "Grandma (Chinese (Taiwan))")); + assert_eq!(v.last().unwrap().0, "Samantha"); + let body = String::from_utf8_lossy(&multipart_lang("m", "a.m4a", b"x", "B", "zh")).to_string(); + assert!(body.contains("name=\"language\"\r\n\r\nzh")); + assert_eq!(voice_for("hello"), None); + } + + #[test] + fn transcription_request_is_well_formed() { + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = server.local_addr().unwrap().port(); + let seen = std::thread::spawn(move || { + let (mut s, _) = server.accept().unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut buf = vec![0u8; 65536]; + let mut got = vec![]; + while let Ok(n) = s.read(&mut buf) { + if n == 0 { + break; + } + got.extend_from_slice(&buf[..n]); + if got.windows(4).any(|w| w == b"--\r\n") { + break; + } + } + let reply = "{\"text\":\"你好,幫我排行程\"}"; + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", reply.len()).as_bytes()); + String::from_utf8_lossy(&got).to_string() + }); + let f = std::env::temp_dir().join(format!("anybot-voice-test-{}.m4a", std::process::id())); + std::fs::write(&f, vec![7u8; 2048]).unwrap(); + let text = transcribe(&format!("http://127.0.0.1:{port}/v1"), "sk-test", &f).unwrap(); + let req = seen.join().unwrap(); + let _ = std::fs::remove_file(&f); + assert_eq!(text, "你好,幫我排行程"); + assert!(req.starts_with("POST /v1/audio/transcriptions")); + assert!(req.contains("Bearer sk-test") && req.contains("name=\"model\"\r\n\r\ngpt-4o-mini-transcribe") && req.contains("filename=\"voice.m4a\"")); + assert!(transcribe("http://127.0.0.1:1", "", &std::path::PathBuf::from("/x")).unwrap_err().contains("OpenAI")); + } +} diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..80ab527 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,484 @@ +// Websites: read a public page, open a link in the person's browser, and read the tab they have +// open there (where they're already logged in: Threads, Instagram, Gmail…). The original reaches +// sites through its own browser; its old "copy logins from Chrome" import is retired (SAND-E0801.. +// E0812 all say "removed"), so nothing is copied here either. AnyBot's own browser (botbrowser.rs) +// is used when a Chrome-family browser is installed; AppleScript on the person's browser otherwise. + +use serde_json::{json, Value}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +pub const NAMES: [&str; 4] = ["web_fetch", "browser_open", "browser_read", "browser_profiles"]; +/// Longest page text handed to the model (estimated). +pub const MAX_PAGE_CHARS: usize = 20_000; +/// Largest download read for web_fetch (estimated). +const MAX_FETCH_BYTES: u64 = 3_000_000; +/// AppleScript can wait on macOS's "allow AnyBot to control Chrome?" prompt; give the person time. +const SCRIPT_TIMEOUT_SECS: u64 = 90; +/// Time for a page to load after opening it (estimated). +const OPEN_SETTLE_SECS: u64 = 4; + +/// Browsers in the order they're tried (Chromium-style ones share the same AppleScript). +const BROWSERS: [(&str, bool); 6] = [("Google Chrome", true), ("Arc", true), ("Microsoft Edge", true), ("Brave Browser", true), ("Chromium", true), ("Safari", false)]; + +pub fn is_web_tool(name: &str) -> bool { + NAMES.contains(&name) || crate::browser::is_tool(name) +} + +pub fn label(name: &str) -> &'static str { + match name { + "web_fetch" => "讀取網頁", + "browser_open" => "在瀏覽器打開", + "browser_read" => "讀取瀏覽器分頁", + "browser_profiles" => "瀏覽器設定檔", + n if crate::browser::is_tool(n) => crate::browser::label(n), + _ => "網頁", + } +} + +/// Does this call use the person's own browser ("你的瀏覽器", or the fallback when no +/// Chrome-family browser is installed)? +pub fn uses_person_browser(args: &str) -> bool { + if !crate::botbrowser::available() { + return true; + } + let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null); + crate::botbrowser::resolve(Some(arg(&v, "profile").as_str())).map(|p| p == crate::botbrowser::PERSON_PROFILE).unwrap_or(false) +} + +/// browser_open / browser_read on the person's own browser run on this Mac. +pub fn uses_this_computer(name: &str, args: &str) -> bool { + // AnyBot's own browser is the bot's, like Grok Bot's; only the person's own browser counts + // as working on their computer. Profiles are the bot's browser's, never the person's. + (name == "browser_open" || name == "browser_read") && uses_person_browser(args) +} + +/// Reading a tab asks first only when it's the person's own browser. +pub fn read_needs_permission(args: &str) -> bool { + uses_person_browser(args) +} + +pub fn specs() -> Vec { + let (names, current) = if crate::botbrowser::available() { + (crate::botbrowser::choices().join(", "), crate::botbrowser::current()) + } else { + (crate::botbrowser::DEFAULT_PROFILE.to_string(), crate::botbrowser::DEFAULT_PROFILE.to_string()) + }; + let profile = json!({"type": "string", "description": format!("Which AnyBot browser profile (設定檔) to use; each has its own logins (e.g. a different account). Profiles now: {names}. \"{}\" is the person's own everyday browser (read through accessibility), in whichever Chrome profile they have open there. Leave out to use the current one ({current}).", crate::botbrowser::PERSON_PROFILE)}); + let mut v = vec![ + json!({"name": "web_fetch", "description": "Download a public web page and return its text (no login). For pages that need the person's login, use browser_open then browser_read.", + "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}), + json!({"name": "browser_open", "description": "Open a link in AnyBot's own browser window (a separate browser profile the person logs in to once, for sites like Threads, Instagram, Facebook, Gmail). Waits a few seconds for it to load; then call browser_read. Never ask the person to change browser settings.", + "parameters": {"type": "object", "properties": {"url": {"type": "string"}, "profile": profile}, "required": ["url"]}}), + json!({"name": "browser_read", "description": "Read the title, address and visible text of the current tab in AnyBot's browser (logged-in pages included), plus a numbered list of its links (with their addresses), buttons, fields and dropdowns. Use those numbers with browser_click / browser_type / browser_select instead of guessing URLs. AnyBot's browser runs on the person's computer, not on 「Bot 的電腦」: screenshots of 「Bot 的電腦」 never show it. If it shows a login page, ask the person to log in once in that window.", + "parameters": {"type": "object", "properties": {"max_chars": {"type": "integer", "description": "Longest text to return (default 20000)."}, "profile": profile}}}), + json!({"name": "browser_profiles", "description": "List, add or switch AnyBot browser profiles (設定檔/profile). Use when the person wants another profile or account: 'list' shows them, 'add' makes a new empty one (they log in once there) and switches to it, 'use' switches. Switching changes which profile browser_open/browser_read use.", + "parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "add", "use"]}, "name": {"type": "string", "description": "Profile name for add/use."}}, "required": ["action"]}}), + ]; + if crate::botbrowser::available() { + v.extend(crate::browser::specs(&profile)); + } + v +} + +fn arg(v: &Value, k: &str) -> String { + v.get(k).and_then(Value::as_str).unwrap_or("").trim().to_string() +} + +pub fn describe(name: &str, args: &str) -> String { + let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); + match name { + "browser_read" => match arg(&v, "profile") { + p if p.is_empty() => "目前的分頁".into(), + p => format!("目前的分頁(設定檔「{p}」)"), + }, + "browser_profiles" => match (arg(&v, "action").as_str(), arg(&v, "name")) { + ("add" | "create", n) => format!("新增「{n}」"), + ("use" | "switch", n) => format!("切換到「{n}」"), + _ => "列出設定檔".into(), + }, + n if crate::browser::is_tool(n) => crate::browser::describe(n, args), + _ => arg(&v, "url"), + } +} + +/// Run a web tool, with a picture when it took one (browser_screenshot). +pub fn run_full(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool, Option) { + if crate::browser::is_tool(name) { + if !crate::botbrowser::available() { + return ("這些瀏覽器工具需要 AnyBot 自己的瀏覽器(Chrome、Brave、Edge 或 Chromium)。".into(), false, None); + } + if uses_person_browser(args) { + return ("點擊、輸入和其他操作只能在 AnyBot 自己的瀏覽器裡做。請用 browser_open 在 AnyBot 瀏覽器打開網頁(第一次要請使用者在那個視窗登入)。".into(), false, None); + } + let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null); + return match crate::browser::run(name, &v) { + Ok((t, img)) => (t, true, img), + Err(e) => (e, false, None), + }; + } + let (t, ok) = run(name, args, cancel); + (t, ok, None) +} + +/// Run a web tool. Returns (text, ok). +pub fn run(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool) { + let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null); + let r = match name { + "web_fetch" => fetch(&arg(&v, "url")), + "browser_open" if crate::botbrowser::available() && uses_person_browser(args) => browser_open(&arg(&v, "url"), cancel), + "browser_read" if crate::botbrowser::available() && uses_person_browser(args) => browser_read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), cancel), + "browser_open" if crate::botbrowser::available() => normalize_url(&arg(&v, "url")).and_then(|u| crate::botbrowser::open(&u, Some(arg(&v, "profile").as_str()))), + "browser_read" if crate::botbrowser::available() => crate::botbrowser::read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), Some(arg(&v, "profile").as_str())), + "browser_profiles" if crate::botbrowser::available() => crate::botbrowser::profiles_tool(&arg(&v, "action"), &arg(&v, "name")), + "browser_profiles" => Err("設定檔要 AnyBot 自己的瀏覽器(需要 Chrome、Brave、Edge 或 Chromium)。現在用的是你自己的 Safari。".into()), + "browser_open" => browser_open(&arg(&v, "url"), cancel), + "browser_read" => browser_read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), cancel), + other => Err(format!("沒有「{other}」這個工具")), + }; + match r { + Ok(s) => (s, true), + Err(e) => (e, false), + } +} + +pub fn normalize_url(u: &str) -> Result { + let u = u.trim(); + if u.is_empty() { + return Err("請給網址".into()); + } + let full = if u.starts_with("http://") || u.starts_with("https://") { u.to_string() } else { format!("https://{u}") }; + if full.contains(char::is_whitespace) || !full[8..].contains('.') && !full.contains("localhost") { + return Err(format!("「{u}」看起來不是網址")); + } + Ok(full) +} + +// ---------- web_fetch ---------- + +fn fetch(url: &str) -> Result { + let url = normalize_url(url)?; + let agent: ureq::Agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_global(Some(Duration::from_secs(20))) + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15") + .build() + .into(); + let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?; + let status = resp.status().as_u16(); + let body = resp.into_body().with_config().limit(MAX_FETCH_BYTES).read_to_string().map_err(|e| format!("讀取失敗:{e}"))?; + if status >= 400 { + return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open + browser_read。")); + } + let title = between(&body, "").map(|t| t.split_once('>').map(|x| x.1).unwrap_or(t).trim().to_string()).unwrap_or_default(); + let text = html_to_text(&body); + let thin = text.chars().count() < 200; + let mut out = format!("網址:{url}\n標題:{}\n\n{}", decode_entities(&title), clip(&text, MAX_PAGE_CHARS)); + if thin { + out.push_str("\n\n(頁面幾乎沒有文字,可能要登入或靠程式載入。可以改用 browser_open 打開,再用 browser_read 讀。)"); + } + Ok(out) +} + +fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> { + let lower = s.to_ascii_lowercase(); + let i = lower.find(a)?; + let j = lower[i..].find(b)? + i; + s.get(i + a.len()..j) +} + +fn clip(s: &str, n: usize) -> String { + let mut t: String = s.chars().take(n).collect(); + if s.chars().count() > n { + t.push_str("\n…(後面還有,已截斷)"); + } + t +} + +pub fn decode_entities(s: &str) -> String { + let mut out = s.replace(" ", " ").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("'", "'"); + // Numeric entities like 中 + while let Some(i) = out.find("&#") { + let Some(j) = out[i..].find(';').map(|j| j + i) else { break }; + let code = &out[i + 2..j]; + let ch = if let Some(h) = code.strip_prefix('x').or_else(|| code.strip_prefix('X')) { u32::from_str_radix(h, 16).ok() } else { code.parse().ok() }; + match ch.and_then(char::from_u32) { + Some(c) => out.replace_range(i..=j, &c.to_string()), + None => break, + } + } + out.replace("&", "&") +} + +/// Visible text of an HTML page: drop scripts, styles and tags, keep line breaks for blocks. +pub fn html_to_text(html: &str) -> String { + let mut s = html.to_string(); + for tag in ["script", "style", "noscript", "svg", "head"] { + loop { + let lower = s.to_ascii_lowercase(); + let Some(a) = lower.find(&format!("<{tag}")) else { break }; + let end_tag = format!(""); + match lower[a..].find(&end_tag) { + Some(b) => s.replace_range(a..a + b + end_tag.len(), " "), + None => { + s.truncate(a); + break; + } + } + } + } + let mut out = String::new(); + let mut in_tag = false; + let mut tag = String::new(); + for c in s.chars() { + match c { + '<' => { + in_tag = true; + tag.clear(); + } + '>' if in_tag => { + in_tag = false; + let t = tag.trim_start_matches('/').split(|c: char| c.is_whitespace()).next().unwrap_or("").to_ascii_lowercase(); + if ["p", "div", "br", "li", "h1", "h2", "h3", "h4", "tr", "section", "article", "header", "footer"].contains(&t.as_str()) { + out.push('\n'); + } + } + _ if in_tag => tag.push(c), + _ => out.push(c), + } + } + let text = decode_entities(&out); + let lines: Vec = text.lines().map(|l| l.split_whitespace().collect::>().join(" ")).filter(|l| !l.is_empty()).collect(); + lines.join("\n") +} + +// ---------- the person's browser ---------- + +fn applescript_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// Run AppleScript and wait (macOS may first ask the person to allow AnyBot to control the browser). +fn osascript(script: &str, cancel: &AtomicBool) -> Result { + if !cfg!(target_os = "macos") { + return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into()); + } + let mut child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?; + let start = Instant::now(); + loop { + if let Ok(Some(_)) = child.try_wait() { + let out = child.wait_with_output().map_err(|e| e.to_string())?; + let so = String::from_utf8_lossy(&out.stdout).trim_end().to_string(); + let se = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return if out.status.success() { Ok(so) } else { Err(se) }; + } + if cancel.load(Ordering::Relaxed) { + let _ = child.kill(); + return Err("已停止".into()); + } + if start.elapsed() > Duration::from_secs(SCRIPT_TIMEOUT_SECS) { + let _ = child.kill(); + return Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into()); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn is_running(app: &str, cancel: &AtomicBool) -> bool { + osascript(&format!("application {} is running", applescript_string(app)), cancel).map(|s| s == "true").unwrap_or(false) +} + +/// On-screen windows, front to back, the way the original's `listWindows` (sand_window_capture.node +/// 0x52c0) reads them: `CGWindowListCopyWindowInfo(0x11 = on-screen only | exclude desktop, 0)`. +const WINDOW_LIST_JXA: &str = "ObjC.import('CoreGraphics');var a=ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(0x11,0)))||[];JSON.stringify(a.map(function(w){var b=w.kCGWindowBounds||{};return {owner:w.kCGWindowOwnerName||'',layer:w.kCGWindowLayer||0,alpha:(w.kCGWindowAlpha===undefined?1:w.kCGWindowAlpha),w:b.Width||0,h:b.Height||0};}));"; + +/// App names of the windows that can be seen, front first. Same filter as `listWindows`: skip +/// fully transparent windows and ones with no width or height; plus only normal app windows +/// (layer 0), so menu-bar items and overlays don't count. +pub fn visible_window_owners(json: &str) -> Vec { + let v: Value = serde_json::from_str(json).unwrap_or(Value::Null); + v.as_array() + .map(|a| { + a.iter() + .filter(|w| w["alpha"].as_f64().unwrap_or(1.0) > 0.0 && w["w"].as_f64().unwrap_or(0.0) > 0.0 && w["h"].as_f64().unwrap_or(0.0) > 0.0 && w["layer"].as_i64().unwrap_or(0) == 0) + .filter_map(|w| w["owner"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn front_window_owners(cancel: &AtomicBool) -> Vec { + if !cfg!(target_os = "macos") { + return vec![]; + } + let Ok(mut child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] }; + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(10) && !cancel.load(Ordering::Relaxed) { + if let Ok(Some(_)) = child.try_wait() { + return child.wait_with_output().map(|o| visible_window_owners(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default(); + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = child.kill(); + vec![] +} + +/// The browser whose window is closest to the front: the one the person is looking at. +pub fn frontmost_browser(owners: &[String]) -> Option<(&'static str, bool)> { + owners.iter().find_map(|o| BROWSERS.iter().find(|(name, _)| name == o).copied()) +} + +/// The browser to use: the front-most browser window, else the first one that's open, else +/// Chrome if installed, else Safari. +fn pick_browser(cancel: &AtomicBool) -> (&'static str, bool) { + if let Some(b) = frontmost_browser(&front_window_owners(cancel)) { + return b; + } + for (name, chromium) in BROWSERS { + if is_running(name, cancel) { + return (name, chromium); + } + } + if std::path::Path::new("/Applications/Google Chrome.app").exists() { + return ("Google Chrome", true); + } + ("Safari", false) +} + +/// macOS refused to let AnyBot control the browser (-1743): open the Automation settings pane +/// right away so the switch is in front of the person, and say what to turn on. +fn explain(err: &str, browser: &str) -> String { + let lower = err.to_lowercase(); + if err.contains("-1743") || lower.contains("not authorized") || lower.contains("不允許") { + let _ = Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn(); + format!("macOS 擋下了 AnyBot 控制 {browser}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{browser}」,然後再叫我試一次。") + } else if lower.contains("javascript") { + if browser == "Safari" { + format!("Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。") + } else { + format!("{browser} 還沒允許讀取網頁內容:請在 {browser} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。") + } + } else { + format!("{browser} 沒有回應:{err}") + } +} + +fn browser_open(url: &str, cancel: &AtomicBool) -> Result { + let url = normalize_url(url)?; + if !cfg!(target_os = "macos") { + // Linux: the person's default browser. + crate::preview::system_open(&url).status().map_err(|e| format!("打不開瀏覽器:{e}"))?; + return Ok(format!("已在你的預設瀏覽器打開 {url}。")); + } + let (browser, chromium) = pick_browser(cancel); + // A Chromium browser started without a folder hands the link to the person's own running + // window (their normal profile), never to AnyBot's own window, which `open -a` might pick. + let exe = app_exe(browser); + let handed = chromium + && std::path::Path::new(&exe).exists() + && { + let mut c = Command::new(&exe); + c.arg(&url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + c.spawn().is_ok() + }; + if !handed { + let st = Command::new("open").arg("-a").arg(browser).arg(&url).status().map_err(|e| e.to_string())?; + if !st.success() { + Command::new("open").arg(&url).status().map_err(|e| e.to_string())?; + } + } + let until = Instant::now() + Duration::from_secs(OPEN_SETTLE_SECS); + while Instant::now() < until { + if cancel.load(Ordering::Relaxed) { + return Err("已停止".into()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Ok(format!("已在 {browser} 打開 {url}。接著可以用 browser_read 讀這個分頁。")) +} + +/// The main executable of a browser app in /Applications. +pub fn app_exe(browser: &str) -> String { + format!("/Applications/{browser}.app/Contents/MacOS/{browser}") +} + +fn browser_read(max_chars: usize, cancel: &AtomicBool) -> Result { + let (browser, chromium) = pick_browser(cancel); + // First through accessibility, like Grok Bot's Computer Use helper: no browser setting, and + // it reads whichever profile's window the person has in front. + match crate::axread::read_person_browser(&app_exe(browser), max_chars) { + Ok(page) if !page.text.trim().is_empty() => { + return Ok(format!("瀏覽器:{browser}(你的瀏覽器)\n標題:{}\n網址:{}\n\n{}", page.title, page.url, clip(&page.text, max_chars))); + } + Err(e) if e.contains("輔助使用") => return Err(e), + _ => {} + } + let app = applescript_string(browser); + let (title_s, url_s, text_s) = if chromium { + ( + format!("tell application {app} to get title of active tab of front window"), + format!("tell application {app} to get URL of active tab of front window"), + format!("tell application {app} to execute active tab of front window javascript \"document.body ? document.body.innerText : ''\""), + ) + } else { + ( + format!("tell application {app} to get name of current tab of front window"), + format!("tell application {app} to get URL of current tab of front window"), + format!("tell application {app} to do JavaScript \"document.body ? document.body.innerText : ''\" in current tab of front window"), + ) + }; + let title = osascript(&title_s, cancel).map_err(|e| explain(&e, browser))?; + let url = osascript(&url_s, cancel).unwrap_or_default(); + match osascript(&text_s, cancel) { + Ok(text) => { + let text: String = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect::>().join("\n"); + Ok(format!("瀏覽器:{browser}\n標題:{title}\n網址:{url}\n\n{}", clip(&text, max_chars))) + } + // Title and address still help; say how to allow the page text. + Err(e) => Ok(format!("瀏覽器:{browser}\n標題:{title}\n網址:{url}\n\n(讀不到頁面文字){}", explain(&e, browser))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_becomes_readable_text() { + let html = "T

標題

第一段 文字 & 更多

中文
"; + assert_eq!(html_to_text(html), "標題\n第一段 文字 & 更多\n中文"); + } + + #[test] + fn urls_are_checked() { + assert_eq!(normalize_url("threads.net/@me").unwrap(), "https://threads.net/@me"); + assert!(normalize_url("not a url").is_err()); + assert!(normalize_url("").is_err()); + } + + #[test] + fn front_browser_follows_window_order() { + let json = r#"[{"owner":"Window Server","layer":24,"alpha":1,"w":1920,"h":30}, + {"owner":"Ghost","layer":0,"alpha":0,"w":800,"h":600}, + {"owner":"Safari","layer":0,"alpha":1,"w":0,"h":600}, + {"owner":"Finder","layer":0,"alpha":1,"w":800,"h":600}, + {"owner":"Google Chrome","layer":0,"alpha":1,"w":1200,"h":800}, + {"owner":"Safari","layer":0,"alpha":1,"w":1200,"h":800}]"#; + let owners = visible_window_owners(json); + assert_eq!(owners, vec!["Finder", "Google Chrome", "Safari"]); + assert_eq!(frontmost_browser(&owners), Some(("Google Chrome", true))); + assert_eq!(frontmost_browser(&["Finder".to_string()]), None); + } + + #[test] + fn automation_refusal_is_explained() { + assert!(explain("execution error: Not authorized to send Apple events to Google Chrome. (-1743)", "Google Chrome").contains("自動化")); + assert!(explain("Executing JavaScript through AppleScript is turned off.", "Google Chrome").contains("允許來自 Apple 事件的 JavaScript")); + } +} diff --git a/src/wl.rs b/src/wl.rs new file mode 100644 index 0000000..e6da344 --- /dev/null +++ b/src/wl.rs @@ -0,0 +1,582 @@ +// Linux Wayland on wlroots desktops (sway, Hyprland, river, labwc, Wayfire…), pure Rust through +// the compositor's own protocols: +// screenshots wlr-screencopy (every screen, placed where it is in the layout) +// mouse wlr-virtual-pointer (absolute moves over the whole layout) +// keyboard virtual-keyboard: AnyBot hands the compositor a small keymap with exactly the +// keys it needs, so any character (Chinese too) types directly, like `wtype` +// windows wlr-foreign-toplevel (which app is in front; Wayland gives no window places) +// idle time ext-idle-notify (a thread that hears "idle" / "back") +// GNOME and KDE don't offer these to apps; desk.rs uses the desktop portals there (portal.rs). + +use std::io::Write as _; +use std::os::fd::AsFd; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use wayland_client::globals::{registry_queue_init, GlobalListContents}; +use wayland_client::protocol::{wl_buffer, wl_output, wl_pointer, wl_registry, wl_seat, wl_shm, wl_shm_pool}; +use wayland_client::{delegate_noop, event_created_child, Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum}; +use wayland_protocols::ext::idle_notify::v1::client::{ext_idle_notification_v1, ext_idle_notifier_v1}; +use wayland_protocols::xdg::xdg_output::zv1::client::{zxdg_output_manager_v1, zxdg_output_v1}; +use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{zwp_virtual_keyboard_manager_v1, zwp_virtual_keyboard_v1}; +use wayland_protocols_wlr::foreign_toplevel::v1::client::{zwlr_foreign_toplevel_handle_v1 as top_handle, zwlr_foreign_toplevel_manager_v1 as top_manager}; +use wayland_protocols_wlr::screencopy::v1::client::{zwlr_screencopy_frame_v1 as frame_v1, zwlr_screencopy_manager_v1}; +use wayland_protocols_wlr::virtual_pointer::v1::client::{zwlr_virtual_pointer_manager_v1, zwlr_virtual_pointer_v1}; + +#[derive(Default, Clone)] +struct Out { + name: u32, + out: Option, + // Logical place and size (xdg-output when there is one, else from the mode and scale). + x: i32, + y: i32, + lw: i32, + lh: i32, + logical: bool, + mode_w: i32, + mode_h: i32, + scale: i32, +} + +impl Out { + fn rect(&self) -> (i32, i32, i32, i32) { + if self.logical && self.lw > 0 { + (self.x, self.y, self.lw, self.lh) + } else { + let s = self.scale.max(1); + (self.x, self.y, self.mode_w / s, self.mode_h / s) + } + } +} + +struct Top { + id: wayland_client::backend::ObjectId, + app: String, + active: bool, +} + +#[derive(Default)] +struct Frame { + buffer: Option<(WEnum, u32, u32, u32)>, + buffer_done: bool, + ready: bool, + failed: bool, + y_invert: bool, +} + +#[derive(Default)] +struct State { + outs: Vec, + tops: Vec, + frame: Frame, +} + +struct Wl { + conn: Connection, + queue: EventQueue, + qh: QueueHandle, + st: State, + shm: wl_shm::WlShm, + copy: zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1, + copy_version: u32, + pointer: zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1, + kb: zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1, + /// Keysyms on our keymap: index i is evdev key i + 1 (xkb keycode i + 9). + keys: Vec, + mods: u32, + start: Instant, +} + +static WL: Mutex> = Mutex::new(None); + +/// The four modifiers always sit on the first keys, with their xkb modifier bits. +const MODS: [(u32, u32, &str); 4] = [(0xffe1, 1, "Shift"), (0xffe3, 4, "Control"), (0xffe9, 8, "Mod1"), (0xffeb, 64, "Mod4")]; + +fn connect() -> Option { + let conn = Connection::connect_to_env().ok()?; + let (globals, mut queue) = registry_queue_init::(&conn).ok()?; + let qh = queue.handle(); + let shm: wl_shm::WlShm = globals.bind(&qh, 1..=1, ()).ok()?; + let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=1, ()).ok()?; + let copy: zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1 = globals.bind(&qh, 1..=3, ()).ok()?; + let copy_version = copy.version(); + let pm: zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 = globals.bind(&qh, 1..=2, ()).ok()?; + let km: zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1 = globals.bind(&qh, 1..=1, ()).ok()?; + let pointer = pm.create_virtual_pointer(Some(&seat), &qh, ()); + let kb = km.create_virtual_keyboard(&seat, &qh, ()); + let xdg: Option = globals.bind(&qh, 1..=3, ()).ok(); + let _tops: Option = globals.bind(&qh, 1..=3, ()).ok(); + let mut st = State::default(); + for g in globals.contents().clone_list() { + if g.interface == "wl_output" { + let out: wl_output::WlOutput = globals.registry().bind(g.name, g.version.min(4), &qh, g.name); + if let Some(x) = &xdg { + x.get_xdg_output(&out, &qh, g.name); + } + st.outs.push(Out { name: g.name, out: Some(out), scale: 1, ..Default::default() }); + } + } + queue.roundtrip(&mut st).ok()?; + queue.roundtrip(&mut st).ok()?; + if st.outs.is_empty() { + return None; + } + let mut wl = Wl { conn, queue, qh, st, shm, copy, copy_version, pointer, kb, keys: MODS.iter().map(|m| m.0).collect(), mods: 0, start: Instant::now() }; + wl.upload_keymap().ok()?; + Some(wl) +} + +fn with(f: impl FnOnce(&mut Wl) -> Option) -> Option { + let mut g = WL.lock().unwrap_or_else(|e| e.into_inner()); + if g.is_none() { + *g = connect(); + } + let wl = g.as_mut()?; + let _ = wl.queue.dispatch_pending(&mut wl.st); + let r = f(wl); + if wl.conn.flush().is_err() || wl.queue.roundtrip(&mut wl.st).is_err() { + // The compositor went away: connect again next time. + *g = None; + } + r +} + +/// This compositor lets AnyBot see and use the screen (screencopy, virtual pointer and keyboard). +pub fn available() -> bool { + with(|_| Some(())).is_some() +} + +/// A file only we can see, gone from the folder already (for shared memory and keymaps). +fn scratch_file(len: u64) -> std::io::Result { + let dir = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir); + let path = dir.join(format!("anybot-wl-{}-{}", std::process::id(), crate::model::now_ms())); + let f = std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(&path)?; + let _ = std::fs::remove_file(&path); + f.set_len(len)?; + Ok(f) +} + +/// The keymap text: one key per keysym we use; the first four are the modifiers. +pub fn keymap_text(keys: &[u32]) -> String { + let mut s = String::from("xkb_keymap {\nxkb_keycodes \"anybot\" {\n minimum = 8;\n"); + s += &format!(" maximum = {};\n", 9 + keys.len().max(1)); + for i in 0..keys.len() { + s += &format!(" = {};\n", i + 9); + } + s += "};\nxkb_types \"anybot\" { include \"complete\" };\nxkb_compat \"anybot\" { include \"complete\" };\nxkb_symbols \"anybot\" {\n"; + for (i, k) in keys.iter().enumerate() { + s += &format!(" key {{ [ 0x{k:x} ] }};\n"); + } + for (i, (_, _, name)) in MODS.iter().enumerate() { + s += &format!(" modifier_map {name} {{ }};\n"); + } + s += "};\n};\n"; + s +} + +impl Wl { + fn time(&self) -> u32 { + self.start.elapsed().as_millis() as u32 + } + + fn upload_keymap(&mut self) -> std::io::Result<()> { + let mut text = keymap_text(&self.keys).into_bytes(); + text.push(0); + let mut f = scratch_file(0)?; + f.write_all(&text)?; + self.kb.keymap(1, f.as_fd(), text.len() as u32); + let _ = self.conn.flush(); + Ok(()) + } + + /// The evdev key that types `sym`, adding it to our keymap when it's new. + fn key_for(&mut self, sym: u32) -> u32 { + if let Some(i) = self.keys.iter().position(|k| *k == sym) { + return i as u32 + 1; + } + if self.keys.len() > 200 { + self.keys.truncate(MODS.len()); + } + self.keys.push(sym); + let _ = self.upload_keymap(); + let _ = self.queue.roundtrip(&mut self.st); + self.keys.len() as u32 + } + + fn key(&mut self, sym: u32, press: bool) { + let code = self.key_for(sym); + let t = self.time(); + self.kb.key(t, code, press as u32); + if let Some((_, bit, _)) = MODS.iter().find(|m| m.0 == sym) { + self.mods = if press { self.mods | bit } else { self.mods & !bit }; + self.kb.modifiers(self.mods, 0, 0, 0); + } + } + + /// The whole layout: (left, top, width, height) in logical points. + fn layout(&self) -> (i32, i32, i32, i32) { + let rects: Vec<_> = self.st.outs.iter().map(|o| o.rect()).collect(); + let x0 = rects.iter().map(|r| r.0).min().unwrap_or(0); + let y0 = rects.iter().map(|r| r.1).min().unwrap_or(0); + let x1 = rects.iter().map(|r| r.0 + r.2).max().unwrap_or(0); + let y1 = rects.iter().map(|r| r.1 + r.3).max().unwrap_or(0); + (x0, y0, (x1 - x0).max(1), (y1 - y0).max(1)) + } + + fn pixel_scale(&self) -> i32 { + self.st.outs.iter().map(|o| o.scale.max(1)).max().unwrap_or(1) + } + + fn capture_output(&mut self, out: &wl_output::WlOutput) -> Option { + self.st.frame = Frame::default(); + let frame = self.copy.capture_output(0, out, &self.qh, ()); + let deadline = Instant::now() + Duration::from_secs(5); + while !(self.st.frame.failed || (self.st.frame.buffer.is_some() && (self.st.frame.buffer_done || self.copy_version < 3))) { + if Instant::now() > deadline { + frame.destroy(); + return None; + } + self.queue.blocking_dispatch(&mut self.st).ok()?; + } + let (format, w, h, stride) = self.st.frame.buffer?; + let WEnum::Value(fmt) = format else { + frame.destroy(); + return None; + }; + let size = (stride * h) as u64; + let file = scratch_file(size).ok()?; + let pool = self.shm.create_pool(file.as_fd(), size as i32, &self.qh, ()); + let buffer = pool.create_buffer(0, w as i32, h as i32, stride as i32, fmt, &self.qh, ()); + frame.copy(&buffer); + while !(self.st.frame.ready || self.st.frame.failed) { + if Instant::now() > deadline { + break; + } + self.queue.blocking_dispatch(&mut self.st).ok()?; + } + let ok = self.st.frame.ready; + let y_invert = self.st.frame.y_invert; + frame.destroy(); + buffer.destroy(); + pool.destroy(); + if !ok { + return None; + } + let map = unsafe { memmap2::Mmap::map(&file) }.ok()?; + to_rgba(&map, fmt, w, h, stride, y_invert) + } + + fn capture(&mut self) -> Option { + let (x0, y0, lw, lh) = self.layout(); + let s = self.pixel_scale(); + let mut canvas = image::RgbaImage::new((lw * s) as u32, (lh * s) as u32); + let outs = self.st.outs.clone(); + let mut any = false; + for o in outs { + let Some(out) = &o.out else { continue }; + let Some(mut img) = self.capture_output(out) else { continue }; + let (ox, oy, ow, oh) = o.rect(); + let (tw, th) = ((ow * s) as u32, (oh * s) as u32); + if img.dimensions() != (tw, th) && tw > 0 && th > 0 { + img = image::imageops::resize(&img, tw, th, image::imageops::FilterType::Triangle); + } + image::imageops::replace(&mut canvas, &img, ((ox - x0) * s) as i64, ((oy - y0) * s) as i64); + any = true; + } + any.then(|| image::DynamicImage::ImageRgba8(canvas).to_rgb8()) + } +} + +/// Shared-memory pixels → RGBA. The 8-bit formats every compositor offers. +pub fn to_rgba(data: &[u8], fmt: wl_shm::Format, w: u32, h: u32, stride: u32, y_invert: bool) -> Option { + // In memory (little-endian): XRGB/ARGB = B G R X; XBGR/ABGR = R G B X. + let bgr = match fmt { + wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888 => true, + wl_shm::Format::Xbgr8888 | wl_shm::Format::Abgr8888 => false, + _ => return None, + }; + let mut out = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h { + let r = if y_invert { h - 1 - row } else { row }; + let line = data.get((r * stride) as usize..(r * stride + w * 4) as usize)?; + for px in line.chunks_exact(4) { + if bgr { + out.extend_from_slice(&[px[2], px[1], px[0], 255]); + } else { + out.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + } + } + image::RgbaImage::from_raw(w, h, out) +} + +// ---------- what desk.rs calls ---------- + +pub fn capture() -> Result { + with(|wl| wl.capture()).ok_or_else(|| "讀不到螢幕畫面(Wayland 截圖失敗)".to_string()) +} + +/// The layout in logical points (where the mouse goes). +pub fn screen_points() -> (f64, f64) { + with(|wl| { + let (_, _, w, h) = wl.layout(); + Some((w as f64, h as f64)) + }) + .unwrap_or((0.0, 0.0)) +} + +/// The screenshot's size in pixels. +pub fn screen_pixels() -> (u32, u32) { + with(|wl| { + let (_, _, w, h) = wl.layout(); + let s = wl.pixel_scale(); + Some(((w * s) as u32, (h * s) as u32)) + }) + .unwrap_or((0, 0)) +} + +pub fn motion(px: f64, py: f64) { + with(|wl| { + let (_, _, w, h) = wl.layout(); + let t = wl.time(); + let x = px.round().clamp(0.0, (w - 1) as f64) as u32; + let y = py.round().clamp(0.0, (h - 1) as f64) as u32; + wl.pointer.motion_absolute(t, x, y, w as u32, h as u32); + wl.pointer.frame(); + Some(()) + }); +} + +/// 1 left, 2 middle, 3 right (the X numbering desk.rs uses). +pub fn button(b: u8, press: bool) { + let code = match b { + 3 => 0x111, + 2 => 0x112, + _ => 0x110, + }; + with(|wl| { + let t = wl.time(); + wl.pointer.button(t, code, if press { wl_pointer::ButtonState::Pressed } else { wl_pointer::ButtonState::Released }); + wl.pointer.frame(); + Some(()) + }); +} + +/// Wheel clicks: positive `up` scrolls up, positive `left` scrolls left (like X buttons 4 and 6). +pub fn wheel(up: i32, left: i32) { + with(|wl| { + for (n, axis) in [(up, wl_pointer::Axis::VerticalScroll), (left, wl_pointer::Axis::HorizontalScroll)] { + if n == 0 { + continue; + } + let t = wl.time(); + wl.pointer.axis_source(wl_pointer::AxisSource::Wheel); + wl.pointer.axis_discrete(t, axis, -15.0 * n as f64, -n); + wl.pointer.frame(); + } + Some(()) + }); +} + +pub fn key(sym: u32, press: bool) { + with(|wl| { + wl.key(sym, press); + Some(()) + }); +} + +/// Any text, Chinese too: each character's keysym goes on our keymap and is pressed. +pub fn type_text(text: &str) { + for c in text.chars() { + let sym = crate::x11::keysym_for_char(c); + with(|wl| { + wl.key(sym, true); + Some(()) + }); + std::thread::sleep(Duration::from_millis(8)); + with(|wl| { + wl.key(sym, false); + Some(()) + }); + std::thread::sleep(Duration::from_millis(8)); + } +} + +/// The app in front (its Wayland app id, e.g. "firefox", "foot"). +pub fn front_app() -> Option<(String, i32)> { + with(|wl| wl.st.tops.iter().find(|t| t.active && !t.app.is_empty()).map(|t| (t.app.clone(), 0))) +} + +// ---------- idle time ---------- + +static IDLE: Mutex>> = Mutex::new(None); + +struct IdleState; + +/// Seconds since the last input, from ext-idle-notify (a thread listening with a 1 s threshold). +/// None when the compositor doesn't offer it. +pub fn idle_secs() -> Option { + { + let g = IDLE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(since) = *g { + return Some(since.map(|t| t.elapsed().as_secs_f64() + 1.0).unwrap_or(0.0)); + } + } + static STARTED: std::sync::Once = std::sync::Once::new(); + STARTED.call_once(|| { + std::thread::spawn(|| { + let Ok(conn) = Connection::connect_to_env() else { return }; + let Ok((globals, mut queue)) = registry_queue_init::(&conn) else { return }; + let qh = queue.handle(); + let Ok(seat) = globals.bind::(&qh, 1..=1, ()) else { return }; + let Ok(notifier) = globals.bind::(&qh, 1..=1, ()) else { return }; + notifier.get_idle_notification(1000, &seat, &qh, ()); + *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = Some(None); + let mut st = IdleState; + while queue.blocking_dispatch(&mut st).is_ok() {} + *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None; + }); + }); + None +} + +impl Dispatch for IdleState { + fn event(_: &mut Self, _: &ext_idle_notification_v1::ExtIdleNotificationV1, event: ext_idle_notification_v1::Event, _: &(), _: &Connection, _: &QueueHandle) { + let v = match event { + ext_idle_notification_v1::Event::Idled => Some(Instant::now()), + _ => None, + }; + *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = Some(v); + } +} + +impl Dispatch for IdleState { + fn event(_: &mut Self, _: &wl_registry::WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} +} +delegate_noop!(IdleState: ignore wl_seat::WlSeat); +delegate_noop!(IdleState: ignore ext_idle_notifier_v1::ExtIdleNotifierV1); + +// ---------- events ---------- + +impl Dispatch for State { + fn event(_: &mut Self, _: &wl_registry::WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} +} + +impl Dispatch for State { + fn event(st: &mut Self, _: &wl_output::WlOutput, event: wl_output::Event, name: &u32, _: &Connection, _: &QueueHandle) { + let Some(o) = st.outs.iter_mut().find(|o| o.name == *name) else { return }; + match event { + wl_output::Event::Geometry { x, y, .. } if !o.logical => { + o.x = x; + o.y = y; + } + wl_output::Event::Mode { flags: WEnum::Value(f), width, height, .. } if f.contains(wl_output::Mode::Current) => { + o.mode_w = width; + o.mode_h = height; + } + wl_output::Event::Scale { factor } => o.scale = factor.max(1), + _ => {} + } + } +} + +impl Dispatch for State { + fn event(st: &mut Self, _: &zxdg_output_v1::ZxdgOutputV1, event: zxdg_output_v1::Event, name: &u32, _: &Connection, _: &QueueHandle) { + let Some(o) = st.outs.iter_mut().find(|o| o.name == *name) else { return }; + match event { + zxdg_output_v1::Event::LogicalPosition { x, y } => { + o.x = x; + o.y = y; + o.logical = true; + } + zxdg_output_v1::Event::LogicalSize { width, height } => { + o.lw = width; + o.lh = height; + o.logical = true; + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event(st: &mut Self, _: &frame_v1::ZwlrScreencopyFrameV1, event: frame_v1::Event, _: &(), _: &Connection, _: &QueueHandle) { + let f = &mut st.frame; + match event { + frame_v1::Event::Buffer { format, width, height, stride } => { + // Prefer a format we can read when several are offered. + let readable = matches!(format, WEnum::Value(wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888 | wl_shm::Format::Xbgr8888 | wl_shm::Format::Abgr8888)); + if f.buffer.is_none() || readable { + f.buffer = Some((format, width, height, stride)); + } + } + frame_v1::Event::BufferDone => f.buffer_done = true, + frame_v1::Event::Flags { flags: WEnum::Value(fl) } => f.y_invert = fl.contains(frame_v1::Flags::YInvert), + frame_v1::Event::Ready { .. } => f.ready = true, + frame_v1::Event::Failed => f.failed = true, + _ => {} + } + } +} + +impl Dispatch for State { + fn event(st: &mut Self, _: &top_manager::ZwlrForeignToplevelManagerV1, event: top_manager::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let top_manager::Event::Toplevel { toplevel } = event { + st.tops.push(Top { id: toplevel.id(), app: String::new(), active: false }); + } + } + + event_created_child!(State, top_manager::ZwlrForeignToplevelManagerV1, [ + top_manager::EVT_TOPLEVEL_OPCODE => (top_handle::ZwlrForeignToplevelHandleV1, ()), + ]); +} + +impl Dispatch for State { + fn event(st: &mut Self, h: &top_handle::ZwlrForeignToplevelHandleV1, event: top_handle::Event, _: &(), _: &Connection, _: &QueueHandle) { + let id = h.id(); + match event { + top_handle::Event::Closed => st.tops.retain(|t| t.id != id), + top_handle::Event::AppId { app_id } => { + if let Some(t) = st.tops.iter_mut().find(|t| t.id == id) { + t.app = app_id; + } + } + top_handle::Event::State { state } => { + let active = state.chunks_exact(4).any(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]) == top_handle::State::Activated as u32); + if let Some(t) = st.tops.iter_mut().find(|t| t.id == id) { + t.active = active; + } + } + _ => {} + } + } +} + +delegate_noop!(State: ignore wl_shm::WlShm); +delegate_noop!(State: ignore wl_shm_pool::WlShmPool); +delegate_noop!(State: ignore wl_buffer::WlBuffer); +delegate_noop!(State: ignore wl_seat::WlSeat); +delegate_noop!(State: ignore zxdg_output_manager_v1::ZxdgOutputManagerV1); +delegate_noop!(State: ignore zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1); +delegate_noop!(State: ignore zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1); +delegate_noop!(State: ignore zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1); +delegate_noop!(State: ignore zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1); +delegate_noop!(State: ignore zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keymap_and_pixels() { + let k = keymap_text(&[0xffe1, 0xffe3, 0xffe9, 0xffeb, 0x1004f60]); + assert!(k.contains(" = 13;")); + assert!(k.contains("key { [ 0x1004f60 ] };")); + assert!(k.contains("modifier_map Control { };")); + // One BGRX pixel, upside down flag on a 1×2 image. + let px = to_rgba(&[1, 2, 3, 0, 4, 5, 6, 0], wl_shm::Format::Xrgb8888, 1, 2, 4, true).unwrap(); + assert_eq!(px.get_pixel(0, 0).0, [6, 5, 4, 255]); + let px = to_rgba(&[1, 2, 3, 0], wl_shm::Format::Abgr8888, 1, 1, 4, false).unwrap(); + assert_eq!(px.get_pixel(0, 0).0, [1, 2, 3, 255]); + } +} diff --git a/src/x11.rs b/src/x11.rs new file mode 100644 index 0000000..d75ad6b --- /dev/null +++ b/src/x11.rs @@ -0,0 +1,564 @@ +// Linux: seeing and using the screen through X11 (x11rb, pure Rust): screenshots with GetImage, +// mouse and keyboard with the XTEST extension, windows from the window manager's EWMH hints, +// idle time from the MIT-SCREEN-SAVER extension. The same functions as computer.rs's macOS part, +// so the computer_use tools work the same way. +// +// Wayland sessions go through wl.rs or portal.rs instead (desk.rs picks). + +use std::sync::Mutex; +use std::time::Duration; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{self, AtomEnum, ConnectionExt as _, ImageFormat, Window}; +use x11rb::protocol::xtest::ConnectionExt as _; +use x11rb::rust_connection::RustConnection; + +struct X { + conn: RustConnection, + root: Window, + width: u16, + height: u16, +} + +static CONN: Mutex> = Mutex::new(None); + +fn with(f: impl FnOnce(&X) -> Option) -> Option { + let mut g = CONN.lock().unwrap_or_else(|e| e.into_inner()); + if g.is_none() { + let (conn, n) = x11rb::connect(None).ok()?; + let s = &conn.setup().roots[n]; + let (root, width, height) = (s.root, s.width_in_pixels, s.height_in_pixels); + *g = Some(X { conn, root, width, height }); + } + let r = f(g.as_ref()?); + if r.is_none() { + // A broken connection (the X server restarted): connect again next time. + if g.as_ref().map(|x| x.conn.get_input_focus().ok().and_then(|c| c.reply().ok()).is_none()).unwrap_or(true) { + *g = None; + } + } + r +} + +pub fn available() -> bool { + with(|_| Some(())).is_some() +} + +/// The whole screen as RGB. +pub fn capture() -> Result { + with(|x| { + let reply = x.conn.get_image(ImageFormat::Z_PIXMAP, x.root, 0, 0, x.width, x.height, !0).ok()?.reply().ok()?; + let setup = x.conn.setup(); + let bpp = setup.pixmap_formats.iter().find(|f| f.depth == reply.depth).map(|f| f.bits_per_pixel).unwrap_or(32); + if bpp != 32 { + return None; + } + let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST; + let (w, h) = (x.width as u32, x.height as u32); + let mut rgb = Vec::with_capacity((w * h * 3) as usize); + for px in reply.data.chunks_exact(4).take((w * h) as usize) { + if lsb { + rgb.extend_from_slice(&[px[2], px[1], px[0]]); + } else { + rgb.extend_from_slice(&[px[1], px[2], px[3]]); + } + } + image::RgbImage::from_raw(w, h, rgb) + }) + .ok_or_else(|| "讀不到螢幕畫面(需要 X11 桌面;Wayland 請在登入畫面選「Xorg」工作階段)".to_string()) +} + +pub fn screen_points() -> (f64, f64) { + with(|x| Some((x.width as f64, x.height as f64))).unwrap_or((0.0, 0.0)) +} + +pub fn screen_pixels() -> (u32, u32) { + with(|x| Some((x.width as u32, x.height as u32))).unwrap_or((0, 0)) +} + +fn fake(x: &X, kind: u8, detail: u8, px: i16, py: i16) { + let _ = x.conn.xtest_fake_input(kind, detail, 0, x.root, px, py, 0); +} + +fn flush(x: &X) { + let _ = x.conn.flush(); + let _ = x.conn.get_input_focus().ok().and_then(|c| c.reply().ok()); +} + +fn sleep_secs(s: f64) { + std::thread::sleep(Duration::from_secs_f64(s)); +} + +fn gap() { + std::thread::sleep(Duration::from_millis(8)); +} + +pub fn move_to(px: f64, py: f64) { + with(|x| { + fake(x, xproto::MOTION_NOTIFY_EVENT, 0, px.round() as i16, py.round() as i16); + flush(x); + Some(()) + }); +} + +fn button_no(button: &str) -> u8 { + match button { + "right" => 3, + "middle" => 2, + _ => 1, + } +} + +/// Hold the modifiers in `flags` (macOS flag bits) while `f` runs. +fn with_mods(flags: u64, f: impl FnOnce()) { + let mods = modifier_keysyms(flags); + let codes: Vec = mods.iter().filter_map(|k| keycode_for(*k)).collect(); + with(|x| { + for c in &codes { + fake(x, xproto::KEY_PRESS_EVENT, *c, 0, 0); + } + flush(x); + Some(()) + }); + f(); + with(|x| { + for c in codes.iter().rev() { + fake(x, xproto::KEY_RELEASE_EVENT, *c, 0, 0); + } + flush(x); + Some(()) + }); +} + +pub fn click(px: f64, py: f64, button: &str, count: i64, flags: u64) { + move_to(px, py); + gap(); + let b = button_no(button); + with_mods(flags, || { + for n in 1..=count { + if n > 1 { + sleep_secs(crate::computer::CLICK_REPEAT_GAP_SECS); + } + with(|x| { + fake(x, xproto::BUTTON_PRESS_EVENT, b, 0, 0); + flush(x); + Some(()) + }); + sleep_secs(crate::computer::CLICK_HOLD_SECS); + with(|x| { + fake(x, xproto::BUTTON_RELEASE_EVENT, b, 0, 0); + flush(x); + Some(()) + }); + } + }); +} + +pub fn mouse_button(at: Option<(f64, f64)>, button: &str, press: bool) { + if let Some((px, py)) = at { + move_to(px, py); + gap(); + } + let b = button_no(button); + with(|x| { + fake(x, if press { xproto::BUTTON_PRESS_EVENT } else { xproto::BUTTON_RELEASE_EVENT }, b, 0, 0); + flush(x); + Some(()) + }); +} + +pub fn drag(path: &[(f64, f64)], button: &str, flags: u64) { + let (x0, y0) = path[0]; + move_to(x0, y0); + gap(); + let b = button_no(button); + with_mods(flags, || { + with(|x| { + fake(x, xproto::BUTTON_PRESS_EVENT, b, 0, 0); + flush(x); + Some(()) + }); + sleep_secs(crate::computer::DRAG_HOLD_SECS); + for (px, py) in crate::computer::drag_points(path) { + gap(); + move_to(px, py); + } + with(|x| { + fake(x, xproto::BUTTON_RELEASE_EVENT, b, 0, 0); + flush(x); + Some(()) + }); + }); +} + +/// Wheel clicks: one per scroll step (`SCROLL_PIXELS_PER_STEP` pixels on macOS). +pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) { + if let Some((px, py)) = at { + move_to(px, py); + gap(); + } + let step = crate::computer::SCROLL_PIXELS_PER_STEP; + let (vy, vx) = (dy / step, dx / step); + with_mods(flags, || { + with(|x| { + for (n, up, down) in [(vy, 4u8, 5u8), (vx, 6, 7)] { + let b = if n > 0 { up } else { down }; + for _ in 0..n.abs() { + fake(x, xproto::BUTTON_PRESS_EVENT, b, 0, 0); + fake(x, xproto::BUTTON_RELEASE_EVENT, b, 0, 0); + flush(x); + std::thread::sleep(Duration::from_millis(12)); + } + } + Some(()) + }); + }); +} + +// ---------- keyboard ---------- + +const XK_SHIFT_L: u32 = 0xffe1; +const XK_CONTROL_L: u32 = 0xffe3; +const XK_ALT_L: u32 = 0xffe9; + +/// macOS flag bits → X keysyms. ⌘ becomes Control on Linux (⌘C = Ctrl+C, ⌘V = Ctrl+V…). +pub fn modifier_keysyms(flags: u64) -> Vec { + use crate::computer::{FLAG_CMD, FLAG_CONTROL, FLAG_OPTION, FLAG_SHIFT}; + let mut v = vec![]; + if flags & (FLAG_CONTROL | FLAG_CMD) != 0 { + v.push(XK_CONTROL_L); + } + if flags & FLAG_SHIFT != 0 { + v.push(XK_SHIFT_L); + } + if flags & FLAG_OPTION != 0 { + v.push(XK_ALT_L); + } + v +} + +/// macOS virtual key code (what `computer::key_code` gives) → X keysym. +pub fn keysym_of_mac_code(code: u16) -> Option { + let named: &[(u16, u32)] = &[ + (36, 0xff0d), (48, 0xff09), (49, 0x20), (51, 0xff08), (53, 0xff1b), (117, 0xffff), (115, 0xff50), (119, 0xff57), (116, 0xff55), (121, 0xff56), + (123, 0xff51), (124, 0xff53), (125, 0xff54), (126, 0xff52), (122, 0xffbe), (120, 0xffbf), (99, 0xffc0), (118, 0xffc1), (96, 0xffc2), (97, 0xffc3), + (98, 0xffc4), (100, 0xffc5), (101, 0xffc6), (109, 0xffc7), (103, 0xffc8), (111, 0xffc9), + ]; + if let Some((_, k)) = named.iter().find(|(c, _)| *c == code) { + return Some(*k); + } + // Letters, digits and punctuation: the character whose key code this is. + "abcdefghijklmnopqrstuvwxyz0123456789-=[];',./\\`".chars().find(|c| crate::computer::key_code(&c.to_string()) == Some(code)).map(|c| c as u32) +} + +/// The key that types a keysym in the current layout (first level only, so no Shift needed). +fn keycode_for(keysym: u32) -> Option { + with(|x| { + let setup = x.conn.setup(); + let (min, max) = (setup.min_keycode, setup.max_keycode); + let m = x.conn.get_keyboard_mapping(min, max - min + 1).ok()?.reply().ok()?; + let per = m.keysyms_per_keycode as usize; + m.keysyms.chunks(per).position(|ks| ks.first() == Some(&keysym)).map(|i| min + i as u8) + }) +} + +pub fn press(code: u16, flags: u64, hold_secs: f64) { + let Some(sym) = keysym_of_mac_code(code) else { return }; + with_mods(flags, || { + let Some(kc) = keycode_for(sym) else { return }; + with(|x| { + fake(x, xproto::KEY_PRESS_EVENT, kc, 0, 0); + flush(x); + Some(()) + }); + sleep_secs(hold_secs); + with(|x| { + fake(x, xproto::KEY_RELEASE_EVENT, kc, 0, 0); + flush(x); + Some(()) + }); + }); +} + +/// The keysym that types a character. +pub fn keysym_for_char(c: char) -> u32 { + match c { + '\n' | '\r' => 0xff0d, + '\t' => 0xff09, + c if (' '..='~').contains(&c) || ('\u{a0}'..='\u{ff}').contains(&c) => c as u32, + c => 0x0100_0000 + c as u32, + } +} + +/// Type any text (Chinese too): each character is put on a spare key for a moment and that key +/// is pressed — the way xdotool types characters the keyboard layout doesn't have. +pub fn type_text(text: &str) { + with(|x| { + let setup = x.conn.setup(); + let (min, max) = (setup.min_keycode, setup.max_keycode); + let m = x.conn.get_keyboard_mapping(min, max - min + 1).ok()?.reply().ok()?; + let per = m.keysyms_per_keycode as usize; + // A key with nothing on it (from the top, where they usually are). + let spare = m.keysyms.chunks(per).enumerate().rev().find(|(_, ks)| ks.iter().all(|k| *k == 0)).map(|(i, _)| min + i as u8)?; + for c in text.chars() { + let sym = keysym_for_char(c); + let syms = vec![sym; per]; + x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).ok()?; + flush(x); + std::thread::sleep(Duration::from_millis(12)); + fake(x, xproto::KEY_PRESS_EVENT, spare, 0, 0); + flush(x); + std::thread::sleep(Duration::from_millis(8)); + fake(x, xproto::KEY_RELEASE_EVENT, spare, 0, 0); + flush(x); + std::thread::sleep(Duration::from_millis(8)); + } + let _ = x.conn.change_keyboard_mapping(1, spare, per as u8, &vec![0; per]); + flush(x); + Some(()) + }); +} + +// ---------- windows ---------- + +fn atom(x: &X, name: &str) -> Option { + Some(x.conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok()?.atom) +} + +fn prop_u32s(x: &X, win: Window, name: &str, kind: AtomEnum) -> Vec { + let Some(a) = atom(x, name) else { return vec![] }; + x.conn.get_property(false, win, a, kind, 0, 4096).ok().and_then(|c| c.reply().ok()).and_then(|r| r.value32().map(|v| v.collect())).unwrap_or_default() +} + +/// A window's app: (name, pid). The name is the WM_CLASS class ("Firefox", "Gedit"…), else the +/// process name. +fn app_of(x: &X, win: Window) -> (String, i32) { + let pid = prop_u32s(x, win, "_NET_WM_PID", AtomEnum::CARDINAL).first().copied().unwrap_or(0) as i32; + let class = x + .conn + .get_property(false, win, AtomEnum::WM_CLASS, AtomEnum::STRING, 0, 256) + .ok() + .and_then(|c| c.reply().ok()) + .map(|r| r.value.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect::>()) + .and_then(|v| v.last().cloned()) + .unwrap_or_default(); + let name = if !class.is_empty() { class } else if pid > 0 { std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default().trim().to_string() } else { String::new() }; + (name, pid) +} + +/// Top-level app windows, front first, with their place on screen. +fn windows(x: &X) -> Vec<(Window, (i32, i32, i32, i32))> { + let mut list = prop_u32s(x, x.root, "_NET_CLIENT_LIST_STACKING", AtomEnum::WINDOW); + if list.is_empty() { + list = x.conn.query_tree(x.root).ok().and_then(|c| c.reply().ok()).map(|t| t.children).unwrap_or_default(); + } + list.reverse(); + list.into_iter() + .filter_map(|w| { + let g = x.conn.get_geometry(w).ok()?.reply().ok()?; + let t = x.conn.translate_coordinates(w, x.root, 0, 0).ok()?.reply().ok()?; + let attrs = x.conn.get_window_attributes(w).ok()?.reply().ok()?; + (attrs.map_state == xproto::MapState::VIEWABLE && g.width > 1 && g.height > 1).then_some((w, (t.dst_x as i32, t.dst_y as i32, g.width as i32, g.height as i32))) + }) + .collect() +} + +pub fn window_at(px: f64, py: f64) -> Option<(String, i32)> { + with(|x| { + let (w, _) = windows(x).into_iter().find(|(_, (wx, wy, ww, wh))| px >= *wx as f64 && py >= *wy as f64 && px < (*wx + *ww) as f64 && py < (*wy + *wh) as f64)?; + Some(app_of(x, w)).filter(|a| !a.0.is_empty()) + }) +} + +pub fn front_app() -> Option<(String, i32)> { + with(|x| { + let active = prop_u32s(x, x.root, "_NET_ACTIVE_WINDOW", AtomEnum::WINDOW).first().copied().filter(|w| *w != 0); + let w = active.or_else(|| windows(x).first().map(|w| w.0))?; + Some(app_of(x, w)).filter(|a| !a.0.is_empty()) + }) +} + +/// Seconds since the last keyboard or mouse input. +pub fn idle_secs() -> Option { + use x11rb::protocol::screensaver::ConnectionExt as _; + with(|x| Some(x.conn.screensaver_query_info(x.root).ok()?.reply().ok()?.ms_since_user_input as f64 / 1000.0)) +} + +/// The X screensaver is on (blanked screen). +pub fn screensaver_on() -> bool { + use x11rb::protocol::screensaver::ConnectionExt as _; + with(|x| Some(x.conn.screensaver_query_info(x.root).ok()?.reply().ok()?.state == 1)).unwrap_or(false) +} + +// ---------- watching the person's input (示範 recording) ---------- + +/// A mouse click or key press the person made, from the X RECORD extension. +#[derive(Debug, Clone)] +pub enum Input { + Click { x: f64, y: f64, right: bool }, + /// `mods`: 1 shift, 4 control, 8 alt, 64 super (X's modifier bits). + Key { text: String, keysym: u32, mods: u16 }, + /// +1 up / −1 down. + Scroll { dy: i64 }, +} + +/// Watch the person's clicks and keys until `keep_going` says stop. Runs on its own thread +/// with its own connections (RECORD needs one that only listens). +pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send + 'static) -> Result<(), String> { + use x11rb::protocol::record::{self, ConnectionExt as _}; + let (ctrl, _) = x11rb::connect(None).map_err(|e| format!("連不上 X11:{e}"))?; + let (data, _) = x11rb::connect(None).map_err(|e| format!("連不上 X11:{e}"))?; + let ctx = ctrl.generate_id().map_err(|e| e.to_string())?; + let range = record::Range { + device_events: record::Range8 { first: xproto::KEY_PRESS_EVENT, last: xproto::BUTTON_PRESS_EVENT }, + ..Default::default() + }; + ctrl.record_create_context(ctx, 0, &[record::CS::ALL_CLIENTS.into()], &[range]).map_err(|e| e.to_string())?.check().map_err(|e| format!("這個 X 伺服器不支援錄製輸入(RECORD):{e}"))?; + // The keyboard map, to turn key codes into characters. + let setup = ctrl.setup(); + let (min, max) = (setup.min_keycode, setup.max_keycode); + let map = ctrl.get_keyboard_mapping(min, max - min + 1).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?; + let per = map.keysyms_per_keycode as usize; + let mut mods: u16 = 0; + let mod_bit = |sym: u32| -> u16 { + match sym { + 0xffe1 | 0xffe2 => 1, + 0xffe3 | 0xffe4 => 4, + 0xffe9 | 0xffea | 0xff7e => 8, + 0xffeb | 0xffec => 64, + _ => 0, + } + }; + let stopper = std::thread::spawn({ + let ctx = ctx; + move || { + // Disabling the context ends the data stream below. + while keep_going() { + std::thread::sleep(Duration::from_millis(150)); + } + let _ = ctrl.record_disable_context(ctx); + let _ = ctrl.flush(); + std::thread::sleep(Duration::from_millis(200)); + let _ = ctrl.record_free_context(ctx); + let _ = ctrl.flush(); + } + }); + let stream = data.record_enable_context(ctx).map_err(|e| e.to_string())?; + for reply in stream { + let Ok(reply) = reply else { break }; + if reply.category != 0 { + continue; + } + for ev in reply.data.chunks_exact(32) { + let (kind, detail) = (ev[0] & 0x7f, ev[1]); + let rx = i16::from_ne_bytes([ev[20], ev[21]]) as f64; + let ry = i16::from_ne_bytes([ev[22], ev[23]]) as f64; + let sym_at = |level: usize| -> u32 { + let i = (detail.saturating_sub(min)) as usize * per + level; + map.keysyms.get(i).copied().unwrap_or(0) + }; + match kind { + xproto::KEY_PRESS_EVENT => { + let base = sym_at(0); + let bit = mod_bit(base); + if bit != 0 { + mods |= bit; + continue; + } + let shifted = sym_at(1); + let sym = if mods & 1 != 0 && shifted != 0 { shifted } else { base }; + let text = char_of_keysym(sym).map(String::from).unwrap_or_default(); + each(Input::Key { text, keysym: sym, mods }); + } + xproto::KEY_RELEASE_EVENT => { + mods &= !mod_bit(sym_at(0)); + } + xproto::BUTTON_PRESS_EVENT => match detail { + 1 | 3 => each(Input::Click { x: rx, y: ry, right: detail == 3 }), + 4 => each(Input::Scroll { dy: 1 }), + 5 => each(Input::Scroll { dy: -1 }), + _ => {} + }, + _ => {} + } + } + } + let _ = stopper.join(); + Ok(()) +} + +pub fn char_of_keysym(sym: u32) -> Option { + match sym { + 0x20..=0x7e | 0xa0..=0xff => char::from_u32(sym), + 0x0100_0000..=0x0110_ffff => char::from_u32(sym - 0x0100_0000), + _ => None, + } +} + +pub fn keysym_name(sym: u32) -> Option<&'static str> { + Some(match sym { + 0xff0d => "return", + 0xff09 => "tab", + 0xff08 => "delete", + 0xff1b => "escape", + 0xffff => "forwarddelete", + 0xff51 => "left", + 0xff53 => "right", + 0xff52 => "up", + 0xff54 => "down", + 0xff50 => "home", + 0xff57 => "end", + 0xff55 => "pageup", + 0xff56 => "pagedown", + _ => return None, + }) +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_map_to_x() { + assert_eq!(keysym_of_mac_code(crate::computer::key_code("return").unwrap()), Some(0xff0d)); + assert_eq!(keysym_of_mac_code(crate::computer::key_code("a").unwrap()), Some('a' as u32)); + assert_eq!(keysym_of_mac_code(crate::computer::key_code("/").unwrap()), Some('/' as u32)); + assert_eq!(keysym_of_mac_code(crate::computer::key_code("f5").unwrap()), Some(0xffc2)); + assert_eq!(keysym_for_char('中'), 0x0100_4e2d); + assert_eq!(char_of_keysym(0x0100_4e2d), Some('中')); + assert_eq!(modifier_keysyms(crate::computer::FLAG_CMD | crate::computer::FLAG_SHIFT), vec![XK_CONTROL_L, XK_SHIFT_L]); + } + + /// Needs a display: `Xvfb :99 & DISPLAY=:99 cargo test x11_live -- --ignored`. + #[test] + #[ignore] + fn x11_live() { + assert!(available(), "no X display"); + let (w, h) = screen_pixels(); + let shot = capture().expect("screenshot"); + assert_eq!((shot.width(), shot.height()), (w as u32, h as u32)); + // Record while clicking and typing through XTEST: the recorder must see both. + let got = std::sync::Arc::new(Mutex::new(Vec::new())); + let g = got.clone(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let s2 = stop.clone(); + let t = std::thread::spawn(move || record(move |i| g.lock().unwrap().push(format!("{i:?}")), move || !s2.load(std::sync::atomic::Ordering::SeqCst))); + std::thread::sleep(Duration::from_millis(500)); + click(30.0, 40.0, "left", 1, 0); + // Real keys (what a person presses); type_text's borrowed spare key has no symbol by the + // time the recorder looks it up. + press(crate::computer::key_code("a").unwrap(), 0, 0.03); + press(crate::computer::key_code("z").unwrap(), crate::computer::FLAG_SHIFT, 0.03); + press(crate::computer::key_code("c").unwrap(), crate::computer::FLAG_CONTROL, 0.03); + scroll(None, -crate::computer::SCROLL_PIXELS_PER_STEP, 0, 0); + std::thread::sleep(Duration::from_millis(500)); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + t.join().unwrap().unwrap(); + let got = got.lock().unwrap().join("\n"); + assert!(got.contains("Click { x: 30.0, y: 40.0, right: false }"), "{got}"); + assert!(got.contains("text: \"a\"") && got.contains("text: \"Z\""), "{got}"); + assert!(got.contains("keysym: 99, mods: 4"), "ctrl+c: {got}"); + assert!(got.contains("Scroll { dy: -1 }"), "{got}"); + println!("{got}"); + } +}