Computer Use

这个示例完成一次真实桌面操作:打开 Chromium,进入示例页面,找到链接并点击,确认窗口标题变化,最后截取屏幕。全程通过键盘和无障碍树完成,不依赖像素坐标。

示例页面由文件 API 和 command plane 在沙箱中创建,因此整个流程可以离线运行,结果保持一致。

本示例使用以下路由:

  • /v2/computer/actions
  • /v2/computer/actions/batch
  • /v2/computer/accessibility/nodes
  • /v2/computer/windows
  • /v2/computer/screenshot

创建 fixture 还需要 /v2/fs/write/v2/commands

要求

GET /v2/sandboxcapabilities 中应包含 computer;fixture 还需要 filesexec

computer-use worker 与 aiod 必须运行在同一台主机上,并且该主机需要有桌面和已启动的 Chromium。

Computer 镜像启动时桌面为空,请先启动浏览器:点击桌面上的 Browser 图标,或通过 exec 执行 /opt/gem/browser-launch.sh

  • Ubuntu 桌面
  • 带 Xvfb 或 Xvnc 的虚拟机
  • Windows 交互登录会话

Computer 镜像提供以上全部(见 Computer 镜像)。worker 未运行时,所有 /v2/computer/* 路由返回 503

fixture 使用 python3 启动服务,也可以替换为任意静态服务器。选择一个未被占用的端口,下面使用 18782

准备可驱动的页面

先通过文件 API 写入两个 HTML 文件,再通过 command plane 启动静态服务器。mode: "async" 启动进程后立即返回,后续可通过 command_id 对该进程进行操作:

Python
TypeScript
import time

BASE_URL = "http://127.0.0.1:18091"
PORT = 18782
DIR = "/tmp/aiod-cu/site"
sb = Aio(BASE_URL)

START_HTML = """<!doctype html>
<title>Sandbox Start</title>
<h1>Start page</h1>
<a href="/report.html">Open the report</a>
"""

REPORT_HTML = """<!doctype html>
<title>Sandbox Report</title>
<h1>Report page</h1>
"""

for name, html in [("index.html", START_HTML), ("report.html", REPORT_HTML)]:
    print(sb.post("/v2/fs/write", path=f"{DIR}/{name}", content=html))

serve = sb.post(
    "/v2/commands",
    command=f"python3 -m http.server {PORT} --bind 127.0.0.1 --directory {DIR}",
    mode="async",
)
command_id = serve["command_id"]
print(serve["status"], command_id)
# → running c5e9300f-73be-406f-9fca-cf571e12838b


def port_state() -> str:
    probe = (f"python3 -c \"import socket; print('free' if "
             f"socket.socket().connect_ex(('127.0.0.1', {PORT})) else 'in use')\"")
    return sb.post("/v2/commands", command=probe)["stdout"].strip()


while port_state() == "free":
    time.sleep(0.2)
print(port_state())
# → in use

桌面端依靠两个页面元素定位页面:链接的无障碍名称就是链接文本,每个页面都有一个可供窗口列表轮询的标题。

必须等待端口就绪。异步命令可能在进程绑定端口前就返回;如果过早导航,Chromium 会打开错误页,而不是 fixture。

清理时使用同一个探针确认端口已经释放。

导航并点击链接

下面的脚本分三步完成:导航、操作无障碍节点、观察结果。

脚本会沿用上一段的 BASE_URLsb、端口和 fixture 目录。

Python
TypeScript
def act(action: dict, screenshot: bool = False) -> dict:
    params = {"include_screenshot": "true"} if screenshot else {}
    return sb.http.post("/v2/computer/actions", params=params, json=action).json()


def batch(actions: list) -> dict:
    return sb.post("/v2/computer/actions/batch", actions=actions)


def nodes(**query) -> list:
    return sb.get("/v2/computer/accessibility/nodes", **query)["nodes"]


def windows() -> list:
    return sb.get("/v2/computer/windows")["windows"]


def wait_for_title(fragment: str, tries: int = 40) -> str:
    for _ in range(tries):
        for window in windows():
            if fragment in window["title"]:
                return window["title"]
        time.sleep(0.25)
    raise TimeoutError(f"no window titled {fragment}")


# 1. Navigate: activate Chromium, focus the address bar, type the URL, press Enter.
chromium = next(w for w in windows() if "Chromium" in w["title"])
batch([
    {"action_type": "WINDOW_ACTIVATE", "window_id": chromium["window_id"]},
    {"action_type": "WAIT", "duration": 0.5},
    {"action_type": "HOTKEY", "keys": ["ctrl", "l"]},
    {"action_type": "TYPING", "text": f"http://127.0.0.1:{PORT}/"},
    {"action_type": "PRESS", "key": "enter"},
])
print("after navigate:", wait_for_title("Sandbox Start"))
# → after navigate: Sandbox Start - Chromium

# 2. Find the link in the accessibility tree and invoke it.
link = nodes(role="link", name="Open the report")[0]
print(link["role"], link["name"], link["node_id"])
# → link Open the report a::1.30:/org/a11y/atspi/accessible/247
act({"action_type": "NODE_INVOKE", "node_id": link["node_id"]})
print("after click:", wait_for_title("Sandbox Report"))
# → after click: Sandbox Report - Chromium

# 3. Observe: one screenshot of the result.
shot = sb.http.get("/v2/computer/screenshot")
with open("report.png", "wb") as out:
    out.write(shot.content)
print(shot.status_code, shot.headers["x-image-width"], len(shot.content))
# → 200 1920 115412

两次页面变化都不依赖固定的 sleep。wait_for_title 会轮询窗口列表,直到窗口标题表明桌面已经更新;等待十秒后放弃。

这里沿用前面等待端口的逻辑。截图路由直接返回图片而不是 JSON,因此通过 sb.http 调用。

PNG 响应还包含 x-image-widthx-image-height。图片字节流会直接返回给调用方,不会写入沙箱。

实际运行中的几点说明:

  • 键盘输入会发送到当前获得焦点的窗口,因此这批动作先激活 Chromium 窗口。WINDOW_ACTIVATE 只向窗口管理器发送请求,不等待结果;这也是脚本里唯一的 WAIT

如果不先激活窗口,按键不会发送到目标窗口,标题也不会变化。

  • nodes(role=..., name=...) 默认按子串匹配;要精确匹配传 match="exact",要用模式传 match="regex"。 链接的无障碍名称就是它自己的文字——节点属性里写着 name-from: contents
  • Chromium 只有以 --force-renderer-accessibility 启动时才暴露页面内容。 否则桌面树里只有面板和桌面,连浏览器都没有,按名字找链接会返回空。
  • Chromium 的地址栏在 AT-SPI 中没有可编辑文本,对它执行 NODE_SET_VALUE 返回 409 node ... has neither editable text nor a value to set。 输入 URL 的可靠方式是键盘路径(HOTKEY + TYPING)。
  • node_id 在元素存在期间有效;它所属的页面消失后,nodes(node_id=...) 返回 404 node ... is gone
  • windows 返回 {snapshot_id, windows: [{window_id, title, process_id, bounds, minimized}]}。标题是最简单的轮询对象,提交导航后就会变化;前面的两次等待都在第一轮获得了结果。

使用 PyAutoGUI

也可以直接在同一个桌面上运行 PyAutoGUI。它使用屏幕坐标,不依赖无障碍树;窗口位置变化时,需要重新确认坐标。脚本通过 /v2/commands 运行,桌面已经设置好 DISPLAY

sb.post("/v2/commands", command="pip install --quiet pyautogui", timeout=280)
sb.post("/v2/commands", command=f'''python3 - <<'EOF'
import pyautogui

pyautogui.hotkey("ctrl", "l")
pyautogui.write("http://127.0.0.1:{PORT}/report.html")
pyautogui.press("enter")
pyautogui.screenshot("/tmp/report.png")
EOF''')

需要按元素定位时,使用前面的无障碍树流程;只需要直接操作桌面时,可以使用 PyAutoGUI。

清理资源

fixture 服务器不会随着脚本结束自动停止,需要显式清理。

使用 commands/{id}/kill 停止服务器,使用 fs/delete 删除目录,再通过第一段中的端口探针确认端口已经释放:

Python
TypeScript
sb.post(f"/v2/commands/{command_id}/kill")
removed = sb.post("/v2/fs/delete", path=DIR, recursive=True)
print(removed["path"], "|", port_state())
# → /tmp/aiod-cu/site | free

浏览器仍停在最后加载的页面上,桌面上没有需要复位的东西。

检查 worker 状态

info 返回显示器、分辨率和支持的操作:

sb.http.get("/v2/computer/info").json()
{
  "success": true,
  "data": {
    "available": true,
    "display": ":99.0",
    "xauthority": null,
    "screen_resolution": {
      "width": 1920,
      "height": 1080
    },
    "capabilities": {
      "screenshot": true,
      "actions": true,
      "clipboard": true,
      "recording": true
    },
    "warnings": []
  }
}

像素操作前,把模型输出的坐标缩放到 screen_resolution

在一次调用中完成操作和观察

include_screenshot=true 会在顶层 screenshot 字段返回操作后的画面(base64 PNG)。前面 act 调用中的 screenshot 参数就是用来开启它的:

act({"action_type": "CLICK", "x": 640, "y": 400}, screenshot=True)

Windows 上,针对安全桌面(UAC 提示、锁屏)的输入被拒绝,返回 403

批量操作

步骤之间不需要决策时,一次请求发送整段序列。在该路由上,include_screenshot 是与 actions 并列的 body 字段,而不是查询参数,因此直接由 sb.post 传入:

sb.post("/v2/computer/actions/batch", include_screenshot=True, actions=[
    {"action_type": "HOTKEY", "keys": ["ctrl", "l"]},
    {"action_type": "TYPING", "text": f"http://127.0.0.1:{PORT}/"},
    {"action_type": "PRESS", "key": "enter"},
])

每批最多 50 个动作;单个 WAIT 最多 10 秒,所有 WAIT 合计最多 20 秒。

某个动作失败时,批次会停止;data.failed_indexdata.error 会说明失败位置和原因。

Agent 循环

每一步一次截图、一次操作:

Python
TypeScript
import base64


def run(model) -> None:
    frame = sb.http.get("/v2/computer/screenshot").content   # initial observation
    while not model.done:
        # e.g. {"action_type": "CLICK", "x": 640, "y": 400}
        action = model.decide(frame)
        frame = base64.b64decode(act(action, screenshot=True)["screenshot"])

每步只看一帧的模型,应在桌面稳定后再截图。如果循环可以检查标题或节点,请按前面的流程轮询。

录屏

通过同一个路由开始和停止:

sb.post(
    "/v2/computer/record", action="start", save_path="/workspace/recordings/session.mp4"
)
sb.post("/v2/computer/record", action="stop")

文件 API 取回文件。

Computer 镜像

预构建的 Computer 镜像提供:

  • DISPLAY=:99 上的 XFCE 桌面
  • aiod 并行运行的 computer-use worker
  • 供 AT-SPI 使用的会话 D-Bus
  • --force-renderer-accessibility 启动的 Chromium
  • /vnc 上的 noVNC

相关页面

  • Computer Use —— 完整参考、动作列表、默认值,以及 501/503 的区别
  • Browser Use —— 同样的 fixture 写法,用 REST 而不是桌面驱动页面
  • 浏览器(CDP) —— 通过 CDP 而不是桌面驱动同一个浏览器