「我的電腦」 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:
parent
61e0531d32
commit
e44614736f
|
|
@ -33,6 +33,7 @@ SCRIPTS = {
|
||||||
'SC_SHELL': [tool_call('shell', {'command': 'echo e2e-$((6*7))', '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_REDIRECT': [tool_call('shell', {'command': 'echo moved-here', 'computer': HANDS})],
|
||||||
'SC_HANDOFF': [tool_call('hand_over_computer', {'instruction': 'E2E:請按「我完成了」', '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))'})],
|
'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)
|
json.dump(hs, open(os.path.join(hdir, 'state.json'), 'w'), ensure_ascii=False)
|
||||||
henv = {'ANYBOT_DATA_DIR': hdir, **NOBOX}
|
henv = {'ANYBOT_DATA_DIR': hdir, **NOBOX}
|
||||||
start('hands', [BIN, '--serve'], henv)
|
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']
|
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)
|
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']}
|
return {x['name']: x for x in http('GET', RURL + '/relay/host/peers', token=tok)[1]['hosts']}
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
p = peers()
|
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
|
break
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
p = peers()
|
p = peers()
|
||||||
|
|
@ -184,10 +193,10 @@ def run():
|
||||||
v = pull()
|
v = pull()
|
||||||
comps = v.get('computers', {}).get('1', {})
|
comps = v.get('computers', {}).get('1', {})
|
||||||
names = [x['name'] for x in comps.get('names', [])]
|
names = [x['name'] for x in comps.get('names', [])]
|
||||||
if HANDS in names:
|
if HANDS in names and 'E2E另一台' in names:
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
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)
|
check('the bot works on its own computer by default', comps.get('current') == 'Master 的電腦', comps)
|
||||||
|
|
||||||
def wait_turn(answer=None, timeout=90, during=None):
|
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])
|
check('a message answers a hand-off', '傳了訊息' in t, t[:300])
|
||||||
ms = full_messages()
|
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'])
|
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.
|
# 9. The screen's copy, kept from small updates, matches the brain's chat exactly.
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
pull()
|
pull()
|
||||||
|
|
@ -329,7 +352,7 @@ def run():
|
||||||
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 四'})
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 四'})
|
||||||
wait_turn('once')
|
wait_turn('once')
|
||||||
t = ' '.join(tool_text_after('SC_SHELL 四'))
|
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()
|
pull(); pull()
|
||||||
check('screen copy still equals the brain chat', sig(copy['msgs'].get(1, [])) == sig(full_messages()))
|
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.
|
# 13. 清除聊天記錄 from the screen clears the brain's chat.
|
||||||
|
|
|
||||||
28
src/agent.rs
28
src/agent.rs
|
|
@ -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()))) {
|
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);
|
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())))) {
|
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,
|
Some(x) => x,
|
||||||
None => (BrowserAt::Here, String::new()),
|
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");
|
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) {
|
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
|
// A computer that isn't on the list now (switched off, its AnyBot closed): the step says
|
||||||
// so instead of quietly running somewhere else.
|
// so instead of quietly running somewhere else.
|
||||||
None => (args.to_string(), None),
|
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("、"))
|
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?
|
/// Does the call name a computer?
|
||||||
fn names_a_computer(args: &str) -> bool {
|
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)
|
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
|
// 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.
|
// 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()),
|
tools::Place::Device { host } => Some(host.clone()),
|
||||||
_ => None,
|
_ => 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 web = path.starts_with("http://") || path.starts_with("https://");
|
||||||
let (output, ok) = match (&person_host, web) {
|
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) {
|
(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),
|
Err(e) => (e, false),
|
||||||
},
|
},
|
||||||
(None, true) => ("不知道使用者現在在用哪台電腦,沒辦法打開給他看。把網址直接寫在回覆裡。".to_string(), 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) {
|
if names_a_computer(&call.args) {
|
||||||
return target_for(&job, &call.args);
|
return target_for(&job, &call.args);
|
||||||
}
|
}
|
||||||
job.roles
|
roles_now(&job)
|
||||||
.person
|
.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()
|
.cloned()
|
||||||
.ok_or_else(|| format!("不知道使用者現在在用哪台電腦。請問他要在哪台做(可以用:{})。", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")))
|
.ok_or_else(|| format!("不知道使用者現在在用哪台電腦。請問他要在哪台做(可以用:{})。", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")))
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,12 @@ impl App {
|
||||||
};
|
};
|
||||||
crate::i18n::set(&app.state.language);
|
crate::i18n::set(&app.state.language);
|
||||||
app.computers_start();
|
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 {
|
if demo {
|
||||||
// The computer helper's pending step, so the picture shows the approval card.
|
// The computer helper's pending step, so the picture shows the approval card.
|
||||||
let (reply, _) = channel();
|
let (reply, _) = channel();
|
||||||
|
|
|
||||||
|
|
@ -1427,9 +1427,27 @@ fn person_cell() -> &'static Mutex<String> {
|
||||||
P.get_or_init(Mutex::default)
|
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) {
|
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 {
|
pub fn person_host() -> String {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue