Cloud brain uses a model on the person's network through their computer; the chosen computer is where a bot works
- A brain that can't reach a model at a private address (e.g. Qwen at 10.x) sends the request through its hands; the hands only forward to addresses on their own network, and only for their brain (uses the existing device channel, so the account server is unchanged). - The computer chosen for a bot is binding: a step naming another computer goes to the chosen one unless the person's message names that one (「在我的 Mac 上」). - Browser steps follow the computer: Bot 的電腦 drives the Chromium on its own desktop (through docker exec, no new port), 你的電腦 its browser; before, they always used this computer's. Checked: on this Mac, Bot 的電腦 browsing opened example.com in the box's Chromium and an unasked 你的電腦 command ran in the box; 「在我的 Mac 上」 ran on the Mac. Linux brain in Docker with 10.0.0.0/8 blocked + this Mac as hands: model calls went through the Mac, shell on 你的電腦 answered from the Mac, browser defaulted to the Mac's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4c0db53647
commit
c1e4994219
71
src/agent.rs
71
src/agent.rs
|
|
@ -114,6 +114,65 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
|
|||
})
|
||||
}
|
||||
|
||||
enum BrowserAt {
|
||||
Here,
|
||||
Box,
|
||||
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 {
|
||||
if name == "web_fetch" {
|
||||
return BrowserAt::Here;
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Words that mean a computer in the person's message.
|
||||
fn mentions(text: &str, t: &tools::Target) -> bool {
|
||||
let low = text.to_lowercase();
|
||||
if low.contains(&t.name.to_lowercase()) {
|
||||
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() && low.contains(&crate::remote::peer_name(host).to_lowercase()),
|
||||
_ => &[],
|
||||
};
|
||||
words.iter().any(|w| low.contains(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 {
|
||||
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() };
|
||||
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
|
||||
return args.to_string();
|
||||
}
|
||||
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(),
|
||||
_ => {
|
||||
v["computer"] = serde_json::Value::String(job.default_computer.clone());
|
||||
v.to_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)
|
||||
|
|
@ -288,6 +347,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
if cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut out = out;
|
||||
for c in out.tool_calls.iter_mut() {
|
||||
c.args = keep_to_chosen(&job, &history, &c.args);
|
||||
}
|
||||
let msg = Message {
|
||||
role: Role::Assistant,
|
||||
text: crate::i18n::reply_text(&out.text),
|
||||
|
|
@ -309,6 +372,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
}
|
||||
continue;
|
||||
}
|
||||
// (Each call's computer was already kept to the chosen one, see keep_to_chosen.)
|
||||
for call in calls {
|
||||
if appctl::is_app_tool(&call.name) {
|
||||
// App actions run on the window's side, which also asks for deletions.
|
||||
|
|
@ -347,12 +411,13 @@ 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 device_named(&job, &call.args).filter(|_| call.name != "web_fetch") {
|
||||
Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
|
||||
let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
|
||||
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),
|
||||
},
|
||||
None => web::run_full(&call.name, &call.args, &cancel),
|
||||
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
|
||||
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
|
||||
};
|
||||
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
|
||||
let mut result = Message::tool_result(&call.id, &call.name, &output);
|
||||
|
|
|
|||
|
|
@ -3722,7 +3722,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
|
|||
),
|
||||
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」「我的電腦」「這台」都是指它。",
|
||||
"- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
|
||||
t.name,
|
||||
crate::remote::peer_name(host),
|
||||
t.name
|
||||
|
|
@ -3732,7 +3732,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
|
|||
.collect();
|
||||
s.push_str(&format!(
|
||||
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
|
||||
- 使用者說在哪台電腦上做(「在我的 Mac 上」「在工作站上」),就把 computer 設成那台;沒說就用預設。\n\
|
||||
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
|
||||
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
|
||||
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
|
||||
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ pub fn engine() -> Option<(&'static str, &'static str)> {
|
|||
}
|
||||
|
||||
pub fn available() -> bool {
|
||||
if using_box() {
|
||||
return true;
|
||||
}
|
||||
engine().is_some()
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +135,9 @@ pub fn match_profile(list: &[String], want: &str) -> Option<String> {
|
|||
|
||||
/// Which profile a tool call means: the one named, or the current one.
|
||||
pub fn resolve(requested: Option<&str>) -> Result<String, String> {
|
||||
if using_box() {
|
||||
return Ok(DEFAULT_PROFILE.to_string());
|
||||
}
|
||||
match requested.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(current()),
|
||||
Some(r) => {
|
||||
|
|
@ -231,8 +237,35 @@ pub fn encode_component(s: &str) -> String {
|
|||
.collect()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Browser steps on this thread go to Bot 的電腦's Chromium (its box), not this computer's.
|
||||
static IN_BOX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Run `f` with the browser steps on Bot 的電腦's own Chromium.
|
||||
pub fn in_box<T>(f: impl FnOnce() -> T) -> T {
|
||||
IN_BOX.with(|b| b.set(true));
|
||||
let r = f();
|
||||
IN_BOX.with(|b| b.set(false));
|
||||
r
|
||||
}
|
||||
|
||||
pub fn using_box() -> bool {
|
||||
IN_BOX.with(|b| b.get())
|
||||
}
|
||||
|
||||
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
|
||||
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
|
||||
if using_box() {
|
||||
let fresh = !crate::cloudbox::browser_running();
|
||||
let port = crate::cloudbox::browser_port()?;
|
||||
if fresh {
|
||||
if let Some(u) = first_url {
|
||||
let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty();
|
||||
}
|
||||
}
|
||||
return Ok(("Bot 的電腦的 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 {
|
||||
return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into());
|
||||
|
|
|
|||
|
|
@ -541,6 +541,73 @@ fn wait_ready(ms: u64) -> Result<(), String> {
|
|||
Err("電腦開機逾時".into())
|
||||
}
|
||||
|
||||
// ---------- the box's own browser ----------
|
||||
// Browser steps on Bot 的電腦 drive the Chromium on its desktop (the person can watch it in the
|
||||
// box's window). Its DevTools port stays inside the box; a local port reaches it through
|
||||
// `docker exec` (no published port, so it also works with a Docker host elsewhere).
|
||||
|
||||
const BOX_CDP_PORT: u16 = 9222;
|
||||
|
||||
fn box_cdp_alive(local: u16) -> bool {
|
||||
crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A local port that reaches the box's DevTools port.
|
||||
fn box_cdp_forward() -> Result<u16, String> {
|
||||
static PORT: Mutex<u16> = Mutex::new(0);
|
||||
let mut p = PORT.lock().unwrap();
|
||||
if *p != 0 {
|
||||
return Ok(*p);
|
||||
}
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||||
let port = l.local_addr().map_err(|e| e.to_string())?.port();
|
||||
std::thread::spawn(move || {
|
||||
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 (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 || {
|
||||
let _ = std::io::copy(&mut r, &mut cin);
|
||||
});
|
||||
let _ = std::io::copy(&mut cout, &mut w);
|
||||
let _ = conn.shutdown(std::net::Shutdown::Both);
|
||||
let _ = child.kill();
|
||||
let _ = up.join();
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
});
|
||||
*p = port;
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Is the box's Chromium answering already?
|
||||
pub fn browser_running() -> bool {
|
||||
box_cdp_forward().map(box_cdp_alive).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The box's Chromium, started on its desktop when it isn't running. Gives the local port.
|
||||
pub fn browser_port() -> Result<u16, String> {
|
||||
ensure_running()?;
|
||||
touch();
|
||||
let port = box_cdp_forward()?;
|
||||
if box_cdp_alive(port) {
|
||||
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}"))?;
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(20) {
|
||||
if box_cdp_alive(port) {
|
||||
return Ok(port);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
|
||||
}
|
||||
|
||||
static START_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready.
|
||||
|
|
|
|||
|
|
@ -414,8 +414,12 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
|
|||
}
|
||||
}
|
||||
|
||||
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
|
||||
let reader = BufReader::new(body.into_reader());
|
||||
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
|
||||
read_sse_from(body.into_reader(), cancel, send, on_data)
|
||||
}
|
||||
|
||||
fn read_sse_from(r: impl std::io::Read, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
|
||||
let reader = BufReader::new(r);
|
||||
let mut event = String::new();
|
||||
for line in reader.lines() {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
|
|
@ -750,29 +754,55 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
|
|||
body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::<Vec<_>>());
|
||||
}
|
||||
let url = format!("{}/chat/completions", req.base_url);
|
||||
let mut r = agent().post(&url).header("Content-Type", "application/json");
|
||||
let mut headers: Vec<(String, String)> = vec![];
|
||||
if !req.api_key.is_empty() {
|
||||
r = r.header("Authorization", &format!("Bearer {}", req.api_key));
|
||||
headers.push(("Authorization".into(), format!("Bearer {}", req.api_key)));
|
||||
}
|
||||
if req.kind == ProviderKind::OpenRouter {
|
||||
r = r.header("X-Title", "AnyBot");
|
||||
headers.push(("X-Title".into(), "AnyBot".into()));
|
||||
}
|
||||
if req.kind == ProviderKind::OpenCodeGo {
|
||||
r = r.header("x-opencode-session", &opencode_session(req));
|
||||
}
|
||||
// SAND-E0418 turnBackendUnreachable: retryable.
|
||||
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status >= 400 {
|
||||
let ra = retry_after_ms(&resp);
|
||||
let text = resp.into_body().read_to_string().unwrap_or_default();
|
||||
return Err(http_error(status, &text, ra));
|
||||
headers.push(("x-opencode-session".into(), opencode_session(req)));
|
||||
}
|
||||
// A brain on a server, a model on the person's own network: through their computer (the
|
||||
// whole answer comes back at once, so no word-by-word streaming).
|
||||
let via_hands: Option<Vec<u8>> = match crate::remote::model_hands_for(&url) {
|
||||
Some(hand) => {
|
||||
let (status, bytes) = crate::remote::model_via_hands(&hand, &url, &headers, &body.to_string()).map_err(Failure::retry)?;
|
||||
if status >= 400 {
|
||||
return Err(http_error(status, &String::from_utf8_lossy(&bytes), None));
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let resp = match via_hands {
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let mut r = agent().post(&url).header("Content-Type", "application/json");
|
||||
for (k, v) in &headers {
|
||||
r = r.header(k, v);
|
||||
}
|
||||
// SAND-E0418 turnBackendUnreachable: retryable.
|
||||
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status >= 400 {
|
||||
let ra = retry_after_ms(&resp);
|
||||
let text = resp.into_body().read_to_string().unwrap_or_default();
|
||||
return Err(http_error(status, &text, ra));
|
||||
}
|
||||
Some(resp)
|
||||
}
|
||||
};
|
||||
let mut out = StepOutput::default();
|
||||
// Tool calls arrive in pieces keyed by index.
|
||||
let mut pending: Vec<(String, String, String)> = vec![];
|
||||
let mut error = None;
|
||||
read_sse(resp.into_body(), cancel, send, |_ev, data| {
|
||||
let reader: Box<dyn std::io::Read> = match (resp, via_hands) {
|
||||
(Some(r), _) => Box::new(r.into_body().into_reader()),
|
||||
(None, b) => Box::new(std::io::Cursor::new(b.unwrap_or_default())),
|
||||
};
|
||||
read_sse_from(reader, cancel, send, |_ev, data| {
|
||||
if data == "[DONE]" {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
103
src/remote.rs
103
src/remote.rs
|
|
@ -619,6 +619,10 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
|
|||
let tool = v["tool"].as_str().unwrap_or("");
|
||||
let args = v["args"].as_str().unwrap_or("{}");
|
||||
let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
|
||||
// The brain's model request to a model only this computer's network reaches.
|
||||
if tool == MODEL_FETCH {
|
||||
return model_fetch_here(args);
|
||||
}
|
||||
if crate::computer::is_computer_tool(tool) {
|
||||
let (text, ok, img) = crate::computer::run_by(tool, args, who);
|
||||
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
|
||||
|
|
@ -645,6 +649,96 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
|
|||
Resp::error(400, "這個工具不能遠端使用")
|
||||
}
|
||||
|
||||
// ---------- a model on the person's network, for a brain on a server ----------
|
||||
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
|
||||
// home). Its hands can: the brain hands them the request and gets the whole answer back.
|
||||
|
||||
const MODEL_FETCH: &str = "model_fetch";
|
||||
|
||||
/// Private, link-local or loopback address (by name: localhost and *.local).
|
||||
fn private_host(host: &str) -> bool {
|
||||
let h = host.trim_matches(['[', ']']);
|
||||
if h == "localhost" || h.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
match h.parse::<std::net::IpAddr>() {
|
||||
Ok(std::net::IpAddr::V4(a)) => a.is_private() || a.is_loopback() || a.is_link_local(),
|
||||
Ok(std::net::IpAddr::V6(a)) => a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_port(url: &str) -> Option<(String, u16)> {
|
||||
let (scheme, rest) = url.split_once("://")?;
|
||||
let auth = rest.split('/').next()?;
|
||||
let auth = auth.rsplit('@').next()?;
|
||||
let (h, p) = match auth.rsplit_once(':') {
|
||||
Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?),
|
||||
_ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }),
|
||||
};
|
||||
Some((h, p))
|
||||
}
|
||||
|
||||
/// The hands to send a model request through: the model is at a private address this brain
|
||||
/// 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()?;
|
||||
let (h, p) = host_port(url)?;
|
||||
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
|
||||
return None;
|
||||
}
|
||||
static SEEN: Mutex<Option<HashMap<String, (bool, std::time::Instant)>>> = Mutex::new(None);
|
||||
let key = format!("{h}:{p}");
|
||||
if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) {
|
||||
if at.elapsed().as_secs() < 300 {
|
||||
return if *reach { None } else { Some(hand) };
|
||||
}
|
||||
}
|
||||
use std::net::ToSocketAddrs;
|
||||
let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false);
|
||||
SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now()));
|
||||
if reach { None } else { Some(hand) }
|
||||
}
|
||||
|
||||
/// Send a model request (POST, JSON) through the hands; gives back its status and whole body.
|
||||
pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec<u8>), String> {
|
||||
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 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)?;
|
||||
if let Some(e) = v["error"].as_str() {
|
||||
return Err(format!("{whom}也連不到模型:{e}"));
|
||||
}
|
||||
Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec()))
|
||||
}
|
||||
|
||||
/// On the hands: make the brain's model request, only to an address on this computer's network.
|
||||
fn model_fetch_here(args: &str) -> Resp {
|
||||
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
|
||||
let url = v["url"].as_str().unwrap_or("");
|
||||
if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) {
|
||||
return Resp::error(403, "只幫大腦連這台電腦網路裡的模型");
|
||||
}
|
||||
let mut r = agent(600).post(url).header("Content-Type", "application/json");
|
||||
for h in v["headers"].as_array().into_iter().flatten() {
|
||||
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
|
||||
r = r.header(k, val);
|
||||
}
|
||||
}
|
||||
match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default();
|
||||
Resp::json(&json!({"status": status, "text": text}))
|
||||
}
|
||||
Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the local server ----------
|
||||
|
||||
static LAN_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
|
@ -1273,6 +1367,15 @@ pub fn page() -> Resp {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_requests_go_only_to_private_addresses() {
|
||||
assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local"));
|
||||
assert!(!private_host("api.x.ai") && !private_host("8.8.8.8"));
|
||||
assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000)));
|
||||
assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443)));
|
||||
assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phone_login_and_api() {
|
||||
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
|
||||
|
|
|
|||
Loading…
Reference in New Issue