Merge branch 'computer-names': every computer by name, the person's computer, hand-off, Wayland screenshot fix, E2E tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 20:57:14 +08:00
commit db028b0b5b
39 changed files with 2366 additions and 444 deletions

View File

@ -2,7 +2,7 @@
# Make AnyBot.app from the release build and zip it (run from app/ on a Mac, after
# `cargo build --release`): deploy/mac/bundle.sh 1.2.0 → dist/AnyBot-1.2.0-macos-<arch>.zip
# With KEEP_APP=1 it leaves dist/AnyBot.app instead of zipping it (make install).
# Signed ad hoc (no Apple developer account): the first time, right-click › 打開.
# Signed with this Mac's own fixed key (no Apple developer account): the first time, right-click › 打開.
set -e
VERSION=${1:?version}
BIN=${CARGO_TARGET_DIR:-target}/release/anybot
@ -39,7 +39,8 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>NSAppleEventsUsageDescription</key><string>讓 Bot 在你允許時操作其他 App。</string>
</dict></plist>
PLIST
codesign --force --deep -s - "$APP"
# The same signature every build, so macOS keeps the permissions it gave (see sign.sh).
sh "$(dirname "$0")/sign.sh" "$APP"
if [ "${KEEP_APP:-}" = 1 ]; then
exit 0
fi

57
deploy/mac/sign.sh Executable file
View File

@ -0,0 +1,57 @@
#!/bin/sh
# Sign AnyBot.app with this Mac's own signing key, the same one every build:
# deploy/mac/sign.sh dist/AnyBot.app
# macOS keeps 輔助使用 / 螢幕錄製 for an app by its signature. Signed "ad hoc", every build was a
# new app to it, so each update quietly lost those permissions and the bots could no longer read
# or operate other apps (agent-desktop answered PERM_DENIED). One fixed self-signed key, kept in
# its own keychain under AnyBot's folder (the login keychain is not touched), keeps them.
# Anything going wrong falls back to ad hoc signing.
set -u
APP=${1:?app}
DIR="$HOME/Library/Application Support/AnyBot/signing"
KC="$DIR/anybot-signing.keychain-db"
PWF="$DIR/keychain-password"
NAME="AnyBot Local Signing"
make_key() {
mkdir -p "$DIR" && chmod 700 "$DIR" || return 1
head -c 24 /dev/urandom | base64 > "$PWF" && chmod 600 "$PWF" || return 1
PW=$(cat "$PWF")
T=$(mktemp -d)
cat > "$T/cert.cnf" <<CNF
[req]
distinguished_name=dn
x509_extensions=ext
prompt=no
[dn]
CN=$NAME
[ext]
keyUsage=critical,digitalSignature
extendedKeyUsage=critical,codeSigning
basicConstraints=critical,CA:false
CNF
ok=1
/usr/bin/openssl req -x509 -newkey rsa:2048 -nodes -keyout "$T/key.pem" -out "$T/cert.pem" -days 7300 -config "$T/cert.cnf" >/dev/null 2>&1 \
&& /usr/bin/openssl pkcs12 -export -inkey "$T/key.pem" -in "$T/cert.pem" -out "$T/id.p12" -passout pass:"$PW" >/dev/null 2>&1 \
&& security create-keychain -p "$PW" "$KC" >/dev/null \
&& security set-keychain-settings "$KC" \
&& security unlock-keychain -p "$PW" "$KC" \
&& security import "$T/id.p12" -k "$KC" -P "$PW" -T /usr/bin/codesign >/dev/null \
&& security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$PW" "$KC" >/dev/null 2>&1 \
&& ok=0
rm -rf "$T"
return $ok
}
signed=1
if [ -f "$KC" ] || make_key; then
if security unlock-keychain -p "$(cat "$PWF")" "$KC" 2>/dev/null \
&& codesign --force --deep --keychain "$KC" -s "$NAME" "$APP" 2>/dev/null; then
signed=0
echo "已用這台 Mac 固定的簽章簽好(更新後「輔助使用」「螢幕錄製」權限會保留)"
fi
fi
if [ $signed -ne 0 ]; then
codesign --force --deep -s - "$APP"
echo "注意:用臨時簽章(更新後要重新允許「輔助使用」「螢幕錄製」)"
fi

View File

@ -54,6 +54,9 @@ services:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
ANYBOT_RESTORE_FROM_ACCOUNT: "${ANYBOT_RESTORE_FROM_ACCOUNT:-}"
# The bots' computer goes by its own name: a desktop AnyBot on this same machine keeps
# "anybot-box" for itself (and used to pause the brain's one as idle).
ANYBOT_BOX_NAME: "anybot-brain-box"
volumes:
- anybot-data:/data
# The bot's own computer runs as a container next to this one.

411
deploy/test/remote_e2e.py Normal file
View File

