Merge branch 'next': bots learn what they did, the box's own disk and tabs, gVisor isolation, checked by using it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
commit
38f15c53a6
|
|
@ -729,6 +729,7 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。
|
||||||
|
|
||||||
- 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。
|
- 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。
|
||||||
- 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。
|
- 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。
|
||||||
|
- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(登入時自動啟動),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。
|
||||||
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
|
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
|
||||||
- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
|
- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
|
||||||
|
|
||||||
|
|
@ -1255,6 +1256,7 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
|
||||||
|
|
||||||
- **記憶**:跟 Bot 說「記住我喜歡靠窗的座位」,它會用 remember 記下來;「忘記…」用 forget。每個 Bot 最多 200 條、每條 500 字(估計值),每次聊天都帶著。Bot 設定裡的「記得的事」可以一條條刪。
|
- **記憶**:跟 Bot 說「記住我喜歡靠窗的座位」,它會用 remember 記下來;「忘記…」用 forget。每個 Bot 最多 200 條、每條 500 字(估計值),每次聊天都帶著。Bot 設定裡的「記得的事」可以一條條刪。
|
||||||
- **技能**:教 Bot 一套做法後請它「存成技能」(save_skill),所有 Bot 共用;系統指示只列名稱和一句說明,Bot 要用時先用 use_skill 讀完整做法。設定 →「連接器」頁的「技能」卡可以刪除。
|
- **技能**:教 Bot 一套做法後請它「存成技能」(save_skill),所有 Bot 共用;系統指示只列名稱和一句說明,Bot 要用時先用 use_skill 讀完整做法。設定 →「連接器」頁的「技能」卡可以刪除。
|
||||||
|
- **自己進化**:一件事做了 5 步以上(操作電腦、網頁、指令,不算記憶和設定),回答完後在背景回頭看一次:這是在網站或程式上動手操作的事(填表、發文、回留言、上傳下載、改設定),就把這次真的成功的最短做法存成技能(用畫面上的名字,不寫編號和座標;繞路和失敗寫成「注意」;使用者說過的偏好寫進去);已經有同一件事的技能就合併成更好的一版,照著做而且很順就不動。回覆下面會多一行「(學會了新做法「…」)」或「(把做法「…」更新了)」。系統指示要 Bot 開始做事前先看技能清單、有符合的先 use_skill。實測(httpbin 訂 pizza,真的 Grok):第一次 11 步學到技能;第二次先讀技能、10 步 24 秒一次做完,回頭看後判斷不用改。
|
||||||
- **範本**:側邊欄「+」按右鍵 →「從範本建立」:研究助理、寫作夥伴、電腦助手、客服助理、開發助手,各有角色說明、指示、頭像和顏色。Bot 也能用 create_bot 的 template 參數建立。
|
- **範本**:側邊欄「+」按右鍵 →「從範本建立」:研究助理、寫作夥伴、電腦助手、客服助理、開發助手,各有角色說明、指示、頭像和顏色。Bot 也能用 create_bot 的 template 參數建立。
|
||||||
|
|
||||||
### 保險庫(照原版重做,2026-11-04)
|
### 保險庫(照原版重做,2026-11-04)
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,20 @@ COPY overlay /etc/anybot/overlay
|
||||||
COPY Xresources /etc/anybot/Xresources
|
COPY Xresources /etc/anybot/Xresources
|
||||||
COPY menu /etc/anybot/menu
|
COPY menu /etc/anybot/menu
|
||||||
RUN chmod +x /usr/local/bin/anybot-box-start /usr/local/bin/anybot-a11y /usr/local/bin/anybot-browser \
|
RUN chmod +x /usr/local/bin/anybot-box-start /usr/local/bin/anybot-a11y /usr/local/bin/anybot-browser \
|
||||||
# Chromium has no sandbox inside a container (no user namespaces); the box itself is the
|
# Chromium has no sandbox inside a plain container (no user namespaces); the box itself is
|
||||||
# sandbox. Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open).
|
# the sandbox. Under gVisor (容器隔離=runsc) the box has its own virtualized kernel and
|
||||||
&& printf '%s\n' 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic --test-type"' > /etc/chromium.d/anybot
|
# Chromium's sandbox works: AnyBot then starts the box with ANYBOT_BOX_SANDBOX=1 and the
|
||||||
|
# sandbox is switched back on (one more wall between a hostile web page and the machine).
|
||||||
|
# Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open).
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'if [ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ]; then' \
|
||||||
|
' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \
|
||||||
|
'else' \
|
||||||
|
' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --test-type --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \
|
||||||
|
'fi' > /etc/chromium.d/anybot
|
||||||
# Accessibility on for every app, including those started over `docker exec`.
|
# Accessibility on for every app, including those started over `docker exec`.
|
||||||
ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
|
ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
|
||||||
EXPOSE 6080
|
EXPOSE 6080
|
||||||
LABEL anybot.box.version="6"
|
LABEL anybot.box.version="7"
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
CMD ["/usr/local/bin/anybot-box-start"]
|
CMD ["/usr/local/bin/anybot-box-start"]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,12 @@ if [[ ! -e "$PROFILE" ]]; then
|
||||||
ln -s /home/bot/.config/anybot-browser "$PROFILE"
|
ln -s /home/bot/.config/anybot-browser "$PROFILE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
FLAGS=(--no-sandbox --test-type --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
# The box (a container) is the sandbox by default, so Chromium runs --no-sandbox. Under gVisor
|
||||||
|
# (容器隔離=runsc) the box has a virtualized kernel of its own and AnyBot starts it with
|
||||||
|
# ANYBOT_BOX_SANDBOX=1 — Chromium's own sandbox works there, so keep it as a second wall.
|
||||||
|
SANDBOX=(--no-sandbox --test-type)
|
||||||
|
[ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ] && SANDBOX=()
|
||||||
|
FLAGS=("${SANDBOX[@]}" --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
||||||
--remote-allow-origins=* --force-renderer-accessibility --disable-blink-features=AutomationControlled
|
--remote-allow-origins=* --force-renderer-accessibility --disable-blink-features=AutomationControlled
|
||||||
--disable-dev-shm-usage --disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader --password-store=basic
|
--disable-dev-shm-usage --disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader --password-store=basic
|
||||||
--no-first-run --no-default-browser-check --disable-session-crashed-bubble --hide-crash-restore-bubble
|
--no-first-run --no-default-browser-check --disable-session-crashed-bubble --hide-crash-restore-bubble
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 把 Bot 的電腦整個搬到 gVisor VM:box 的全部檔案與瀏覽器登入都在 home 資料卷
|
||||||
|
# (anybot-box-home,Chromium 的 profile 也在裡面),整卷複製過去後,
|
||||||
|
# AnyBot 在 VM 上掛同名資料卷開機=檔案、登入、桌面設定全部跟著走。
|
||||||
|
# 所有 Bot 共用這一台、這一份登入與檔案:不分帳號,資料夾照常共用。
|
||||||
|
#
|
||||||
|
# 先完全退出 AnyBot 應用程式,然後:
|
||||||
|
# ./gvisor-migrate.sh 搬過去(本機這份保留,當備份)
|
||||||
|
# ./gvisor-migrate.sh --check 只比對兩邊,不動資料
|
||||||
|
set -euo pipefail
|
||||||
|
VM=anybot-gvisor
|
||||||
|
VOL=anybot-box-home
|
||||||
|
VMHOST="unix://$HOME/.lima/$VM/sock/docker.sock"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "找不到 docker 指令"; exit 1; }
|
||||||
|
if [ -n "${DOCKER_HOST:-}" ]; then
|
||||||
|
echo "現在環境裡有 DOCKER_HOST=${DOCKER_HOST}(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1
|
||||||
|
fi
|
||||||
|
docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 ${VOL}(box 還沒建立過?)"; exit 1; }
|
||||||
|
limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running || { echo "VM $VM 沒在跑,先跑 gvisor-vm.sh"; exit 1; }
|
||||||
|
# The Mac app's process is "AnyBot" and `anybot --serve` is "anybot": match either case.
|
||||||
|
if pgrep -ix anybot >/dev/null 2>&1; then
|
||||||
|
echo "AnyBot 還在跑:請先從選單完全退出(它會把 box 叫醒、資料卷會一直在用)。"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "本機 box 的檔案:$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h | wc -l') 個項目、$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'du -sh /h | cut -f1')"
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--check" ]; then
|
||||||
|
echo "VM 上的檔案:$(DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h 2>/dev/null | wc -l') 個項目"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "停止本機的 box(容器留在本機當備份,不刪)…"
|
||||||
|
docker stop anybot-box anybot-box-home-holder >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "VM 上準備同名資料卷…"
|
||||||
|
DOCKER_HOST=$VMHOST docker volume create "$VOL" >/dev/null
|
||||||
|
|
||||||
|
echo "搬移中(本機 → VM,同一條管線直送,不落地)…"
|
||||||
|
# 兩邊都是 Linux,檔案擁有者(uid 1000 = box 裡的 bot 使用者)直接對得上。
|
||||||
|
docker run --rm -v "$VOL":/h -w /h alpine:3.20 tar -cf - . \
|
||||||
|
| DOCKER_HOST=$VMHOST docker run --rm -i -v "$VOL":/h -w /h alpine:3.20 tar -xf -
|
||||||
|
|
||||||
|
echo "核對…"
|
||||||
|
DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'echo "VM 上的檔案:$(find /h | wc -l) 個項目、$(du -sh /h | cut -f1)"'
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
搬好了。接著在 AnyBot(重新開啟)設定 › 電腦 › 在哪裡執行:
|
||||||
|
1. Docker 主機:填 $VMHOST → 套用
|
||||||
|
2. 容器隔離:選 強隔離(gVisor)
|
||||||
|
電腦卡片按「建立/更新」:VM 上會先建映像檔(第一次下載套件,幾分鐘),
|
||||||
|
開機後檔案與登入就是原本那些——所有 Bot 共用同一台、同一份,沒變。
|
||||||
|
本機那份 box 和資料卷都留著當備份;確認 VM 上都正常後想刪再刪:
|
||||||
|
docker rm -f anybot-box anybot-box-home-holder; docker volume rm $VOL
|
||||||
|
EOF
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 強隔離 on this Mac: macOS can't run gVisor directly, and OrbStack/Docker Desktop's Linux VM
|
||||||
|
# can't take it. This makes a small Ubuntu VM (Lima, Apple Virtualization) with its OWN Docker
|
||||||
|
# and gVisor (runsc): Bot 的電腦 runs there on a virtualized kernel, verified by a sandboxed
|
||||||
|
# Chromium — the Mac's kernel is a whole VM away.
|
||||||
|
#
|
||||||
|
# ./gvisor-vm.sh create + start the VM (idempotent; re-run to fix/update)
|
||||||
|
# ./gvisor-vm.sh --rm remove the VM (the box's files live in a volume inside it)
|
||||||
|
#
|
||||||
|
# Afterwards, in AnyBot 設定 › 電腦 › 在哪裡執行:
|
||||||
|
# Docker 主機: ssh://lima-anybot-gvisor
|
||||||
|
# 容器隔離: 強隔離(gVisor)
|
||||||
|
# and press 更新 on the computer card. (AnyBot's Docker host accepts ssh://, the VNC viewer
|
||||||
|
# forwards through it.)
|
||||||
|
set -euo pipefail
|
||||||
|
VM=anybot-gvisor
|
||||||
|
CPUS=4
|
||||||
|
MEM=6 # GiB
|
||||||
|
DISK=60 # GiB
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--rm" ]; then
|
||||||
|
limactl delete --force "$VM" 2>/dev/null || true
|
||||||
|
echo "已移除 VM ${VM}。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v limactl >/dev/null 2>&1 || { echo "先裝 Lima:brew install lima"; exit 1; }
|
||||||
|
|
||||||
|
STATUS=$(limactl list "$VM" --format '{{.Status}}' 2>/dev/null || true)
|
||||||
|
if [ "$STATUS" = "Running" ]; then
|
||||||
|
echo "VM $VM 已在執行。"
|
||||||
|
elif [ -n "$STATUS" ]; then
|
||||||
|
echo "VM $VM 是 ${STATUS},啟動中…"
|
||||||
|
limactl start --tty=false "$VM"
|
||||||
|
else
|
||||||
|
echo "建立 VM ${VM}($CPUS CPU / ${MEM}GB / ${DISK}GB,第一次要下載映像檔,幾分鐘)…"
|
||||||
|
# template:docker-rootful = Lima's rootful Docker template (runsc wants a plain root daemon;
|
||||||
|
# the rootless docker template runs the daemon in a user namespace).
|
||||||
|
limactl start --name="$VM" --tty=false --cpus "$CPUS" --memory "$MEM" --disk "$DISK" template:docker-rootful
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VM 裡安裝 gVisor(runsc,systrap 平台:不需要 KVM,ARM VM 可用)…"
|
||||||
|
limactl shell "$VM" -- bash -ec '
|
||||||
|
set -e
|
||||||
|
if ! command -v runsc >/dev/null 2>&1; then
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list >/dev/null
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq runsc
|
||||||
|
fi
|
||||||
|
# Register the runtime in the VM'\''s Docker daemon (merge, do not clobber other settings).
|
||||||
|
# Restart Docker only when that changed: a restart stops Bot 的電腦 running in this VM.
|
||||||
|
CHANGED=$(sudo python3 - <<EOF
|
||||||
|
import json, os
|
||||||
|
p = "/etc/docker/daemon.json"
|
||||||
|
d = json.load(open(p)) if os.path.exists(p) else {}
|
||||||
|
if d.get("runtimes", {}).get("runsc", {}).get("path") != "runsc":
|
||||||
|
d.setdefault("runtimes", {})["runsc"] = {"path": "runsc"}
|
||||||
|
os.makedirs("/etc/docker", exist_ok=True)
|
||||||
|
json.dump(d, open(p, "w"), indent=2)
|
||||||
|
print("changed")
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
if [ "$CHANGED" = changed ]; then sudo systemctl restart docker; fi
|
||||||
|
# Prove the wall works: a container whose kernel really is gVisor.
|
||||||
|
docker run --rm --runtime=runsc --entrypoint sh alpine:3.20 -c "grep -q gVisor /proc/version && uname -r" | sed "s/^/runsc 容器核心: /"
|
||||||
|
'
|
||||||
|
|
||||||
|
# Bot 的電腦 lives in this VM: start it with the Mac, or after a restart the computer is gone
|
||||||
|
# until someone runs this again.
|
||||||
|
limactl autostart enable "$VM" >/dev/null 2>&1 || limactl start-at-login "$VM" --enabled >/dev/null 2>&1 || echo "(沒能設定開機自動啟動:Mac 重開機後要再跑一次這個腳本)"
|
||||||
|
|
||||||
|
# Lima forwards the VM's Docker socket to the Mac: AnyBot's Docker 主機用這個 unix:// 位址,
|
||||||
|
# 不用 ssh、不用碰 ~/.ssh/config。
|
||||||
|
SOCK="$HOME/.lima/$VM/sock/docker.sock"
|
||||||
|
DOCKER_HOST="unix://$SOCK" docker info --format '{{range $k, $v := .Runtimes}}{{$k}} {{end}}' | grep -qw runsc || { echo "從 Mac 連到 VM 的 Docker 看不到 runsc"; exit 1; }
|
||||||
|
KERNEL=$(DOCKER_HOST="unix://$SOCK" docker run --rm --runtime=runsc --entrypoint cat alpine:3.20 /proc/version | cut -d' ' -f3)
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
都好了(從 Mac 驗證:runsc 容器的核心是 ${KERNEL})。AnyBot 設定 › 電腦 › 在哪裡執行:
|
||||||
|
Docker 主機: unix://$SOCK
|
||||||
|
容器隔離: 強隔離(gVisor)
|
||||||
|
然後在電腦卡片按「建立/更新」。要讓舊 box 的檔案與登入跟著走,
|
||||||
|
先完全退出 AnyBot、跑同資料夾的 gvisor-migrate.sh,再回來開。
|
||||||
|
EOF
|
||||||
|
|
@ -42,6 +42,13 @@ services:
|
||||||
args: { JOBS: "${ANYBOT_BUILD_JOBS:-}" }
|
args: { JOBS: "${ANYBOT_BUILD_JOBS:-}" }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on: [relay]
|
depends_on: [relay]
|
||||||
|
# A second wall for the bots' computer (optional, Linux): install gVisor on this host
|
||||||
|
# (see gvisor.dev/docs "Installation" for the apt repository + `apt-get install runsc`,
|
||||||
|
# then register it in /etc/docker/daemon.json: {"runtimes": {"runsc": {"path": "runsc"}}}
|
||||||
|
# and `systemctl restart docker`). In AnyBot: 設定 › 電腦 › 在哪裡執行 › 容器隔離 →
|
||||||
|
# 「強隔離(gVisor)」, then rebuild the computer (更新). The box gets a virtualized
|
||||||
|
# kernel and Chromium's own sandbox comes back on. On a host without runsc the box won't
|
||||||
|
# start — switch 容器隔離 back to 標準.
|
||||||
environment:
|
environment:
|
||||||
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
|
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
|
||||||
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
|
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
|
||||||
|
|
||||||
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
|
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
|
||||||
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。
|
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。
|
||||||
- **Bot 的電腦**:放在容器裡,和你的電腦分開。
|
- **Bot 的電腦**:放在容器裡,和你的電腦分開。標準(runc)容器和主機共用 Linux 核心,Chromium 在裡面關掉自己的沙盒,容器就是唯一那道牆。Docker 主機裝了 gVisor 時,設定 › 電腦 › 容器隔離可改「強隔離(gVisor)」:容器有自己的虛擬核心,Chromium 的沙盒重新開啟,牆變成兩道(虛擬核心+瀏覽器沙盒)。改完要按電腦的「更新」重建才生效。在 macOS 上沒有能裝 gVisor 的 Docker,用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機,再把 Docker 主機指過去。
|
||||||
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
|
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
|
||||||
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
|
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
|
||||||
|
|
||||||
|
|
|
||||||
304
src/agent.rs
304
src/agent.rs
|
|
@ -61,6 +61,8 @@ pub struct Job {
|
||||||
pub review: Option<Review>,
|
pub review: Option<Review>,
|
||||||
/// 示範教學 this bot has learned (for view_demo_step).
|
/// 示範教學 this bot has learned (for view_demo_step).
|
||||||
pub demos: Vec<crate::teach::Demo>,
|
pub demos: Vec<crate::teach::Demo>,
|
||||||
|
/// The skills there are (a longer job is looked back on against them; see learn).
|
||||||
|
pub skills: Vec<crate::memory::Skill>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
|
|
@ -103,6 +105,37 @@ fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 自己進化: after a longer job, the model looks back on its steps and keeps the way that worked
|
||||||
|
/// as a skill (or improves the one it followed), so the next time takes the short path. It runs
|
||||||
|
/// after Done: the person isn't kept waiting.
|
||||||
|
fn learn(job: &Job, history: &[Message], cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) {
|
||||||
|
let Some(start) = history.iter().rposition(|m| m.role == Role::User && !m.steer) else { return };
|
||||||
|
let turn = &history[start..];
|
||||||
|
if !crate::memory::worth_learning(turn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let req = ChatRequest {
|
||||||
|
system: crate::memory::LEARN_SYSTEM.into(),
|
||||||
|
history: vec![Message::user(&crate::memory::learn_brief(turn, &job.skills))],
|
||||||
|
tools: vec![],
|
||||||
|
..job.req.clone()
|
||||||
|
};
|
||||||
|
let out = match providers::run_step(&req, cancel, &|_| {}) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("AnyBot:整理做法時模型沒有回應:{e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match (crate::memory::learned_skill(&out.text), turn.last()) {
|
||||||
|
(Some(k), Some(reply)) => {
|
||||||
|
eprintln!("AnyBot:學到做法「{}」", k.name);
|
||||||
|
send(StreamEvent::Learned(k, reply.at));
|
||||||
|
}
|
||||||
|
_ => eprintln!("AnyBot:這次的做法沒有存:{}", out.text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(200).collect::<String>()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Take what the person typed mid-turn and add it to the chat as steering.
|
/// Take what the person typed mid-turn and add it to the chat as steering.
|
||||||
fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)) -> bool {
|
fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)) -> bool {
|
||||||
let queued: Vec<String> = std::mem::take(&mut *job.steer.lock().unwrap());
|
let queued: Vec<String> = std::mem::take(&mut *job.steer.lock().unwrap());
|
||||||
|
|
@ -121,6 +154,76 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
|
||||||
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want))
|
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One of the bot's own browsers (where, and which profile).
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||||
|
enum BotBrowser {
|
||||||
|
Here(String),
|
||||||
|
Box(String),
|
||||||
|
Device(String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many running jobs are using each bot browser (bots share the box's Chromium).
|
||||||
|
static BROWSER_JOBS: Mutex<Vec<BotBrowser>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// The bot's browsers a job used. When the job ends — answered, stopped or failed — each closes
|
||||||
|
/// the tabs the job left open and keeps the page it ended on (every browser_open is a new tab;
|
||||||
|
/// nothing used to close them, and the box ran out of memory with 19). A browser another job is
|
||||||
|
/// still using is left alone.
|
||||||
|
struct UsedBrowsers {
|
||||||
|
list: Vec<BotBrowser>,
|
||||||
|
via_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A job starts using a bot browser.
|
||||||
|
fn claim(b: &BotBrowser) {
|
||||||
|
BROWSER_JOBS.lock().unwrap().push(b.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A job is done with a bot browser: true when another job is still using it.
|
||||||
|
fn release(b: &BotBrowser) -> bool {
|
||||||
|
let mut jobs = BROWSER_JOBS.lock().unwrap();
|
||||||
|
if let Some(i) = jobs.iter().position(|x| x == b) {
|
||||||
|
jobs.remove(i);
|
||||||
|
}
|
||||||
|
jobs.contains(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsedBrowsers {
|
||||||
|
fn add(&mut self, b: BotBrowser) {
|
||||||
|
if !self.list.contains(&b) {
|
||||||
|
claim(&b);
|
||||||
|
self.list.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_tabs(&mut self) {
|
||||||
|
for b in std::mem::take(&mut self.list) {
|
||||||
|
if release(&b) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let args = |p: &str| if p.is_empty() { r#"{"action":"tidy"}"#.to_string() } else { serde_json::json!({"action": "tidy", "profile": p}).to_string() };
|
||||||
|
let never = AtomicBool::new(false);
|
||||||
|
match &b {
|
||||||
|
BotBrowser::Here(p) => {
|
||||||
|
let _ = web::run_full("browser_tabs", &args(p), &never);
|
||||||
|
}
|
||||||
|
BotBrowser::Box(p) => {
|
||||||
|
let _ = crate::botbrowser::in_box(|| web::run_full("browser_tabs", &args(p), &never));
|
||||||
|
}
|
||||||
|
BotBrowser::Device(host, p) => {
|
||||||
|
let _ = crate::remote::run_on_device(host, "browser_tabs", &args(p), &self.via_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UsedBrowsers {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.close_tabs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum BrowserAt {
|
enum BrowserAt {
|
||||||
Here,
|
Here,
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -542,6 +645,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// The screen and browser steps of this turn (to stop the same step being tried over and over).
|
// The screen and browser steps of this turn (to stop the same step being tried over and over).
|
||||||
let mut tried: Vec<String> = Vec::new();
|
let mut tried: Vec<String> = Vec::new();
|
||||||
let mut local_on = job.local_execution;
|
let mut local_on = job.local_execution;
|
||||||
|
let mut browsers = UsedBrowsers { list: vec![], via_name: job.via_name.clone() };
|
||||||
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
|
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
|
||||||
for step in 0..MAX_STEPS {
|
for step in 0..MAX_STEPS {
|
||||||
if step > 0 {
|
if step > 0 {
|
||||||
|
|
@ -593,6 +697,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// Something typed while it answered keeps the same turn going.
|
// Something typed while it answered keeps the same turn going.
|
||||||
if job.steer.lock().unwrap().is_empty() {
|
if job.steer.lock().unwrap().is_empty() {
|
||||||
send(StreamEvent::Done);
|
send(StreamEvent::Done);
|
||||||
|
browsers.close_tabs();
|
||||||
|
learn(&job, &history, &cancel, &send);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -673,6 +779,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
|
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
|
||||||
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
|
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
||||||
|
if call.name != "web_fetch" {
|
||||||
|
let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string();
|
||||||
|
browsers.add(match &place {
|
||||||
|
BrowserAt::Here => BotBrowser::Here(profile),
|
||||||
|
BrowserAt::Box => BotBrowser::Box(profile),
|
||||||
|
BrowserAt::Device(host) => BotBrowser::Device(host.clone(), profile),
|
||||||
|
});
|
||||||
|
}
|
||||||
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
||||||
let (output, ok, img) = match place {
|
let (output, ok, img) = match place {
|
||||||
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
|
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
|
||||||
|
|
@ -1284,6 +1398,7 @@ mod tests {
|
||||||
steer: Arc::new(Mutex::new(vec![])),
|
steer: Arc::new(Mutex::new(vec![])),
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
|
skills: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1452,6 +1567,191 @@ mod tests {
|
||||||
assert!(results[2].contains("盒子裡的測試頁 42"), "{}", results[2]);
|
assert!(results[2].contains("盒子裡的測試頁 42"), "{}", results[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The box's browser works with the box's own disk (the brain runs elsewhere): an upload
|
||||||
|
/// finds the file there, a download and a PDF land there, and old tabs close. Needs Docker
|
||||||
|
/// and the box: `cargo test the_box_browser_uses_the_box_disk -- --ignored --nocapture`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn the_box_browser_uses_the_box_disk() {
|
||||||
|
let own = crate::model::BOT_COMPUTER_NAME;
|
||||||
|
let setup = r#"mkdir -p /tmp/anybot-files /tmp/anybot-up && cd /tmp/anybot-files && cat > index.html <<'EOF'
|
||||||
|
<meta charset=utf-8><title>files</title><h1>檔案測試</h1>
|
||||||
|
<input type=file id=f onchange="document.getElementById('o').textContent='已選:'+this.files[0].name+' '+this.files[0].size">
|
||||||
|
<p id=o>還沒選</p><a href="report.csv" download>下載報表</a>
|
||||||
|
EOF
|
||||||
|
printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳測試.txt' && (setsid python3 -m http.server 8768 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"#;
|
||||||
|
let check = "ls /home/bot/Downloads/report.csv /home/bot/Downloads/files-*.pdf && head -c 4 /home/bot/Downloads/files-*.pdf && echo";
|
||||||
|
let pages = r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print("pages:", len([t for t in json.load(sys.stdin) if t["type"] == "page"]))'"#;
|
||||||
|
let clean = r#"pkill -f 'http[.]server 8768'; rm -rf /tmp/anybot-files /tmp/anybot-up /home/bot/Downloads/report.csv /home/bot/Downloads/files-*.pdf; curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys,urllib.request; [urllib.request.urlopen("http://127.0.0.1:9222/json/close/" + t["id"]) for t in json.load(sys.stdin) if ":8768" in t["url"]]'; echo cleaned"#;
|
||||||
|
let mut replies: Vec<(&'static str, serde_json::Value)> = vec![
|
||||||
|
("shell", serde_json::json!({"command": setup})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8768/"})),
|
||||||
|
("browser_upload", serde_json::json!({"path": "/tmp/anybot-up/上傳測試.txt"})),
|
||||||
|
("browser_read", serde_json::json!({})),
|
||||||
|
("browser_upload", serde_json::json!({"path": "/tmp/anybot-up/沒有這個.txt"})),
|
||||||
|
("browser_download", serde_json::json!({"text": "下載報表"})),
|
||||||
|
("browser_pdf", serde_json::json!({})),
|
||||||
|
("shell", serde_json::json!({"command": check})),
|
||||||
|
];
|
||||||
|
for n in 1..=8 {
|
||||||
|
replies.push(("browser_open", serde_json::json!({"url": format!("http://127.0.0.1:8768/?n={n}")})));
|
||||||
|
}
|
||||||
|
replies.push(("shell", serde_json::json!({"command": pages})));
|
||||||
|
replies.push(("shell", serde_json::json!({"command": clean})));
|
||||||
|
replies.push(("", serde_json::json!("好了")));
|
||||||
|
let port = fake_model(replies);
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("測試盒子裡的檔案")], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![tools::Target { name: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }],
|
||||||
|
default_computer: own.into(),
|
||||||
|
roles: tools::Roles { own: own.into(), person: None, server: true },
|
||||||
|
local_execution: false,
|
||||||
|
direct: Arc::new(AtomicBool::new(true)),
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
let mut results = vec![];
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(Duration::from_secs(180)).expect("loop stalled") {
|
||||||
|
(_, StreamEvent::ToolResult(m)) => {
|
||||||
|
println!("{}: {}\n", m.tool_name, m.text.chars().take(300).collect::<String>());
|
||||||
|
results.push(m.text)
|
||||||
|
}
|
||||||
|
(_, StreamEvent::Done) => break,
|
||||||
|
(_, StreamEvent::Error(e)) => panic!("{e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(results[0].contains("served"), "{}", results[0]);
|
||||||
|
assert!(results[1].contains("檔案測試"), "{}", results[1]);
|
||||||
|
assert!(results[2].contains("已把 /tmp/anybot-up/上傳測試.txt 放進上傳欄位,欄位裡現在是:上傳測試.txt(9 位元組)") && results[2].contains("已選:上傳測試.txt 9"), "{}", results[2]);
|
||||||
|
assert!(results[3].contains("已選:上傳測試.txt 9"), "{}", results[3]);
|
||||||
|
assert!(results[4].contains("這台電腦上沒有「/tmp/anybot-up/沒有這個.txt」"), "{}", results[4]);
|
||||||
|
assert!(results[5].contains("下載好了:/home/bot/Downloads/report.csv"), "{}", results[5]);
|
||||||
|
assert!(results[6].contains("已存成 PDF:/home/bot/Downloads/files-"), "{}", results[6]);
|
||||||
|
assert!(results[7].contains("%PDF") && !results[7].contains("No such file"), "{}", results[7]);
|
||||||
|
let count = results[16].split("pages:").nth(1).and_then(|n| n.trim().parse::<usize>().ok()).unwrap_or(99);
|
||||||
|
assert!(count <= 6, "{}", results[16]);
|
||||||
|
assert!(results[17].contains("cleaned"), "{}", results[17]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a job ends, the tabs it left open in the box's browser close; the page it ended on
|
||||||
|
/// stays. Needs the box: `cargo test a_finished_job_closes_its_tabs -- --ignored --nocapture`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn a_finished_job_closes_its_tabs() {
|
||||||
|
let own = crate::model::BOT_COMPUTER_NAME;
|
||||||
|
let setup = "mkdir -p /tmp/anybot-tabs && cd /tmp/anybot-tabs && echo '<meta charset=utf-8><title>tab</title>分頁' > index.html && (setsid python3 -m http.server 8770 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served";
|
||||||
|
let port = fake_model(vec![
|
||||||
|
("shell", serde_json::json!({"command": setup})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=1"})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=2"})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=3"})),
|
||||||
|
("", serde_json::json!("好了")),
|
||||||
|
]);
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("開三頁")], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![tools::Target { name: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }],
|
||||||
|
default_computer: own.into(),
|
||||||
|
roles: tools::Roles { own: own.into(), person: None, server: true },
|
||||||
|
local_execution: false,
|
||||||
|
direct: Arc::new(AtomicBool::new(true)),
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(Duration::from_secs(120)).expect("loop stalled") {
|
||||||
|
(_, StreamEvent::Done) => break,
|
||||||
|
(_, StreamEvent::Error(e)) => panic!("{e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
let box_sh = |script: &str| String::from_utf8_lossy(&std::process::Command::new("docker").args(["exec", crate::cloudbox::container(), "sh", "-c", script]).output().unwrap().stdout).to_string();
|
||||||
|
let left = box_sh(r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print("\n".join(t["url"] for t in json.load(sys.stdin) if t["type"] == "page"))'"#);
|
||||||
|
println!("left open:\n{left}");
|
||||||
|
box_sh(r#"pkill -f 'http[.]server 8770'; rm -rf /tmp/anybot-tabs"#);
|
||||||
|
assert_eq!(left.trim(), "http://127.0.0.1:8770/?n=3", "{left}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words reach the control they name on a form laid out like httpbin's (each label and its
|
||||||
|
/// input inside a <p>): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit
|
||||||
|
/// order」. They used to land on the <p> and do nothing. Needs the box:
|
||||||
|
/// `cargo test the_words_on_a_form_reach_its_controls -- --ignored --nocapture`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn the_words_on_a_form_reach_its_controls() {
|
||||||
|
let own = crate::model::BOT_COMPUTER_NAME;
|
||||||
|
let setup = r#"mkdir -p /tmp/anybot-form && cd /tmp/anybot-form && cat > index.html <<'EOF'
|
||||||
|
<meta charset=utf-8><title>form</title>
|
||||||
|
<form action="done.html" method="get">
|
||||||
|
<p><label>Customer name: <input name="custname"></label></p>
|
||||||
|
<p><label>Telephone: <input type=tel name="custtel"></label></p>
|
||||||
|
<fieldset><legend> Pizza Size </legend>
|
||||||
|
<p><label> <input type=radio name=size value="small"> Small </label></p>
|
||||||
|
<p><label> <input type=radio name=size value="large"> Large </label></p></fieldset>
|
||||||
|
<fieldset><legend> Pizza Toppings </legend>
|
||||||
|
<p><label> <input type=checkbox name="topping" value="cheese"> Extra Cheese </label></p>
|
||||||
|
<p><label> <input type=checkbox name="topping" value="onion"> Onion </label></p></fieldset>
|
||||||
|
<p><button>Submit order</button></p>
|
||||||
|
</form>
|
||||||
|
EOF
|
||||||
|
echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid python3 -m http.server 8769 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"#;
|
||||||
|
let clean = r#"pkill -f 'http[.]server 8769'; rm -rf /tmp/anybot-form; curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys,urllib.request; [urllib.request.urlopen("http://127.0.0.1:9222/json/close/" + t["id"]) for t in json.load(sys.stdin) if ":8769" in t["url"]]'; echo cleaned"#;
|
||||||
|
let port = fake_model(vec![
|
||||||
|
("shell", serde_json::json!({"command": setup})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8769/"})),
|
||||||
|
("browser_type", serde_json::json!({"text": "Customer name:", "value": "李大華"})),
|
||||||
|
("browser_type", serde_json::json!({"text": "Telephone", "value": "0922000111"})),
|
||||||
|
("browser_click", serde_json::json!({"text": "Large"})),
|
||||||
|
("browser_click", serde_json::json!({"text": "Extra Cheese"})),
|
||||||
|
("browser_click", serde_json::json!({"text": "Onion"})),
|
||||||
|
("browser_click", serde_json::json!({"text": "Submit order"})),
|
||||||
|
("shell", serde_json::json!({"command": clean})),
|
||||||
|
("", serde_json::json!("好了")),
|
||||||
|
]);
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("填表")], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![tools::Target { name: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }],
|
||||||
|
default_computer: own.into(),
|
||||||
|
roles: tools::Roles { own: own.into(), person: None, server: true },
|
||||||
|
local_execution: false,
|
||||||
|
direct: Arc::new(AtomicBool::new(true)),
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
let mut results = vec![];
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(Duration::from_secs(180)).expect("loop stalled") {
|
||||||
|
(_, StreamEvent::ToolResult(m)) => {
|
||||||
|
println!("{}: {}\n", m.tool_name, m.text.chars().take(240).collect::<String>());
|
||||||
|
results.push(m.text)
|
||||||
|
}
|
||||||
|
(_, StreamEvent::Done) => break,
|
||||||
|
(_, StreamEvent::Error(e)) => panic!("{e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(results[2].contains("欄位現在是「李大華」"), "{}", results[2]);
|
||||||
|
assert!(results[3].contains("欄位現在是「0922000111」"), "{}", results[3]);
|
||||||
|
let sent = &results[7];
|
||||||
|
assert!(sent.contains("done.html?custname=%E6%9D%8E%E5%A4%A7%E8%8F%AF&custtel=0922000111&size=large&topping=cheese&topping=onion"), "{sent}");
|
||||||
|
assert!(results[8].contains("cleaned"), "{}", results[8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_another_job_uses_keeps_its_tabs() {
|
||||||
|
let b = BotBrowser::Device("test-host-tabs".into(), String::new());
|
||||||
|
claim(&b);
|
||||||
|
claim(&b);
|
||||||
|
assert!(release(&b), "the other job still uses it");
|
||||||
|
assert!(!release(&b), "the last one out closes the tabs");
|
||||||
|
assert!(!release(&b));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_same_miss_counts_as_the_same_step() {
|
fn the_same_miss_counts_as_the_same_step() {
|
||||||
// Clicks a few pixels apart on the same strip are one step; another computer or target isn't.
|
// Clicks a few pixels apart on the same strip are one step; another computer or target isn't.
|
||||||
|
|
@ -1596,6 +1896,7 @@ mod tests {
|
||||||
steer: Arc::new(Mutex::new(vec![])),
|
steer: Arc::new(Mutex::new(vec![])),
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
|
skills: vec![],
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
|
@ -1668,6 +1969,7 @@ mod tests {
|
||||||
steer,
|
steer,
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
|
skills: vec![],
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
|
@ -1746,6 +2048,7 @@ mod tests {
|
||||||
steer: Arc::new(Mutex::new(vec![])),
|
steer: Arc::new(Mutex::new(vec![])),
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
|
skills: vec![],
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
|
@ -1885,6 +2188,7 @@ mod tests {
|
||||||
steer: Arc::new(Mutex::new(vec![])),
|
steer: Arc::new(Mutex::new(vec![])),
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
|
skills: vec![],
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
|
|
||||||
55
src/app.rs
55
src/app.rs
|
|
@ -554,10 +554,20 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.apply_theme(ctx);
|
app.apply_theme(ctx);
|
||||||
// Real-data screenshots can open 設定 › 手機與帳號 (to press its buttons with ANYBOT_SHOT_CLICK).
|
// Real-data screenshots can open 設定 › 手機與帳號 (to press its buttons with ANYBOT_SHOT_CLICK)
|
||||||
if app.screenshot.is_some() && !app.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("remote") {
|
// or 設定 › 電腦 (容器隔離 shows what the box really runs on).
|
||||||
app.show_settings = true;
|
if app.screenshot.is_some() && !app.demo {
|
||||||
app.settings_tab = SettingsTab::Remote;
|
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
|
||||||
|
Ok("remote") => {
|
||||||
|
app.show_settings = true;
|
||||||
|
app.settings_tab = SettingsTab::Remote;
|
||||||
|
}
|
||||||
|
Ok("computers") => {
|
||||||
|
app.show_settings = true;
|
||||||
|
app.settings_tab = SettingsTab::Computers;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// …or the side panel, or the computer viewer, as they look with that data.
|
// …or the side panel, or the computer viewer, as they look with that data.
|
||||||
if app.screenshot.is_some() && !app.demo {
|
if app.screenshot.is_some() && !app.demo {
|
||||||
|
|
@ -832,6 +842,7 @@ impl App {
|
||||||
steer,
|
steer,
|
||||||
review,
|
review,
|
||||||
demos: b.demos.clone(),
|
demos: b.demos.clone(),
|
||||||
|
skills: self.state.skills.clone(),
|
||||||
};
|
};
|
||||||
agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone());
|
agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone());
|
||||||
// (Its eyes start reading while it works; see face_pose.)
|
// (Its eyes start reading while it works; see face_pose.)
|
||||||
|
|
@ -1360,6 +1371,16 @@ impl App {
|
||||||
|
|
||||||
fn drain_events(&mut self, ctx: &egui::Context) {
|
fn drain_events(&mut self, ctx: &egui::Context) {
|
||||||
while let Ok((id, ev)) = self.rx.try_recv() {
|
while let Ok((id, ev)) = self.rx.try_recv() {
|
||||||
|
// 自己進化 arrives after the turn has ended: keep the skill, say so under the reply.
|
||||||
|
if let StreamEvent::Learned(k, reply_at) = ev {
|
||||||
|
let name = k.name.clone();
|
||||||
|
let fresh = crate::memory::upsert_skill(&mut self.state.skills, k);
|
||||||
|
if let Some(m) = self.state.bot_mut(id).and_then(|b| b.messages.iter_mut().rev().find(|m| m.role == Role::Assistant && m.at == reply_at)) {
|
||||||
|
m.text.push_str(&crate::memory::learned_note(&name, fresh));
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// A stopped request may still deliver a few pieces; ignore them.
|
// A stopped request may still deliver a few pieces; ignore them.
|
||||||
if !self.streams.contains_key(&id) {
|
if !self.streams.contains_key(&id) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -3560,6 +3581,20 @@ impl App {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
let secs = t0.elapsed().as_secs();
|
let secs = t0.elapsed().as_secs();
|
||||||
|
// 自己進化 comes after the turn: wait for it when the turn was long enough to learn from.
|
||||||
|
let turn_msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
|
||||||
|
if crate::memory::worth_learning(&turn_msgs) {
|
||||||
|
let before = app.state.skills.clone();
|
||||||
|
let t1 = Instant::now();
|
||||||
|
while app.state.skills == before && t1.elapsed().as_secs() < 180 {
|
||||||
|
app.drain_events(ctx);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
match app.state.skills.iter().find(|k| !before.contains(k)) {
|
||||||
|
Some(k) => println!("📚 學到({} 秒):{} — {}\n{}\n", t1.elapsed().as_secs(), k.name, k.description, k.instructions),
|
||||||
|
None => println!("📚 這次沒有存技能({} 秒)", t1.elapsed().as_secs()),
|
||||||
|
}
|
||||||
|
}
|
||||||
// The chat can shrink (compaction) between the snapshot and now: slice safely.
|
// The chat can shrink (compaction) between the snapshot and now: slice safely.
|
||||||
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
|
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
|
||||||
let clip = |s: &str, n: usize| -> String {
|
let clip = |s: &str, n: usize| -> String {
|
||||||
|
|
@ -3691,6 +3726,18 @@ impl eframe::App for App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ANYBOT_SHOT_SCROLL="x,y,dy@frame": from that frame on, a wheel step of dy over x,y on
|
||||||
|
// each of 100 frames (egui eases a wheel in over real time; screenshot frames are quick).
|
||||||
|
if let Ok(sc) = std::env::var("ANYBOT_SHOT_SCROLL") {
|
||||||
|
if let Some((v, at)) = sc.split_once('@') {
|
||||||
|
let n: Vec<f32> = v.split(',').filter_map(|x| x.trim().parse().ok()).collect();
|
||||||
|
let (at, f) = (at.trim().parse::<u32>().unwrap_or(30), self.frames + 1);
|
||||||
|
if n.len() == 3 && f >= at && f < at + 100 {
|
||||||
|
raw.events.push(egui::Event::PointerMoved(egui::pos2(n[0], n[1])));
|
||||||
|
raw.events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, n[2]), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers: Default::default() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let Ok(spec) = std::env::var("ANYBOT_SHOT_CLICK") else { return };
|
let Ok(spec) = std::env::var("ANYBOT_SHOT_CLICK") else { return };
|
||||||
for one in spec.split(';') {
|
for one in spec.split(';') {
|
||||||
let (pos, right) = match one.strip_suffix(":right") { Some(p) => (p, true), None => (one, false) };
|
let (pos, right) = match one.strip_suffix(":right") { Some(p) => (p, true), None => (one, false) };
|
||||||
|
|
|
||||||
|
|
@ -356,7 +356,7 @@ impl App {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setting_card(ui, p, |ui| {
|
setting_card(ui, p, |ui| {
|
||||||
setting_title(ui, "技能", "所有 Bot 共用的做法說明。在聊天裡教 Bot 一套做法並請它「存成技能」;Bot 需要時會先讀取再照做。", p);
|
setting_title(ui, "技能", "所有 Bot 共用的做法。Bot 做完較長的事,會自己把成功的做法整理成技能、下次照著做,做得更好時也會更新;你也可以在聊天裡教它一套做法。", p);
|
||||||
if self.state.skills.is_empty() {
|
if self.state.skills.is_empty() {
|
||||||
hint(ui, "還沒有技能。", p);
|
hint(ui, "還沒有技能。", p);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ pub struct ComputerUi {
|
||||||
replace_secret: Option<String>,
|
replace_secret: Option<String>,
|
||||||
secret_error: String,
|
secret_error: String,
|
||||||
docker_host: Option<String>,
|
docker_host: Option<String>,
|
||||||
|
/// 容器隔離 row: "runc" (standard) or "runsc" (gVisor).
|
||||||
|
box_runtime: String,
|
||||||
|
/// Checked in the background: the Docker host has gVisor, and the runtime the box runs on.
|
||||||
|
runtime_facts: Arc<Mutex<Option<RuntimeFacts>>>,
|
||||||
show_log: bool,
|
show_log: bool,
|
||||||
teach_name: String,
|
teach_name: String,
|
||||||
teach_for: Option<u64>,
|
teach_for: Option<u64>,
|
||||||
|
|
@ -51,6 +55,9 @@ pub struct ComputerUi {
|
||||||
thumb_at: Option<Instant>,
|
thumb_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (the Docker host has gVisor, the runtime the box runs on now)
|
||||||
|
type RuntimeFacts = (bool, Option<String>);
|
||||||
|
|
||||||
fn status_color(r: &Run, p: Palette) -> Color32 {
|
fn status_color(r: &Run, p: Palette) -> Color32 {
|
||||||
match r {
|
match r {
|
||||||
Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70),
|
Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70),
|
||||||
|
|
@ -97,7 +104,11 @@ impl App {
|
||||||
if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) {
|
if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) {
|
||||||
self.cu.update_checked = Some(Instant::now());
|
self.cu.update_checked = Some(Instant::now());
|
||||||
let flag = self.cu.update_flag.clone();
|
let flag = self.cu.update_flag.clone();
|
||||||
std::thread::spawn(move || flag.store(cloudbox::update_available(), Ordering::SeqCst));
|
let facts = self.cu.runtime_facts.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
flag.store(cloudbox::update_available(), Ordering::SeqCst);
|
||||||
|
*facts.lock().unwrap() = Some((cloudbox::host_has_gvisor(), cloudbox::running_runtime()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst);
|
self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst);
|
||||||
ui.spacing_mut().item_spacing.y = 0.0;
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
|
@ -616,6 +627,40 @@ impl App {
|
||||||
}
|
}
|
||||||
ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace));
|
ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace));
|
||||||
});
|
});
|
||||||
|
let rt_now = cloudbox::box_runtime();
|
||||||
|
let facts = self.cu.runtime_facts.lock().unwrap().clone();
|
||||||
|
// What the box really runs on, and what pressing 更新 would change.
|
||||||
|
let status = match &facts {
|
||||||
|
Some((false, _)) if rt_now == "runsc" => "這台 Docker 主機沒有裝 gVisor:會用標準隔離開機".to_string(),
|
||||||
|
Some((_, Some(running))) if *running != rt_now => format!("目前是{};按電腦的「更新」重建後改成{}", if running == "runsc" { "強隔離" } else { "標準" }, if rt_now == "runsc" { "強隔離" } else { "標準" }),
|
||||||
|
Some((_, Some(running))) => format!("目前是{}", if running == "runsc" { "強隔離(gVisor)" } else { "標準" }),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let gvisor_here = facts.as_ref().map(|f| f.0).unwrap_or(false);
|
||||||
|
sec_row_s(ui, p, "容器隔離", &status, "標準:容器和這台 Docker 主機共用 Linux 核心,Bot 開網頁時只有一層牆。Docker 主機裝了 gVisor 才能選強隔離:電腦得到自己的虛擬核心,Chromium 的沙盒也會打開,多一道牆。改完按電腦的「更新」重建後生效。", |ui| {
|
||||||
|
let rt = if self.cu.box_runtime.is_empty() { rt_now.clone() } else { self.cu.box_runtime.clone() };
|
||||||
|
let std_on = rt == "runc";
|
||||||
|
let gvis_on = rt == "runsc";
|
||||||
|
// (The row lays out right to left: 強隔離 first puts 標準 on its left.)
|
||||||
|
let g = ui.add_enabled_ui(gvisor_here || gvis_on, |ui| ui.selectable_label(gvis_on, t("強隔離(gVisor)"))).inner;
|
||||||
|
if g.clicked() {
|
||||||
|
self.cu.box_runtime = "runsc".into();
|
||||||
|
}
|
||||||
|
g.on_disabled_hover_text(t("這台 Docker 主機沒有裝 gVisor(runsc)"));
|
||||||
|
if ui.selectable_label(std_on, t("標準")).clicked() {
|
||||||
|
self.cu.box_runtime = "runc".into();
|
||||||
|
}
|
||||||
|
if !self.cu.box_runtime.is_empty() && self.cu.box_runtime != rt_now {
|
||||||
|
if ui.add(primary_button("套用", p)).clicked() {
|
||||||
|
cloudbox::set_box_runtime(&self.cu.box_runtime);
|
||||||
|
self.cu.box_runtime.clear();
|
||||||
|
self.cu.update_checked = None;
|
||||||
|
}
|
||||||
|
if ui.add(egui::Button::new(RichText::new(t("取消")).size(FONT_XS).color(p.text3)).frame(false)).clicked() {
|
||||||
|
self.cu.box_runtime.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if apply {
|
if apply {
|
||||||
cloudbox::set_docker_host(edit.trim());
|
cloudbox::set_docker_host(edit.trim());
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,10 @@ 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"),
|
||||||
|
// Which skill (the phone showed a bare 「刪除技能」), and which memory.
|
||||||
|
"save_skill" | "use_skill" | "delete_skill" => s("name"),
|
||||||
|
"remember" => s("fact").chars().take(40).collect(),
|
||||||
|
"forget" => s("what"),
|
||||||
n if crate::pages::NAMES.contains(&n) => crate::pages::describe(n, &v),
|
n if crate::pages::NAMES.contains(&n) => crate::pages::describe(n, &v),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -425,11 +429,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
||||||
if name.is_empty() || instructions.trim().is_empty() {
|
if name.is_empty() || instructions.trim().is_empty() {
|
||||||
return err("技能要有名稱和做法".into());
|
return err("技能要有名稱和做法".into());
|
||||||
}
|
}
|
||||||
let k = crate::memory::Skill { name: name.clone(), description: s("description").unwrap_or_default(), instructions };
|
crate::memory::upsert_skill(&mut state.skills, crate::memory::Skill { name: name.clone(), description: s("description").unwrap_or_default(), instructions });
|
||||||
match state.skills.iter_mut().find(|x| x.name == name) {
|
|
||||||
Some(x) => *x = k,
|
|
||||||
None => state.skills.push(k),
|
|
||||||
}
|
|
||||||
ok(format!("已存成技能「{name}」,所有 Bot 都能用。"))
|
ok(format!("已存成技能「{name}」,所有 Bot 都能用。"))
|
||||||
}
|
}
|
||||||
"use_skill" => {
|
"use_skill" => {
|
||||||
|
|
@ -598,6 +598,14 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_step_on_skills_and_memories_names_which() {
|
||||||
|
assert_eq!(describe("delete_skill", r#"{"name":"本地活動報名"}"#), "本地活動報名");
|
||||||
|
assert_eq!(describe("use_skill", r#"{"name":"Threads 發文"}"#), "Threads 發文");
|
||||||
|
assert_eq!(describe("remember", r#"{"fact":"之後都用自己的電腦"}"#), "之後都用自己的電腦");
|
||||||
|
assert_eq!(describe("forget", r#"{"what":"2"}"#), "2");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_sets_up_and_manages_routines() {
|
fn chat_sets_up_and_manages_routines() {
|
||||||
let mut st = State::samples();
|
let mut st = State::samples();
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,14 @@ pub fn using_box() -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
|
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
|
||||||
|
/// The debugging port of a bot browser that is already open (None when it isn't; never starts one).
|
||||||
|
pub fn running_port(profile: Option<&str>) -> Option<u16> {
|
||||||
|
if using_box() {
|
||||||
|
return crate::cloudbox::browser_running().then(crate::cloudbox::browser_port).and_then(Result::ok);
|
||||||
|
}
|
||||||
|
live_port(&resolve(profile).ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
|
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
|
||||||
if using_box() {
|
if using_box() {
|
||||||
let fresh = !crate::cloudbox::browser_running();
|
let fresh = !crate::cloudbox::browser_running();
|
||||||
|
|
@ -317,6 +325,7 @@ pub fn open(url: &str, profile: Option<&str>) -> Result<String, String> {
|
||||||
if r.status().as_u16() >= 400 {
|
if r.status().as_u16() >= 400 {
|
||||||
return Err(format!("{name} 沒辦法開新分頁({})", r.status()));
|
return Err(format!("{name} 沒辦法開新分頁({})", r.status()));
|
||||||
}
|
}
|
||||||
|
close_old_tabs(port);
|
||||||
}
|
}
|
||||||
// Back when the page has loaded (not after a fixed 3 s: a slow page was still blank then),
|
// Back when the page has loaded (not after a fixed 3 s: a slow page was still blank then),
|
||||||
// with what it shows — the same as browser_read, one step fewer for every page.
|
// with what it shows — the same as browser_read, one step fewer for every page.
|
||||||
|
|
@ -326,6 +335,23 @@ pub fn open(url: &str, profile: Option<&str>) -> Result<String, String> {
|
||||||
Ok(if page.is_empty() { head } else { format!("{head}\n\n{page}") })
|
Ok(if page.is_empty() { head } else { format!("{head}\n\n{page}") })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most tabs the bot's browser keeps. Every browser_open is a new tab and nothing closed them:
|
||||||
|
/// Bot 的電腦 once had 19 Threads pages open and its whole 2 GB in use (estimated).
|
||||||
|
const MAX_TABS: usize = 6;
|
||||||
|
|
||||||
|
/// Close the tabs past MAX_TABS that were used longest ago (Chrome lists the latest first).
|
||||||
|
fn close_old_tabs(port: u16) {
|
||||||
|
let Ok(list) = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string()).and_then(|r| r.into_body().read_json::<Value>().map_err(|e| e.to_string())) else { return };
|
||||||
|
for id in old_tabs(&list) {
|
||||||
|
let _ = local().get(&endpoint(port, &format!("/json/close/{id}"))).call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page tabs past MAX_TABS in a /json/list answer (latest-used first).
|
||||||
|
fn old_tabs(list: &Value) -> Vec<String> {
|
||||||
|
list.as_array().map(|a| a.iter().filter(|t| t["type"] == "page").skip(MAX_TABS).filter_map(|t| t["id"].as_str().map(String::from)).collect()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Longest wait for a page browser_open opened (estimated).
|
/// Longest wait for a page browser_open opened (estimated).
|
||||||
const OPEN_WAIT_MS: u64 = 12_000;
|
const OPEN_WAIT_MS: u64 = 12_000;
|
||||||
/// Text of the page handed back by browser_open (browser_read gives more when asked).
|
/// Text of the page handed back by browser_open (browser_read gives more when asked).
|
||||||
|
|
@ -431,6 +457,16 @@ mod tests {
|
||||||
println!("{page}");
|
println!("{page}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_tabs_used_longest_ago_close() {
|
||||||
|
let mut list: Vec<Value> = (0..9).map(|i| serde_json::json!({"type": "page", "id": format!("p{i}")})).collect();
|
||||||
|
list.insert(2, serde_json::json!({"type": "browser_ui", "id": "ui"}));
|
||||||
|
list.insert(4, serde_json::json!({"type": "service_worker", "id": "sw"}));
|
||||||
|
assert_eq!(old_tabs(&Value::Array(list.clone())), vec!["p6", "p7", "p8"]);
|
||||||
|
list.truncate(5);
|
||||||
|
assert!(old_tabs(&Value::Array(list)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn urls_are_encoded_for_new_tabs() {
|
fn urls_are_encoded_for_new_tabs() {
|
||||||
assert_eq!(encode_component("https://www.threads.net/@me?x=1&y=2"), "https://www.threads.net/%40me%3Fx%3D1%26y%3D2");
|
assert_eq!(encode_component("https://www.threads.net/@me?x=1&y=2"), "https://www.threads.net/%40me%3Fx%3D1%26y%3D2");
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,20 @@ fn find_js(r#ref: &str, text: &str) -> String {
|
||||||
const vis = e => {{ const r = e.getBoundingClientRect(); return r.width > 1 && r.height > 1; }};
|
const vis = e => {{ const r = e.getBoundingClientRect(); return r.width > 1 && r.height > 1; }};
|
||||||
const all = [...document.querySelectorAll('a,button,[role],input,select,textarea,label,summary,li,td,th,span,div,p,img')].filter(vis);
|
const all = [...document.querySelectorAll('a,button,[role],input,select,textarea,label,summary,li,td,th,span,div,p,img')].filter(vis);
|
||||||
const hit = e => [t(e.innerText), t(e.getAttribute('aria-label')), t(e.getAttribute('placeholder')), t(e.title), t(e.value), e.labels && e.labels[0] ? t(e.labels[0].innerText) : ''];
|
const hit = e => [t(e.innerText), t(e.getAttribute('aria-label')), t(e.getAttribute('placeholder')), t(e.title), t(e.value), e.labels && e.labels[0] ? t(e.labels[0].innerText) : ''];
|
||||||
return all.find(e => hit(e).includes(tx)) || all.filter(e => hit(e).some(h => h.includes(tx))).sort((a, b) => t(a.innerText).length - t(b.innerText).length)[0] || null;
|
// The control the words name, not the box around it: 「Customer name:」 is a <p> holding a
|
||||||
|
// label and its input, 「Submit order」 a <p> holding the button (its middle is empty page).
|
||||||
|
const ctl = 'a,button,input,select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=switch],[role=tab],[role=menuitem],[role=option],[role=textbox],[role=combobox],[contenteditable=""],[contenteditable=true]';
|
||||||
|
const isCtl = e => e.matches(ctl);
|
||||||
|
const inner = e => {{
|
||||||
|
if (isCtl(e)) return e;
|
||||||
|
if (e.tagName === 'LABEL' && e.control && vis(e.control)) return e.control;
|
||||||
|
const c = [...e.querySelectorAll(ctl)].filter(vis);
|
||||||
|
return c.length === 1 ? c[0] : e;
|
||||||
|
}};
|
||||||
|
const shortest = l => l.slice().sort((a, b) => t(a.innerText).length - t(b.innerText).length)[0];
|
||||||
|
const pick = l => l.find(isCtl) || l.map(inner).find(isCtl) || shortest(l) || null;
|
||||||
|
const exact = all.filter(e => hit(e).includes(tx));
|
||||||
|
return exact.length ? pick(exact) : pick(all.filter(e => hit(e).some(h => h.includes(tx))));
|
||||||
}})()"#
|
}})()"#
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -704,31 +717,53 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option<String>), String>
|
||||||
}
|
}
|
||||||
"browser_upload" => {
|
"browser_upload" => {
|
||||||
let path = s(args, "path");
|
let path = s(args, "path");
|
||||||
let full = crate::tools::expand_home(&path);
|
// The browser opens the file from its own disk: Bot 的電腦's when it runs there.
|
||||||
if !full.is_file() {
|
let in_box = botbrowser::using_box();
|
||||||
|
let full = if in_box { crate::cloudbox::box_path(&path) } else { crate::tools::expand_home(&path).display().to_string() };
|
||||||
|
let there = if in_box { crate::cloudbox::box_has_file(&full) } else { std::path::Path::new(&full).is_file() };
|
||||||
|
if !there {
|
||||||
return Err(format!("這台電腦上沒有「{path}」這個檔案"));
|
return Err(format!("這台電腦上沒有「{path}」這個檔案"));
|
||||||
}
|
}
|
||||||
let js = format!("(() => {{ let el = {}; if (el && el.tagName !== 'INPUT') el = el.querySelector('input[type=file]') || el.closest('label')?.querySelector('input[type=file]'); return el || document.querySelector('input[type=file]'); }})()", find_js(&rf, &text));
|
let js = format!("(() => {{ let el = {}; if (el && el.tagName !== 'INPUT') el = el.querySelector('input[type=file]') || el.closest('label')?.querySelector('input[type=file]'); return el || document.querySelector('input[type=file]'); }})()", find_js(&rf, &text));
|
||||||
let r = tab.call("Runtime.evaluate", json!({"expression": js, "returnByValue": false}))?;
|
let r = tab.call("Runtime.evaluate", json!({"expression": js, "returnByValue": false}))?;
|
||||||
let obj = r["result"]["objectId"].as_str().ok_or("這一頁找不到上傳檔案的欄位")?.to_string();
|
let obj = r["result"]["objectId"].as_str().ok_or("這一頁找不到上傳檔案的欄位")?.to_string();
|
||||||
tab.call("DOM.setFileInputFiles", json!({"files": [full.display().to_string()], "objectId": obj}))?;
|
let before = snapshot(&mut tab);
|
||||||
|
tab.call("DOM.setFileInputFiles", json!({"files": [full], "objectId": obj}))?;
|
||||||
tab.eval(&format!("(() => {{ const el = {js}; if (el) el.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()"))?;
|
tab.eval(&format!("(() => {{ const el = {js}; if (el) el.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()"))?;
|
||||||
settle(1000);
|
settle(1000);
|
||||||
Ok((format!("已把 {} 放進上傳欄位。", full.display()), None))
|
// What the field holds now (a page that shows nothing about it left the bot
|
||||||
|
// checking with five more steps), and what the page did with it.
|
||||||
|
let held = tab.eval(&format!("(() => {{ const el = {js}; return el && el.files ? Array.from(el.files).map(f => f.name + '(' + f.size + ' 位元組)').join('、') : ''; }})()")).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default();
|
||||||
|
if held.is_empty() {
|
||||||
|
return Err(format!("放了 {full},但上傳欄位還是空的(這一頁可能要先按「上傳」之類的按鈕,或欄位不是這一個)。"));
|
||||||
|
}
|
||||||
|
let after = snapshot(&mut tab);
|
||||||
|
let seen = changes(&before, &after);
|
||||||
|
let seen = if seen.contains("沒有變化") { String::new() } else { format!("\n{seen}") };
|
||||||
|
Ok((format!("已把 {full} 放進上傳欄位,欄位裡現在是:{held}。{seen}"), None))
|
||||||
}
|
}
|
||||||
"browser_download" => {
|
"browser_download" => {
|
||||||
let dir = downloads_dir();
|
// The file lands on the browser's own disk: Bot 的電腦's when it runs there.
|
||||||
|
let in_box = botbrowser::using_box();
|
||||||
|
let dir = if in_box { crate::cloudbox::BOX_DOWNLOADS.to_string() } else { downloads_dir().display().to_string() };
|
||||||
|
if in_box {
|
||||||
|
crate::cloudbox::box_mkdir(&dir)?;
|
||||||
|
}
|
||||||
let clicked_at = std::time::SystemTime::now() - Duration::from_secs(1);
|
let clicked_at = std::time::SystemTime::now() - Duration::from_secs(1);
|
||||||
tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir.display().to_string(), "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir.display().to_string()})))?;
|
tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir, "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir})))?;
|
||||||
let (x, y, what) = locate(&mut tab, &rf, &text)?;
|
let (x, y, what) = locate(&mut tab, &rf, &text)?;
|
||||||
click_at(&mut tab, x, y, "left", 1)?;
|
click_at(&mut tab, x, y, "left", 1)?;
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
while start.elapsed().as_secs() < 60 {
|
while start.elapsed().as_secs() < 60 {
|
||||||
settle(700);
|
settle(700);
|
||||||
// A new file, or one written again since the click (same name replaced).
|
// A new file, or one written again since the click (same name replaced).
|
||||||
let now: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).map(|r| r.flatten().filter(|e| e.metadata().and_then(|m| m.modified()).map(|t| t >= clicked_at).unwrap_or(false)).map(|e| e.path()).collect()).unwrap_or_default();
|
let now: Vec<String> = if in_box {
|
||||||
if let Some(f) = now.iter().find(|p| !p.extension().map(|e| e == "crdownload" || e == "tmp").unwrap_or(false)) {
|
crate::cloudbox::box_new_files(&dir, clicked_at.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0))
|
||||||
return Ok((format!("已點 {what},下載好了:{}(可以用 read_file 讀它)", f.display()), None));
|
} else {
|
||||||
|
std::fs::read_dir(&dir).map(|r| r.flatten().filter(|e| e.metadata().and_then(|m| m.modified()).map(|t| t >= clicked_at).unwrap_or(false)).map(|e| e.path().display().to_string()).collect()).unwrap_or_default()
|
||||||
|
};
|
||||||
|
if let Some(f) = now.iter().find(|p| !(p.ends_with(".crdownload") || p.ends_with(".tmp"))) {
|
||||||
|
return Ok((format!("已點 {what},下載好了:{f}(可以用 read_file 讀它)"), None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(format!("已點 {what},但 60 秒內沒有下載到檔案(有些網站要先選格式或日期,或在新分頁產生報表)。"))
|
Err(format!("已點 {what},但 60 秒內沒有下載到檔案(有些網站要先選格式或日期,或在新分頁產生報表)。"))
|
||||||
|
|
@ -739,9 +774,18 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option<String>), String>
|
||||||
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or("")).map_err(|e| e.to_string())?;
|
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or("")).map_err(|e| e.to_string())?;
|
||||||
let (title, _, _) = snapshot(&mut tab);
|
let (title, _, _) = snapshot(&mut tab);
|
||||||
let safe: String = title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).take(60).collect();
|
let safe: String = title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).take(60).collect();
|
||||||
let path = downloads_dir().join(format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Local::now().format("%Y%m%d-%H%M%S")));
|
let name = format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Utc::now().with_timezone(&crate::routines::person_tz()).format("%Y%m%d-%H%M%S"));
|
||||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
// Saved where the bot can open it: on Bot 的電腦 when that's the browser's computer.
|
||||||
Ok((format!("已存成 PDF:{}", path.display()), None))
|
let path = if botbrowser::using_box() {
|
||||||
|
let p = format!("{}/{name}", crate::cloudbox::BOX_DOWNLOADS);
|
||||||
|
crate::cloudbox::box_write(&p, &bytes)?;
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
let p = downloads_dir().join(name);
|
||||||
|
std::fs::write(&p, bytes).map_err(|e| e.to_string())?;
|
||||||
|
p.display().to_string()
|
||||||
|
};
|
||||||
|
Ok((format!("已存成 PDF:{path}"), None))
|
||||||
}
|
}
|
||||||
"browser_dialog" => {
|
"browser_dialog" => {
|
||||||
let mut p = json!({"accept": args["accept"].as_bool().unwrap_or(true)});
|
let mut p = json!({"accept": args["accept"].as_bool().unwrap_or(true)});
|
||||||
|
|
@ -789,7 +833,19 @@ fn shrink(b64: &str) -> Option<String> {
|
||||||
Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner()))
|
Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A job is done with this browser: keep the page it ended on, close the rest (the list is
|
||||||
|
/// latest-used first). A browser that isn't open stays closed. Not offered to the model; the
|
||||||
|
/// loop calls it (browser_tabs action "tidy") when a job ends.
|
||||||
|
fn tidy(profile: Option<&str>) -> Result<String, String> {
|
||||||
|
let Some(port) = botbrowser::running_port(profile) else { return Ok("瀏覽器沒有開著。".into()) };
|
||||||
|
let closed = targets(port)?.iter().skip(1).filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count();
|
||||||
|
Ok(format!("關了 {closed} 個用完的分頁。"))
|
||||||
|
}
|
||||||
|
|
||||||
fn tabs(args: &Value, profile: Option<&str>) -> Result<String, String> {
|
fn tabs(args: &Value, profile: Option<&str>) -> Result<String, String> {
|
||||||
|
if s(args, "action") == "tidy" {
|
||||||
|
return tidy(profile);
|
||||||
|
}
|
||||||
let (_, port) = port_of(profile)?;
|
let (_, port) = port_of(profile)?;
|
||||||
let http = |path: &str, put: bool| -> Result<String, String> {
|
let http = |path: &str, put: bool| -> Result<String, String> {
|
||||||
let url = format!("http://127.0.0.1:{port}{path}");
|
let url = format!("http://127.0.0.1:{port}{path}");
|
||||||
|
|
|
||||||
130
src/cloudbox.rs
130
src/cloudbox.rs
|
|
@ -167,6 +167,13 @@ pub struct Config {
|
||||||
/// loopback-only, but the box's own programs can reach it, and they should need a key).
|
/// loopback-only, but the box's own programs can reach it, and they should need a key).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vnc_password: String,
|
pub vnc_password: String,
|
||||||
|
/// 容器隔離: the Docker runtime the box runs on. Empty = Docker's default (runc: the box
|
||||||
|
/// shares the host's kernel, and Chromium runs with its own sandbox off). On a Linux Docker
|
||||||
|
/// host with gVisor installed, "runsc" gives the box a virtualized kernel and Chromium's
|
||||||
|
/// sandbox comes back — a second wall for the pages bots open. Takes effect on the next
|
||||||
|
/// 更新/重設/復原 (the container is recreated).
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A scheduled update that was missed may still run for this long (the original: one hour).
|
/// A scheduled update that was missed may still run for this long (the original: one hour).
|
||||||
|
|
@ -312,6 +319,39 @@ fn save_config(c: &Config) {
|
||||||
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default());
|
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 容器隔離: the box's Docker runtime — "runc" (the default: a container sharing the host's
|
||||||
|
/// kernel) or "runsc" (gVisor: a virtualized kernel and user-space seccomp, for Docker hosts
|
||||||
|
/// that have it installed). Anything else falls back to the default.
|
||||||
|
pub fn box_runtime() -> String {
|
||||||
|
match config().lock().unwrap().runtime.as_str() {
|
||||||
|
"runsc" => "runsc".into(),
|
||||||
|
_ => "runc".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box's Docker host has gVisor (a runtime registered as runsc).
|
||||||
|
pub fn host_has_gvisor() -> bool {
|
||||||
|
dk(&["info", "--format", "{{range $k, $v := .Runtimes}}{{$k}} {{end}}"]).map(|o| o.split_whitespace().any(|r| r == "runsc")).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The runtime the box is running on now ("runc", "runsc"), when there is a box.
|
||||||
|
pub fn running_runtime() -> Option<String> {
|
||||||
|
dk(&["inspect", "-f", "{{.HostConfig.Runtime}}", container()]).ok().map(|r| r.trim().to_string()).filter(|r| !r.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changing it takes effect on the box's next rebuild (更新/重設/復原), where the container
|
||||||
|
/// is created again — nothing is recreated here behind the person's back.
|
||||||
|
pub fn set_box_runtime(r: &str) {
|
||||||
|
let r = if r.trim() == "runsc" { "runsc" } else { "" };
|
||||||
|
let mut c = config().lock().unwrap();
|
||||||
|
if c.runtime == r {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.runtime = r.to_string();
|
||||||
|
save_config(&c);
|
||||||
|
log(&format!("容器隔離改為「{}」,下次重建電腦(更新、重設或復原)後生效。", if r == "runsc" { "gVisor(虛擬核心)" } else { "標準(共用主機核心)" }));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn egress_via_mac() -> bool {
|
pub fn egress_via_mac() -> bool {
|
||||||
config().lock().unwrap().egress_via_mac
|
config().lock().unwrap().egress_via_mac
|
||||||
}
|
}
|
||||||
|
|
@ -380,7 +420,8 @@ fn maybe_scheduled_update() {
|
||||||
(c.auto_update_at.clone(), c.auto_update_done.clone())
|
(c.auto_update_at.clone(), c.auto_update_done.clone())
|
||||||
};
|
};
|
||||||
let Some(at) = at else { return };
|
let Some(at) = at else { return };
|
||||||
let now = chrono::Local::now();
|
// The person's clock (a brain on a server keeps UTC: 03:00 used to be 11:00 in Taipei).
|
||||||
|
let now = chrono::Utc::now().with_timezone(&crate::routines::person_tz());
|
||||||
let today = now.format("%Y-%m-%d").to_string();
|
let today = now.format("%Y-%m-%d").to_string();
|
||||||
if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() {
|
if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -688,8 +729,40 @@ fn create_container() -> Result<(), String> {
|
||||||
let env = format!("SCREEN_SIZE={SCREEN}");
|
let env = format!("SCREEN_SIZE={SCREEN}");
|
||||||
let vnc = format!("VNC_PASSWORD={}", vnc_password());
|
let vnc = format!("VNC_PASSWORD={}", vnc_password());
|
||||||
log("正在開一台新的電腦…");
|
log("正在開一台新的電腦…");
|
||||||
|
// 容器隔離: on a Docker host with gVisor (`runsc`), the box gets a virtualized kernel
|
||||||
|
// instead of the host's, its seccomp filters run in user space, and Chromium's own sandbox
|
||||||
|
// works inside the box (ANYBOT_BOX_SANDBOX=1 re-enables it; see box/Dockerfile). With the
|
||||||
|
// plain runc runtime the box itself is the sandbox, like the original.
|
||||||
|
// A Docker host without gVisor (the setting moved with a new Docker host, or gVisor was
|
||||||
|
// removed) would leave no computer at all ("unknown runtime"): open it the standard way and
|
||||||
|
// say so; the settings page shows which one it runs on.
|
||||||
|
let hardened = box_runtime() == "runsc" && host_has_gvisor();
|
||||||
|
if box_runtime() == "runsc" && !hardened {
|
||||||
|
log("這台 Docker 主機沒有 gVisor(runsc),電腦先用標準隔離開機;裝好 gVisor 後按「更新」重建。");
|
||||||
|
}
|
||||||
|
let runtime = "runsc";
|
||||||
|
let sandbox_env = "ANYBOT_BOX_SANDBOX=1";
|
||||||
|
let caps: Vec<String> = ["CHOWN", "SETUID", "SETGID", "FOWNER", "DAC_OVERRIDE", "KILL", "NET_BIND_SERVICE"]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|c| ["--cap-add".to_string(), (*c).to_string()])
|
||||||
|
.collect();
|
||||||
|
let mut args: Vec<&str> = vec!["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc];
|
||||||
|
if hardened {
|
||||||
|
// (No swap on top of the 2 GB under gVisor; a standard box keeps Docker's default.)
|
||||||
|
args.extend(["--memory-swap", "2g"]);
|
||||||
|
args.push("--runtime");
|
||||||
|
args.push(runtime);
|
||||||
|
args.push("--cap-drop");
|
||||||
|
args.push("ALL");
|
||||||
|
for c in &caps {
|
||||||
|
args.push(c);
|
||||||
|
}
|
||||||
|
args.push("-e");
|
||||||
|
args.push(sandbox_env);
|
||||||
|
}
|
||||||
|
args.extend(["--label", "anybot.box=1", IMAGE]);
|
||||||
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
|
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
|
||||||
dk(&["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
|
dk(&args).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
|
||||||
ensure_holder();
|
ensure_holder();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1598,6 +1671,46 @@ fn box_exec(script: &str) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- files for the box's browser ----------
|
||||||
|
// The brain drives the box's Chromium from outside, but that browser reads and writes the box's
|
||||||
|
// own disk: an upload, a download or a PDF has to be looked for there, not on the brain (a
|
||||||
|
// cloud brain once told the bot an attachment sitting in the box "isn't on this computer", and
|
||||||
|
// the bot spent 24 steps getting one picture into a post).
|
||||||
|
|
||||||
|
/// Where the box's browser keeps downloads and PDFs (the bot reads them there).
|
||||||
|
pub const BOX_DOWNLOADS: &str = "/home/bot/Downloads";
|
||||||
|
|
||||||
|
/// A path on Bot 的電腦 as written by the bot: `~` is the bot user's home.
|
||||||
|
pub fn box_path(p: &str) -> String {
|
||||||
|
match p.trim() {
|
||||||
|
"~" => "/home/bot".into(),
|
||||||
|
t => t.strip_prefix("~/").map(|rest| format!("/home/bot/{rest}")).unwrap_or_else(|| t.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// There is a file at `path` on Bot 的電腦.
|
||||||
|
pub fn box_has_file(path: &str) -> bool {
|
||||||
|
box_exec(&format!("[ -f {} ] && echo yes", crate::tools::sh_quote(path))).map(|o| o.trim() == "yes").unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files in `dir` on Bot 的電腦 written at or after `since` (seconds since 1970).
|
||||||
|
pub fn box_new_files(dir: &str, since: u64) -> Vec<String> {
|
||||||
|
let script = format!("find {} -maxdepth 1 -type f -newermt @{since} 2>/dev/null; true", crate::tools::sh_quote(dir));
|
||||||
|
box_exec(&script).map(|o| o.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a folder on Bot 的電腦 (as the bot user).
|
||||||
|
pub fn box_mkdir(dir: &str) -> Result<(), String> {
|
||||||
|
box_exec(&format!("mkdir -p {}", crate::tools::sh_quote(dir))).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a file on Bot 的電腦 (as the bot user), making its folder first.
|
||||||
|
pub fn box_write(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let dir = std::path::Path::new(path).parent().map(|d| d.display().to_string()).unwrap_or_else(|| "/home/bot".into());
|
||||||
|
let script = format!("mkdir -p {} && cat > {}", crate::tools::sh_quote(&dir), crate::tools::sh_quote(path));
|
||||||
|
dk_with_input(&["exec", "-i", "-u", "bot", container(), "sh", "-c", &script], Some(bytes), 60_000).map(|_| ()).map_err(|e| format!("存不進 Bot 的電腦:{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// The numbered controls of the box's latest look (centres in desktop pixels).
|
/// The numbered controls of the box's latest look (centres in desktop pixels).
|
||||||
static BOX_SPOTS: Mutex<Option<crate::marks::Spots>> = Mutex::new(None);
|
static BOX_SPOTS: Mutex<Option<crate::marks::Spots>> = Mutex::new(None);
|
||||||
/// The latest look's fingerprint (to say when a step changed nothing).
|
/// The latest look's fingerprint (to say when a step changed nothing).
|
||||||
|
|
@ -2457,6 +2570,13 @@ mod tests {
|
||||||
let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids);
|
let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn box_paths_are_the_bot_users() {
|
||||||
|
assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg");
|
||||||
|
assert_eq!(box_path(" ~ "), "/home/bot");
|
||||||
|
assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schedule_window_is_one_hour() {
|
fn schedule_window_is_one_hour() {
|
||||||
let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap();
|
let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap();
|
||||||
|
|
@ -2466,7 +2586,11 @@ mod tests {
|
||||||
assert!(!in_schedule_window("03:00", t("02:59")));
|
assert!(!in_schedule_window("03:00", t("02:59")));
|
||||||
assert!(in_schedule_window("23:30", t("00:10")));
|
assert!(in_schedule_window("23:30", t("00:10")));
|
||||||
assert!(!in_schedule_window("bad", t("00:10")));
|
assert!(!in_schedule_window("bad", t("00:10")));
|
||||||
assert_eq!(wanted_version(), "6");
|
// On a server (UTC), 03:00 is the person's 03:00: 19:00 UTC in Taipei.
|
||||||
|
use chrono::TimeZone;
|
||||||
|
let at = chrono::Utc.with_ymd_and_hms(2026, 11, 6, 19, 0, 0).unwrap().with_timezone(&chrono_tz::Asia::Taipei);
|
||||||
|
assert!(in_schedule_window("03:00", at.time()));
|
||||||
|
assert_eq!(wanted_version(), "7");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
196
src/memory.rs
196
src/memory.rs
|
|
@ -63,6 +63,121 @@ pub fn forget(list: &mut Vec<String>, what: &str) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a skill, or replace the one with the same name. True when it is new.
|
||||||
|
pub fn upsert_skill(list: &mut Vec<Skill>, k: Skill) -> bool {
|
||||||
|
match list.iter_mut().find(|x| x.name == k.name) {
|
||||||
|
Some(x) => {
|
||||||
|
*x = k;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
list.push(k);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 自己進化: a job's way that worked becomes a skill ----------
|
||||||
|
// The bot forgot everything between chats: a post with a picture on Threads took it 39 steps,
|
||||||
|
// and the next one would have taken as many. After a longer job the model looks back at the
|
||||||
|
// steps and writes down the short path that worked (or improves the skill it followed); the
|
||||||
|
// next time it reads that first.
|
||||||
|
|
||||||
|
/// Steps on computers and pages a job needs before it is worth looking back on (estimated).
|
||||||
|
pub const LEARN_MIN_STEPS: usize = 5;
|
||||||
|
/// Longest learned skill (estimated; a skill is read whole before each such job).
|
||||||
|
const MAX_SKILL_CHARS: usize = 4000;
|
||||||
|
|
||||||
|
/// A turn that did enough real work, and didn't already save a skill itself.
|
||||||
|
pub fn worth_learning(turn: &[crate::model::Message]) -> bool {
|
||||||
|
let calls: Vec<&str> = turn.iter().flat_map(|m| m.tool_calls.iter().map(|c| c.name.as_str())).collect();
|
||||||
|
!calls.contains(&"save_skill") && calls.iter().filter(|n| !crate::appctl::is_app_tool(n) && **n != "react").count() >= LEARN_MIN_STEPS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LEARN_SYSTEM: &str = "你在幫一個會操作電腦的 Bot 整理經驗,讓它下次做同樣的事更快、不再犯同樣的錯。下面是它剛做完的一件事:使用者的要求、每一步和結果、最後的回覆,還有它已經有的技能。
|
||||||
|
|
||||||
|
先判斷要不要存。使用者希望它從做過的事學會、下次不用重新摸索:只要這件事是在某個網站或程式上動手操作(填表、發文、回留言、按讚、上傳下載、改設定、跑一套固定的檢查),就要存,就算這次只做了一次。只有閒聊、單純回答問題、只是查資料讀內容,才不用存。這次沒做成功的,不要存成做法;但如果已經有這件事的技能,可以把「這樣做行不通」補進去。
|
||||||
|
|
||||||
|
要存的話,寫一份「下次照著做就會成功」的技能:
|
||||||
|
- 寫這次真的成功的那條最短路徑,一步一行,寫出用哪個工具、在哪台電腦、哪個網址或程式(每一步結果開頭 [ ] 裡的名字,就是那一步用的電腦)。
|
||||||
|
- 繞路和失敗的嘗試不要寫成步驟;把「哪裡會卡、要怎麼避開」寫成「注意:」。
|
||||||
|
- 用畫面上看得到的名字指東西(例如 browser_click 的 text 寫「Attach media」、browser_type 的 text 寫欄位名「Customer name」),不要寫元素編號或座標,那些每次都會變。browser_open 的結果就有這一頁的元素清單,不用另外截圖。
|
||||||
|
- 寫通用的做法,不要寫這次的內容(這篇文的文字、這張圖的檔名);使用者表達過的偏好(語氣、格式、一定要附圖、用哪台電腦)要寫進去。
|
||||||
|
- 已經有做同一件事的技能,就用同一個名稱,把舊的和這次學到的合併成更好的一版:舊的對的保留,錯的改掉。這次照著技能做而且很順,就不用再存。
|
||||||
|
|
||||||
|
只回一個 JSON,不要別的字:
|
||||||
|
不用存:{\"save\": false, \"why\": \"一句話說為什麼\"}
|
||||||
|
要存:{\"save\": true, \"name\": \"短名稱\", \"description\": \"一行:什麼時候用\", \"instructions\": \"做法\"}";
|
||||||
|
|
||||||
|
fn cut(s: &str, n: usize) -> String {
|
||||||
|
let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if flat.chars().count() <= n { flat } else { format!("{}…", flat.chars().take(n).collect::<String>()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the model looks back on: the request, each step with its result, the answer and the
|
||||||
|
/// skills there are.
|
||||||
|
pub fn learn_brief(turn: &[crate::model::Message], skills: &[Skill]) -> String {
|
||||||
|
use crate::model::Role;
|
||||||
|
let mut steps: Vec<String> = vec![];
|
||||||
|
for (i, m) in turn.iter().enumerate() {
|
||||||
|
if m.role == Role::User && i > 0 {
|
||||||
|
steps.push(format!("(使用者中途說:{})", cut(&m.text, 300)));
|
||||||
|
}
|
||||||
|
for c in &m.tool_calls {
|
||||||
|
let result = turn.iter().find(|r| r.role == Role::Tool && r.tool_call_id == c.id).map(|r| r.text.as_str()).unwrap_or("");
|
||||||
|
let failed = if c.status == crate::model::ToolStatus::Failed { "(失敗)" } else { "" };
|
||||||
|
steps.push(format!("{} {} → {}{failed}", c.name, cut(&c.args, 160), cut(result, 220)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const KEEP: usize = 40;
|
||||||
|
if steps.len() > KEEP * 2 {
|
||||||
|
let gone = steps.len() - KEEP * 2;
|
||||||
|
steps.splice(KEEP..steps.len() - KEEP, [format!("…(中間省略 {gone} 步)…")]);
|
||||||
|
}
|
||||||
|
let ask = turn.first().map(|m| cut(&m.text, 800)).unwrap_or_default();
|
||||||
|
let answer = turn.iter().rev().find(|m| m.role == crate::model::Role::Assistant && !m.text.trim().is_empty()).map(|m| cut(&m.text, 600)).unwrap_or_default();
|
||||||
|
let mut s = format!("使用者的要求:\n{ask}\n\n每一步(工具 參數 → 結果):\n");
|
||||||
|
for (i, st) in steps.iter().enumerate() {
|
||||||
|
s.push_str(&format!("{}. {st}\n", i + 1));
|
||||||
|
}
|
||||||
|
s.push_str(&format!("\n最後的回覆:\n{answer}\n\n已經有的技能:"));
|
||||||
|
if skills.is_empty() {
|
||||||
|
s.push_str("(沒有)");
|
||||||
|
}
|
||||||
|
let mut room = 8000usize;
|
||||||
|
for k in skills {
|
||||||
|
let body = cut(&k.instructions, 2000.min(room));
|
||||||
|
room = room.saturating_sub(body.chars().count());
|
||||||
|
s.push_str(&format!("\n- {}:{}\n 做法:{body}", k.name, k.description));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The skill in the model's answer, when it chose to save one.
|
||||||
|
pub fn learned_skill(text: &str) -> Option<Skill> {
|
||||||
|
let t = text.trim();
|
||||||
|
let json = t.find('{').and_then(|a| t.rfind('}').filter(|b| *b >= a).map(|b| &t[a..=b]))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||||
|
if v["save"].as_bool() != Some(true) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name: String = v["name"].as_str()?.trim().chars().take(40).collect();
|
||||||
|
let instructions: String = v["instructions"].as_str()?.trim().chars().take(MAX_SKILL_CHARS).collect();
|
||||||
|
if name.is_empty() || instructions.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Skill { name, description: v["description"].as_str().unwrap_or("").trim().chars().take(120).collect(), instructions })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line under the bot's reply that says it learned (or improved) a skill.
|
||||||
|
pub fn learned_note(name: &str, fresh: bool) -> String {
|
||||||
|
if fresh {
|
||||||
|
format!("\n\n(學會了新做法「{name}」,下次做這件事會照著做。)")
|
||||||
|
} else {
|
||||||
|
format!("\n\n(把做法「{name}」更新了,下次會照新的做。)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String {
|
pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
if !memories.is_empty() {
|
if !memories.is_empty() {
|
||||||
|
|
@ -74,7 +189,7 @@ pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String {
|
||||||
s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。");
|
s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。");
|
||||||
}
|
}
|
||||||
if !skills.is_empty() {
|
if !skills.is_empty() {
|
||||||
s.push_str("\n\n可用的技能(做法說明;需要時先用 use_skill 讀取完整內容再照做):\n");
|
s.push_str("\n\n你會的技能(以前做成功的做法)。開始做一件事之前先看這裡:有符合的,先用 use_skill 讀出完整做法再照著做,不要重新摸索:\n");
|
||||||
for k in skills {
|
for k in skills {
|
||||||
s.push_str(&format!("- {}:{}\n", k.name, k.description));
|
s.push_str(&format!("- {}:{}\n", k.name, k.description));
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +217,83 @@ mod tests {
|
||||||
assert_eq!(m.len(), MAX_MEMORIES);
|
assert_eq!(m.len(), MAX_MEMORIES);
|
||||||
assert_eq!(m[0], "事 5");
|
assert_eq!(m[0], "事 5");
|
||||||
let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into() }]);
|
let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into() }]);
|
||||||
assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度"));
|
assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度") && p.contains("use_skill"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(id: &str, name: &str, args: &str, failed: bool) -> crate::model::ToolCall {
|
||||||
|
crate::model::ToolCall { id: id.into(), name: name.into(), args: args.into(), status: if failed { crate::model::ToolStatus::Failed } else { crate::model::ToolStatus::Done } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result(id: &str, text: &str) -> crate::model::Message {
|
||||||
|
crate::model::Message { role: crate::model::Role::Tool, tool_call_id: id.into(), text: text.into(), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A post with a picture: five real steps, one of them failed, the person corrects it midway.
|
||||||
|
fn post_turn() -> Vec<crate::model::Message> {
|
||||||
|
use crate::model::Message;
|
||||||
|
let step = |c: crate::model::ToolCall| Message { tool_calls: vec![c], ..Message::assistant("", "b") };
|
||||||
|
vec![
|
||||||
|
Message::user("幫我發一篇 Threads,記得附圖"),
|
||||||
|
step(call("1", "browser_open", r#"{"url":"https://www.threads.com/"}"#, false)),
|
||||||
|
result("1", "已打開 https://www.threads.com/\n[13] 按鈕「Create」"),
|
||||||
|
step(call("2", "browser_click", r#"{"ref":"13"}"#, false)),
|
||||||
|
result("2", "已點 [13]。新出現的文字:New thread"),
|
||||||
|
Message { steer: true, ..Message::user("語氣要輕鬆一點") },
|
||||||
|
step(call("3", "browser_type", r#"{"ref":"12","value":"今天天氣好"}"#, false)),
|
||||||
|
result("3", "已在 [12] 輸入。"),
|
||||||
|
step(call("4", "browser_upload", r#"{"path":"/home/bot/x.jpg"}"#, true)),
|
||||||
|
result("4", "這台電腦上沒有這個檔案"),
|
||||||
|
step(call("5", "browser_upload", r#"{"path":"/home/bot/bot-6/附件/x.jpg"}"#, false)),
|
||||||
|
result("5", "已把 /home/bot/bot-6/附件/x.jpg 放進上傳欄位。"),
|
||||||
|
step(call("6", "remember", r#"{"fact":"x"}"#, false)),
|
||||||
|
result("6", "記住了"),
|
||||||
|
Message::assistant("發好了:https://www.threads.com/@me/post/1", "b"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_enough_job_is_looked_back_on() {
|
||||||
|
let turn = post_turn();
|
||||||
|
assert!(worth_learning(&turn));
|
||||||
|
// App steps (remember) don't count; four real steps aren't enough.
|
||||||
|
assert!(!worth_learning(&turn[..10]));
|
||||||
|
// A job that saved a skill itself is left alone.
|
||||||
|
let mut saved = turn.clone();
|
||||||
|
saved[12].tool_calls[0].name = "save_skill".into();
|
||||||
|
assert!(!worth_learning(&saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_look_back_has_every_step_and_the_skills() {
|
||||||
|
let skills = [Skill { name: "Threads 發文".into(), description: "在 Threads 發文".into(), instructions: "1. browser_open https://www.threads.com/".into() }];
|
||||||
|
let b = learn_brief(&post_turn(), &skills);
|
||||||
|
assert!(b.starts_with("使用者的要求:\n幫我發一篇 Threads,記得附圖"), "{b}");
|
||||||
|
assert!(b.contains("1. browser_open {\"url\":\"https://www.threads.com/\"} → 已打開 https://www.threads.com/ [13] 按鈕「Create」"), "{b}");
|
||||||
|
assert!(b.contains("(使用者中途說:語氣要輕鬆一點)"), "{b}");
|
||||||
|
assert!(b.contains("browser_upload {\"path\":\"/home/bot/x.jpg\"} → 這台電腦上沒有這個檔案(失敗)"), "{b}");
|
||||||
|
assert!(b.contains("最後的回覆:\n發好了"), "{b}");
|
||||||
|
assert!(b.contains("- Threads 發文:在 Threads 發文\n 做法:1. browser_open"), "{b}");
|
||||||
|
// A very long job keeps its start and its end.
|
||||||
|
let mut long = vec![crate::model::Message::user("做很多事")];
|
||||||
|
for i in 0..200 {
|
||||||
|
long.push(crate::model::Message { tool_calls: vec![call(&i.to_string(), "browser_click", "{}", false)], ..crate::model::Message::assistant("", "b") });
|
||||||
|
}
|
||||||
|
let b = learn_brief(&long, &[]);
|
||||||
|
assert!(b.contains("(中間省略 120 步)") && b.contains("81. browser_click") && !b.contains("82. "), "{b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_learned_skill_is_read_from_the_answer() {
|
||||||
|
let k = learned_skill("好的\n{\"save\": true, \"name\": \" Threads 發文附圖 \", \"description\": \"在 Threads 發一篇附圖的文\", \"instructions\": \"1. browser_open https://www.threads.com/\"}").unwrap();
|
||||||
|
assert_eq!((k.name.as_str(), k.description.as_str()), ("Threads 發文附圖", "在 Threads 發一篇附圖的文"));
|
||||||
|
assert!(learned_skill(r#"{"save": false}"#).is_none());
|
||||||
|
assert!(learned_skill(r#"{"save": true, "name": "", "instructions": "x"}"#).is_none());
|
||||||
|
assert!(learned_skill("不用存").is_none());
|
||||||
|
assert!(learned_skill("} {").is_none());
|
||||||
|
let mut list = vec![];
|
||||||
|
assert!(upsert_skill(&mut list, k.clone()));
|
||||||
|
assert!(!upsert_skill(&mut list, Skill { instructions: "新的".into(), ..k }));
|
||||||
|
assert_eq!((list.len(), list[0].instructions.as_str()), (1, "新的"));
|
||||||
|
assert!(learned_note("Threads 發文附圖", true).contains("學會了新做法「Threads 發文附圖」"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,9 @@ pub enum StreamEvent {
|
||||||
React(String),
|
React(String),
|
||||||
/// The bot sent a file into the chat (a file card under its reply).
|
/// The bot sent a file into the chat (a file card under its reply).
|
||||||
Attach(crate::attach::Attachment),
|
Attach(crate::attach::Attachment),
|
||||||
|
/// 自己進化: after the turn, the way that worked was kept as a skill (with the time of the
|
||||||
|
/// reply it learned from, which gets a line saying so).
|
||||||
|
Learned(crate::memory::Skill, i64),
|
||||||
Done,
|
Done,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue