Merge branch 'easy-install': 頁面, Tailscale servers, hands fixes, one-step install updates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
This commit is contained in:
王性驊 2026-11-05 08:53:29 +08:00
commit 5dfb086071
22 changed files with 1336 additions and 44 deletions

View File

@ -1259,6 +1259,15 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
- Bot 用 `open_for_person` 把它做好的網頁、圖表、報告、圖片、文件在你的螢幕上打開(網址也可以)。在 Bot 的電腦上做的檔案會先複製到這台 Mac。 - Bot 用 `open_for_person` 把它做好的網頁、圖表、報告、圖片、文件在你的螢幕上打開(網址也可以)。在 Bot 的電腦上做的檔案會先複製到這台 Mac。
### 頁面(Bot 做、Bot 持續更新的報告)
- 你請 Bot 追蹤、整理、比較一件之後還會回來看的事(例如「幫我追蹤一下台灣 FSD 狀況」),它用 `write_page` 做成一頁:先寫現況,再寫細節和來源(附日期)。回答裡用「[標題](page:ID)」連到它,回答下面出現頁面卡片(標題、開頭幾行、頁面圖示、「頁面 · 更新時間」、最近一次更新了什麼),點一下打開。
- 頁面可以放表格、Mermaid 圖表(圓餅、長條、折線、時間軸…),以及頁面專用的區塊:```stats 數字卡片、```steps 進度(✓ 完成、◉ 進行中、○ 還沒到)、`> [!NOTE]`/`[!TIP]`/`[!WARNING]`/`[!IMPORTANT]` 提示框。這些區塊在聊天裡也能用。
- 要定期追蹤時,Bot 會同時設例行任務:每次先 `read_page`、查新消息,有實質進展才更新頁面並告訴你;沒有就說「沒有新消息」,頁面不動。
- 每次更新保留上一版(最多 10 版),打開頁面後下方「版本紀錄」可以看舊版。也可以複製成 Markdown、存成檔案(到「下載」)、刪除。
- 右側面板「資料庫」最上面列出這個 Bot 的頁面。手機也能看:聊天裡的卡片、頁面連結、完整頁面(圖表由 AnyBot 畫好傳過去)。
- 頁面存在大腦上,螢幕電腦和手機看到的是同一份。
### 生活卡片 ### 生活卡片
- Bot 用 `show_card` 把機票、餐廳或地點、天氣、商品、活動整理成卡片顯示在聊天裡:圖示、標題、副標、最多 12 列「項目/內容」,以及「查看來源 ↗」。 - Bot 用 `show_card` 把機票、餐廳或地點、天氣、商品、活動整理成卡片顯示在聊天裡:圖示、標題、副標、最多 12 列「項目/內容」,以及「查看來源 ↗」。

View File

@ -44,13 +44,13 @@
**步驟 1:架伺服器**(只做一次) **步驟 1:架伺服器**(只做一次)
需要:一台 Linux 主機(2 GB 以上記憶體)、一個網域,網域的 A 紀錄要指到這台主機,80 和 443 埠要打開。 需要:一台 Linux 主機(2 GB 以上記憶體),加上一個網域(A 紀錄指到這台、80 和 443 埠打開)或 Tailscale(不用網域,只有你的裝置連得到)。
```sh ```sh
curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server
``` ```
只會問三件事:**網域、帳號、密碼**。它會自動裝 Docker、申請 HTTPS 憑證、建立帳號、啟動大腦。完成畫面會告訴你網址。 只會問三件事:**網址(網域或 Tailscale)、帳號、密碼**。它會自動裝 Docker、申請 HTTPS 憑證、建立帳號、啟動大腦。完成畫面會告訴你網址。
> 沒有伺服器?也可以先拿一台 Mac 當大腦,見 [docs/multi-device.md](docs/multi-device.md)(做法 B)。 > 沒有伺服器?也可以先拿一台 Mac 當大腦,見 [docs/multi-device.md](docs/multi-device.md)(做法 B)。
@ -133,7 +133,8 @@ make install # 安裝:Mac → ~/Applications/AnyBot.app;Linux → ~/.loca
| 方式 | 用在 | 說明 | | 方式 | 用在 | 說明 |
|---|---|---| |---|---|---|
| **Docker Compose**(預設) | 伺服器 | `install.sh server` 用的就是它:帳號伺服器、大腦、Caddy(自動 HTTPS)三個容器,資料放在 Docker 磁碟區,重開機自動啟動。設定檔:[deploy/server/docker-compose.yml](deploy/server/docker-compose.yml) | | **Docker Compose**(預設) | 伺服器 | `install.sh server` 用的就是它:帳號伺服器、大腦、Caddy(自動 HTTPS)三個容器,資料放在 Docker 磁碟區,重開機自動啟動。用 Tailscale 網址時不開 Caddy,改由 `tailscale serve` 提供 HTTPS。設定檔:[deploy/server/docker-compose.yml](deploy/server/docker-compose.yml) |
| 免問安裝(腳本、Ansible…) | 伺服器 | 答案用環境變數給:`ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… [ANYBOT_SETUP_DOMAIN=網域] sh install.sh server`;不給網域時用這台的 Tailscale 網址 |
| Portainer、Coolify、Dokploy… | 已經在用這些管理工具的伺服器 | 它們都吃標準的 `docker-compose.yml`:把 repo 加進去、指定 `deploy/server/docker-compose.yml`、在環境變數填 `ANYBOT_USER`、`ANYBOT_PASSWORD`、`ANYBOT_RELAY_PUBLIC_URL`。若工具自己處理 HTTPS,就拿掉 `caddy` 那一段,把網域導到 `relay` 的 8080 埠 | | Portainer、Coolify、Dokploy… | 已經在用這些管理工具的伺服器 | 它們都吃標準的 `docker-compose.yml`:把 repo 加進去、指定 `deploy/server/docker-compose.yml`、在環境變數填 `ANYBOT_USER`、`ANYBOT_PASSWORD`、`ANYBOT_RELAY_PUBLIC_URL`。若工具自己處理 HTTPS,就拿掉 `caddy` 那一段,把網域導到 `relay` 的 8080 埠 |
| **systemd**(使用者服務) | 手腳、沒螢幕的 Linux 大腦 | `anybot setup` 會自動建立 `anybot.service`;登出後也要繼續跑:`sudo loginctl enable-linger $USER` | | **systemd**(使用者服務) | 手腳、沒螢幕的 Linux 大腦 | `anybot setup` 會自動建立 `anybot.service`;登出後也要繼續跑:`sudo loginctl enable-linger $USER` |
| **launchd** | Mac 當大腦或帳號伺服器 | 見 [docs/multi-device.md](docs/multi-device.md) | | **launchd** | Mac 當大腦或帳號伺服器 | 見 [docs/multi-device.md](docs/multi-device.md) |

View File

@ -0,0 +1,9 @@
# Tailscale instead of a public domain: only devices in your tailnet reach this server, and
# Tailscale gives it HTTPS (`tailscale serve`), so Caddy isn't started and ports 80/443 stay closed.
# setup.sh turns this on (COMPOSE_FILE in .env) when you pick 「用 Tailscale」.
services:
caddy:
profiles: ["caddy"]
relay:
ports:
- "127.0.0.1:8780:8080"

View File

@ -5,10 +5,17 @@
# ./setup.sh --dry-run ask and write the files, but don't start anything # ./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). # Run it again any time: it offers to keep the current settings (to update and restart).
#
# Without questions (scripts, Ansible…): give the answers as environment variables.
# ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… ANYBOT_SETUP_DOMAIN=anybot.example.com ./setup.sh
# leave out ANYBOT_SETUP_DOMAIN on a computer with Tailscale to use its Tailscale address.
# An existing setup is kept (updated and restarted).
set -u set -u
cd "$(dirname "$0")" cd "$(dirname "$0")"
DRY=0 DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1 [ "${1:-}" = "--dry-run" ] && DRY=1
AUTO=0
[ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ] && AUTO=1
# ---------- looks ---------- # ---------- looks ----------
if [ -t 1 ]; then if [ -t 1 ]; then
@ -64,6 +71,8 @@ ask() {
# choose VAR "問題" "選項|說明" ... (↑↓ and Enter) # choose VAR "問題" "選項|說明" ... (↑↓ and Enter)
choose() { choose() {
# Without questions: the first choice (keep the setup, use Tailscale).
if [ "$AUTO" = 1 ]; then printf -v "$1" '%s' 0; return; fi
local __v=$1 title=$2; shift 2 local __v=$1 title=$2; shift 2
local opts=("$@") sel=0 n=$# key rest i local opts=("$@") sel=0 n=$# key rest i
while :; do while :; do
@ -105,7 +114,7 @@ spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest
return $rc return $rc
} }
pause() { printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } pause() { [ "$AUTO" = 1 ] && return; printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; }
die() { card_close; printf '\n'; exit 1; } die() { card_close; printf '\n'; exit 1; }
# ---------- 0. this server ---------- # ---------- 0. this server ----------
@ -141,8 +150,20 @@ fi
if [ "${SKIP:-0}" != 1 ]; then if [ "${SKIP:-0}" != 1 ]; then
# ---------- 1. the address ---------- # ---------- 1. the address ----------
AT=1; MSG="" AT=1; MSG=""; TS=0
while :; do # On a tailnet: offer its address (HTTPS from Tailscale, no domain, nothing open to the internet).
TSNAME=$(tailscale status --json 2>/dev/null | sed -n 's/.*"DNSName": *"\([^"]*\)\.".*/\1/p' | head -1)
if [ "$AUTO" = 1 ] && [ -n "${ANYBOT_SETUP_DOMAIN:-}" ]; then DOMAIN=${ANYBOT_SETUP_DOMAIN#https://}; TSNAME=""; TS=2; fi
if [ "$AUTO" = 1 ] && [ "$TS" = 0 ] && [ -z "$TSNAME" ]; then
header; card_open "網址"; bad "這台沒有 Tailscale:請加上 ANYBOT_SETUP_DOMAIN=你的網域"; die
fi
if [ -n "$TSNAME" ]; then
choose HOW "手機和其他電腦用哪個網址連到這台?" \
"用 Tailscale:https://$TSNAME|只有你 Tailscale 裡的手機和電腦連得到。不用網域,也不用開 80、443 埠" \
"用自己的網域|任何地方都連得到(網域要先指到這台)"
if [ "$HOW" = 0 ]; then DOMAIN=$TSNAME; TS=1; fi
fi
while [ "$TS" = 0 ]; do
header; card_open "手機和其他電腦用哪個網址連到這台?" header; card_open "手機和其他電腦用哪個網址連到這台?"
note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。"
line "" line ""
@ -164,9 +185,11 @@ if [ "${SKIP:-0}" != 1 ]; then
break break
done done
[ "$TS" = 2 ] && TS=0
# ---------- 2. the account ---------- # ---------- 2. the account ----------
AT=2; MSG="" AT=2; MSG=""
while :; do if [ "$AUTO" = 1 ]; then USERNAME=$ANYBOT_SETUP_USER; PW1=$ANYBOT_SETUP_PASSWORD; BRAIN="雲端大腦"; fi
while [ "$AUTO" = 0 ]; do
header; card_open "你的 AnyBot 帳號" header; card_open "你的 AnyBot 帳號"
note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。" note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。"
line "" line ""
@ -199,6 +222,10 @@ if [ "${SKIP:-0}" != 1 ]; then
q ANTHROPIC_API_KEY "$ANT" q ANTHROPIC_API_KEY "$ANT"
q OPENAI_API_KEY "$OAI" q OPENAI_API_KEY "$OAI"
q GEMINI_API_KEY "$GEM" q GEMINI_API_KEY "$GEM"
if [ "$TS" = 1 ]; then
q ANYBOT_TAILSCALE 1
q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml"
fi
} >.env } >.env
chmod 600 .env chmod 600 .env
umask 022 umask 022
@ -223,6 +250,16 @@ if [ -z "${ANYBOT_BUILD_JOBS:-}" ] && [ -r /proc/meminfo ]; then
[ "$mem" -lt 3000 ] && warn "記憶體只有 ${mem} MB:編譯會比較久。被中斷的話先加 2 GB swap(見 docs/install.md)" [ "$mem" -lt 3000 ] && warn "記憶體只有 ${mem} MB:編譯會比較久。被中斷的話先加 2 GB swap(見 docs/install.md)"
fi fi
spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die
[ "${ANYBOT_TAILSCALE:-}" = 1 ] && TS=1
if [ "${TS:-0}" = 1 ]; then
# Tailscale gives the address HTTPS and passes it to the account server (remembered across reboots).
if tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1 \
|| sudo tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1; then
ok "Tailscale:https://$DOMAIN → 帳號伺服器"
else
bad "沒辦法設定 tailscale serve。請執行:sudo tailscale serve --bg --https=443 http://127.0.0.1:8780"
fi
fi
# The brain joins the account (and makes it, the first time). # The brain joins the account (and makes it, the first time).
f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined="" f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined=""
@ -246,7 +283,9 @@ for _ in $(seq 1 120); do
printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1 printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1
done done
printf '\r\e[K' printf '\r\e[K'
if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"; else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"
elif [ "${TS:-0}" = 1 ]; then warn "https://$DOMAIN 還連不到。Tailscale 的 HTTPS 要在管理頁打開(DNS › HTTPS Certificates),看設定:tailscale serve status"
else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi
card_close card_close
printf '\n' printf '\n'

View File

@ -15,7 +15,10 @@
### 做法 A:放在伺服器上(推薦,Mac 關機時大腦也還在) ### 做法 A:放在伺服器上(推薦,Mac 關機時大腦也還在)
需要:一台 Linux 主機(2 GB 以上記憶體;更小的請先加 swap,見 [install.md](install.md) 的常見問題)、一個 A 紀錄指到它的網域、打開 80 和 443 埠。Docker 沒裝會自動裝。 需要:一台 Linux 主機(2 GB 以上記憶體;更小的請先加 swap,見 [install.md](install.md) 的常見問題),以及下面其中一種網址。Docker 沒裝會自動裝。
- **Tailscale**(不用網域):那台裝好 Tailscale,並在 Tailscale 管理頁打開 HTTPS(DNS › HTTPS Certificates)。設定時會出現「用 Tailscale:https://那台.xxx.ts.net」,選它就好;只有你 Tailscale 裡的手機和電腦連得到,不用開任何埠。
- **自己的網域**:A 紀錄指到這台,打開 80 和 443 埠,HTTPS 憑證自動申請。
```sh ```sh
curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server
@ -63,6 +66,10 @@ curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh
虛擬螢幕(Xvfb)、視窗管理員和輔助使用服務會一起裝好,Bot 看得到這台的畫面。 虛擬螢幕(Xvfb)、視窗管理員和輔助使用服務會一起裝好,Bot 看得到這台的畫面。
這台登入了桌面(例如 GNOME)也一樣:Bot 用的是 AnyBot 自己開的虛擬螢幕,不會動到你的桌面,也不用有人在那台前面按「允許分享螢幕」。想讓 Bot 操作那台登入中的桌面,加 `--real-screen`。
更新:在那台再執行一次安裝的那一行(或 `cd ~/AnyBot && ./install.sh hands`),會換成新版並重新啟動,不用再配對。
只打 `anybot setup` 會出現一個畫面:填網址和配對碼(或改用帳號密碼)。給程式用: 只打 `anybot setup` 會出現一個畫面:填網址和配對碼(或改用帳號密碼)。給程式用:
```sh ```sh