@ -0,0 +1,411 @@
# End-to-end test of a brain on a server working on a person's computer, all on this machine and
# all made up for the test: an account server, a headless brain, a headless "hands" computer (it
# also plays the person's screen, as a Mac showing the brain does), and a scripted fake model.
# Never touches a real account, and no Docker box (the bot's own computer is a sealed folder).
# Build first (cargo build --release; and in relay/), then, from app/:
# python3 -I deploy/test/remote_e2e.py .
# On a Mac the terminal needs 輔助使用 (the hands open Calculator through agent-desktop).
import base64, json, os, re, shutil, socket, subprocess, sys, tempfile, threading, time, urllib.request, urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
APP = os.path.abspath(sys.argv[1])
BIN = os.path.join(APP, 'target/release/anybot')
RELAY = os.path.join(APP, 'relay/target/release/anybot-relay')
WORK = tempfile.mkdtemp(prefix='anybot-e2e-')
USER, PW = 'e2euser', 'e2e-pass-12345'
HANDS = 'E2E手腳'
APP_NAME = os.environ.get('E2E_APP', 'Calculator')
# E2E_SCREENSHOT=1: also a screenshot of the hands' screen, sent back to the brain (needs a
# screen: on Linux a Wayland or X11 session, on a Mac 螢幕錄製 for the terminal).
SHOT = os.environ.get('E2E_SCREENSHOT') == '1'
# E2E_TYPE=1 (with E2E_SCREENSHOT): also click, type and press Enter in a terminal on the right
# half of the hands' screen (the Linux Wayland test opens one there).
results = []
images = []
def free_port():
s = socket.socket(); s.bind(('127.0.0.1', 0)); p = s.getsockname()[1]; s.close(); return p
def check(name, ok, detail=''):
results.append((name, bool(ok), detail))
print(('PASS ' if ok else 'FAIL ') + name + ('' if ok else f' — {detail}'), flush=True)
# ---------- the scripted model ----------
def tool_call(name, args):
return {'tool': (name, json.dumps(args, ensure_ascii=False))}
SCRIPTS = {
'SC_LIST': [tool_call('list_computers', {})],
'SC_OPEN': [tool_call('computer_use_open_app', {'app': APP_NAME, 'computer': HANDS})],
'SC_SHOT': [tool_call('computer_use_screenshot', {'computer': HANDS})],
# Mouse and keyboard there: click the terminal on the right, type a command (Chinese too), Enter.
'SC_TYPE': [tool_call('computer_use_screenshot', {'computer': HANDS}),
tool_call('computer_use_click', {'x': 768, 'y': 300, 'computer': HANDS}),
tool_call('computer_use_typing', {'value': 'echo 打字-$((3*4)) > /tmp/e2e-typed.txt', 'computer': HANDS}),
tool_call('computer_use_press_key', {'key': 'return', 'computer': HANDS}),
tool_call('shell', {'command': 'sleep 1; cat /tmp/e2e-typed.txt', 'computer': HANDS})],
'SC_SHELL': [tool_call('shell', {'command': 'echo e2e-$((6*7))', 'computer': HANDS})],
'SC_REDIRECT': [tool_call('shell', {'command': 'echo moved-here', 'computer': HANDS})],
'SC_HANDOFF': [tool_call('hand_over_computer', {'instruction': 'E2E:請按「我完成了」', 'computer': HANDS})],
'另一台:': [tool_call('hand_over_computer', {'instruction': 'E2E:在另一台', 'computer': 'E2E另一台'})],
'SC_OWN': [tool_call('shell', {'command': 'echo on-own-$((2+3))'})],
}
class Model(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({'data': [{'id': 'e2e'}]}).encode()
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get('Content-Length', '0') or 0)
raw = self.rfile.read(n) or b'{}'
req = json.loads(raw)
# Pictures the model was shown (a screenshot from another computer comes back as one).
for m in re.finditer(rb'data:image/jpeg;base64,([A-Za-z0-9+/=]+)', raw):
images.append(base64.b64decode(m.group(1)))
msgs = req.get('messages', [])
# The person's last message (a screenshot comes back as a user message of pictures: not them).
said = lambda m: m.get('role') == 'user' and not (isinstance(m.get('content'), list) and any(isinstance(p, dict) and p.get('type') == 'image_url' for p in m['content']))
last_user = max((i for i, m in enumerate(msgs) if said(m)), default=-1)
text = msgs[last_user].get('content', '') if last_user >= 0 else ''
if isinstance(text, list):
text = ' '.join(p.get('text', '') for p in text if isinstance(p, dict))
done = sum(1 for m in msgs[last_user + 1:] if m.get('role') == 'tool')
last_tool = next((m.get('content', '') for m in reversed(msgs) if m.get('role') == 'tool'), '')
script = next((v for k, v in SCRIPTS.items() if k in text), [])
if done < len(script):
name, args = script[done]['tool']
chunk = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': f'c{int(time.time()*1000)}', 'function': {'name': name, 'arguments': args}}]}}]}
else:
chunk = {'choices': [{'delta': {'content': 'E2E 完成:' + (last_tool if isinstance(last_tool, str) else '')[:300]}}]}
self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers()
self.wfile.write(('data: ' + json.dumps(chunk, ensure_ascii=False) + '\n\ndata: [DONE]\n\n').encode())
# ---------- relay helpers ----------
RP = free_port(); MP = free_port()
RURL = f'http://127.0.0.1:{RP}'
def http(method, url, body=None, token=None, timeout=60, raw=False):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, method=method, data=data, headers={'Content-Type': 'application/json', **({'Authorization': 'Bearer ' + token} if token else {})})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
b = r.read(); return r.status, (b if raw else json.loads(b or b'null'))
except urllib.error.HTTPError as e:
b = e.read()
try:
return e.code, json.loads(b)
except Exception:
return e.code, b
procs = {}
def start(name, args, env):
log = open(os.path.join(WORK, name + '.log'), 'a')
e = dict(os.environ); e.update(env)
procs[name] = subprocess.Popen(args, env=e, stdout=log, stderr=log, cwd=WORK)
def stop(name):
p = procs.pop(name, None)
if p:
p.terminate()
try:
p.wait(10)
except Exception:
p.kill()
def template():
path = os.path.join(WORK, 'sample.json')
subprocess.run([BIN, '--sample-state', path], check=True)
t = json.load(open(path))
for k in ('channels', 'mcp_servers', 'skills', 'pages', 'review_allow_rules', 'review_ask_rules', 'remote_computers', 'folders'):
if k in t:
t[k] = []
t['providers'] = {'custom': {'api_key': '', 'base_url': f'http://127.0.0.1:{MP}/v1', 'default_model': 'e2e', 'known_models': ['e2e']}}
t['remote'] = {'lan': False, 'password': '', 'devices': [], 'mac_from_phone': False, 'device_link': False, 'relay_url': '', 'relay_user': '', 'host_id': '', 'host_token': '', 'host_name': '', 'backup_key': '', 'auto_backup': False, 'last_backup': 0}
b = t['bots'][0]
b.update({'name': 'Master', 'provider': 'Custom', 'model': 'e2e', 'messages': [], 'memories': [], 'allow_rules': [], 'last_turn': None, 'default_computer': 'Bot 的電腦', 'tools_enabled': True, 'instructions': ''})
b.pop('threads', None)
t['bots'] = [b]; t['primary_bot'] = b['id']; t['selected'] = b['id']
t['local_execution'] = False; t['direct_mode'] = False; t['auto_review'] = False
return t
NOBOX = {'DOCKER_HOST': 'unix:///nonexistent/docker.sock', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin'}
def run():
srv = ThreadingHTTPServer(('127.0.0.1', MP), Model)
threading.Thread(target=srv.serve_forever, daemon=True).start()
os.makedirs(os.path.join(WORK, 'relay'))
start('relay', [RELAY, '--bind', '127.0.0.1', '--port', str(RP), '--data', os.path.join(WORK, 'relay')], {})
for _ in range(50):
try:
if http('GET', RURL + '/api/hello')[0] == 200:
break
except Exception:
time.sleep(0.2)
# The brain: a seeded bot on the fake model, joins (and makes) the account.
bdir = os.path.join(WORK, 'brain'); os.makedirs(bdir)
st = template(); st['remote']['host_name'] = 'E2E大腦'
json.dump(st, open(os.path.join(bdir, 'state.json'), 'w'), ensure_ascii=False)
benv = {'ANYBOT_DATA_DIR': bdir, 'ANYBOT_ACCOUNT': f'{RURL}|{USER}', 'ANYBOT_ACCOUNT_PASSWORD': PW, 'ANYBOT_ACCOUNT_CREATE': '1', 'ANYBOT_HOST_NAME': 'E2E大腦', **NOBOX}
start('brain', [BIN, '--serve'], benv)
brain_id = None
for _ in range(60):
time.sleep(0.5)
try:
s = json.load(open(os.path.join(bdir, 'state.json')))
if s['remote'].get('host_id'):
brain_id = s['remote']['host_id']; break
except Exception:
pass
check('brain joins the account', brain_id, 'no host id')
# The hands: a second AnyBot of the account, lending its hands to that brain only.
_, v = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': HANDS})
_, h = http('POST', RURL + '/relay/hosts', {'name': HANDS}, token=v['token'])
hdir = os.path.join(WORK, 'hands'); os.makedirs(hdir)
hs = template(); hs['bots'] = []
hs['remote'].update({'relay_url': RURL, 'relay_user': USER, 'host_id': h['id'], 'host_token': h['token'], 'host_name': HANDS, 'device_link': True, 'hands_for': brain_id, 'hands_for_name': 'E2E大腦'})
json.dump(hs, open(os.path.join(hdir, 'state.json'), 'w'), ensure_ascii=False)
henv = {'ANYBOT_DATA_DIR': hdir, **NOBOX}
start('hands', [BIN, '--serve'], henv)
# A second computer showing the same brain (as a desktop AnyBot left on the server does):
# with two, 「我的電腦」 has to be the one the person typed on, not a guess.
_, h2 = http('POST', RURL + '/relay/hosts', {'name': 'E2E另一台'}, token=v['token'])
h2dir = os.path.join(WORK, 'hands2'); os.makedirs(h2dir)
hs2 = template(); hs2['bots'] = []
hs2['remote'].update({'relay_url': RURL, 'relay_user': USER, 'host_id': h2['id'], 'host_token': h2['token'], 'host_name': 'E2E另一台', 'device_link': True, 'hands_for': brain_id, 'hands_for_name': 'E2E大腦', 'brain_id': brain_id, 'brain_name': 'E2E大腦'})
json.dump(hs2, open(os.path.join(h2dir, 'state.json'), 'w'), ensure_ascii=False)
start('hands2', [BIN, '--serve'], {'ANYBOT_DATA_DIR': h2dir, **NOBOX})
tok = h['token']
sync = lambda method, rest='', body=None, q='': http(method, f'{RURL}/relay/hosts/{brain_id}/api/sync{rest}{q}', body, token=tok)
def peers():
return {x['name']: x for x in http('GET', RURL + '/relay/host/peers', token=tok)[1]['hosts']}
for _ in range(40):
p = peers()
if p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online') and p.get('E2E另一台', {}).get('online'):
break
time.sleep(0.5)
p = peers()
check('brain and hands are online', p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'), json.dumps(p, ensure_ascii=False))
# Tell the account this computer shows that brain (as the Mac's screen does).
copy = {'version': 0, 'msgs': {}}
def pull(full=False, client='screen'):
since = 0 if full else copy['version']
code, v = sync('GET', q=f'?since={since}&client={client}')
if code != 200:
raise RuntimeError(f'pull {code} {v}')
if client == 'screen':
copy['version'] = v.get('version', 0)
for d in v.get('bots', []):
cur = copy['msgs'].setdefault(d['id'], [])
if len(cur) < d['from']:
copy['version'] = 0 # the screen asks for everything again
continue
del cur[d['from']:]
cur.extend(d['messages'])
return v
# The brain looks the account's computers up every 15 s: a computer that just came on is
# there within that.
for _ in range(25):
v = pull()
comps = v.get('computers', {}).get('1', {})
names = [x['name'] for x in comps.get('names', [])]
if HANDS in names and 'E2E另一台' in names:
break
time.sleep(1)
check('computers by their own names; no server computer', sorted(names) == sorted(['Master 的電腦', HANDS, 'E2E另一台']), names)
check('the bot works on its own computer by default', comps.get('current') == 'Master 的電腦', comps)
def wait_turn(answer=None, timeout=90, during=None):
"""Until the turn ends; answers requests with `answer` ("once"/"reject")."""
t0 = time.time(); seen = []
while time.time() - t0 < timeout:
v = pull(client='poll')
for a in v.get('approvals', []):
key = (a.get('call_id'), a.get('kind'))
if key not in [s[:2] for s in seen]:
seen.append((a.get('call_id'), a.get('kind'), a))
if during:
during(a)
elif answer:
sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': answer})
if not v.get('live') and not v.get('approvals') and time.time() - t0 > 2.5:
return seen, v
time.sleep(0.5)
return seen, None
def last_tool(v):
msgs = [m for m in v['bots'][0]['messages'] if m['role'] == 'Tool'] if v and v.get('bots') else []
return msgs[-1]['text'] if msgs else ''
def full_messages():
return pull(full=True, client='check')['bots'][0]['messages']
def tool_text_after(marker):
ms = full_messages()
i = max((k for k, m in enumerate(ms) if m['role'] == 'User' and marker in m['text']), default=-1)
return [m['text'] for m in ms[i + 1:] if m['role'] == 'Tool']
# 1. 查看電腦: the person's computer is the one they typed on.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我現在用哪台?SC_LIST'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_LIST'))
check('list_computers names the hands as where the person is', HANDS in t and '使用者現在就是在這台' in t, t[:300])
# 4. Not asked for: back to the chosen computer, with a word for the bot.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '跑一下 SC_REDIRECT'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_REDIRECT'))
check('a step the person did not ask for goes to the chosen computer, and the bot is told', 'moved-here' in t and '[Master 的電腦]' in t and '這一步你指定了' in t, t[:400])
# 2. A step on the person's computer, named by them: asks once, runs there (agent-desktop).
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦打開計算機 SC_OPEN'})
seen, _ = wait_turn('once')
t = ' '.join(tool_text_after('SC_OPEN'))
check('open_app on the person computer asks first', any(s[1] == 'action' for s in seen), seen)
check('open_app ran on the hands via agent-desktop', f'[{HANDS}]' in t and ('已打開' in t), t[:400])
# 3. A command there.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_SHELL'))
check('shell runs on the hands', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300])
# 3b. A screenshot there, back to the brain and on to the model as a picture.
if SHOT:
before = len(images)
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '看一下我的螢幕 SC_SHOT'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_SHOT'))
check('screenshot runs on the hands', f'[{HANDS}]' in t and '螢幕截圖(' in t, t[:300])
pics = [b for b in images[before:] if b[:2] == b'\xff\xd8' and len(b) > 1000]
if pics:
open(os.path.join(WORK, 'shot.jpg'), 'wb').write(pics[-1])
check('the model gets the screenshot as a JPEG', bool(pics), f'{len(images) - before} pictures; saved {os.path.join(WORK, "shot.jpg")}')
if os.environ.get('E2E_TYPE') == '1':
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦打字 SC_TYPE'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_TYPE'))
check('click, typing (Chinese too) and Enter reach the hands', '打字-12' in t, t[-400:])
# 5. Its own computer.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '用你自己的 SC_OWN'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_OWN'))
check('own computer by default', 'on-own-5' in t and '[Master 的電腦]' in t, t[:300])
# 6. 需要處理: 我完成了.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦 SC_HANDOFF'})
got = {}
phone = {}
def on_handoff(a):
got.update(a)
# The phone (signed in as the person) sees it too, as 需要處理 with the computer.
_, ses = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': 'e2e-phone'})
code, view = http('GET', f'{RURL}/h/{brain_id}/api/state?since=0', token=ses.get('token'))
phone.update(next((x for x in (view or {}).get('approvals', []) if x.get('bot') == 1), {}) if code == 200 else {'code': code, 'body': str(view)[:200]})
sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': 'once'})
wait_turn(during=on_handoff)
t = ' '.join(tool_text_after('在我的電腦 SC_HANDOFF'))
check('hand-off shows as 需要處理 with the instruction and computer', got.get('kind') == 'handoff' and got.get('reason') == 'E2E:請按「我完成了」' and got.get('grant') == HANDS, got)
check('我完成了 lets the bot carry on', '我完成了' in t, t[:300])
check('the phone shows the hand-off with its computer', phone.get('kind') == 'handoff' and phone.get('computer') == HANDS and not phone.get('can_always'), phone)
# 7. 需要處理: 跳過.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '再一次在我的電腦 SC_HANDOFF'})
wait_turn('reject')
t = ' '.join(tool_text_after('再一次在我的電腦 SC_HANDOFF'))
check('跳過 tells the bot the step is skipped', '跳過' in t, t[:300])
# 8. 需要處理: answered by a message.
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '第三次在我的電腦 SC_HANDOFF'})
def on_reply(a):
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我已經登入好了'})
seen, v = wait_turn(during=on_reply)
t = ' '.join(tool_text_after('第三次在我的電腦 SC_HANDOFF'))
check('a message answers a hand-off', '傳了訊息' in t, t[:300])
ms = full_messages()
check('the reply is in the chat once', sum(1 for m in ms if m['role'] == 'User' and m['text'] == '我已經登入好了') == 1, [m['text'] for m in ms if m['role'] == 'User'])
# 8b. A turn started on one computer; while it waits, the person types on another one:
# 「我的電腦」 in that message is the computer it was typed on (the turn's own idea was fixed
# when it started).
tok2 = h2['token']
sync2 = lambda method, rest='', body=None, q='': http(method, f'{RURL}/relay/hosts/{brain_id}/api/sync{rest}{q}', body, token=tok2)
sync2('POST', '/act', {'bot': 1, 'action': 'send', 'text': '另一台:在這台 E2E另一台 請我接手'})
def steer_from_hands(a):
if a.get('kind') == 'handoff':
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '算了,在我的電腦跑指令 SC_SHELL 插話'})
else:
sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': 'once'})
wait_turn(during=steer_from_hands)
t = ' '.join(tool_text_after('SC_SHELL 插話'))
check('a message typed on another computer mid-turn: 「我的電腦」 is that one', 'e2e-42' in t and f'[{HANDS}]' in t and '這一步你指定了' not in t, t[:300])
# 9. The screen's copy, kept from small updates, matches the brain's chat exactly.
for _ in range(3):
pull()
mine = copy['msgs'].get(1, [])
full = full_messages()
sig = lambda ms: [(m['role'], m['text'], m.get('at')) for m in ms]
check('screen copy equals the brain chat (no duplicates)', sig(mine) == sig(full), f'{len(mine)} vs {len(full)}')
users = [m['text'] for m in full if m['role'] == 'User']
check('no user message twice', len(users) == len(set(users)), users)
# 10. Choosing a computer from the screen reaches the brain.
sync('POST', '/edit', {'client': 'screen', 'seq': 1, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': HANDS}]})
time.sleep(1.0)
c = pull(full=True, client='check2')['computers']['1']
check('the computer chosen on the screen is the brain\'s choice', c['current'] == HANDS, c)
sync('POST', '/edit', {'client': 'screen', 'seq': 2, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': 'Bot 的電腦'}]})
time.sleep(1.0)
c = pull(full=True, client='check3')['computers']['1']
check('and back to its own', c['current'] == 'Master 的電腦', c)
# 11. The hands go away: plain words, not codes.
stop('hands')
time.sleep(10)
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 二'})
wait_turn('once', timeout=120)
t = ' '.join(tool_text_after('SC_SHELL 二'))
check('hands offline: the bot hears it in words', ('連不到' in t or '離線' in t) and 'e2e-42' not in t and '這一步你指定了' not in t and not any(code in t for code in ('503', '502', '404')), t[:300])
start('hands', [BIN, '--serve'], henv)
for _ in range(40):
if peers().get(HANDS, {}).get('online'):
break
time.sleep(0.5)
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 三'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_SHELL 三'))
check('hands back: works again', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300])
# 12. The brain restarts: the screen picks everything up again, and work goes on.
before = len(full_messages())
stop('brain')
time.sleep(3)
start('brain', [BIN, '--serve'], benv)
for _ in range(60):
try:
if peers().get('E2E大腦', {}).get('online'):
v = pull()
break
except Exception:
pass
time.sleep(0.5)
time.sleep(2)
pull()
check('after a brain restart the screen has the whole chat', len(copy['msgs'].get(1, [])) >= before, f'{len(copy["msgs"].get(1, []))} < {before}')
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 四'})
wait_turn('once')
t = ' '.join(tool_text_after('SC_SHELL 四'))
check('after a brain restart, the first 「我的電腦」 is the computer the person typed on', 'e2e-42' in t and f'[{HANDS}]' in t and '這一步你指定了' not in t, t[:300])
pull(); pull()
check('screen copy still equals the brain chat', sig(copy['msgs'].get(1, [])) == sig(full_messages()))
# 13. 清除聊天記錄 from the screen clears the brain's chat.
sync('POST', '/act', {'bot': 1, 'action': 'clear'})
time.sleep(1.5)
check('clear from the screen empties the brain chat', full_messages() == [])
pull(); pull()
check('and the screen copy follows', copy['msgs'].get(1, []) == [], len(copy['msgs'].get(1, [])))
try:
run()
except Exception as e:
import traceback; traceback.print_exc()
check('harness ran to the end', False, repr(e))
finally:
for n in list(procs):
stop(n)
failed = [r for r in results if not r[1]]
print(f'\n{len(results) - len(failed)}/{len(results)} passed; logs in {WORK}')
sys.exit(1 if failed else 0)

View File

@ -0,0 +1,16 @@
# The remote end-to-end test (../remote_e2e.py) on a Linux Wayland desktop: a headless sway in
# Docker whose runtime folder (XDG_RUNTIME_DIR) is full, as on a desktop that once crashed when
# AnyBot took a screenshot. Brain, relay and devices are the server images' own builds.
# On the server, after `docker compose build` in deploy/server, from app/:
# docker build -t anybot-wl-e2e deploy/test/wayland
# docker run --rm --user 1000:1000 --tmpfs /run/xdg:size=4m,mode=0700,uid=1000,gid=1000 \
# -v $PWD/deploy:/e2e/app/deploy:ro anybot-wl-e2e dbus-run-session -- /e2e/app/deploy/test/wayland/run.sh
# (add -v <an older anybot>:/old/anybot:ro to see how that one does with the full folder)
FROM server-relay:latest AS relay
FROM server-anybot:latest
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends sway foot python3 dbus at-spi2-core fonts-dejavu-core procps && rm -rf /var/lib/apt/lists/*
COPY --from=relay /usr/local/bin/anybot-relay /usr/local/bin/anybot-relay
RUN mkdir -p /e2e/app/target/release /e2e/app/relay/target/release \
&& ln -s /usr/local/bin/anybot /e2e/app/target/release/anybot \
&& ln -s /usr/local/bin/anybot-relay /e2e/app/relay/target/release/anybot-relay
RUN (getent passwd 1000 >/dev/null || useradd -u 1000 -m tester)

24
deploy/test/wayland/run.sh Executable file
View File

@ -0,0 +1,24 @@
#!/bin/sh
# Inside the container: a headless sway desktop with a full runtime folder (like the crashed one).
export XDG_RUNTIME_DIR=/run/xdg HOME=/tmp/home
mkdir -p $HOME
export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman
sway -c /e2e/app/deploy/test/wayland/sway.conf > /tmp/sway.log 2>&1 &
SWAY=$!
for i in $(seq 1 50); do ls $XDG_RUNTIME_DIR | grep -q '^wayland-[0-9]*$' && break; sleep 0.2; done
export WAYLAND_DISPLAY=$(ls $XDG_RUNTIME_DIR | grep '^wayland-[0-9]*$' | head -1)
sleep 2
dd if=/dev/zero of=$XDG_RUNTIME_DIR/filler bs=64k 2>/dev/null
echo "== runtime folder: $(df -h $XDG_RUNTIME_DIR | tail -1) display=$WAYLAND_DISPLAY"
alive() { kill -0 $SWAY 2>/dev/null && echo " sway still running" || { echo " SWAY DIED"; tail -5 /tmp/sway.log; }; }
shot() {
timeout 30 "$1" --tool computer_use_screenshot '{}' > /tmp/shot-$2.txt 2>&1
echo "== $2 build, screenshot with the folder full: exit $? :: $(head -c 160 /tmp/shot-$2.txt | tr '\n' ' ')"
alive
}
shot /usr/local/bin/anybot new
echo "== full E2E (brain + relay + 2 devices), with a remote screenshot"
cd /e2e/app && E2E_SCREENSHOT=1 E2E_TYPE=1 E2E_APP=foot python3 -I deploy/test/remote_e2e.py /e2e/app 2>&1 | tail -40
alive
[ -d /out ] && cp /tmp/anybot-e2e-*/shot.jpg /out/ 2>/dev/null
if [ -x /old/anybot ]; then shot /old/anybot old; fi

View File

@ -0,0 +1,2 @@
output HEADLESS-1 resolution 1280x800
exec foot -e sh -c 'echo AnyBot Wayland E2E; sleep 100000'

22
icons/anybot-full.svg Normal file
View File

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- AnyBot's icon edge to edge (web, iPhone home screen, Android): the same drawing as the app icon. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3b3e47"/>
<stop offset="1" stop-color="#121317"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.18" r="0.75">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="240" x2="0" y2="817">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dcdde3"/>
</linearGradient>
<filter id="soft" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="19.8"/></filter>
</defs>
<rect x="0" y="0" width="1024" height="1024" rx="0" fill="url(#bg)"/>
<rect x="0" y="0" width="1024" height="1024" rx="0" fill="url(#glow)"/>
<ellipse cx="512.0" cy="826.9" rx="257.4" ry="36.3" fill="#000" fill-opacity="0.45" filter="url(#soft)"/><g fill="url(#body)"><circle cx="363.5" cy="569.5" r="181.5"/><circle cx="660.5" cy="569.5" r="181.5"/><circle cx="512.0" cy="454.0" r="214.5"/><circle cx="512.0" cy="635.5" r="181.5"/></g><ellipse cx="422.9" cy="526.6" rx="36.3" ry="72.6" transform="rotate(-18 422.9 526.6)" fill="#17171a"/><circle cx="411.3" cy="490.3" r="9.9" fill="#ffffff" fill-opacity="0.9"/><ellipse cx="601.1" cy="526.6" rx="36.3" ry="72.6" transform="rotate(-18 601.1 526.6)" fill="#17171a"/><circle cx="589.6" cy="490.3" r="9.9" fill="#ffffff" fill-opacity="0.9"/>
<rect x="1.5" y="1.5" width="1021" height="1021" rx="-1.5" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,13 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- AnyBot's app icon: the bot's face (two eyes) on a dark rounded square. -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- AnyBot's app icon (macOS grid: an 824 tile in 1024): the bot's cloud body and slanted eyes, as in the app's avatars. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3a3a3a"/>
<stop offset="1" stop-color="#1c1c1c"/>
<stop offset="0" stop-color="#3b3e47"/>
<stop offset="1" stop-color="#121317"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.18" r="0.75">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="309" x2="0" y2="768">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dcdde3"/>
</linearGradient>
<filter id="soft" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="15.7"/></filter>
</defs>
<rect x="32" y="32" width="448" height="448" rx="112" fill="url(#bg)"/>
<rect x="32.5" y="32.5" width="447" height="447" rx="111.5" fill="none" stroke="#ffffff" stroke-opacity="0.08"/>
<ellipse cx="200" cy="262" rx="30" ry="50" fill="#ffffff" transform="rotate(-12 200 262)"/>
<ellipse cx="312" cy="262" rx="30" ry="50" fill="#ffffff" transform="rotate(-12 312 262)"/>
<rect x="100" y="100" width="824" height="824" rx="186" fill="url(#bg)"/>
<rect x="100" y="100" width="824" height="824" rx="186" fill="url(#glow)"/>
<ellipse cx="512.0" cy="775.7" rx="204.4" ry="28.8" fill="#000" fill-opacity="0.45" filter="url(#soft)"/><g fill="url(#body)"><circle cx="394.1" cy="571.3" r="144.1"/><circle cx="629.9" cy="571.3" r="144.1"/><circle cx="512.0" cy="479.6" r="170.3"/><circle cx="512.0" cy="623.7" r="144.1"/></g><ellipse cx="441.3" cy="537.2" rx="28.8" ry="57.6" transform="rotate(-18 441.3 537.2)" fill="#17171a"/><circle cx="432.1" cy="508.4" r="7.9" fill="#ffffff" fill-opacity="0.9"/><ellipse cx="582.7" cy="537.2" rx="28.8" ry="57.6" transform="rotate(-18 582.7 537.2)" fill="#17171a"/><circle cx="573.6" cy="508.4" r="7.9" fill="#ffffff" fill-opacity="0.9"/>
<rect x="101.5" y="101.5" width="821" height="821" rx="184.5" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="3"/>
</svg>

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
mobile/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
mobile/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
mobile/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

22
mobile/icon.svg Normal file
View File

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- AnyBot's icon for browser tabs: the same drawing with rounded corners. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3b3e47"/>
<stop offset="1" stop-color="#121317"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.18" r="0.75">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="240" x2="0" y2="817">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dcdde3"/>
</linearGradient>
<filter id="soft" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="19.8"/></filter>
</defs>
<rect x="0" y="0" width="1024" height="1024" rx="230" fill="url(#bg)"/>
<rect x="0" y="0" width="1024" height="1024" rx="230" fill="url(#glow)"/>
<ellipse cx="512.0" cy="826.9" rx="257.4" ry="36.3" fill="#000" fill-opacity="0.45" filter="url(#soft)"/><g fill="url(#body)"><circle cx="363.5" cy="569.5" r="181.5"/><circle cx="660.5" cy="569.5" r="181.5"/><circle cx="512.0" cy="454.0" r="214.5"/><circle cx="512.0" cy="635.5" r="181.5"/></g><ellipse cx="422.9" cy="526.6" rx="36.3" ry="72.6" transform="rotate(-18 422.9 526.6)" fill="#17171a"/><circle cx="411.3" cy="490.3" r="9.9" fill="#ffffff" fill-opacity="0.9"/><ellipse cx="601.1" cy="526.6" rx="36.3" ry="72.6" transform="rotate(-18 601.1 526.6)" fill="#17171a"/><circle cx="589.6" cy="490.3" r="9.9" fill="#ffffff" fill-opacity="0.9"/>
<rect x="1.5" y="1.5" width="1021" height="1021" rx="228.5" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -9,8 +9,9 @@
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg">
<link rel="apple-touch-icon" href="icon.svg">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="icon" href="icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="icon-180.png">
<title>AnyBot</title>
<style>
:root{
@ -38,6 +39,9 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
.icon-btn:active{background:var(--raise)}
.icon-btn svg{width:20px;height:20px}
.scroll{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
/* A list that reaches the bottom of the screen keeps its last row above the iPhone's home bar and
Safari's toolbar (it looked cut off). */
.view > .scroll:last-child{padding-bottom:calc(env(safe-area-inset-bottom) + 16px)}
/* login */
.login{flex:1;display:flex;flex-direction:column;justify-content:center;padding:32px 24px calc(env(safe-area-inset-bottom) + 32px);max-width:420px;margin:0 auto;width:100%}
.logo{width:56px;height:56px;margin-bottom:20px}
@ -53,6 +57,8 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
.row{display:flex;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}
.row:active{background:var(--raise)}
.av{width:42px;height:42px;border-radius:50%;flex:none;display:grid;place-items:center;font-weight:600;color:#fff;font-size:16px;position:relative;box-shadow:inset 0 0 0 1px var(--line)}
.av.face{background:none !important;box-shadow:none;border-radius:0}
.av.face svg{width:100%;height:100%;display:block}
.av.sm{width:26px;height:26px;font-size:12px}
.row .mid{flex:1;min-width:0}
.row .name{font-weight:600;display:flex;gap:6px;align-items:center}
@ -318,6 +324,33 @@ const S = {
};
// The app sends each bot's colour as #rrggbb (the same palette as the window).
const color = (c) => (/^#[0-9a-f]{6}$/i.test(String(c || "")) ? c : "#1c1c1c");
// A bot's face as in the app (src/avatar.rs): its shape in its colour, two slanted oval eyes.
// Drawn in a -1..1 box; the phone used to show the name's first letter in a circle.
function avatarSvg(shape, hex) {
const col = color(hex), P = Math.PI, f = (n) => n.toFixed(3);
const pts = (a) => `<polygon points="${a.map(([x, y]) => f(x) + "," + f(y)).join(" ")}"/>`;
const circ = (x, y, r) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}"/>`;
const range = (n) => [...Array(n).keys()];
let body;
switch (shape) {
case "squircle": body = `<rect x="-0.92" y="-0.92" width="1.84" height="1.84" rx="0.42"/>`; break;
case "capsule": body = `<rect x="-0.675" y="-1" width="1.35" height="2" rx="0.66"/>`; break;
case "egg": body = pts(range(48).map((i) => { const t = i / 48 * 2 * P; return [0.8 * Math.cos(t), 0.08 + (Math.sin(t) < 0 ? 1 : 0.86) * Math.sin(t)]; })); break;
case "hex": body = pts(range(6).map((i) => { const a = i / 6 * 2 * P + P / 6; return [Math.cos(a), Math.sin(a)]; })); break;
case "gem": body = pts([[-0.55, -0.8], [0.55, -0.8], [1, -0.2], [0, 1], [-1, -0.2]]); break;
case "arch": body = pts(range(25).map((i) => { const a = P + i / 24 * P; return [0.85 * Math.cos(a), -0.15 + 0.85 * Math.sin(a)]; }).concat([[0.85, 1], [-0.85, 1]])); break;
case "shield": body = pts([[-0.85, -0.9], [0.85, -0.9], [0.85, 0.05], [0.5, 0.65], [0, 1], [-0.5, 0.65], [-0.85, 0.05]]); break;
case "pebble": body = `<ellipse rx="1" ry="0.78" transform="rotate(-14.3)"/>`; break;
case "cloud": body = circ(-0.45, 0.15, 0.55) + circ(0.45, 0.15, 0.55) + circ(0, -0.2, 0.65) + circ(0, 0.35, 0.55); break;
case "heart": body = circ(-0.45, -0.25, 0.55) + circ(0.45, -0.25, 0.55) + pts([[-0.97, -0.1], [0.97, -0.1], [0, 1]]); break;
case "star": body = pts(range(10).map((i) => { const a = -P / 2 + i * P / 5, r = i % 2 ? 0.48 : 1.05; return [r * Math.cos(a), 0.08 + r * Math.sin(a)]; })); break;
default: body = circ(0, 0, 0.95);
}
const rgb = [1, 3, 5].map((i) => parseInt(col.slice(i, i + 2), 16));
const eye = rgb[0] + rgb[1] + rgb[2] > 600 ? "#1c1c1c" : "#fcfcfc";
const eyes = [-0.27, 0.27].map((x) => `<ellipse cx="${x}" cy="0.02" rx="0.11" ry="0.22" transform="rotate(-20 ${x} 0.02)"/>`).join("");
return `<svg viewBox="-1.1 -1.1 2.2 2.2" aria-hidden="true"><g fill="${col}">${body}</g><g fill="${eye}">${eyes}</g></svg>`;
}
function toast(t) { const e = $("toast"); e.textContent = t; e.classList.add("on"); clearTimeout(toast.t); toast.t = setTimeout(() => e.classList.remove("on"), 2600); }
function show(v) { document.querySelectorAll(".view").forEach((x) => x.classList.toggle("on", x.id === "v-" + v)); S.view = v; if (v !== "screen") stopFrames(); }
@ -379,8 +412,13 @@ function signOut(expired) {
}
$("b-out1").onclick = () => signOut();
// The list waits for a brain that is restarting (or a computer that woke up): it looks again
// every few seconds and goes in by itself; it used to sit on 「離線」 until tapped.
let hostRetry = null;
async function pickHost() {
const me = await api("relay/me");
clearTimeout(hostRetry);
let me;
try { me = await api("relay/me"); } catch (e) { toast(e.message); if (S.token) hostRetry = setTimeout(pickHost, 3000); return; }
const all = me.hosts || [];
// A computer that shows another one's bots (大腦) leads to that brain.
const byId = Object.fromEntries(all.map((h) => [h.id, h]));
@ -399,10 +437,14 @@ async function pickHost() {
}
if (live.length === 1) { S.host = live[0].id; localStorage.setItem("anybot.host", S.host); return start(); }
const note = (h) => { const n = sharers(h.id); return n.length ? " · " + esc(n.join("、")) + " 也用這個大腦" : ""; };
$("hosts").innerHTML = hosts.length ? hosts.map((h) => `<div class="row" data-h="${esc(h.id)}"><div class="av" style="background:${h.online ? "var(--ok)" : "#888"}">${esc((h.name || "?")[0])}</div><div class="mid"><div class="name">${esc(h.name)}</div><div class="last">${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}</div></div></div>`).join("")
: `<div class="empty">這個帳號還沒有連上任何 AnyBot。<br>在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。</div>`;
document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); });
const wanted = (S.host && byId[target(S.host)]) || (all.find((h) => sharers(h.id).length)) || hosts[0];
const waiting = hosts.length && !live.length && wanted
? `<div class="empty">「${esc(wanted.name)}」暫時沒有連線(可能正在重新啟動,或那台電腦睡著了)。<br>連上後會自動進入,不用重新整理。</div>` : "";
$("hosts").innerHTML = waiting + (hosts.length ? hosts.map((h) => `<div class="row" data-h="${esc(h.id)}"><div class="av" style="background:${h.online ? "var(--ok)" : "#888"}">${esc((h.name || "?")[0])}</div><div class="mid"><div class="name">${esc(h.name)}</div><div class="last">${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}</div></div></div>`).join("")
: `<div class="empty">這個帳號還沒有連上任何 AnyBot。<br>在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。</div>`);
document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { clearTimeout(hostRetry); S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); });
show("hosts");
if (S.token && !live.length) hostRetry = setTimeout(pickHost, 3000);
}
// ---------- live state ----------
@ -432,7 +474,7 @@ function apply(v) {
renderList();
if (S.open) {
const b = S.bots.find((x) => x.id === S.open);
if (b) { $("chat-name").textContent = b.name; $("chat-av").textContent = (b.name || "?")[0]; $("chat-av").style.background = color(b.color); }
if (b) { $("chat-name").textContent = b.name; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face"); }
loadChat(); renderAsk();
}
}
@ -443,7 +485,7 @@ function renderList() {
el.innerHTML = S.bots.map((b) => {
const waiting = S.approvals.some((a) => a.bot === b.id);
const state = waiting ? `<span class="wait">等你決定</span>` : b.busy ? `<span class="busy">${esc(b.status || "工作中…")}</span>` : "";
return `<div class="row" data-id="${b.id}"><div class="av" style="background:${color(b.color)}">${esc((b.name || "?")[0])}</div><div class="mid"><div class="name">${esc(b.name)}${b.members ? '<span class="pill">群組</span>' : ""}${b.unread ? '<span class="badge"></span>' : ""}</div><div class="last">${state || esc(b.last || b.description || "")}</div></div></div>`;
return `<div class="row" data-id="${b.id}"><div class="av face">${avatarSvg(b.shape, b.color)}</div><div class="mid"><div class="name">${esc(b.name)}${b.members ? '<span class="pill">群組</span>' : ""}${b.unread ? '<span class="badge"></span>' : ""}</div><div class="last">${state || esc(b.last || b.description || "")}</div></div></div>`;
}).join("");
el.querySelectorAll(".row").forEach((r) => r.onclick = () => openChat(+r.dataset.id));
}
@ -452,7 +494,7 @@ function renderList() {
function openChat(id) {
S.open = id; S.msgs = []; S.pending = []; renderChips();
const b = S.bots.find((x) => x.id === id) || {};
$("chat-name").textContent = b.name || ""; $("chat-av").textContent = (b.name || "?")[0]; $("chat-av").style.background = color(b.color);
$("chat-name").textContent = b.name || ""; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face");
$("msgs").innerHTML = ""; show("chat"); loadChat(true); renderAsk();
api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {});
}
@ -647,8 +689,14 @@ function renderAsk() {
const a = S.approvals.find((x) => x.bot === S.open);
if (!a) { $("ask").innerHTML = ""; return; }
if (S.role === "member") { $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">等擁有者或管理員決定</div></div>`; return; }
// 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過.
if (a.kind === "handoff") {
$("ask").innerHTML = `<div class="ask"><div class="q">需要處理 · ${esc(a.computer || "")}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn" data-screen="1">接管</button><button class="btn main" data-a="once">我完成了</button><button class="btn no" data-a="reject" title="取消此要求且不執行該步驟;Bot 會跳過它並繼續">跳過</button></div></div>`;
$("ask").querySelector("[data-screen]").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
} else {
$("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn main" data-a="once">允許一次</button>${a.can_always ? `<button class="btn" data-a="always">一律允許</button>` : ""}<button class="btn no" data-a="reject">拒絕</button></div></div>`;
$("ask").querySelectorAll("button").forEach((b) => b.onclick = async () => {
}
$("ask").querySelectorAll("button[data-a]").forEach((b) => b.onclick = async () => {
$("ask").innerHTML = "";
try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: b.dataset.a}}); } catch (e) { toast(e.message); }
});
@ -687,7 +735,9 @@ function renderChips() {
}
// ---------- screens ----------
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
// Each bot's own computer goes by the bot's name (「Master 的電腦」).
function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; }
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
$("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list");
document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); });
let busyFrame = false, lastUrl = null;
@ -735,7 +785,7 @@ $("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typeba
$("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); };
$("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } });
$("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); };
// ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };

View File

@ -29,8 +29,10 @@ use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const PAGE: &str = include_str!("../../mobile/index.html");
const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##;
const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="#161616"/><circle cx="48" cy="48" r="24" fill="none" stroke="#f5f5f5" stroke-width="7"/><circle cx="48" cy="48" r="7" fill="#f5f5f5"/></svg>"##;
const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##;
/// AnyBot's icon (the same drawing as the app's), and PNGs for the iPhone home screen and Android.
const ICON: &str = include_str!("../../mobile/icon.svg");
const ICON_PNGS: [(&str, &[u8]); 3] = [("icon-180.png", include_bytes!("../../mobile/icon-180.png")), ("icon-192.png", include_bytes!("../../mobile/icon-192.png")), ("icon-512.png", include_bytes!("../../mobile/icon-512.png"))];
/// How long a host's "next request?" waits before answering "nothing".
const HOST_POLL_SECS: u64 = 25;
@ -124,7 +126,7 @@ self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
self.addEventListener("push", (e) => {
let d = {};
try { d = e.data ? e.data.json() : {}; } catch (_) { d = {body: e.data ? e.data.text() : ""}; }
e.waitUntil(self.registration.showNotification(d.title || "AnyBot", {body: d.body || "", tag: "bot-" + (d.bot || 0), renotify: true, data: d, icon: "icon.svg", badge: "icon.svg"}));
e.waitUntil(self.registration.showNotification(d.title || "AnyBot", {body: d.body || "", tag: "bot-" + (d.bot || 0), renotify: true, data: d, icon: "icon-192.png", badge: "icon-192.png"}));
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
@ -497,6 +499,7 @@ impl Relay {
.with_header("Strict-Transport-Security", "max-age=31536000"),
("GET", ["manifest.webmanifest"]) => Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
("GET", ["icon.svg"]) => Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()),
("GET", ["api", "hello"]) => Resp::json(&json!({"app": "anybot-relay"})),
("GET", ["sw.js"]) => Resp::bytes("text/javascript; charset=utf-8", SW.as_bytes().to_vec()).with_header("Service-Worker-Allowed", "/"),
("GET", ["relay", "push", "key"]) => Resp::json(&json!({"key": self.vapid.public_b64})),

View File

@ -34,6 +34,8 @@ pub struct Job {
/// Computers this bot may work on (empty when computer tools are off).
pub computers: Vec<tools::Target>,
pub default_computer: String,
/// Which computer is the bot's own, which one the person is using, and whether this is a server.
pub roles: tools::Roles,
/// 在這台電腦上執行 is on.
pub local_execution: bool,
/// This bot's computer tools are on (when off, using them asks the person first).
@ -110,9 +112,7 @@ fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)
fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| {
format!("沒有「{want}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、"))
})
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want))
}
enum BrowserAt {
@ -121,23 +121,29 @@ enum BrowserAt {
Device(String),
}
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer.
fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt {
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer; a
/// computer without a browser (a server, an SSH one) hands it to the person's computer.
fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) {
if name == "web_fetch" {
return BrowserAt::Here;
return (BrowserAt::Here, String::new());
}
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
let any_device = || job.computers.iter().find_map(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
});
match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) {
Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()),
Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box,
// No browser here (a brain on a server): the 手腳 computer's.
_ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here),
_ => BrowserAt::Here,
let at = |t: &tools::Target| -> Option<BrowserAt> {
match &t.place {
tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())),
tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box),
tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
_ => None,
}
};
if let Some((b, n)) = job.computers.iter().find(|t| t.name == want).and_then(|t| at(t).map(|b| (b, t.name.clone()))) {
return (b, n);
}
let person = roles_now(job).person.and_then(|p| job.computers.iter().find(|t| t.name == p));
match person.and_then(|t| at(t).map(|b| (b, t.name.clone()))).or_else(|| job.computers.iter().find_map(|t| at(t).map(|b| (b, t.name.clone())))) {
Some(x) => x,
None => (BrowserAt::Here, String::new()),
}
}
@ -172,54 +178,102 @@ fn finds(hay: &str, needle: &str) -> bool {
false
}
/// Words that mean a computer in the person's message.
fn mentions(text: &str, t: &tools::Target) -> bool {
/// Words that mean a computer in the person's message: its name (or the part after 「的」, so
/// 「Mac mini」 names 「王小明的Mac mini」), and for the bot's own computer and the one the person
/// is using, the everyday words for them.
fn mentions(text: &str, t: &tools::Target, roles: &tools::Roles) -> bool {
let low = text.to_lowercase();
if finds(&low, &t.name.to_lowercase()) {
let name = t.name.to_lowercase();
if finds(&low, &name) {
return true;
}
let words: &[&str] = match &t.place {
_ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"],
_ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"],
tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()),
_ => &[],
if let Some((_, tail)) = name.rsplit_once('的') {
let tail = tail.trim();
if tail.chars().count() >= 3 && tail != "電腦" && finds(&low, tail) {
return true;
}
}
if finds(&name, "mac") && finds(&low, "mac") {
return true;
}
let words: &[&str] = if t.name == roles.own {
&["bot 的電腦", "bot的電腦", "你的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"]
} else if roles.person.as_deref() == Some(t.name.as_str()) {
&["我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電", "my computer"]
} else if roles.server && matches!(t.place, tools::Place::Local { .. }) {
&["伺服器", "server"]
} else {
&[]
};
if t.name == roles.own && finds(&low, &name.replace(' ', "")) {
return true;
}
words.iter().any(|w| finds(&low, w))
}
/// A step naming a computer other than the chosen one, which the person didn't ask for, goes
/// to the chosen one.
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String {
/// to the chosen one — and the bot is told so (quietly moving it made a bot "ask your Mac" six
/// times while every step landed on its own computer).
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option<String>) {
let mut v = tools::args_of(args);
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() };
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return (args.to_string(), None) };
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
return args.to_string();
return (args.to_string(), None);
}
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
match job.computers.iter().find(|t| t.name == named) {
Some(t) if mentions(&said, t) => args.to_string(),
Some(t) if mentions(&said, t, &roles_now(job)) => (args.to_string(), None),
// A computer that isn't on the list now (switched off, its AnyBot closed): the step says
// so instead of quietly running somewhere else.
None => (args.to_string(), None),
_ => {
v["computer"] = serde_json::Value::String(job.default_computer.clone());
v.to_string()
let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer);
(v.to_string(), Some(note))
}
}
}
/// What element `e7` is, from the latest app list in the chat (「按鈕「7」」).
fn element_named(history: &[Message], r: &str) -> Option<String> {
let tag = format!("{r} ");
history.iter().rev().filter(|m| m.role == Role::Tool).take(12).find_map(|m| {
m.text.lines().find_map(|l| l.trim_start().strip_prefix(&tag).map(|rest| rest.trim().to_string())).filter(|e| !e.is_empty())
})
}
/// A step's result, with a word first when the step was moved back to the chosen computer.
fn noted(moved: &std::collections::HashMap<String, String>, id: &str, text: &str) -> String {
match moved.get(id) {
Some(n) => format!("{n}{text}"),
None => text.to_string(),
}
}
/// A computer the step names that isn't among this bot's computers right now, in plain words.
fn not_here(job: &Job, name: &str) -> String {
format!("「{name}」現在連不到:那台電腦可能沒開,或上面的 AnyBot 沒開、沒登入。請告訴使用者,等它連上再做(或問他要不要改用別台)。現在可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、"))
}
/// Which computer is the person's right now: a message typed on one of the account's computers
/// while the turn runs (a steer, or the answer to a hand-off) moves it there — the turn's own
/// idea was fixed when it started, maybe from the phone, which isn't a computer.
fn roles_now(job: &Job) -> tools::Roles {
let mut r = job.roles.clone();
let host = crate::remote::person_host();
if !host.is_empty() {
if let Some(t) = job.computers.iter().find(|t| matches!(&t.place, tools::Place::Device { host: h } if *h == host)) {
r.person = Some(t.name.clone());
}
}
r
}
/// Does the call name a computer?
fn names_a_computer(args: &str) -> bool {
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
}
/// The other computer (裝置連結) a call names, if it names one.
fn device_named(job: &Job, args: &str) -> Option<String> {
let v = tools::args_of(args);
let n = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty())?;
job.computers.iter().find(|t| t.name == n).and_then(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
})
}
/// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated).
/// Which screen a computer_use call is for: the one named, else the bot's default when it has a
/// screen, else the bot's own desktop when there is one, else this Mac.
@ -228,10 +282,10 @@ fn screen_target(job: &Job, args: &str) -> Result<tools::Target, String> {
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. });
let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
if let Some(n) = named {
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦"))?;
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| not_here(job, n))?;
if !has_screen(t) {
return Err(if n == crate::model::BOT_COMPUTER_NAME {
if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() }
return Err(if n == job.roles.own {
format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" })
} else {
format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。")
});
@ -383,8 +437,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
return;
}
let mut out = out;
// Steps moved back to the chosen computer: what the bot is told with each result.
let mut moved: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for c in out.tool_calls.iter_mut() {
c.args = keep_to_chosen(&job, &history, &c.args);
let (args, note) = keep_to_chosen(&job, &history, &c.args);
c.args = args;
if let Some(n) = note {
moved.insert(c.id.clone(), n);
}
}
let msg = Message {
role: Role::Assistant,
@ -425,7 +485,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -435,7 +495,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args);
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@ -446,7 +506,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins).
let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
let (place, on) = browser_place(&job, &call.name, &call.args);
let (output, ok, img) = match place {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
@ -454,8 +515,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
};
// Which computer's browser it was, like every other step.
let output = if on.is_empty() { output } else { format!("[{on}] {output}") };
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@ -467,7 +530,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
let what = if is_mac {
format!("{} {}", crate::macapps::label(&call.name), crate::macapps::describe(&call.name, &call.args))
} else if is_screen {
format!("{} {}", computer::label(&call.name), computer::describe(&call.name, &call.args))
// A control by its number: name it from the app's list the bot last read.
let named = tools::args_of(&call.args)["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()).and_then(|r| element_named(&history, r)).map(|e| format!("({e})")).unwrap_or_default();
format!("{} {}{named}", computer::label(&call.name), computer::describe(&call.name, &call.args))
} else if is_web {
web::describe(&call.name, &call.args)
} else {
@ -480,10 +545,50 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason, grant, reply: atx, kind }));
wait_answer(arx, &cancel)
};
// 需要處理 (the original's box hand-off): a step only the person can do — signing
// in, a captcha, a code from their phone. A card with the computer's live screen
// appears in the chat; they take over, do it, and say 「我完成了」 or 「跳過」. A
// message typed meanwhile answers it too.
if call.name == tools::HANDOFF_TOOL {
let instruction = tools::args_of(&call.args)["instruction"].as_str().unwrap_or("").trim().to_string();
let (output, status) = match screen_target(&job, &call.args) {
Err(e) => (e, ToolStatus::Failed),
Ok(_) if instruction.is_empty() => ("請在 instruction 寫清楚要使用者做什麼。".to_string(), ToolStatus::Failed),
Ok(t) => {
let (atx, arx) = channel();
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting));
send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason: instruction.clone(), grant: t.name.clone(), reply: atx, kind: ApprovalKind::Handoff }));
let answer = loop {
if cancel.load(Ordering::Relaxed) {
return;
}
match arx.recv_timeout(Duration::from_millis(200)) {
Ok(a) => break Some(a),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if !job.steer.lock().unwrap().is_empty() {
break None;
}
}
Err(_) => break Some(ApprovalAnswer::Reject),
}
};
match answer {
Some(ApprovalAnswer::Reject) => ("使用者選擇跳過這一步。不要再要求同一件事:沒有它就繼續其餘的工作,做不到的照實說。".to_string(), ToolStatus::Denied),
Some(_) => (format!("[{}] 使用者按了「我完成了」。先截圖確認畫面,再接著做。", t.name), ToolStatus::Done),
None => ("使用者沒有按按鈕,而是傳了訊息(就在下面)。照他說的做。".to_string(), ToolStatus::Done),
}
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 生活卡片: drawn by the window from the call itself.
if call.name == "show_card" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, "卡片已顯示給使用者。");
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, "卡片已顯示給使用者。"));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -493,7 +598,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect();
send(StreamEvent::React(emoji.clone()));
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, &format!("已在使用者的訊息上按了 {emoji}。"));
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &format!("已在使用者的訊息上按了 {emoji}。")));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -510,7 +615,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()),
Ok(_) => Err(format!("這台電腦上的檔案不能直接傳,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
@ -525,7 +630,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -542,12 +647,35 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Err(_) => None,
};
// On a server nobody sits at its screen: a link opens in the browser of the
// computer the person is using; a file is sent into the chat instead.
let person_now = roles_now(&job).person;
let person_host = person_now.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
});
if job.roles.server {
let web = path.starts_with("http://") || path.starts_with("https://");
let (output, ok) = match (&person_host, web) {
(Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) {
Ok(v) => (format!("[{}] {}", person_now.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)),
Err(e) => (e, false),
},
(None, true) => ("不知道使用者現在在用哪台電腦,沒辦法打開給他看。把網址直接寫在回覆裡。".to_string(), false),
(_, false) => ("AnyBot 在伺服器上,檔案沒辦法直接在使用者的螢幕打開。改用 send_file 把檔案傳到聊天裡,使用者點一下就能看。".to_string(), false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
let local = match (&on_box, target_for(&job, &call.args)) {
(Some(p), _) => crate::cloudbox::copy_out(p),
(None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)),
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
_ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()),
_ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)),
};
// Secrets never open on the person's screen either.
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
@ -560,7 +688,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -583,7 +711,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -618,7 +746,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接發布,請先複製到 Bot 的電腦。".to_string()),
Ok(_) => Err(format!("這台電腦上的檔案不能直接發布,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
match local.and_then(|p| crate::remote::publish_site(&name, &p)) {
@ -627,7 +755,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -638,7 +766,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == crate::vault::LIST_TOOL {
let output = crate::vault::list_for_bot(&call.args);
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -696,7 +824,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -731,29 +859,29 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None)
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// The browser tools use the person's own browser, so always this Mac.
// …and so does screen control: it's the person's own screen.
let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned();
// The person's own apps and everyday browser are on the computer they are using:
// the one they name, else the one they typed on (else this one, when it is theirs).
let person_target = || -> Result<tools::Target, String> {
if names_a_computer(&call.args) {
return target_for(&job, &call.args);
}
roles_now(&job)
.person
.and_then(|n| job.computers.iter().find(|t| t.name == n))
.cloned()
.ok_or_else(|| format!("不知道使用者現在在用哪台電腦。請問他要在哪台做(可以用:{})。", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")))
};
let target = if is_screen {
screen_target(&job, &call.args)
} else if is_web && device_named(&job, &call.args).is_some() {
// The browser on another computer of the account, as named.
target_for(&job, &call.args)
} else if is_web && call.name.starts_with("browser_") && person_device.is_some() && !names_a_computer(&call.args) {
// A brain on a server: the browser is the person's computer's (the one they
// can see), unless the server's own is named.
Ok(person_device.clone().unwrap())
} else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) {
// No browser here (a brain on a server): the 手腳 computer's.
job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else if is_mac || web::uses_this_computer(&call.name, &call.args) {
job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else if is_mac || is_web {
// (Other browser steps went their own way above; this is the person's own Chrome.)
person_target()
} else {
target_for(&job, &call.args)
};
@ -790,17 +918,17 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
// An admin lock (policy) refuses outright instead of asking.
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked {
gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, crate::model::BOT_COMPUTER_NAME));
gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, job.roles.own));
}
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on {
let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name);
let reason = format!("在「{}」上執行任務,而不只是在它自己的電腦上:{}{what}", t.name, if is_web { format!("{} ", web::label(&call.name)) } else { String::new() });
match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) {
ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => {
local_on = true;
said_yes = true;
}
ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)),
ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, job.roles.own)),
}
}
}
@ -934,9 +1062,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Ok(()), Ok(t)) => {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
let (out, ok) = if is_mac {
let (out, ok) = if is_mac && on_device.is_none() {
crate::macapps::run(&call.name, &call.args)
} else if let (true, Some(host)) = (is_screen || is_web, on_device.as_ref()) {
} else if let (true, Some(host)) = (is_screen || is_web || is_mac, on_device.as_ref()) {
match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) {
Ok(v) => {
image = v["image"].as_str().map(String::from);
@ -967,7 +1095,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Err(x), _) => x,
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(image);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@ -988,6 +1116,28 @@ mod tests {
use crate::model::ToolCall;
use crate::providers::ProviderKind;
/// A job with nothing in it, for tests that only need a few fields.
fn test_job() -> Job {
Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: String::new(), api_key: String::new(), model: String::new(), system: String::new(), history: vec![], tools: vec![], session: String::new() },
computers: vec![],
default_computer: String::new(),
roles: tools::Roles::default(),
local_execution: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
via: String::new(),
via_name: String::new(),
tools_enabled: true,
direct: Arc::new(AtomicBool::new(false)),
local_locked: false,
steer: Arc::new(Mutex::new(vec![])),
review: None,
demos: vec![],
}
}
#[test]
fn computer_words_need_word_boundaries() {
// "mac" as its own word names this computer; inside longer words (macOS, a device
@ -1000,9 +1150,60 @@ mod tests {
assert!(finds("我的電腦好慢", "我的電腦"));
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } };
assert!(mentions("幫我在 Mac 截圖", &t));
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t));
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
assert!(mentions("幫我在 Mac 截圖", &mac, &roles));
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
}
#[test]
fn a_click_by_number_is_named_in_the_question() {
let list = Message::tool_result("c1", "computer_use_read_app", "[王小明的Mac mini] 「計算機」的畫面:\n 文字「0」\n e4 按鈕「全部清除」\n e7 按鈕「7」");
assert_eq!(element_named(&[list.clone()], "e7").as_deref(), Some("按鈕「7」"));
assert_eq!(element_named(&[list], "e9"), None);
assert_eq!(computer::describe("computer_use_click", r#"{"ref":"e7"}"#), "點 e7");
assert_eq!(computer::describe("computer_use_click", r#"{"x":10,"y":20}"#), "點 (10, 20)");
}
#[test]
fn every_computer_answers_to_its_own_name() {
// One name per computer: the bot's own goes by the bot's name, the others by theirs, and
// the everyday words point at the right one (「我的電腦」 = where the person typed).
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } };
let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into() } };
let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } };
assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles));
assert!(mentions("用master的電腦", &own, &roles));
assert!(mentions("你都用 Bot的電腦", &own, &roles));
assert!(mentions("在 Mac mini 上開", &mac, &roles));
assert!(mentions("截圖我的電腦", &mac, &roles));
assert!(!mentions("截圖我的電腦", &desk, &roles));
assert!(!mentions("截圖我的電腦", &own, &roles));
assert!(mentions("在 ai 上跑", &desk, &roles));
assert!(!mentions("用 AI 幫我想標題", &mac, &roles));
assert!(mentions("在伺服器上看看磁碟", &server, &roles));
}
#[test]
fn a_step_moved_back_to_the_chosen_computer_says_so() {
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let job = Job {
computers: vec![
tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } },
tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } },
],
default_computer: "Master 的電腦".into(),
roles,
..test_job()
};
// Not asked for: back to the chosen one, with a word for the bot.
let (args, note) = keep_to_chosen(&job, &[Message::user("幫我截圖")], r#"{"computer":"王小明的Mac mini"}"#);
assert!(args.contains("Master 的電腦") && note.unwrap().contains("王小明的Mac mini"));
// Named by the person: it stays.
let (args, note) = keep_to_chosen(&job, &[Message::user("看一下我的電腦畫面")], r#"{"computer":"王小明的Mac mini"}"#);
assert!(args.contains("王小明的Mac mini") && note.is_none());
}
#[test]
@ -1069,6 +1270,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: true,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
@ -1140,6 +1342,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() },
computers: vec![],
default_computer: crate::model::BOT_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
@ -1217,6 +1420,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("看看我的電腦")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(false)),
@ -1249,6 +1453,71 @@ mod tests {
assert!(result.contains("note.txt"), "{result}");
}
/// 需要處理: the model hands a step to the person; the card's request carries the instruction
/// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped.
#[test]
fn a_hand_off_waits_for_the_person_and_says_what_they_chose() {
use std::io::{Read, Write};
use std::net::TcpListener;
for (answer, expect, status) in [(ApprovalAnswer::Once, "我完成了", ToolStatus::Done), (ApprovalAnswer::Reject, "跳過", ToolStatus::Denied)] {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let replies = [
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"h1\",\"function\":{\"name\":\"hand_over_computer\",\"arguments\":\"{\\\"instruction\\\":\\\"請登入 Threads\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\ndata: [DONE]\n\n",
];
for body in replies {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let t = String::from_utf8_lossy(&req).to_string();
if let Some(end) = t.find("\r\n\r\n") {
let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
}
});
let 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("幫我登入 Threads")], tools: vec![], session: String::new() },
computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }],
default_computer: "王小明的Mac mini".into(),
roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true },
..test_job()
};
let (tx, rx) = channel();
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
let (mut asked, mut result, mut last) = (None, String::new(), None);
loop {
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
(_, StreamEvent::NeedApproval(a)) => {
asked = Some((a.kind, a.reason.clone(), a.grant.clone()));
let _ = a.reply.send(answer.clone());
}
(_, StreamEvent::ToolStatus(_, st)) => last = Some(st),
(_, StreamEvent::ToolResult(m)) => result = m.text,
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
assert_eq!(asked, Some((ApprovalKind::Handoff, "請登入 Threads".to_string(), "王小明的Mac mini".to_string())));
assert!(result.contains(expect), "{result}");
assert_eq!(last, Some(status));
}
}
/// 直接操作: tools off, this Mac off, a command that changes files — and still no question.
/// Dangerous commands stay blocked.
#[test]
@ -1290,6 +1559,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(true)),

View File

@ -44,6 +44,9 @@ pub const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0;
pub const SIDEBAR_MIN_WIDTH: f32 = 240.0;
pub const SIDEBAR_MAX_WIDTH: f32 = 400.0;
pub const SIDEBAR_RAIL_WIDTH: f32 = 88.0;
/// Mac: the window has no title bar of its own (the content reaches the top); this strip at the
/// top moves the window, and the traffic lights sit in its left corner.
pub const TITLEBAR_H: f32 = 28.0;
pub const SIDEBAR_COLLAPSE_BELOW: f32 = 210.0;
pub const SIDEBAR_EXPAND_FROM: f32 = 220.0;
/// `--sand-chat-min-width:424px` (index CSS, class sand-1fiktjy).
@ -180,6 +183,9 @@ pub struct App {
rm: remote_ui::RemoteUi,
/// Running as `anybot --serve` (no window; the phone is the screen).
headless: bool,
/// The computer the person last typed to a bot on: an account computer's id, "" for this
/// one, None when not known (the phone isn't a computer). It is "我的電腦" to the bots.
typed_on: Option<String>,
/// 右側面板 and its tab (None = closed).
panel: Option<panel_ui::PanelTab>,
/// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in).
@ -277,6 +283,7 @@ impl App {
msg_index: 0,
rm: Default::default(),
headless: false,
typed_on: None,
panel: None,
popped: HashMap::new(),
appeared: HashMap::new(),
@ -296,6 +303,12 @@ impl App {
};
crate::i18n::set(&app.state.language);
app.computers_start();
// The computer the person used last time (a brain that restarted for an update).
if !app.demo {
if let Some(h) = crate::remote::load_person_host() {
app.typed_on = Some(h);
}
}
if demo {
// The computer helper's pending step, so the picture shows the approval card.
let (reply, _) = channel();
@ -487,6 +500,13 @@ impl App {
}
}
}
// 需要處理: the bot waits for the person on its own computer (the hand-off card).
Ok("handoff") => {
app.state.selected = 1;
let (reply, _) = channel();
let own = app.state.bot(1).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_default();
app.approvals.insert(1, Approval { call_id: "h1".into(), tool: tools::HANDOFF_TOOL.into(), reason: "請在 Threads 登入頁輸入帳號和密碼後登入,好了按「我完成了」。".into(), grant: own, reply, kind: ApprovalKind::Handoff });
}
Ok("panel") => {
// The real box, so the 電腦 card shows its screen.
crate::cloudbox::start_background();
@ -546,6 +566,13 @@ impl App {
app.show_settings = true;
app.settings_tab = SettingsTab::Remote;
}
// …or the side panel, or the computer viewer, as they look with that data.
if app.screenshot.is_some() && !app.demo {
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
Ok("panel") => app.panel = Some(panel_ui::PanelTab::Activity),
_ => {}
}
}
app
}
@ -638,6 +665,8 @@ impl App {
return;
}
self.drafts.insert(bot_id, String::new());
// Typed here: this computer is the person's.
self.typed_on = Some(String::new());
// While it works, a new message steers the running turn (the original's `turnSteer`:
// it doesn't count as a new turn) and reaches the model at its next step.
if let Some(l) = self.streams.get(&bot_id) {
@ -685,24 +714,42 @@ impl App {
// Computer tools are always offered; if they're off for this bot (or this Mac isn't
// allowed yet), using one pops up a request instead of failing quietly.
let computers = self.targets_for(&b);
let roles = self.roles_for(&b, &computers);
let names: Vec<String> = computers.iter().map(|t| t.name.clone()).collect();
let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() };
let (default_computer, missing_computer) = Self::chosen_computer(&b, &computers, &roles);
let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer));
let devices: Vec<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect();
let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into());
tool_specs.extend(web::specs_for(&here, &devices));
// Screen tools: the bot's own desktop (when it has one) and this Mac.
let mut screens = vec![];
if crate::cloudbox::available() {
screens.push(crate::model::BOT_COMPUTER_NAME.to_string());
}
if crate::computer::screen_supported() {
screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()));
}
// …and the account's other computers (手腳): their own AnyBot looks and clicks there.
screens.extend(devices.iter().cloned());
if cfg!(target_os = "macos") {
// Browsers: the bot's own computer's Chromium, this computer's AnyBot browser, and the
// account's other computers' ones (each with its own logins).
let browsers: Vec<String> = computers
.iter()
.filter(|t| match t.place {
tools::Place::Box { .. } => crate::cloudbox::available(),
tools::Place::Local { .. } => crate::botbrowser::available(),
tools::Place::Device { .. } => true,
_ => false,
})
.map(|t| t.name.clone())
.collect();
let browser_default = if browsers.contains(&default_computer) { default_computer.clone() } else { roles.person.clone().filter(|p| browsers.contains(p)).or_else(|| browsers.first().cloned()).unwrap_or_default() };
tool_specs.extend(web::specs_for(&browsers, &browser_default));
// Screens: the bot's own desktop (when it has one), this computer's (when it has one), and
// the account's other computers (their own AnyBot looks and clicks there).
let screens: Vec<String> = computers
.iter()
.filter(|t| match t.place {
tools::Place::Box { .. } => crate::cloudbox::available(),
tools::Place::Local { .. } => crate::computer::screen_supported(),
tools::Place::Device { .. } => true,
_ => false,
})
.map(|t| t.name.clone())
.collect();
// The person's Mac apps (mail, notes, calendar…): here on a Mac, or on the computer the
// person is using when this is a server.
let person_is_device = roles.person.as_ref().map(|n| computers.iter().any(|t| &t.name == n && matches!(t.place, tools::Place::Device { .. }))).unwrap_or(false);
let mac_apps = cfg!(target_os = "macos") || (roles.server && person_is_device);
if mac_apps {
tool_specs.extend(crate::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE));
}
tool_specs.extend(crate::mcp::specs());
@ -732,6 +779,13 @@ impl App {
if !screens.is_empty() {
let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() };
tool_specs.extend(crate::computer::specs_for(&screens, &default_screen));
// 需要處理: hand a step to the person on that screen (the original's box hand-off).
tool_specs.push(serde_json::json!({"name": tools::HANDOFF_TOOL,
"description": "Ask the person to do one step themselves on a computer's screen: signing in (you never type their passwords), a captcha or puzzle, a code sent to their phone or email, approving a payment, or anything else only they may do. First get the computer to that point (open the page, click into the sign-in form), then call this with a short instruction. A card with that computer's live screen appears in the chat; they take over, do it, and press 「我完成了」 (or 「跳過」). The result says which; after 「我完成了」 take a screenshot and carry on. Don't ask them in words to find a window themselves.",
"parameters": {"type": "object", "properties": {
"instruction": {"type": "string", "description": "What they should do, one or two short sentences in their language, e.g. 「請在 Threads 登入頁輸入帳號和密碼後登入」."},
"computer": {"type": "string", "enum": screens.clone(), "description": format!("Which computer's screen. Default: {default_screen}.")}},
"required": ["instruction"]}}));
}
// 看圖小幫手: the person's models that may see pictures, for a bot whose model can't.
providers::set_eyes(self.eyes());
@ -741,7 +795,7 @@ impl App {
api_key: providers::resolve_key(b.provider, &ps.api_key),
model: b.model.clone(),
system: {
let mut sys = system_prompt(&b, &self.state, &computers, &default_computer);
let mut sys = system_prompt(&b, &self.state, &computers, &default_computer, &roles, missing_computer.as_deref(), &self.computer_lines(&computers, &roles), mac_apps);
if speaker.is_some() {
let names: Vec<String> = conv.members.iter().filter_map(|m| self.state.bot(*m)).map(|x| x.name.clone()).collect();
sys.push_str(&format!("\n\n你在群組聊天「{}」裡,成員依序回答:{}。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。", conv.name, names.join("、")));
@ -767,6 +821,7 @@ impl App {
req,
computers,
default_computer,
roles,
local_execution: self.local_on(),
local_locked: !self.managed.local.allows() || self.state.local_never,
tools_enabled: b.tools_enabled,
@ -790,64 +845,207 @@ impl App {
self.mark();
}
/// The computers a bot can be told to use (its own, this Mac, then SSH ones).
fn targets_for(&self, b: &Bot) -> Vec<tools::Target> {
let mut v = vec![
tools::Target {
name: crate::model::BOT_COMPUTER_NAME.into(),
place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
},
tools::Target {
name: crate::model::THIS_COMPUTER_NAME.into(),
place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) },
},
];
v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }));
// 裝置連結: other AnyBots of this account that allow it.
v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::<Vec<_>>());
// A brain on a server whose hands are the person's computer: "你的電腦" is that computer
// (what the person means by "my Mac"), and the server itself goes by its own name.
if !cfg!(target_os = "macos") {
if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() {
if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) {
for t in v.iter_mut() {
if matches!(t.place, tools::Place::Local { .. }) {
t.name = crate::model::BRAIN_HOST_NAME.into();
}
}
v[i].name = crate::model::THIS_COMPUTER_NAME.into();
}
/// The computers a bot can be told to use, each under its own name: the bot's own
/// (「Master 的電腦」), this computer, the SSH ones, then the account's other computers.
pub(super) fn targets_for(&self, b: &Bot) -> Vec<tools::Target> {
let mut v = vec![tools::Target {
name: crate::model::own_computer_name(&b.name),
place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
}];
// Two computers never share a name (the bot and the person name them to choose).
let add = |v: &mut Vec<tools::Target>, mut t: tools::Target| {
let base = t.name.clone();
let mut n = 2;
while v.iter().any(|x| x.name == t.name) {
t.name = format!("{base} ({n})");
n += 1;
}
v.push(t);
};
// A brain on a server works on the bots' own computer and the person's computers, never on
// the server itself (the person asked for it to be off the list).
if !self.headless {
add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } });
}
for c in &self.state.remote_computers {
add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } });
}
// 裝置連結: other AnyBots of this account that allow it.
for d in self.linked_devices() {
add(&mut v, d);
}
v
}
/// 查看電腦: the computers this bot really has, in plain words.
fn computers_text(&self, b: &Bot) -> String {
self.targets_for(b)
.iter()
.map(|t| match &t.place {
tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:你自己的電腦,一律可用", t.name),
tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name),
tools::Place::Local { .. } => format!(
"{}:{}「{}」,{}",
t.name,
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" },
self.state.machine_label,
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
/// Which of these computers is the bot's own, which one the person is using, and whether
/// this AnyBot is a server. The person's computer is the one they last typed on; a server
/// that hasn't heard from any yet takes the account's only other computer, if there is one.
pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
let server = self.headless;
let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone());
let person = match self.typed_on.as_deref() {
Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
_ if !server => local,
// Not heard yet (or only the phone): when the account has one other computer, that
// is it; with more, nobody guesses (the bot asks which one).
_ => {
let mut devices = targets.iter().filter(|t| matches!(t.place, tools::Place::Device { .. }));
match (devices.next(), devices.next()) {
(Some(only), None) => Some(only.name.clone()),
_ => None,
}
}
};
tools::Roles { own: crate::model::own_computer_name(&b.name), person, server }
}
/// The computer the person chose for this bot, by its name now (the bot's own is kept under a
/// fixed key so renaming the bot keeps it), and the chosen one when it isn't connected.
pub(super) fn chosen_computer(b: &Bot, targets: &[tools::Target], roles: &tools::Roles) -> (String, Option<String>) {
let want = b.default_computer.trim();
if want.is_empty() || want == crate::model::BOT_COMPUTER_NAME || want == roles.own {
return (roles.own.clone(), None);
}
// Unless the person chose another computer, a bot works on its own computer (in the
// cloud). 「你的電腦」 from older versions named no computer in particular: the bot's own.
if want == crate::model::THIS_COMPUTER_NAME {
return (roles.own.clone(), None);
}
if targets.iter().any(|t| t.name == want) {
return (want.to_string(), None);
}
(roles.own.clone(), Some(want.to_string()))
}
/// What to keep in `default_computer` for a computer chosen by name.
pub(super) fn computer_to_keep(b: &Bot, name: &str) -> String {
if name == crate::model::own_computer_name(&b.name) {
crate::model::BOT_COMPUTER_NAME.to_string()
} else {
name.to_string()
}
}
/// The computers to choose from for a bot (name, what it is) and the chosen one. A screen of a
/// brain asks the brain (only it knows the account's computers and which one is chosen).
pub(super) fn computer_choices(&self, b: &Bot) -> (Vec<(String, String)>, String) {
if self.mir.on {
let own = crate::model::own_computer_name(&b.name);
return match self.mir.computers.get(&b.id) {
Some(c) => (
c["names"].as_array().map(|a| a.iter().filter_map(|x| Some((x["name"].as_str()?.to_string(), x["kind"].as_str().unwrap_or("").to_string()))).collect()).unwrap_or_default(),
c["current"].as_str().unwrap_or(&own).to_string(),
),
tools::Place::Ssh { target, .. } => format!("{}:透過 SSH 連到 {target}", t.name),
tools::Place::Device { host } => {
let real = crate::remote::peer_name(host);
if t.name == crate::model::THIS_COMPUTER_NAME {
format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name)
} else {
format!("{}:帳號裡的另一台電腦(裝置連結)", t.name)
None => (vec![(own.clone(), "own".into())], own),
};
}
let t = self.targets_for(b);
let r = self.roles_for(b, &t);
let (current, _) = Self::chosen_computer(b, &t, &r);
(t.iter().map(|x| (x.name.clone(), mirror_ui::computer_kind(x, &r).to_string())).collect(), current)
}
/// Choose the computer a bot works on (a screen sends it to the brain with its other edits).
pub(super) fn set_computer(&mut self, bot_id: u64, name: &str) {
if let Some(b) = self.state.bot_mut(bot_id) {
let keep = Self::computer_to_keep(b, name);
if b.default_computer != keep {
b.default_computer = keep;
self.mark();
}
}
// Shown at once on a screen (the brain's answer follows).
if let Some(c) = self.mir.computers.get_mut(&bot_id) {
c["current"] = serde_json::json!(name);
}
}
/// 查看電腦 and the instructions: each computer by its name, what it is, in plain words.
pub(super) fn computer_lines(&self, targets: &[tools::Target], roles: &tools::Roles) -> Vec<String> {
targets
.iter()
.map(|t| {
let yours = roles.person.as_deref() == Some(t.name.as_str());
match &t.place {
tools::Place::Box { dir } => {
let secrets = crate::cloudbox::secret_names();
format!(
"「{}」:你自己的 Linux 電腦(Debian,有桌面和 Chromium;你的資料夾是 {dir},可以 sudo 安裝套件),不是使用者的電腦,一律可用,需要時會自動喚醒。{}",
t.name,
if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) }
)
}
tools::Place::Sealed { root } => format!("「{}」:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕),一律可用。", t.name, root.display()),
tools::Place::Local { workdir } if roles.server => format!("「{}」:AnyBot 大腦所在的伺服器(起始資料夾 {}),不是使用者在用的電腦;只有使用者指名它才用。", t.name, workdir.display()),
tools::Place::Local { workdir } => format!(
"「{}」:AnyBot 開著的這台{}(起始資料夾 {}){},{}。",
t.name,
if cfg!(target_os = "macos") { " Mac" } else { "電腦(快捷鍵用 Ctrl,不是 ⌘)" },
workdir.display(),
if yours { ",使用者現在就在用這台" } else { "" },
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
),
tools::Place::Ssh { target, .. } => format!("「{}」:使用者的另一台電腦,透過 SSH 連到 {target}。", t.name),
tools::Place::Device { .. } => format!(
"「{}」:使用者帳號裡的電腦,透過它上面開著的 AnyBot 操作(檔案、指令、瀏覽器、看畫面和點擊){}。",
t.name,
if yours { ",使用者現在就是在這台跟你說話" } else { "" }
),
}
})
.collect::<Vec<_>>()
.join("\n")
.collect()
}
/// 查看電腦: the computers this bot really has, in plain words.
fn computers_text(&self, b: &Bot) -> String {
let targets = self.targets_for(b);
let roles = self.roles_for(b, &targets);
let (chosen, missing) = Self::chosen_computer(b, &targets, &roles);
let mut lines = self.computer_lines(&targets, &roles);
lines.push(format!("使用者選的電腦:「{chosen}」"));
if let Some(m) = missing {
lines.push(format!("(使用者原本選的「{m}」現在沒有連線:那台的 AnyBot 沒開或沒登入。)"));
}
lines.join("\n")
}
/// update_bot with a computer: the names are this bot's computers (the account's ones too),
/// which only the app knows; the rest of the change goes through appctl as usual.
fn update_bot_computer(&mut self, bot_id: u64, args: &str) -> appctl::Outcome {
let mut v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
let want = v["default_computer"].as_str().unwrap_or("").trim().to_string();
let fail = |text: String| appctl::Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None };
let target = match v["bot"].as_str().map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => match self.state.bots.iter().find(|b| b.name == n) {
Some(b) => b.id,
None => return fail(format!("找不到叫「{n}」的 Bot")),
},
None => bot_id,
};
let Some(b) = self.state.bot(target).cloned() else { return fail("找不到這個 Bot".into()) };
let targets = self.targets_for(&b);
let roles = self.roles_for(&b, &targets);
let name = if want == crate::model::BOT_COMPUTER_NAME || want == crate::model::THIS_COMPUTER_NAME { Self::chosen_computer(&Bot { default_computer: want.clone(), ..b.clone() }, &targets, &roles).0 } else { want.clone() };
if !targets.iter().any(|t| t.name == name) {
return fail(format!("沒有「{want}」這台電腦。可以用:{}", targets.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")));
}
if let Some(bm) = self.state.bot_mut(target) {
bm.default_computer = Self::computer_to_keep(&b, &name);
}
self.mark();
if let Some(o) = v.as_object_mut() {
o.remove("default_computer");
}
let more = v.as_object().map(|o| o.keys().any(|k| k != "bot")).unwrap_or(false);
let note = format!("已把「{}」的電腦改成「{name}」。", b.name);
if more {
let mut out = appctl::apply(&mut self.state, bot_id, "update_bot", &v.to_string());
out.text = format!("{note}{}", out.text);
out
} else {
appctl::Outcome { text: note, ok: true, wake: vec![], theme_changed: false, page: None }
}
}
/// Record how a turn ended (success / cancelled / error) and clear its live state.
@ -968,6 +1166,8 @@ impl App {
let out = if call.name == "list_computers" {
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default();
appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
} else if call.name == "update_bot" && serde_json::from_str::<serde_json::Value>(&call.args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
self.update_bot_computer(bot_id, &call.args)
} else {
appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
};
@ -1123,6 +1323,7 @@ impl App {
}
}
ApprovalKind::EnableThisComputer => self.state.local_execution = true,
ApprovalKind::Handoff => {}
ApprovalKind::Action => {
if let Some(b) = self.state.bot_mut(bot_id) {
if !g.is_empty() && !b.allow_rules.contains(g) {
@ -1258,6 +1459,10 @@ impl App {
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
c.status = st;
}
// The step moved on without the buttons (a message answered a hand-off).
if st != ToolStatus::Waiting && self.approvals.get(&id).map(|a| a.call_id == call_id).unwrap_or(false) {
self.approvals.remove(&id);
}
}
other => {
let Some(m) = b.messages.last_mut() else { continue };
@ -1564,6 +1769,10 @@ impl App {
fn rail(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
ui.vertical_centered(|ui| {
// (The Mac's window buttons sit in the top corner.)
if cfg!(target_os = "macos") {
ui.add_space(TITLEBAR_H - 6.0);
}
if icon_button(ui, "◨", "展開側邊欄", p).clicked() {
self.state.sidebar_collapsed = false;
self.mark();
@ -1728,11 +1937,17 @@ impl App {
ui.close();
}
if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
b.messages.clear();
if self.mir.on {
// The chat lives on the brain: clearing only this copy left the brain sure this
// screen still had it, and its next changes landed in the wrong place.
self.mirror_act(serde_json::json!({"bot": id, "action": "clear"}));
} else {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
b.messages.clear();
}
self.mark();
}
self.mark();
ui.close();
}
if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() {
@ -1844,9 +2059,11 @@ impl App {
self.voice_toggle(&ctx, bot.id);
}
}
if self.approvals.contains_key(&bot.id) {
if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &t("需要你允許"), p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() {
if let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) {
let label = if kind == ApprovalKind::Handoff { t("等你處理") } else { t("需要你允許") };
if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &label, p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() {
self.popup_hidden.remove(&bot.id);
self.stick_bottom = true;
}
}
let right_w = bar.right() - right.min_rect().left();
@ -1920,11 +2137,12 @@ impl App {
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
ApprovalKind::Action if machine.is_empty() => format!("允許 {} {}嗎?", bot.name, what),
ApprovalKind::Action => format!("允許 {} 在 {machine} 上{what}嗎?", bot.name),
ApprovalKind::Handoff => format!("{}:需要你", bot.name),
};
let note = match kind {
ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"),
ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"),
ApprovalKind::Action => None,
ApprovalKind::Action | ApprovalKind::Handoff => None,
};
let mut answer = None;
ui.label(RichText::new(title).strong().size(if big { 17.0 } else { 14.0 }));
@ -1952,7 +2170,7 @@ impl App {
ApprovalKind::EnableTools => Some("一律允許這個 Bot 使用電腦".to_string()),
ApprovalKind::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()),
ApprovalKind::Action if !grant.is_empty() => Some(format!("一律允許{}", grant_label(&grant))),
ApprovalKind::Action => None,
ApprovalKind::Action | ApprovalKind::Handoff => None,
};
if let Some(label) = always {
if ui.button(label).clicked() {
@ -1977,7 +2195,9 @@ impl App {
fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
let p = self.pal;
if !self.approvals.contains_key(&bot.id) {
let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) else { return };
if kind == ApprovalKind::Handoff {
self.handoff_card(ui, ctx, bot);
return;
}
let mut answer = None;
@ -1997,6 +2217,91 @@ impl App {
}
}
/// 需要處理 (the original's box hand-off card): what the bot needs the person to do, the
/// computer's live screen with 「接管」 on it, and 跳過 / 我完成了 / 接管.
fn handoff_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
let p = self.pal;
let Some(a) = self.approvals.get(&bot.id) else { return };
let (instruction, computer) = (a.reason.clone(), a.grant.clone());
// Its own computer shows here (on a screen of a brain, the brain's); another computer of
// the person's is the one in front of them.
let own = computer == crate::model::own_computer_name(&bot.name) || computer == crate::model::BOT_COMPUTER_NAME;
let shows = own && (self.mir.on || crate::cloudbox::available());
let thumb = if shows { self.box_thumbnail(ctx) } else { None };
let width = (ui.available_width() - 32.0 - 28.0).clamp(200.0, 560.0);
enum Act {
Skip,
Done,
TakeOver,
}
let mut act = None;
ui.horizontal_top(|ui| {
ui.add_space(32.0);
Frame::new().fill(p.elevated).stroke(Stroke::new(2.0, p.accent)).corner_radius(14).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(width);
ui.horizontal(|ui| {
crate::icons::label(ui, crate::icons::Icon::Monitor, &t("電腦"), FONT_XS, p.text3);
ui.label(RichText::new(sidebar_ui::shorten_width(ui, &format!("· {computer}"), (ui.available_width() - 4.0).max(40.0), FONT_XS)).size(FONT_XS).color(p.text3));
});
ui.add_space(2.0);
ui.label(RichText::new(t("需要處理")).strong().size(15.0));
ui.add(egui::Label::new(RichText::new(&instruction).size(13.5).color(p.text2)).wrap());
ui.add_space(8.0);
// The screen, 16:10, with 「接管」 on it.
let sw = width.min(420.0);
let (rect, r) = ui.allocate_exact_size(vec2(sw, sw * 0.625), if shows { egui::Sense::click() } else { egui::Sense::hover() });
match thumb {
Some((tex, size)) => {
egui::Image::new((tex, size)).fit_to_exact_size(rect.size()).corner_radius(10).paint_at(ui, rect);
}
None => {
ui.painter().rect_filled(rect, 10, Color32::from_rgb(0x2b, 0x2f, 0x36));
let text = if shows { t("連結中") } else { t("請到「{name}」上操作").replace("{name}", &computer) };
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, sidebar_ui::shorten_width(ui, &text, rect.width() - 20.0, FONT_BASE), egui::FontId::proportional(FONT_BASE), Color32::from_gray(220));
}
}
ui.painter().rect_stroke(rect, 10, Stroke::new(1.0, p.border), egui::StrokeKind::Inside);
if shows {
let label = t("接管");
let g = ui.painter().layout_no_wrap(label.clone(), egui::FontId::proportional(13.0), Color32::WHITE);
let pill = egui::Rect::from_min_size(rect.right_bottom() - vec2(g.size().x + 28.0, 38.0), vec2(g.size().x + 20.0, 28.0));
ui.painter().rect_filled(pill, 14, Color32::from_black_alpha(if r.hovered() { 200 } else { 160 }));
ui.painter().galley(pill.center() - g.size() / 2.0, g, Color32::WHITE);
if r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t("接管電腦")).clicked() {
act = Some(Act::TakeOver);
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button(t("跳過")).on_hover_text(t("取消此要求且不執行該步驟;Bot 會跳過它並繼續")).clicked() {
act = Some(Act::Skip);
}
if ui.button(t("我完成了")).clicked() {
act = Some(Act::Done);
}
if shows && ui.add(egui::Button::new(RichText::new(t("接管")).color(p.on_primary)).fill(p.primary).corner_radius(8)).clicked() {
act = Some(Act::TakeOver);
}
});
});
});
});
ui.add_space(10.0);
match act {
Some(Act::Skip) => {
self.give_back_computer();
self.answer(ctx, bot.id, ApprovalAnswer::Reject);
}
Some(Act::Done) => {
self.give_back_computer();
self.answer(ctx, bot.id, ApprovalAnswer::Once);
}
Some(Act::TakeOver) => self.take_over_computer(),
None => {}
}
}
/// A new request: show it as a pop-up (even over another bot's chat) and get the person's
/// attention (the Dock icon bounces when AnyBot isn't in front).
fn on_new_approval(&mut self, ctx: &egui::Context, bot_id: u64) {
@ -2007,10 +2312,12 @@ impl App {
if self.state.notify && self.away() {
if let (Some(b), Some(a)) = (self.state.bot(bot_id), self.approvals.get(&bot_id)) {
let what = a.reason.split('\n').next().unwrap_or("").to_string();
// 「{callee} 需要你」 for a step only the person can do.
let title = if a.kind == ApprovalKind::Handoff { format!("{}:需要你", b.name) } else { format!("{} 需要你允許", b.name) };
if !self.headless {
notify::banner(&format!("{} 需要你允許", b.name), &what);
notify::banner(&title, &what);
}
crate::remote::notify(&format!("{} 需要你允許", b.name), &what, bot_id);
crate::remote::notify(&title, &what, bot_id);
}
}
}
@ -2037,7 +2344,8 @@ impl App {
/// The pop-up: a dimmed window with the request in the middle, so it can't be missed.
fn approval_popup(&mut self, ctx: &egui::Context) {
let selected = self.state.selected;
let mut ids: Vec<u64> = self.approvals.keys().copied().filter(|id| !self.popup_hidden.contains(id)).collect();
// (A hand-off is the card in the chat, with the live screen, like the original's.)
let mut ids: Vec<u64> = self.approvals.iter().filter(|(id, a)| !self.popup_hidden.contains(id) && a.kind != ApprovalKind::Handoff).map(|(id, _)| *id).collect();
ids.sort_by_key(|id| (*id != selected, *id));
let Some(&bot_id) = ids.first() else { return };
let Some(bot) = self.state.bot(bot_id).cloned() else { return };
@ -2574,15 +2882,21 @@ impl App {
ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令"));
if b.tools_enabled {
ui.add_space(6.0);
form_row(ui, "預設電腦", p, |ui| {
let names = self.state.computer_names();
egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| {
for n in names {
let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
ui.selectable_value(&mut b.default_computer, n, label);
form_row(ui, "做事的電腦", p, |ui| {
let (choices, current) = self.computer_choices(&b);
let mut pick = None;
egui::ComboBox::from_id_salt("default_computer").selected_text(current.clone()).width(190.0).show_ui(ui, |ui| {
for (n, kind) in choices {
let label = if kind == "this" && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
if ui.selectable_label(n == current, label).clicked() {
pick = Some(n);
}
}
});
ui.label(RichText::new(t("聊天時說「在我的 Mac 上」就會換")).size(11.5).color(p.text3));
if let Some(n) = pick {
b.default_computer = Self::computer_to_keep(&b, &n);
}
ui.label(RichText::new(t("聊天時指名別台就會換")).size(11.5).color(p.text3));
});
form_row(ui, "起始資料夾", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width()));
@ -2596,7 +2910,7 @@ impl App {
ui.label(RichText::new(t("用到 70% 時會把舊對話整理成摘要")).size(11.5).color(p.text3));
});
ui.add_space(2.0);
hint(ui, "在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", p);
hint(ui, "在它自己的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", p);
if !b.allow_rules.is_empty() {
ui.add_space(6.0);
ui.label(RichText::new(t("已設定「一律允許」")).size(FONT_XS).color(p.text2));
@ -3109,6 +3423,11 @@ impl App {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h)));
}
}
// Real data: the computer viewer opens a little after start, as a person would open it (a
// screen of a brain has said so by then, so it shows the brain's computer).
if self.frames == 10 && !self.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("viewer") {
self.open_viewer();
}
// ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first).
if self.frames == need * 3 / 4 {
if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) {
@ -3278,6 +3597,7 @@ impl App {
}
pub fn serve(ctx: &egui::Context) -> ! {
crate::appctl::SERVING.store(true, std::sync::atomic::Ordering::Relaxed);
let mut app = App::new(ctx, None);
app.headless = true;
if let Ok(pw) = std::env::var("ANYBOT_PHONE_PASSWORD") {
@ -3422,6 +3742,20 @@ impl eframe::App for App {
if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::Comma)) {
self.show_settings = true;
}
// Mac: the top strip moves the window like a title bar would (buttons drawn over it still
// take their clicks: they come later, so they are on top); a double click zooms.
if cfg!(target_os = "macos") {
let full = ui.max_rect();
let strip = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, full.min.y + TITLEBAR_H));
let r = ui.interact(strip, egui::Id::unique("titlebar-drag"), egui::Sense::click_and_drag());
if r.drag_started_by(egui::PointerButton::Primary) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if r.double_clicked() {
let zoomed = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!zoomed));
}
}
self.sidebar(ui);
self.main_pane(ui);
self.bot_editor(ctx);
@ -3670,7 +4004,8 @@ fn transcript_markdown(b: &Bot) -> String {
}
/// What the bot is told about itself, the app it lives in and the computers it can use.
fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str) -> String {
#[allow(clippy::too_many_arguments)]
fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str, roles: &tools::Roles, missing: Option<&str>, lines: &[String], mac_apps: bool) -> String {
let mut s = format!("你是「{}」。", b.name);
if !b.description.trim().is_empty() {
s.push_str(&format!("你的角色:{}。", b.description.trim()));
@ -3679,6 +4014,17 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。");
}
let now = crate::routines::now_in(&st.timezone);
// Which screens can really be operated, each by its computer's name.
let mut screens: Vec<String> = Vec::new();
for t in computers {
match &t.place {
tools::Place::Box { .. } if crate::cloudbox::available() => screens.push(format!("「{}」", t.name)),
tools::Place::Local { .. } if crate::computer::screen_supported() => screens.push(format!("「{}」", t.name)),
tools::Place::Device { .. } => screens.push(format!("「{}」", t.name)),
_ => {}
}
}
let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { format!("可操作的螢幕:{}", screens.join("、")) };
s.push_str(&format!(
"\n\n現在是 {}({},時區 {})。\n\
你在 AnyBot 這個 App 裡,可以直接操作 App:\n\
@ -3691,15 +4037,17 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
- 公開網頁:web_fetch。\n\
- 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開,再用 browser_read 讀分頁內容。例如看 Threads 帳號:browser_open(\"https://www.threads.net\") 然後 browser_read。\n\
- 在網頁上做事(後台、報表、表單)要像人一樣操作:先 browser_screenshot 看一眼(圖上每個可點的東西都有編號),再用 browser_click/browser_type/browser_select/browser_press 照編號操作;表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。
- 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。
- 需要使用者親手做的步驟(登入、輸入密碼、驗證碼、拼圖、簡訊或 email 驗證、確認付款):先在電腦上把那一頁打開、點到要輸入的地方,再呼叫 hand_over_computer,instruction 寫清楚要他做什麼。聊天裡會出現那台電腦的即時畫面,他點「接管」做完後按「我完成了」,你再截圖確認、接著做。不要只用文字叫他自己去找視窗;也絕對不要替他打密碼。
- 如果讀到的是登入頁:把登入頁開在那台電腦上,用 hand_over_computer 請使用者登入一次(之後會記住),他按「我完成了」再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\
- AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\
- 你也能像人一樣操作螢幕:你自己的電腦(Bot 的電腦,Linux 桌面)或使用者的 Mac(你的電腦,用在使用者自己的 App)。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\
- 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\
- 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\
{}\
- 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。",
now.format("%Y-%m-%d %H:%M"),
["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize],
st.timezone
st.timezone,
if mac_apps { " - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準,會在使用者正在用的那台 Mac 上做;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n" } else { "" }
));
if !b.routines.is_empty() {
s.push_str("\n\n你目前的例行任務:\n");
@ -3712,45 +4060,29 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str(&mine.join("\n"));
}
if !computers.is_empty() {
let list: Vec<String> = computers
.iter()
.map(|t| match &t.place {
tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()),
tools::Place::Box { dir } => {
let secrets = crate::cloudbox::secret_names();
format!(
"- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}",
t.name,
t.name,
if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) }
)
}
tools::Place::Local { workdir } => format!(
"- {}:{},起始資料夾 {}{}",
t.name,
if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
workdir.display(),
if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" }
),
tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name),
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!(
"- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
t.name,
crate::remote::peer_name(host),
t.name
),
tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name),
})
.collect();
let list: Vec<String> = lines.iter().map(|l| format!("- {l}")).collect();
let mine = match &roles.person {
Some(p) => format!("使用者說「我的電腦」「這台」「我的 Mac」時,指的是「{p}」(他正在用的那台)。"),
None => "現在不知道使用者在用哪台電腦:他說「我的電腦」「這台」時,先問他是哪一台。".to_string(),
};
let gone = match missing {
Some(m) => format!("\n- 使用者選的「{m}」現在沒有連線(那台的 AnyBot 沒開或沒登入)。要在那台做事時,告訴使用者先打開它;要改用別台,先問使用者。"),
None => String::new(),
};
s.push_str(&format!(
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
"\n\n你可以用工具在電腦上做事。每台電腦都用它自己的名字稱呼,工具的 computer 參數就填那個名字:\n{}\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台,才把 computer 設成那台。{gone}\n\
- 「{own}」是你自己的電腦;使用者說「你的電腦」「Bot 的電腦」指的就是它。{mine}\n\
- 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\
- 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
- 只有工具結果證明做完了,才說做完;失敗就照實說。\n\
- 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\
- 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。",
list.join("\n")
list.join("\n"),
own = roles.own,
));
}
let chans: Vec<String> = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect();

View File

@ -459,10 +459,19 @@ impl App {
let waiting = self.approvals.contains_key(&bot.id);
let last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant);
let running = last.and_then(|m| m.tool_calls.iter().rev().find(|c| matches!(c.status, ToolStatus::Running | ToolStatus::Waiting)));
let on_box = running.map(|c| tools::args_of(&c.args)["computer"].as_str().map(|n| n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true)).unwrap_or(false);
// Where it works, by the computer's name (its own computer is 「<name> 的電腦」).
let named = running.and_then(|c| tools::args_of(&c.args)["computer"].as_str().map(String::from));
let own = crate::model::own_computer_name(&bot.name);
let on_box = running.is_some() && named.as_deref().map(|n| n == own || n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true);
let has_text = last.map(|m| !m.text.trim().is_empty()).unwrap_or(false);
let seed = live.turn_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64));
let phrase = live.status.clone().unwrap_or_else(|| t(&status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed)));
let phrase = live.status.clone().unwrap_or_else(|| {
let ph = status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed);
match &named {
Some(n) if ph == "正在你的電腦上工作" => t("正在「{name}」上工作").replace("{name}", n),
_ => t(&ph),
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(6.0);

View File

@ -9,6 +9,8 @@ use crate::cloudbox::{self, Rebuild, Run};
/// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS: u64 = 450;
/// The brain's computer comes over the account server: a picture about every 0.7 s.
const REMOTE_FRAME_MS: u64 = 700;
/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8;
@ -61,6 +63,8 @@ pub struct ComputerUi {
thumb_img: Arc<Mutex<Option<egui::ColorImage>>>,
thumb_busy: Arc<AtomicBool>,
thumb_at: Option<Instant>,
/// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it.
remote: Option<crate::remote::Settings>,
}
enum Input {
@ -356,7 +360,13 @@ impl App {
sec_title(ui, "Bot 的電腦", p, |_| {});
sec_card(ui, p, |ui| {
let name = self.state.remote.brain_name.clone();
sec_row(ui, p, "在大腦上", &format!("Bot 用的電腦在大腦「{name}」上,要在那台管理。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |_| {});
let mut view = false;
sec_row(ui, p, "在大腦上", &format!("每個 Bot 自己的電腦(例如「Master 的電腦」)在大腦「{name}」上,要在那台管理;這裡可以看它的畫面。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |ui| {
view = ui.add(primary_button("開啟電腦", p)).clicked();
});
if view {
self.open_viewer();
}
});
} else {
sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p));
@ -367,7 +377,6 @@ impl App {
Download,
Docker,
Browser,
Sleep,
Wake,
View,
UpdateClick,
@ -434,16 +443,12 @@ impl App {
}
_ => {
// The screen: AnyBot has no per-bot overview, so it opens from here.
sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。閒置一陣子會自動休眠,需要時自動喚醒。", |ui| {
sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", |ui| {
if ui.add(primary_button("開啟電腦", p)).on_hover_text(t("看 Bot 的電腦的螢幕,也可以取得控制權自己操作")).clicked() {
act = Act::View;
}
match run {
Run::Running => {
if ui.add(secondary_button("休眠")).clicked() {
act = Act::Sleep;
}
}
Run::Running => {}
_ => {
if ui.add(secondary_button("喚醒電腦")).clicked() {
act = Act::Wake;
@ -496,11 +501,11 @@ impl App {
// 重設 Bot 的電腦: from the last snapshot (red, asks first).
let snap = cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string());
let desc = match &snap { Some(t) => format!("上次快照:{t}"), None => t("還沒有快照") };
sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,休眠前也會自動存一份。", |ui| {
sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,每天也會自動存一份。", |ui| {
if ui.add_enabled(!busy_rebuild, danger_button("重設", p)).clicked() {
act = Act::Reset;
}
if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;休眠前也會自動存一份")).clicked() {
if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;每天也會自動存一份")).clicked() {
act = Act::Snapshot;
}
});
@ -535,7 +540,6 @@ impl App {
Act::Download => crate::preview::open_with_system("https://orbstack.dev/download"),
Act::Docker => crate::preview::open_with_system("https://docs.docker.com/engine/install/"),
Act::View => self.open_viewer(),
Act::Sleep => self.box_task(|| cloudbox::sleep_now().map(|_| "已休眠。".to_string())),
Act::Wake => self.box_task(|| cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())),
Act::Browser => self.box_task(|| {
let url = cloudbox::viewer_url(false)?;
@ -919,12 +923,19 @@ impl App {
None => self.cu.thumb = Some(ctx.load_texture("box-thumb", img, egui::TextureOptions::LINEAR)),
}
}
let every = if cloudbox::run_state().is_running() { 3 } else { 30 };
// On a screen of a brain the bots' computer is the brain's: its picture comes from there.
let brain = self.mir.on.then(|| self.state.remote.clone());
let running = if brain.is_some() { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { cloudbox::run_state().is_running() };
let every = if running { 3 } else { 30 };
if !self.cu.thumb_at.map(|t| t.elapsed().as_secs() < every).unwrap_or(false) && !self.cu.thumb_busy.swap(true, Ordering::SeqCst) {
self.cu.thumb_at = Some(Instant::now());
let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone());
std::thread::spawn(move || {
if let Some(img) = cloudbox::thumbnail(320) {
let img = match &brain {
Some(s) => crate::remote::sync_screen(s, true).ok().and_then(|b| image::load_from_memory(&b).ok()).map(|i| i.to_rgba8()),
None => cloudbox::thumbnail(320),
};
if let Some(img) = img {
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()));
c.request_repaint();
@ -939,18 +950,79 @@ impl App {
pub(super) fn open_viewer(&mut self) {
self.cu.viewer_open = true;
self.cu.fit = true;
cloudbox::set_viewer_open(true);
self.show_settings = false;
// A screen of a brain watches the brain's computer (where its bots really work).
if self.mir.on {
let s = self.state.remote.clone();
self.cu.remote = Some(s.clone());
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.texture = None;
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": true}));
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
});
return;
}
if self.cu.remote.take().is_some() {
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.texture = None;
}
cloudbox::set_viewer_open(true);
if !cloudbox::run_state().is_running() && !self.demo {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
}
}
/// 接管: open the computer and take control (the bot waits for the person anyway).
pub(super) fn take_over_computer(&mut self) {
self.open_viewer();
if self.cu.control {
return;
}
self.cu.control = true;
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": true}));
});
}
None => cloudbox::HELD_BY_PERSON.store(true, Ordering::SeqCst),
}
}
/// 我完成了 / 跳過: the bot gets its computer back (its steps would otherwise wait for the
/// person to let go).
pub(super) fn give_back_computer(&mut self) {
if !self.cu.control {
return;
}
self.cu.control = false;
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
});
}
None => cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst),
}
}
fn close_viewer(&mut self) {
self.cu.viewer_open = false;
self.cu.control = false;
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
cloudbox::set_viewer_open(false);
if let Some(s) = self.cu.remote.clone() {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": false}));
});
} else {
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
cloudbox::set_viewer_open(false);
}
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
@ -966,6 +1038,54 @@ impl App {
let slot = self.cu.frame.clone();
let c = ctx.clone();
let s2 = stop.clone();
let brain = self.cu.remote.clone();
if let Some(s) = brain.clone() {
// The brain's computer: pictures and the person's input go through the account server.
let s_frames = s.clone();
std::thread::spawn(move || {
let s = s_frames;
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
match crate::remote::sync_screen(&s, false).ok().and_then(|b| image::load_from_memory(&b).ok()) {
Some(img) => {
let img = img.to_rgba8();
seq += 1;
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some((seq, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())));
c.request_repaint();
std::thread::sleep(std::time::Duration::from_millis(REMOTE_FRAME_MS));
}
None => {
std::thread::sleep(std::time::Duration::from_millis(1500));
c.request_repaint();
}
}
}
});
let (tx, rx) = channel::<Input>();
self.cu.input_tx = Some(tx);
std::thread::spawn(move || {
let mut last_move = Instant::now();
while let Ok(i) = rx.recv() {
let v = match i {
Input::Click(x, y, b, n) => serde_json::json!({"kind": "click", "x": x, "y": y, "button": b, "count": n}),
// Hovering sends many moves; a few a second are enough over the network.
Input::Move(x, y) => {
if last_move.elapsed().as_millis() < 250 {
continue;
}
last_move = Instant::now();
serde_json::json!({"kind": "move", "x": x, "y": y})
}
Input::Scroll(x, y, down, n) => serde_json::json!({"kind": "scroll", "x": x, "y": y, "down": down, "steps": n}),
Input::Key(k) => serde_json::json!({"kind": "key", "key": k}),
Input::Text(t) => serde_json::json!({"kind": "text", "text": t}),
};
let _ = crate::remote::sync_post(&s, "screen", &v);
}
});
return;
}
std::thread::spawn(move || {
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
@ -1014,13 +1134,28 @@ impl App {
}
}
}
let run = cloudbox::run_state();
let brain = self.cu.remote.is_some();
// The brain's computer reports its state with the brain's answers.
let run = if brain {
match (self.mir.box_state["running"].as_bool().unwrap_or(false), self.mir.box_state["available"].as_bool().unwrap_or(true)) {
(true, _) => Run::Running,
(false, false) => Run::NoDocker("大腦那台沒有 Docker".into()),
_ => Run::Asleep,
}
} else {
cloudbox::run_state()
};
let screen = ctx.content_rect();
let open = true;
let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0);
let busy = cloudbox::is_busy();
let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() };
let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string());
let mut close_clicked = false;
egui::Window::new("Bot 的電腦")
let mut done_clicked = false;
let selected = self.state.selected;
let handoff = self.approvals.get(&selected).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false);
egui::Window::new("box-viewer")
.id(egui::Id::unique("box-viewer"))
.title_bar(false)
.default_size(default)
@ -1037,16 +1172,9 @@ impl App {
.shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }),
)
.show(ctx, |ui| {
// Buttons first, from the right; the title and state get what is left and end in
// … when it's short (the state used to slide under 「取得控制權」 in a narrow window).
ui.horizontal(|ui| {
monitor_icon(ui, 20.0, p.text);
ui.label(RichText::new(crate::model::BOT_COMPUTER_NAME).strong().size(FONT_LG));
status_pill(ui, &run, p);
if busy && !self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent);
}
if self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Users, &t("你正在操作,Bot 已暫停"), FONT_XS, p.accent);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
if r.hovered() {
@ -1060,7 +1188,11 @@ impl App {
close_clicked = true;
}
ui.add_space(4.0);
if ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
// The bot is waiting for the person here: done, and it carries on.
if handoff && ui.add(egui::Button::new(RichText::new(t("我完成了")).strong().color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(84.0, 30.0))).clicked() {
done_clicked = true;
}
if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
let control = self.cu.control;
self.box_task(move || {
let url = cloudbox::viewer_url(control)?;
@ -1076,8 +1208,29 @@ impl App {
let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0));
if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() {
self.cu.control = !self.cu.control;
cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst);
match self.cu.remote.clone() {
Some(s) => {
let on = self.cu.control;
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on}));
});
}
None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst),
}
}
ui.add_space(8.0);
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
monitor_icon(ui, 20.0, p.text);
let note = if self.cu.control { Some(t("你正在操作,Bot 已暫停")) } else if busy { Some(t("Bot 正在操作")) } else { None };
let room = (ui.available_width() - 96.0 - if note.is_some() { 120.0 } else { 0.0 }).max(60.0);
ui.label(RichText::new(super::sidebar_ui::shorten_width(ui, &title, room, FONT_LG)).strong().size(FONT_LG));
if ui.available_width() > 80.0 {
status_pill(ui, &run, p);
}
if let Some(n) = note.filter(|_| ui.available_width() > 110.0) {
crate::icons::label(ui, if self.cu.control { crate::icons::Icon::Users } else { crate::icons::Icon::Bot }, &n, FONT_XS, p.accent);
}
});
});
});
ui.add_space(6.0);
@ -1094,7 +1247,17 @@ impl App {
};
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() };
let resp = ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense));
// Centred in the window (it sat at the left with an empty band beside it).
let resp = if self.cu.fit {
let pad = ((ui.available_width() - size.x) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(pad);
ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense))
})
.inner
} else {
ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense))
};
if self.cu.control {
ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside);
self.forward_input(ui, &resp, (fw, fh));
@ -1114,13 +1277,24 @@ impl App {
if matches!(run, Run::Asleep | Run::Absent) {
let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0));
if ui.put(b, primary_button("喚醒", p)).clicked() {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
});
}
None => self.box_task(|| cloudbox::ensure_running().map(|_| String::new())),
}
}
}
}
}
});
if close_clicked || !open {
if done_clicked {
self.give_back_computer();
self.answer(ctx, selected, crate::providers::ApprovalAnswer::Once);
self.close_viewer();
} else if close_clicked || !open {
self.close_viewer();
}
ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS));

View File

@ -393,6 +393,27 @@ pub struct Mirror {
fetching: std::collections::HashSet<String>,
/// 加一台電腦: the line to run on the new computer (or why there is none), and when it was made.
pub(super) pair: Arc<Mutex<(String, bool, Option<Instant>)>>,
/// The bots' own computer on the brain: {label, running, available, busy}.
pub(super) box_state: Value,
/// Each bot's computers on the brain, by bot id: {names: [{name, kind}], current, missing}.
pub(super) computers: HashMap<u64, Value>,
}
/// What a computer is, in a word a screen can show next to its name.
pub(super) fn computer_kind(t: &crate::tools::Target, r: &crate::tools::Roles) -> &'static str {
use crate::tools::Place;
if t.name == r.own {
"own"
} else if r.person.as_deref() == Some(t.name.as_str()) {
"person"
} else {
match t.place {
Place::Local { .. } if r.server => "server",
Place::Local { .. } => "this",
Place::Device { .. } => "device",
_ => "ssh",
}
}
}
/// Where a screen keeps its copy of a file the brain has.
@ -512,10 +533,28 @@ impl App {
ApprovalKind::Action => "action",
ApprovalKind::EnableTools => "tools",
ApprovalKind::EnableThisComputer => "local",
ApprovalKind::Handoff => "handoff",
};
json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind})
})
.collect();
// The bots' computer lives here; a screen shows its state, not its own computer's.
let run = crate::cloudbox::run_state();
out["box"] = json!({"label": run.label(), "running": run.is_running(), "available": crate::cloudbox::available(), "busy": crate::cloudbox::is_busy()});
// Which computers each bot has (only the brain knows the account's other computers).
out["computers"] = self
.state
.bots
.iter()
.map(|b| {
let t = self.targets_for(b);
let r = self.roles_for(b, &t);
let (current, missing) = Self::chosen_computer(b, &t, &r);
let names: Vec<Value> = t.iter().map(|x| json!({"name": x.name, "kind": computer_kind(x, &r)})).collect();
(b.id.to_string(), json!({"names": names, "current": current, "missing": missing}))
})
.collect::<serde_json::Map<_, _>>()
.into();
out
}
@ -724,6 +763,8 @@ impl App {
self.mir.base = Value::Null;
self.mir.local = Some(local);
self.mir.on = true;
// The bots' computer is the brain's now; this one's box isn't kept awake.
crate::cloudbox::set_brain(false);
self.mir.online = false;
self.mir.version.store(0, Ordering::SeqCst);
self.mir.pending.lock().unwrap().clear();
@ -739,6 +780,7 @@ impl App {
fn mirror_leave(&mut self) {
self.mir.gen.fetch_add(1, Ordering::SeqCst);
self.mir.on = false;
crate::cloudbox::set_brain(true);
self.mir.online = false;
self.mir.outbox = None;
self.streams.clear();
@ -932,6 +974,14 @@ impl App {
let mut msgs: Vec<Message> = serde_json::from_value(d["messages"].clone()).unwrap_or_default();
self.mirror_files(&mut msgs);
if let Some(b) = self.state.bot_mut(id) {
// The brain sends what changed from message `from` on, sure this copy has the
// ones before. A copy that is shorter (cleared here, or an answer lost on the
// way) would get them appended in the wrong place — the same message twice and
// half-written replies piling up. Ask for the whole chat again instead.
if b.messages.len() < from {
self.mir.resync.store(true, Ordering::SeqCst);
continue;
}
b.messages.truncate(from);
b.messages.extend(msgs);
}
@ -966,6 +1016,7 @@ impl App {
let kind = match a["kind"].as_str().unwrap_or("") {
"tools" => ApprovalKind::EnableTools,
"local" => ApprovalKind::EnableThisComputer,
"handoff" => ApprovalKind::Handoff,
_ => ApprovalKind::Action,
};
let (reply, _) = channel();
@ -982,6 +1033,12 @@ impl App {
}
}
}
if v["box"].is_object() {
self.mir.box_state = v["box"].clone();
}
if let Some(c) = v["computers"].as_object() {
self.mir.computers = c.iter().filter_map(|(k, x)| Some((k.parse().ok()?, x.clone()))).collect();
}
let keep: Vec<u64> = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect();
self.approvals.retain(|id, _| keep.contains(id));
if self.state.bot(self.state.selected).is_none() {

View File

@ -105,7 +105,8 @@ impl App {
let status = self.streams.get(&bot.id).and_then(|l| l.status.clone()).unwrap_or_else(|| t("工作中…"));
let mut clicked = false;
panel_card(ui, p, |ui| {
clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &t("需要你允許"), "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() };
let label = if self.approvals.get(&bot.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { t("需要你允許") };
clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &label, "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() };
});
if clicked {
if waiting {
@ -159,19 +160,42 @@ impl App {
// 電腦: the bot's own computer with a live picture of its screen (like the original).
panel_title(ui, "電腦", p);
let mut view = false;
let thumb = if crate::cloudbox::available() { self.box_thumbnail(ctx) } else { None };
// On a screen of a brain, the bot's computer is on the brain: its state and picture come
// from there (this computer's own box is a different machine).
let brain = self.mir.on;
let available = if brain { self.mir.box_state["available"].as_bool().unwrap_or(true) } else { crate::cloudbox::available() };
let thumb = if available { self.box_thumbnail(ctx) } else { None };
let own_name = crate::model::own_computer_name(&bot.name);
let brain_name = self.state.remote.brain_name.clone();
// 做事的電腦: which computer this bot works on (its own unless the person picks another).
let (choices, current) = if bot.members.is_empty() { self.computer_choices(bot) } else { (vec![], String::new()) };
let mut pick: Option<String> = None;
panel_card(ui, p, |ui| {
let run = crate::cloudbox::run_state();
let running = run.is_running();
let state = if crate::cloudbox::available() { t(&run.label()) } else { t("沒有 Docker") };
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click());
let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() };
let state = if brain {
self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…"))
} else if available {
t(&run.label())
} else {
t("沒有 Docker")
};
// Where it is, on its own line (it ran into the picture when it shared the state's).
let place = brain.then(|| t("在大腦「{name}」上").replace("{name}", &brain_name));
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), if place.is_some() { 72.0 } else { 64.0 }), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 9, p.hover);
}
ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, t("Bot 的電腦"), egui::FontId::proportional(13.0), p.text);
ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3);
// Every line stops before the picture (long names end in …).
let text_w = (rect.width() - 86.0 - 10.0 - 8.0).max(40.0);
let top = if place.is_some() { 9.0 } else { 15.0 };
ui.painter().text(rect.left_top() + vec2(10.0, top), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, &own_name, text_w, 13.0), egui::FontId::proportional(13.0), p.text);
ui.painter().text(rect.left_top() + vec2(10.0, top + 18.0), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, &state, text_w, 12.0), egui::FontId::proportional(12.0), p.text3);
if let Some(place) = &place {
ui.painter().text(rect.left_top() + vec2(10.0, top + 35.0), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, place, text_w, 12.0), egui::FontId::proportional(12.0), p.text3);
}
// The screen: 16:10, rounded, the real desktop when there is a picture.
let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0));
let box_r = egui::Rect::from_min_size(egui::pos2(rect.right() - 86.0, rect.center().y - 24.0), vec2(78.0, 48.0));
match thumb {
Some((tex, size)) => {
let mut img = egui::Image::new((tex, size)).fit_to_exact_size(box_r.size()).corner_radius(7);
@ -192,11 +216,32 @@ impl App {
if running {
ui.painter().circle_filled(box_r.right_top() + vec2(-6.0, 6.0), 3.5, Color32::from_rgb(0x14, 0xc3, 0x70));
}
let tip = if running { "看 Bot 的電腦的螢幕,也可以取得控制權自己操作" } else { "叫醒 Bot 的電腦並看它的螢幕" };
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t(tip)).clicked();
let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name);
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
if !choices.is_empty() {
let shown = super::sidebar_ui::shorten_width(ui, &current, (ui.available_width() - 120.0).max(60.0), 13.0);
panel_select(ui, p, "做事的電腦", ("computer", bot.id), &shown, |ui| {
for (name, kind) in &choices {
let what = t(match kind.as_str() {
"own" => "它自己的電腦",
"person" => "你正在用的這台",
"this" => "這台電腦",
"server" => "大腦所在的伺服器",
"device" => "帳號裡的電腦",
_ => "SSH 電腦",
});
if ui.selectable_label(*name == current, format!("{name} · {what}")).clicked() {
pick = Some(name.clone());
}
}
});
}
});
if let Some(n) = pick {
self.set_computer(bot.id, &n);
}
if view {
if crate::cloudbox::available() {
if available {
self.open_viewer();
} else {
self.show_settings = true;

View File

@ -41,8 +41,10 @@ fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value {
ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name),
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
ApprovalKind::Action => format!("{} 想要{what}", bot.name),
ApprovalKind::Handoff => format!("{}:需要你", bot.name),
};
json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": a.kind != ApprovalKind::Action || !a.grant.is_empty()})
let handoff = a.kind == ApprovalKind::Handoff;
json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": !handoff && (a.kind != ApprovalKind::Action || !a.grant.is_empty()), "kind": if handoff { "handoff" } else { "ask" }, "computer": if handoff { a.grant.as_str() } else { "" }})
}
impl App {
@ -129,8 +131,9 @@ impl App {
if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) {
self.remote_backup();
}
// 裝置連結: keep the list of this account's computers fresh (once a minute).
if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) {
// 裝置連結: keep the list of this account's computers fresh (every 15 s: a computer that
// comes on is one the bots can use soon after).
if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 15).unwrap_or(false) {
self.rm.peers_at = Some(Instant::now());
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
std::thread::spawn(move || {
@ -258,10 +261,25 @@ impl App {
fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) {
match c {
Cmd::Send { bot, text, attachments } => {
Cmd::Send { bot, text, attachments, from } => {
if self.state.bot(bot).is_none() {
return;
}
// Typed on one of the account's computers: that one is the person's now (for
// 「我的電腦」 and the model on their network). The phone isn't a computer.
if !from.is_empty() {
remote::set_person_host(&from);
// A computer this brain hasn't heard of yet (it just came on): look the
// account's computers up now, or its 「我的電腦」 wouldn't be known for a minute.
if !self.rm.peers.lock().unwrap().iter().any(|(id, _, online)| *id == from && *online) {
let v = remote::devices_online(&self.state.remote);
if !v.is_empty() {
*self.rm.peers.lock().unwrap() = v;
}
self.rm.peers_at = Some(Instant::now());
}
self.typed_on = Some(from);
}
// Only files the app itself stored in its attachments folder may ride along;
// resolve first, so "../../" paths cannot escape it (the phone can name any path).
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
@ -275,7 +293,10 @@ impl App {
let meta = std::fs::metadata(&f).ok()?;
let file = f.file_name()?.to_string_lossy().to_string();
let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file);
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box: String::new() })
// Also on the bot's own computer, where it works (a brain on a server
// has no other computer of its own to read it from).
let on_box = crate::cloudbox::copy_in(&f.display().to_string(), bot, &name).unwrap_or_default();
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box })
})
.collect();
if let Some(l) = self.streams.get(&bot) {
@ -327,6 +348,13 @@ impl App {
}
}
}
Cmd::Clear { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
b.messages.clear();
}
self.mark();
}
Cmd::NewThread { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
@ -743,6 +771,11 @@ impl App {
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
// A brain with hands of its own works on those, not on the account's other brains.
.filter(|(id, _, _)| !has_hands || remote::is_hands(id))
// A computer showing another brain lends its hands to that one, not to this.
.filter(|(id, _, _)| {
let uses = remote::peer_uses(id);
uses.is_empty() || uses == self.state.remote.host_id
})
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
.collect()
}

View File

@ -745,7 +745,7 @@ impl App {
};
painter.galley(egui::pos2(tx, rect.top() + 9.0), one_line(b.name.clone(), 15.0, p.text), p.text);
let sub = if waiting {
t("需要你允許")
if self.approvals.get(&b.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { t("需要你允許") }
} else if streaming {
t("正在工作…")
} else {

View File

@ -145,6 +145,10 @@ pub fn needs_approval(name: &str, args: &str, state: &State) -> Option<String> {
None
}
/// True while running as a headless brain (`anybot --serve`): "this computer" is a server,
/// not a person's desktop, and the computer-wording tools must say so. Set in App::serve.
pub static SERVING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub struct Outcome {
pub text: String,
pub ok: bool,
@ -543,11 +547,16 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None }
}
"list_computers" => {
let mut lines = vec![
format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME),
format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }),
];
lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target)));
// (The window answers this with the account's computers too; this is the plain list.)
let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed);
let own = state.bot(bot_id).map(|b| model::own_computer_name(&b.name)).unwrap_or_else(|| model::BOT_COMPUTER_NAME.into());
let here = state.this_computer_name();
let mut lines = vec![format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用")];
// A brain on a server doesn't work on the server itself.
if !serving {
lines.push(format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }));
}
lines.extend(state.remote_computers.iter().map(|c| format!("「{}」:透過 SSH 連到 {}", c.name, c.ssh_target)));
ok(lines.join("\n"))
}
"update_settings" => {

View File

@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
let mut text = String::new();
let mut images = vec![];
for a in atts {
let where_ = if a.on_box.is_empty() { format!("檔案在使用者的 Mac:{}", a.path) } else { format!("檔案在使用者的 Mac:{};也已放進 Bot 的電腦:{}", a.path, a.on_box) };
let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案已放進你自己的電腦:{}", a.on_box) };
if a.image {
match image_b64(&a.path) {
Some(b) => {

View File

@ -303,8 +303,8 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result<Page, String>
if !cfg!(target_os = "macos") {
return Err("只支援 macOS".into());
}
if !trusted(true) {
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
if !trusted(false) {
crate::computer::ask_permission("Privacy_Accessibility");
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into());
}
let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();

View File

@ -264,7 +264,7 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u
let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty();
}
}
return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some()));
return Ok(("它桌面上的 Chromium", port, fresh && first_url.is_some()));
}
let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) };
if profile == PERSON_PROFILE {

View File

@ -27,7 +27,34 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
/// The box's usual name. Two AnyBots on one Docker host (a server's brain and a desktop AnyBot
/// left on the same machine) each took "anybot-box" for their own, and the desktop one kept
/// pausing the brain's box as "idle". ANYBOT_BOX_NAME gives an AnyBot a box of its own name.
pub const CONTAINER: &str = "anybot-box";
/// This AnyBot's box (ANYBOT_BOX_NAME, else "anybot-box").
pub fn container() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
C.get_or_init(|| {
std::env::var("ANYBOT_BOX_NAME")
.ok()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty() && n.len() <= 60 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.unwrap_or_else(|| CONTAINER.to_string())
})
}
/// A box of its own name takes over the usual one this AnyBot used before (same files: the
/// home folder is a volume, kept by name; a rename doesn't stop it).
fn adopt_usual_box() {
let me = container();
if me == CONTAINER || dk(&["inspect", "-f", "{{.Id}}", me]).is_ok() {
return;
}
if dk(&["inspect", "-f", "{{.Id}}", CONTAINER]).is_ok() && dk(&["rename", CONTAINER, me]).is_ok() {
log(&format!("Bot 的電腦改用自己的名字 {me}(原本叫 {CONTAINER})。"));
}
}
pub const IMAGE: &str = "anybot-box:1";
/// The bot's files live here and survive an update (the original's mounted sand-data tree).
pub const VOLUME: &str = "anybot-box-home";
@ -41,8 +68,9 @@ pub const HOST_PORT: u16 = 16080;
pub const SCREEN: &str = "1280x800x24";
/// Health probe timeout (QQn).
pub const HEALTH_TIMEOUT_MS: u64 = 3_000;
/// Snapshot ("flush the durable store") timeout (VQn).
pub const SNAPSHOT_TIMEOUT_MS: u64 = 30_000;
/// Saving a snapshot ("flush the durable store"). The original waits 30 s (VQn); a home folder of
/// a GB with an open browser takes minutes to pack, and a cut-off snapshot is no snapshot.
pub const SNAPSHOT_SAVE_TIMEOUT_MS: u64 = 600_000;
/// Restore copy-in: longest wait and poll interval (zQn, HQn).
pub const COPY_IN_WAIT_MS: u64 = 90_000;
pub const COPY_IN_POLL_MS: u64 = 2_000;
@ -50,8 +78,11 @@ pub const COPY_IN_POLL_MS: u64 = 2_000;
pub const RESUME_FRESH_MS: u64 = 600_000;
/// Boot: longest wait for the desktop to come up (estimated).
pub const BOOT_WAIT_MS: u64 = 60_000;
/// Idle time before the box goes to sleep (estimated; the original parks an idle box).
pub const IDLE_SLEEP_SECS: u64 = 900;
/// Quiet time before the daily snapshot is taken (nobody using the box for this long).
pub const QUIET_SECS: u64 = 900;
/// The box's files are saved to this computer about once a day (it no longer sleeps, which is
/// when they used to be saved).
pub const SNAPSHOT_EVERY_SECS: i64 = 24 * 3600;
/// Secret names: an upper-case letter, then A–Z, 0–9, _ (original rule); size limits estimated.
pub const SECRET_MAX_VALUE_BYTES: usize = 32_768;
pub const SECRET_MAX_COUNT: usize = 100;
@ -317,7 +348,7 @@ pub fn wanted_version() -> String {
/// The running computer is older than the one this AnyBot carries.
pub fn update_available() -> bool {
match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", CONTAINER]) {
match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", container()]) {
Ok(v) => v.trim() != wanted_version(),
Err(_) => false,
}
@ -456,12 +487,12 @@ pub fn probe() -> Run {
format!("Docker 沒有在執行:{}", e.lines().last().unwrap_or(""))
});
}
match dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]) {
match dk(&["inspect", "-f", "{{.State.Status}}", container()]) {
Err(_) => Run::Absent,
Ok(s) => match s.as_str() {
"running" => {
// The health probe (3 s, like the original's GET /health).
if dk_timeout(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() {
if dk_timeout(&["exec", container(), "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() {
Run::Running
} else {
Run::Starting("連線中")
@ -546,14 +577,14 @@ fn create_container() -> Result<(), String> {
let vnc = format!("VNC_PASSWORD={}", vnc_password());
log("正在開一台新的電腦…");
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--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(&["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)))?;
Ok(())
}
fn wait_ready(ms: u64) -> Result<(), String> {
let start = Instant::now();
while start.elapsed() < Duration::from_millis(ms) {
if dk(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"]).is_ok() {
if dk(&["exec", container(), "test", "-f", "/tmp/anybot-box.ready"]).is_ok() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(250));
@ -585,7 +616,7 @@ fn box_cdp_forward() -> Result<u16, String> {
for conn in l.incoming().flatten() {
std::thread::spawn(move || {
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null");
let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return };
let Ok(mut child) = docker().args(["exec", "-i", container(), "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return };
let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return };
let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return };
let up = std::thread::spawn(move || {
@ -617,7 +648,7 @@ pub fn browser_port() -> Result<u16, String> {
return Ok(port);
}
let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}");
dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?;
dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", container(), "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?;
let start = Instant::now();
while start.elapsed() < Duration::from_secs(20) {
if box_cdp_alive(port) {
@ -625,7 +656,7 @@ pub fn browser_port() -> Result<u16, String> {
}
std::thread::sleep(Duration::from_millis(300));
}
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
Err("這台電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(());
@ -648,8 +679,8 @@ pub fn ensure_running() -> Result<(), String> {
Run::Asleep => {
set_run(Run::Starting("喚醒中"));
log("喚醒電腦…");
let st = dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]).unwrap_or_default();
let woke = if st == "paused" { dk(&["unpause", CONTAINER]) } else { dk(&["start", CONTAINER]) };
let st = dk(&["inspect", "-f", "{{.State.Status}}", container()]).unwrap_or_default();
let woke = if st == "paused" { dk(&["unpause", container()]) } else { dk(&["start", container()]) };
woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS))
}
Run::Starting(_) => {
@ -661,13 +692,13 @@ pub fn ensure_running() -> Result<(), String> {
// A broken container: start it again.
log(&format!("電腦狀態異常({e}),重新啟動…"));
set_run(Run::Starting("連線中"));
dk(&["restart", CONTAINER]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS))
dk(&["restart", container()]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS))
}
};
match &result {
Ok(()) => {
set_run(Run::Running);
let _ = dk(&["exec", CONTAINER, "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]);
let _ = dk(&["exec", container(), "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]);
}
Err(e) => {
log(e);
@ -681,38 +712,36 @@ pub fn ensure_running() -> Result<(), String> {
result
}
/// Put the box to sleep now (saves a snapshot first, like the original's flush before parking).
pub fn sleep_now() -> Result<(), String> {
if !matches!(probe(), Run::Running) {
return Ok(());
}
if is_busy() {
return Err("有 Bot 正在用這台電腦,等它做完再休眠。".into());
}
let _ = save_snapshot();
log("電腦進入休眠。");
dk(&["pause", CONTAINER]).map(|_| set_run(Run::Asleep))
}
/// Started once: puts an idle box to sleep, and replays a rebuild cut short by a restart.
/// Started once: keeps the box awake (wakes it when it was asleep), saves its files once a day,
/// and replays a rebuild cut short by a restart. The person asked for the bots' computer to be
/// always there: an idle box used to be paused after 15 minutes, and the person — and a bot
/// working from a brain far away — kept finding it "asleep".
pub fn start_background() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(|| {
adopt_usual_box();
set_run(probe());
resume_interrupted();
loop {
// (First wait a little: a computer showing another brain says so right after start.)
std::thread::sleep(Duration::from_secs(20));
let (idle, running, rebuilding) = {
let (idle, rebuilding) = {
let s = shared().lock().unwrap();
(s.last_used.elapsed().as_secs(), s.run.is_running(), s.rebuilding)
(s.last_used.elapsed().as_secs(), s.rebuilding)
};
if running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && idle >= IDLE_SLEEP_SECS && !viewer_open() {
let _ = sleep_now();
}
if !rebuilding {
if IS_BRAIN.load(Ordering::SeqCst) && matches!(probe(), Run::Asleep) {
log("Bot 的電腦不休眠:叫醒它。");
let _ = ensure_running();
}
let quiet = idle >= QUIET_SECS && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open();
let due = snapshot_time().map(|t| (chrono::Local::now() - t).num_seconds() >= SNAPSHOT_EVERY_SECS).unwrap_or(true);
if quiet && due && run_state().is_running() {
let _ = save_snapshot();
}
refresh();
maybe_scheduled_update();
}
@ -720,6 +749,13 @@ pub fn start_background() {
});
}
/// This AnyBot is the brain (its box is the bots' computer). A computer showing another brain
/// doesn't use its own box, so it isn't kept awake there.
static IS_BRAIN: AtomicBool = AtomicBool::new(true);
pub fn set_brain(on: bool) {
IS_BRAIN.store(on, Ordering::SeqCst);
}
static VIEWER_OPEN: AtomicBool = AtomicBool::new(false);
pub fn set_viewer_open(on: bool) {
VIEWER_OPEN.store(on, Ordering::SeqCst);
@ -731,6 +767,7 @@ fn viewer_open() -> bool {
VIEWER_OPEN.load(Ordering::SeqCst)
}
// ---------- snapshots (the durable store) ----------
/// Save the bot's home folder to this Mac. Returns its size in bytes.
@ -739,7 +776,11 @@ pub fn save_snapshot() -> Result<u64, String> {
let tmp = data_dir().join("snapshot.tmp");
let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut child = docker()
.args(["exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "-czf", "-", "."])
// Caches are left out: the browser's grow to hundreds of MB and come back by themselves.
.args([
"exec", container(), "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad",
"--warning=no-file-changed", "-czf", "-", ".",
])
.stdout(Stdio::from(file))
.stderr(Stdio::piped())
.spawn()
@ -755,10 +796,12 @@ pub fn save_snapshot() -> Result<u64, String> {
});
let start = Instant::now();
let ok = loop {
// tar says 1 when a file changed while it was read (the open browser writes all the
// time): the archive is still whole. Only 2 and up are failures.
if let Ok(Some(st)) = child.try_wait() {
break st.success();
break st.code().map(|c| c <= 1).unwrap_or(false);
}
if start.elapsed() > Duration::from_millis(SNAPSHOT_TIMEOUT_MS * 4) {
if start.elapsed() > Duration::from_millis(SNAPSHOT_SAVE_TIMEOUT_MS) {
let _ = child.kill();
break false;
}
@ -779,7 +822,7 @@ fn copy_in_snapshot() -> Result<(), String> {
use std::io::Write;
let data = std::fs::read(snapshot_path()).map_err(|_| "還沒有快照可以還原".to_string())?;
let mut child = docker()
.args(["exec", "-i", CONTAINER, "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")])
.args(["exec", "-i", container(), "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
@ -793,7 +836,7 @@ fn copy_in_snapshot() -> Result<(), String> {
// Like readCopyInOutcome: wait for the copy-in to report "hydrated".
let start = Instant::now();
while start.elapsed() < Duration::from_millis(COPY_IN_WAIT_MS) {
if let Ok(s) = dk(&["exec", CONTAINER, "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) {
if let Ok(s) = dk(&["exec", container(), "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) {
if s.contains("outcome=hydrated") {
return Ok(());
}
@ -804,7 +847,7 @@ fn copy_in_snapshot() -> Result<(), String> {
}
fn home_has_files() -> bool {
dk(&["exec", CONTAINER, "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false)
dk(&["exec", container(), "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false)
}
pub fn human(b: u64) -> String {
@ -916,7 +959,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String>
phase("creating", "更新電腦的映像檔");
build_image(true)?;
phase("creating", "換上一台新的電腦");
let _ = dk(&["rm", "-f", CONTAINER]);
let _ = dk(&["rm", "-f", container()]);
create_container()?;
wait_ready(BOOT_WAIT_MS)?;
phase("moving", "確認保留的檔案和新電腦的連線");
@ -927,7 +970,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String>
}
Rebuild::Reset => {
phase("cleaning-up", "拆掉舊的電腦");
let _ = dk(&["rm", "-f", CONTAINER]);
let _ = dk(&["rm", "-f", container()]);
phase("wiping", "清除電腦裡的資料");
let _ = dk(&["volume", "rm", "-f", VOLUME]);
phase("creating", "開一台全新的電腦");
@ -939,7 +982,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String>
}
Rebuild::Restore => {
phase("cleaning-up", "拆掉舊的電腦和它的資料");
let _ = dk(&["rm", "-f", CONTAINER]);
let _ = dk(&["rm", "-f", container()]);
let _ = dk(&["volume", "rm", "-f", VOLUME]);
phase("creating", "從快照開一台新的電腦");
if !image_exists() {
@ -975,7 +1018,7 @@ fn resume_interrupted() {
/// Disk use in the box, for the settings page and the clean-up request.
pub fn disk_report() -> Result<String, String> {
ensure_running()?;
dk(&["exec", "-u", "bot", CONTAINER, "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")])
dk(&["exec", "-u", "bot", container(), "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")])
}
// ---------- secrets (write-only) ----------
@ -1065,7 +1108,7 @@ pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> Strin
let inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir));
let env = env_file_path();
let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() };
format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner))
format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {box} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner), r#box = container())
}
/// An argv that kills the box process carrying this job tag (and its children).
@ -1077,7 +1120,7 @@ pub fn exec_kill_argv(job: &str) -> Vec<String> {
}
argv.extend([
"exec".to_string(),
CONTAINER.to_string(),
container().to_string(),
"sh".to_string(),
"-c".to_string(),
format!("for p in $(pgrep -f 'export ANYBOT_JOB={job}'); do pkill -9 -P $p; pkill -9 -P $p; kill -9 $p 2>/dev/null; done; true"),
@ -1098,10 +1141,10 @@ pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result<String, String> {
.filter(|n| !n.is_empty() && n != "." && n != "..")
.ok_or_else(|| "檔案名稱無效".to_string())?;
let dir = format!("{}/附件", bot_dir(bot_id));
dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?;
dk(&["exec", "-u", "bot", container(), "mkdir", "-p", &dir])?;
let dst = format!("{dir}/{safe}");
dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?;
let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]);
dk(&["cp", local, &format!("{c}:{dst}", c = container())])?;
let _ = dk(&["exec", container(), "chown", "bot:bot", &dst]);
Ok(dst)
}
@ -1112,21 +1155,21 @@ pub fn copy_out(path_in_box: &str) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join(format!("anybot-from-box-{}", chrono::Utc::now().timestamp_millis()));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let local = dir.join(&name);
dk(&["cp", &format!("{CONTAINER}:{path_in_box}"), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?;
dk(&["cp", &format!("{}:{path_in_box}", container()), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?;
Ok(local)
}
// ---------- the box's screen (xdotool) ----------
fn xdo(args: &[&str]) -> Result<String, String> {
let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "xdotool"];
let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "xdotool"];
a.extend_from_slice(args);
dk(&a).map_err(|e| format!("xdotool:{e}"))
}
/// The desktop as an image (full size).
pub fn grab() -> Result<image::RgbImage, String> {
let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?;
let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?;
image::load_from_memory(&png).map(|i| i.to_rgb8()).map_err(|e| format!("截圖讀不出來:{e}"))
}
@ -1149,7 +1192,7 @@ pub fn frame() -> Option<image::RgbaImage> {
if !run_state().is_running() {
return None;
}
let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?;
let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?;
image::load_from_memory(&png).ok().map(|i| i.to_rgba8())
}
@ -1228,7 +1271,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
ensure_running()?;
let _busy = Busy::new(true);
if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
return Err("使用者正在自己操作 Bot 的電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
}
let scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), String> {
@ -1255,7 +1298,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
r
};
match name {
"computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_check_permissions" => Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => {
let img = grab()?;
let (w, h) = img.dimensions();
@ -1263,7 +1306,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
let jpeg = crate::computer::encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
shared().lock().unwrap().scale = Some((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64));
Ok((format!("Bot 的電腦的螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
Ok((format!("螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
}
"computer_use_click" => {
let (x, y) = pt(v, scale()?)?;
@ -1369,7 +1412,7 @@ pub fn type_text(text: &str) -> Result<(), String> {
use std::io::Write;
// Both selections hold the text; xclip keeps serving it after docker exec returns (setsid).
let mut child = docker()
.args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt </dev/null >/dev/null 2>&1 & done; sleep 0.15"])
.args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt </dev/null >/dev/null 2>&1 & done; sleep 0.15"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
@ -1377,7 +1420,7 @@ pub fn type_text(text: &str) -> Result<(), String> {
.map_err(|e| e.to_string())?;
child.stdin.take().ok_or("寫不進剪貼簿")?.write_all(text.as_bytes()).map_err(|e| e.to_string())?;
let _ = child.wait();
let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase();
let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase();
let paste = if class.contains("term") { "shift+Insert" } else { "ctrl+v" };
xdo(&["key", "--clearmodifiers", paste])?;
Ok(())
@ -1412,7 +1455,7 @@ pub fn viewer_text(text: &str) {
// ---------- the browser viewer (noVNC) ----------
fn published_port() -> u16 {
dk(&["port", CONTAINER, &NOVNC_PORT.to_string()])
dk(&["port", container(), &NOVNC_PORT.to_string()])
.ok()
.and_then(|s| s.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.trim().parse().ok()))
.unwrap_or(HOST_PORT)
@ -1519,15 +1562,29 @@ mod tests {
println!("{txt}");
assert!(img.is_some());
run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids);
computer_run("computer_use_click", &serde_json::json!({"x": 200, "y": 150})).unwrap();
// Click the middle of that window, wherever it opened (other windows — a browser left
// open — may cover the corner), in the screenshot's pixels. (Its shell renames it, so it
// is found by its class: the newest xterm.)
let (geo, _) = run_on("shell", r#"{"command":"W=$(DISPLAY=:1 xdotool search --onlyvisible --class xterm | tail -1); DISPLAY=:1 xdotool getwindowgeometry --shell $W | tail -n +2 | head -4 | tr '\n' ' '"}"#, &t, &cancel, &ids);
let num = |k: &str| geo.split_whitespace().find_map(|w| w.strip_prefix(&format!("{k}=")).and_then(|v| v.parse::<f64>().ok())).unwrap_or(0.0);
let (cx, cy) = (num("X") + num("WIDTH") / 2.0, num("Y") + num("HEIGHT") / 2.0);
let k = 1024.0 / 1280.0;
computer_run("computer_use_click", &serde_json::json!({"x": cx * k, "y": cy * k})).unwrap();
computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap();
computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap();
std::thread::sleep(Duration::from_millis(800));
let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids);
println!("typed: {out}");
assert!(out.contains("中文輸入OK"), "{out}");
// snapshot + restore
save_snapshot().unwrap();
// snapshot (with the browser and its caches in the home folder), then restore — which
// rebuilds the whole box, so only on a box meant for it (ANYBOT_TEST_RESTORE=1).
let size = save_snapshot().expect("snapshot saves even while the browser writes");
println!("snapshot: {}", human(size));
assert!(size > 0);
if std::env::var("ANYBOT_TEST_RESTORE").as_deref() != Ok("1") {
let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids);
return;
}
run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids);
rebuild(Rebuild::Restore).unwrap();
let start = Instant::now();
@ -1600,7 +1657,7 @@ mod tests {
fn wrapper_runs_as_the_bot_in_its_folder() {
let w = exec_wrapper("/home/bot/bot-3", "ls -la", false, "");
assert!(w.contains("exec -u bot"));
assert!(w.contains(CONTAINER));
assert!(w.contains(container()));
assert!(w.contains("/home/bot/bot-3"));
}
}

View File

@ -161,7 +161,12 @@ fn args_of(args: &str) -> Value {
pub fn describe(name: &str, args: &str) -> String {
let v = args_of(args);
let xy = || format!("({}, {})", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));
// By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every
// click by number).
let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) {
Some(r) => r.to_string(),
None => format!("({}, {})", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)),
};
match name {
"computer_use_screenshot" => "螢幕截圖".into(),
"computer_use_click" => {
@ -172,7 +177,8 @@ pub fn describe(name: &str, args: &str) -> String {
"computer_use_typing" => {
let t = v["value"].as_str().unwrap_or("");
let short: String = t.chars().take(60).collect();
format!("「{short}{}」", if t.chars().count() > 60 { "…" } else { "" })
let at = v["ref"].as_str().filter(|r| !r.trim().is_empty()).map(|r| format!("在 {r} ")).unwrap_or_default();
format!("{at}「{short}{}」", if t.chars().count() > 60 { "…" } else { "" })
}
"computer_use_press_key" => v["key"].as_str().unwrap_or("").to_string(),
"computer_use_scroll" => format!("{} {} 格,在 {}", v["direction"].as_str().unwrap_or("down"), v["amount"].as_i64().unwrap_or(3), xy()),
@ -188,7 +194,7 @@ pub fn describe(name: &str, args: &str) -> String {
#[allow(dead_code)]
pub fn specs() -> Vec<Value> {
specs_for(&[crate::model::THIS_COMPUTER_NAME.to_string()], crate::model::THIS_COMPUTER_NAME)
specs_for(&[], "")
}
/// The screen tools, with a `computer` choice when the bot has its own desktop too.
@ -220,13 +226,12 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.",
"parameters": {"type": "object", "properties": {}}}),
];
// Named whenever there is a choice, or the one screen isn't this computer or the bot's own
// (a 手腳 computer): the bot should say whose screen it means.
let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME];
if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) {
// Always named: each computer goes by its own name (the instructions say which is which),
// so the bot says whose screen it means and the person can tell where it worked.
if !computers.is_empty() {
let desc = format!("Whose screen, by the computer's name. Default: {default} (the one the person chose). Use another only when the person names it.");
for t in v.iter_mut() {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers,
"description": format!("Whose screen. Default: {default}. {} is the bot's own Linux desktop; {} is the person's Mac (use it for the person's own apps).", crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME)});
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc});
}
}
v
@ -395,6 +400,67 @@ fn run_text_mode_name(name: &str, v: &Value) -> Option<()> {
}
}
/// Ask macOS for a permission (its own prompt, then 系統設定 at that pane) at most once every ten
/// minutes. Asking on every step meant a bot working through several steps — or a brain far away
/// working on this computer — covered the screen with prompts and Settings windows, and the
/// person could do nothing else.
pub fn ask_permission(pane: &str) {
static LAST: std::sync::Mutex<Vec<(String, std::time::Instant)>> = std::sync::Mutex::new(Vec::new());
{
let mut l = LAST.lock().unwrap_or_else(|e| e.into_inner());
if l.iter().any(|(p, t)| p == pane && t.elapsed() < std::time::Duration::from_secs(600)) {
return;
}
l.retain(|(p, _)| p != pane);
l.push((pane.to_string(), std::time::Instant::now()));
}
if !cfg!(target_os = "macos") {
return;
}
match pane {
"Privacy_Accessibility" => {
let _ = crate::axread::trusted(true);
}
#[cfg(target_os = "macos")]
"Privacy_ScreenCapture" => {
let _ = mac::screen_capture_allowed();
}
_ => {}
}
let _ = spawn_reaped(&mut std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}")));
}
const NO_ACCESSIBILITY: &str = "macOS 還沒讓 AnyBot 使用「輔助使用」,所以讀不到、也按不了這台電腦上的 App。這台電腦上已經跳出詢問並打開「系統設定 → 隱私權與安全性 → 輔助使用」:請使用者把 AnyBot 打開(只要一次),再叫我試一次。";
/// agent-desktop's PERM_DENIED (no 輔助使用), as words the bot can pass on — and the person asked.
fn plain_permission_words(t: String) -> String {
if !t.contains("PERM_DENIED") {
return t;
}
ask_permission("Privacy_Accessibility");
match t.split_once(",但讀不到它的畫面") {
Some((opened, _)) => format!("{opened},但讀不到它的畫面:{NO_ACCESSIBILITY}"),
None => NO_ACCESSIBILITY.to_string(),
}
}
/// The app a shown name stands for (「計算機」 → Calculator), from the apps' display names.
#[cfg(target_os = "macos")]
fn app_named(name: &str) -> Option<String> {
if name.is_empty() || name.contains(['"', '\\', '\'']) {
return None;
}
let q = format!("kMDItemDisplayName == \"{name}\"cd && kMDItemContentType == \"com.apple.application-bundle\"");
let out = std::process::Command::new("mdfind").arg(q).output().ok()?;
let path = String::from_utf8_lossy(&out.stdout).lines().next()?.trim().to_string();
std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string())
}
#[cfg(not(target_os = "macos"))]
fn app_named(_name: &str) -> Option<String> {
None
}
/// The accessibility (text) way: agent-desktop, by element number, in the background.
fn run_text_mode(name: &str, v: &Value) -> Option<Result<(String, Option<String>), String>> {
let rf = v["ref"].as_str().unwrap_or("").trim().to_string();
@ -408,9 +474,20 @@ fn run_text_mode(name: &str, v: &Value) -> Option<Result<(String, Option<String>
}),
"computer_use_open_app" => {
let app = v["app"].as_str().unwrap_or("").trim().to_string();
crate::desktop::run("launch", &json!({"app": app})).and_then(|_| {
// A name as the person sees it (「計算機」 on a Chinese Mac) isn't one the system
// launches by; find the app it is and launch that.
let launched = crate::desktop::run("launch", &json!({"app": app})).or_else(|e| match app_named(&app) {
Some(real) if real != app => crate::desktop::run("launch", &json!({"app": real})),
_ => Err(e),
});
launched.map(|_| {
std::thread::sleep(std::time::Duration::from_millis(800));
crate::desktop::read_app(&app).map(|(a, lines)| format!("已打開「{a}」。\n{}", lines.join("\n")))
// Open is open, even when its window can't be read (that's a separate problem,
// and the bot said "couldn't open" while the app sat there on the screen).
match crate::desktop::read_app(&app) {
Ok((a, lines)) => format!("已打開「{a}」。\n{}", lines.join("\n")),
Err(e) => format!("已打開「{app}」,但讀不到它的畫面:{e}"),
}
})
}
"computer_use_select" => crate::desktop::act("select", &rf, &json!({"value": v["value"]})),
@ -440,8 +517,16 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作。".into());
}
}
// Reading and using apps by their controls needs 輔助使用: without it macOS answers
// PERM_DENIED and nobody was asked. Ask (prompt + the Settings pane, now and then) and say it
// plainly; opening an app still opens it.
let by_controls = name == "computer_use_read_app" || run_text_mode_name(name, v).is_some();
if cfg!(target_os = "macos") && by_controls && name != "computer_use_open_app" && !crate::axread::trusted(false) {
ask_permission("Privacy_Accessibility");
return Err(NO_ACCESSIBILITY.into());
}
if let Some(r) = run_text_mode(name, v) {
return r;
return r.map(|(t, img)| (plain_permission_words(t), img)).map_err(plain_permission_words);
}
if !cfg!(any(target_os = "macos", target_os = "linux")) {
return Err("操作畫面只支援 macOS 和 Linux".into());
@ -465,8 +550,8 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) {
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
}
if cfg!(target_os = "macos") && !crate::axread::trusted(true) {
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
if cfg!(target_os = "macos") && !crate::axread::trusted(false) {
ask_permission("Privacy_Accessibility");
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
}
let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
@ -632,8 +717,8 @@ pub fn app_at_point(x: f64, y: f64) -> Option<String> {
}
fn screenshot() -> Result<(String, String), String> {
if !mac::screen_capture_allowed() {
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"));
if !mac::screen_capture_preflight() {
ask_permission("Privacy_ScreenCapture");
return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
}
let img = capture_screen()?;

View File

@ -119,7 +119,7 @@ pub const ROWS: &[(&str, &str, &str)] = &[
("連接器", "Connectors", "コネクタ"),
("例如:「每個工作日早上 9 點整理新聞給我」", "e.g. “Every weekday at 9 am, send me a news summary”", "例:「平日の毎朝 9 時にニュースをまとめて」"),
("要新增或修改,直接在聊天裡告訴 Bot。", "To add or change one, just tell the bot in the chat.", "追加や変更は、チャットで Bot に伝えるだけです。"),
("在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", "On the bot's computer it can work freely (it can't see your files); on your computer, changing files and commands that change things ask you first. Password and key files are never touched.", "Bot のコンピューターでは自由に作業できます(あなたのファイルは見えません)。あなたのコンピューターでは、ファイルの変更や変更を伴うコマンドは先に確認します。パスワードや鍵のファイルには一切触れません。"),
("在它自己的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", "On the bot's computer it can work freely (it can't see your files); on your computer, changing files and commands that change things ask you first. Password and key files are never touched.", "Bot のコンピューターでは自由に作業できます(あなたのファイルは見えません)。あなたのコンピューターでは、ファイルの変更や変更を伴うコマンドは先に確認します。パスワードや鍵のファイルには一切触れません。"),
("還沒有。跟 Bot 說「記住我喜歡靠窗的座位」試試看。", "Nothing yet. Try telling the bot “remember I like window seats”.", "まだありません。Bot に「窓側の席が好きだと覚えて」と言ってみてください。"),
("選單會用英文顯示;Bot 會用你選的語言回答。", "Menus are shown in English; bots answer in the language you chose.", "メニューは英語で表示されます。Bot は選んだ言語で答えます。"),
("視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。登入帳號後也會推播到手機。", "You're notified when the window isn't in front, or after 90 s without keyboard or mouse. Once signed in, your phone is notified too.", "ウィンドウが前面にないとき、または 90 秒間操作がないときに通知します。ログインするとスマホにも通知されます。"),
@ -598,4 +598,29 @@ pub const ROWS: &[(&str, &str, &str)] = &[
("發起語音聊天:你講完它就回答並唸出來,再繼續聽", "Start voice chat: when you finish speaking it answers out loud, then keeps listening", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"),
("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"),
("你好,我是你的 Bot。", "Hi, I'm your bot.", "こんにちは、あなたの Bot です。"),
("在大腦「{name}」上", "On the brain “{name}”", "ブレイン「{name}」上"),
("連線中…", "Connecting…", "接続中…"),
("看「{name}」的螢幕,也可以取得控制權自己操作", "Watch the screen of “{name}”; you can also take control", "「{name}」の画面を見ます。操作を代わることもできます"),
("叫醒「{name}」並看它的螢幕", "Wake “{name}” and watch its screen", "「{name}」を起こして画面を見ます"),
("要 {name} 在哪台電腦做事", "Which computer {name} works on", "{name} が作業するコンピューター"),
("它自己的電腦", "its own computer", "自分のコンピューター"),
("你正在用的這台", "the one you're using", "今使っているコンピューター"),
("這台電腦", "this computer", "このコンピューター"),
("大腦所在的伺服器", "the brain's server", "ブレインのサーバー"),
("SSH 電腦", "SSH computer", "SSH コンピューター"),
("做事的電腦", "Works on", "作業するコンピューター"),
("聊天時指名別台就會換", "Name another in the chat to switch", "チャットで別の名前を言うと切り替わります"),
("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"),
("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"),
("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", "A Linux computer all your bots share; each bot has its own folder (e.g. “Master's computer”). Always on, it never sleeps.", "すべての Bot が共有する Linux コンピューター。Bot ごとにフォルダーがあります(例:「Master のコンピューター」)。常に起動していて、スリープしません。"),
("把 Bot 的檔案備份到這台電腦;每天也會自動存一份", "Back up the bots' files to this computer; a copy is also saved every day", "Bot のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"),
("等你處理", "Waiting for you", "対応待ち"),
("需要處理", "Action needed", "対応が必要です"),
("接管", "Take over", "操作を代わる"),
("接管電腦", "Take over the computer", "コンピューターの操作を代わる"),
("我完成了", "I'm done", "完了しました"),
("跳過", "Skip", "スキップ"),
("取消此要求且不執行該步驟;Bot 會跳過它並繼續", "Cancel this request without doing the step; the Bot continues without it", "この依頼を取り消し、その手順は行いません。Bot はそれを飛ばして続けます"),
("連結中", "Connecting", "接続中"),
("請到「{name}」上操作", "Please do it on “{name}”", "「{name}」で操作してください"),
];

View File

@ -147,8 +147,11 @@ pub fn load_fonts(ctx: &egui::Context) {
/// The window icon (Linux taskbars and window switchers; macOS uses the app bundle's).
fn app_icon() -> egui::IconData {
let svg = include_str!("../icons/anybot.svg");
let px = 256u32;
svg_icon(include_str!("../icons/anybot.svg"), 256)
}
/// An SVG drawn to a square RGBA picture of `px` pixels.
fn svg_icon(svg: &str, px: u32) -> egui::IconData {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("icon svg");
let mut pm = resvg::tiny_skia::Pixmap::new(px, px).expect("icon pixmap");
let s = px as f32 / tree.size().width();
@ -166,21 +169,35 @@ fn app_icon() -> egui::IconData {
egui::IconData { rgba, width: px, height: px }
}
/// `anybot --write-icon out.png [size]`: the app icon as a PNG (for Linux packages).
fn write_icon(out: &str, px: u32) -> Result<(), String> {
let icon = app_icon();
/// `anybot --write-icon out.png [size] [file.svg]`: the app icon (or that SVG) as a PNG, drawn at
/// that size (for the Mac bundle, Linux packages and the web icons).
fn write_icon(out: &str, px: u32, svg: Option<&str>) -> Result<(), String> {
let icon = match svg {
Some(path) => svg_icon(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?, px),
None => svg_icon(include_str!("../icons/anybot.svg"), px),
};
let img = image::RgbaImage::from_raw(icon.width, icon.height, icon.rgba).ok_or("icon")?;
let img = if px != icon.width { image::imageops::resize(&img, px, px, image::imageops::FilterType::Lanczos3) } else { img };
img.save(out).map_err(|e| e.to_string())
}
fn main() -> eframe::Result<()> {
let args: Vec<String> = std::env::args().collect();
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
// For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit.
if args.get(1).map(String::as_str) == Some("--sample-state") {
let out = args.get(2).cloned().unwrap_or_else(|| "state.json".into());
let mut st = model::State::samples();
st.remote = Default::default();
if let Err(e) = std::fs::write(&out, serde_json::to_vec_pretty(&st).unwrap_or_default()) {
eprintln!("{e}");
std::process::exit(1);
}
return Ok(());
}
if args.get(1).map(String::as_str) == Some("--write-icon") {
let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into());
let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256);
if let Err(e) = write_icon(&out, px) {
if let Err(e) = write_icon(&out, px, args.get(4).map(String::as_str)) {
eprintln!("{e}");
std::process::exit(1);
}
@ -235,15 +252,18 @@ fn main() -> eframe::Result<()> {
app::App::serve(&ctx);
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("AnyBot")
.with_inner_size(shot_size())
.with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0])
.with_app_id("anybot")
.with_icon(app_icon()),
..Default::default()
};
let mut viewport = egui::ViewportBuilder::default()
.with_title("AnyBot")
.with_inner_size(shot_size())
.with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0])
.with_app_id("anybot")
.with_icon(app_icon());
// On a Mac the window is one surface to the very top (the grey system title bar didn't match
// a dark window); the traffic lights sit over the sidebar's empty corner.
if cfg!(target_os = "macos") {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
let options = eframe::NativeOptions { viewport, ..Default::default() };
eframe::run_native(
"AnyBot",
options,

View File

@ -28,7 +28,7 @@ pub struct Template {
pub const TEMPLATES: [Template; 5] = [
Template { name: "研究助理", description: "上網找資料、整理重點、附上來源", instructions: "先釐清問題,再用 web_fetch 或瀏覽器找至少兩個可靠來源。回答先給結論,再列重點,每個重點附來源網址。不確定的地方直接說不確定。", shape: "circle", color: "green" },
Template { name: "寫作夥伴", description: "幫你構思、改寫和潤稿", instructions: "先問清楚讀者和語氣。改稿時保留作者的意思,列出改了什麼和為什麼。用繁體中文、台灣用語。", shape: "cloud", color: "purple" },
Template { name: "電腦助手", description: "在 Bot 的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在「Bot 的電腦」上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" },
Template { name: "電腦助手", description: "在自己的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在你自己的電腦上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" },
Template { name: "客服助理", description: "在 Slack、Discord、Telegram 或 Teams 回覆客人", instructions: "回答簡短、有禮貌。不確定的事不要編,說會請同事確認。不要透露內部資料或使用者的私人資訊。", shape: "pill", color: "orange" },
Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" },
];

View File

@ -123,10 +123,23 @@ fn is_zero(n: &u64) -> bool {
*n == 0
}
/// A bot's own computer as kept in `Bot::default_computer` (it survives renaming the bot). It is
/// shown and offered under the bot's name: see [`own_computer_name`].
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
/// What older versions kept for "the person's computer". Read as the computer the person is using
/// now; every computer is shown under its own name.
pub const THIS_COMPUTER_NAME: &str = "你的電腦";
/// A brain on a server, when the person's own computer lends it hands (then that one is 你的電腦).
pub const BRAIN_HOST_NAME: &str = "大腦伺服器";
/// What a bot calls its own computer: 「Master 的電腦」 for the bot Master. One plain name per
/// computer — 「Bot 的電腦」 and 「你的電腦」 meant different machines to the person and the bot.
pub fn own_computer_name(bot_name: &str) -> String {
let n = bot_name.trim();
if n.is_empty() {
BOT_COMPUTER_NAME.to_string()
} else {
format!("{n} 的電腦")
}
}
impl Message {
pub fn user(text: &str) -> Self {
@ -382,13 +395,25 @@ fn default_sidebar_width() -> f32 {
}
impl State {
/// Every computer a bot can be told to use: its own, this Mac, then the SSH ones.
/// Every computer name a bot can be told to use here: its own (by its stored name), this
/// computer, then the SSH ones.
pub fn computer_names(&self) -> Vec<String> {
let mut v = vec![BOT_COMPUTER_NAME.to_string(), THIS_COMPUTER_NAME.to_string()];
let mut v = vec![BOT_COMPUTER_NAME.to_string(), self.this_computer_name()];
v.extend(self.remote_computers.iter().map(|c| c.name.clone()));
v
}
/// This computer's own name: the one given in 設定 › 電腦, else its name in the account, else
/// the name the system gives it.
pub fn this_computer_name(&self) -> String {
for n in [&self.machine_label, &self.remote.host_name] {
if !n.trim().is_empty() {
return n.trim().to_string();
}
}
crate::remote::default_host_name()
}
pub fn routine_owner(&self, routine_id: u64) -> Option<u64> {
self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id)
}

View File

@ -3,15 +3,6 @@
// `localize` is applied to every translated label (i18n::t) and to what the bot is told (its
// instructions and tool descriptions), so the wording follows the system in one place.
/// A screen the person sits at (not a server with no display).
pub fn has_desktop() -> bool {
if cfg!(target_os = "linux") {
std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) || std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false)
} else {
true
}
}
const PAIRS: &[(&str, &str)] = &[
("使用者的這台 Mac", "使用者的這台電腦"),
("這台 Mac", "這台電腦"),

View File

@ -223,6 +223,10 @@ pub enum ApprovalKind {
EnableTools,
/// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.)
EnableThisComputer,
/// 需要處理: a step only the person can do on a computer (signing in, a captcha…). `reason`
/// is what to do, `grant` the computer's name; Once = 「我完成了」, Reject = 「跳過」 (the
/// original's box hand-off card).
Handoff,
}
pub struct AppCall {

View File

@ -92,6 +92,12 @@ impl Default for Settings {
}
pub fn default_host_name() -> String {
// Asked of the system once (it runs a program).
static N: OnceLock<String> = OnceLock::new();
N.get_or_init(system_host_name).clone()
}
fn system_host_name() -> String {
if !cfg!(target_os = "macos") {
let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty };
@ -109,7 +115,8 @@ pub fn default_host_name() -> String {
/// What the phone asks AnyBot to do; the app carries these out on its own thread.
#[derive(Clone, Debug, PartialEq)]
pub enum Cmd {
Send { bot: u64, text: String, attachments: Vec<String> },
/// `from`: the account computer the person typed it on (empty: the phone, or this one).
Send { bot: u64, text: String, attachments: Vec<String>, from: String },
Stop { bot: u64 },
Answer { bot: u64, answer: String },
Read { bot: u64 },
@ -119,6 +126,8 @@ pub enum Cmd {
Reply { bot: u64 },
/// Bring back an earlier conversation (過去的對話).
RestoreThread { bot: u64, index: usize },
/// 清除聊天記錄 from a screen of this brain (the chat lives here).
Clear { bot: u64 },
/// A new phone signed in (to remember), or one was signed out.
DeviceAdded(Device),
DeviceSeen(String),
@ -315,6 +324,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
("GET", []) | ("GET", ["index.html"]) => return page(),
("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()),
("GET", ["api", "hello"]) => {
let s = hub().settings();
return Resp::json(&json!({"app": "anybot", "host": s.host_name, "relay": via_relay, "needs_password": !via_relay}));
@ -393,7 +403,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
// 團隊: the bot knows who is talking when it isn't the owner.
let who = req.header("x-anybot-user");
let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text };
Cmd::Send { bot, text, attachments }
Cmd::Send { bot, text, attachments, from: String::new() }
}
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
@ -425,6 +435,52 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
Err(e) => Resp::error(503, &e),
},
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req),
// A screen of this brain watches (and may take over) the bots' own computer, which lives
// here: its picture on that screen used to be its own computer's, not this one's.
("GET", ["api", "sync", "screen"]) => {
if req.query.get("thumb").map(|t| t == "1").unwrap_or(false) {
match crate::cloudbox::thumbnail(320) {
Some(img) => {
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
match crate::computer::encode_jpeg(&rgb) {
Some(j) => Resp::bytes("image/jpeg", j),
None => Resp::error(500, "畫面編碼失敗"),
}
}
None => Resp::error(404, "還沒有畫面"),
}
} else {
screen("box", &s)
}
}
("POST", ["api", "sync", "screen"]) => {
let v = req.json();
match v["kind"].as_str().unwrap_or("") {
"hold" => {
crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst);
Resp::json(&json!({"ok": true}))
}
"viewer" => {
crate::cloudbox::set_viewer_open(v["on"].as_bool().unwrap_or(false));
Resp::json(&json!({"ok": true}))
}
// The person's own hands on the picture (frame pixels, like this computer's own
// viewer; not the bot's tools, which stop while the person holds it).
"click" | "move" | "scroll" | "key" | "text" => {
crate::cloudbox::touch();
let (x, y) = (v["x"].as_i64().unwrap_or(0), v["y"].as_i64().unwrap_or(0));
match v["kind"].as_str().unwrap_or("") {
"click" => crate::cloudbox::viewer_click(x, y, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1).clamp(1, 3) as u32),
"move" => crate::cloudbox::viewer_move(x, y),
"scroll" => crate::cloudbox::viewer_scroll(x, y, v["down"].as_bool().unwrap_or(true), v["steps"].as_u64().unwrap_or(1).clamp(1, 20) as u32),
"key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")),
_ => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")),
}
Resp::json(&json!({"ok": true}))
}
_ => screen_input("box", &v, &s),
}
}
("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) {
Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e),
@ -449,6 +505,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
bot,
text: b["text"].as_str().unwrap_or("").trim().to_string(),
attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
// The relay names the computer that asked: the one the person is using.
from: req.header("x-anybot-from").to_string(),
},
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
@ -457,6 +515,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
"regenerate" => Cmd::Regenerate { bot },
"reply" => Cmd::Reply { bot },
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
"clear" => Cmd::Clear { bot },
_ => return Resp::error(404, "沒有這個動作"),
};
hub().push(cmd);
@ -590,7 +649,7 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
}
let (name, args) = match kind {
"click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
"type" => ("computer_use_typing", json!({"text": v["text"]})),
"type" => ("computer_use_typing", json!({"value": v["text"]})),
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
"scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})),
_ => return Resp::error(400, "不認得的操作"),
@ -693,7 +752,11 @@ fn host_port(url: &str) -> Option<(String, u16)> {
/// can't open a connection to, and the person's computer lends this brain its hands.
pub fn model_hands_for(url: &str) -> Option<String> {
let s = hub().settings();
let hand = my_hands(&s.host_id).into_iter().next()?;
// The person's own computer (the one they type on) is the one on their network; the first
// hands by id was a desktop in the server room that can't reach a model at their office.
let hands = my_hands(&s.host_id);
let person = person_host();
let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?;
let (h, p) = host_port(url)?;
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
return None;
@ -716,7 +779,7 @@ pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
let name = peer_name(hand);
let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") };
let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") };
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
let v = answer_value(a.status, &a.body, &whom)?;
@ -1155,6 +1218,15 @@ pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result<S
v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into())
}
/// The brain's bots' computer as a JPEG (`thumb`: the small picture that never wakes it).
pub fn sync_screen(s: &Settings, thumb: bool) -> Result<Vec<u8>, String> {
let (st, bytes) = to_brain_raw(s, "GET", "/screen", &[("thumb", if thumb { "1" } else { "0" }.to_string())], &[], 30)?;
if !(200..300).contains(&st) {
return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦"));
}
Ok(bytes)
}
/// A file the brain keeps (an attachment shown in a chat), as bytes.
pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> {
let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?;
@ -1350,6 +1422,43 @@ pub fn my_hands(me: &str) -> Vec<String> {
v
}
fn person_cell() -> &'static Mutex<String> {
static P: OnceLock<Mutex<String>> = OnceLock::new();
P.get_or_init(Mutex::default)
}
/// The account computer the person last typed on (they are sitting at it). Kept beside the data,
/// so a brain that restarts (an update) still knows it.
pub fn set_person_host(id: &str) {
let mut cur = person_cell().lock().unwrap();
if *cur != id {
*cur = id.to_string();
let _ = std::fs::write(person_file(), id);
}
}
fn person_file() -> std::path::PathBuf {
crate::model::State::path().with_file_name("person-computer.txt")
}
/// At start: the computer the person used last time.
pub fn load_person_host() -> Option<String> {
let id = std::fs::read_to_string(person_file()).ok()?.trim().to_string();
(!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| {
*person_cell().lock().unwrap() = id.clone();
id
})
}
pub fn person_host() -> String {
person_cell().lock().unwrap().clone()
}
/// The brain an account computer shows (empty: it thinks for itself), as last seen.
pub fn peer_uses(id: &str) -> String {
peer_rows().lock().unwrap().get(id).map(|r| r.1.clone()).unwrap_or_default()
}
/// A computer's own name as the account server knows it.
pub fn peer_name(id: &str) -> String {
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default()
@ -1374,8 +1483,11 @@ pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
// ---------- the phone page ----------
const PAGE: &str = include_str!("../mobile/index.html");
pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##;
pub const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="#161616"/><circle cx="48" cy="48" r="24" fill="none" stroke="#f5f5f5" stroke-width="7"/><circle cx="48" cy="48" r="7" fill="#f5f5f5"/></svg>"##;
pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##;
/// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one).
pub const ICON: &str = include_str!("../mobile/icon.svg");
/// PNG sizes for the iPhone home screen (it ignores SVG) and Android.
pub const ICON_PNGS: [(&str, &[u8]); 3] = [("icon-180.png", include_bytes!("../mobile/icon-180.png")), ("icon-192.png", include_bytes!("../mobile/icon-192.png")), ("icon-512.png", include_bytes!("../mobile/icon-512.png"))];
pub fn page() -> Resp {
Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
@ -1466,7 +1578,7 @@ mod tests {
assert_eq!(handle(send, false).status, 200);
let cmds = hub().take_cmds();
assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone")));
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] }));
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![], from: String::new() }));
// The Mac's own screen stays closed unless allowed.
let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() };
mac.headers = req.headers.clone();

View File

@ -60,8 +60,12 @@ pub fn specs() -> Vec<Value> {
]
}
/// 請你接手: the step that asks the person to do something by hand on a computer.
pub const HANDOFF_TOOL: &str = "hand_over_computer";
pub fn label(name: &str) -> &'static str {
match name {
HANDOFF_TOOL => "請你接手電腦",
"list_dir" => "查看資料夾",
"search" => "搜尋檔案",
"read_file" => "讀取檔案",
@ -622,6 +626,17 @@ pub struct Target {
pub place: Place,
}
/// Which of a bot's computers is which, by name (every computer goes by one plain name).
#[derive(Clone, Debug, Default)]
pub struct Roles {
/// The bot's own computer: 「Master 的電腦」.
pub own: String,
/// The computer the person is using now (where they typed to the bot), when known.
pub person: Option<String>,
/// This AnyBot runs on a server: the computer it is on is nobody's desk.
pub server: bool,
}
/// The tools with a `computer` choice added to each, listing the computers this bot can use.
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
let mut v = specs();
@ -629,10 +644,13 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
if t["name"] == "read_terminal" || t["name"] == "write_terminal" {
continue;
}
// Each computer goes by its own name; the instructions say which is which. Working on
// one and calling it another (the server as "your computer") is what confused people.
let desc = format!("Which computer to work on, by its name. Default: {default} (the one the person chose). Use another only when the person names it; when unsure which one they mean, ask.");
t["parameters"]["properties"]["computer"] = json!({
"type": "string",
"enum": computers,
"description": format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME)
"description": desc
});
}
v
@ -678,7 +696,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
}
let p = resolve(root, &arg_str(&v, "path"));
if !inside(root, &p) {
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理你的檔案,請用「{}」。", target.name, p.display(), crate::model::THIS_COMPUTER_NAME));
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
}
Decision::Allow
}

View File

@ -59,30 +59,14 @@ pub fn read_needs_permission(args: &str) -> bool {
uses_person_browser(args)
}
/// The browser tools, also when the browser is on another computer of the account (裝置連結):
/// then each takes a `computer`, since each computer's AnyBot browser has its own logins.
pub fn specs_for(here: &str, devices: &[String]) -> Vec<Value> {
let local = crate::botbrowser::available();
let mut v = specs_inner(local || !devices.is_empty());
if !devices.is_empty() {
let mut names: Vec<String> = devices.to_vec();
// A brain on a server with the person's computer as its hands: that one's browser first.
let person = devices.iter().any(|d| d == crate::model::THIS_COMPUTER_NAME);
if local {
names.insert(if person { names.len() } else { 0 }, here.to_string());
}
let desc = format!(
"Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}",
if person {
format!("Leave out for {} (the person's own computer, where they can see the window).", crate::model::THIS_COMPUTER_NAME)
} else if local {
format!("Leave out for {here}.")
} else {
"This computer has no browser: always name one.".to_string()
}
);
/// The browser tools. `browsers`: the computers with an AnyBot browser this bot can use (its own,
/// this one, the account's other computers), by name; each has its own logins.
pub fn specs_for(browsers: &[String], default: &str) -> Vec<Value> {
let mut v = specs_inner(!browsers.is_empty());
if !browsers.is_empty() {
let desc = format!("Which computer's AnyBot browser to use, by the computer's name. Each computer's browser has its own logins (a site logged in on one isn't on another). Default: {default}. Use another only when the person names it.");
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": browsers, "description": desc});
}
}
v

View File

@ -168,15 +168,52 @@ pub fn available() -> bool {
}
/// A file only we can see, gone from the folder already (for shared memory and keymaps).
/// Its memory is really there before the compositor gets it: the compositor writes screen
/// pixels straight into it, and if the space ran out half-way the compositor would be the one
/// to crash (SIGBUS: a full /run/user once took a whole Hyprland desktop down this way).
/// So it lives in memory of its own (memfd), not in the runtime folder, and with no room the
/// screenshot just fails here.
fn scratch_file(len: u64) -> std::io::Result<std::fs::File> {
let dir = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir);
let path = dir.join(format!("anybot-wl-{}-{}", std::process::id(), crate::model::now_ms()));
let f = std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(&path)?;
let _ = std::fs::remove_file(&path);
f.set_len(len)?;
use std::os::fd::FromRawFd as _;
let fd = unsafe { libc::memfd_create(c"anybot-wl".as_ptr(), libc::MFD_CLOEXEC) };
let f = if fd >= 0 {
unsafe { std::fs::File::from_raw_fd(fd) }
} else {
let dir = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir);
let path = dir.join(format!("anybot-wl-{}-{}", std::process::id(), crate::model::now_ms()));
let f = std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(&path)?;
let _ = std::fs::remove_file(&path);
f
};
if len > 0 {
reserve(&f, len)?;
}
Ok(f)
}
/// Make every page of the file real now (not a promise the compositor finds broken later).
fn reserve(f: &std::fs::File, len: u64) -> std::io::Result<()> {
use std::os::fd::AsRawFd as _;
let r = unsafe { libc::posix_fallocate(f.as_raw_fd(), 0, len as libc::off_t) };
if r == 0 {
return Ok(());
}
if r != libc::EOPNOTSUPP && r != libc::EINVAL {
return Err(std::io::Error::from_raw_os_error(r));
}
// A file system without fallocate: write the zeros ourselves.
f.set_len(0)?;
let mut w = f;
let zeros = vec![0u8; 1 << 20];
let mut left = len;
while left > 0 {
let n = left.min(zeros.len() as u64) as usize;
w.write_all(&zeros[..n])?;
left -= n as u64;
}
Ok(())
}
/// The keymap text: one key per keysym we use; the first four are the modifiers.
pub fn keymap_text(keys: &[u32]) -> String {
let mut s = String::from("xkb_keymap {\nxkb_keycodes \"anybot\" {\n minimum = 8;\n");
@ -614,4 +651,19 @@ mod tests {
let px = to_rgba(&[1, 2, 3, 0], wl_shm::Format::Abgr8888, 1, 1, 4, false).unwrap();
assert_eq!(px.get_pixel(0, 0).0, [1, 2, 3, 255]);
}
/// The screenshot buffer is real memory before the compositor writes into it, and not a
/// file in the runtime folder (a full /run/user must not crash the desktop).
#[test]
fn screenshot_buffer_is_allocated_up_front() {
use std::os::fd::AsRawFd as _;
let len = 3 * 1024 * 1024 + 17;
let f = scratch_file(len).unwrap();
assert_eq!(f.metadata().unwrap().len(), len);
let mut st: libc::stat = unsafe { std::mem::zeroed() };
assert_eq!(unsafe { libc::fstat(f.as_raw_fd(), &mut st) }, 0);
assert!(st.st_blocks as u64 * 512 >= len, "pages not reserved: {} blocks", st.st_blocks);
let link = std::fs::read_link(format!("/proc/self/fd/{}", f.as_raw_fd())).unwrap();
assert!(link.to_string_lossy().starts_with("/memfd:"), "{link:?}");
}
}