Replies in Traditional Chinese when that is the setting; a cloud brain's browser is the person's

- 語言 繁體中文: a reply's Simplified characters are turned into Traditional with Taiwan
  wording (zhconv), since some models (Qwen) slip despite the instruction.
- A brain on a server: browser steps go to the person's computer (its hands) unless the
  server's own browser is named; 你的電腦 is listed first.
- update_bot says the default computer it set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 14:46:42 +08:00
parent 94cadca1b8
commit 4c0db53647
6 changed files with 40 additions and 6 deletions

View File

@ -10,6 +10,7 @@ name = "anybot"
path = "src/main.rs"
[dependencies]
zhconv = "0.4"
eframe = "0.37"
ureq = { version = "3", features = ["json"] }
# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it.
@ -48,7 +49,6 @@ agent-desktop-core = { git = "https://github.com/vercel-labs/agent-desktop", tag
opt-level = 2
[target.'cfg(not(target_os = "macos"))'.dependencies]
zhconv = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
agent-desktop-linux = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" }

View File

@ -114,6 +114,11 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
})
}
/// 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);
@ -285,7 +290,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
let msg = Message {
role: Role::Assistant,
text: out.text,
text: crate::i18n::reply_text(&out.text),
thinking: out.thinking,
via: job.via.clone(),
tool_calls: out.tool_calls,
@ -625,11 +630,16 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
// 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();
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())

View File

@ -1251,7 +1251,7 @@ impl App {
other => {
let Some(m) = b.messages.last_mut() else { continue };
match other {
StreamEvent::Text(t) => m.text.push_str(&t),
StreamEvent::Text(t) => m.text.push_str(&crate::i18n::reply_text(&t)),
StreamEvent::Thinking(t) => m.thinking.push_str(&t),
StreamEvent::ToolCall(c) => m.tool_calls.push(c),
StreamEvent::StepDone(full) => {

View File

@ -528,7 +528,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
if changed.is_empty() {
return err("沒有要改的項目".into());
}
ok(format!("已更新「{}」的{}(現在:{} · {})", b.name, changed.join("、"), b.provider.label(), b.model))
ok(format!("已更新「{}」的{}(現在:{} · {},預設電腦「{}」)", b.name, changed.join("、"), b.provider.label(), b.model, b.default_computer))
}
"message_bot" => {
let Some(n) = s("bot") else { return err("要指定 Bot".into()) };

View File

@ -55,8 +55,22 @@ fn current() -> &'static RwLock<String> {
L.get_or_init(|| RwLock::new("zh-TW".into()))
}
/// Replies are wanted in Traditional Chinese (the language setting says so, not "auto").
static REPLIES_HANT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// What the bot wrote, in Traditional Chinese with Taiwan wording when that is the setting.
/// Models (Qwen especially) slip into Simplified characters despite the instruction.
pub fn reply_text(s: &str) -> String {
if REPLIES_HANT.load(std::sync::atomic::Ordering::Relaxed) && !s.is_empty() {
zhconv::zhconv(s, zhconv::Variant::ZhTW)
} else {
s.to_string()
}
}
/// "auto" follows the system language.
pub fn set(tag: &str) {
REPLIES_HANT.store(tag == "zh-TW", std::sync::atomic::Ordering::Relaxed);
let t = if tag == "auto" || tag.is_empty() { system() } else { tag.to_string() };
*current().write().unwrap() = t;
}
@ -208,6 +222,8 @@ mod tests {
fn languages_and_tables() {
assert_eq!(LANGS.len(), 37);
assert_eq!(match_tag("zh-Hant-TW"), "zh-TW");
set("zh-TW");
assert_eq!(reply_text("那边的软件"), "那邊的軟體");
assert_eq!(match_tag("zh-Hans-CN"), "zh-CN");
assert_eq!(match_tag("ja-JP"), "ja");
assert_eq!(match_tag("pt-PT"), "pt-PT");

View File

@ -66,12 +66,20 @@ pub fn specs_for(here: &str, devices: &[String]) -> Vec<Value> {
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(0, here.to_string());
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 local { format!("Leave out for {here}.") } else { "This computer has no browser: always name one.".to_string() }
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()
}
);
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});