diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..496d65a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,45 @@ +# Push a version tag (git tag v1.2.0 && git push origin v1.2.0) and every build lands on that +# tag's release: macOS (the app), Ubuntu (with a window), Linux without a screen, and the server. +# +# Runners (see deploy/ci/README.md): +# anybot-linux a Linux computer with Docker; jobs run in anybot-builder:22.04 +# macos-arm64 a Mac (runs directly on it) +name: Release +on: + push: + tags: ['v*'] + +env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + +jobs: + linux: + runs-on: anybot-linux + env: + # Kept between releases (a Docker volume on the runner): only what changed is compiled. + CARGO_TARGET_DIR: /ci-target + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + cargo build --release --bin anybot + cd relay && cargo build --release + - name: Test + run: cargo test --release --bin anybot -- --skip live 2>&1 | tail -3 + - name: Package + run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}" + - name: Upload + run: sh deploy/ci/upload-release.sh "$GITHUB_REF_NAME" dist/*.tar.gz + + macos: + runs-on: macos-arm64 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/../anybot-target + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --release --bin anybot + - name: Package + run: sh deploy/mac/bundle.sh "${GITHUB_REF_NAME#v}" + - name: Upload + run: sh deploy/ci/upload-release.sh "$GITHUB_REF_NAME" dist/*.zip diff --git a/.gitignore b/.gitignore index 3fc2395..a9fc12b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,12 @@ # Local secrets (deploy/server/.env.example is the template) .env +# Written by deploy/server/setup.sh +/deploy/server/Caddyfile *.env.local # macOS .DS_Store + +# Release packages (deploy/ci, deploy/mac/bundle.sh) +/dist/ diff --git a/Cargo.lock b/Cargo.lock index 3b8845a..db37e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,8 +343,10 @@ dependencies = [ "chrono", "chrono-tz", "cpal", + "crossterm", "dirs", "eframe", + "flate2", "futures-lite", "hayro", "iana-time-zone", @@ -356,15 +358,18 @@ dependencies = [ "resvg", "ring", "rodio", + "rustls", "serde", "serde_json", "tungstenite", "unicode-normalization", + "unicode-width", "ureq", "wayland-client", "wayland-protocols", "wayland-protocols-misc", "wayland-protocols-wlr", + "webpki-roots", "x11rb 0.13.2", "zbus", "zhconv", @@ -393,7 +398,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb 0.13.2", ] @@ -1029,6 +1034,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -1224,6 +1238,33 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.5", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1291,6 +1332,28 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1641,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2876,6 +2939,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3496,7 +3571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4202,7 +4277,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4389,6 +4464,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4852,10 +4948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5901,15 +5997,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 4f94769..315b011 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,13 @@ path = "src/main.rs" [dependencies] eframe = "0.37" ureq = { version = "3", features = ["json"] } +# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +webpki-roots = "1" +flate2 = "1" +# `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window). +crossterm = "0.29" +unicode-width = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "7" diff --git a/FEATURES.md b/FEATURES.md index 22bd751..d7c7e2f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2074,10 +2074,17 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開) | 邊說邊插話的即時語音 | 原版用 xAI 即時語音服務;AnyBot 的通話模式是輪流說話 | | 帳單、方案 | 不需要:AnyBot 用你自己的 AI 金鑰 | | 代你購物與付款 | 刻意不做:會動到你的錢和卡片 | -| 即時同步(同一個帳號的兩台電腦同時改同一個 Bot) | 帳號只做每日加密備份和還原;要 24 小時運作,請把 AnyBot 放在一直開著的主機(第 36 節) | | 原生 iPhone/Android App | 用手機網頁版,可加到主畫面、收推播通知 | +## 多台電腦:大腦、手腳、螢幕(2026-11-04) + +- 帳號裡一台 AnyBot 當大腦(Bot、聊天、記憶、排程都在它上面),其他有視窗的電腦當螢幕(看到、改的都是大腦的 Bot),沒有螢幕的電腦當手腳;同一個帳號的電腦之間即時同步。 +- 每台 AnyBot 和帳號伺服器之間保持一條長連線(WebSocket),斷了自動接回;連不到時畫面說明是哪一段。 +- 「加一台電腦」給一行 `anybot setup 網址 配對碼`,新電腦不用密碼就接成那個大腦的手腳。 +- 看不到圖片的模型:你帳號裡看得到圖的模型會先描述圖片(看圖小幫手)。 +- 詳細說明:docs/multi-device.md;技術細節:docs/architecture.md、docs/protocol.md。 + ## 側邊欄:釘選與分區(照原版重做,2026-11-04) - 釘選的 Bot 放在最上面的大頭像格子裡(主要 Bot 第一個,有星星)。右鍵「釘選/取消釘選」,或把 Bot 拖進格子(第一次拖時出現「拖曳到此處以釘選」);格子內可拖曳排序;拖到分區會取消釘選並移過去。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..94bee1e --- /dev/null +++ b/Makefile @@ -0,0 +1,109 @@ +# AnyBot — build and install from source. make help lists everything. +# +# git clone git@git.30cm.net:daniel.w/AnyBot.git && cd AnyBot +# make build (release) +# make install install for you: ~/Applications/AnyBot.app on a Mac, ~/.local/bin on Linux +# make run open it without installing + +CARGO ?= cargo +UNAME := $(shell uname -s) +VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' || echo dev) +TARGET := $(or $(CARGO_TARGET_DIR),target) +APPDIR ?= $(HOME)/Applications +BIN := $(TARGET)/release/anybot +RELAY := $(if $(CARGO_TARGET_DIR),$(CARGO_TARGET_DIR),relay/target)/release/anybot-relay + +.PHONY: all build app relay run serve setup relay-run test deps install uninstall hands server dist clean help + +all: build + +help: + @echo "AnyBot $(VERSION)" + @echo "" + @echo " make 建置 AnyBot 和帳號伺服器(正式版)" + @echo " make run 直接打開 AnyBot(不安裝)" + @echo " make install 安裝:Mac → $(APPDIR)/AnyBot.app;Linux → ~/.local/bin/anybot 和選單" + @echo " make uninstall 移除安裝的程式(你的 Bot 和聊天不會刪)" + @echo " make deps Linux:安裝系統套件(會問 sudo 密碼)" + @echo " make test 跑測試" + @echo "" + @echo " make serve 不開視窗執行(伺服器、一直開著的電腦)" + @echo " make setup 把這台接到你的 AnyBot(終端機裡的引導畫面)" + @echo " make hands Linux:裝好虛擬螢幕等,再接成大腦的手腳" + @echo " make relay-run 在這台跑帳號伺服器(埠 8080,資料在 ./relay-data)" + @echo " make server 在伺服器上架好帳號伺服器 + 大腦 + HTTPS(Docker)" + @echo "" + @echo " make dist 打包發行檔到 dist/(Mac:.app 的 zip;Linux:三個 tar.gz)" + @echo " make clean 清掉建置結果" + +build: app relay + +app: + $(CARGO) build --release --bin anybot + +relay: + cd relay && $(CARGO) build --release + +run: app + $(BIN) + +serve: app + $(BIN) --serve + +setup: app + $(BIN) setup + +relay-run: relay + $(RELAY) --port 8080 --data relay-data + +test: + $(CARGO) test --bin anybot + cd relay && $(CARGO) test + +deps: +ifeq ($(UNAME),Linux) + sh deploy/linux/install.sh --deps-only +else ifeq ($(UNAME),Darwin) + @xcode-select -p >/dev/null 2>&1 || xcode-select --install + @command -v cargo >/dev/null || echo "需要 Rust:curl --proto '=https' -sSf https://sh.rustup.rs | sh" +else + @echo "Windows:請照 docs/install.md 安裝 Rust 和 Visual Studio Build Tools" +endif + +install: app +ifeq ($(UNAME),Darwin) + KEEP_APP=1 sh deploy/mac/bundle.sh $(VERSION) + mkdir -p "$(APPDIR)" + rm -rf "$(APPDIR)/AnyBot.app" + mv dist/AnyBot.app "$(APPDIR)/AnyBot.app" + @echo "已安裝 $(APPDIR)/AnyBot.app(第一次打開:按右鍵 › 打開)" +else + sh deploy/linux/install.sh +endif + +uninstall: +ifeq ($(UNAME),Darwin) + rm -rf "$(APPDIR)/AnyBot.app" +else + rm -f "$(HOME)/.local/bin/anybot" "$(HOME)/.local/share/applications/anybot.desktop" + rm -f "$(HOME)/.local/share/icons/hicolor/scalable/apps/anybot.svg" "$(HOME)/.local/share/icons/hicolor/256x256/apps/anybot.png" "$(HOME)/.local/share/icons/hicolor/128x128/apps/anybot.png" +endif + @echo "已移除程式。你的 Bot、聊天和設定還在(要全部刪掉請看 docs/install.md)。" + +hands: app + sh deploy/linux/hands.sh --deps + +server: + cd deploy/server && ./setup.sh + +dist: build +ifeq ($(UNAME),Darwin) + sh deploy/mac/bundle.sh $(VERSION) +else + sh deploy/ci/package-linux.sh $(VERSION) +endif + +clean: + $(CARGO) clean + cd relay && $(CARGO) clean + rm -rf dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..13b81e0 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# AnyBot + +你自己的 AI Bot:可以聊天,也能在它自己的電腦和你的電腦上幫你做事(檔案、指令、瀏覽器、螢幕操作、例行任務)。每個 Bot 可以用不同的 AI 服務:xAI Grok、OpenAI、Claude、Gemini、OpenRouter、OpenCode Go、Ollama(本機),或任何 OpenAI 相容的服務。 + +- 桌面 App:macOS、Linux(Windows 可以編譯,還沒有安裝包) +- 手機:網頁版,可加到主畫面、收推播通知 +- 多台電腦:一台當**大腦**(保存 Bot、聊天、記憶和排程),其他電腦當**手腳**或**螢幕** +- 用 Rust 和 egui 寫成,一個執行檔就是全部(視窗、無視窗伺服器模式、設定工具) + +## 從原始碼安裝 + +```sh +git clone git@git.30cm.net:daniel.w/AnyBot.git +cd AnyBot +make deps # 只有 Linux 需要:安裝系統套件(會問 sudo 密碼) +make # 建置 +make install # Mac → ~/Applications/AnyBot.app;Linux → ~/.local/bin/anybot 和選單 +``` + +需要 Rust(`curl --proto '=https' -sSf https://sh.rustup.rs | sh`)。Mac 另外需要 Xcode Command Line Tools(`make deps` 會提示)。詳細步驟、各平台需求和常見問題:[docs/install.md](docs/install.md)。 + +常用指令(`make help` 列出全部): + +| 指令 | 做什麼 | +|---|---| +| `make run` | 不安裝,直接打開 | +| `make test` | 跑測試 | +| `make serve` | 不開視窗執行(伺服器、一直開著的電腦) | +| `make setup` | 把這台接到你的 AnyBot(終端機裡的引導畫面) | +| `make server` | 在伺服器上架好帳號伺服器、大腦和 HTTPS(Docker) | +| `make dist` | 打包發行檔到 `dist/` | +| `make uninstall` | 移除程式(Bot 和聊天不會刪) | + +## 多台電腦一起用 + +``` + 手機(網頁) 筆電(螢幕) 沒螢幕的 Linux(手腳) + \ | / + └──── 帳號伺服器(一條一直開著的連線)────┘ + | + 大腦:Bot、聊天、記憶、排程 +``` + +1. **架帳號伺服器和大腦**:在伺服器上執行 `make server`(只問網域、帳號、密碼);或先用一台 Mac 當大腦([docs/multi-device.md](docs/multi-device.md))。 +2. **其他電腦當螢幕**:打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號,在「大腦」選它。 +3. **加一台手腳**:手機 AnyBot 右上「⋯ › 加一台電腦」,把出現的那一行貼到那台的終端機: + ```sh + anybot setup https://anybot.example.com 469615 + ``` + 不用帳號密碼,會設成開機自動執行;沒有螢幕就開一個虛擬螢幕。 + +## 文件 + +| 文件 | 內容 | +|---|---| +| [docs/install.md](docs/install.md) | 從原始碼建置和安裝(macOS、Linux、Windows)、更新、移除、常見問題 | +| [docs/multi-device.md](docs/multi-device.md) | 大腦、手腳、螢幕:怎麼架、怎麼接、連不上時看什麼 | +| [docs/architecture.md](docs/architecture.md) | 技術架構:程式分層、資料存放、安全設計、同步怎麼運作 | +| [docs/protocol.md](docs/protocol.md) | 帳號伺服器和 AnyBot 之間的介面與長連線格式 | +| [docs/release.md](docs/release.md) | 發行:打包、Gitea Actions 自動建置、runner 設定 | +| [FEATURES.md](FEATURES.md) | 所有功能的完整說明 | +| [relay/README.md](relay/README.md) | 帳號伺服器的架設細節 | + +## 專案結構 + +``` +src/ AnyBot(桌面 App、無視窗模式、anybot setup) + app/ 視窗介面(聊天、設定、電腦、大腦同步的畫面) +relay/ 帳號伺服器(獨立的小程式) +mobile/ 手機網頁(由 AnyBot 和帳號伺服器提供) +box/ Bot 自己的電腦(Docker 容器) +deploy/ 安裝與部署:mac/、linux/、server/、ci/ +docs/ 技術文件 +``` diff --git a/deploy/ci/Dockerfile.linux b/deploy/ci/Dockerfile.linux new file mode 100644 index 0000000..b76f4d4 --- /dev/null +++ b/deploy/ci/Dockerfile.linux @@ -0,0 +1,12 @@ +# The Linux build environment for the release workflow (.gitea/workflows/release.yml). +# Ubuntu 22.04 (glibc 2.35), so the binaries also run on newer Ubuntu, Debian 12 and the like. +# Built once on the runner's computer: docker build -t anybot-builder:22.04 -f deploy/ci/Dockerfile.linux deploy/ci +FROM ubuntu:22.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git build-essential pkg-config cmake clang libclang-dev python3 nodejs xz-utils zip \ + libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxtst-dev libdbus-1-dev \ + libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev libxdo-dev libpipewire-0.3-dev libgbm-dev \ + && rm -rf /var/lib/apt/lists/* +ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:$PATH +RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.1 && chmod -R a+w $RUSTUP_HOME $CARGO_HOME diff --git a/deploy/ci/Dockerfile.server-anybot b/deploy/ci/Dockerfile.server-anybot new file mode 100644 index 0000000..5e1d6c9 --- /dev/null +++ b/deploy/ci/Dockerfile.server-anybot @@ -0,0 +1,8 @@ +# AnyBot (the brain, no window) from the release's binary. +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 libasound2 \ + && rm -rf /var/lib/apt/lists/* +COPY bin/anybot /usr/local/bin/anybot +ENV ANYBOT_DATA_DIR=/data +VOLUME /data +CMD ["anybot", "--serve"] diff --git a/deploy/ci/Dockerfile.server-relay b/deploy/ci/Dockerfile.server-relay new file mode 100644 index 0000000..34d1bab --- /dev/null +++ b/deploy/ci/Dockerfile.server-relay @@ -0,0 +1,8 @@ +# The account server from the release's binary (no compiling on the server). +FROM debian:bookworm-slim +RUN useradd -m relay && mkdir /data && chown relay /data +COPY bin/anybot-relay /usr/local/bin/anybot-relay +USER relay +VOLUME /data +EXPOSE 8080 +CMD ["anybot-relay", "--port", "8080", "--data", "/data"] diff --git a/deploy/ci/linux-install.sh b/deploy/ci/linux-install.sh new file mode 100755 index 0000000..2676935 --- /dev/null +++ b/deploy/ci/linux-install.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Install AnyBot from this package for the current user (no root needed). +# ./install.sh the app (menu entry and icon too when this computer has a desktop) +# Then: open AnyBot from the menu, or on a computer without a screen: anybot setup +set -e +cd "$(dirname "$0")" +BIN="$HOME/.local/bin" +mkdir -p "$BIN" +install -m 755 anybot "$BIN/anybot" +if [ -f anybot.desktop ]; then + APPS="$HOME/.local/share/applications"; ICONS="$HOME/.local/share/icons/hicolor" + mkdir -p "$APPS" "$ICONS/scalable/apps" "$ICONS/256x256/apps" + install -m 644 anybot.desktop "$APPS/anybot.desktop" + install -m 644 anybot.svg "$ICONS/scalable/apps/anybot.svg" + "$BIN/anybot" --write-icon "$ICONS/256x256/apps/anybot.png" 256 2>/dev/null || true + command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" 2>/dev/null || true +fi +echo "AnyBot 已安裝在 $BIN/anybot" +case ":$PATH:" in *":$BIN:"*) ;; *) echo "(把 $BIN 加到 PATH,才能直接打 anybot)" ;; esac +if ! ldconfig -p 2>/dev/null | grep -q libasound.so.2; then + echo "還需要 libasound2:sudo apt-get install -y libasound2(或 libasound2t64)" +fi +echo "接到你的大腦:在手機或電腦的 AnyBot 按「加一台電腦」,把那一行貼到這裡執行。" diff --git a/deploy/ci/package-linux.sh b/deploy/ci/package-linux.sh new file mode 100755 index 0000000..6288fe1 --- /dev/null +++ b/deploy/ci/package-linux.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Package the Linux builds for a release (run from app/ after the release builds): +# dist/AnyBot--ubuntu-x86_64.tar.gz the app with its window (menu entry, icon) +# dist/AnyBot--headless-x86_64.tar.gz for a computer without a screen (hands, or a brain) +# dist/AnyBot--server-x86_64.tar.gz account server + brain on one server (Docker) +set -e +VERSION=${1:?version} +ARCH=$(uname -m) +OUT=dist +rm -rf "$OUT" && mkdir -p "$OUT" +BIN=${CARGO_TARGET_DIR:-target}/release/anybot +RELAY=${CARGO_TARGET_DIR:-relay/target}/release/anybot-relay +strip "$BIN" "$RELAY" 2>/dev/null || true + +# Ubuntu (with a window) +D="$OUT/AnyBot-$VERSION-ubuntu-$ARCH" +mkdir -p "$D" +cp "$BIN" deploy/linux/anybot.desktop icons/anybot.svg "$D/" +cp deploy/ci/linux-install.sh "$D/install.sh" +printf 'AnyBot %s(Ubuntu 22.04 以後、Debian 12 以後)\n\n ./install.sh 安裝,之後從選單打開 AnyBot\n' "$VERSION" > "$D/讀我.txt" +tar -C "$OUT" -czf "$D.tar.gz" "$(basename "$D")" + +# Without a screen (hands, or a brain) +D="$OUT/AnyBot-$VERSION-headless-$ARCH" +mkdir -p "$D" +cp "$BIN" "$D/" +cp deploy/ci/linux-install.sh "$D/install.sh" +cp deploy/linux/hands.sh "$D/hands.sh" +printf 'AnyBot %s:沒有螢幕的 Linux\n\n ./install.sh\n 然後在手機或電腦的 AnyBot 按「加一台電腦」,把那一行貼到這台執行。\n 想讓 Bot 看這台的畫面(虛擬螢幕):先執行 ./hands.sh --deps\n' "$VERSION" > "$D/讀我.txt" +tar -C "$OUT" -czf "$D.tar.gz" "$(basename "$D")" + +# Server: account server + brain, from these binaries (no compiling on the server) +D="$OUT/AnyBot-$VERSION-server-$ARCH" +mkdir -p "$D/bin" "$D/deploy/server" "$D/relay" +cp "$BIN" "$RELAY" "$D/bin/" +cp deploy/server/setup.sh deploy/server/.env.example "$D/deploy/server/" +cp deploy/ci/server-compose.yml "$D/deploy/server/docker-compose.yml" +cp deploy/ci/Dockerfile.server-relay deploy/ci/Dockerfile.server-anybot "$D/deploy/server/" +cp relay/Caddyfile "$D/relay/Caddyfile" +printf 'AnyBot %s 伺服器(帳號伺服器 + 大腦 + HTTPS,用 Docker)\n\n cd deploy/server && ./setup.sh\n' "$VERSION" > "$D/讀我.txt" +tar -C "$OUT" -czf "$D.tar.gz" "$(basename "$D")" + +find "$OUT" -maxdepth 1 -type d -name 'AnyBot-*' -exec rm -rf {} + +ls -la "$OUT" diff --git a/deploy/ci/runner-linux.yaml b/deploy/ci/runner-linux.yaml new file mode 100644 index 0000000..9d06a2b --- /dev/null +++ b/deploy/ci/runner-linux.yaml @@ -0,0 +1,19 @@ +# gitea-runner settings for the Linux build computer (~/.config/gitea-runner/config.yaml). +log: + level: info +runner: + file: /home/daniel/.config/gitea-runner/.runner + capacity: 1 + timeout: 3h + labels: + - "anybot-linux:docker://anybot-builder:22.04" +cache: + enabled: true +container: + # anybot-builder is built on this computer, not pulled. + force_pull: false + # Downloads and compiled pieces kept between releases (much faster second builds). + valid_volumes: + - anybot-cargo-registry + - anybot-ci-target + options: "-v anybot-cargo-registry:/usr/local/cargo/registry -v anybot-ci-target:/ci-target" diff --git a/deploy/ci/server-compose.yml b/deploy/ci/server-compose.yml new file mode 100644 index 0000000..c219f4c --- /dev/null +++ b/deploy/ci/server-compose.yml @@ -0,0 +1,63 @@ +# The server from a release package (prebuilt binaries in ../../bin). Made from deploy/server/docker-compose.yml. +# Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself. +# Easiest: ./setup.sh (asks for the domain, account and keys, writes .env and Caddyfile, starts +# everything and checks it). By hand: +# 1. Point your domain at the server; copy ../../relay/Caddyfile here and put the domain in it. +# 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). +# +# 大腦: this AnyBot is the brain of the account. It keeps the bots, chats, memory and routines, +# and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 › +# 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands +# (screen, files, browser), asking you first as usual. A Linux computer without a screen can be +# hands too: run `anybot setup` there and pick this brain. +services: + relay: + build: + context: ../.. + dockerfile: deploy/server/Dockerfile.server-relay + 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: + # Written by setup.sh (your domain); ../../relay/Caddyfile is the template. + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + depends_on: [relay] + anybot: + build: + context: ../.. + dockerfile: deploy/server/Dockerfile.server-anybot + 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}" + # First start on a new server: make the account (the first one needs no invite). + ANYBOT_ACCOUNT_CREATE: "1" + ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" + 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/deploy/ci/upload-release.sh b/deploy/ci/upload-release.sh new file mode 100755 index 0000000..4a69bcb --- /dev/null +++ b/deploy/ci/upload-release.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Attach files to this tag's release on Gitea (made if it isn't there yet). +# deploy/ci/upload-release.sh … +# Uses GITHUB_SERVER_URL, GITHUB_REPOSITORY and RELEASE_TOKEN (set by the workflow). +set -e +TAG=${1:?tag}; shift +API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" +AUTH="Authorization: token $RELEASE_TOKEN" +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('$1','') if isinstance(d,dict) else '')"; } +ID=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json id || true) +if [ -z "$ID" ]; then + # Two jobs may get here at once: whoever is second finds the first one's release. + ID=$(curl -fsS -H "$AUTH" -H "Content-Type: application/json" -X POST "$API/releases" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"AnyBot $TAG\",\"body\":\"自動建置:macOS、Ubuntu、沒有螢幕的 Linux、伺服器。\"}" | json id) \ + || ID=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" | json id) +fi +[ -n "$ID" ] || { echo "找不到也建不了 $TAG 的 release" >&2; exit 1; } +for f in "$@"; do + name=$(basename "$f") + # Built again: replace the old file. + old=$(curl -fsS -H "$AUTH" "$API/releases/$ID/assets" | python3 -c "import json,sys; print(next((str(a['id']) for a in json.load(sys.stdin) if a['name']=='$name'),''))") + [ -n "$old" ] && curl -fsS -H "$AUTH" -X DELETE "$API/releases/$ID/assets/$old" >/dev/null + curl -fsS -H "$AUTH" -F "attachment=@$f" "$API/releases/$ID/assets?name=$name" >/dev/null + echo "已上傳 $name" +done diff --git a/deploy/linux/hands.sh b/deploy/linux/hands.sh new file mode 100755 index 0000000..7b9e33d --- /dev/null +++ b/deploy/linux/hands.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Make this Linux computer a pair of hands for your AnyBot brain (or a brain itself): installs what +# it needs, then opens the guided setup (`anybot setup`), where you pick the brain from your +# account's AnyBots. Works on a desktop and on a computer without a screen. Run from app/: +# +# ./deploy/linux/hands.sh --deps also install what a computer without a screen needs (sudo) +# ./deploy/linux/hands.sh install AnyBot if needed, then the setup +# ./deploy/linux/hands.sh --stop stop AnyBot and don't start it with the computer any more +set -e +cd "$(dirname "$0")/../.." + +if [ "$1" = "--stop" ]; then + for u in anybot.service anybot-hands.service; do + systemctl --user disable --now "$u" 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/$u" + done + systemctl --user daemon-reload 2>/dev/null || true + echo "Stopped. Run ./deploy/linux/hands.sh (or anybot setup) to set it up again." + exit 0 +fi + +if [ "$1" = "--deps" ]; then + # A virtual screen, a window manager, the accessibility bus (for reading apps' buttons), a session bus. + if command -v apt-get >/dev/null; then + sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 dbus-user-session xauth fonts-noto-cjk + elif command -v dnf >/dev/null; then + sudo dnf install -y xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk dbus-x11 xorg-x11-xauth google-noto-sans-cjk-fonts + elif command -v pacman >/dev/null; then + sudo pacman -S --needed xorg-server-xvfb openbox at-spi2-core dbus xorg-xauth noto-fonts-cjk + else + echo "Install with your package manager: Xvfb, openbox, at-spi2-core, dbus, xauth" + fi +fi + +[ -x "$HOME/.local/bin/anybot" ] || sh deploy/linux/install.sh +# Older versions of this script kept the account in a file; the setup keeps only a key. +rm -f "$HOME/.config/anybot/hands.env" "$HOME/.local/bin/anybot-hands" +exec "$HOME/.local/bin/anybot" setup diff --git a/deploy/linux/install.sh b/deploy/linux/install.sh index 83e6fa2..7690147 100755 --- a/deploy/linux/install.sh +++ b/deploy/linux/install.sh @@ -3,6 +3,7 @@ # # ./deploy/linux/install.sh build from source, then install # ./deploy/linux/install.sh --deps also install the system packages (asks for sudo) +# ./deploy/linux/install.sh --deps-only only the system packages (make deps) # # 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. @@ -21,7 +22,7 @@ DEPS_FEDORA="alsa-lib libxkbcommon-x11 mesa-libGL xdg-utils libnotify libsecret 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 [ "$1" = "--deps" ] || [ "$1" = "--deps-only" ]; 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 @@ -37,6 +38,8 @@ if [ "$1" = "--deps" ]; then fi fi +[ "$1" = "--deps-only" ] && exit 0 + OUT="${CARGO_TARGET_DIR:-target}/release/anybot" if [ -x "$OUT" ] && [ "$1" != "--rebuild" ]; then echo "Using the existing build ($OUT)." @@ -58,6 +61,7 @@ command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" 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" +echo "To make this computer a brain, or hands for your brain (also without a screen): anybot setup" 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:" diff --git a/deploy/mac/bundle.sh b/deploy/mac/bundle.sh new file mode 100755 index 0000000..8639713 --- /dev/null +++ b/deploy/mac/bundle.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Make AnyBot.app from the release build and zip it (run from app/ on a Mac, after +# `cargo build --release`): deploy/mac/bundle.sh 1.2.0 → dist/AnyBot-1.2.0-macos-.zip +# With KEEP_APP=1 it leaves dist/AnyBot.app instead of zipping it (make install). +# Signed ad hoc (no Apple developer account): the first time, right-click › 打開. +set -e +VERSION=${1:?version} +BIN=${CARGO_TARGET_DIR:-target}/release/anybot +ARCH=$(uname -m) +OUT=dist +APP="$OUT/AnyBot.app" +rm -rf "$APP" && mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/AnyBot" + +# The icon: every size the system asks for. +SET=$(mktemp -d)/AnyBot.iconset +mkdir -p "$SET" +for s in 16 32 128 256 512; do + "$BIN" --write-icon "$SET/icon_${s}x${s}.png" $s + "$BIN" --write-icon "$SET/icon_${s}x${s}@2x.png" $((s * 2)) +done +iconutil -c icns "$SET" -o "$APP/Contents/Resources/AnyBot.icns" + +cat > "$APP/Contents/Info.plist" < + + + CFBundleNameAnyBot + CFBundleDisplayNameAnyBot + CFBundleIdentifiernet.30cm.anybot + CFBundleExecutableAnyBot + CFBundleIconFileAnyBot + CFBundlePackageTypeAPPL + CFBundleShortVersionString$VERSION + CFBundleVersion$VERSION + LSMinimumSystemVersion12.0 + NSHighResolutionCapable + NSMicrophoneUsageDescription語音聊天時,聽你說話。 + NSAppleEventsUsageDescription讓 Bot 在你允許時操作其他 App。 + +PLIST +codesign --force --deep -s - "$APP" +if [ "${KEEP_APP:-}" = 1 ]; then + exit 0 +fi +ditto -c -k --norsrc --keepParent "$APP" "$OUT/AnyBot-$VERSION-macos-$ARCH.zip" +rm -rf "$APP" +ls -la "$OUT" diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index a09616e..865d4bb 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -1,10 +1,18 @@ # 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. +# Easiest: ./setup.sh (asks for the domain, account and keys, writes .env and Caddyfile, starts +# everything and checks it). By hand: +# 1. Point your domain at the server; copy ../../relay/Caddyfile here and put the domain in it. # 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). +# +# 大腦: this AnyBot is the brain of the account. It keeps the bots, chats, memory and routines, +# and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 › +# 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands +# (screen, files, browser), asking you first as usual. A Linux computer without a screen can be +# hands too: run `anybot setup` there and pick this brain. services: relay: build: @@ -22,7 +30,8 @@ services: restart: unless-stopped ports: ["80:80", "443:443"] volumes: - - ../../relay/Caddyfile:/etc/caddy/Caddyfile:ro + # Written by setup.sh (your domain); ../../relay/Caddyfile is the template. + - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data depends_on: [relay] anybot: @@ -35,6 +44,9 @@ services: ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}" ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}" ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}" + # First start on a new server: make the account (the first one needs no invite). + ANYBOT_ACCOUNT_CREATE: "1" + ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" XAI_API_KEY: "${XAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" diff --git a/deploy/server/setup.sh b/deploy/server/setup.sh new file mode 100755 index 0000000..5231fa2 --- /dev/null +++ b/deploy/server/setup.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# AnyBot 伺服器設定: the account server, HTTPS and the brain on this server, step by step. +# +# cd app/deploy/server && ./setup.sh ask, write .env and Caddyfile, start, check +# ./setup.sh --dry-run ask and write the files, but don't start anything +# +# Run it again any time: it offers to keep the current settings (to update and restart). +set -u +cd "$(dirname "$0")" +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +# ---------- looks ---------- +if [ -t 1 ]; then + B=$'\e[1m'; D=$'\e[90m'; A=$'\e[36m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m' +else + B=""; D=""; A=""; G=""; Y=""; R=""; N="" +fi +STEPS=("檢查" "網址" "帳號" "啟動") +AT=0 + +header() { + printf '\e[2J\e[H\n' + printf ' %s◆%s %sAnyBot 伺服器設定%s ' "$A" "$N" "$B" "$N" + local i + for i in "${!STEPS[@]}"; do + [ "$i" -gt 0 ] && printf '%s ─ %s' "$D" "$N" + if [ "$i" -lt "$AT" ]; then printf '%s✓ %s%s' "$G" "${STEPS[$i]}" "$N" + elif [ "$i" -eq "$AT" ]; then printf '%s%s%d %s%s' "$A" "$B" $((i + 1)) "${STEPS[$i]}" "$N" + else printf '%s%d %s%s' "$D" $((i + 1)) "${STEPS[$i]}" "$N"; fi + done + printf '\n\n' +} +# A card: title, then lines (each printed after the left edge). +card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N" "$D" "$N"; } +line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; } +note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; } +ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; } +warn() { WARNED=1; printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; } +bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; } +card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; } + +# Pad a label to 10 columns (Chinese characters are two columns wide). +padl() { + local w + w=$(printf '%s' "$1" | LC_ALL=C.UTF-8 wc -L 2>/dev/null || printf '%s' "$1" | wc -m) + printf '%s%*s' "$1" $((10 - w > 1 ? 10 - w : 1)) "" +} + +# ask VAR "標籤" [default] [secret]: once answered, the line is redrawn without the marker. +ask() { + local __v=$1 label=$2 def=${3:-} secret=${4:-} got shown + printf ' %s│%s %s❯%s %s%s%s' "$D" "$N" "$A" "$N" "$A" "$(padl "$label")" "$N" + if [ -n "$secret" ]; then + IFS= read -rs got; printf '\n' + shown=$(printf '%s' "$got" | sed 's/./●/g') + else + IFS= read -r -e -i "$def" got || true + shown=$got + fi + printf '\e[1A\r\e[K %s│%s %s%s%s%s\n' "$D" "$N" "$D" "$(padl "$label")" "$N" "$shown" + printf -v "$__v" '%s' "$got" +} + +# choose VAR "問題" "選項|說明" ... (↑↓ and Enter) +choose() { + local __v=$1 title=$2; shift 2 + local opts=("$@") sel=0 n=$# key rest i + while :; do + header + card_open "$title" + for i in "${!opts[@]}"; do + local name=${opts[$i]%%|*} desc=${opts[$i]#*|} + if [ "$i" -eq "$sel" ]; then line "$A❯ ● $B$name$N"; else line " $D○$N $name"; fi + [ "$desc" != "${opts[$i]}" ] && [ -n "$desc" ] && note " $desc" + line "" + done + card_close "↑↓ 選擇 Enter 確定" + IFS= read -rsn1 key + if [ "$key" = $'\e' ]; then + IFS= read -rsn2 -t 0.05 rest || true + case "$rest" in "[A") sel=$(((sel + n - 1) % n)) ;; "[B") sel=$(((sel + 1) % n)) ;; esac + elif [ "$key" = "k" ]; then sel=$(((sel + n - 1) % n)) + elif [ "$key" = "j" ]; then sel=$(((sel + 1) % n)) + elif [ -z "$key" ]; then break + fi + done + printf -v "$__v" '%s' "$sel" +} + +spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest log line + local what=$1; shift + local log; log=$(mktemp) + "$@" >"$log" 2>&1 & + local pid=$! f=0 c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") + while kill -0 "$pid" 2>/dev/null; do + local last; last=$(tail -n 1 "$log" | tr -d '\r' | cut -c1-60) + printf '\r\e[K %s│%s %s%s%s %s %s%s%s' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N" "$what" "$D" "$last" "$N" + f=$((f + 1)); sleep 0.15 + done + wait "$pid"; local rc=$? + printf '\r\e[K' + if [ $rc -eq 0 ]; then ok "$what"; else bad "$what 失敗:"; tail -n 8 "$log" | while IFS= read -r l; do note " $l"; done; fi + rm -f "$log" + return $rc +} + +pause() { printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } +die() { card_close; printf '\n'; exit 1; } + +# ---------- 0. this server ---------- +AT=0; header; card_open "檢查這台伺服器" +FAIL=0; WARNED=0 +if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; FAIL=1; fi +if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi +if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi +if command -v ss >/dev/null; then + busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker || true) + if [ -n "$busy" ] && ! docker compose ps -q caddy 2>/dev/null | grep -q .; then warn "80 或 443 埠已被別的程式佔用(HTTPS 需要它們)"; else ok "80、443 埠可以用"; fi +fi +mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null || echo 0) +[ "${mem:-0}" -ge 2 ] && ok "記憶體 ${mem} GB" || warn "記憶體只有 ${mem:-?} GB(建議 2 GB 以上,第一次建置會比較久)" +disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}') +[ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)" +if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi +# All fine: go on by itself. +if [ $WARNED = 1 ]; then card_close; pause; else card_close; sleep 1; fi + +# Keep the current settings? +if [ -f .env ] && [ -f Caddyfile ]; then + # shellcheck disable=SC1091 + set -a; . ./.env; set +a + choose KEEP "這台已經設定過了(${ANYBOT_RELAY_PUBLIC_URL:-})" \ + "用現有設定,更新並重新啟動|拉下最新版、重建、重新啟動,帳號和 Bot 都不變" \ + "重新設定|網址、帳號或金鑰要改" + if [ "$KEEP" = 0 ]; then + DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-} + AT=3; SKIP=1 + fi +fi + +if [ "${SKIP:-0}" != 1 ]; then + # ---------- 1. the address ---------- + AT=1; MSG="" + while :; do + header; card_open "手機和其他電腦用哪個網址連到這台?" + note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" + line "" + [ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; } + ask DOMAIN "網域" "${DOMAIN:-}" + DOMAIN=${DOMAIN#https://}; DOMAIN=${DOMAIN#http://}; DOMAIN=${DOMAIN%%/*} + if ! printf '%s' "$DOMAIN" | grep -Eq '^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then MSG=$(bad "請輸入像 anybot.example.com 這樣的網域"); continue; fi + ips=$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ') + if [ -z "$ips" ]; then + card_close + choose GO "「$DOMAIN」還沒有指到任何位址" "重新輸入|" "仍然繼續|DNS 剛設好、還沒生效時可以先繼續;生效後憑證會自動申請" + [ "$GO" = 0 ] && { MSG=""; continue; } + else + mine=$(hostname -I 2>/dev/null || true) + hit=0; for ip in $ips; do case " $mine " in *" $ip "*) hit=1 ;; esac; done + if [ $hit = 1 ]; then MSG=$(ok "$DOMAIN → $ips(就是這台)"); else MSG=$(warn "$DOMAIN → $ips(這台的位址:${mine:-不知道};在 NAT 或雲端主機後面時這是正常的)"); fi + header; card_open "手機和其他電腦用哪個網址連到這台?"; printf '%s\n' "$MSG"; card_close; sleep 1 + fi + break + done + + # ---------- 2. the account ---------- + AT=2; MSG="" + while :; do + header; card_open "你的 AnyBot 帳號" + note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。" + line "" + [ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; } + ask USERNAME "帳號" "${USERNAME:-}" + if ! printf '%s' "$USERNAME" | grep -Eq '^[A-Za-z0-9._@-]{2,64}$'; then MSG=$(bad "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)"); continue; fi + ask PW1 "密碼" "" secret + ask PW2 "再一次" "" secret + if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi + if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi + case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac + BRAIN="雲端大腦" + break + done + # Only this account (others can be let in later: ANYBOT_RELAY_INVITE in .env). AI keys are set + # in the app (設定 › 模型); they stay on the brain. + INVITE=""; XAI=""; ANT=""; OAI=""; GEM=""; RESTORE="" + + q() { printf "%s='%s'\n" "$1" "$2"; } + umask 077 + { + echo "# Written by setup.sh. Run ./setup.sh again to change." + q ANYBOT_USER "$USERNAME" + q ANYBOT_PASSWORD "$PW1" + q ANYBOT_HOST_NAME "$BRAIN" + q ANYBOT_RELAY_INVITE "$INVITE" + q ANYBOT_RELAY_PUBLIC_URL "https://$DOMAIN" + q ANYBOT_RESTORE_FROM_ACCOUNT "$RESTORE" + q XAI_API_KEY "$XAI" + q ANTHROPIC_API_KEY "$ANT" + q OPENAI_API_KEY "$OAI" + q GEMINI_API_KEY "$GEM" + } >.env + chmod 600 .env + umask 022 + sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile +fi + +# ---------- 4. start ---------- +AT=3; header; card_open "啟動" +ok "寫好設定(.env、Caddyfile)" +if [ $DRY = 1 ]; then + warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build" + card_close; exit 0 +fi +if [ "${SKIP:-0}" = 1 ] && [ -d ../../../.git ] && command -v git >/dev/null; then + spin "拉下最新版" git -C ../../.. pull --ff-only || true +fi +spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die + +# The brain joins the account (and makes it, the first time). +f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined="" +for _ in $(seq 1 600); do + logs=$(docker compose logs --no-color anybot 2>/dev/null | tail -n 40) + if printf '%s' "$logs" | grep -q "透過帳號"; then joined=1; break; fi + printf '\r\e[K %s│%s %s%s%s 大腦正在登入帳號…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 0.3 +done +printf '\r\e[K' +if [ -n "$joined" ]; then + printf '%s' "$logs" | grep -q "已建立帳號" && ok "建立帳號 ${USERNAME}" || ok "大腦「${BRAIN}」已登入帳號" +else + bad "大腦沒有登入帳號。最近的紀錄:" + printf '%s\n' "$logs" | grep "AnyBot:" | tail -n 4 | while IFS= read -r l; do note " ${l#*| }"; done +fi + +# HTTPS from outside (the certificate can take a minute). +https="" +for _ in $(seq 1 120); do + if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=1; break; fi + printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1 +done +printf '\r\e[K' +if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"; else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi +card_close + +printf '\n' +card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")" +line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」" +line "${B}電腦${N} AnyBot › 設定 › 手機與帳號 登入,在「大腦」選「$BRAIN」" +line "${B}AI 金鑰${N} 手機或電腦的 AnyBot › 設定 › 模型(只存在大腦上)" +line "${B}加一台電腦${N} 手機 AnyBot 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機" +line "" +note "看紀錄 docker compose logs -f anybot" +note "更新 ./setup.sh(選「用現有設定」)" +card_close +printf '\n' diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4a09cdc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,126 @@ +# 技術架構 + +AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一個執行檔有三種跑法: + +| 跑法 | 指令 | 用途 | +|---|---|---| +| 視窗 | `anybot` | 桌面 App | +| 無視窗 | `anybot --serve` | 伺服器、一直開著的電腦、手腳:介面邏輯照跑,只是不畫畫面 | +| 設定 | `anybot setup …` | 終端機裡把這台接到帳號(`src/setup.rs`) | + +另一個小程式 `anybot-relay`(`relay/`)是帳號伺服器。 + +## 程式分層 + +``` +┌─────────────── 介面(src/app.rs、src/app/*)───────────────┐ +│ 聊天、側邊欄、設定、電腦、檔案預覽、大腦同步(mirror_ui) │ +│ 每個畫面一個 App 方法;狀態全部在 App.state(model::State) │ +└───────┬───────────────────────────────┬────────────────────┘ + │ 送出訊息 │ 手機/其他 AnyBot 的要求 +┌───────▼──────────┐ ┌────────▼───────────────────┐ +│ 回合(agent.rs) │ │ 遠端(remote.rs) │ +│ 呼叫模型、跑工具、 │ │ 手機 API、裝置連結、大腦同步 │ +│ 等你核准、整理摘要 │ │ 長連線(tunnel.rs、wire.rs) │ +└───┬─────────┬────┘ └──────────────────────────────┘ + │ │ +┌───▼───┐ ┌───▼──────────────────────────────────────────────┐ +│ 模型 │ │ 工具(tools.rs 為主) │ +│ provi- │ │ 檔案與指令、Bot 的電腦(cloudbox.rs)、瀏覽器 │ +│ ders.rs│ │ (botbrowser.rs、browser.rs、web.rs)、螢幕操作 │ +└────────┘ │ (computer.rs、desktop.rs、axread.rs;Linux:x11.rs、 │ + │ wl.rs、portal.rs、desk.rs)、Mac App(macapps.rs)、 │ + │ 保險庫(vault*.rs)、MCP(mcp.rs)、記憶(memory.rs) │ + └──────────────────────────────────────────────────────┘ +``` + +| 檔案 | 做什麼 | +|---|---| +| `model.rs` | 資料:State、Bot、Message、設定;讀寫 `state.json` | +| `agent.rs` | 一個回合:組系統指示、送模型、收串流、跑工具、核准關卡、長對話摘要 | +| `providers.rs` | 各家模型的 API(OpenAI 相容、Anthropic、OpenCode Go…)、串流、重試、看不到圖時的看圖小幫手 | +| `tools.rs` | 檔案、指令、背景指令;指令在 macOS 沙盒裡跑(見「安全」) | +| `policy.rs` | 哪些動作直接做、先問、拒絕 | +| `cloudbox.rs`、`box/` | Bot 自己的電腦:一個 Docker 容器(有桌面、瀏覽器) | +| `computer.rs`、`desktop.rs`、`axread.rs` | 看畫面、滑鼠鍵盤;讀 App 的按鈕清單(agent-desktop) | +| `botbrowser.rs`、`browser.rs`、`web.rs` | AnyBot 自己的瀏覽器(Chrome DevTools 協定)、讀你的瀏覽器、抓網頁 | +| `routines.rs`、`triggers.rs` | 例行任務和觸發 | +| `channels.rs` | 訊息管道(Slack、Discord、Telegram) | +| `remote.rs` | 手機 API、帳號登入、裝置連結、大腦同步的請求端 | +| `tunnel.rs`、`wire.rs` | 和帳號伺服器之間的長連線(WebSocket) | +| `app/mirror_ui.rs` | 大腦和螢幕之間的同步 | +| `setup.rs` | `anybot setup` | +| `httpd.rs` | 很小的 HTTP 伺服器(AnyBot 和帳號伺服器共用) | +| `mobile/index.html` | 手機網頁(單一檔案,編進程式裡) | + +## 資料存放 + +都在資料資料夾裡(macOS `~/Library/Application Support/AnyBot/`、Linux `~/.local/share/AnyBot/`、Windows `%APPDATA%\AnyBot\`;`ANYBOT_DATA_DIR` 可以改): + +| 檔案 | 內容 | +|---|---| +| `state.json` | 全部的 Bot、聊天、設定和金鑰 | +| `attachments/bot-/` | 聊天附件 | +| `browser/` | AnyBot 瀏覽器的設定檔(登入狀態) | +| `brain-copy.json` | 螢幕電腦保留的大腦副本(離線時還能看) | +| `brain-files/` | 螢幕電腦從大腦下載的附件 | +| `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) | + +寫入時先寫暫存檔再改名,不會寫一半。 + +## 安全 + +- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。 +- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾和 `.ssh`、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 可以讀(不能寫)。環境變數裡的模型金鑰會先拿掉。 +- **Bot 的電腦**:放在容器裡,和你的電腦分開。 +- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。 +- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。 + +## 多台電腦怎麼運作 + +### 連線 + +每台 AnyBot(大腦、螢幕、手腳)都保持一條 WebSocket 連到帳號伺服器(`/relay/host/ws`),雙方每 15–20 秒互相確認,45–60 秒沒聲音就重連。帳號伺服器沒有 WebSocket 時(舊版),改用 HTTP 長輪詢(`/relay/host/next`)。 + +- 手機的要求:手機 → 帳號伺服器(HTTPS)→ 這條連線 → AnyBot → 回覆原路回去。 +- AnyBot 找另一台 AnyBot(同步、手腳):從自己的連線送 `call` 上去,伺服器轉給對方的連線,答案以 `ret` 回來。 +- 連線斷掉時,等待中的請求立刻收到「斷線了」,不用等逾時;伺服器也立刻知道這台離線。 +- 超過 1 KB、而且壓得小的內容用 deflate 壓縮(圖片、PDF 這類已壓縮的照原樣送)。 + +格式見 [protocol.md](protocol.md)。 + +### 大腦和螢幕的同步(`app/mirror_ui.rs`) + +大腦回答「從版本 N 之後改了什麼」: + +- **設定**:State 去掉聊天、拿掉金鑰後算指紋,變了才送整份。 +- **聊天**:每則訊息有指紋,只送第一個不同的那則之後的部分。 +- **討論串**、**誰在工作**、**等你核准的事**。 + +螢幕的請求會等最多 20 秒,有變化就馬上回(長輪詢,跑在長連線上)。大腦上的改動通常 1 秒內出現在螢幕上。 + +螢幕上的改動變成小操作送回大腦: + +- 單一值(`set`、`bot_set`); +- 清單和對照表逐項修改(`patch`、`bot_patch`:新增或修改某幾項、刪掉某幾項、改順序),所以兩台同時改同一份清單的不同項目,兩邊都會保留; +- 新增、刪除、排序 Bot(`bot_add`、`bot_del`、`bot_order`)。 + +每個改動有編號,大腦回報收到第幾號;還沒確認的改動,會疊在大腦傳回來的新設定上,打字不會被蓋回去。所有改動和聊天動作由一個工作排隊依序送出。送失敗時,螢幕重新要一份完整資料。 + +聊天動作(傳訊息、停止、核准、重新產生、開新對話…)送 `act`,由大腦執行。附件先上傳到大腦。 + +### 金鑰只在大腦 + +送給螢幕的設定裡,模型金鑰、訊息管道的 Token、連接器的環境變數和標頭都換成「••••(已設定,在大腦上)」。螢幕改設定送回來時,大腦看到這個替代字,就保留原本的值(清單裡依 id 或名字對上)。螢幕要取得模型清單時,由大腦用自己的金鑰去抓。 + +### 手腳 + +大腦的 Bot 呼叫工具時可以指定 `computer`。如果是帳號裡的另一台,就透過長連線送 `/api/device/run` 給它,由它在自己身上執行(螢幕、檔案、指令、它的 AnyBot 瀏覽器),再把文字和截圖傳回來。那台要開「裝置連結」才接受;若設了只給某個大腦用(`hands_for`),帳號伺服器會附上是哪一台在問,不是那台就拒絕。 + +### 配對碼 + +已登入的手機或 AnyBot 向帳號伺服器要一組 6 位數字(10 分鐘、一次)。新電腦用這組數字換到自己的身分(主機 id 和金鑰),不經過密碼。配對碼記得它是給哪個大腦的,新電腦就只讓那個大腦使用。 + +### 看圖小幫手 + +模型拒收圖片時,AnyBot 記下這個模型看不到圖。之後送給它的請求裡,你傳的每張圖會先交給你帳號裡看得到圖的模型(依序試 xAI、Anthropic、OpenAI、Gemini、OpenRouter…,先用你設的預設模型),把描述(包括圖裡的文字)放進訊息。描述依圖片記住,同一張圖不會再看一次;失敗的模型 10 分鐘內跳過。 diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..097fd85 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,103 @@ +# 從原始碼建置和安裝 + +## 需要準備 + +| 平台 | 需要 | +|---|---| +| 全部 | Rust(stable,用 rustup 安裝)、git、make | +| macOS 12 以後 | Xcode Command Line Tools(`xcode-select --install`) | +| Linux | 編譯用:`build-essential pkg-config libasound2-dev`;執行用的系統套件見下面,`make deps` 會一次裝好 | +| Windows | Visual Studio Build Tools(C++)。可以 `cargo build --release`,安裝包還沒做 | + +安裝 Rust: + +```sh +curl --proto '=https' -sSf https://sh.rustup.rs | sh +``` + +## 步驟 + +```sh +git clone git@git.30cm.net:daniel.w/AnyBot.git +cd AnyBot +make deps # Linux:安裝系統套件(會問 sudo 密碼);Mac:檢查 Xcode 工具 +make # 建置 AnyBot 和帳號伺服器(第一次約 5–15 分鐘) +make install +``` + +`make install` 做的事: + +- **macOS**:做出 `AnyBot.app`(含圖示),放到 `~/Applications/`。沒有 Apple 開發者簽章,第一次打開請按右鍵 › 打開。想放到別的地方:`make install APPDIR=/Applications`。 +- **Linux**:程式放在 `~/.local/bin/anybot`,選單項目和圖示放在 `~/.local/share/`。不需要 root。 + +不想安裝、只想試:`make run`。 + +## Linux 的系統套件 + +`make deps` 依發行版(apt、dnf、pacman)安裝這些,全部都是選用的,缺了 App 會說明少了什麼: + +| 套件 | 用途 | +|---|---| +| libasound2、libxkbcommon-x11-0、libgl1、xdg-utils | 視窗和聲音(必要) | +| libnotify-bin | 通知 | +| libsecret-tools | 保險庫存在系統鑰匙圈 | +| espeak-ng | 唸出回覆 | +| zenity | 選檔案的視窗 | +| xclip / wl-clipboard | 貼上圖片;GNOME/KDE Wayland 上打中文 | +| ffmpeg | 影片、語音、示範錄影 | +| docker.io | Bot 自己的電腦 | +| fonts-noto-cjk | 更多中文字形(內建的粉圓體已經夠用) | + +沒有螢幕的 Linux(只當手腳或大腦):只需要 `libasound2`。要讓 Bot 看得到畫面,再執行 `make hands`,會裝好虛擬螢幕(Xvfb)、視窗管理員和輔助使用服務。 + +## 更新 + +```sh +git pull +make install +``` + +你的 Bot、聊天和設定不在程式裡,更新不會動到它們。 + +## 移除 + +```sh +make uninstall +``` + +只移除程式。要連資料一起刪,刪掉資料資料夾: + +| 平台 | 資料在哪 | +|---|---| +| macOS | `~/Library/Application Support/AnyBot/` | +| Linux | `~/.local/share/AnyBot/` | +| Windows | `%APPDATA%\AnyBot\` | + +設成開機自動執行的(`anybot setup` 裝的)也要停掉:Linux 執行 `systemctl --user disable --now anybot`。 + +## 其他執行方式 + +| 指令 | 用途 | +|---|---| +| `make serve`(`anybot --serve`) | 不開視窗:Bot、例行任務、訊息管道一直運作,用手機操作 | +| `make setup`(`anybot setup`) | 把這台接到你的 AnyBot(大腦或手腳),見 [multi-device.md](multi-device.md) | +| `make relay-run` | 在這台跑帳號伺服器(測試用,埠 8080) | +| `make server` | 在伺服器上用 Docker 架帳號伺服器、大腦和 HTTPS | +| `ANYBOT_DATA_DIR=/某個資料夾 anybot` | 用另一份資料(測試、同一台跑兩份) | + +## 常見問題 + +**Mac 說「無法打開,因為無法驗證開發者」** +按右鍵 › 打開,再按一次「打開」。只有第一次需要。 + +**Mac 上 Bot 看不到螢幕、不能點擊** +系統設定 › 隱私權與安全性:「螢幕錄製」和「輔助使用」要打開 AnyBot。App 的 設定 › 電腦 有按鈕直接開到那一頁。 + +**Linux 啟動時說找不到 libasound.so.2** +`sudo apt-get install libasound2`(Ubuntu 24.04 以後叫 `libasound2t64`)。 + +**編譯時找不到 alsa 或 pkg-config** +Linux 少了編譯用的套件:`sudo apt-get install build-essential pkg-config libasound2-dev`。 + +**第一次 `make` 很久** +要下載並編譯所有套件,第一次需要 5–15 分鐘,之後只會重編有改的部分。 diff --git a/docs/multi-device.md b/docs/multi-device.md new file mode 100644 index 0000000..bd13967 --- /dev/null +++ b/docs/multi-device.md @@ -0,0 +1,109 @@ +# 多台電腦:大腦、手腳、螢幕 + +帳號裡選一台 AnyBot 當**大腦**,其他電腦和手機都連到它。 + +| 角色 | 是什麼 | 怎麼設 | +|---|---|---| +| 大腦 | 保存所有 Bot、聊天、記憶和例行任務,由它思考和排程。要一直開著 | 登入帳號就是(預設) | +| 螢幕 | 有視窗的電腦:看到的、改的都是大腦的 Bot;同時也是大腦的手腳 | 設定 › 手機與帳號 › 大腦 › 「改用它當大腦」 | +| 手腳 | 讓大腦的 Bot 操作這台:畫面、點擊打字、檔案和指令、這台的瀏覽器 | 「加一台電腦」的那一行,或 `anybot setup` | +| 手機 | 網頁版,打開帳號伺服器的網址 | 登入同一個帳號 | + +所有電腦都透過**帳號伺服器**互相連絡。每台 AnyBot 主動連過去、保持一條一直開著的連線,所以電腦不用開放連接埠,也不用固定 IP。 + +## 一、架帳號伺服器和大腦 + +### 做法 A:放在伺服器上(推薦,Mac 關機時大腦也還在) + +需要:一台有 Docker 的 Linux 主機,以及一個指到它的網域。 + +```sh +git clone git@git.30cm.net:daniel.w/AnyBot.git && cd AnyBot +make server +``` + +只問三件事:網域、帳號、密碼。會自動申請 HTTPS 憑證、建立帳號、啟動大腦。完成畫面會告訴你手機和電腦怎麼連。之後再執行一次 `make server`,可以選「用現有設定,更新並重新啟動」。 + +不想在伺服器上編譯:下載發行檔 `AnyBot-<版本>-server-x86_64.tar.gz`,解開後 `cd deploy/server && ./setup.sh`,用的是已經編好的程式。 + +### 做法 B:先用一台 Mac 當大腦(家裡用 Tailscale) + +1. 跑帳號伺服器,並設成開機自動啟動: + ```sh + make relay + mkdir -p "$HOME/Library/Application Support/AnyBot/relay" + cp relay/target/release/anybot-relay "$HOME/Library/Application Support/AnyBot/relay/" + cp deploy/mac/com.anybot.relay.plist ~/Library/LaunchAgents/ # 先把裡面的網址改成你的 + launchctl load ~/Library/LaunchAgents/com.anybot.relay.plist + ``` +2. 用 Tailscale 給它 HTTPS 網址:`tailscale serve --bg 8780`(網址像 `https://mac-mini.xxxx.ts.net`)。 +3. 打開 AnyBot › 設定 › 手機與帳號,填這個網址,建立帳號並登入。這台就是大腦。 + +只有同一個 Tailscale 網路裡的手機和電腦連得到。 + +## 二、接上其他電腦 + +### 有視窗的電腦(螢幕) + +打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號,在「大腦」那一欄按某一台旁邊的「改用它當大腦」。 + +- 這台原本的 Bot 會留著不刪,可以按「把這台的 Bot 搬過去」複製到大腦(連同聊天、金鑰、技能和連接器;已經在大腦上的不會重複搬)。 +- 隨時可以按「改回用這台自己的 Bot」。 +- 選大腦時也會打開這台的「裝置連結」:大腦的 Bot 可以用這台當手腳,每個動作仍照大腦的規則先問你。 + +### 沒有螢幕的 Linux(手腳) + +1. 在手機 AnyBot 右上「⋯ › 加一台電腦」,或電腦 AnyBot 的 設定 › 手機與帳號 › 大腦 › 加一台電腦 › 產生配對碼。 +2. 會出現一行(10 分鐘內有效,只能用一次): + ```sh + anybot setup https://anybot.example.com 469615 + ``` +3. 在那台電腦裝好 AnyBot(`make install`,或發行檔 `AnyBot-<版本>-headless-x86_64.tar.gz` 裡的 `./install.sh`),再貼上這一行執行。 + +它會:加入帳號(不用密碼)、只讓產生配對碼的那個大腦使用、設成開機自動執行(systemd 使用者服務 `anybot`)、沒有螢幕時開一個 1440×900 的虛擬螢幕,最後確認大腦看得到它。大腦約一分鐘內就會把它列進 Bot 可以用的電腦。 + +想讓 Bot 看得到這台的畫面,先執行 `make hands`(裝 Xvfb、視窗管理員、輔助使用服務)。 + +只打 `anybot setup` 會出現一個畫面:填網址和配對碼(或改用帳號密碼)。給程式用: + +```sh +ANYBOT_ACCOUNT_PASSWORD=… anybot setup --server https://… --user 帳號 [--brain-name 雲端大腦] [--no-service] +``` + +登出後也要繼續跑:`sudo loginctl enable-linger $USER`(完成畫面會提醒)。 + +### 手機 + +打開帳號伺服器的網址,登入。可以「加入主畫面」當 App 用、開通知。帳號裡有螢幕電腦時,手機會直接打開它們用的大腦。 + +## 三、連不上時 + +畫面上方會說是哪一段連不到: + +| 看到 | 意思 | 怎麼辦 | +|---|---|---| +| 這台電腦沒有連上網路 | 這台斷網 | 檢查 Wi-Fi 或網路線 | +| 連不到帳號伺服器:伺服器沒有開 | 帳號伺服器沒在跑 | 伺服器上 `docker compose ps`;Mac 上看 `launchctl list | grep anybot` | +| 帳號伺服器暫時無法使用 | 正在重開或 HTTPS 憑證還沒好 | 等一下,會自動重連 | +| 大腦目前離線 | 大腦關機、睡眠或 AnyBot 沒開 | 打開大腦;這段時間還能看之前的對話 | +| 大腦收到了,但太久沒有回應 | 大腦太忙或網路很慢 | 稍後再試 | +| 這台電腦的帳號登入失效了 | 帳號被登出或金鑰換了 | 重新登入 | +| 這台電腦只讓大腦「…」使用 | 別台想用這台當手腳 | 正常:配對時就限定了 | + +大腦連不到時:之前的對話還能看,不能傳訊息;連回來會自動恢復,不用重開。 + +**忘記帳號密碼**:在帳號伺服器那台,先停掉它,再執行(輸入兩次新密碼,畫面不顯示): + +```sh +anybot-relay --data <資料夾> --reset-password <帳號> +``` + +舊的加密備份是用舊密碼鎖的,改完之後在電腦上按一次「立刻備份」。 + +## 四、安全 + +- 模型金鑰、訊息管道的 Token、連接器的密碼只在大腦上。螢幕電腦看到的是「已設定,在大腦上」,在螢幕上改設定也不會洗掉真的金鑰。 +- 手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。 +- 每個動作照大腦的規則先問你(或照你設的「一律允許」)。 +- 配對碼 10 分鐘有效、只能用一次,連續猜錯會被暫停 5 分鐘。 +- 技術細節見 [architecture.md](architecture.md) 和 [protocol.md](protocol.md)。 diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..a4c565b --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,117 @@ +# 介面與長連線格式 + +給要改帳號伺服器或寫其他用戶端的人。所有回覆都是 JSON;錯誤是 `{"error": "白話的說明"}` 加上對應的 HTTP 狀態碼。 + +## 身分 + +| 誰 | 怎麼證明 | 從哪來 | +|---|---|---| +| 人(手機) | `Authorization: Bearer ` | `POST /relay/login` | +| 一台 AnyBot(主機) | `Authorization: Bearer ` | `POST /relay/hosts`(登入後)或 `POST /relay/pair/claim`(配對碼) | + +伺服器只存密碼和金鑰的雜湊值。 + +## 帳號伺服器(relay) + +### 帳號 + +| 方法 | 路徑 | 說明 | +|---|---|---| +| POST | `/relay/signup` | `{user, password, invite}`。第一個帳號不用邀請碼,之後要 `ANYBOT_RELAY_INVITE` | +| POST | `/relay/login` | `{user, password, device}` → `{token}`。錯 8 次停 5 分鐘 | +| POST | `/relay/logout` | 登出這個 session | +| GET | `/relay/me` | 帳號、可用的 AnyBot(含 `online`、`uses`、`hands`)、登入過的裝置、有沒有備份 | + +### 主機 + +| 方法 | 路徑 | 說明 | +|---|---|---| +| POST | `/relay/hosts` | (人)`{name}` → `{id, token}`。同名的主機會換新金鑰 | +| POST | `/relay/hosts//remove` | (人)移除主機 | +| GET | `/relay/host/ws` | (主機)長連線,見下面 | +| GET | `/relay/host/next` | (主機)舊式長輪詢:有要求就回 job,25 秒沒有回 204 | +| POST | `/relay/host/reply/` | (主機)回答舊式 job | +| GET | `/relay/host/peers` | (主機)帳號裡的其他主機 | +| GET/POST | `/relay/host/members` | (主機)團隊成員和角色(admin、member) | +| POST | `/relay/host/notify` | (主機)推播通知到帳號的手機 | +| POST | `/relay/host/sites/` | (主機)發布 Bot 做的網頁,公開在 `/s//` | +| PUT/GET | `/relay/backup` | 加密備份(伺服器打不開) | + +### 配對碼 + +| 方法 | 路徑 | 說明 | +|---|---|---| +| POST | `/relay/pair` | (人或主機)`{for: 大腦的主機 id}` → `{code, expires_in: 600}` | +| POST | `/relay/pair/claim` | (不用登入)`{code, name}` → `{id, token, user, for, for_name}`。只能用一次;同一個位址錯 8 次停 5 分鐘 | + +### 轉送 + +| 路徑 | 誰 | 轉成 | +|---|---|---| +| `/h//api/...` | 手機 | 那台 AnyBot 的 `/api/...`,角色是 owner/admin/member | +| `/relay/hosts//api/sync[/...]` | 同帳號的主機 | 大腦的 `/api/sync...`,角色 `mirror` | +| `/relay/hosts//api/device/run` | 同帳號的主機 | 手腳的 `/api/device/run`,角色 `device` | + +轉送時伺服器附上 `user`、`role` 和 `from`(問的那台主機 id)。對方不在線時立刻回 503「目前離線」;收到了但 35 秒內沒回答,回 504。 + +## AnyBot 提供的介面(`remote::handle`) + +只有經過帳號伺服器(或同一個網路、用手機密碼登入)才到得了。 + +| 方法 | 路徑 | 角色 | 說明 | +|---|---|---|---| +| GET | `/api/state?since=` | 手機 | Bot 清單、誰在工作、等核准的事(有變化才回,最多等 20 秒) | +| GET | `/api/bots/?since=` | 手機 | 一個 Bot 的訊息 | +| POST | `/api/bots//<動作>` | 手機 | `send`、`stop`、`answer`、`read`、`new`、`upload`… | +| GET/POST | `/api/screen/` | 手機 | 看畫面、點擊打字 | +| POST | `/api/device/run` | device | `{tool, args, who}` → `{text, ok, image}`:在這台跑一個工具 | +| GET | `/api/sync?since=&client=` | mirror | 大腦的變化(見 architecture.md) | +| POST | `/api/sync/edit` | mirror | `{ops, client, seq}` → `{ok, applied}` | +| POST | `/api/sync/act` | mirror | `{bot, action, …}`:send、stop、answer、read、new、regenerate、reply、restore | +| POST | `/api/sync/upload/?name=` | mirror | 原始檔案內容 → `{path}` | +| GET | `/api/sync/file?path=` | mirror | 大腦上的附件(只限 `attachments/` 裡的檔案) | +| POST | `/api/sync/models` | mirror | `{provider}` → `{models}`:用大腦的金鑰取得模型清單 | + +## 長連線的格式(`src/wire.rs`) + +WebSocket 的二進位訊息,每則是: + +``` +[4 位元組,表頭長度,big-endian] [表頭 JSON] [內容的原始位元組] +``` + +內容不做 base64。`z: true` 表示內容用 deflate 壓過;`z_ok: true` 表示問的人看得懂壓過的回答。 + +| t | 方向 | 表頭欄位 | 意思 | +|---|---|---|---| +| `hello` | AnyBot → 伺服器 | `brain`、`hands` | 這台在看哪個大腦、是不是手腳(手機和設定畫面用) | +| `job` | 伺服器 → AnyBot | `id, method, path, query, user, role, from, z, z_ok` | 給這台的要求 | +| `reply` | AnyBot → 伺服器 | `id, status, ctype, headers, z` | 回答 job | +| `call` | AnyBot → 伺服器 | `id, to, method, path, query, z` | 要問帳號裡的另一台(只能是 `/api/sync…` 或 `/api/device/run`) | +| `ret` | 伺服器 → AnyBot | `id, status, ctype, z` | call 的回答 | + +心跳用 WebSocket 的 ping/pong。連線斷掉時,伺服器把還在等的 job 都回 503「斷線了」,AnyBot 把還在等的 call 都回「和帳號伺服器的連線斷了」。 + +## 同步的改動(`/api/sync/edit` 的 ops) + +| op | 欄位 | 說明 | +|---|---|---| +| `set` | `key, value` | 換掉一個設定(`next_*` 計數只會變大) | +| `patch` | `key, patch` | 清單或對照表逐項修改 | +| `merge` | `key, value` | 搬移時補上大腦沒有的(providers、skills、mcp_servers) | +| `bot_add` | `bot` | 新增 Bot(id 撞到會換) | +| `bot_del` | `id` | 刪除(主要 Bot 不能刪) | +| `bot_set` | `id, key, value` | 改 Bot 的一個欄位 | +| `bot_patch` | `id, key, patch` | 逐項修改 Bot 的清單欄位(例行任務保留大腦記錄的執行時間) | +| `bot_read` | `id` | 標為已讀 | +| `bot_order` | `ids` | Bot 的順序 | + +`patch` 的格式: + +```json +{"value": …} 換掉整個值 +{"map": {"put": {"鍵": 值}, "del": ["鍵"]}} 對照表 +{"list": {"put": [項目], "del": [鍵], "order": [鍵]}} 清單;鍵是項目的 id,沒有就用 name,純值就是值本身 +``` + +清單裡的項目沒有 id 或名字、或鍵重複時,送整個值。 diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..17d807f --- /dev/null +++ b/docs/release.md @@ -0,0 +1,106 @@ +# 發行 + +## 自己打包 + +```sh +make dist # 版本號取自 git tag(沒有就用 commit 編號) +make dist VERSION=1.2.0 # 指定版本號 +``` + +| 在哪打包 | 產生 | +|---|---| +| Mac | `dist/AnyBot-<版本>-macos-arm64.zip`:AnyBot.app(含圖示,ad hoc 簽章) | +| Linux | `dist/AnyBot-<版本>-ubuntu-<架構>.tar.gz`:有視窗的版本,`./install.sh` 安裝 | +| | `dist/AnyBot-<版本>-headless-<架構>.tar.gz`:沒有螢幕的 Linux(手腳或大腦),附 `hands.sh` | +| | `dist/AnyBot-<版本>-server-<架構>.tar.gz`:帳號伺服器+大腦+HTTPS,用已編好的程式建 Docker 映像,`deploy/server/setup.sh` | + +Linux 版要在 Ubuntu 22.04(glibc 2.35)上編譯,才能在較新的 Ubuntu、Debian 12 等直接跑;用 `deploy/ci/Dockerfile.linux` 這個環境就是。 + +打包腳本:`deploy/mac/bundle.sh`、`deploy/ci/package-linux.sh`。 + +## 推 tag 自動建置(Gitea Actions) + +`.gitea/workflows/release.yml`:推一個 `v` 開頭的 tag,兩個 runner 各自建置,把檔案掛到那個 tag 的 Release 上(沒有 Release 會自動建立;重建時會換掉同名的檔案)。 + +```sh +git tag v1.2.0 +git push origin v1.2.0 +``` + +| 工作 | runner 標籤 | 做什麼 | +|---|---|---| +| linux | `anybot-linux` | 在 `anybot-builder:22.04` 容器裡編譯、跑測試、打三個 Linux 包、上傳 | +| macos | `macos-arm64` | 在 Mac 上直接編譯、做 AnyBot.app、上傳 | + +上傳用 `secrets.RELEASE_TOKEN`;沒設時用 Gitea 自動給的 token。如果自動的 token 沒有建立 Release 的權限,到 repo › 設定 › Actions › Secrets 加一個 `RELEASE_TOKEN`(有這個 repo 寫入權限的 access token)。 + +### 設定 Linux runner + +在一台有 Docker 的 Linux(x86_64)上: + +1. 在 Gitea 打開 Actions(管理員:`app.ini` 的 `[actions] ENABLED = true`),到 repo › 設定 › Actions › Runners › 建立新的 runner,複製註冊權杖。 +2. 安裝 runner([gitea/runner](https://gitea.com/gitea/runner/releases),下載後核對 `.sha256`): + ```sh + V=5.3.0 + curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-linux-amd64.xz + curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-linux-amd64.xz.sha256 + sha256sum -c gitea-runner-$V-linux-amd64.xz.sha256 && xz -d gitea-runner-$V-linux-amd64.xz + install -m 755 gitea-runner-$V-linux-amd64 ~/.local/bin/gitea-runner + ``` +3. 建好建置環境(只要一次;更新 Rust 時再建一次): + ```sh + docker build -t anybot-builder:22.04 -f deploy/ci/Dockerfile.linux deploy/ci + ``` +4. 設定和註冊: + ```sh + mkdir -p ~/.config/gitea-runner + cp deploy/ci/runner-linux.yaml ~/.config/gitea-runner/config.yaml # 檔案路徑裡的使用者名稱照你的改 + cd ~/.config/gitea-runner + gitea-runner register --no-interactive --instance https://你的-gitea --token 註冊權杖 \ + --name anybot-linux --labels "anybot-linux:docker://anybot-builder:22.04" -c config.yaml + ``` +5. 一直跑(systemd 使用者服務): + ```ini + # ~/.config/systemd/user/gitea-runner.service + [Unit] + Description=Gitea runner (AnyBot releases) + After=network-online.target docker.service + + [Service] + WorkingDirectory=%h/.config/gitea-runner + ExecStart=%h/.local/bin/gitea-runner daemon -c %h/.config/gitea-runner/config.yaml + Restart=always + RestartSec=5 + + [Install] + WantedBy=default.target + ``` + ```sh + systemctl --user daemon-reload && systemctl --user enable --now gitea-runner + sudo loginctl enable-linger $USER # 登出後也繼續跑 + ``` + +`runner-linux.yaml` 用兩個 Docker 磁碟區保留下載的套件(`anybot-cargo-registry`)和編譯結果(`anybot-ci-target`),之後每次發行只重編有改的部分。 + +### 設定 Mac runner + +Apple 的 App 只能在 Mac 上編譯。在一台裝好 Rust 和 Xcode Command Line Tools 的 Mac 上: + +```sh +V=5.3.0 +curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-darwin-arm64.xz +curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-darwin-arm64.xz.sha256 +shasum -a 256 -c gitea-runner-$V-darwin-arm64.xz.sha256 && xz -d gitea-runner-$V-darwin-arm64.xz +install -m 755 gitea-runner-$V-darwin-arm64 ~/.local/bin/gitea-runner +mkdir -p ~/.config/gitea-runner && cd ~/.config/gitea-runner +gitea-runner register --no-interactive --instance https://你的-gitea --token 註冊權杖 \ + --name anybot-mac --labels "macos-arm64:host" +gitea-runner daemon # 或做成 launchd 代理,開機自動執行 +``` + +`host` 表示工作直接在這台 Mac 上跑(不用容器)。編譯結果保留在工作資料夾旁的 `anybot-target/`。 + +### 注意 + +- runner 要連得到 Gitea 的網址(HTTPS),也要能從 GitHub 下載 `actions/checkout`(Gitea 預設的 action 來源)。 +- Mac 版用 ad hoc 簽章,沒有公證;使用者第一次打開要按右鍵 › 打開。要正式簽章和公證需要 Apple 開發者帳號。 diff --git a/mobile/index.html b/mobile/index.html index 464e3f1..07f1df3 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -132,6 +132,11 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400} .sheet button{display:block;width:100%;text-align:left;padding:14px 16px;border-radius:12px;font-size:16px} .sheet button:active{background:var(--raise)} .sheet .no{color:var(--danger)} +.pair{padding:18px 16px calc(env(safe-area-inset-bottom) + 12px)} +.pair-t{font-size:18px;font-weight:600;margin:2px 4px 6px} +.pair-code{font:600 34px/1.2 ui-monospace,Menlo,monospace;letter-spacing:6px;text-align:center;margin:16px 0 10px} +.pair-line{display:block;font:13px/1.5 ui-monospace,Menlo,monospace;background:var(--raise);border-radius:10px;padding:10px 12px;word-break:break-all;margin-bottom:12px;user-select:all} +.sheet .pair-copy,.pair .btn.main{text-align:center;padding:13px} .sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px} .toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center} .toast.on{opacity:.95} @@ -217,11 +222,22 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
Bot 需要你允許、或在你不在時做完事,手機會跳通知。
+
+
+
加一台電腦
+
在那台電腦的終端機貼上這一行執行,它就會變成「」的手腳,開機自動執行。不用帳號密碼。
+
······
+ + +
+ +
+