dsh-screen-flow
让大模型在 Android 上做界面自动化的 DSH 插件:
等到位 + 把一串「等 → 点 → 等」合成一次调用。
配套插件:dsh-screen-reader 负责看见(读屏、增量、截图);
本插件负责等到位(等某段文字出现/消失、等画面稳定)和把步骤合并。
English — A DeepSeek Harness plugin for Android UI
automation. It adds condition waiting (wait until a piece of text appears/disappears, or until the screen
settles) and a declarative step flow that runs a whole "wait → tap → wait → type → wait" sequence in ONE
tool call. The win is round trips, not CPU: each LLM round trip is seconds, each UI step is milliseconds.
Requires the DSHA Android app (its accessibility service owns the screen). Not a standalone npm package —
it is a plugin for a DSH profile.
为什么需要它
做手机自动化时,真正的成本不在 UI 操作(百毫秒级),而在来回(秒级):
拆成多次调用: 合成一次调用:
screen_read ← 往返 1,塞一屏文本 screen_flow(steps=[...]) ← 往返 1
screen_act 点登录 ← 往返 2,塞一屏文本 拿回 trace + 一屏增量
screen_read ← 往返 3,塞一屏文本
screen_act 输号码 ← 往返 4,塞一屏文本
screen_read ← 往返 5,塞一屏文本
... (本地引擎耗时:两者持平,省下的是 4 次往返)
而且「等界面变了」和「等界面变成我要的样子」是两回事。后者才能告诉你「可以下一步了」——
这个插件补的就是这块。
实测
同一台平板(Lenovo TB710FU · Android 16 · 3200×2000)上的真实数据:
读屏基准 复用连接后中位 11.0 ms(新起 curl 进程 200 ms 起)
条件等待 条件已成立时 16 ms 返回(先判断再睡,绝不白等一个轮询周期)
一次 4 步流程 391 ms,1 次工具调用
$ screen_wait {for:"验证码已发送", timeout_ms:10000}
# 条件已满足 等了 2400ms com.tencent.mm 3200x2000
等「验证码已发送」出现
$ screen_flow {steps:[
{wait_for:"手机号", tap:"手机号"},
{input:"13800138000"},
{wait_for:"获取验证码", tap:"获取验证码"},
{wait_for:"验证码已发送", timeout_ms:15000}
]}
# 流程完成 4/4 步,共 4.2s
1 ✓ 等「手机号」出现 320ms → 点「手机号」 180ms
2 ✓ 输入「13800138000」 210ms
3 ✓ 等「获取验证码」出现 1.8s → 点「获取验证码」 240ms
4 ✓ 等「验证码已发送」出现 1.2s
── 最终屏幕 Δ +3 -1
+ c 重新获取 (800,600)
- c 获取验证码
失败时它不会含糊:
# 流程在第 3 步失败(共 4 步),已用 12.4s
1 ✓ 等「手机号」出现 320ms → 点「手机号」 180ms
2 ✓ 输入「13800138000」 210ms
3 ✗ 等「获取验证码」出现 —— 等「获取验证码」出现超时(10000ms)
当前屏幕:
c 手机号 (400,300)
c 重新发送 (800,600)
两个工具
screen_wait —— 等一个条件成立
| 参数 | 说明 |
|---|
for | 等到这段文字出现(不区分大小写的子串) |
gone | 等到这段文字消失 |
stable_ms | 前面条件满足后,再等画面稳定这么久 |
timeout_ms | 总超时。条件已成立时立刻返回,不睡满一个间隔 |
find / max_elements | 返回屏幕时的收窄与上限 |
多个条件可以叠加,语义是「同时满足」。
screen_flow —— 顺序跑一串步骤
| 参数 | 说明 |
|---|
steps | 步骤数组,见下表 |
timeout_ms | 整条流程的预算 |
find / max_elements | 返回屏幕时的收窄与上限 |
一步可以写多个键,按固定顺序执行:
launch → wait_for → wait_gone → wait_idle → expect → tap → input → key → swipe → sleep
所以 {wait_for:"登录", tap:"登录"} 就是「等它出现再点它」,不用拆两步。
| 键 | 值 | 说明 |
|---|
launch | "com.tencent.mm" | 按包名启动应用 |
wait_for | "文字" | 等它出现 |
wait_gone | "文字" | 等它消失 |
wait_idle | 300 | 等画面稳定 300ms(尽力而为,见下) |
expect | "文字" | 断言它在屏幕上,不在就停在这一步 |
tap | "文字" 或 [x, y] | 优先按文字点;桥找不到时会自己找元素按坐标点 |
input | "内容" | 写进当前焦点输入框 |
key | "back" | back/home/recents/notifications/quicksettings/lock |
swipe | [x1,y1,x2,y2] | 滑动(可用 swipe_ms 调时长) |
sleep | 500 | 硬等 |
每步还支持 timeout_ms(本步超时)和 optional:true(失败不中止,继续下一步)。
动作之后会自动等画面稳定(settleAfterAction,默认开)—— 这是躲掉「点太早、读到动画中间帧」
这类最烦人的偶发失败的关键,别关。
配置
在 $DSH_HOME/profiles/web/cordis.patch.yml 里按 id 覆盖:
- id: screen-flow
config:
waitTimeoutMs: 20000
actionSettleMs: 250
| 键 | 默认 | 含义 |
|---|
enabled | true | 总开关 |
cacheMs | 120 | 同一次调用内的采样缓存时长 |
minIntervalMs / maxIntervalMs | 60 / 240 | 等待轮询的起始间隔与退避上限 |
maxAttempts / retryDelayMs | 3 / 120 | 读屏重试(超时 / 瞬时无窗口 / 退化帧) |
waitTimeoutMs | 10000 | wait_for / wait_gone 默认超时 |
idleTimeoutMs | 3000 | wait_idle 默认超时 |
actionTimeoutMs / launchTimeoutMs | 3000 / 10000 | 动作类默认超时 |
flowTimeoutMs | 120000 | 整条流程预算 |
maxSteps | 40 | 单条流程步数上限(护栏) |
settleAfterAction / actionSettleMs | true / 150 | 动作后自动等稳定 |
swipeMs | 300 | 滑动默认时长 |
maxElements / maxText | 80 / 96 | 返回屏幕的条目上限与文字截断 |
prompt | true | 是否注入那段简短的系统提示 |
warmup | true | 加载时预热桥连接(只打 /app/version,不碰屏幕) |
文件
| 文件 | 职责 |
|---|
lib/bridge.js | 3090 桥客户端(精简版):连接复用、超时、错误分类 + 给模型的下一步建议 |
lib/screen.js | 纯函数:解析 dump、按文字查找、压缩渲染、增量、FNV 指纹、退化帧判定 |
lib/reader.js | 采样缓存、可取消 sleep、waitUntil(条件轮询)、waitIdle(等稳定) |
lib/flow.js | 步骤引擎:校验、按序执行、动作后退避、失败定位、trace 渲染 |
lib/index.js | Cordis 插件:注册两个工具、系统提示段 |
测试
npm test # = test/flow-unit.mjs,纯逻辑,不需要设备
node test/flow-unit.mjs # 注入「假手机」,把步骤引擎的行为钉死
node test/plugin-load.mjs # 最小 cordis 上下文里真加载 + 真 dispatch
node test/smoke.mjs # 真机:读屏基准、条件等待、真实流程(只读)
node test/smoke.mjs --with-ui # 额外跑一次真实动作流程(下拉通知栏再返回)
| 测试 | 需要 | 覆盖 |
|---|
flow-unit.mjs | 无 | 解析、查找、条件等待、等稳定、步骤引擎、兜底点按、结构校验、护栏、取消 |
plugin-load.mjs | DSH 运行时 | 真 ToolRuntime 加载、schema 投影、参数校验、输出校验、render 全链路 |
smoke.mjs | DSH + DSHA 设备 | 真桥延迟、条件命中延迟、真实流程、失败定位 |
步骤引擎是有状态的(等多久、在哪一步停、动作后要不要等稳定),靠真机测既慢又不稳定;
而它一旦回归,模型会在错误的页面上继续操作 —— 那比直接失败更糟。所以 flow-unit.mjs
用一台「假手机」(屏幕是一串帧,动作会切帧)把这些行为全部钉死,不需要真机。
已知边界
wait_idle 是尽力而为。有些界面永远不会停(正在流式输出的对话、视频、加载动画)。
等不到时不判失败,只在 trace 里留一句提醒然后继续 —— 后面那句 wait_for 会兜住正确性。
但如果整个等待期间一次都没读到屏幕,那就是「读不到」而不是「没稳定」,这种情况会报错。
- 你写在对话里的字可能出现在屏幕上。DSHA 的界面本身就是这次对话,如果目标是 DSHA 自己,
那么你传给
wait_for 的文字可能已经被渲染上屏,导致条件秒成立。写测试时尤其要注意 ——
用运行时随机串,别写死。
- 无障碍服务被关掉:桥会回
[ERR] 无障碍服务未开启…。插件把它单独归类为 A11Y_OFF,
原话转告用户该去哪开,不重试(重试不会让开关自己变)。
- 读屏/截屏被拒(
你拒绝了这次屏幕读取):App 侧的授权决定,同样不重试、不绕道。
- 三种瞬时态(超时 / 瞬时无窗口 / 退化帧)会自动重试 ≤
maxAttempts 次;读屏幂等,重试永远安全。
只对这三种重试,POLICY_BLOCKED / SCREEN_DENIED / A11Y_OFF 一律立刻抛出。
- 没有任何 OCR:条件全部基于无障碍树的文字。画布、游戏画面、图形按钮用文字等不到,
那种场景请配合
dsh-screen-reader 的 mode:"image" 走视觉。
开发笔记
实测踩出来的坑,不是想出来的:
- 首次读屏失败不能让整条流程空转返回。我原先在起手读失败时直接返回,结果 trace 是空的、
错误原因也丢了,模型只看到「流程在第 1 步失败」却不知道为什么。改成「起手读只是优化」,
失败就往下走,让第一步自己报出原因。
- 错误文本不能全局正则匹配。屏幕正文里完全可能出现
无障碍服务未开启、你拒绝了这次屏幕读取
这类句子(模型正在讨论它),不锚定就会把一次成功的 dump 判成故障。
wait_idle 的语义要分两种:「界面一直在变」和「界面根本读不到」必须区分,否则用户看到
「没等到稳定」会去查动画,而真实原因是无障碍服务被关了。
- 注册脚本的相对路径陷阱:
dsha-builtin.txt 是相对 cwd 解析的,不在 / 下跑会静默失效。
安装
git clone https://github.com/HONYUZHE/dsh-screen-flow.git /root/dsha-screen-flow
ln -sfn ../../usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules /root/dsha-screen-flow/node_modules
cd / && echo 'dsh-screen-flow' >> /root/dsha-builtin.txt
cd / && python3 /root/.dsh/register-builtin-plugins.py
dsh --profile web --dump-config | grep -A2 screen-flow
然后重启 DSHA Web(DSH 的插件树只在启动时 compose)。
⚠️ 注册脚本必须在 / 下执行:它里面的 dsha-builtin.txt 是相对当前目录的。
License
MIT © 2026 HONYUZHE