# Agent 集群服务对上 Open API 文档

本文档面向控制端、数字员工后台或其它上层业务系统开发者。

Agent 集群服务对上只识别 **应用身份** 和 **接口权限**，不识别最终用户、数字员工、任务权限。上层业务可以通过 `metadata` 透传用户、数字员工、任务等信息，Agent 集群服务只做记录和追踪。

## 当前实现状态

当前代码已实现以下 Open API：

```text
GET  /open/v1/devices
GET  /open/v1/devices/{deviceId}
GET  /open/v1/pairing-codes
GET  /open/v1/pairing-codes/{pairingId}
POST /open/v1/pairing-codes
DELETE /open/v1/pairing-codes/{pairingId}
POST /open/v1/devices/{deviceId}/commands
GET  /open/v1/commands/{commandId}
POST /open/v1/devices/{deviceId}/scripts
GET  /open/v1/devices/{deviceId}/scripts/{scriptTaskId}
POST /open/v1/devices/{deviceId}/scripts/{scriptTaskId}/refresh
POST /open/v1/events/tickets
WS   /open/v1/events/ws?ticket={oneTimeTicket}
POST /open/v1/devices/{deviceId}/screenshot
POST /open/v1/devices/{deviceId}/streams
GET  /open/v1/streams/{streamId}
DELETE /open/v1/streams/{streamId}
POST /open/v1/streams/{streamId}/play-token
GET  /open/v1/devices/{deviceId}/files
POST /open/v1/devices/{deviceId}/files/upload
POST /open/v1/devices/{deviceId}/files/mkdir
POST /open/v1/devices/{deviceId}/files/download-token
GET  /open/v1/files/download
POST /open/v1/devices/{deviceId}/files/delete
POST /open/v1/devices/{deviceId}/files/rename
POST /open/v1/devices/{deviceId}/files/move
GET  /open/v1/file-tasks/{fileTaskId}
```

其中 `POST /open/v1/devices/{deviceId}/commands` 已支持实时下发到 Agent，并可通过 `GET /open/v1/commands/{commandId}` 查询命令状态。

文件管理 REST 接口已经提供第一版生产形态：对上是 REST 风格，对下复用当前 Agent 文件指令协议。后续如果升级为分片、对象存储或独立文件通道，上层调用路径保持不变。

当前鉴权已实现：

```text
X-App-Id: <app_id>
Authorization: Bearer <app_token>
```

当前文件 REST 接口已实现基础 scope 检查：

```text
file.read
file.write
```

其它控制、推流、截图接口的细粒度 scope 后续继续补齐。

`GET /open/v1/agents` 和 `GET /open/v1/agents/{agentId}` 当前尚未在后端注册，本文档只保留规划契约，不计入上述已实现接口，也不能作为生产调用地址。

## 1. 基础约定

### 1.1 Base URL

```text
https://agent-cluster.example.com/open/v1
```

### 1.2 鉴权

```http
Authorization: Bearer <app_access_token>
X-App-Id: app_xxx
X-Request-Id: req_xxx
```

`app_access_token` 由 Agent 集群管理后台创建或通过应用密钥换取。

### 1.3 Token 内容

```json
{
  "appId": "app_digital_employee",
  "scopes": [
    "agent.read",
    "pairing.read",
    "pairing.write",
    "pairing.delete",
    "device.read",
    "device.control",
    "device.screenshot",
    "stream.start",
    "stream.stop",
    "stream.play_token",
    "file.read",
    "file.write"
  ],
  "expiresAt": 1780000000000
}
```

### 1.4 metadata 透传

所有控制、推流、截图接口都允许带 `metadata`。

```json
{
  "metadata": {
    "externalUserId": "user_001",
    "externalEmployeeId": "employee_001",
    "externalTaskId": "task_001"
  }
}
```

Agent 集群服务只记录 metadata，不做业务权限判断。

### 1.5 统一错误格式

```json
{
  "errorCode": "DEVICE_OFFLINE",
  "message": "device is offline",
  "requestId": "req_xxx"
}
```

常见错误码：

```text
UNAUTHORIZED
FORBIDDEN_SCOPE
APP_DISABLED
DEVICE_NOT_FOUND
DEVICE_OFFLINE
AGENT_OFFLINE
CAPABILITY_NOT_SUPPORTED
STREAM_NOT_FOUND
TOKEN_EXPIRED
RATE_LIMITED
COMMAND_TIMEOUT
INTERNAL_ERROR
```

### 1.6 JavaScript 示例的通用请求函数

本文档中所有 JavaScript 调用示例都复用下面的 `openApi`。示例可以直接用于浏览器、Node.js 18+ 或其它支持标准 `fetch` 的运行时；生产环境不要把 `appToken` 写死在浏览器代码中，应由可信服务端保管并代理调用。

```js
const OPEN_API_BASE = 'https://agent-cluster.example.com/open/v1';
const APP_ID = 'app_xxx';
const APP_TOKEN = 'act_xxx';

async function openApi(path, { method = 'GET', body, headers = {} } = {}) {
  const response = await fetch(`${OPEN_API_BASE}${path}`, {
    method,
    headers: {
      'X-App-Id': APP_ID,
      Authorization: `Bearer ${APP_TOKEN}`,
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
      ...headers
    },
    body: body === undefined ? undefined : JSON.stringify(body)
  });

  const contentType = response.headers.get('content-type') || '';
  const result = contentType.includes('application/json')
    ? await response.json()
    : await response.arrayBuffer();

  if (!response.ok) {
    const error = new Error(result?.message || `HTTP ${response.status}`);
    error.status = response.status;
    error.detail = result;
    throw error;
  }
  return result;
}
```

路径参数和查询参数必须使用 `encodeURIComponent` / `URLSearchParams` 编码。文末“全部已实现接口 JavaScript 示例”逐一覆盖当前注册的 REST 与 WebSocket 接口。

## 2. Agent 查询（规划接口，当前未注册）

本节是预留契约。当前后端没有注册 `/open/v1/agents` 路由，调用会返回 404；已实现的设备查询从第 3 节开始。

### 2.1 获取 Agent 列表

```http
GET /open/v1/agents
```

查询参数：

```text
agentType 可选
status 可选，online/offline/disabled
page 可选
pageSize 可选
```

响应：

```json
{
  "items": [
    {
      "agentId": "agent_001",
      "agentType": "mobile_app",
      "status": "online",
      "version": "1.0.0",
      "nodeId": "node_01",
      "lastSeenAt": "2026-06-29T10:00:00Z",
      "deviceCount": 1,
      "capabilities": ["device", "stream", "control", "file"]
    }
  ],
  "page": 1,
  "pageSize": 20,
  "total": 1
}
```

所需 scope：

```text
agent.read
```

### 2.2 获取 Agent 详情

```http
GET /open/v1/agents/{agentId}
```

响应：

```json
{
  "agentId": "agent_001",
  "agentType": "adb_windows",
  "status": "online",
  "version": "1.0.0",
  "nodeId": "node_01",
  "lastSeenAt": "2026-06-29T10:00:00Z",
  "devices": ["dev_001", "dev_002"]
}
```

## 2.5 连接码 OpenAPI

连接码可以由 Agent 集群后台创建，也可以由上层应用通过 OpenAPI 创建。

归属规则：

- 后台创建的连接码 `appId = null`，只由 Agent 集群后台管理。
- OpenAPI 创建的连接码 `appId = 当前 X-App-Id`。
- OpenAPI 查询、删除连接码时，只能看到和操作当前 `X-App-Id` 关联的连接码。
- Agent 使用连接码绑定成功后，Agent 和设备会继承该连接码的 `appId`。
- OpenAPI 后续查询设备、下发控制、启动推流、文件管理、查询命令和查询 stream 时，都必须命中相同 `appId`。

### 2.5.1 创建连接码

```http
POST /open/v1/pairing-codes
```

所需 scope：

```text
pairing.write
```

请求：

```json
{
  "allowedAgentTypes": ["mobile_app_agent"],
  "expiresInMinutes": 10,
  "deviceName": "运营手机 01"
}
```

响应：

```json
{
  "pairingId": "pair_xxx",
  "appId": "app_xxx",
  "displayCode": "123456",
  "allowedAgentTypes": ["mobile_app_agent"],
  "deviceName": "运营手机 01",
  "status": "active",
  "expiresAt": "2026-07-09T12:00:00.000Z",
  "createdAt": "2026-07-09T11:50:00.000Z",
  "qrData": {
    "type": "digit_body_agent_pairing",
    "version": 1,
    "serverUrl": "https://agent-cluster.example.com",
    "linkCode": "123456",
    "allowedAgentTypes": ["mobile_app_agent"],
    "expiresAt": "2026-07-09T12:00:00.000Z"
  }
}
```