View File

@ -95,7 +95,6 @@ linux_packages() {
# $1: build | app | hands | server # $1: build | app | hands | server
case $PM in case $PM in
apt-get) apt-get)
$SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看"
case $1 in case $1 in
server) set -- curl ca-certificates git ;; server) set -- curl ca-certificates git ;;
build) set -- build-essential pkg-config git curl ca-certificates libasound2-dev ;; build) set -- build-essential pkg-config git curl ca-certificates libasound2-dev ;;
@ -106,8 +105,19 @@ linux_packages() {
libxkbcommon-x11-0 "libgl1|libgl1-mesa-glx" xdg-utils libnotify-bin libsecret-tools espeak-ng zenity \ libxkbcommon-x11-0 "libgl1|libgl1-mesa-glx" xdg-utils libnotify-bin libsecret-tools espeak-ng zenity \
xclip wl-clipboard ffmpeg fonts-noto-cjk ;; xclip wl-clipboard ffmpeg fonts-noto-cjk ;;
esac esac
# A fresh system has no package lists yet: fetch them first, or nothing can be picked.
if ! ls /var/lib/apt/lists/*_Packages >/dev/null 2>&1; then
$SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看"
UPDATED=1
fi
pkgs=$(apt_pick "$@" | tr '\n' ' ') pkgs=$(apt_pick "$@" | tr '\n' ' ')
info "apt-get install $pkgs" # Already there (an update): nothing to install, no sudo password needed.
missing=""
for p in $pkgs; do dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"; done
if [ -z "$missing" ]; then info "都已經裝好了"; return 0; fi
pkgs=$missing
[ -n "${UPDATED:-}" ] || $SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看"
info "apt-get install$pkgs"
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $pkgs >/dev/null || die "安裝系統套件失敗。" "請看上面的錯誤,或手動執行:sudo apt-get install $pkgs" $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $pkgs >/dev/null || die "安裝系統套件失敗。" "請看上面的錯誤,或手動執行:sudo apt-get install $pkgs"
;; ;;
dnf) dnf)
@ -263,9 +273,11 @@ case $MODE in
find_source find_source
step "設定和啟動" step "設定和啟動"
info "接下來會問網域、帳號、密碼。" info "接下來會問網域、帳號、密碼。"
[ -n "$TTY" ] || die "需要一個終端機回答問題。" "在伺服器上直接執行:cd $SRC && ./install.sh server"
cd "$SRC/deploy/server" cd "$SRC/deploy/server"
if [ "${DOCKER_SUDO:-0}" = 1 ]; then exec $SUDO -E ./setup.sh "$@" <"$TTY"; else exec ./setup.sh "$@" <"$TTY"; fi IN=$TTY
if [ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ]; then IN=/dev/null # 免問模式
else [ -n "$TTY" ] || die "需要一個終端機回答問題。" "在伺服器上直接執行:cd $SRC && ./install.sh server(或用 ANYBOT_SETUP_USER、ANYBOT_SETUP_PASSWORD 免問)"; fi
if [ "${DOCKER_SUDO:-0}" = 1 ]; then exec $SUDO -E ./setup.sh "$@" <"$IN"; else exec ./setup.sh "$@" <"$IN"; fi
;; ;;
app|hands) app|hands)
@ -290,6 +302,10 @@ case $MODE in
sh "$SRC/deploy/linux/install.sh" >/dev/null || die "安裝失敗。" "執行 sh $SRC/deploy/linux/install.sh 看錯誤。" sh "$SRC/deploy/linux/install.sh" >/dev/null || die "安裝失敗。" "執行 sh $SRC/deploy/linux/install.sh 看錯誤。"
ok "程式放在 ~/.local/bin/anybot" ok "程式放在 ~/.local/bin/anybot"
[ "$MODE" = app ] && ok "應用程式選單裡有 AnyBot" [ "$MODE" = app ] && ok "應用程式選單裡有 AnyBot"
# 更新: the one running in the background (anybot setup's service) switches to the new one.
if [ -f "$HOME/.local/bin/anybot-run" ] && systemctl --user is-enabled --quiet anybot 2>/dev/null; then
"$HOME/.local/bin/anybot" setup --refresh-service >/dev/null 2>&1 && ok "重新啟動背景執行的 AnyBot(換成新版)"
fi
fi fi
fi fi
PATH="$HOME/.local/bin:$PATH" PATH="$HOME/.local/bin:$PATH"

View File

@ -90,6 +90,64 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
.card .t{font-weight:600}.card .s{color:var(--text3);font-size:13px;margin-bottom:6px} .card .t{font-weight:600}.card .s{color:var(--text3);font-size:13px;margin-bottom:6px}
.card .r{display:flex;justify-content:space-between;gap:10px;font-size:13.5px;padding:3px 0;border-top:1px solid var(--line)} .card .r{display:flex;justify-content:space-between;gap:10px;font-size:13.5px;padding:3px 0;border-top:1px solid var(--line)}
.card .r span:first-child{color:var(--text2)} .card .r span:first-child{color:var(--text2)}
/* 頁面: the card in a chat, and the page */
.pcard{border:1px solid var(--line);border-radius:16px;margin:8px 0;background:var(--surface);max-width:520px;overflow:hidden;cursor:pointer;-webkit-tap-highlight-color:transparent}
.pcard:active{background:var(--raise)}
.pcard .top{padding:14px 16px 10px}
.pcard .t{font-weight:700;font-size:17px;margin-bottom:6px}
.pcard .l{font-size:13.5px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.55}
.pcard .foot{display:flex;gap:12px;align-items:center;padding:10px 16px;border-top:1px solid var(--line)}
.pcard .n{font-size:14px}.pcard .m{font-size:12px;color:var(--text3);padding:0;display:block}
.pcard .note{font-size:12px;color:var(--text2);padding:0 16px 10px}
.pglyph{flex:none}
a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;cursor:pointer}
.page{padding:18px 18px calc(env(safe-area-inset-bottom) + 40px);max-width:760px;margin:0 auto;line-height:1.65;font-size:15.5px;box-sizing:border-box;width:100%;min-width:0;overflow-wrap:anywhere}
#pscroll{overflow-x:hidden}
#v-page header{min-width:0}
.page h1.pt{font-size:25px;line-height:1.3;margin:0 0 6px}
.page .meta{color:var(--text3);font-size:12.5px;margin-bottom:12px}
.page .sum{font-size:16px;margin:0 0 6px}
.page .chg{font-size:12.5px;color:var(--text2);margin-bottom:8px}
.page .old{background:var(--raise);border-radius:8px;padding:8px 10px;font-size:13px;color:var(--text2);margin-bottom:10px}
.page h2{font-size:19px;margin:1.3em 0 .4em}.page h3{font-size:16.5px;margin:1.1em 0 .3em}.page h4{font-size:15px;margin:1em 0 .3em}
.page p{margin:0 0 .75em}
.page ul,.page ol{margin:.2em 0 .8em;padding-left:1.3em}.page li{margin:.15em 0}
.page li.task{list-style:none;margin-left:-1.2em}
.page a{color:var(--accent)}
.page code{font:13px var(--mono);background:var(--raise);padding:1px 5px;border-radius:5px}
.page pre{background:var(--raise);padding:10px 12px;border-radius:10px;overflow-x:auto;font:12.5px/1.45 var(--mono)}
.page pre code{background:none;padding:0}
.page hr{border:0;border-top:1px solid var(--line);margin:1.2em 0}
.page blockquote{margin:0 0 .8em;padding-left:12px;border-left:3px solid var(--line);color:var(--text2)}
.tw{overflow-x:auto;margin:0 0 1em;border:1px solid var(--line);border-radius:10px;max-width:100%}
.page td{min-width:5em;overflow-wrap:normal;word-break:normal}
.page table{border-collapse:collapse;width:100%;font-size:14px}
.page th,.page td{padding:7px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
.page th{background:var(--raise);font-weight:600;white-space:nowrap}
.page tr:last-child td{border-bottom:0}
.stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin:0 0 1em}
.stat{background:var(--raise);border-radius:12px;padding:10px 12px}
.stat .k{font-size:12px;color:var(--text2)}.stat .v{font-size:21px;font-weight:700;line-height:1.3}.stat .v.long{font-size:15.5px}.stat .s{font-size:11.5px;color:var(--text3)}
.steps{margin:0 0 1em;padding:0;list-style:none}
.steps li{position:relative;padding:0 0 14px 30px}
.steps li:before{content:"";position:absolute;left:8px;top:20px;bottom:-2px;width:2px;background:var(--line)}
.steps li:last-child:before{display:none}
.steps li.done:before{background:var(--ok)}
.steps .dot{position:absolute;left:0;top:3px;width:18px;height:18px;border-radius:50%;box-sizing:border-box;border:1.5px solid var(--text3);background:var(--bg);display:grid;place-items:center;color:#fff;font-size:11px;animation:none;margin:0}
.steps .done .dot{background:var(--ok);border-color:var(--ok)}
.steps .now .dot{border:2.5px solid var(--accent)}
.steps .now .dot:after{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent)}
.steps .todo .nm{color:var(--text3)}.steps .now .nm{font-weight:700}
.steps .nt{font-size:13px;color:var(--text2)}
.call{border-radius:10px;padding:10px 12px 10px 14px;margin:0 0 1em;border-left:3px solid var(--accent);background:rgba(22,128,243,.08)}
.call .ct{font-size:12.5px;font-weight:700;color:var(--accent);margin-bottom:2px}
.call.warning{border-color:#e08a00;background:rgba(224,138,0,.10)}.call.warning .ct{color:#e08a00}
.call.important{border-color:#d03b3b;background:rgba(208,59,59,.09)}.call.important .ct{color:#d03b3b}
.call.tip{border-color:var(--ok);background:rgba(46,160,67,.09)}.call.tip .ct{color:var(--ok)}
.call p:last-child{margin:0}
.chart{margin:0 0 1em;text-align:center}.chart img{max-width:100%;height:auto}
.chart .wait{color:var(--text3);font-size:13px;padding:20px}
#pver{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:4px 6px;font-size:13px;color:var(--text);max-width:42%;flex:none}
.typing{display:inline-flex;gap:4px;padding:8px 4px} .typing{display:inline-flex;gap:4px;padding:8px 4px}
.typing i{width:6px;height:6px;border-radius:50%;background:var(--text3);animation:b 1.2s infinite} .typing i{width:6px;height:6px;border-radius:50%;background:var(--text3);animation:b 1.2s infinite}
.typing i:nth-child(2){animation-delay:.15s}.typing i:nth-child(3){animation-delay:.3s} .typing i:nth-child(2){animation-delay:.15s}.typing i:nth-child(3){animation-delay:.3s}
@ -195,6 +253,15 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
</div> </div>
</section> </section>
<section class="view" id="v-page">
<header>
<button class="icon-btn" id="b-back3"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
<h1 id="page-name" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">頁面</h1>
<select id="pver" style="display:none"></select>
</header>
<div class="scroll" id="pscroll"><div class="page" id="page"></div></div>
</section>
<!-- screens --> <!-- screens -->
<section class="view" id="v-screen"> <section class="view" id="v-screen">
<header> <header>
@ -405,6 +472,7 @@ function md(text) {
let h = esc(part) let h = esc(part)
.replace(/`([^`\n]+)`/g, "<code>$1</code>") .replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>") .replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `${GLYPH(14)}<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>') .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>'); .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
const out = []; let list = null; const out = []; let list = null;
@ -431,7 +499,7 @@ function renderMsgs() {
return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`; return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`;
} }
if (m.summary) return `<div class="m bot"><div class="via">(較早的對話已整理成摘要)</div></div>`; if (m.summary) return `<div class="m bot"><div class="via">(較早的對話已整理成摘要)</div></div>`;
const tools = (m.tools || []).map((t) => t.card ? card(t.card) : `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`).join(""); const tools = (m.tools || []).map((t) => t.page ? pageCard(t.page) : t.card ? card(t.card) : `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`).join("");
const last = i === S.msgs.length - 1; const last = i === S.msgs.length - 1;
const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : ""); const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : "");
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : ""; const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
@ -448,6 +516,128 @@ function card(c) {
const rows = (c.rows || []).map((r) => `<div class="r"><span>${esc(r.label)}</span><span>${esc(r.value)}</span></div>`).join(""); const rows = (c.rows || []).map((r) => `<div class="r"><span>${esc(r.label)}</span><span>${esc(r.value)}</span></div>`).join("");
return `<div class="card"><div class="t">${esc(c.title)}</div>${c.subtitle ? `<div class="s">${esc(c.subtitle)}</div>` : ""}${rows}${c.link ? `<div class="r"><a href="${esc(c.link)}" target="_blank" rel="noopener">來源</a><span></span></div>` : ""}</div>`; return `<div class="card"><div class="t">${esc(c.title)}</div>${c.subtitle ? `<div class="s">${esc(c.subtitle)}</div>` : ""}${rows}${c.link ? `<div class="r"><a href="${esc(c.link)}" target="_blank" rel="noopener">來源</a><span></span></div>` : ""}</div>`;
} }
// ---------- 頁面 ----------
const GLYPH = (h = 34) => `<svg class="pglyph" width="${Math.round(h * .8)}" height="${h}" viewBox="0 0 24 30" style="vertical-align:-3px"><rect x=".5" y=".5" width="23" height="29" rx="4" fill="var(--raise)" stroke="var(--line)"/><path d="M5 7h9M5 11h6" stroke="var(--text3)" stroke-width="2" stroke-linecap="round"/><rect x="5" y="20" width="2.6" height="5" rx=".6" fill="#34a853"/><rect x="9" y="18" width="2.6" height="7" rx=".6" fill="#fbbc05"/><rect x="13" y="16" width="2.6" height="9" rx=".6" fill="#ea4335"/><rect x="17" y="14" width="2.6" height="11" rx=".6" fill="#4285f4"/></svg>`;
function pageCard(p) {
const lines = (p.preview || []).map((l, i, a) => `<div class="l" style="opacity:${1 - (i / Math.max(a.length, 1)) * .65}">${esc(l)}</div>`).join("");
const meta = `頁面 · ${esc(p.updated)}${p.revision > 1 ? " · 已更新" : ""}`;
return `<div class="pcard" data-page="${p.id}"><div class="top"><div class="t">${esc(p.title)}</div>${lines}</div>${p.revision > 1 && p.note ? `<div class="note">最近更新:${esc(p.note)}</div>` : ""}<div class="foot">${GLYPH()}<div style="min-width:0"><div class="n">${esc(p.title)}</div><span class="m">${meta}</span></div></div></div>`;
}
document.addEventListener("click", (e) => {
const el = e.target.closest && e.target.closest("[data-page]");
if (el) { e.preventDefault(); openPage(+el.dataset.page); }
});
async function openPage(id, rev) {
if (S.view !== "page") S.pageBack = S.view;
S.page = id; show("page"); $("pscroll").scrollTop = 0;
if (!rev) $("page").innerHTML = `<div class="empty">載入中…</div>`;
try {
const v = await api(base() + "api/pages/" + id + (rev ? "?rev=" + rev : ""));
$("page-name").textContent = v.title;
const old = v.revision !== v.latest;
const sel = $("pver");
if ((v.versions || []).length) {
sel.innerHTML = `<option value="">第 ${v.latest} 版(最新)</option>` + v.versions.map((x) => `<option value="${x.revision}">第 ${x.revision} 版 · ${esc(x.at)}</option>`).join("");
sel.value = old ? String(v.revision) : ""; sel.style.display = "";
} else sel.style.display = "none";
let head = `<h1 class="pt">${esc(v.title)}</h1><div class="meta">${esc(v.bot)} · 更新於 ${esc(v.updated)}${v.latest > 1 ? ` · 第 ${v.revision} 版` : ""}</div>`;
if (old) head += `<div class="old">你在看舊的版本。<a class="plink" data-page="${id}">回到最新版</a></div>`;
else if (v.summary) head += `<p class="sum">${esc(v.summary)}</p>`;
if (v.note && v.revision > 1) head += `<div class="chg">這一版更新了:${esc(v.note)}</div>`;
$("page").innerHTML = head + pageMd(v.body, id, old ? v.revision : 0);
loadCharts();
} catch (e) { $("page").innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
}
$("pver").onchange = () => openPage(S.page, +$("pver").value || 0);
$("b-back3").onclick = () => { show(S.pageBack && S.pageBack !== "page" ? S.pageBack : "list"); if (S.view === "chat") loadChat(); };
const dark = () => matchMedia("(prefers-color-scheme: dark)").matches;
async function loadCharts() {
for (const el of document.querySelectorAll(".chart[data-src]")) {
const src = el.dataset.src; el.removeAttribute("data-src");
try {
const r = await api(base() + src + (src.includes("?") ? "&" : "?") + "dark=" + (dark() ? 1 : 0), {raw: true});
if (!r.ok) throw new Error();
el.innerHTML = `<img alt="圖表" src="${URL.createObjectURL(await r.blob())}">`;
} catch (_) { el.innerHTML = `<div class="wait">這張圖畫不出來</div>`; }
}
}
// Inline Markdown: code, bold, italics, strike, links (page:N opens the page).
function inl(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>")
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s((])(https?:\/\/[^\s<))]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
}
const CALL = {note: "說明", tip: "提示", warning: "注意", important: "重要"};
// A page's Markdown: headings, paragraphs, lists and tasks, tables, quotes and call-outs, code,
// Mermaid charts (drawn by the AnyBot as SVG), ```stats tiles and ```steps progress.
function pageMd(text, id, rev) {
const L = String(text || "").replace(/\r/g, "").split("\n");
const out = []; let i = 0, chart = 0;
const cells = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
while (i < L.length) {
const l = L[i], t = l.trim();
if (!t) { i++; continue; }
const fence = t.match(/^```\s*([\w-]*)/);
if (fence) {
const lang = fence[1].toLowerCase(); const body = []; i++;
while (i < L.length && !L[i].trim().startsWith("```")) body.push(L[i++]);
i++;
if (lang === "mermaid") { out.push(`<div class="chart" data-src="api/pages/${id}/chart/${chart++}${rev ? "?rev=" + rev : ""}"><div class="wait">圖表載入中…</div></div>`); continue; }
if (lang === "stats") {
const tiles = body.map(cells).filter((c) => c.length >= 2 && c[0]).map((c) => `<div class="stat"><div class="k">${inl(c[0])}</div><div class="v${c[1].length > 10 ? " long" : ""}">${inl(c[1])}</div>${c[2] ? `<div class="s">${inl(c[2])}</div>` : ""}</div>`);
if (tiles.length) { out.push(`<div class="stats">${tiles.join("")}</div>`); continue; }
}
if (lang === "steps") {
const items = body.map((b) => b.trim().replace(/^[-*]\s*/, "").match(/^\[(x|X|✓|>|~|-| )\]\s*(.*)$/)).filter(Boolean).map((m) => {
const st = /[xX✓]/.test(m[1]) ? "done" : /[>~-]/.test(m[1]) ? "now" : "todo";
const [nm, nt] = m[2].split(/\s*\|\s*/, 2);
return `<li class="${st}"><span class="dot">${st === "done" ? "✓" : ""}</span><div class="nm">${inl(nm)}</div>${nt ? `<div class="nt">${inl(nt)}</div>` : ""}</li>`;
});
if (items.length) { out.push(`<ul class="steps">${items.join("")}</ul>`); continue; }
}
out.push(`<pre><code>${esc(body.join("\n"))}</code></pre>`); continue;
}
const h = t.match(/^(#{1,6})\s+(.*)$/);
if (h) { const n = Math.min(h[1].length + 1, 4); out.push(`<h${n}>${inl(h[2])}</h${n}>`); i++; continue; }
if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) { out.push("<hr>"); i++; continue; }
if (t.startsWith("|") && i + 1 < L.length && /^\|?\s*:?-{2,}/.test(L[i + 1].trim())) {
const head = cells(t); i += 2; const rows = [];
while (i < L.length && L[i].trim().startsWith("|")) rows.push(cells(L[i++]));
out.push(`<div class="tw"><table><thead><tr>${head.map((c) => `<th>${inl(c)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inl(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
continue;
}
if (t.startsWith(">")) {
const q = []; while (i < L.length && L[i].trim().startsWith(">")) q.push(L[i++].trim().replace(/^>\s?/, ""));
const c = (q[0] || "").match(/^\[!(NOTE|INFO|TIP|WARNING|CAUTION|IMPORTANT)\]\s*(.*)$/i);
if (c) {
const k = {info: "note", caution: "warning"}[c[1].toLowerCase()] || c[1].toLowerCase();
q[0] = c[2];
out.push(`<div class="call ${k}"><div class="ct">${CALL[k]}</div>${pageMd(q.join("\n"), id, rev)}</div>`);
} else out.push(`<blockquote>${pageMd(q.join("\n"), id, rev)}</blockquote>`);
continue;
}
if (/^([-*+•]|\d+[.)])\s+/.test(t)) {
const ordered = /^\d/.test(t); const items = [];
while (i < L.length && /^\s*([-*+•]|\d+[.)])\s+/.test(L[i])) {
let it = L[i++].trim().replace(/^([-*+•]|\d+[.)])\s+/, "");
const task = it.match(/^\[( |x|X)\]\s+(.*)$/);
items.push(task ? `<li class="task">${task[1] === " " ? "☐" : "☑"} ${inl(task[2])}</li>` : `<li>${inl(it)}</li>`);
}
out.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`); continue;
}
const para = [];
while (i < L.length && L[i].trim() && !/^(#{1,6}\s|```|>|\||([-*+•]|\d+[.)])\s)/.test(L[i].trim())) para.push(L[i++].trim());
if (!para.length) { para.push(t); i++; }
out.push(`<p>${para.map(inl).join("<br>")}</p>`);
}
return out.join("");
}
function renderAsk() { function renderAsk() {
const a = S.approvals.find((x) => x.bot === S.open); const a = S.approvals.find((x) => x.bot === S.open);
if (!a) { $("ask").innerHTML = ""; return; } if (!a) { $("ask").innerHTML = ""; return; }

View File

@ -145,9 +145,19 @@ fn screen_target(job: &Job, args: &str) -> Result<tools::Target, String> {
if let Some(t) = job.computers.iter().find(|t| t.name == job.default_computer && has_screen(t)) { if let Some(t) = job.computers.iter().find(|t| t.name == job.default_computer && has_screen(t)) {
return Ok(t.clone()); return Ok(t.clone());
} }
// Not named: a screen that is really there — the bot's desktop, this computer's, then one of
// the account's other computers (a brain on a server has no screen of its own).
let real = |t: &&tools::Target| match t.place {
tools::Place::Box { .. } => crate::cloudbox::available(),
tools::Place::Local { .. } => computer::screen_supported(),
_ => false,
};
job.computers job.computers
.iter() .iter()
.find(|t| matches!(t.place, tools::Place::Box { .. })) .find(|t| matches!(t.place, tools::Place::Box { .. }) && real(t))
.or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. }) && real(t)))
.or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })))
.or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Box { .. })))
.or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. }))) .or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })))
.cloned() .cloned()
.ok_or_else(|| "找不到可以操作螢幕的電腦".to_string()) .ok_or_else(|| "找不到可以操作螢幕的電腦".to_string())
@ -617,6 +627,12 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// …and so does screen control: it's the person's own screen. // …and so does screen control: it's the person's own screen.
let target = if is_screen { let target = if is_screen {
screen_target(&job, &call.args) screen_target(&job, &call.args)
} else if is_web && device_named(&job, &call.args).is_some() {
// The browser on another computer of the account, as named.
target_for(&job, &call.args)
} else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) {
// No browser here (a brain on a server): the 手腳 computer's.
job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else if is_mac || web::uses_this_computer(&call.name, &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()) job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else { } else {
@ -630,6 +646,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// Permission to use computers at all, and this Mac in particular, is asked for // Permission to use computers at all, and this Mac in particular, is asked for
// when it's needed instead of the step failing quietly. // when it's needed instead of the step failing quietly.
let mut gate: Result<(), String> = Ok(()); let mut gate: Result<(), String> = Ok(());
// The person just said yes to this very step in a switch's pop-up (it shows the
// step): no second pop-up for it.
let mut said_yes = false;
// 直接操作: like Grok Bot after its one-time setup ("讓 Grok Bot 在你的電腦上開啟檔案和 // 直接操作: like Grok Bot after its one-time setup ("讓 Grok Bot 在你的電腦上開啟檔案和
// 執行任務。自動審核仍會先檢查所有操作", ENHi8c), nothing is asked except what the // 執行任務。自動審核仍會先檢查所有操作", ENHi8c), nothing is asked except what the
// safety checks and the person's "一律詢問" rules stop. // safety checks and the person's "一律詢問" rules stop.
@ -642,8 +661,11 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if !tools_on { if !tools_on {
let reason = format!("讓 {} 使用電腦工具:{}\n(在 {} 上)", job.via_name, what, t.name); let reason = format!("讓 {} 使用電腦工具:{}\n(在 {} 上)", job.via_name, what, t.name);
match ask_person(ApprovalKind::EnableTools, reason, "tools:on".into()) { match ask_person(ApprovalKind::EnableTools, reason, "tools:on".into()) {
ApprovalAnswer::Once => {} ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => tools_on = true, ApprovalAnswer::Always(_) => {
tools_on = true;
said_yes = true;
}
ApprovalAnswer::Reject => gate = Err("使用者沒有允許使用電腦工具。請改用文字回答,或問使用者想怎麼做。".into()), ApprovalAnswer::Reject => gate = Err("使用者沒有允許使用電腦工具。請改用文字回答,或問使用者想怎麼做。".into()),
} }
} }
@ -654,8 +676,11 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on { 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); 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()) { match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) {
ApprovalAnswer::Once => {} ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => local_on = true, ApprovalAnswer::Always(_) => {
local_on = true;
said_yes = true;
}
ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)), ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)),
} }
} }
@ -764,6 +789,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
let approved = match decision { let approved = match decision {
_ if gate.is_err() => Err((gate.clone().unwrap_err(), ToolStatus::Denied)), _ if gate.is_err() => Err((gate.clone().unwrap_err(), ToolStatus::Denied)),
Decision::Allow => Ok(()), Decision::Allow => Ok(()),
// Sending (Messages, mail…) still asks every time, even right after a switch.
Decision::Ask { .. } if said_yes && !(is_mac && crate::macapps::is_send(&call.name)) => Ok(()),
Decision::Deny(r) => Err((format!("被安全規則擋下:{r}"), ToolStatus::Denied)), Decision::Deny(r) => Err((format!("被安全規則擋下:{r}"), ToolStatus::Denied)),
Decision::Ask { reason, grant } => { Decision::Ask { reason, grant } => {
let machine = target.as_ref().map(|t| t.name.clone()).unwrap_or_default(); let machine = target.as_ref().map(|t| t.name.clone()).unwrap_or_default();
@ -790,7 +817,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() }; let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
let (out, ok) = if is_mac { let (out, ok) = if is_mac {
crate::macapps::run(&call.name, &call.args) crate::macapps::run(&call.name, &call.args)
} else if let (true, Some(host)) = (is_screen, on_device.as_ref()) { } else if let (true, Some(host)) = (is_screen || is_web, on_device.as_ref()) {
match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) { match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) {
Ok(v) => { Ok(v) => {
image = v["image"].as_str().map(String::from); image = v["image"].as_str().map(String::from);

View File

@ -34,6 +34,7 @@ mod menu_ui;
mod vault_ui; mod vault_ui;
mod markdown_ui; mod markdown_ui;
mod mirror_ui; mod mirror_ui;
mod pages_ui;
// Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025), // Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025),
// clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523): // clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523):
@ -159,6 +160,8 @@ pub struct App {
cu: computers_ui::ComputerUi, cu: computers_ui::ComputerUi,
vault: vault_ui::VaultUi, vault: vault_ui::VaultUi,
md: markdown_ui::MdState, md: markdown_ui::MdState,
/// The page open large (頁面): its id, and an older revision when looking at one.
page_view: Option<(u64, Option<u32>)>,
/// Files and pictures waiting to go with the next message. /// Files and pictures waiting to go with the next message.
att: attach_ui::AttachUi, att: attach_ui::AttachUi,
/// 訊息管道 (Slack / Discord / Telegram). /// 訊息管道 (Slack / Discord / Telegram).
@ -263,6 +266,7 @@ impl App {
cu: Default::default(), cu: Default::default(),
vault: Default::default(), vault: Default::default(),
md: Default::default(), md: Default::default(),
page_view: None,
att: Default::default(), att: Default::default(),
ch: Default::default(), ch: Default::default(),
share_rx: None, share_rx: None,
@ -318,6 +322,24 @@ impl App {
// Optional view for checking the dialogs in a picture. // Optional view for checking the dialogs in a picture.
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
Ok("chat") => app.state.selected = 1, Ok("chat") => app.state.selected = 1,
// 頁面: a tracking page in the chat (pagecard), and opened (page).
Ok(v @ ("pagecard" | "page")) => {
app.state.selected = 1;
app.approvals.clear();
app.streams.remove(&5);
let (pages, next, id) = crate::pages::demo(crate::model::now_ms());
app.state.pages = pages;
app.state.next_page_id = next;
if let Some(b) = app.state.bot_mut(1) {
b.messages.push(Message::user("幫我追蹤一下台灣 FSD 狀況"));
let mut m = Message::assistant("交通部今天對台灣 FSD 有新的官方說法,但你 12 月交車時多半還是不能用,現在先別花錢。\n\nNOWnews 今天專訪交通部監理司專門委員。他說 7 月和 9 月已經開過兩次專家審查會議,交通部希望年底前完成相關審查,但不保證年底開放,而且路測計畫還沒核定。來源和細節都更新在[台灣 Tesla FSD 追蹤](page:1)。", "Master · Custom · qwen3.8-flash-next");
m.tool_calls.push(crate::model::ToolCall { id: "pg1".into(), name: "write_page".into(), args: serde_json::json!({"id": id, "title": "台灣 Tesla FSD 追蹤"}).to_string(), status: crate::model::ToolStatus::Done });
b.messages.push(m);
}
if v == "page" {
app.page_view = Some((id, None));
}
}
Ok("settings") => { Ok("settings") => {
app.show_settings = true; app.show_settings = true;
app.settings_tab = SettingsTab::Providers; app.settings_tab = SettingsTab::Providers;
@ -676,6 +698,8 @@ impl App {
if crate::computer::screen_supported() { if crate::computer::screen_supported() {
screens.push(crate::model::THIS_COMPUTER_NAME.to_string()); screens.push(crate::model::THIS_COMPUTER_NAME.to_string());
} }
// …and the account's other computers (手腳): their own AnyBot looks and clicks there.
screens.extend(devices.iter().cloned());
if cfg!(target_os = "macos") { 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::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE));
} }
@ -889,6 +913,18 @@ impl App {
fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) { 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); let out = appctl::apply(&mut self.state, bot_id, &call.name, &call.args);
// write_page: remember which page the call made, so its card shows under the reply.
if let Some(pid) = out.page {
if let Some(c) = self.state.bot_mut(bot_id).and_then(|b| b.messages.iter_mut().rev().take(12).flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call.call_id)) {
let mut a: serde_json::Value = serde_json::from_str(&c.args).unwrap_or(serde_json::json!({}));
a["id"] = serde_json::json!(pid);
// The card shows the page itself; the long content needn't stay twice in the chat.
if let Some(o) = a.as_object_mut() {
o.remove("content");
}
c.args = a.to_string();
}
}
if out.theme_changed { if out.theme_changed {
self.apply_theme(ctx); self.apply_theme(ctx);
} }
@ -3018,6 +3054,12 @@ impl App {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h)));
} }
} }
// ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first).
if self.frames == need * 3 / 4 {
if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) {
self.page_view = Some((id, None));
}
}
if self.frames >= need && !self.shot_requested { if self.frames >= need && !self.shot_requested {
self.shot_requested = true; self.shot_requested = true;
let slot = self.shot.clone(); let slot = self.shot.clone();
@ -3306,6 +3348,7 @@ impl eframe::App for App {
let ctx = &ui.ctx().clone(); let ctx = &ui.ctx().clone();
self.handle_screenshot(ctx); self.handle_screenshot(ctx);
self.md_viewer(ctx); self.md_viewer(ctx);
self.page_viewer(ctx);
self.refresh_presence(ctx); self.refresh_presence(ctx);
self.check_for_newer_build(); self.check_for_newer_build();
if std::mem::take(&mut self.relaunch_requested) { if std::mem::take(&mut self.relaunch_requested) {
@ -3605,6 +3648,12 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str("\n\n你目前的例行任務:\n"); s.push_str("\n\n你目前的例行任務:\n");
s.push_str(&b.routines.iter().map(appctl::routine_line).collect::<Vec<_>>().join("\n")); s.push_str(&b.routines.iter().map(appctl::routine_line).collect::<Vec<_>>().join("\n"));
} }
// 頁面: what this bot already keeps, so a routine or a follow-up updates the same page.
let mine: Vec<String> = st.pages.iter().filter(|p| p.bot == b.id).map(|p| format!("#{} 「{}」(第 {} 版,更新於 {})", p.id, p.title, p.revision, crate::pages::when(p.updated))).collect();
if !mine.is_empty() {
s.push_str("\n\n你做過的頁面(更新時用 write_page 帶 id,先 read_page 看現在的內容):\n");
s.push_str(&mine.join("\n"));
}
if !computers.is_empty() { if !computers.is_empty() {
let list: Vec<String> = computers let list: Vec<String> = computers
.iter() .iter()
@ -3656,6 +3705,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str("\n\n"); s.push_str("\n\n");
s.push_str(b.instructions.trim()); s.push_str(b.instructions.trim());
} }
s.push_str("\n\n使用者要你追蹤、整理、比較、研究一件之後還會回來看的事(例如「幫我追蹤台灣 FSD 狀況」),就用 write_page 做成一頁:先寫結論和現況,再寫細節和來源(附日期),適合時用表格、```stats、```steps、圖表。回答時簡短說重點,並用 [頁面標題](page:ID) 連到它。要定期追蹤時,再用 create_routine 設例行任務,每次執行:read_page、查新消息,有實質進展才用 write_page 更新(note 寫改了什麼)並在聊天裡說;沒有新消息就簡短回答「沒有新消息」,不要更新頁面。");
s.push_str("\n\n做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。"); s.push_str("\n\n做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。");
s.push_str("\n\n"); s.push_str("\n\n");
s.push_str(&crate::i18n::reply_instruction(&st.language)); s.push_str(&crate::i18n::reply_instruction(&st.language));

View File

@ -126,7 +126,7 @@ impl App {
if m.role == Role::Tool { if m.role == Role::Tool {
return; return;
} }
let has_body = !m.text.trim().is_empty() || !m.attachments.is_empty() || m.tool_calls.iter().any(|c| c.name == "show_card"); let has_body = !m.text.trim().is_empty() || !m.attachments.is_empty() || m.tool_calls.iter().any(|c| c.name == "show_card") || !crate::pages::ids_in(&m.tool_calls).is_empty();
let steps_open = self.steps_open.contains(&(bot.id, idx)); let steps_open = self.steps_open.contains(&(bot.id, idx));
if m.role == Role::Assistant && !has_body && !m.summary && !steps_open { if m.role == Role::Assistant && !has_body && !m.summary && !steps_open {
return; return;
@ -380,6 +380,11 @@ impl App {
ui.add_space(6.0); ui.add_space(6.0);
card(ui, &c.args, p); card(ui, &c.args, p);
} }
// 頁面 written in this reply: the card (the page as it is now).
for id in crate::pages::ids_in(&m.tool_calls) {
ui.add_space(8.0);
self.page_card(ui, id);
}
for a in m.attachments.clone() { for a in m.attachments.clone() {
ui.add_space(6.0); ui.add_space(6.0);
self.file_card(ui, &a); self.file_card(ui, &a);

View File

@ -372,6 +372,28 @@ fn key_of(s: &str, dark: bool) -> u64 {
h.finish() h.finish()
} }
/// `> [!NOTE] …` quotes: the kind, and the quote without the marker.
fn callout_of(inner: &[Block]) -> Option<(&'static str, Vec<Block>)> {
let Some(Block::Para(spans)) = inner.first() else { return None };
let first = spans.first()?;
// "[!NOTE]" may be parsed as text, or with the rest of the line after it.
let (kind, rest) = crate::pages::callout(&first.text)?;
let mut spans = spans.clone();
spans[0].text = rest.trim_start().to_string();
if spans[0].text.is_empty() {
spans.remove(0);
if let Some(s) = spans.first_mut() {
s.text = s.text.trim_start().to_string();
}
}
let mut out = vec![];
if !spans.is_empty() {
out.push(Block::Para(spans));
}
out.extend(inner[1..].iter().cloned());
Some((kind, out))
}
impl App { impl App {
/// A message's Markdown. `streaming`: the reply is still coming (diagrams wait for the end). /// 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) { pub(super) fn markdown(&mut self, ui: &mut egui::Ui, text: &str, col: Color32, streaming: bool) {
@ -404,6 +426,10 @@ impl App {
ui.painter().hline(r.x_range(), r.top(), Stroke::new(1.0, p.border)); ui.painter().hline(r.x_range(), r.top(), Stroke::new(1.0, p.border));
ui.add_space(4.0); ui.add_space(4.0);
} }
Block::Quote(inner) if callout_of(inner).is_some() => {
let (kind, inner) = callout_of(inner).unwrap();
self.md_callout(ui, kind, &inner, streaming, depth);
}
Block::Quote(inner) => { Block::Quote(inner) => {
let top = ui.cursor().top(); let top = ui.cursor().top();
Frame::new().inner_margin(Margin { left: 14, right: 0, top: 0, bottom: 0 }).show(ui, |ui| { Frame::new().inner_margin(Margin { left: 14, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
@ -463,6 +489,8 @@ impl App {
Block::Table { aligns, head, rows } => self.md_table(ui, aligns, head, rows, col), Block::Table { aligns, head, rows } => self.md_table(ui, aligns, head, rows, col),
Block::Math(t) => self.md_code(ui, "math", t, 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 } if lang == "mermaid" => self.md_mermaid(ui, text, col, streaming),
Block::Code { lang, text } if lang == "stats" && !crate::pages::stats(text).is_empty() => self.md_stats(ui, text),
Block::Code { lang, text } if lang == "steps" && !crate::pages::steps(text).is_empty() => self.md_steps(ui, text),
Block::Code { lang, text } => self.md_code(ui, lang, text, col), Block::Code { lang, text } => self.md_code(ui, lang, text, col),
} }
} }
@ -549,6 +577,12 @@ impl App {
if let Some((_, url)) = ranges.iter().find(|(r, _)| r.contains(&idx)) { if let Some((_, url)) = ranges.iter().find(|(r, _)| r.contains(&idx)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
let url = url.clone(); let url = url.clone();
if let Some(pid) = crate::pages::link_id(&url) {
let resp = resp.on_hover_text(t("打開頁面"));
if resp.clicked() {
self.page_view = Some((pid, None));
}
} else {
let resp = resp.on_hover_text(&url); let resp = resp.on_hover_text(&url);
if resp.clicked() { if resp.clicked() {
let _ = crate::preview::system_open(&url).spawn(); let _ = crate::preview::system_open(&url).spawn();
@ -556,6 +590,115 @@ impl App {
} }
} }
} }
}
/// ```stats: number tiles in rows (label above, the value large, a note below).
fn md_stats(&mut self, ui: &mut egui::Ui, src: &str) {
let p = self.pal;
let items = crate::pages::stats(src);
let gap = 8.0;
let w = ui.available_width();
let per_row = ((w + gap) / (150.0 + gap)).floor().clamp(1.0, 4.0) as usize;
let tile_w = (w - gap * (per_row as f32 - 1.0)) / per_row as f32;
for row in items.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (label, value, note) in row {
Frame::new().fill(p.hover).corner_radius(10).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| {
ui.set_width(tile_w - 24.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.add(egui::Label::new(RichText::new(label).size(12.0).color(p.text2)).wrap());
// A long value (a phrase, not a number) a size smaller, so it doesn't fill the tile.
let size = if value.chars().count() > 10 { 15.0 } else { 20.0 };
ui.add(egui::Label::new(RichText::new(value).size(size).strong().color(p.text)).wrap());
if !note.is_empty() {
ui.add(egui::Label::new(RichText::new(note).size(11.5).color(p.text3)).wrap());
}
});
});
}
});
ui.add_space(gap);
}
}
/// ```steps: a vertical progress line — done ✓, now (a ring in the accent colour), to do.
fn md_steps(&mut self, ui: &mut egui::Ui, src: &str) {
let p = self.pal;
let items = crate::pages::steps(src);
let n = items.len();
let green = Color32::from_rgb(0x2e, 0xa0, 0x43);
for (i, (st, name, note)) in items.iter().enumerate() {
let top = ui.cursor().top();
let x = ui.cursor().left() + 9.0;
Frame::new().inner_margin(Margin { left: 28, right: 0, top: 0, bottom: if i + 1 < n { 12 } else { 0 } }).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
let c = match st {
crate::pages::Step::Todo => p.text3,
_ => p.text,
};
let mut t = RichText::new(name).size(TRANSCRIPT_FONT).color(c);
if *st == crate::pages::Step::Now {
t = t.strong();
}
ui.add(egui::Label::new(t).wrap());
if !note.is_empty() {
ui.add(egui::Label::new(RichText::new(note).size(12.5).color(p.text2)).wrap());
}
});
let bottom = ui.cursor().top();
let cy = top + 10.0;
let painter = ui.painter();
if i + 1 < n {
let line = if *st == crate::pages::Step::Done { green } else { p.border };
painter.vline(x, (cy + 9.0)..=(bottom + 1.0), Stroke::new(2.0, line));
}
let c = egui::pos2(x, cy);
match st {
crate::pages::Step::Done => {
painter.circle_filled(c, 8.0, green);
painter.line_segment([c + vec2(-3.5, 0.0), c + vec2(-1.0, 2.8)], Stroke::new(1.8, Color32::WHITE));
painter.line_segment([c + vec2(-1.0, 2.8), c + vec2(3.8, -2.6)], Stroke::new(1.8, Color32::WHITE));
}
crate::pages::Step::Now => {
painter.circle_filled(c, 8.0, p.pane);
painter.circle_stroke(c, 7.0, Stroke::new(2.5, p.accent));
painter.circle_filled(c, 3.0, p.accent);
}
crate::pages::Step::Todo => {
painter.circle_filled(c, 8.0, p.pane);
painter.circle_stroke(c, 7.0, Stroke::new(1.5, p.text3));
}
}
}
}
/// `> [!WARNING] …`: a tinted box with a coloured edge and a small title.
fn md_callout(&mut self, ui: &mut egui::Ui, kind: &str, inner: &[Block], streaming: bool, depth: usize) {
let p = self.pal;
let (edge, title) = match kind {
"warning" => (Color32::from_rgb(0xe0, 0x8a, 0x00), "注意"),
"important" => (Color32::from_rgb(0xd0, 0x3b, 0x3b), "重要"),
"tip" => (Color32::from_rgb(0x2e, 0xa0, 0x43), "提示"),
_ => (p.accent, "說明"),
};
let top = ui.cursor().top();
Frame::new().fill(edge.gamma_multiply(0.10)).corner_radius(8).inner_margin(Margin { left: 16, right: 12, top: 10, bottom: 10 }).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
ui.label(RichText::new(title).size(12.5).strong().color(edge));
ui.add_space(4.0);
for (i, b) in inner.iter().enumerate() {
if i > 0 {
ui.add_space(6.0);
}
self.md_block(ui, b, p.text, streaming, depth);
}
});
let r = ui.min_rect();
ui.painter().vline(r.left() + 1.5, (top + 2.0)..=(ui.cursor().top() - 2.0), Stroke::new(3.0, edge));
}
fn md_code(&mut self, ui: &mut egui::Ui, lang: &str, text: &str, col: Color32) { fn md_code(&mut self, ui: &mut egui::Ui, lang: &str, text: &str, col: Color32) {
let p = self.pal; let p = self.pal;
@ -794,4 +937,17 @@ mod tests {
assert!(hl.iter().any(|(s, _)| s == "let") && hl.iter().any(|(s, _)| s.starts_with("// hi"))); assert!(hl.iter().any(|(s, _)| s == "let") && hl.iter().any(|(s, _)| s.starts_with("// hi")));
} }
#[test]
fn call_outs_and_page_blocks() {
for src in ["> [!WARNING] 現在先別花錢", "> [!WARNING]\n> 現在先別花錢"] {
let b = parse(src);
let Block::Quote(inner) = &b[0] else { panic!("{b:?}") };
let (kind, rest) = callout_of(inner).unwrap_or_else(|| panic!("{src}: {inner:?}"));
assert_eq!(kind, "warning");
let text: String = rest.iter().map(|b| match b { Block::Para(s) => s.iter().map(|x| x.text.clone()).collect::<String>(), _ => String::new() }).collect();
assert!(text.contains("現在先別花錢") && !text.contains("[!"), "{src}: {text:?}");
}
assert!(callout_of(match &parse("> 一般的引言")[0] { Block::Quote(i) => i, _ => panic!() }).is_none());
assert!(matches!(&parse("```stats\n狀態 | 審查中\n```")[0], Block::Code { lang, .. } if lang == "stats"));
}
} }

232
src/app/pages_ui.rs Normal file
View File

@ -0,0 +1,232 @@
// 頁面 in the window: the card under a reply (like the original's chat card — the title, a few
// lines of the page fading out, then a footer row with the page icon, its name and 「頁面」), the
// page itself in a large dialog (the whole page drawn with the chat's Markdown, the version picker
// and 存成檔案), and the list under 資料庫 in the right panel.
use super::*;
const CARD_W: f32 = 520.0;
impl App {
/// The card for page `id` under a reply.
pub(super) fn page_card(&mut self, ui: &mut egui::Ui, id: u64) {
let p = self.pal;
let Some(page) = self.state.pages.iter().find(|x| x.id == id).cloned() else {
ui.label(RichText::new(t("(這個頁面已刪除)")).size(12.5).color(p.text3));
return;
};
let now = crate::model::now_ms();
let w = ui.available_width().min(CARD_W);
let resp = Frame::new().fill(p.pane).stroke(Stroke::new(1.0, p.border)).corner_radius(16).show(ui, |ui| {
ui.set_width(w);
ui.spacing_mut().item_spacing.y = 0.0;
// The page's beginning: the title, then a few lines fading out.
Frame::new().inner_margin(Margin { left: 18, right: 18, top: 16, bottom: 12 }).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(&page.title).size(17.0).strong().color(p.text)).wrap());
ui.add_space(8.0);
let lines = crate::pages::preview(&page, 4);
let n = lines.len().max(1) as f32;
for (i, l) in lines.iter().enumerate() {
let fade = 1.0 - (i as f32 / n) * 0.65;
let c = if i == 0 && !page.summary.is_empty() { p.text } else { p.text2 };
ui.add(egui::Label::new(RichText::new(l).size(13.5).color(c.gamma_multiply(fade))).truncate());
ui.add_space(4.0);
}
});
let r = ui.min_rect();
ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, p.border));
// The footer row: page icon, name, 「頁面 · 第 N 版 · 更新」.
Frame::new().inner_margin(Margin::symmetric(18, 12)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(30.0, 36.0), egui::Sense::hover());
page_glyph(ui.painter(), ir, p);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.add(egui::Label::new(RichText::new(&page.title).size(14.0).color(p.text)).truncate());
let mut meta = format!("{} · {}", t("頁面"), crate::pages::ago(page.updated, now));
if page.revision > 1 {
meta = format!("{meta} · {}", t("已更新"));
}
ui.label(RichText::new(meta).size(12.0).color(p.text3));
});
});
if page.revision > 1 && !page.note.is_empty() {
ui.add_space(6.0);
ui.add(egui::Label::new(RichText::new(format!("{}:{}", t("最近更新"), page.note)).size(12.0).color(p.text2)).wrap());
}
});
});
let r = ui.interact(resp.response.rect, egui::Id::unique(("page-card", id, self.msg_index)), egui::Sense::click());
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.on_hover_text(t("打開頁面")).clicked() {
self.page_view = Some((id, None));
}
}
/// The page, large: everything drawn like the chat, with the version picker and the footer.
pub(super) fn page_viewer(&mut self, ctx: &egui::Context) {
let Some((id, rev)) = self.page_view else { return };
let p = self.pal;
let Some(cur) = self.state.pages.iter().find(|x| x.id == id).cloned() else {
self.page_view = None;
self.notice = Some(t("這個頁面已經刪除了"));
return;
};
let shown = rev.and_then(|r| crate::pages::version_of(&cur, r)).unwrap_or_else(|| cur.clone());
let old = shown.revision != cur.revision;
let bot_name = self.state.bot(cur.bot).map(|b| b.name.clone()).unwrap_or_default();
let w = (ctx.content_rect().width() - 80.0).clamp(360.0, 860.0);
let h = (dialog_max_height(ctx) - 150.0).max(240.0);
let mut keep = true;
let mut delete = false;
let mut pick: Option<Option<u32>> = None;
let close = dialog(ctx, "page-view", "頁面", w, true, p, |ui| {
dialog_body(ui, ("page-body", id), h, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.add(egui::Label::new(RichText::new(&shown.title).size(26.0).strong().color(p.text)).wrap());
ui.add_space(6.0);
let mut meta = format!("{} · {} {}", bot_name, t("更新於"), crate::pages::when(shown.updated));
if cur.revision > 1 || old {
meta = format!("{meta} · {} {}", t("第"), shown.revision) + &t(" 版");
}
ui.label(RichText::new(meta).size(12.5).color(p.text3));
if old {
ui.add_space(8.0);
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(t("你在看舊的版本")).size(12.5).color(p.text2));
if ui.add(secondary_button("回到最新版")).clicked() {
pick = Some(None);
}
});
});
} else if !shown.summary.is_empty() {
ui.add_space(12.0);
ui.add(egui::Label::new(RichText::new(&shown.summary).size(15.0).color(p.text)).wrap());
}
if !shown.note.is_empty() && shown.revision > 1 {
ui.add_space(6.0);
ui.add(egui::Label::new(RichText::new(format!("{}:{}", t("這一版更新了"), shown.note)).size(12.5).color(p.text2)).wrap());
}
ui.add_space(16.0);
let body = shown.body.clone();
self.markdown(ui, &body, p.text, false);
ui.add_space(8.0);
});
dialog_footer(ui, p, |ui| {
if !cur.history.is_empty() {
let label = if old { format!("{} {}", t("第"), shown.revision) + &t(" 版") } else { t("版本紀錄") };
egui::ComboBox::from_id_salt(("page-versions", id)).selected_text(label).width(150.0).show_ui(ui, |ui| {
if ui.selectable_label(!old, format!("{} {} {}({})", t("第"), cur.revision, t("版"), t("最新"))).clicked() {
pick = Some(None);
}
for v in cur.history.iter().rev() {
let text = format!("{} {} {} · {}", t("第"), v.revision, t("版"), crate::pages::when(v.at));
if ui.selectable_label(rev == Some(v.revision), text).clicked() {
pick = Some(Some(v.revision));
}
}
});
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(primary_button("完成", p)).clicked() {
keep = false;
}
if ui.add(secondary_button("存成檔案")).on_hover_text(t("存成 Markdown 到「下載」")).clicked() {
let dir = dirs::download_dir().unwrap_or_else(std::env::temp_dir);
let name: String = shown.title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).collect();
let path = dir.join(format!("{name}.md"));
let text = format!("# {}\n\n{}\n", shown.title, shown.body);
match std::fs::write(&path, text) {
Ok(()) => {
crate::preview::reveal(&path);
self.notice = Some(format!("{}{}", t("已存到 "), path.display()));
}
Err(e) => self.notice = Some(format!("{}{e}", t("存不了:"))),
}
}
if ui.add(secondary_button("複製")).on_hover_text(t("複製成 Markdown")).clicked() {
ui.ctx().copy_text(format!("# {}\n\n{}", shown.title, shown.body));
self.notice = Some(t("已複製"));
}
if !old && ui.add(egui::Button::new(RichText::new(t("刪除")).color(p.danger)).frame(false)).clicked() {
delete = true;
}
});
});
});
if let Some(v) = pick {
self.page_view = Some((id, v));
return;
}
if delete {
self.state.pages.retain(|x| x.id != id);
self.page_view = None;
self.notice = Some(format!("{}「{}」", t("已刪除頁面"), cur.title));
self.mark();
return;
}
if close || !keep {
self.page_view = None;
}
}
/// 資料庫 › 頁面: this bot's pages, newest change first. Whether there were any.
pub(super) fn pages_library(&mut self, ui: &mut egui::Ui, bot: &Bot) -> bool {
let p = self.pal;
let mut mine: Vec<crate::pages::Page> = self.state.pages.iter().filter(|x| x.bot == bot.id).cloned().collect();
if mine.is_empty() {
return false;
}
mine.sort_by_key(|x| std::cmp::Reverse(x.updated));
let now = crate::model::now_ms();
let mut open = None;
super::panel_ui::panel_title(ui, "頁面", p);
super::panel_ui::panel_card(ui, p, |ui| {
for pg in &mine {
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 9, p.hover);
}
let ir = egui::Rect::from_center_size(rect.left_center() + vec2(20.0, 0.0), vec2(18.0, 24.0));
page_glyph(ui.painter(), ir, p);
let x = rect.left() + 38.0;
let title = super::panel_ui::shorten_size(ui, &pg.title, rect.right() - x - 8.0, 13.0);
ui.painter().text(egui::pos2(x, rect.top() + 6.0), egui::Align2::LEFT_TOP, title, egui::FontId::proportional(13.0), p.text);
ui.painter().text(egui::pos2(x, rect.top() + 25.0), egui::Align2::LEFT_TOP, format!("{} · {}", t("頁面"), crate::pages::ago(pg.updated, now)), egui::FontId::proportional(11.5), p.text3);
if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
open = Some(pg.id);
}
}
});
if let Some(id) = open {
self.page_view = Some((id, None));
}
true
}
}
/// The page icon: a sheet with a folded corner, two text lines and four coloured bars (like the
/// original's page glyph).
pub(super) fn page_glyph(painter: &egui::Painter, r: egui::Rect, p: Palette) {
let w = r.width().min(r.height() * 0.8);
let rect = egui::Rect::from_center_size(r.center(), vec2(w, w * 1.25));
painter.rect_filled(rect, 4, p.hover);
painter.rect_stroke(rect, 4, Stroke::new(1.0, p.border), egui::StrokeKind::Inside);
let u = rect.width() / 10.0;
let l = rect.left() + u * 2.0;
painter.hline(l..=(rect.left() + u * 6.0), rect.top() + u * 2.6, Stroke::new(u * 0.9, p.text3));
painter.hline(l..=(rect.left() + u * 4.6), rect.top() + u * 4.4, Stroke::new(u * 0.9, p.text3));
let colors = [Color32::from_rgb(0x34, 0xa8, 0x53), Color32::from_rgb(0xfb, 0xbc, 0x05), Color32::from_rgb(0xea, 0x43, 0x35), Color32::from_rgb(0x42, 0x85, 0xf4)];
let base = rect.bottom() - u * 2.0;
for (i, c) in colors.iter().enumerate() {
let h = u * (2.0 + i as f32 * 1.1);
let x = l + i as f32 * u * 1.7;
painter.rect_filled(egui::Rect::from_min_max(egui::pos2(x, base - h), egui::pos2(x + u * 1.1, base)), 1, *c);
}
}

