diff --git a/Cargo.toml b/Cargo.toml index 10dba24..2dc9b22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/src/agent.rs b/src/agent.rs index 6409e30..3943af0 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -114,6 +114,11 @@ fn target_for(job: &Job, args: &str) -> Result { }) } +/// 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 { 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()) diff --git a/src/app.rs b/src/app.rs index c88f5de..8764002 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) => { diff --git a/src/appctl.rs b/src/appctl.rs index 00056fe..2fcb7e5 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -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()) }; diff --git a/src/i18n.rs b/src/i18n.rs index f736652..a4e0ebf 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -55,8 +55,22 @@ fn current() -> &'static RwLock { 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"); diff --git a/src/web.rs b/src/web.rs index 59f4bff..e0e3520 100644 --- a/src/web.rs +++ b/src/web.rs @@ -66,12 +66,20 @@ pub fn specs_for(here: &str, devices: &[String]) -> Vec { let mut v = specs_inner(local || !devices.is_empty()); if !devices.is_empty() { let mut names: Vec = 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});