`displayCode` 是给 Agent App 手动填写或二维码携带的连接码。数据库只保存 hash，接口返回明文仅用于创建后的展示。`qrData` 是推荐写入二维码的 JSON 对象，上层可以直接对该对象做 JSON 序列化后生成二维码。

### 2.5.2 查询连接码列表

```http
GET /open/v1/pairing-codes?page=1&pageSize=20&status=active&code=123
```

所需 scope：

```text
pairing.read
```

查询参数：

```text
page      可选，默认 1。
pageSize  可选，默认 20，最大 200。
status    可选，active / used / expired。
code      可选，按展示连接码模糊搜索。
```

响应：

```json
{
  "items": [
    {
      "pairingId": "pair_xxx",
      "appId": "app_xxx",
      "displayCode": "123456",
      "allowedAgentTypes": ["mobile_app_agent"],
      "deviceName": "运营手机 01",
      "status": "used",
      "usedByAgentId": "agent_xxx",
      "usedAt": "2026-07-09T11:55:00.000Z",
      "expiresAt": "2026-07-09T12:00:00.000Z",
      "createdAt": "2026-07-09T11:50:00.000Z",
      "qrData": {
        "type": "digit_body_agent_pairing",
        "version": 1,
        "serverUrl": "https://agent-cluster.example.com",
        "linkCode": "123456",
        "allowedAgentTypes": ["mobile_app_agent"],
        "expiresAt": "2026-07-09T12:00:00.000Z"
      }
    }
  ],
  "page": 1,
  "pageSize": 20,
  "total": 1
}
```

### 2.5.3 查询连接码详情

```http
GET /open/v1/pairing-codes/{pairingId}
```

所需 scope：

```text
pairing.read
```

只能查询当前 `X-App-Id` 名下的连接码。成功时返回与列表中单个 `items` 元素相同的结构：

```json
{
  "pairingId": "pair_xxx",
  "appId": "app_xxx",
  "displayCode": "123456",
  "allowedAgentTypes": ["mobile_app_agent"],
  "deviceName": "运营手机 01",
  "status": "used",
  "usedByAgentId": "agent_xxx",
  "usedAt": "2026-07-09T11:55:00.000Z",
  "expiresAt": "2026-07-09T12:00:00.000Z",
  "createdAt": "2026-07-09T11:50:00.000Z",
  "qrData": {
    "type": "digit_body_agent_pairing",
    "version": 1,
    "serverUrl": "https://agent-cluster.example.com",
    "linkCode": "123456",
    "allowedAgentTypes": ["mobile_app_agent"],
    "expiresAt": "2026-07-09T12:00:00.000Z"
  }
}
```

连接码不存在或不属于当前应用时返回：

```json
{
  "errorCode": "PAIRING_NOT_FOUND",
  "message": "pairing code not found"
}
```

### 2.5.4 删除连接码

```http
DELETE /open/v1/pairing-codes/{pairingId}
```

所需 scope：

```text
pairing.delete
```

删除规则：

- 只能删除当前 `X-App-Id` 创建的连接码。
- 如果连接码尚未绑定，只删除连接码。
- 如果连接码已绑定，会删除当前 appId 名下该 Agent 关联的设备、命令、stream、viewer。
- 如果该 Agent 下已经没有其它设备，会断开 Agent 控制通道并删除 Agent。
- 如果未来同一 Agent 承载多个 appId 的设备，不会跨 appId 删除其它应用资源。

响应：

```json
{
  "success": true
}
```

### 2.5.4 同一 Agent 多设备边界

当前实现允许同一 `agentId` 关联多个 `deviceId`，适合后续 ADB 网关、浏览器网关、桌面 Agent 管理多个被控对象的场景。

当前实现不建议让同一 `agentId` 同时服务多个 `appId`。如果未来确实需要一个 Agent 同时承载多个应用的设备，需要增加独立的 Agent-Device-App 绑定表，避免出现以下冲突：

- 删除某个应用连接码时，不能误断开其它应用仍在使用的 Agent。
- Agent 心跳是 Agent 级别，多个 appId 共用同一 Agent 时，在线状态会一起变化。
- Agent Secret 是 Agent 级别，跨 appId 共用会扩大凭证泄露影响面。
- 设备上报如果只按 `agentId` 找最近一次连接码，会导致设备归属被最新连接码覆盖。

因此第一版生产规则是：一个连接码绑定出来的 Agent 和设备继承同一个 `appId`；OpenAPI 只允许操作同 appId 资源。

如果同一台物理设备曾经通过多个 appId 或多个连接码绑定，某个 appId 删除连接码并导致设备记录被删除，另一个 appId 后续继续用旧 `deviceId` 调用接口时，服务端必须稳定返回：

```json
{
  "errorCode": "DEVICE_NOT_FOUND",
  "message": "设备已删除或不属于当前应用"
}
```

上层业务收到该错误后，应把本地设备关系标记为失效，提示重新绑定，或让用户手动删除自己应用下的连接码。该情况不应导致 Agent 集群程序异常。

## 3. Device 查询

### 3.1 获取设备列表

```http
GET /open/v1/devices
```

查询参数：

```text
page 可选，默认 1，从 1 开始。
pageSize 可选，默认 20，最大 200。
deviceName 可选，按设备名称模糊搜索。
status 可选，按设备状态精确过滤，例如 ready/offline/disabled/error。
```

当前生产实现已支持 `page`、`pageSize`、`deviceName`、`status`。`agentId`、`deviceType`、`connectionMode`、`capability` 可以作为后续扩展参数，未实现前不要在上层业务中依赖。

响应：

```json
{
  "items": [
    {
      "deviceId": "dev_001",
      "agentId": "agent_001",
      "deviceType": "mobile",
      "connectionMode": "mobile_app_accessibility",
      "status": "ready",
      "streamStatus": "idle",
      "screenWidth": 720,
      "screenHeight": 1457,
      "imeOverlayImage": "https://example.com/ime/redmi-keyboard.png",
      "capabilities": {
        "schemaVersion": 1,
        "display": {
          "hasScreen": true,
          "screenWidth": 720,
          "screenHeight": 1457
        },
        "observe": {
          "screenshot": true,
          "stream": true,
          "stateRead": true
        },
        "control": {
          "tap": true,
          "swipe": true,
          "touchGesture": true,
          "text": true,
          "key": true,
          "intent": true,
          "home": true,
          "back": true,
          "returnAgent": true,
          "scriptRun": true,
          "scriptResult": true,
          "scriptLanguage": "mobile-agent-js-v1",
          "switch": false
        },
        "uiHints": {
          "primaryPanel": "screen",
          "preferredControls": ["screen_view", "tap", "swipe", "touch_gesture", "text_input", "home", "back", "return_agent", "script_run"]
        }
      }
    }
  ],
  "page": 1,
  "pageSize": 20,
  "total": 1
}
```

所需 scope：

```text
device.read
```

### 3.2 获取设备详情

```http
GET /open/v1/devices/{deviceId}
```

响应：

```json
{
  "deviceId": "dev_001",
  "agentId": "agent_001",
  "deviceType": "mobile",
  "connectionMode": "mobile_app_accessibility",
  "status": "ready",
  "screenWidth": 720,
  "screenHeight": 1457,
  "imeOverlayImage": "https://example.com/ime/redmi-keyboard.png",
  "runtime": {
    "battery": 82,
    "screenOn": true,
    "network": "wifi"
  },
  "capabilities": {
    "schemaVersion": 1,
    "display": {
      "hasScreen": true,
      "screenWidth": 720,
      "screenHeight": 1457,
      "density": 420
    },
    "observe": {
      "screenshot": true,
      "stream": true,
      "streamInputs": ["h264_webrtc", "jpeg_frame_ws"],
      "stateRead": true
    },
    "control": {
      "tap": true,
      "swipe": true,
      "touchGesture": true,
      "text": true,
      "key": true,
      "intent": true,
      "home": true,
      "back": true,
      "returnAgent": true,
      "scriptRun": true,
      "scriptResult": true,
      "scriptLanguage": "mobile-agent-js-v1"
    },
    "file": {
      "list": false,
      "upload": false,
      "download": false
    },
    "uiHints": {
      "primaryPanel": "screen",
      "preferredControls": ["screen_view", "tap", "swipe", "touch_gesture", "text_input", "home", "back", "return_agent", "script_run"]
    }
  }
}
```

无屏开关设备示例：