View File

@ -312,6 +312,11 @@ impl App {
} }
} }
files.sort_by(|a, b| b.2.cmp(&a.2)); files.sort_by(|a, b| b.2.cmp(&a.2));
// 頁面 the bot made come first.
let has_pages = self.pages_library(ui, bot);
if files.is_empty() && has_pages {
return;
}
if files.is_empty() { if files.is_empty() {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add_space(20.0); ui.add_space(20.0);
@ -368,7 +373,7 @@ fn round_button(ui: &mut egui::Ui, p: Palette, size: f32) -> egui::Response {
r.on_hover_cursor(egui::CursorIcon::PointingHand) r.on_hover_cursor(egui::CursorIcon::PointingHand)
} }
fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) { pub(super) fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(12.0); ui.add_space(12.0);
ui.label(RichText::new(t(title)).size(13.0).color(p.text.gamma_multiply(0.66))); ui.label(RichText::new(t(title)).size(13.0).color(p.text.gamma_multiply(0.66)));
@ -377,7 +382,7 @@ fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) {
} }
/// The original's card: radius 12, a hairline border, 4 px inside. /// 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)) { pub(super) 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| { 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.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().item_spacing.y = 0.0;
@ -468,7 +473,7 @@ fn shorten(ui: &egui::Ui, s: &str, max_w: f32) -> String {
} }
/// Cut text to fit, keeping the end of a file name ("orders_….xlsx" like the original). /// 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 { pub(super) fn shorten_size(ui: &egui::Ui, s: &str, max_w: f32, size: f32) -> String {
if text_width(ui, s, size) <= max_w { if text_width(ui, s, size) <= max_w {
return s.to_string(); return s.to_string();
} }

