「我的電腦」 follows the computer the person types on, also mid-turn and after a restart

A turn started from the phone (not a computer) kept 「don't know」 for its whole run: a message
typed on the Mac while it waited on a hand-off had its 「我的電腦」 moved to the bot's own computer.
Each step now asks which computer the person last typed on, and the brain keeps it beside its
data (person-computer.txt) across restarts. remote_e2e: a second computer, and a mid-turn
message from the other one (fails without the fix); 28 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 19:41:49 +08:00
parent 61e0531d32
commit e44614736f
4 changed files with 74 additions and 13 deletions

View File

@ -33,6 +33,7 @@ SCRIPTS = {
'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))'})],
}
@ -147,6 +148,14 @@ def run():
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)
@ -154,7 +163,7 @@ def run():
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'):
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()
@ -184,10 +193,10 @@ def run():
v = pull()
comps = v.get('computers', {}).get('1', {})
names = [x['name'] for x in comps.get('names', [])]
if HANDS in names:
if HANDS in names and 'E2E另一台' in names:
break
time.sleep(1)
check('computers by their own names; no server computer', names == ['Master 的電腦', HANDS], names)
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):
@ -276,6 +285,20 @@ def run():
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()
@ -329,7 +352,7 @@ def run():
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 hands still work', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300])
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.

View File

@ -140,7 +140,7 @@ fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) {
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 = job.roles.person.as_ref().and_then(|p| job.computers.iter().find(|t| &t.name == p));
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()),
@ -222,7 +222,7 @@ fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option
}
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, &job.roles) => (args.to_string(), None),
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),
@ -255,6 +255,20 @@ 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)
@ -635,7 +649,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
};
// 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_host = job.roles.person.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place {
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,
});
@ -643,7 +658,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
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!("[{}] {}", job.roles.person.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)),
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),
@ -856,10 +871,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if names_a_computer(&call.args) {
return target_for(&job, &call.args);
}
job.roles
roles_now(&job)
.person
.as_ref()
.and_then(|n| job.computers.iter().find(|t| &t.name == n))
.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("、")))
};

View File

@ -303,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();

View File

@ -1427,9 +1427,27 @@ fn person_cell() -> &'static Mutex<String> {
P.get_or_init(Mutex::default)
}
/// The account computer the person last typed on (they are sitting at it).
/// 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) {
*person_cell().lock().unwrap() = id.to_string();
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 {