```json
{
  "deviceId": "dev_switch_001",
  "agentId": "agent_iot_001",
  "deviceType": "switch",
  "connectionMode": "iot_gateway",
  "status": "ready",
  "runtime": {
    "power": "off"
  },
  "capabilities": {
    "schemaVersion": 1,
    "display": {
      "hasScreen": false
    },
    "observe": {
      "stateRead": true,
      "screenshot": false,
      "stream": false
    },
    "control": {
      "switch": true,
      "tap": false,
      "swipe": false,
      "text": false
    },
    "hardware": {
      "powerSwitch": true
    },
    "uiHints": {
      "primaryPanel": "state",
      "preferredControls": ["power_switch", "state_badge"]
    }
  }
}
```

上层业务系统必须根据 `capabilities` 渲染操作界面。比如 `display.hasScreen=false` 时不应展示屏幕画面区域，`observe.stream=false` 时不应展示实时观看按钮，`control.switch=true` 时才展示开关控件。

`imeOverlayImage` 是设备的输入法区域辅助图，可以是公网 URL、内网可访问 URL 或 base64 data URL。上层控制台仅在键盘已显示且该字段非空时渲染辅助图；图片与画面帧底部对齐，宽度适配画面帧宽度，高度按图片比例自适应。键盘未显示时不覆盖，键盘已显示但该字段为空时保留真实推流画面，不使用白色或空白占位。

## 4. 控制指令

### 4.1 下发通用指令

```http
POST /open/v1/devices/{deviceId}/commands
Content-Type: application/json
```

请求：

```json
{
  "action": "tap",
  "params": {
    "x": 0.5,
    "y": 0.8,
    "coordinateMode": "normalized"
  },
  "waitMs": 0,
  "metadata": {
    "externalUserId": "user_001",
    "externalTaskId": "task_001"
  }
}
```

响应：

```json
{
  "commandId": "cmd_001",
  "deviceId": "dev_001",
  "status": "accepted",
  "createdAt": "2026-06-29T10:00:00Z"
}
```

如果设备不具备该 action 对应的能力，返回：

```json
{
  "errorCode": "CAPABILITY_NOT_SUPPORTED",
  "message": "device does not support action: tap"
}
```

常见 action 与能力映射：

| action | 所需能力 |
|---|---|
| `tap` | `control.tap` |
| `swipe` | `control.swipe` |
| `touch_down` / `touch_move` / `touch_up` | `control.touchGesture` |
| `text` | `control.text` |
| `key` | `control.key` |
| `intent` | `control.intent` |
| `home` | `control.home` |
| `back` | `control.back` |
| `return_agent` | `control.returnAgent` |
| `script_run` | `control.scriptRun` |
| `script_result` | `control.scriptResult` |
| `screenshot` | `observe.screenshot` |
| `file_list` | `file.list` |
| `file_upload` | `file.upload` |
| `file_download` | `file.download` |
| `file_mkdir` | `file.mkdir` |
| `file_delete` | `file.delete` |
| `file_rename` | `file.rename` |
| `file_move` | `file.move` |

所需 scope：

```text
device.control
```

说明：

- `waitMs` / `timeoutMs` 为可选参数，单位毫秒。
- 未传时，普通控制指令默认异步返回 `sent`。
- `screenshot` 作为通用指令使用时默认等待 15 秒；生产调用推荐使用 `POST /open/v1/devices/{deviceId}/screenshot`。
- 通用命令接口中的 `script_run` 始终按异步方式返回 202，不接受 `waitMs` 强制等待；`script_result` 仅作为人工兜底查询，默认最多等待 Agent 15 秒。

### 4.2 支持的 action

#### tap

```json
{
  "action": "tap",
  "params": {
    "x": 0.5,
    "y": 0.8,
    "coordinateMode": "normalized"
  }
}
```

#### swipe

```json
{
  "action": "swipe",
  "params": {
    "x": 0.5,
    "y": 0.8,
    "x2": 0.5,
    "y2": 0.2,
    "durationMs": 420,
    "coordinateMode": "normalized"
  }
}
```

`durationMs` 表示设备执行普通滑动的时长。Android Mobile App Agent 默认使用 `420ms`，有效范围为 `120–1200ms`；超出范围时设备端会收敛到最近边界值。

#### touch_down / touch_move / touch_up

同步滑动适用于拖动验证码、画布拖拽等需要连续轨迹的场景。它不是一个单独的新接口，而是连续调用通用命令接口 `POST /open/v1/devices/{deviceId}/commands`，并按顺序发送以下三个 action：

| action | 数量 | 作用 |
|---|---:|---|
| `touch_down` | 1 次 | 开始手势，固定 `sequence=0`。 |
| `touch_move` | 0 到多次 | 更新手指位置，建议由 SDK 节流到 30–60Hz，默认 45Hz。 |
| `touch_up` | 1 次 | 在最终坐标结束手势；取消手势时仍要发送。 |

普通滑动与同步滑动的区别：普通滑动只在用户抬手后发送一次 `swipe`；同步滑动从按下开始实时发送 `touch_down/move/up`，一次拖动会产生多个 HTTP 命令和多个 `commandId`。

同一次手势的所有事件必须使用相同的 `gestureId` 和 `pointerId`。`touch_down.sequence` 必须为 `0`；后续 `sequence` 必须严格递增。允许网络丢包造成序号跳跃，例如从 `1` 跳到 `3`；不允许重复或倒退。`x/y` 是 `[0,1]` 归一化画面坐标。官方 SDK 使用 `timestamp` 表示 Unix 毫秒时间戳；接口同时兼容旧字段 `eventTime`，服务端会统一补齐 `eventTime`。

字段说明：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `gestureId` | string | 是 | 一次手势的唯一 ID，建议使用 UUID；最长 128 字符。 |
| `pointerId` | string / integer | 是 | 指针 ID；一次手势内保持不变。 |
| `x` / `y` | number | 是 | 归一化坐标，范围 `[0,1]`。 |
| `sequence` | integer | 是 | `touch_down=0`，后续严格递增。 |
| `timestamp` | integer | 建议 | 事件发生时的 Unix 毫秒时间戳。 |
| `coordinateMode` | string | 建议 | 固定为 `normalized`。 |
| `durationMs` / `segmentDurationMs` | integer | 否 | Android continued stroke 单段时长，范围 16–250ms，默认 40ms。 |
| `cancelled` | boolean | 否 | `pointercancel`、失焦、切换模式等取消结束时为 `true`。 |
| `reason` | string | 否 | 取消原因，最长 128 字符。 |

一次完整手势的请求体依次如下。第一条是按下：

```json
{
  "action": "touch_down",
  "params": {
    "gestureId": "gesture_01",
    "pointerId": 1,
    "x": 0.5,
    "y": 0.8,
    "sequence": 0,
    "timestamp": 1780000000000,
    "coordinateMode": "normalized"
  }
}
```

第二条和后续若干条是移动：

```json
{
  "action": "touch_move",
  "params": {
    "gestureId": "gesture_01",
    "pointerId": 1,
    "x": 0.52,
    "y": 0.68,
    "sequence": 1,
    "timestamp": 1780000000022,
    "coordinateMode": "normalized"
  }
}
```

最后一条是抬起：

```json
{
  "action": "touch_up",
  "params": {
    "gestureId": "gesture_01",
    "pointerId": 1,
    "x": 0.72,
    "y": 0.36,
    "sequence": 2,
    "timestamp": 1780000000044,
    "coordinateMode": "normalized"
  }
}
```

三个 action 均立即返回各自的 `commandId`，响应结构与 4.1 节一致。最终执行结果通过 `GET /open/v1/commands/{commandId}` 查询，通常以 `touch_up` 对应命令的结果代表整次手势结果。

直接用 JavaScript 发送一条完整轨迹的示例：

```js
const deviceId = 'dev_001';
const gestureId = crypto.randomUUID();
const pointerId = 1;
const points = [
  { x: 0.50, y: 0.80, action: 'touch_down' },
  { x: 0.52, y: 0.68, action: 'touch_move' },
  { x: 0.60, y: 0.52, action: 'touch_move' },
  { x: 0.72, y: 0.36, action: 'touch_up' }
];

for (let sequence = 0; sequence < points.length; sequence += 1) {
  const point = points[sequence];
  await openApi(`/devices/${encodeURIComponent(deviceId)}/commands`, {
    method: 'POST',
    body: {
      action: point.action,
      params: {
        gestureId,
        pointerId,
        x: point.x,
        y: point.y,
        sequence,
        timestamp: Date.now(),
        coordinateMode: 'normalized'
      }
    }
  });
}
```

浏览器播放控制应优先使用 Stream Server JSSDK 产生事件，再由业务层转发到 OpenAPI。下面的串行队列保证 HTTP 到达顺序；某个 `touch_move` 失败后，下一个更高序号仍可继续，因为服务端允许序号跳跃：

