#!/usr/bin/env node /** * 应用页面导航工具 (TypeScript 版) —— 使用视觉 LLM 按指定路径操作设备,导航到目标页面。 * * 等价于 app_feature_verify.py(functional equivalence,非逐字节一致),去掉了对 Python / * uv / phone-agent 的依赖。零第三方依赖,仅用 Node 内置模块 + 全局 fetch,可独立分发。 * 运行环境:Node ≥ 22.18 或 ≥ 23.6(原生剥离类型,无需编译)。 * * 复刻自 Zhipu Open-AutoGLM (`phone-agent` 0.1.1) 的 ReAct 循环: * 截图 + 当前app名 → 拼 messages → 请求模型 → 切出 thinking/action * → parse_action → 换算坐标(0-1000→px) → 执行 hdc/adb 命令 * → 把 塞回历史 → 判断 finish? → 循环到 max_steps * * 已内建 py 版通过 monkey-patch 修复的 3 个 phone-agent 0.1.1 bug: * ① 鸿蒙 swipe 最后一个参数是速度(pps) 而非时长(ms) * ② 模型输出剥离 / 包裹 * ③ do() 畸形输出的正则兜底解析 * * 用法: * # 自由 prompt 导航(会自动前置 "打开{app},") * node app_feature_verify.ts --device adb --app Gallery --package com.example.gallery --prompt "进入设置页面" * # 按功能路径导航 * node app_feature_verify.ts --app 简单图库 --package com.simplemobiletools.gallery.pro --task "L1相册详情L2更多L3筛选媒体文件" * # 功能清单文件(.md) * node app_feature_verify.ts --app xxx --package yyy --feature ./features.md */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; type DeviceType = 'hdc' | 'adb'; // 与 phone-agent 一致的默认动作后延时(秒)。可用 HT_ACTION_DELAY 覆盖。 const ACTION_DELAY = Number(process.env.HT_ACTION_DELAY ?? '1') || 1; const sleep = (seconds: number): Promise => new Promise((resolve) => setTimeout(resolve, Math.max(0, seconds) * 1000)); // --------------------------------------------------------------------------- // System prompt —— 逐字复刻 phone_agent/config/prompts_zh.py 的 SYSTEM_PROMPT。 // 这是模型行为的"契约",必须与 py 版一致,否则同一模型会输出不兼容内容。 // --------------------------------------------------------------------------- function buildSystemPrompt(): string { const now = new Date(); const weekdayNames = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; const formattedDate = `${now.getFullYear()}年${String(now.getMonth() + 1).padStart(2, '0')}月` + `${String(now.getDate()).padStart(2, '0')}日 ${weekdayNames[now.getDay()]}`; return ( '今天的日期是: ' + formattedDate + ` 你是一个智能体分析专家,可以根据操作历史和当前状态图执行一系列操作来完成任务。 你必须严格按照要求输出以下格式: {think} {action} 其中: - {think} 是对你为什么选择这个操作的简短推理说明。 - {action} 是本次执行的具体操作指令,必须严格遵循下方定义的指令格式。 操作指令及其作用如下: - do(action="Launch", app="xxx") Launch是启动目标app的操作,这比通过主屏幕导航更快。此操作完成后,您将自动收到结果状态的截图。 - do(action="Tap", element=[x,y]) Tap是点击操作,点击屏幕上的特定点。可用此操作点击按钮、选择项目、从主屏幕打开应用程序,或与任何可点击的用户界面元素进行交互。坐标系统从左上角 (0,0) 开始到右下角(999,999)结束。此操作完成后,您将自动收到结果状态的截图。 - do(action="Tap", element=[x,y], message="重要操作") 基本功能同Tap,点击涉及财产、支付、隐私等敏感按钮时触发。 - do(action="Type", text="xxx") Type是输入操作,在当前聚焦的输入框中输入文本。使用此操作前,请确保输入框已被聚焦(先点击它)。输入的文本将像使用键盘输入一样输入。重要提示:手机可能正在使用 ADB 键盘,该键盘不会像普通键盘那样占用屏幕空间。要确认键盘已激活,请查看屏幕底部是否显示 'ADB Keyboard {ON}' 类似的文本,或者检查输入框是否处于激活/高亮状态。不要仅仅依赖视觉上的键盘显示。自动清除文本:当你使用输入操作时,输入框中现有的任何文本(包括占位符文本和实际输入)都会在输入新文本前自动清除。你无需在输入前手动清除文本——直接使用输入操作输入所需文本即可。操作完成后,你将自动收到结果状态的截图。 - do(action="Type_Name", text="xxx") Type_Name是输入人名的操作,基本功能同Type。 - do(action="Interact") Interact是当有多个满足条件的选项时而触发的交互操作,询问用户如何选择。 - do(action="Swipe", start=[x1,y1], end=[x2,y2]) Swipe是滑动操作,通过从起始坐标拖动到结束坐标来执行滑动手势。可用于滚动内容、在屏幕之间导航、下拉通知栏以及项目栏或进行基于手势的导航。坐标系统从左上角 (0,0) 开始到右下角(999,999)结束。滑动持续时间会自动调整以实现自然的移动。此操作完成后,您将自动收到结果状态的截图。 - do(action="Note", message="True") 记录当前页面内容以便后续总结。 - do(action="Call_API", instruction="xxx") 总结或评论当前页面或已记录的内容。 - do(action="Long Press", element=[x,y]) Long Pres是长按操作,在屏幕上的特定点长按指定时间。可用于触发上下文菜单、选择文本或激活长按交互。坐标系统从左上角 (0,0) 开始到右下角(999,999)结束。此操作完成后,您将自动收到结果状态的屏幕截图。 - do(action="Double Tap", element=[x,y]) Double Tap在屏幕上的特定点快速连续点按两次。使用此操作可以激活双击交互,如缩放、选择文本或打开项目。坐标系统从左上角 (0,0) 开始到右下角(999,999)结束。此操作完成后,您将自动收到结果状态的截图。 - do(action="Back") 导航返回到上一个屏幕或关闭当前对话框。相当于按下 Android 的返回按钮。使用此操作可以从更深的屏幕返回、关闭弹出窗口或退出当前上下文。此操作完成后,您将自动收到结果状态的截图。 - do(action="Home") Home是回到系统桌面的操作,相当于按下 Android 主屏幕按钮。使用此操作可退出当前应用并返回启动器,或从已知状态启动新任务。此操作完成后,您将自动收到结果状态的截图。 - do(action="Wait", duration="x seconds") 等待页面加载,x为需要等待多少秒。 - finish(message="xxx") finish是结束任务的操作,表示准确完整完成任务,message是终止信息。 必须遵循的规则: 1. 在执行任何操作前,先检查当前app是否是目标app,如果不是,先执行 Launch。 2. 如果进入到了无关页面,先执行 Back。如果执行Back后页面没有变化,请点击页面左上角的返回键进行返回,或者右上角的X号关闭。 3. 如果页面未加载出内容,最多连续 Wait 三次,否则执行 Back重新进入。 4. 如果页面显示网络问题,需要重新加载,请点击重新加载。 5. 如果当前页面找不到目标联系人、商品、店铺等信息,可以尝试 Swipe 滑动查找。 6. 遇到价格区间、时间区间等筛选条件,如果没有完全符合的,可以放宽要求。 7. 在做小红书总结类任务时一定要筛选图文笔记。 8. 购物车全选后再点击全选可以把状态设为全不选,在做购物车任务时,如果购物车里已经有商品被选中时,你需要点击全选后再点击取消全选,再去找需要购买或者删除的商品。 9. 在做外卖任务时,如果相应店铺购物车里已经有其他商品你需要先把购物车清空再去购买用户指定的外卖。 10. 在做点外卖任务时,如果用户需要点多个外卖,请尽量在同一店铺进行购买,如果无法找到可以下单,并说明某个商品未找到。 11. 请严格遵循用户意图执行任务,用户的特殊要求可以执行多次搜索,滑动查找。 12. 在选择日期时,如果原滑动方向与预期日期越来越远,请向反方向滑动查找。 13. 执行任务过程中如果有多个可选择的项目栏,请逐个查找每个项目栏,直到完成任务,一定不要在同一项目栏多次查找,从而陷入死循环。 14. 在执行下一步操作前请一定要检查上一步的操作是否生效,如果点击没生效,可能因为app反应较慢,请先稍微等待一下,如果还是不生效请调整一下点击位置重试,如果仍然不生效请跳过这一步继续任务,并在finish message说明点击不生效。 15. 在执行任务中如果遇到滑动不生效的情况,请调整一下起始点位置,增大滑动距离重试,如果还是不生效,有可能是已经滑到底了,请继续向反方向滑动,直到顶部或底部,如果仍然没有符合要求的结果,请跳过这一步继续任务,并在finish message说明但没找到要求的项目。 16. 在做游戏任务时如果在战斗页面如果有自动战斗一定要开启自动战斗,如果多轮历史状态相似要检查自动战斗是否开启。 17. 如果没有合适的搜索结果,可能是因为搜索页面不对,请返回到搜索页面的上一级尝试重新搜索,如果尝试三次返回上一级搜索后仍然没有符合要求的结果,执行 finish(message="原因")。 18. 在结束任务前请一定要仔细检查任务是否完整准确的完成,如果出现错选、漏选、多选的情况,请返回之前的步骤进行纠正。 ` ); } // --------------------------------------------------------------------------- // 子进程封装(复用 page_capture.ts 的模式) // --------------------------------------------------------------------------- function runBin( bin: string, args: string[], opts: { timeout?: number; binary?: boolean } = {}, ): { stdout: string; stdoutBuf: Buffer; stderr: string; code: number | null } { const res = spawnSync(bin, args, { timeout: (opts.timeout ?? 15) * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true, }); const outBuf = (res.stdout as Buffer) ?? Buffer.alloc(0); const errBuf = (res.stderr as Buffer) ?? Buffer.alloc(0); if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { console.log(` [WARN] ${bin} timeout: ${args.join(' ')}`); } return { stdout: outBuf.toString('utf-8'), stdoutBuf: outBuf, stderr: errBuf.toString('utf-8'), code: res.status, }; } // --------------------------------------------------------------------------- // 图片尺寸解析(无需第三方库)—— 坐标换算需要截图的像素宽高。 // PNG: IHDR 在偏移 16(宽)/20(高)。JPEG: 扫描 SOF 标记读取宽高。 // --------------------------------------------------------------------------- function imageSize(buf: Buffer): { width: number; height: number } { // PNG if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; } // JPEG if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) { let i = 2; while (i < buf.length - 1) { if (buf[i] !== 0xff) { i += 1; continue; } const marker = buf[i + 1]; // SOF0..SOF15 (排除 C4=DHT, C8=JPG, CC=DAC) if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { const height = buf.readUInt16BE(i + 5); const width = buf.readUInt16BE(i + 7); return { width, height }; } if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } const segLen = buf.readUInt16BE(i + 2); i += 2 + segLen; } } return { width: 1080, height: 2400 }; // 回落默认值 } // --------------------------------------------------------------------------- // 设备驱动层(HDC / ADB) // --------------------------------------------------------------------------- interface Screenshot { base64: string; mime: string; width: number; height: number; } interface DeviceDriver { getScreenshot(): Screenshot; getCurrentApp(): string; launchApp(appName: string): boolean; tap(x: number, y: number): void; doubleTap(x: number, y: number): void; longPress(x: number, y: number): void; swipe(x1: number, y1: number, x2: number, y2: number): void; back(): void; home(): void; typeText(text: string): void; } // 应用注册表:app_name -> package(由 --app/--package 动态填充) const APP_PACKAGES = new Map(); class HdcDriver implements DeviceDriver { private serial: string | null; constructor(serial: string | null) { this.serial = serial; } private prefix(): string[] { return this.serial ? ['-t', this.serial] : []; } private shell(args: string[], timeout = 15) { return runBin('hdc', [...this.prefix(), 'shell', ...args], { timeout }); } getScreenshot(): Screenshot { const remote = '/data/local/tmp/tmp_screenshot.jpeg'; let out = this.shell(['snapshot_display', '-f', remote], 10); const combined = (out.stdout + out.stderr).toLowerCase(); if (combined.includes('fail') || combined.includes('error')) { // 备用方法(新版 HarmonyOS) this.shell(['screenshot', remote], 10); } const localPath = path.join(os.tmpdir(), `ht_shot_${process.pid}_${Date.now()}.jpeg`); runBin('hdc', [...this.prefix(), 'file', 'recv', remote, localPath], { timeout: 10 }); try { const buf = fs.readFileSync(localPath); fs.unlinkSync(localPath); const { width, height } = imageSize(buf); return { base64: buf.toString('base64'), mime: 'image/jpeg', width, height }; } catch { return fallbackScreenshot(); } } getCurrentApp(): string { const out = this.shell(['aa', 'dump', '-l']).stdout; if (!out) return 'System Home'; let currentBundle: string | null = null; let foreground: string | null = null; for (const line of out.split('\n')) { if (line.includes('app name [')) { const m = line.match(/\[([^\]]+)\]/); if (m) currentBundle = m[1]; } if (line.toLowerCase().includes('state #foreground')) { if (currentBundle) { foreground = currentBundle; break; } } if (line.includes('Mission ID')) currentBundle = null; } if (foreground) { for (const [name, pkg] of APP_PACKAGES) if (pkg === foreground) return name; return foreground; } return 'System Home'; } launchApp(appName: string): boolean { const bundle = APP_PACKAGES.get(appName); if (!bundle) { console.log(`[HDC] App '${appName}' not registered`); return false; } const ability = process.env.HT_HMOS_ABILITY || 'EntryAbility'; this.shell(['aa', 'start', '-b', bundle, '-a', ability]); return true; } tap(x: number, y: number): void { this.shell(['uitest', 'uiInput', 'click', String(x), String(y)]); } doubleTap(x: number, y: number): void { this.shell(['uitest', 'uiInput', 'doubleClick', String(x), String(y)]); } longPress(x: number, y: number): void { this.shell(['uitest', 'uiInput', 'longClick', String(x), String(y)]); } swipe(x1: number, y1: number, x2: number, y2: number): void { // Patch ①: 鸿蒙 swipe 最后一个参数是速度(pps),不是时长(ms)。 const distance = Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2); let durationMs = Math.trunc((distance * distance) / 1000); durationMs = Math.max(500, Math.min(durationMs, 1000)); let velocity = Math.trunc((distance / durationMs) * 4000); velocity = Math.max(500, Math.min(velocity, 40000)); this.shell(['uitest', 'uiInput', 'swipe', String(x1), String(y1), String(x2), String(y2), String(velocity)]); } back(): void { this.shell(['uitest', 'uiInput', 'keyEvent', 'Back']); } home(): void { this.shell(['uitest', 'uiInput', 'keyEvent', 'Home']); } typeText(text: string): void { const escaped = text.replace(/"/g, '\\"').replace(/\$/g, '\\$'); this.shell(['uitest', 'uiInput', 'text', escaped]); } } class AdbDriver implements DeviceDriver { private serial: string | null; constructor(serial: string | null) { this.serial = serial; } private prefix(): string[] { return this.serial ? ['-s', this.serial] : []; } private shell(args: string[], timeout = 15) { return runBin('adb', [...this.prefix(), 'shell', ...args], { timeout }); } getScreenshot(): Screenshot { const { stdoutBuf, code } = runBin('adb', [...this.prefix(), 'exec-out', 'screencap', '-p'], { timeout: 15, binary: true, }); if (code !== 0 || stdoutBuf.length === 0) return fallbackScreenshot(); const { width, height } = imageSize(stdoutBuf); return { base64: stdoutBuf.toString('base64'), mime: 'image/png', width, height }; } getCurrentApp(): string { const out = this.shell(['dumpsys', 'window']).stdout; for (const line of out.split('\n')) { if (line.includes('mCurrentFocus') || line.includes('mFocusedApp')) { for (const [name, pkg] of APP_PACKAGES) if (line.includes(pkg)) return name; } } return 'System Home'; } launchApp(appName: string): boolean { const pkg = APP_PACKAGES.get(appName); if (!pkg) { console.log(`[ADB] App '${appName}' not registered`); return false; } this.shell(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']); return true; } tap(x: number, y: number): void { this.shell(['input', 'tap', String(x), String(y)]); } doubleTap(x: number, y: number): void { this.shell(['input', 'tap', String(x), String(y)]); this.shell(['input', 'tap', String(x), String(y)]); } longPress(x: number, y: number): void { this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), '3000']); } swipe(x1: number, y1: number, x2: number, y2: number): void { const distSq = (x1 - x2) ** 2 + (y1 - y2) ** 2; let durationMs = Math.trunc(distSq / 1000); durationMs = Math.max(1000, Math.min(durationMs, 2000)); this.shell(['input', 'swipe', String(x1), String(y1), String(x2), String(y2), String(durationMs)]); } back(): void { this.shell(['input', 'keyevent', '4']); } home(): void { this.shell(['input', 'keyevent', 'KEYCODE_HOME']); } typeText(text: string): void { // 需设备安装 ADB Keyboard (com.android.adbkeyboard/.AdbIME) const encoded = Buffer.from(text, 'utf-8').toString('base64'); this.shell(['am', 'broadcast', '-a', 'ADB_INPUT_B64', '--es', 'msg', encoded]); } } function fallbackScreenshot(): Screenshot { // 1x1 黑色 PNG(截图失败时的占位,避免整个循环崩溃) const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64', ); return { base64: png.toString('base64'), mime: 'image/png', width: 1080, height: 2400 }; } // --------------------------------------------------------------------------- // 模型输出解析:切 thinking/action(复刻 ModelClient._parse_response 规则 1-4) // --------------------------------------------------------------------------- function splitThinkingAction(content: string): { thinking: string; action: string } { // 保留 内容用于展示。 const thinkM = content.match(/(.*?)<\/think>/s); const think = thinkM ? thinkM[1].trim() : ''; // Patch ②: 先剥离 / 包裹,再按标记切出干净的动作 // (避免把标签塞进 action,也避免存进历史时二次包裹)。 let cleaned = content.replace(/.*?<\/think>\s*/gs, ''); const ansM = cleaned.match(/\s*(.*?)\s*<\/answer>/s); if (ansM) cleaned = ansM[1]; cleaned = cleaned.replace(/<\/answer>/g, '').trim(); if (cleaned.includes('finish(message=')) { const idx = cleaned.indexOf('finish(message='); return { thinking: think || cleaned.slice(0, idx).trim(), action: cleaned.slice(idx) }; } if (cleaned.includes('do(action=')) { const idx = cleaned.indexOf('do(action='); return { thinking: think || cleaned.slice(0, idx).trim(), action: cleaned.slice(idx) }; } return { thinking: think, action: cleaned || content }; } // --------------------------------------------------------------------------- // 动作解析(复刻 actions/handler.parse_action + skill 的 Patch ②③) // --------------------------------------------------------------------------- type Action = Record & { _metadata: 'do' | 'finish' }; function stripLlmTags(text: string): string { let t = text.replace(/.*?<\/think>\s*/gs, ''); const m = t.match(/\s*(.*?)\s*<\/answer>/s); if (m) t = m[1]; return t.replace(/<\/answer>/g, '').trim(); } // 解析形如 do(action="Tap", element=[x,y], message="...") 的键值参数。 // 用逐键正则代替 Python 的 ast —— 覆盖 phone-agent 全部动作字段。 function parseDoArgs(inner: string): Record { const args: Record = {}; const strRe = /(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"/g; let m: RegExpExecArray | null; while ((m = strRe.exec(inner)) !== null) { args[m[1]] = m[2].replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\"/g, '"'); } const arrRe = /(\w+)\s*=\s*\[\s*(-?\d+)\s*,\s*(-?\d+)\s*\]/g; while ((m = arrRe.exec(inner)) !== null) { args[m[1]] = [parseInt(m[2], 10), parseInt(m[3], 10)]; } return args; } function parseAction(response: string): Action | null { const cleaned = stripLlmTags(response); for (const text of [cleaned, response.trim()]) { const finishM = text.match(/finish\s*\(\s*message\s*=\s*"((?:[^"\\]|\\.)*)"\s*\)/s); if (finishM) { return { _metadata: 'finish', message: finishM[1].replace(/\\"/g, '"').replace(/\\n/g, '\n') }; } const doM = text.match(/do\s*\((.*)\)\s*$/s) || text.match(/do\s*\((.*?)\)/s); if (doM) { const args = parseDoArgs(doM[1]); if (args.action) return { _metadata: 'do', ...args } as Action; } } return null; } // --------------------------------------------------------------------------- // LLM 客户端(OpenAI 兼容 /chat/completions,非流式) // --------------------------------------------------------------------------- interface ModelConfig { baseUrl: string; apiKey: string; modelName: string; temperature: number; } type ChatMessage = { role: string; content: unknown }; async function requestModel(cfg: ModelConfig, messages: ChatMessage[]): Promise { const url = cfg.baseUrl.replace(/\/$/, '') + '/chat/completions'; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.apiKey}`, }, body: JSON.stringify({ model: cfg.modelName, messages, max_tokens: 3000, temperature: cfg.temperature, top_p: 0.85, frequency_penalty: 0.2, stream: false, }), }); if (!resp.ok) { const body = await resp.text().catch(() => ''); throw new Error(`Model HTTP ${resp.status}: ${body.slice(0, 500)}`); } const data = (await resp.json()) as { choices?: { message?: { content?: string } }[] }; return data.choices?.[0]?.message?.content ?? ''; } // --------------------------------------------------------------------------- // 消息构造(复刻 MessageBuilder) // --------------------------------------------------------------------------- function userMessage(text: string, shot?: Screenshot): ChatMessage { const content: unknown[] = []; if (shot) { content.push({ type: 'image_url', image_url: { url: `data:${shot.mime};base64,${shot.base64}` } }); } content.push({ type: 'text', text }); return { role: 'user', content }; } function stripImages(msg: ChatMessage): ChatMessage { if (Array.isArray(msg.content)) { return { ...msg, content: msg.content.filter((c) => (c as { type?: string }).type === 'text') }; } return msg; } // --------------------------------------------------------------------------- // Agent 主循环(复刻 PhoneAgent.run / _execute_step) // --------------------------------------------------------------------------- class PhoneAgent { private cfg: ModelConfig; private device: DeviceDriver; private maxSteps: number; private verbose: boolean; private context: ChatMessage[] = []; constructor(cfg: ModelConfig, device: DeviceDriver, maxSteps: number, verbose: boolean) { this.cfg = cfg; this.device = device; this.maxSteps = maxSteps; this.verbose = verbose; } reset(): void { this.context = []; } async run(task: string): Promise { this.context = []; let stepCount = 0; let isFirst = true; while (stepCount < this.maxSteps) { stepCount += 1; const shot = this.device.getScreenshot(); const currentApp = this.device.getCurrentApp(); const screenInfo = JSON.stringify({ current_app: currentApp }); if (isFirst) { this.context.push({ role: 'system', content: buildSystemPrompt() }); this.context.push(userMessage(`${task}\n\n${screenInfo}`, shot)); isFirst = false; } else { this.context.push(userMessage(`** Screen Info **\n\n${screenInfo}`, shot)); } let content: string; try { content = await requestModel(this.cfg, this.context); } catch (e) { return `Model error: ${(e as Error).message}`; } const { thinking, action: actionStr } = splitThinkingAction(content); if (this.verbose) { console.log(`\n${'='.repeat(50)}\n💭 思考过程:\n${'-'.repeat(50)}`); console.log(thinking || '(无)'); console.log(`${'-'.repeat(50)}\n🎯 执行动作: ${actionStr}\n${'='.repeat(50)}`); } // 解析失败 → 当作 finish(原文作为 message),与 py 版一致。 const action = parseAction(actionStr) ?? { _metadata: 'finish', message: actionStr }; // 从历史里移除图片,省 token(复刻 remove_images_from_message) this.context[this.context.length - 1] = stripImages(this.context[this.context.length - 1]); this.context.push({ role: 'assistant', content: `${thinking}${actionStr}`, }); if (action._metadata === 'finish') { return (action.message as string) || 'Task completed'; } await this.execute(action, shot.width, shot.height); await sleep(ACTION_DELAY); } return 'Max steps reached'; } private async execute(action: Action, width: number, height: number): Promise { const toPx = (el: unknown): [number, number] => { const [rx, ry] = el as [number, number]; return [Math.trunc((rx / 1000) * width), Math.trunc((ry / 1000) * height)]; }; const name = action.action as string; try { switch (name) { case 'Launch': this.device.launchApp(action.app as string); break; case 'Tap': { const [x, y] = toPx(action.element); this.device.tap(x, y); break; } case 'Double Tap': { const [x, y] = toPx(action.element); this.device.doubleTap(x, y); break; } case 'Long Press': { const [x, y] = toPx(action.element); this.device.longPress(x, y); break; } case 'Swipe': { const [sx, sy] = toPx(action.start); const [ex, ey] = toPx(action.end); this.device.swipe(sx, sy, ex, ey); break; } case 'Type': case 'Type_Name': this.device.typeText((action.text as string) ?? ''); break; case 'Back': this.device.back(); break; case 'Home': this.device.home(); break; case 'Wait': { const dur = parseFloat(String(action.duration ?? '1').replace('seconds', '').trim()); await sleep(Number.isFinite(dur) ? dur : 1); break; } case 'Note': case 'Call_API': case 'Interact': case 'Take_over': // 占位动作(phone-agent 亦为空实现) break; default: console.log(` [WARN] Unknown action: ${name}`); } } catch (e) { console.log(` [WARN] Action '${name}' failed: ${(e as Error).message}`); } } } // --------------------------------------------------------------------------- // 功能清单解析 / prompt 构建 / 到达判定(复刻 py 版逻辑) // --------------------------------------------------------------------------- interface FeatureItem { [k: string]: unknown; } function parseFeatureMd(filePath: string): FeatureItem[] { const text = fs.readFileSync(filePath, 'utf-8'); const results: FeatureItem[] = []; let currentFeature = ''; let current: FeatureItem | null = null; let collecting: string | null = null; for (const line of text.split(/\r?\n/)) { const stripped = line.trim(); const feat = stripped.match(/^##\s+Feature:\s*(.+)$/); if (feat) { if (current) results.push(current); current = null; currentFeature = feat[1].trim(); collecting = null; continue; } const scen = stripped.match(/^###\s+Scenario:\s*(.+)$/); if (scen) { if (current) results.push(current); const scenarioName = scen[1].trim(); const featureName = currentFeature.replace(/^FEA-\d+\s*/, ''); current = { feature: currentFeature, scenario: scenarioName, preconditions: [] as string[], actions: [] as string[], expectations: [] as string[], L1: featureName, L2: scenarioName, L3: '', }; collecting = null; continue; } if (!current) continue; if (/^-\s*前置条件[::]/.test(stripped)) { collecting = 'preconditions'; continue; } const act = stripped.match(/^-\s*动作[::]\s*(.+)$/); if (act) { collecting = null; (current.actions as string[]).push(act[1].trim()); continue; } const exp = stripped.match(/^-\s*预期结果[::]\s*(.+)$/); if (exp) { collecting = null; (current.expectations as string[]).push(exp[1].trim()); continue; } const sub = line.match(/^\s+-\s+(.+)$/); if (sub && collecting === 'preconditions') { (current.preconditions as string[]).push(sub[1].trim()); continue; } if (stripped && !stripped.startsWith('-')) collecting = null; } if (current) results.push(current); return results; } function getFeaturePath(item: FeatureItem): { pathStr: string; leaf: string } { let keys = ['L1', 'L2', 'L3'].filter((k) => k in item); if (keys.length === 0) keys = ['一级', '二级', '三级'].filter((k) => k in item); if (keys.length === 0) keys = Object.keys(item).slice(0, 3); const parts: string[] = []; for (const k of keys) { const v = item[k]; if (v != null && String(v).trim()) parts.push(String(v).trim()); } if (parts.length === 0) return { pathStr: '', leaf: '' }; return { pathStr: parts.join(' > '), leaf: parts[parts.length - 1] }; } function buildNavigatePrompt(appName: string, pathStr: string, leaf: string, hints?: string): string { let prompt = `打开${appName},按照以下路径依次点击导航到目标页面:${pathStr}。` + `最终目标是到达「${leaf}」所在的页面。` + '注意:' + '1. 鸿蒙手机浅色/深色模式切换路径:设置->显示与亮度' + "2. '更多'组件可能表示为 三横 或者 三点。" + "3. '设置'组件可能表示为 齿轮。" + "到达目标页面后请回复'已到达',无法到达请回复'未找到'。"; if (hints) prompt += `\n补充提示: ${hints}`; return prompt; } function judgeReached(msg: string): boolean { if (!msg) return false; const reached = ['已到达', '到达', '存在', '找到', '完成', '成功', '已确认', '已进入', '已打开']; const failed = ['未找到', '不存在', '无法找到', '无法到达', '没有', '失败', '未到达']; const firstLine = msg.split('\n')[0]; if (failed.some((k) => firstLine.includes(k))) return false; if (reached.some((k) => firstLine.includes(k))) return true; return false; } // --------------------------------------------------------------------------- // 模型配置加载(复刻 _load_model_config:env > config.json > GLM 兼容) // --------------------------------------------------------------------------- function loadModelConfig(cliApiKey: string): ModelConfig { let apiKey = (cliApiKey || process.env.HOMETRANS_MODEL_API_KEY || '').trim(); let baseUrl = (process.env.HOMETRANS_MODEL_BASE_URL || '').trim(); let modelName = (process.env.HOMETRANS_MODEL_NAME || '').trim(); let temperature = 0.1; const configPath = path.join(os.homedir(), '.hometrans', 'config.json'); if (fs.existsSync(configPath)) { try { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const um = cfg?.autotest?.unified_model ?? {}; apiKey = apiKey || String(um.api_key ?? '').trim(); baseUrl = baseUrl || String(um.base_url ?? '').trim(); modelName = modelName || String(um.name ?? '').trim(); if (um.temperature != null) temperature = Number(um.temperature); } catch { /* ignore */ } } if (apiKey) { return { baseUrl: baseUrl || 'http://localhost:8000/v1', apiKey, modelName, temperature }; } const glmKey = (process.env.GLM_API_KEY || '').trim(); if (glmKey) { return { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', apiKey: glmKey, modelName: 'autoglm-phone', temperature: 0.1, }; } return { baseUrl: 'http://localhost:8000/v1', apiKey: 'EMPTY', modelName: '', temperature }; } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- interface Args { device: DeviceType; app: string; package: string; task: string | null; prompt: string | null; feature: string | null; limit: number | null; offset: number; apiKey: string; maxSteps: number; appHints: string | null; serial: string | null; quiet: boolean; } function parseArgs(argv: string[]): Args { const a: Args = { device: 'hdc', app: '', package: '', task: null, prompt: null, feature: null, limit: null, offset: 1, apiKey: '', maxSteps: 30, appHints: null, serial: null, quiet: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const next = () => argv[++i]; switch (arg) { case '--device': a.device = next() as DeviceType; break; case '--app': a.app = next(); break; case '--package': a.package = next(); break; case '--task': a.task = next(); break; case '--prompt': a.prompt = next(); break; case '--feature': a.feature = next(); break; case '--limit': a.limit = parseInt(next(), 10); break; case '--offset': a.offset = parseInt(next(), 10); break; case '--api-key': a.apiKey = next(); break; case '--max-steps': a.maxSteps = parseInt(next(), 10); break; case '--app-hints': a.appHints = next(); break; case '--serial': a.serial = next(); break; case '-q': case '--quiet': a.quiet = true; break; default: console.log(`[WARN] 未知参数: ${arg}`); } } return a; } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); if (!args.app || !args.package) { console.error('错误: 必须指定 --app 和 --package'); return 2; } if (!args.prompt && !args.task && !args.feature) { console.error('错误: 必须指定 --feature、--task 或 --prompt 之一'); return 2; } // 注册应用(复刻 register_app) APP_PACKAGES.set(args.app, args.package); const model = loadModelConfig(args.apiKey); if (!model.apiKey || model.apiKey === 'EMPTY') { if (!args.quiet) { console.log('提示: 未找到可用的模型配置。请运行 `ht init` 配置统一模型,'); console.log('或通过 --api-key 传入 API Key。\n'); } } const deviceLabel = args.device === 'adb' ? 'ADB (Android)' : 'HDC (HarmonyOS)'; console.log(`设备: ${deviceLabel}, 应用: ${args.app} (${args.package})`); console.log('-'.repeat(50)); const driver: DeviceDriver = args.device === 'adb' ? new AdbDriver(args.serial) : new HdcDriver(args.serial); const agent = new PhoneAgent(model, driver, args.maxSteps, !args.quiet); // 收集任务列表 [(prompt, label), ...](复刻 navigate 的三种模式) const tasks: { prompt: string; label: string }[] = []; if (args.prompt) { tasks.push({ prompt: `打开${args.app},${args.prompt}`, label: args.prompt }); } else if (args.task) { const parts = args.task.split(/(L[123])/); const item: FeatureItem = {}; for (let j = 1; j < parts.length - 1; j += 2) { const val = parts[j + 1].trim(); if (val) item[parts[j]] = val; } const { pathStr, leaf } = getFeaturePath(item); if (pathStr) tasks.push({ prompt: buildNavigatePrompt(args.app, pathStr, leaf, args.appHints ?? undefined), label: pathStr }); } else if (args.feature) { if (!args.feature.toLowerCase().endsWith('.md')) { console.error('错误: --feature 仅支持 .md 功能清单,或改用 --prompt / --task。'); return 2; } let features = parseFeatureMd(args.feature); if (args.offset > 1) features = features.slice(args.offset - 1); if (args.limit != null) features = features.slice(0, args.limit); for (const item of features) { const { pathStr, leaf } = getFeaturePath(item); if (pathStr) tasks.push({ prompt: buildNavigatePrompt(args.app, pathStr, leaf, args.appHints ?? undefined), label: pathStr }); } } for (let i = 0; i < tasks.length; i++) { const { prompt, label } = tasks[i]; console.log(tasks.length > 1 ? `\n[${i + 1}/${tasks.length}] 导航: ${label}` : `导航: ${label}`); let reached = false; try { const resultMsg = await agent.run(prompt); reached = judgeReached(resultMsg); } catch { reached = false; } console.log(reached ? '到达目标页面' : '未找到目标页面'); if (i < tasks.length - 1) agent.reset(); } return 0; } // 用 exitCode 而非 process.exit(),让事件循环自然排空, // 规避 Windows 上 libuv 在句柄关闭期间被 process.exit 打断的 teardown 断言。 main() .then((code) => { process.exitCode = code; }) .catch((e) => { console.error(e); process.exitCode = 1; });