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>
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
output HEADLESS-1 resolution 1280x800
|
||||
exec foot -e sh -c 'echo AnyBot Wayland E2E; sleep 100000'
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
|
@ -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 |
|
|
@ -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"); };
|
||||
|
|
|
|||
|
|
@ -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})),
|
||||
|
|
|
|||
440
src/agent.rs
|
|
@ -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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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, ¬ed(&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)),
|
||||
|
|
|
|||
580
src/app.rs
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, ¤t, (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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
185
src/cloudbox.rs
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
src/computer.rs
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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}」で操作してください"),
|
||||
];
|
||||
|
|
|
|||
52
src/main.rs
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
33
src/model.rs
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "這台電腦"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
128
src/remote.rs
|
|
@ -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();
|
||||
|
|
|
|||
22
src/tools.rs
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
30
src/web.rs
|
|
@ -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
|
||||
|
|
|
|||
62
src/wl.rs
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||