```js
const controlledDeviceId = 'dev_001';
const viewerPlayUrl = 'wss://stream-server.example.com/open/v1/streams/stream_001/play/ws?token=view_xxx';
const player = streamServer.play(document.getElementById('screen'), {
  playUrl: viewerPlayUrl,
  swipeMode: 'sync',
  syncMoveHz: 45,
  workerPlayback: true
});

let touchRequestChain = Promise.resolve();

function forwardTouch(action, event) {
  const request = () => openApi(`/devices/${encodeURIComponent(controlledDeviceId)}/commands`, {
    method: 'POST',
    body: {
      action,
      params: {
        gestureId: event.gestureId,
        pointerId: event.pointerId,
        x: event.x,
        y: event.y,
        sequence: event.sequence,
        timestamp: event.timestamp,
        coordinateMode: 'normalized',
        ...(event.cancelled ? { cancelled: true, reason: event.reason } : {})
      }
    }
  });

  touchRequestChain = touchRequestChain.then(request, request);
  return touchRequestChain;
}

player.touch_down = (event) => void forwardTouch('touch_down', event);
player.touch_move = (event) => void forwardTouch('touch_move', event);
player.touch_up = (event) => void forwardTouch('touch_up', event);

// 运行中切换为普通滑动，不需要重建播放连接：
// player.setSwipeMode('normal');
```

同一设备与 `gestureId` 由集群做 30 秒顺序保护。重复/倒退序号、没有先发送 `touch_down`、重复开始相同 `gestureId`，或 `pointerId` 不一致时返回 HTTP 409；序号缺口本身允许存在：

```json
{
  "errorCode": "TOUCH_SEQUENCE_CONFLICT",
  "message": "touch gesture sequence must increase strictly",
  "detail": {
    "gestureId": "gesture_01",
    "pointerId": 1,
    "receivedSequence": 5,
    "lastSequence": 3,
    "minimumSequence": 4
  }
}
```

Android Agent 应使用 Accessibility continued stroke 续接，不应把该协议理解为 raw `MotionEvent` 注入。

SDK 在 `pointercancel`、窗口失焦、切换模式或销毁播放器时仍会发出 `touch_up`，并附加 `cancelled=true` 与 `reason`；OpenAPI 可直接透传这些字段。

#### text

```json
{
  "action": "text",
  "params": {
    "text": "hello",
    "mode": "insert"
  }
}
```

`mode` 可选 `insert` 或 `replace`，默认 `insert`。`replace` 会尝试覆盖当前输入框内容。

#### key

```json
{
  "action": "key",
  "params": {
    "key": "IME_ENTER"
  }
}
```

可选 key：

```text
IME_ENTER / ENTER
SEARCH / IME_SEARCH
CONFIRM
DONE / IME_DONE
GO / IME_GO
SEND / IME_SEND
HIDE_KEYBOARD / KEYBOARD_HIDE / IME_HIDE
BACKSPACE / DELETE / DEL / KEYCODE_DEL
```

`home` 和 `back` 是独立 action，不要作为 `key` 参数发送：

```json
{
  "action": "home",
  "params": {}
}
```

```json
{
  "action": "back",
  "params": {}
}
```

#### return_agent

用于把手机端无影爪 App 拉回前台。该命令不需要参数，适合在目标 App 页面跑偏、需要回到 Agent 本地设置或排查状态时使用。

```json
{
  "action": "return_agent",
  "params": {}
}
```

上层控制台应在设备声明 `control.returnAgent=true` 时展示该按钮。Agent 端执行成功后通过通用 `command_result` 回传结果。

#### intent

用于让 Agent 在设备侧执行 Android Intent / deep link 跳转，例如打开小红书笔记链接、拨号页、系统设置页等。

```json
{
  "action": "intent",
  "params": {
    "action": "android.intent.action.VIEW",
    "data": "xhsdiscover://item/5dce59630000000001006272",
    "packageName": "com.xingin.xhs"
  }
}
```

参数：

| 字段 | 必填 | 说明 |
|---|---:|---|
| `action` / `intentAction` | 否 | Android Intent action，默认 `android.intent.action.VIEW`。 |
| `data` / `uri` / `url` | 否 | Intent data URI，例如 `xhsdiscover://item/...`、`https://...`、`tel:10086`。 |
| `type` | 否 | MIME type。 |
| `packageName` / `package` | 否 | 限定目标 App 包名。 |
| `className` / `activity` | 否 | 目标 Activity 类名；与 `packageName` 同时存在时使用显式 Component。 |
| `categories` | 否 | 字符串数组，追加 Intent category。 |
| `extras` | 否 | JSON 对象，写入 Intent extras。 |
| `flags` | 否 | 字符串数组，例如 `FLAG_ACTIVITY_CLEAR_TOP`、`FLAG_ACTIVITY_SINGLE_TOP`。 |

Agent 集群只负责下发 `intent` 指令并记录执行结果，不会在跳转后自动截图。控制端如果需要确认页面状态，应在收到成功结果后自行调用截图或拉流。

Agent 可以基于安全策略限制可执行的 action、包名、scheme 或 flag；被拒绝时返回 `COMMAND_FAILED` 或对应错误消息。

#### script_run

在 Android `mobile-app-agent` 上异步启动 JavaScript。内联模式传多行 `script`，文件模式传设备侧 `path`；`args` 是 JSON 对象，`params.timeoutMs` 是脚本自身的执行超时，有效范围为 1000 至 300000 毫秒。`language` 可省略，传入时固定为 `js`。

内联模式：

```json
{
  "action": "script_run",
  "params": {
    "mode": "inline",
    "language": "js",
    "script": "const keyword = args.keyword;\nreturn { keyword, ok: true };",
    "args": {
      "keyword": "test"
    },
    "timeoutMs": 60000
  }
}
```

文件模式：

```json
{
  "action": "script_run",
  "params": {
    "mode": "file",
    "language": "js",
    "path": "/sdcard/Download/mobile-agent/scripts/task.js",
    "args": {
      "keyword": "test"
    },
    "timeoutMs": 60000
  }
}
```

推荐使用专用提交接口。它只等待 App 通过 `/agent/v1/ws` 立即返回“任务已启动”，绝不会等待脚本执行完成：

```http
POST /open/v1/devices/{deviceId}/scripts
Content-Type: application/json
```

请求体就是上述 `mode/language/script|path/args/timeoutMs` 对象，不再外包 `action` 和 `params`。启动成功返回 HTTP 201：

```json
{
  "commandId": "cmd_script_run_001",
  "scriptTaskId": "script_001",
  "status": "running",
  "startedAt": 1780000000000,
  "timeoutMs": 60000
}
```

也可以继续调用通用 `POST /open/v1/devices/{deviceId}/commands`。该入口会立即返回 HTTP 202 和 `commandId`；App 随后的启动 `command_result.result` 会被集群归一化到该命令的 `resultData`，状态更新为 `running`。

同一设备同一时间只允许运行一个脚本。已有脚本运行时，接口返回 HTTP 409，命令记录形态如下；调用方可以直接保存 `runningTaskId` 并继续查询：

```json
{
  "commandId": "cmd_script_run_002",
  "errorCode": "SCRIPT_BUSY",
  "message": "script runtime busy",
  "runningTaskId": "script_001"
}
```

脚本完成后不需要自动调用 `script_result`。App 会主动向集群推送 `script_task_event`；集群同时完成三件事：更新 `script_tasks` 持久化记录、更新原 `script_run` 命令、向已订阅该 `deviceId` 的浏览器事件 WebSocket 转发。

#### script_result

优先查询集群已经持久化的任务状态，不会访问 App：

```http
GET /open/v1/devices/{deviceId}/scripts/{scriptTaskId}
```

持久化任务响应示例：

```json
{
  "scriptTaskId": "script_001",
  "originCommandId": "cmd_script_run_001",
  "deviceId": "dev_001",
  "status": "succeeded",
  "success": true,
  "message": "script succeeded",
  "result": {
    "returnValue": {
      "keyword": "test",
      "ok": true
    }
  },
  "startedAt": "2026-06-29T10:00:00.000Z",
  "finishedAt": "2026-06-29T10:00:01.200Z",
  "durationMs": 1200,
  "createdAt": "2026-06-29T10:00:00.000Z",
  "updatedAt": "2026-06-29T10:00:01.200Z"
}
```

如果完成事件丢失或仅用于调试，可显式触发一次 App 本地缓存查询：

```http
POST /open/v1/devices/{deviceId}/scripts/{scriptTaskId}/refresh
```

刷新完成后返回同一持久化任务结构；如果 App 查询仍在执行，返回 HTTP 202：