View File

@ -218,6 +218,9 @@ impl App {
} }
self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h); self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h);
} }
for pg in &self.state.pages {
(pg.id, pg.revision, pg.updated, &pg.title).hash(&mut h);
}
h.finish() h.finish()
} }
@ -244,11 +247,12 @@ impl App {
"unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned, "unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned,
})); }));
let start = b.messages.len().saturating_sub(remote::PHONE_TAIL); 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()); view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names, &self.state.pages)).collect());
if let Some(a) = self.approvals.get(&b.id) { if let Some(a) = self.approvals.get(&b.id) {
view.approvals.push(approval_json(b, a)); view.approvals.push(approval_json(b, a));
} }
} }
view.pages = self.state.pages.clone();
remote::hub().publish(view); remote::hub().publish(view);
} }

View File

@ -17,7 +17,7 @@ pub const NAMES: [&str; 20] = [
]; ];
pub fn is_app_tool(name: &str) -> bool { pub fn is_app_tool(name: &str) -> bool {
NAMES.contains(&name) NAMES.contains(&name) || crate::pages::NAMES.contains(&name)
} }
pub fn label(name: &str) -> &'static str { pub fn label(name: &str) -> &'static str {
@ -42,6 +42,7 @@ pub fn label(name: &str) -> &'static str {
"move_to_group" => "移到群組", "move_to_group" => "移到群組",
"rename_group" => "重新命名群組", "rename_group" => "重新命名群組",
"delete_group" => "刪除群組", "delete_group" => "刪除群組",
n if crate::pages::NAMES.contains(&n) => crate::pages::label(n),
_ => "App 操作", _ => "App 操作",
} }
} }
@ -109,6 +110,9 @@ pub fn specs() -> Vec<Value> {
"parameters": {"type": "object", "properties": { "parameters": {"type": "object", "properties": {
"theme": {"type": "string", "enum": ["light", "dark", "system"]}, "accent": {"type": "string", "enum": ["bot_color", "black"]}, "timezone": {"type": "string"}}}}), "theme": {"type": "string", "enum": ["light", "dark", "system"]}, "accent": {"type": "string", "enum": ["bot_color", "black"]}, "timezone": {"type": "string"}}}}),
] ]
.into_iter()
.chain(crate::pages::specs())
.collect()
} }
/// One line for the chat row. /// One line for the chat row.
@ -124,6 +128,7 @@ pub fn describe(name: &str, args: &str) -> String {
"delete_routine" | "run_routine_now" => format!("#{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), "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::<String>()), "message_bot" => format!("給 {}:{}", s("bot"), s("text").chars().take(40).collect::<String>()),
"create_bot" | "update_bot" => s("name").to_string() + &s("bot"), "create_bot" | "update_bot" => s("name").to_string() + &s("bot"),
n if crate::pages::NAMES.contains(&n) => crate::pages::describe(n, &v),
_ => String::new(), _ => String::new(),
} }
} }
@ -146,14 +151,16 @@ pub struct Outcome {
/// Bots to wake with a new turn (message_bot, run_routine_now). /// Bots to wake with a new turn (message_bot, run_routine_now).
pub wake: Vec<(u64, Message)>, pub wake: Vec<(u64, Message)>,
pub theme_changed: bool, pub theme_changed: bool,
/// write_page: the page made or changed (its card goes under the reply).
pub page: Option<u64>,
} }
fn ok(text: String) -> Outcome { fn ok(text: String) -> Outcome {
Outcome { text, ok: true, wake: vec![], theme_changed: false } Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
} }
fn err(text: String) -> Outcome { fn err(text: String) -> Outcome {
Outcome { text, ok: false, wake: vec![], theme_changed: false } Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None }
} }
fn find_bot<'a>(state: &'a State, name: &str) -> Option<&'a Bot> { fn find_bot<'a>(state: &'a State, name: &str) -> Option<&'a Bot> {
@ -190,6 +197,15 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
let now = routines::now(); let now = routines::now();
match name { match name {
"write_page" => match crate::pages::write(&mut state.pages, &mut state.next_page_id, bot_id, &v, crate::model::now_ms()) {
Ok(w) => Outcome { text: w.text, ok: true, wake: vec![], theme_changed: false, page: Some(w.id) },
Err(e) => err(e),
},
"read_page" => match crate::pages::read(&state.pages, bot_id, &v) {
Ok(t) => ok(t),
Err(e) => err(e),
},
"list_pages" => ok(crate::pages::list(&state.pages, bot_id)),
"list_routines" => { "list_routines" => {
let Some(b) = state.bot(bot_id) else { return err("找不到這個 Bot".into()) }; let Some(b) = state.bot(bot_id) else { return err("找不到這個 Bot".into()) };
if b.routines.is_empty() { if b.routines.is_empty() {
@ -255,7 +271,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
let r = &mut b.routines[idx]; let r = &mut b.routines[idx];
r.last_run = Some(now); r.last_run = Some(now);
let msg = wake_message(r); let msg = wake_message(r);
Outcome { text: format!("已開始執行「{}」", r.title), ok: true, wake: vec![(owner, msg)], theme_changed: false } Outcome { text: format!("已開始執行「{}」", r.title), ok: true, wake: vec![(owner, msg)], theme_changed: false, page: None }
} }
_ => { _ => {
let r = &mut b.routines[idx]; let r = &mut b.routines[idx];
@ -322,7 +338,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
if !missing.is_empty() { if !missing.is_empty() {
text.push_str(&format!("找不到:{}。", missing.join("、"))); text.push_str(&format!("找不到:{}。", missing.join("、")));
} }
Outcome { text, ok: missing.is_empty() || !moved.is_empty(), wake: vec![], theme_changed: false } Outcome { text, ok: missing.is_empty() || !moved.is_empty(), wake: vec![], theme_changed: false, page: None }
} }
"rename_group" => { "rename_group" => {
let (old, new) = (s("group").unwrap_or_default(), s("new_name").unwrap_or_default().trim().to_string()); let (old, new) = (s("group").unwrap_or_default(), s("new_name").unwrap_or_default().trim().to_string());
@ -524,7 +540,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
let text = s("text").unwrap_or_default(); let text = s("text").unwrap_or_default();
let msg = Message { wake: true, ..Message::user(&format!("[來自 Bot]「{from}」:{text}")) }; let msg = Message { wake: true, ..Message::user(&format!("[來自 Bot]「{from}」:{text}")) };
let name = state.bot(target).map(|b| b.name.clone()).unwrap_or_default(); 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 } Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None }
} }
"list_computers" => { "list_computers" => {
let mut lines = vec![ let mut lines = vec![
@ -535,7 +551,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
ok(lines.join("\n")) ok(lines.join("\n"))
} }
"update_settings" => { "update_settings" => {
let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false }; let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None };
let mut changed = vec![]; let mut changed = vec![];
if let Some(t) = s("theme") { if let Some(t) = s("theme") {
state.theme = match t.as_str() { state.theme = match t.as_str() {

View File

@ -210,7 +210,10 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.", 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": {}}}), "parameters": {"type": "object", "properties": {}}}),
]; ];
if computers.len() > 1 { // Named whenever there is a choice, or the one screen isn't this computer or the bot's own
// (a 手腳 computer): the bot should say whose screen it means.
let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME];
if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) {
for t in v.iter_mut() { for t in v.iter_mut() {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, 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)}); "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)});

View File

@ -13,6 +13,7 @@ mod desktop;
mod diagram; mod diagram;
mod platform; mod platform;
mod mic; mod mic;
mod pages;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod x11; mod x11;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]

View File

@ -323,6 +323,11 @@ pub struct State {
/// 技能 every bot can load. /// 技能 every bot can load.
#[serde(default)] #[serde(default)]
pub skills: Vec<crate::memory::Skill>, pub skills: Vec<crate::memory::Skill>,
/// 頁面 the bots made and keep up to date.
#[serde(default)]
pub pages: Vec<crate::pages::Page>,
#[serde(default = "first_routine_id")]
pub next_page_id: u64,
/// 手機與帳號: phone access, the relay account and backups. /// 手機與帳號: phone access, the relay account and backups.
#[serde(default)] #[serde(default)]
pub remote: crate::remote::Settings, pub remote: crate::remote::Settings,
@ -507,6 +512,8 @@ impl State {
next_channel_id: 1, next_channel_id: 1,
mcp_servers: vec![], mcp_servers: vec![],
skills: vec![], skills: vec![],
pages: vec![],
next_page_id: 1,
remote: Default::default(), remote: Default::default(),
language: auto_lang(), language: auto_lang(),
folders: vec![], folders: vec![],

419
src/pages.rs Normal file
View File

@ -0,0 +1,419 @@
// 頁面: pages a bot makes and keeps up to date — a tracker ("台灣 Tesla FSD 追蹤"), a report, a
// comparison — like the original's canvases. The bot writes Markdown (with tables, Mermaid charts
// and a few page blocks: ```stats tiles, ```steps progress, > [!NOTE] call-outs); the chat shows a
// card under the reply, `[標題](page:12)` links open it, and a routine can update it later (each
// update keeps the previous version). Pages live in State, so a brain's pages reach its screens
// and phones with everything else.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Older versions kept per page.
pub const KEEP_VERSIONS: usize = 10;
pub const MAX_BODY: usize = 200_000;
const MAX_TITLE: usize = 120;
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)]
pub struct Page {
pub id: u64,
/// The bot that made it (and may change it).
pub bot: u64,
pub title: String,
/// One line under the title on the card ("現況:還在審查第一步").
#[serde(default)]
pub summary: String,
/// Markdown.
pub body: String,
/// ms since 1970.
pub created: i64,
pub updated: i64,
/// 1 for a new page; +1 on every change.
#[serde(default = "one")]
pub revision: u32,
/// What changed last time (the bot's words), shown as 「更新:…」.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
/// Older versions, oldest first.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<Version>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)]
pub struct Version {
pub revision: u32,
pub at: i64,
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub summary: String,
pub body: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
fn one() -> u32 {
1
}
pub fn specs() -> Vec<Value> {
vec![
json!({"name": "write_page", "description": "Make or update a page: a lasting, nicely laid-out document the person can open from the chat and that you keep up to date — a tracker (e.g. 「台灣 Tesla FSD 追蹤」), a report, a comparison, a plan, research notes. Use it whenever the person asks you to track, follow, summarise or compare something they will come back to; then link it in your reply as [標題](page:ID). With an id (or the same title) it replaces that page's content and keeps the old version. Write Markdown: headings, short paragraphs, lists, tables, links to sources, Mermaid charts (```mermaid: pie, xychart-beta bar/line, timeline, gantt, flowchart), and these page blocks:\n```stats\n狀態 | 審查中 | 第 1 步\n預計開放 | 2027 Q2 | 媒體推估\n```\n```steps\n[x] 道路測試計畫審查 | 7/9 月開過兩次會\n[>] 道路測試 | 等交通部核定\n[ ] 正式開放\n```\n> [!NOTE] / [!TIP] / [!WARNING] / [!IMPORTANT] call-outs.\nStart with the answer (現況), then the details, then 來源 with links and dates. Keep the whole page current when updating (not a log), and say what changed in `note`.",
"parameters": {"type": "object", "properties": {
"title": {"type": "string", "description": "Short title, e.g. 台灣 Tesla FSD 追蹤"},
"content": {"type": "string", "description": "The whole page in Markdown (without the title)"},
"summary": {"type": "string", "description": "One line for the card: the current state in a few words"},
"note": {"type": "string", "description": "When updating: what changed, in one short sentence"},
"id": {"type": "integer", "description": "The page to update (from list_pages or an earlier write_page)"}},
"required": ["title", "content"]}}),
json!({"name": "read_page", "description": "Read one of your pages (its Markdown), e.g. before updating it.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}, "title": {"type": "string"}}}}),
json!({"name": "list_pages", "description": "List the pages you made (id, title, last update).", "parameters": {"type": "object", "properties": {}}}),
]
}
pub const NAMES: [&str; 3] = ["write_page", "read_page", "list_pages"];
pub fn label(name: &str) -> &'static str {
match name {
"write_page" => "寫頁面",
"read_page" => "讀頁面",
_ => "查看頁面",
}
}
pub fn describe(name: &str, args: &Value) -> String {
match name {
"write_page" | "read_page" => args["title"].as_str().map(String::from).or_else(|| args["id"].as_u64().map(|i| format!("#{i}"))).unwrap_or_default(),
_ => String::new(),
}
}
fn find<'a>(pages: &'a [Page], bot: u64, id: Option<u64>, title: &str) -> Option<&'a Page> {
match id {
Some(i) => pages.iter().find(|p| p.id == i),
None => pages.iter().find(|p| p.bot == bot && same_title(&p.title, title)),
}
}
fn same_title(a: &str, b: &str) -> bool {
let n = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
!b.trim().is_empty() && n(a) == n(b)
}
/// What write_page did: the page's id and the text for the model.
pub struct Written {
pub id: u64,
pub text: String,
}
/// write_page: make a page, or replace one (by id, or the same title from this bot).
pub fn write(pages: &mut Vec<Page>, next_id: &mut u64, bot: u64, args: &Value, now: i64) -> Result<Written, String> {
let title: String = args["title"].as_str().unwrap_or("").trim().chars().take(MAX_TITLE).collect();
let body = args["content"].as_str().unwrap_or("").trim().to_string();
let summary: String = args["summary"].as_str().unwrap_or("").trim().chars().take(200).collect();
let note: String = args["note"].as_str().unwrap_or("").trim().chars().take(300).collect();
if title.is_empty() {
return Err("頁面要有標題".into());
}
if body.is_empty() {
return Err("頁面內容是空的".into());
}
if body.len() > MAX_BODY {
return Err(format!("頁面太長了(最多 {} 字)。請精簡,或拆成幾頁。", MAX_BODY));
}
let id = args["id"].as_u64();
let existing = find(pages, bot, id, &title).map(|p| p.id);
if id.is_some() && existing.is_none() {
return Err(format!("找不到頁面 #{}。用 list_pages 看有哪些頁面,或不帶 id 建立新的。", id.unwrap_or(0)));
}
match existing {
Some(pid) => {
let p = pages.iter_mut().find(|p| p.id == pid).unwrap();
if p.bot != bot {
return Err("這是別的 Bot 的頁面,不能改".into());
}
if p.body == body && p.title == title && (summary.is_empty() || p.summary == summary) {
return Ok(Written { id: pid, text: format!("頁面「{}」內容沒有變化,沒有更新。連結:[{}](page:{})", p.title, p.title, pid) });
}
p.history.push(Version { revision: p.revision, at: p.updated, title: p.title.clone(), summary: p.summary.clone(), body: std::mem::take(&mut p.body), note: std::mem::take(&mut p.note) });
if p.history.len() > KEEP_VERSIONS {
let extra = p.history.len() - KEEP_VERSIONS;
p.history.drain(..extra);
}
p.title = title;
p.body = body;
if !summary.is_empty() {
p.summary = summary;
}
p.note = note;
p.revision += 1;
p.updated = now;
Ok(Written { id: pid, text: format!("已更新頁面「{}」(第 {} 版,上一版留著)。在回答裡用 [{}](page:{}) 連到它。", p.title, p.revision, p.title, pid) })
}
None => {
let pid = *next_id;
*next_id += 1;
let text = format!("已建立頁面「{title}」。在回答裡用 [{title}](page:{pid}) 連到它;要更新時用 write_page 帶 id {pid}。");
pages.push(Page { id: pid, bot, title, summary, body, created: now, updated: now, revision: 1, note, history: vec![] });
Ok(Written { id: pid, text })
}
}
}
pub fn read(pages: &[Page], bot: u64, args: &Value) -> Result<String, String> {
let title = args["title"].as_str().unwrap_or("");
let p = find(pages, bot, args["id"].as_u64(), title).ok_or("找不到這個頁面。用 list_pages 看有哪些。")?;
Ok(format!("# {}\n(頁面 #{},第 {} 版,更新於 {}{})\n\n{}", p.title, p.id, p.revision, when(p.updated), if p.summary.is_empty() { String::new() } else { format!(";摘要:{}", p.summary) }, p.body))
}
pub fn list(pages: &[Page], bot: u64) -> String {
let mine: Vec<&Page> = pages.iter().filter(|p| p.bot == bot).collect();
if mine.is_empty() {
return "你還沒有做任何頁面。".into();
}
mine.iter().map(|p| format!("#{} 「{}」 第 {} 版,更新於 {}{}", p.id, p.title, p.revision, when(p.updated), if p.summary.is_empty() { String::new() } else { format!(" — {}", p.summary) })).collect::<Vec<_>>().join("\n")
}
/// "11/5 09:33" in the local time zone.
pub fn when(ms: i64) -> String {
use chrono::TimeZone;
chrono::Local.timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default()
}
/// 「剛剛」「5 分鐘前」「昨天 09:33」「11/3」.
pub fn ago(ms: i64, now: i64) -> String {
use chrono::TimeZone;
let d = (now - ms).max(0) / 1000;
if d < 60 {
return "剛剛".into();
}
if d < 3600 {
return format!("{} 分鐘前", d / 60);
}
let (Some(t), Some(n)) = (chrono::Local.timestamp_millis_opt(ms).single(), chrono::Local.timestamp_millis_opt(now).single()) else { return String::new() };
let days = (n.date_naive() - t.date_naive()).num_days();
match days {
0 => format!("今天 {}", t.format("%H:%M")),
1 => format!("昨天 {}", t.format("%H:%M")),
_ if t.format("%Y").to_string() == n.format("%Y").to_string() => t.format("%-m/%-d").to_string(),
_ => t.format("%Y/%-m/%-d").to_string(),
}
}
/// A few lines of plain text for the card: the summary, then the page's first words (no
/// Markdown marks, no code or chart blocks).
pub fn preview(p: &Page, lines: usize) -> Vec<String> {
let mut out = vec![];
if !p.summary.is_empty() {
out.push(p.summary.clone());
}
let mut fence = false;
for l in p.body.lines() {
let t = l.trim();
if t.starts_with("```") {
fence = !fence;
continue;
}
if fence || t.is_empty() || t.starts_with('|') || t.starts_with("---") || t.starts_with('#') {
continue;
}
let t = t.trim_start_matches(['-', '*', '>', ' ']).trim();
let t = callout(t).map(|(_, rest)| rest).unwrap_or_else(|| t.to_string());
let t = plain(&t);
if !t.is_empty() && !out.contains(&t) {
out.push(t);
}
if out.len() >= lines {
break;
}
}
out
}
/// Markdown marks off: **b**, `c`, [text](url) → text.
pub fn plain(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find('[') {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
match (after.find("]("), after.find(')')) {
(Some(a), Some(b)) if a < b => {
out.push_str(&after[..a]);
rest = &after[b + 1..];
}
_ => {
out.push('[');
rest = after;
}
}
}
out.push_str(rest);
out.replace("**", "").replace('`', "").replace("__", "")
}
/// The id in a `page:12` link.
pub fn link_id(url: &str) -> Option<u64> {
url.strip_prefix("page:").or_else(|| url.strip_prefix("anybot://page/")).and_then(|s| s.trim_matches('/').parse().ok())
}
/// For the phone: the card in a chat, and the whole page.
pub fn card_json(p: &Page, now: i64) -> Value {
json!({"id": p.id, "title": p.title, "preview": preview(p, 4), "revision": p.revision, "updated": ago(p.updated, now), "note": p.note})
}
pub fn page_json(p: &Page, bot_name: &str, now: i64) -> Value {
json!({"id": p.id, "title": p.title, "summary": p.summary, "body": p.body, "revision": p.revision, "note": p.note,
"updated": when(p.updated), "updated_ago": ago(p.updated, now), "created": when(p.created), "bot": bot_name,
"versions": p.history.iter().rev().map(|v| json!({"revision": v.revision, "at": when(v.at), "note": v.note})).collect::<Vec<_>>()})
}
/// One older version as a whole page (for 「看上一版」).
pub fn version_of(p: &Page, revision: u32) -> Option<Page> {
p.history.iter().find(|v| v.revision == revision).map(|v| Page { id: p.id, bot: p.bot, title: v.title.clone(), summary: v.summary.clone(), body: v.body.clone(), created: p.created, updated: v.at, revision: v.revision, note: v.note.clone(), history: vec![] })
}
/// The page's ```mermaid blocks, in order (the phone asks for them one by one as SVG).
pub fn mermaid_blocks(body: &str) -> Vec<String> {
let mut out = vec![];
let mut cur: Option<String> = None;
for l in body.lines() {
let t = l.trim_start();
match &mut cur {
None if t.starts_with("```mermaid") => cur = Some(String::new()),
Some(s) if t.starts_with("```") => {
out.push(std::mem::take(s));
cur = None;
}
Some(s) => {
s.push_str(l);
s.push('\n');
}
None => {}
}
}
out
}
/// The pages written in one reply (its write_page calls), each once, in order.
pub fn ids_in(calls: &[crate::model::ToolCall]) -> Vec<u64> {
let mut out = vec![];
for c in calls.iter().filter(|c| c.name == "write_page") {
if let Some(id) = serde_json::from_str::<Value>(&c.args).ok().and_then(|v| v["id"].as_u64()) {
if !out.contains(&id) {
out.push(id);
}
}
}
out
}
/// ```stats lines: 標籤 | 數值 | 說明
pub fn stats(src: &str) -> Vec<(String, String, String)> {
src.lines()
.filter_map(|l| {
let c: Vec<&str> = l.split('|').map(str::trim).collect();
(c.len() >= 2 && !c[0].is_empty()).then(|| (plain(c[0]), plain(c[1]), c.get(2).map(|s| plain(s)).unwrap_or_default()))
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Step {
Done,
Now,
Todo,
}
/// ```steps lines: [x] 名稱 | 說明 ([>] = now, [ ] = to do)
pub fn steps(src: &str) -> Vec<(Step, String, String)> {
src.lines()
.filter_map(|l| {
let t = l.trim().trim_start_matches(['-', '*']).trim();
let (st, rest) = if let Some(r) = t.strip_prefix("[x]").or_else(|| t.strip_prefix("[X]")).or_else(|| t.strip_prefix("[✓]")) {
(Step::Done, r)
} else if let Some(r) = t.strip_prefix("[>]").or_else(|| t.strip_prefix("[~]")).or_else(|| t.strip_prefix("[-]")) {
(Step::Now, r)
} else if let Some(r) = t.strip_prefix("[ ]") {
(Step::Todo, r)
} else {
return None;
};
let mut c = rest.splitn(2, '|');
let name = plain(c.next().unwrap_or("").trim());
(!name.is_empty()).then(|| (st, name, plain(c.next().unwrap_or("").trim())))
})
.collect()
}
/// `> [!WARNING] text` → ("warning", text).
pub fn callout(first_line: &str) -> Option<(&'static str, String)> {
let t = first_line.trim();
let rest = t.strip_prefix("[!")?;
let end = rest.find(']')?;
let kind = match rest[..end].to_ascii_uppercase().as_str() {
"NOTE" | "INFO" => "note",
"TIP" => "tip",
"WARNING" | "CAUTION" => "warning",
"IMPORTANT" => "important",
_ => return None,
};
Some((kind, rest[end + 1..].trim().to_string()))
}
/// A sample tracking page (screenshots, and the look the bots are asked for).
pub fn demo(now: i64) -> (Vec<Page>, u64, u64) {
let body = "## 現況\n台灣 FSD **現在還不能用**:交通部把它當 L2 輔助駕駛,走「新技術駕駛系統審查」,不用修法;目前卡在第 1 步,路測計畫還沒核准。\n\n```stats\n目前階段 | 第 1 步 | 道路測試計畫審查\n預計開放 | 2027 Q2 | 媒體推估,官方未公布\n月費 | 未公布 | 網路數字是美國價換算\n你的車 | HW4 | 柏林廠,硬體不卡\n```\n\n## 審查進度\n```steps\n[x] 專家審查會議 | 7 月、9 月各開一次\n[>] 道路測試計畫審查 | 特斯拉依會議結論補件中\n[ ] 道路測試 | 計畫核定後才開始\n[ ] 正式開放\n```\n\n> [!WARNING] 你 12 月交車時多半還不能用,現在先別花錢。等正式開放後先訂閱一個月最划算。\n\n## 時間線\n| 日期 | 消息 | 來源 |\n|---|---|---|\n| 11/4 | 交通部:希望年底前完成相關審查,但不保證年底開放 | NOWnews |\n| 10/2 | 特斯拉:7–9 月完成第一輪技術審查,提出修改後的路測計畫 | 特斯拉 |\n| 6/30 | FSD 買斷停賣 | 特斯拉官網 |\n\n```mermaid\npie title 媒體預估開放時間\n \"2026 年底\" : 1\n \"2027 上半年\" : 3\n \"2027 下半年\" : 4\n```\n\n## 來源\n- [NOWnews 專訪交通部監理司](https://www.nownews.com)(11/4)\n- [車安中心 新技術駕駛系統審查流程](https://www.vscc.org.tw)\n";
let mut pages = vec![];
let mut next = 1;
let first = body.replace("交通部:希望年底前完成相關審查,但不保證年底開放", "(還沒有新消息)");
let _ = write(&mut pages, &mut next, 1, &json!({"title": "台灣 Tesla FSD 追蹤", "content": first, "summary": "還不能用:審查第一步,路測計畫未核准"}), now - 86_400_000);
let id = write(&mut pages, &mut next, 1, &json!({"title": "台灣 Tesla FSD 追蹤", "content": body, "note": "交通部 11/4 的新說法:希望年底前完成審查"}), now - 3_600_000).map(|w| w.id).unwrap_or(1);
(pages, next, id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_makes_then_updates_and_keeps_versions() {
let mut pages = vec![];
let mut next = 1;
let w = write(&mut pages, &mut next, 7, &json!({"title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n還在審查", "summary": "審查第一步"}), 1000).unwrap();
assert_eq!(w.id, 1);
assert!(w.text.contains("page:1"));
// Same title, no id: the same page.
let w2 = write(&mut pages, &mut next, 7, &json!({"title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n交通部有新說法", "note": "交通部新說法"}), 2000).unwrap();
assert_eq!(w2.id, 1);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].revision, 2);
assert_eq!(pages[0].summary, "審查第一步", "kept when not given");
assert_eq!(pages[0].history.len(), 1);
assert_eq!(pages[0].history[0].body, "# 現況\n還在審查");
// Nothing changed: no new version.
let w3 = write(&mut pages, &mut next, 7, &json!({"id": 1, "title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n交通部有新說法"}), 3000).unwrap();
assert!(w3.text.contains("沒有變化"));
assert_eq!(pages[0].revision, 2);
// Another bot can't change it; an unknown id is an error.
assert!(write(&mut pages, &mut next, 8, &json!({"id": 1, "title": "x", "content": "y"}), 4000).is_err());
assert!(write(&mut pages, &mut next, 7, &json!({"id": 99, "title": "x", "content": "y"}), 4000).is_err());
for i in 0..15 {
write(&mut pages, &mut next, 7, &json!({"id": 1, "title": "台灣 Tesla FSD 追蹤", "content": format!("v{i}")}), 5000 + i).unwrap();
}
assert_eq!(pages[0].history.len(), KEEP_VERSIONS);
assert!(version_of(&pages[0], pages[0].revision - 1).is_some());
}
#[test]
fn preview_and_blocks() {
let p = Page { title: "t".into(), summary: "還不能用".into(), body: "# 現況\n\n```stats\n狀態 | 審查中\n```\n交通部說**年底前**完成審查,見 [來源](https://x.com)。\n\n| a | b |\n|---|---|\n".into(), ..Default::default() };
assert_eq!(preview(&p, 3), vec!["還不能用", "交通部說年底前完成審查,見 來源。"]);
assert_eq!(stats("狀態 | 審查中 | 第 1 步\n預計 | 2027"), vec![("狀態".into(), "審查中".into(), "第 1 步".into()), ("預計".into(), "2027".into(), String::new())]);
let s = steps("[x] 審查 | 開過兩次會\n[>] 路測\n[ ] 開放\n不是步驟");
assert_eq!(s.len(), 3);
assert_eq!((s[0].0, s[1].0, s[2].0), (Step::Done, Step::Now, Step::Todo));
assert_eq!(s[0].2, "開過兩次會");
assert_eq!(callout("[!WARNING] 先別花錢"), Some(("warning", "先別花錢".into())));
assert_eq!(link_id("page:12"), Some(12));
assert_eq!(link_id("https://x"), None);
}
}

View File

@ -372,7 +372,7 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
let mut blind_copy: Option<ChatRequest> = None; let mut blind_copy: Option<ChatRequest> = None;
loop { loop {
// A model that can't see pictures gets the request without them: the person's pictures // A model that can't see pictures gets the request without them: the person's pictures
// described by a model that can (看圖小幫手), screenshots as a short note. // and the newest screenshot described by a model that can (看圖小幫手).
if !sees_images(req) && blind_copy.is_none() { if !sees_images(req) && blind_copy.is_none() {
blind_copy = Some(if DESCRIBING.with(|d| d.get()) { without_images(req) } else { with_descriptions(req, cancel, send) }); blind_copy = Some(if DESCRIBING.with(|d| d.get()) { without_images(req) } else { with_descriptions(req, cancel, send) });
} }
@ -458,6 +458,8 @@ fn kept_images(history: &[Message]) -> Vec<usize> {
const OLD_SCREENSHOT_NOTE: &str = "(較早的截圖已省略)"; const OLD_SCREENSHOT_NOTE: &str = "(較早的截圖已省略)";
/// Said in place of a screenshot when the model can't take pictures. /// Said in place of a screenshot when the model can't take pictures.
const BLIND_SCREENSHOT_NOTE: &str = "(截圖已拍好,但這個模型看不到圖片,所以沒有附上。請改用文字方式確認畫面,例如讀取視窗或網頁的文字。)"; const BLIND_SCREENSHOT_NOTE: &str = "(截圖已拍好,但這個模型看不到圖片,所以沒有附上。請改用文字方式確認畫面,例如讀取視窗或網頁的文字。)";
/// What a helper is asked about a screenshot.
const SCREEN_ASK: &str = "這是一台電腦螢幕的截圖。請描述:開著哪些視窗或 App、畫面上的文字(照抄重要的字)、按鈕和欄位,以及它們大約的位置(以這張圖的像素座標,左上角是 0,0)。不要猜看不清楚的字。";
const BLIND_ATTACHMENT_NOTE: &str = "(使用者附了圖片,但這個模型看不到圖片。)"; const BLIND_ATTACHMENT_NOTE: &str = "(使用者附了圖片,但這個模型看不到圖片。)";
/// Models (base url + name) that refused a picture: later requests leave pictures out. /// Models (base url + name) that refused a picture: later requests leave pictures out.
@ -631,10 +633,25 @@ fn describe(picture: &str, ask: &str, not: &ChatRequest, cancel: &AtomicBool) ->
fn with_descriptions(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> ChatRequest { fn with_descriptions(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> ChatRequest {
let mut r = req.clone(); let mut r = req.clone();
let mut told = false; let mut told = false;
for m in r.history.iter_mut().filter(|m| !m.images.is_empty()) { // The newest screenshot is described too (what the bot needs to act next); older ones stay a note.
let newest_shot = r.history.iter().rposition(|m| m.role == Role::Tool && !m.images.is_empty());
for (i, m) in r.history.iter_mut().enumerate().filter(|(_, m)| !m.images.is_empty()) {
if m.role == Role::Tool { if m.role == Role::Tool {
m.images.clear(); let pictures = std::mem::take(&mut m.images);
m.text = if m.text.is_empty() { BLIND_SCREENSHOT_NOTE.to_string() } else { format!("{}\n{BLIND_SCREENSHOT_NOTE}", m.text) }; let seen = if Some(i) == newest_shot {
if !told && !notes().lock().unwrap().contains_key(&picture_key(&pictures[0])) {
send(StreamEvent::Retrying("這個模型看不到圖片,正在請另一個看得懂圖的模型看這張截圖…".into()));
told = true;
}
describe(&pictures[0], SCREEN_ASK, req, cancel)
} else {
None
};
let add = match seen {
Some((d, model)) => format!("(截圖已拍好;你看不到圖片,以下是「{model}」看過這張截圖後的描述)\n{d}"),
None => BLIND_SCREENSHOT_NOTE.to_string(),
};
m.text = if m.text.is_empty() { add } else { format!("{}\n{add}", m.text) };
continue; continue;
} }
let pictures = std::mem::take(&mut m.images); let pictures = std::mem::take(&mut m.images);

View File

@ -131,6 +131,8 @@ pub struct View {
pub bots: Vec<Value>, pub bots: Vec<Value>,
pub approvals: Vec<Value>, pub approvals: Vec<Value>,
pub messages: HashMap<u64, Vec<Value>>, pub messages: HashMap<u64, Vec<Value>>,
/// 頁面, for the phone's page view.
pub pages: Vec<crate::pages::Page>,
pub version: u64, pub version: u64,
} }
@ -251,7 +253,7 @@ fn tool_detail(name: &str, args: &str) -> String {
} }
/// One message as the phone shows it (tool results are folded away like in the window). /// 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<u64, String>) -> Option<Value> { pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pages: &[crate::pages::Page]) -> Option<Value> {
use crate::model::{Role, ToolStatus}; use crate::model::{Role, ToolStatus};
if m.role == Role::Tool { if m.role == Role::Tool {
return None; return None;
@ -267,8 +269,11 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>) ->
ToolStatus::Denied => "denied", ToolStatus::Denied => "denied",
ToolStatus::Failed => "failed", ToolStatus::Failed => "failed",
}; };
let page = (c.name == "write_page").then(|| serde_json::from_str::<Value>(&c.args).ok().and_then(|a| a["id"].as_u64())).flatten().and_then(|id| pages.iter().find(|p| p.id == id));
if c.name == "show_card" { if c.name == "show_card" {
json!({"card": serde_json::from_str::<Value>(&c.args).unwrap_or(Value::Null), "status": st}) json!({"card": serde_json::from_str::<Value>(&c.args).unwrap_or(Value::Null), "status": st})
} else if let Some(p) = page {
json!({"page": crate::pages::card_json(p, crate::model::now_ms()), "status": st})
} else { } else {
json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st}) json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st})
} }
@ -340,6 +345,32 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
"role": role, "role": role,
})) }))
} }
// 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG.
("GET", ["api", "pages", id]) => {
let id: u64 = id.parse().unwrap_or(0);
let v = hub().view.lock().unwrap();
let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") };
let shown = req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone());
let bot = v.bots.iter().find(|b| b["id"].as_u64() == Some(cur.bot)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string();
let mut out = crate::pages::page_json(&shown, &bot, crate::model::now_ms());
out["versions"] = crate::pages::page_json(cur, &bot, 0)["versions"].clone();
out["latest"] = json!(cur.revision);
Resp::json(&out)
}
("GET", ["api", "pages", id, "chart", n]) => {
let id: u64 = id.parse().unwrap_or(0);
let n: usize = n.parse().unwrap_or(0);
let body = {
let v = hub().view.lock().unwrap();
let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") };
req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()).body
};
let Some(src) = crate::pages::mermaid_blocks(&body).into_iter().nth(n) else { return Resp::error(404, "找不到這張圖") };
match crate::diagram::svg(&src, req.query.get("dark").map(|d| d == "1").unwrap_or(false)) {
Ok(svg) => Resp::bytes("image/svg+xml", svg.into_bytes()),
Err(e) => Resp::error(422, &e),
}
}
("GET", ["api", "bots", id]) => { ("GET", ["api", "bots", id]) => {
let id: u64 = id.parse().unwrap_or(0); let id: u64 = id.parse().unwrap_or(0);
let v = hub().view.lock().unwrap(); let v = hub().view.lock().unwrap();
@ -450,7 +481,7 @@ pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
// 大腦: the owner's other AnyBots showing this one's bots. // 大腦: the owner's other AnyBots showing this one's bots.
"mirror" => sync, "mirror" => sync,
"owner" | "admin" => !device && !sync, "owner" | "admin" => !device && !sync,
"member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
_ => false, _ => false,
} }
} }
@ -689,6 +720,10 @@ pub fn why_unreachable(e: &ureq::Error) -> String {
use std::io::ErrorKind as K; use std::io::ErrorKind as K;
use ureq::Error as E; use ureq::Error as E;
let text = e.to_string().to_lowercase(); let text = e.to_string().to_lowercase();
// A TLS failure often comes wrapped as an I/O error: look at the words first.
if text.contains("certificate") || text.contains("tls") || text.contains("handshake") {
return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string();
}
let w = match e { let w = match e {
E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了",
E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了", E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了",
@ -700,7 +735,6 @@ pub fn why_unreachable(e: &ureq::Error) -> String {
K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路", K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路",
_ => "網路連線出了問題", _ => "網路連線出了問題",
}, },
_ if text.contains("tls") || text.contains("certificate") => "和伺服器的安全連線失敗(伺服器的憑證有問題)",
_ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", _ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了",
_ => "網路連線出了問題", _ => "網路連線出了問題",
}; };