```json
{
  "commandId": "cmd_script_result_001",
  "status": "acknowledged",
  "scriptTaskId": "script_001"
}
```

该刷新接口内部通过 `/agent/v1/ws` 下发 `script_result`。通用命令入口仍接受下列兜底 action：

```json
{
  "action": "script_result",
  "params": {
    "scriptTaskId": "script_001"
  }
}
```

查询命令本身成功时，集群命令状态为 `succeeded`；脚本的真实状态在 `resultData.status` 中，取值为 `running`、`succeeded`、`failed` 或 `timeout`。例如：

```json
{
  "commandId": "cmd_script_result_001",
  "deviceId": "dev_001",
  "action": "script_result",
  "status": "succeeded",
  "resultData": {
    "scriptTaskId": "script_001",
    "status": "succeeded",
    "returnValue": {
      "keyword": "test",
      "ok": true
    },
    "startedAt": 1780000000000,
    "finishedAt": 1780000001200,
    "durationMs": 1200
  }
}
```

`script_result` 不会重新执行脚本，也不是正常完成链路的必需步骤。任务不存在或 App 缓存已淘汰时返回 `SCRIPT_TASK_NOT_FOUND`。`params.timeoutMs` 是脚本执行超时，与任何 HTTP/命令回执等待时间无关。

### 4.3 查询指令结果

```http
GET /open/v1/commands/{commandId}
```

响应：

```json
{
  "commandId": "cmd_001",
  "deviceId": "dev_001",
  "action": "tap",
  "status": "succeeded",
  "message": "ok",
  "createdAt": "2026-06-29T10:00:00Z",
  "finishedAt": "2026-06-29T10:00:01Z"
}
```

### 4.4 浏览器实时事件 WebSocket

浏览器和调试程序不能连接 Agent 内部通道 `/agent/v1/ws`，也不能依赖浏览器 WebSocket 握手携带自定义 `Authorization`、`X-App-Id` 请求头。对上事件通道使用一次性 ticket，鉴权和订阅契约如下。

#### 4.4.1 获取一次性 ticket

```http
POST /open/v1/events/tickets
X-App-Id: app_xxx
Authorization: Bearer sk_xxx
Content-Type: application/json

{
  "expiresInSec": 60
}
```

该接口要求 `device.control` scope。`expiresInSec` 可取 10 至 120 秒，只约束 ticket 的使用期限，不是 WebSocket 会话期限。响应示例：

```json
{
  "ticket": "evt_ticket_xxx",
  "expiresAt": 1780000000000,
  "wsUrl": "wss://agent-cluster.example.com/open/v1/events/ws?ticket=evt_ticket_xxx"
}
```

ticket 只能消费一次，过期、重复使用或无效时，服务端发送失败的 `event_auth_result`，然后以 4401 关闭连接。ticket 属于敏感信息，不应写入日志或长期存储。

#### 4.4.2 建立连接和订阅设备

浏览器直接使用响应中的完整 `wsUrl`：

```js
const socket = new WebSocket(ticketResponse.wsUrl);
```

连接鉴权成功后，服务端首先发送：

```json
{
  "type": "event_auth_result",
  "success": true,
  "appId": "app_xxx",
  "expiresAt": 1780000000000
}
```

收到鉴权成功消息后再订阅设备：

```json
{
  "type": "subscribe",
  "deviceIds": ["dev_001", "dev_002"]
}
```

服务端校验这些设备均属于当前 App，然后返回：

```json
{
  "type": "subscription_ack",
  "deviceIds": ["dev_001", "dev_002"]
}
```

单次最多订阅 100 台设备；再次发送 `subscribe` 会整体替换当前订阅列表。发送 `{"type":"unsubscribe_all"}` 可清空订阅。无权访问任一设备时，本次订阅失败且不会部分生效。

#### 4.4.3 `script_task_event`

App 在内部 `/agent/v1/ws` 推送的完成事件会先由集群持久化，再按 `deviceId` 转发给已订阅的浏览器：

```json
{
  "type": "script_task_event",
  "eventId": "evt_001",
  "deviceId": "dev_001",
  "scriptTaskId": "script_001",
  "originCommandId": "cmd_script_run_001",
  "status": "succeeded",
  "success": true,
  "message": "script succeeded",
  "result": {
    "scriptTaskId": "script_001",
    "originCommandId": "cmd_script_run_001",
    "status": "succeeded",
    "mode": "inline",
    "startedAt": 1780000000000,
    "timeoutMs": 60000,
    "finishedAt": 1780000001200,
    "durationMs": 1200,
    "returnValue": {
      "keyword": "test",
      "ok": true
    }
  },
  "startedAt": 1780000000000,
  "finishedAt": 1780000001200,
  "durationMs": 1200,
  "occurredAt": 1780000001200
}
```

失败或超时时，`status` 分别为 `failed` 或 `timeout`，`success=false`，错误详情位于 `result.errorCode` 和 `result.errorMessage`。公开事件不包含内部 `appId`。

客户端可发送 `{"type":"ping","timestamp":1780000000000}` 保活，服务端返回同一时间戳的 `pong`。事件通道当前不提供历史重放；断线重连时必须重新申请 ticket 并重新订阅。若断线期间遗漏事件，使用 `GET /open/v1/devices/{deviceId}/scripts/{scriptTaskId}` 查询持久化任务，或使用 `GET /open/v1/commands/{originCommandId}` 恢复最终状态；只有排障时才调用 `script_result` 刷新接口。

## 5. 截图

### 5.1 获取截图

```http
POST /open/v1/devices/{deviceId}/screenshot
```

请求：

```json
{
  "quality": 80,
  "maxWidth": 1080,
  "metadata": {
    "externalTaskId": "task_001"
  }
}
```

响应：

```json
{
  "screenshotId": "cmd_001",
  "commandId": "cmd_001",
  "deviceId": "dev_001",
  "width": 720,
  "height": 1457,
  "format": "jpeg",
  "data": "<base64 jpeg>",
  "createdAt": "2026-06-29T10:00:00Z",
  "finishedAt": "2026-06-29T10:00:01Z"
}
```

当前版本直接返回 base64 图片数据，便于上层业务立即给多模态模型使用或展示。生产环境如果截图体积较大，可以升级为临时 URL 或对象存储 URL，但接口语义保持为“单次截图并返回截图资源”。

所需 scope：

```text
device.screenshot
```

## 6. 推流和拉流

当前 Open API 已实现启动推流、获取播放地址、查询推流状态、停止推流和播放 WebSocket。

当前可用播放形态：

```text
playMode=websocket
```

说明：

- 当 Agent 侧选择 `h264_annexb` 时，播放 WebSocket 会转发 H.264 Annex-B 帧和 config。
- 当 Agent 侧选择 `jpeg` 时，播放 WebSocket 会转发 JPG 帧。
- `playUrl` 指向独立媒体节点，例如 Stream Server 或未来 LiveKit。Agent 集群不再提供内置播放地址。上层业务不应该自行拼接播放地址，必须使用接口返回的 `playUrl`。
- WebRTC/SFU 是后续生产升级方向；当前接口已经预留 `mode=webrtc`，但服务端会返回未实现提示。
- 控制端必须发送 viewer heartbeat，否则服务端会判定观看端离线。

长期生产目标仍然是对外统一 WebRTC + H.264 拉流。当前第一版为了先稳定跑通真实设备链路，对上播放形态为 WebSocket：H.264 Annex-B 或 JPG 帧都通过同一个播放 WebSocket 输出，控制端根据 `selectedInputMode` 选择对应解码方式。

### 6.1 启动推流

```http
POST /open/v1/devices/{deviceId}/streams
```

请求：

```json
{
  "mode": "auto",
  "fps": 60,
  "quality": 65,
  "maxWidth": 720,
  "playTokenExpiresInSec": 300
}
```

也可以把画面参数放入 `video` 对象：

```json
{
  "mode": "auto",
  "video": {
    "fps": 60,
    "quality": 65,
    "maxWidth": 720
  },
  "playTokenExpiresInSec": 300
}
```

响应：

```json
{
  "streamId": "stream_001",
  "deviceId": "dev_001",
  "status": "starting",
  "playMode": "websocket",
  "selectedInputMode": "h264_annexb",
  "selectedWireFormat": "binary_h264_v1",
  "playUrl": "wss://stream-server.example.com/open/v1/streams/stream_001/play/ws?token=view_xxx",
  "playToken": "view_xxx",
  "imeOverlayImage": "https://example.com/ime/redmi-keyboard.png",
  "leaseExpiresAt": "2026-06-29T11:00:00Z",
  "expiresAt": "2026-06-29T10:05:00Z"
}
```

说明：

- 如果 Agent 集群没有可用外置媒体节点，接口会返回 `MEDIA_NODE_UNAVAILABLE` 或 `MEDIA_NODE_PLAY_UNAVAILABLE`，不会降级为 Agent 集群内置播放地址。
- `selectedWireFormat` 是设备到媒体节点的上行线格式。H.264 仅允许 `binary_h264_v1`：设备的 `h264_annexb` 输入和媒体节点健康检查必须同时显式声明支持。旧 Agent 未声明时，显式 H.264 请求返回 `H264_BINARY_NOT_SUPPORTED`；`auto` 在设备实现 JPEG 时选择 JPEG。该字段不改变上层直接使用 `playUrl` 和 SDK 的方式。
- 如果使用外置 Stream Server，`streamId`、`pushToken`、`playToken` 仍由 Agent 集群签发，媒体节点只验证短期 token。
- 上层开发者只需要按 `playUrl` 建立播放连接，不需要关心本次流落在哪台媒体服务器。
- `imeOverlayImage` 是输入法区域辅助图，用户端控制界面可以直接传给 Stream Server JS SDK。SDK 仅在键盘已显示且图片非空时渲染辅助图，图片底部对齐、宽度适配画面帧、高度按图片比例自适应；图片为空时保留真实推流画面。
- `leaseExpiresAt` 是 Agent 集群侧的推流租约到期时间，默认创建或复用 stream 后 1 小时。到期后 Agent 集群会强制通知 Agent 停止推流，作为 viewer 心跳和媒体回调之外的流量兜底。

Stream Server `playUrl` 的播放 WebSocket 契约：

1. WebSocket 建立后 10 秒内先发送文本协商消息：

```json
{
  "type": "stream_viewer_hello",
  "wireFormats": ["binary_h264_v1"]
}
```

2. 服务端返回文本 `stream_viewer_ready`，其中 `wireFormat` 必须为 `binary_h264_v1`。
3. H.264 播放会先收到文本 `h264_config`，随后所有视频帧均为 `DB-H264-WS/1` Binary WebSocket 消息；不再支持 `h264_frame` JSON/Base64。
4. 播放端继续每 10 秒发送文本 `viewer_heartbeat`。JPEG 画面仍使用文本 `stream_frame`，但观看连接也必须先完成上述 viewer hello。
5. 浏览器应直接使用随 OpenAPI Demo 发布的 `stream-server.sdk.js`。调用 `streamServer.getSupport()` 检查 `binaryH264`；SDK 会完成协商、DBH1 校验、WebCodecs 解码和连接内关键帧追赶，并通过 `ready.wireFormat`、`frame`、`latency`、`error` 等事件暴露状态。普通解码积压不会重建 Viewer WebSocket。

同一设备的推流模式冲突规则：

- 同一设备同一时间只允许一条活跃上行推流。
- 如果已有活跃推流，新的观看端请求相同模式时，复用已有 `streamId`。
- 如果请求 `mode=auto`，也复用已有活跃推流，不强制切换模式。
- 复用已有 `streamId` 时会刷新 `leaseExpiresAt`，重新从当前时间计算 1 小时强制回收时间。
- 如果已有活跃推流是 `jpeg`，新请求明确要求 `h264_annexb`，返回 `409 STREAM_MODE_CONFLICT`。
- 如果已有活跃推流是 `h264_annexb`，新请求明确要求 `jpeg`，同样返回 `409 STREAM_MODE_CONFLICT`。
- 如果需要切换模式，必须先停止当前推流，等设备停止后再按新模式启动。

冲突响应示例：

```json
{
  "errorCode": "STREAM_MODE_CONFLICT",
  "message": "该设备已有活跃推流，且当前请求的推流模式与已有推流模式不一致。请复用当前模式，或先停止已有推流后再切换模式。",
  "streamId": "stream_001",
  "activeMode": "jpeg",
  "requestedMode": "h264_annexb",
  "selectedInputMode": "h264_annexb",
  "activeStatus": "streaming"
}
```

所需 scope：

```text
stream.start
```

### 6.2 停止推流

```http
DELETE /open/v1/streams/{streamId}
```

响应：

```json
{
  "streamId": "stream_001",
  "status": "stopped"
}
```

所需 scope：

```text
stream.stop
```

### 6.3 查询推流状态

```http
GET /open/v1/streams/{streamId}
```

响应：

```json
{
  "streamId": "stream_001",
  "deviceId": "dev_001",
  "status": "streaming",
  "playMode": "websocket",
  "selectedInputMode": "h264_annexb",
  "fallback": false,
  "viewerCount": 2,
  "config": {
    "mode": "h264_annexb",
    "fps": 60,
    "quality": 65,
    "maxWidth": 720,
    "transport": "websocket",
    "protocolVersion": 1,
    "wireFormat": "binary_h264_v1",
    "wireFormats": ["binary_h264_v1"],
    "wireProtocol": "DB-H264-WS/1"
  },
  "lastFrameAt": "2026-06-29T10:00:00Z",
  "leaseExpiresAt": "2026-06-29T11:00:00Z"
}
```

### 6.4 创建拉流 Token

```http
POST /open/v1/streams/{streamId}/play-token
```

请求：

```json
{
  "expiresInSec": 120
}
```

响应：

```json
{
  "streamId": "stream_001",
  "viewerId": "viewer_001",
  "playMode": "websocket",
  "playUrl": "wss://stream-server.example.com/open/v1/streams/stream_001/play/ws?token=view_xxx",
  "playToken": "view_xxx",
  "expiresAt": "2026-06-29T10:02:00Z"
}
```

所需 scope：

```text
stream.play_token
```

## 7. 观看心跳

当控制端正在拉流时，必须通过当前 `playUrl` 对应的拉流通道每 10 秒发送一次心跳：

```json
{
  "type": "viewer_heartbeat",
  "streamId": "stream_001",
  "timestamp": 1780000000000
}
```

规则：

```text
控制端每 10 秒发送 viewer_heartbeat
服务端 60 秒未收到 viewer_heartbeat，则判定该观看端离线
如果 stream 没有任何有效 viewer，服务端通知 Agent 停止推流
从 stream 创建或复用开始，默认 1 小时后无条件触发强制停止
```

说明：

- Stream Server 链路由 Stream Server 接收心跳，并把 viewer 变化、无观看者事件回调给 Agent 集群。
- 未来 LiveKit 链路由 LiveKit Webhook 和 Agent 集群定时查询共同校准 viewer 状态。
- 上层业务统一向 `playUrl` 所在的播放通道发送心跳，或者使用 Stream Server JS SDK 自动发送心跳。
- 强制停止时间由 `STREAM_LEASE_TTL_SECONDS` 控制，默认 3600 秒；扫描间隔由 `STREAM_LEASE_SWEEP_INTERVAL_SECONDS` 控制，默认 120 秒。

## 8. 文件接口

文件能力属于高风险接口，默认关闭。

### 8.0 当前实现状态

这一节必须严格区分“对上 REST 形态”和“对下 Agent 文件协议”。

当前管理后台已经可以通过内部管理接口完成文件浏览、新建文件夹、上传、下载、删除、重命名、移动等操作。

当前 Open API 已提供 REST 风格文件接口：

```text
GET  /open/v1/devices/{deviceId}/files
POST /open/v1/devices/{deviceId}/files/upload
POST /open/v1/devices/{deviceId}/files/mkdir
POST /open/v1/devices/{deviceId}/files/download-token
GET  /open/v1/files/download
POST /open/v1/devices/{deviceId}/files/delete
POST /open/v1/devices/{deviceId}/files/rename
POST /open/v1/devices/{deviceId}/files/move
GET  /open/v1/file-tasks/{fileTaskId}
```

内部实现方式：

```text
REST Open API
-> 创建 commands 任务
-> 通过 Agent 控制 WebSocket 下发文件 action
-> Agent 执行本地文件操作
-> command_result 写回 commands.result_data
-> REST 接口同步返回结果，或返回 fileTaskId 供查询
```

对下仍复用以下文件 action：

```text
file_list
file_download
file_upload
file_mkdir
file_delete
file_rename
file_move
```

上层业务不要直接依赖 `file_list/file_download/...` 这些对下 action，而应该调用本节 REST 接口。这样后续 Agent 集群内部升级传输方式时，上层不需要重构。

当前状态表：