View File

@ -795,6 +795,42 @@ impl Plan {
} }
} }
/// `anybot setup --refresh-service` (install.sh runs it after an update): write the start script
/// again — a newer AnyBot may start differently — and restart. The computer stays joined as it was,
/// and a screen remembered at setup stays.
fn refresh_service() -> i32 {
let Some(home) = dirs::home_dir() else { return 1 };
let launcher = home.join(".local/bin/anybot-run");
let Ok(old) = std::fs::read_to_string(&launcher) else {
println!("這台沒有設成開機自動執行,不用更新服務。");
return 0;
};
let role = if old.contains("accessibility bus") { Role::Hands } else { Role::Brain };
let display = old.lines().find_map(|l| l.strip_prefix("export DISPLAY=\"${DISPLAY:-").and_then(|r| r.strip_suffix("}\""))).unwrap_or("").to_string();
let env = Env { linux: cfg!(target_os = "linux"), display, systemd: true };
let exe = match std::env::current_exe().and_then(|p| p.canonicalize()) {
Ok(e) => e,
Err(e) => {
eprintln!("✗ 找不到 AnyBot 程式:{e}");
return 1;
}
};
if let Err(e) = std::fs::write(&launcher, launcher_script(role, &exe, &env)) {
eprintln!("✗ 寫入 {} 失敗:{e}", launcher.display());
return 1;
}
match systemctl(&["restart", UNIT]) {
Ok(_) => {
println!("已更新並重新啟動 AnyBot 服務。");
0
}
Err(e) => {
eprintln!("✗ 重新啟動失敗:{e}");
1
}
}
}
fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String { fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String {
let q = |s: &str| format!("'{}'", s.replace('\'', "'\\''")); let q = |s: &str| format!("'{}'", s.replace('\'', "'\\''"));
let mut s = String::from("#!/bin/sh\n# Started by systemd (anybot.service); written by `anybot setup`.\n"); let mut s = String::from("#!/bin/sh\n# Started by systemd (anybot.service); written by `anybot setup`.\n");
@ -806,11 +842,17 @@ fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String {
if role == Role::Hands { if role == Role::Hands {
s += "# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels.\n"; s += "# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels.\n";
s += "export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\n"; s += "export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\n";
let real_screen = std::env::args().any(|a| a == "--real-screen");
if env.has_display() && env.display != "wayland" && env.display != "desktop" { if env.has_display() && env.display != "wayland" && env.display != "desktop" {
s += &format!("# The screen this computer had when it was set up.\nexport DISPLAY=\"${{DISPLAY:-{}}}\"\n", env.display); s += &format!("# The screen this computer had when it was set up.\nexport DISPLAY=\"${{DISPLAY:-{}}}\"\n", env.display);
if let Ok(x) = std::env::var("XAUTHORITY") { if let Ok(x) = std::env::var("XAUTHORITY") {
s += &format!("export XAUTHORITY=\"${{XAUTHORITY:-{x}}}\"\n"); s += &format!("export XAUTHORITY=\"${{XAUTHORITY:-{x}}}\"\n");
} }
} else if !real_screen {
// Set up without a screen (over SSH, or on Wayland): the bots get a virtual screen of
// their own, even when a desktop session is logged in here — nobody would be there to
// answer its 「允許分享螢幕」 dialog. `anybot setup --real-screen` uses the desktop.
s += "unset DISPLAY WAYLAND_DISPLAY XDG_SESSION_TYPE\n";
} }
s += "if [ -z \"$DISPLAY\" ] && [ -z \"$WAYLAND_DISPLAY\" ] && command -v xvfb-run >/dev/null; then\n"; s += "if [ -z \"$DISPLAY\" ] && [ -z \"$WAYLAND_DISPLAY\" ] && command -v xvfb-run >/dev/null; then\n";
s += " # No screen: a virtual one, a window manager, and a session bus for the accessibility bus.\n"; s += " # No screen: a virtual one, a window manager, and a session bus for the accessibility bus.\n";
@ -843,9 +885,12 @@ const VALUED: [&str; 4] = ["--server", "--user", "--name", "--brain-name"];
pub fn run(args: &[String]) -> i32 { pub fn run(args: &[String]) -> i32 {
let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
if args.iter().any(|a| a == "-h" || a == "--help") { if args.iter().any(|a| a == "-h" || a == "--help") {
println!("anybot setup:把這台電腦接到你的 AnyBot。\n\n anybot setup 網址 配對碼 (手機或電腦的 AnyBot 按「加一台電腦」會給你這一行)\n anybot setup 一個畫面:填網址和配對碼(或帳號密碼)\n 加 --brain 這台當大腦,不當手腳\n 加 --no-service 不設開機自動執行\n\n給程式用:anybot setup --server 網址 --user 帳號 [--brain-name 大腦名字],密碼放在 ANYBOT_ACCOUNT_PASSWORD。"); println!("anybot setup:把這台電腦接到你的 AnyBot。\n\n anybot setup 網址 配對碼 (手機或電腦的 AnyBot 按「加一台電腦」會給你這一行)\n anybot setup 一個畫面:填網址和配對碼(或帳號密碼)\n 加 --brain 這台當大腦,不當手腳\n 加 --no-service 不設開機自動執行\n 加 --real-screen 讓 Bot 用這台登入中的桌面(預設用自己的虛擬螢幕)\n\n給程式用:anybot setup --server 網址 --user 帳號 [--brain-name 大腦名字],密碼放在 ANYBOT_ACCOUNT_PASSWORD。");
return 0; return 0;
} }
if args.iter().any(|a| a == "--refresh-service") {
return refresh_service();
}
let role = if args.iter().any(|a| a == "--brain") { Role::Brain } else { Role::Hands }; let role = if args.iter().any(|a| a == "--brain") { Role::Brain } else { Role::Hands };
let mut pos = vec![]; let mut pos = vec![];
let mut skip = false; let mut skip = false;