| 能力 | 管理后台 | Open API | 说明 |
| --- | --- | --- | --- |
| 列目录 | 已实现 | 已实现 | `GET /files`，同步等待结果 |
| 新建文件夹 | 已实现 | 已实现 | `POST /files/mkdir` |
| 上传 | 已实现 | 已实现 | `POST /files/upload`，当前 JSON/base64 |
| 下载 | 已实现 | 已实现 | `POST /files/download-token` + `GET /files/download` |
| 删除 | 已实现 | 已实现 | `POST /files/delete` |
| 重命名 | 已实现 | 已实现 | `POST /files/rename` |
| 移动 | 已实现 | 已实现 | `POST /files/move` |
| 任务查询 | 已实现 | 已实现 | `GET /open/v1/file-tasks/{fileTaskId}` |

当前第一版限制：

```text
对下仍通过 WebSocket + JSON/base64 传输文件内容。
下载 token 当前为单机内存短期 token。
多节点生产部署时，下载 token 和文件缓存需要切换到 Redis、对象存储或独立文件服务。
```

当前单次上传/下载配置上限为：

```text
MAX_HTTP_BODY_BYTES=734003200
MAX_WS_PAYLOAD_BYTES=734003200
```

这个上限是为了兼容 base64 后的体积膨胀。生产版本不建议长期使用 base64 大文件直传，因为会放大带宽和内存占用，并且不利于断点续传。

### 8.1 列目录

```http
GET /open/v1/devices/{deviceId}/files?path=/sdcard/Download
```

所需 scope：

```text
file.read
```

响应：

```json
{
  "fileTaskId": "cmd_xxx",
  "path": "/sdcard/Download",
  "parentPath": "/sdcard",
  "entryCount": 2,
  "truncated": false,
  "items": [
    {
      "name": "a.jpg",
      "path": "/sdcard/Download/a.jpg",
      "directory": false,
      "size": 102400,
      "lastModified": 1780000000000,
      "readable": true,
      "writable": true
    }
  ]
}
```

如果 Agent 尚未返回结果，接口会返回 `202`：

```json
{
  "fileTaskId": "cmd_xxx",
  "status": "acknowledged",
  "message": "file task is still running"
}
```

### 8.2 上传文件

当前第一版使用 JSON/base64。后续升级 multipart、分片或对象存储时，接口路径保持不变。

```http
POST /open/v1/devices/{deviceId}/files/upload
Content-Type: application/json
```

所需 scope：

```text
file.write
```

请求：

```json
{
  "parentPath": "/sdcard/Download",
  "name": "a.txt",
  "base64": "...",
  "overwrite": true
}
```

响应：

```json
{
  "fileTaskId": "cmd_xxx",
  "status": "succeeded",
  "result": {
    "type": "file_upload",
    "path": "/sdcard/Download/a.txt",
    "size": 123
  }
}
```

### 8.3 新建文件夹

```http
POST /open/v1/devices/{deviceId}/files/mkdir
Content-Type: application/json
```

所需 scope：

```text
file.write
```

推荐按父目录和名称创建：

```json
{
  "parentPath": "/sdcard/Download",
  "name": "mobile-agent",
  "recursive": true
}
```

也可以直接指定完整目标路径：

```json
{
  "path": "/sdcard/Download/mobile-agent/scripts",
  "recursive": true,
  "waitMs": 30000
}
```

`path` 与 `name` 至少提供一个；同时提供时以 `path` 为准。`recursive` 默认 `true`，表示递归创建缺失的父目录。成功响应：

```json
{
  "fileTaskId": "cmd_xxx",
  "status": "succeeded",
  "result": {
    "type": "file_mkdir",
    "path": "/sdcard/Download/mobile-agent/scripts",
    "existed": false,
    "directory": true
  }
}
```

目标目录已经存在时仍视为幂等成功，`result.existed=true`。参数无效返回 HTTP 400 和 `INVALID_FILE_MKDIR_PARAMS`；Agent 文件权限不足或路径被安全策略拒绝时，任务返回失败。

### 8.4 创建下载 Token

```http
POST /open/v1/devices/{deviceId}/files/download-token
```

所需 scope：

```text
file.read
```

请求：

```json
{
  "path": "/sdcard/Download/a.jpg",
  "expiresInSec": 300
}
```

响应：

```json
{
  "fileTaskId": "cmd_xxx",
  "downloadUrl": "https://agent-cluster.example.com/open/v1/files/download?token=file_xxx",
  "expiresAt": "2026-07-03T10:01:00.000Z"
}
```

拉取文件：

```http
GET /open/v1/files/download?token=file_xxx
```

当前下载 token 本身就是短期授权，下载 URL 不再额外要求 `Authorization`。

下载成功响应不是 JSON，而是文件二进制，并包含 `Content-Type`、`Content-Length`、`Content-Disposition`。JavaScript 下载示例：

```js
const { downloadUrl } = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/download-token`, {
  method: 'POST',
  body: { path: '/sdcard/Download/a.jpg', expiresInSec: 300 }
});
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
const fileBlob = await response.blob();
```

### 8.5 删除文件

```http
POST /open/v1/devices/{deviceId}/files/delete
```

请求：

```json
{
  "paths": ["/sdcard/Download/a.txt"]
}
```

响应：

```json
{
  "fileTaskId": "cmd_delete_001",
  "status": "succeeded",
  "result": {
    "type": "file_delete",
    "deletedPaths": ["/sdcard/Download/a.txt"]
  }
}
```

### 8.6 重命名文件

```http
POST /open/v1/devices/{deviceId}/files/rename
```

请求：

```json
{
  "path": "/sdcard/Download/a.txt",
  "newName": "b.txt"
}
```

响应：

```json
{
  "fileTaskId": "cmd_rename_001",
  "status": "succeeded",
  "result": {
    "type": "file_rename",
    "oldPath": "/sdcard/Download/a.txt",
    "path": "/sdcard/Download/b.txt"
  }
}
```

### 8.7 移动文件

```http
POST /open/v1/devices/{deviceId}/files/move
```

请求：

```json
{
  "paths": ["/sdcard/Download/b.txt"],
  "targetPath": "/sdcard/Documents",
  "overwrite": false
}
```

响应：

```json
{
  "fileTaskId": "cmd_move_001",
  "status": "succeeded",
  "result": {
    "type": "file_move",
    "movedPaths": ["/sdcard/Documents/b.txt"]
  }
}
```

### 8.8 查询文件任务状态

```http
GET /open/v1/file-tasks/{fileTaskId}
```

响应：

```json
{
  "fileTaskId": "cmd_xxx",
  "deviceId": "dev_xxx",
  "action": "file_download",
  "status": "succeeded",
  "message": "file download ok",
  "result": {
    "type": "file_download",
    "name": "a.jpg",
    "format": "base64",
    "size": 102400
  },
  "createdAt": "2026-07-03T10:00:00.000Z",
  "sentAt": "2026-07-03T10:00:00.100Z",
  "finishedAt": "2026-07-03T10:00:01.000Z"
}
```

### 8.9 后续生产增强

```text
第一阶段：REST 外壳 + commands 任务 + Agent 文件指令，已经实现。
第二阶段：下载 token 缓存迁移到 Redis，支持多节点。
第三阶段：大文件改为分片上传/下载或对象存储临时 URL。
第四阶段：危险路径策略、操作二次确认、审计检索和限流。
```

## 9. 全部已实现接口 JavaScript 示例

本节逐项覆盖“当前实现状态”中列出的全部接口。代码复用 1.6 节的 `openApi`；每段应单独执行，并先替换示例 ID 和路径。删除连接码、停止推流、删除/移动/重命名文件都会修改真实数据，不要把整节代码一次性运行。

### 9.1 连接码接口

```js
// POST /open/v1/pairing-codes
const pairing = await openApi('/pairing-codes', {
  method: 'POST',
  body: {
    allowedAgentTypes: ['mobile_app_agent'],
    expiresInMinutes: 10,
    deviceName: '运营手机 01'
  }
});

// GET /open/v1/pairing-codes
const pairingQuery = new URLSearchParams({
  page: '1',
  pageSize: '20',
  status: 'active'
});
const pairingList = await openApi(`/pairing-codes?${pairingQuery}`);

// GET /open/v1/pairing-codes/{pairingId}
const pairingDetail = await openApi(`/pairing-codes/${encodeURIComponent(pairing.pairingId)}`);

// DELETE /open/v1/pairing-codes/{pairingId}
// 警告：该调用会删除真实连接码及其当前应用关联资源。
const pairingDeleteResult = await openApi(`/pairing-codes/${encodeURIComponent(pairing.pairingId)}`, {
  method: 'DELETE'
});
```

### 9.2 设备和通用命令接口

```js
// GET /open/v1/devices
const deviceQuery = new URLSearchParams({
  page: '1',
  pageSize: '20',
  status: 'ready'
});
const devices = await openApi(`/devices?${deviceQuery}`);
const deviceId = devices.items[0].deviceId;

// GET /open/v1/devices/{deviceId}
const device = await openApi(`/devices/${encodeURIComponent(deviceId)}`);

// POST /open/v1/devices/{deviceId}/commands
const tapCommand = await openApi(`/devices/${encodeURIComponent(deviceId)}/commands`, {
  method: 'POST',
  body: {
    action: 'tap',
    params: { x: 0.5, y: 0.5, coordinateMode: 'normalized' }
  }
});

// GET /open/v1/commands/{commandId}
const commandResult = await openApi(`/commands/${encodeURIComponent(tapCommand.commandId)}`);
```

同步滑动仍调用同一个 commands 接口，完整 `touch_down/move/up` 代码见 4.2 节。

### 9.3 异步脚本接口

```js
const deviceId = 'dev_001';

// POST /open/v1/devices/{deviceId}/scripts
const scriptStart = await openApi(`/devices/${encodeURIComponent(deviceId)}/scripts`, {
  method: 'POST',
  body: {
    mode: 'inline',
    language: 'js',
    script: 'return { ok: true, keyword: args.keyword };',
    args: { keyword: 'test' },
    timeoutMs: 60000
  }
});
const scriptTaskId = scriptStart.scriptTaskId;

// GET /open/v1/devices/{deviceId}/scripts/{scriptTaskId}
const scriptTask = await openApi(
  `/devices/${encodeURIComponent(deviceId)}/scripts/${encodeURIComponent(scriptTaskId)}`
);

// POST /open/v1/devices/{deviceId}/scripts/{scriptTaskId}/refresh
// 仅在实时事件丢失或排障时调用，不要持续轮询 refresh。
const refreshedTask = await openApi(
  `/devices/${encodeURIComponent(deviceId)}/scripts/${encodeURIComponent(scriptTaskId)}/refresh`,
  { method: 'POST' }
);
```

### 9.4 浏览器事件 WebSocket

```js
const deviceId = 'dev_001';

// POST /open/v1/events/tickets
const eventTicket = await openApi('/events/tickets', {
  method: 'POST',
  body: { expiresInSec: 60 }
});

// WS /open/v1/events/ws?ticket={oneTimeTicket}
// 不要自行拼 URL，直接使用 ticket 接口返回的完整 wsUrl。
const eventSocket = new WebSocket(eventTicket.wsUrl);

eventSocket.addEventListener('message', (event) => {
  const message = JSON.parse(event.data);
  if (message.type === 'event_auth_result' && message.success) {
    eventSocket.send(JSON.stringify({ type: 'subscribe', deviceIds: [deviceId] }));
    return;
  }
  if (message.type === 'script_task_event') {
    console.log('脚本最终结果', message);
  }
});

// 可选保活：在连接存续期间每隔一段时间发送。
const pingTimer = setInterval(() => {
  if (eventSocket.readyState === WebSocket.OPEN) {
    eventSocket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
  }
}, 30000);

eventSocket.addEventListener('close', () => clearInterval(pingTimer));
```

### 9.5 截图接口

```js
const deviceId = 'dev_001';

// POST /open/v1/devices/{deviceId}/screenshot
const screenshot = await openApi(`/devices/${encodeURIComponent(deviceId)}/screenshot`, {
  method: 'POST',
  body: {
    quality: 80,
    maxWidth: 1080,
    metadata: { externalTaskId: 'task_001' }
  }
});

// 浏览器展示 base64 JPEG：
const image = new Image();
image.src = `data:image/${screenshot.format};base64,${screenshot.data}`;
document.body.appendChild(image);
```

### 9.6 推流和拉流接口

```js
const deviceId = 'dev_001';

// POST /open/v1/devices/{deviceId}/streams
const stream = await openApi(`/devices/${encodeURIComponent(deviceId)}/streams`, {
  method: 'POST',
  body: {
    mode: 'auto',
    fps: 60,
    quality: 65,
    maxWidth: 720,
    playTokenExpiresInSec: 300
  }
});
const streamId = stream.streamId;

// GET /open/v1/streams/{streamId}
const streamStatus = await openApi(`/streams/${encodeURIComponent(streamId)}`);

// POST /open/v1/streams/{streamId}/play-token
const viewer = await openApi(`/streams/${encodeURIComponent(streamId)}/play-token`, {
  method: 'POST',
  body: { expiresInSec: 120 }
});

// HTML 需要先准备 <div id="screen"></div>。
// 使用 JSSDK 播放接口返回的 playUrl，不要自行拼接媒体节点地址。
const player = streamServer.play(document.getElementById('screen'), {
  playUrl: viewer.playUrl,
  streamId,
  deviceId,
  swipeMode: 'sync',
  syncMoveHz: 45,
  workerPlayback: true
});

// DELETE /open/v1/streams/{streamId}
// 先 player.destroy() 关闭当前浏览器 Viewer，再决定是否停止设备上行推流。
player.destroy();
const stopResult = await openApi(`/streams/${encodeURIComponent(streamId)}`, {
  method: 'DELETE'
});
```

### 9.7 文件接口

```js
const deviceId = 'dev_001';

// GET /open/v1/devices/{deviceId}/files
const fileQuery = new URLSearchParams({ path: '/sdcard/Download' });
const directory = await openApi(
  `/devices/${encodeURIComponent(deviceId)}/files?${fileQuery}`
);

// POST /open/v1/devices/{deviceId}/files/upload
const upload = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/upload`, {
  method: 'POST',
  body: {
    parentPath: '/sdcard/Download',
    name: 'openapi-example.txt',
    base64: btoa('hello from OpenAPI'),
    overwrite: true
  }
});

// POST /open/v1/devices/{deviceId}/files/mkdir
const directoryCreate = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/mkdir`, {
  method: 'POST',
  body: {
    path: '/sdcard/Download/mobile-agent/scripts',
    recursive: true
  }
});

// POST /open/v1/devices/{deviceId}/files/download-token
const download = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/download-token`, {
  method: 'POST',
  body: { path: '/sdcard/Download/openapi-example.txt', expiresInSec: 300 }
});

// GET /open/v1/files/download?token=...
// downloadUrl 已带短期 token，不再发送 App Token。
const downloadResponse = await fetch(download.downloadUrl);
if (!downloadResponse.ok) throw new Error(`download failed: HTTP ${downloadResponse.status}`);
const downloadedBlob = await downloadResponse.blob();

// POST /open/v1/devices/{deviceId}/files/rename
const rename = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/rename`, {
  method: 'POST',
  body: {
    path: '/sdcard/Download/openapi-example.txt',
    newName: 'openapi-renamed.txt'
  }
});

// POST /open/v1/devices/{deviceId}/files/move
const move = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/move`, {
  method: 'POST',
  body: {
    paths: ['/sdcard/Download/openapi-renamed.txt'],
    targetPath: '/sdcard/Documents',
    overwrite: false
  }
});

// GET /open/v1/file-tasks/{fileTaskId}
const fileTask = await openApi(`/file-tasks/${encodeURIComponent(move.fileTaskId)}`);

// POST /open/v1/devices/{deviceId}/files/delete
const fileDelete = await openApi(`/devices/${encodeURIComponent(deviceId)}/files/delete`, {
  method: 'POST',
  body: { paths: ['/sdcard/Documents/openapi-renamed.txt'] }
});
```

## 截图与推流互斥规则

截图接口服务于 AI/工作流自动观察，推流接口服务于人工介入控制。两者在业务语义和 Android `MediaProjection` 生命周期上都必须互斥。

规则：

- 设备存在 `starting` 或 `streaming` 状态的活跃推流时，`POST /open/v1/devices/{deviceId}/screenshot` 会直接返回失败，不再下发截图指令给 Agent。
- 通过 `POST /open/v1/devices/{deviceId}/commands` 下发 `action=screenshot` 时，同样执行该互斥判断。
- 推流表示人工正在查看或控制设备，此时上层 AI 应暂停自动观察和自动操作，不应继续请求截图。
- 停止推流后，截图可以重新发起；Agent 侧会为单次截图创建并释放对应录屏资源。

错误响应：

```json
{
  "errorCode": "SCREENSHOT_BLOCKED_BY_ACTIVE_STREAM",
  "message": "device is streaming; screenshot is disabled during manual control",
  "deviceId": "dev_android_xxx",
  "streamId": "stream_xxx",
  "streamStatus": "streaming"
}
```

上层业务建议：

- 收到 `SCREENSHOT_BLOCKED_BY_ACTIVE_STREAM` 时，将数字员工任务置为“人工介入中/等待人工结束”。
- 不要尝试并发调用截图和推流。
- 如需 AI 继续执行，先停止当前推流，等待设备回到无人接管状态后再截图。
