android-midscene-automation 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -4
- package/README.md +106 -9
- package/package.json +1 -1
- package/server/config.ts +71 -1
- package/server/http-api.ts +143 -107
- package/server/script-agent.ts +50 -45
- package/src/App.vue +133 -54
- package/src/api.ts +15 -10
- package/src/appium-recorder/AppiumPage.vue +1 -1
- package/src/config/prompt-example.ts +4 -0
- package/src/config/prompt-presets.ts +172 -0
- package/src/pages/AiGeneratorPage.vue +188 -5
- package/src/pages/AutomationPage.vue +3 -3
- package/src/pages/ConfigPage.vue +55 -66
- package/src/pages/GeneratorPage.vue +7 -0
- package/src/style.css +104 -137
- package/src/types.ts +0 -14
- package/server/model-call-usage-importer.ts +0 -108
- package/server/model-usage-repository.ts +0 -131
- package/src/components/config/ModelUsageChart.vue +0 -188
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## v0.1.25
|
|
4
|
+
|
|
5
|
+
- Android 设备预览恢复使用 scrcpy 实时流,优化实时流参数,减少 H.264 丢帧导致的花屏;刷新画面时会重启底层 Playground 预览流并等待会话就绪。
|
|
6
|
+
- 测试脚本生成新增提示词模板,可在通用提示词、广告测试、表单填写、聊天对话、播放器测试、搜索列表、订单支付、权限弹窗、个人设置和 H5/WebView 等场景间切换,并支持编辑或上传当前原始 Prompt。
|
|
7
|
+
- 优化 AI 生成流程,支持后台处理和终止生成;生成中锁定原始 Prompt,再次点击“AI 生成”可恢复进度弹窗,终止后不再弹出额外提示。
|
|
8
|
+
- 优化脚本生成提示词策略,区分广告、播放器、弹窗等被测目标与无关遮挡内容,登录流程增加登录后遮挡弹窗处理和无遮挡首页成功判定。
|
|
9
|
+
|
|
10
|
+
## v0.1.24
|
|
11
|
+
|
|
12
|
+
- 参数配置的运行配置新增保存校验,Android SDK 路径必须包含 `platform-tools/adb`,回放报告目录必须填写已存在且可写入的绝对路径。
|
|
13
|
+
- Midscene 自定义提供方的 `Model Name` 和 `Model Family` 改为输入框,`Base URL` 匹配已知提供方时自动填充模型名称和模型系列,并在字段旁新增官方配置说明入口。
|
|
14
|
+
|
|
3
15
|
## v0.1.23
|
|
4
16
|
|
|
5
17
|
- 常见问题拆分为独立 `FAQ.md` 文档,并在 README 的“功能文档”中增加跳转入口。
|
|
@@ -17,10 +29,6 @@
|
|
|
17
29
|
- 连接脚本回放时会跳过子脚本开始后的重复“启动 App”和“清除 App”操作,减少跨脚本连接时的重复初始化。
|
|
18
30
|
- 移除“连接到下一节点”“取消连接”“继续主流程”等冗余连接操作,保留复制节点作为主要复用方式。
|
|
19
31
|
- 修复放大面板后点击操作按钮或节点配置会重置缩放的问题,保持当前画布大小和位置。
|
|
20
|
-
- Android Playground 改为后台启动 5800 服务,不再自动打开 `http://localhost:5800/` 浏览器窗口。
|
|
21
|
-
- CLI 启动时输出 GitHub 和 Issues 地址,方便用户反馈问题和提交建议。
|
|
22
|
-
- 常见问题新增桌面点击 App 录制说明:部分厂商系统无法获取桌面图标稳定 id,建议使用开始节点后的“启动 App”操作替代。
|
|
23
|
-
- 补充 GitHub 仓库、问题反馈和项目主页信息。
|
|
24
32
|
|
|
25
33
|
## v0.1.20
|
|
26
34
|
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npx android-midscene-automation --port 5174
|
|
|
52
52
|
## 功能文档
|
|
53
53
|
|
|
54
54
|
- [Appium 录制器使用说明](#appium-录制器使用说明)
|
|
55
|
-
- [常见问题](
|
|
55
|
+
- [常见问题](#常见问题)
|
|
56
56
|
- [项目更新记录](#项目更新记录)
|
|
57
57
|
|
|
58
58
|
## 源码开发
|
|
@@ -165,7 +165,7 @@ midscene_run/ # Midscene 执行报告和运行产物
|
|
|
165
165
|
|
|
166
166
|
本文档说明 Appium 组件树录制器的完整使用方式,包括每个操作按钮的用途、录制方法和示例。
|
|
167
167
|
|
|
168
|
-
本文档随 npm 包发布,也可以从 README 的“功能文档”直接打开。常见问题请查看 [
|
|
168
|
+
本文档随 npm 包发布,也可以从 README 的“功能文档”直接打开。常见问题请查看 [常见问题](#常见问题)。
|
|
169
169
|
|
|
170
170
|
## 使用前准备
|
|
171
171
|
|
|
@@ -1106,9 +1106,6 @@ output/2026-08-21_17-13-42-831-登录流程.html
|
|
|
1106
1106
|
- 校验页面返回路径。
|
|
1107
1107
|
- 校验返回后列表仍可见。
|
|
1108
1108
|
|
|
1109
|
-
## 常见问题
|
|
1110
|
-
|
|
1111
|
-
常见问题已独立整理到 [FAQ.md](./FAQ.md)。
|
|
1112
1109
|
|
|
1113
1110
|
## 推荐录制规范
|
|
1114
1111
|
|
|
@@ -1120,8 +1117,112 @@ output/2026-08-21_17-13-42-831-登录流程.html
|
|
|
1120
1117
|
- 给关键节点填写“登录按钮”“账号输入框”等备注,方便查看流程和报告。
|
|
1121
1118
|
- 回放失败后优先查看“回放输出”中的失败节点、Activity 和 selector,再打开 `output` 中的 Markdown 报告查看完整配置。
|
|
1122
1119
|
|
|
1120
|
+
# 常见问题
|
|
1121
|
+
|
|
1122
|
+
## 同一个 id 定位到错误输入框怎么办
|
|
1123
|
+
|
|
1124
|
+
如果账号框和密码框都是 `id/tg_edit`,只按 id 回放可能输入到第一个输入框。
|
|
1125
|
+
|
|
1126
|
+
建议:
|
|
1127
|
+
|
|
1128
|
+
- 直接分别选择账号框和密码框录制输入。
|
|
1129
|
+
- 在节点详情里确认 selector 是否显示“重复 N”,以及“推荐定位”是否显示父级 + 子级。
|
|
1130
|
+
- 如果推荐定位不准确,展开录制步骤,手动修改父级上下文 selector。
|
|
1131
|
+
- 坐标点击只作为最后兜底。
|
|
1132
|
+
|
|
1133
|
+
## 点击后没有检测到页面跳转怎么办
|
|
1134
|
+
|
|
1135
|
+
可能原因:
|
|
1136
|
+
|
|
1137
|
+
- 按钮当前禁用。
|
|
1138
|
+
- 账号密码不正确。
|
|
1139
|
+
- App 使用单 Activity,Activity 不变化。
|
|
1140
|
+
- 页面跳转依赖网络。
|
|
1141
|
+
|
|
1142
|
+
推荐:
|
|
1143
|
+
|
|
1144
|
+
- 使用“断言存在”判断下个页面核心元素。
|
|
1145
|
+
- 多 Activity 页面可以拆成两个脚本,在判断分支中用“连接脚本”串起来;回放会等待目标 Activity 出现。
|
|
1146
|
+
- 单 Activity App 不要依赖 Activity 变化,应使用页面核心元素判断是否已进入目标状态。
|
|
1147
|
+
|
|
1148
|
+
## 为什么连接脚本提示入口 Activity 不匹配
|
|
1149
|
+
|
|
1150
|
+
- 主流程连接要求目标脚本入口 Activity 与当前插入点一致,适合复用同一页面上的公共流程。
|
|
1151
|
+
- 登录后进入首页等跨 Activity 场景,应先添加“判断存在”,再在“是”或“否”分支中添加“连接脚本”。
|
|
1152
|
+
- 分支连接允许选择同一 App 的其他 Activity,但不会主动跳转页面;前面的点击或输入必须真正触发跳转。
|
|
1153
|
+
- 如果页面未跳转,回放会在等待目标 Activity 超时后失败,这是为了防止在错误页面执行目标脚本。
|
|
1154
|
+
|
|
1155
|
+
## 为什么在桌面点击 App 后回放无效
|
|
1156
|
+
|
|
1157
|
+
部分厂商系统会限制桌面或启动器页面的控件信息,Appium 可能无法获取桌面图标的稳定 id。此时在桌面录制“点击 App 图标”可能无法回放。
|
|
1158
|
+
|
|
1159
|
+
建议在开始节点后添加“启动 App”操作,由系统按包名打开目标 App,替代桌面点击图标。需要重置登录态或缓存时,可以先添加“清理 App 缓存”,再添加“启动 App”。
|
|
1160
|
+
|
|
1161
|
+
## 弹窗只出现一次怎么办
|
|
1162
|
+
|
|
1163
|
+
使用“判断存在”。
|
|
1164
|
+
|
|
1165
|
+
```text
|
|
1166
|
+
判断存在 确认按钮
|
|
1167
|
+
是 -> 点击 确认按钮
|
|
1168
|
+
否 -> 不添加操作
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
不要直接把弹窗确认按钮作为普通必选点击,否则弹窗不出现时脚本会失败。
|
|
1172
|
+
|
|
1173
|
+
## 什么时候用等待 Activity
|
|
1174
|
+
|
|
1175
|
+
适合多 Activity App。
|
|
1176
|
+
|
|
1177
|
+
如果 App 是单 Activity 架构,优先用:
|
|
1178
|
+
|
|
1179
|
+
```text
|
|
1180
|
+
断言存在 页面核心元素
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
## 什么时候用可选步骤
|
|
1184
|
+
|
|
1185
|
+
可选步骤适合非主流程阻塞项:
|
|
1186
|
+
|
|
1187
|
+
- 权限弹窗
|
|
1188
|
+
- 协议弹窗
|
|
1189
|
+
- 活动弹窗
|
|
1190
|
+
- 首次引导
|
|
1191
|
+
|
|
1192
|
+
不建议把登录按钮、提交按钮、核心断言设置为可选。
|
|
1193
|
+
|
|
1194
|
+
## 回放时出现 UiAutomation not connected 怎么办
|
|
1195
|
+
|
|
1196
|
+
组件树刷新和 Appium 回放不能同时占用 UiAutomation。当前版本会在回放期间暂停组件树自动刷新,并在断开时清理残留抓取进程后重试一次。
|
|
1197
|
+
|
|
1198
|
+
如果仍然失败:
|
|
1199
|
+
|
|
1200
|
+
1. 确认手机保持解锁,USB 调试授权没有失效。
|
|
1201
|
+
2. 确认 Appium 和 UiAutomator2 Driver 已正常启动。
|
|
1202
|
+
3. 停止其他正在抓取同一设备组件树的工具。
|
|
1203
|
+
4. 重新连接设备后再次回放。
|
|
1204
|
+
|
|
1205
|
+
## 设备预览或组件树没有更新怎么办
|
|
1206
|
+
|
|
1207
|
+
1. 先确认设备仍显示在设备下拉框中。
|
|
1208
|
+
2. 点击设备预览刷新按钮重新获取画面。
|
|
1209
|
+
3. 点击“刷新组件树”重新抓取页面结构。
|
|
1210
|
+
4. 回放期间组件树自动刷新会暂停,回放完成后会自动恢复。
|
|
1211
|
+
|
|
1123
1212
|
# 项目更新记录
|
|
1124
1213
|
|
|
1214
|
+
## v0.1.25
|
|
1215
|
+
|
|
1216
|
+
- Android 设备预览恢复使用 scrcpy 实时流,优化实时流参数,减少 H.264 丢帧导致的花屏;刷新画面时会重启底层 Playground 预览流并等待会话就绪。
|
|
1217
|
+
- 测试脚本生成新增提示词模板,可在通用提示词、广告测试、表单填写、聊天对话、播放器测试、搜索列表、订单支付、权限弹窗、个人设置和 H5/WebView 等场景间切换,并支持编辑或上传当前原始 Prompt。
|
|
1218
|
+
- 优化 AI 生成流程,支持后台处理和终止生成;生成中锁定原始 Prompt,再次点击“AI 生成”可恢复进度弹窗,终止后不再弹出额外提示。
|
|
1219
|
+
- 优化脚本生成提示词策略,区分广告、播放器、弹窗等被测目标与无关遮挡内容,登录流程增加登录后遮挡弹窗处理和无遮挡首页成功判定。
|
|
1220
|
+
|
|
1221
|
+
## v0.1.24
|
|
1222
|
+
|
|
1223
|
+
- 参数配置的运行配置新增保存校验,Android SDK 路径必须包含 `platform-tools/adb`,回放报告目录必须填写已存在且可写入的绝对路径。
|
|
1224
|
+
- Midscene 自定义提供方的 `Model Name` 和 `Model Family` 改为输入框,`Base URL` 匹配已知提供方时自动填充模型名称和模型系列,并在字段旁新增官方配置说明入口。
|
|
1225
|
+
|
|
1125
1226
|
## v0.1.23
|
|
1126
1227
|
|
|
1127
1228
|
- 常见问题拆分为独立 `FAQ.md` 文档,并在 README 的“功能文档”中增加跳转入口。
|
|
@@ -1139,10 +1240,6 @@ output/2026-08-21_17-13-42-831-登录流程.html
|
|
|
1139
1240
|
- 连接脚本回放时会跳过子脚本开始后的重复“启动 App”和“清除 App”操作,减少跨脚本连接时的重复初始化。
|
|
1140
1241
|
- 移除“连接到下一节点”“取消连接”“继续主流程”等冗余连接操作,保留复制节点作为主要复用方式。
|
|
1141
1242
|
- 修复放大面板后点击操作按钮或节点配置会重置缩放的问题,保持当前画布大小和位置。
|
|
1142
|
-
- Android Playground 改为后台启动 5800 服务,不再自动打开 `http://localhost:5800/` 浏览器窗口。
|
|
1143
|
-
- CLI 启动时输出 GitHub 和 Issues 地址,方便用户反馈问题和提交建议。
|
|
1144
|
-
- 常见问题新增桌面点击 App 录制说明:部分厂商系统无法获取桌面图标稳定 id,建议使用开始节点后的“启动 App”操作替代。
|
|
1145
|
-
- 补充 GitHub 仓库、问题反馈和项目主页信息。
|
|
1146
1243
|
|
|
1147
1244
|
## v0.1.20
|
|
1148
1245
|
|
package/package.json
CHANGED
package/server/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import YAML from 'yaml';
|
|
4
5
|
import { appDataPath } from './paths';
|
|
@@ -31,6 +32,14 @@ export type AppConfig = {
|
|
|
31
32
|
let cachedConfig: AppConfig | null = null;
|
|
32
33
|
const configPath = appDataPath('config.json');
|
|
33
34
|
const legacyConfigPath = appDataPath('config.yaml');
|
|
35
|
+
const adbFileName = process.platform === 'win32' ? 'adb.exe' : 'adb';
|
|
36
|
+
|
|
37
|
+
export class ConfigValidationError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'ConfigValidationError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
34
43
|
|
|
35
44
|
function defaultConfig(): AppConfig {
|
|
36
45
|
return {
|
|
@@ -95,6 +104,65 @@ function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConf
|
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
function expandRuntimePath(value: string) {
|
|
108
|
+
const trimmed = value.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
109
|
+
if (trimmed === '~') return os.homedir();
|
|
110
|
+
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
|
|
111
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
112
|
+
}
|
|
113
|
+
return path.isAbsolute(trimmed) ? path.normalize(trimmed) : appDataPath(trimmed);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isAbsoluteRuntimePath(value: string) {
|
|
117
|
+
const trimmed = value.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
118
|
+
return trimmed === '~'
|
|
119
|
+
|| trimmed.startsWith(`~${path.sep}`)
|
|
120
|
+
|| trimmed.startsWith('~/')
|
|
121
|
+
|| path.isAbsolute(trimmed);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateAndroidSdkPath(value: string) {
|
|
125
|
+
if (!value) return;
|
|
126
|
+
const candidate = expandRuntimePath(value);
|
|
127
|
+
const lowerBaseName = path.basename(candidate).toLowerCase();
|
|
128
|
+
const root = lowerBaseName === adbFileName.toLowerCase()
|
|
129
|
+
? path.dirname(path.dirname(candidate))
|
|
130
|
+
: lowerBaseName === 'platform-tools'
|
|
131
|
+
? path.dirname(candidate)
|
|
132
|
+
: candidate;
|
|
133
|
+
const adbPath = path.join(root, 'platform-tools', adbFileName);
|
|
134
|
+
if (!fs.existsSync(adbPath)) {
|
|
135
|
+
throw new ConfigValidationError(
|
|
136
|
+
`Android SDK 路径无效:${value}。请选择包含 platform-tools/${adbFileName} 的 SDK 目录。`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateReportOutputPath(value: string) {
|
|
142
|
+
if (!value) return;
|
|
143
|
+
if (!isAbsoluteRuntimePath(value)) {
|
|
144
|
+
throw new ConfigValidationError(`回放报告目录无效:${value}。请填写绝对路径,或留空使用默认 output 目录。`);
|
|
145
|
+
}
|
|
146
|
+
const outputDir = expandRuntimePath(value);
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(outputDir)) {
|
|
149
|
+
throw new Error('目录不存在');
|
|
150
|
+
}
|
|
151
|
+
if (!fs.statSync(outputDir).isDirectory()) {
|
|
152
|
+
throw new Error('不是目录');
|
|
153
|
+
}
|
|
154
|
+
fs.accessSync(outputDir, fs.constants.W_OK);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const detail = error instanceof Error && error.message ? `(${error.message})` : '';
|
|
157
|
+
throw new ConfigValidationError(`回放报告目录无效:${value}。请选择已经存在并可写入的目录${detail}。`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validateRuntimeConfig(config: AppConfig) {
|
|
162
|
+
validateAndroidSdkPath(config.runtime.androidSdkPath);
|
|
163
|
+
validateReportOutputPath(config.runtime.reportOutputPath);
|
|
164
|
+
}
|
|
165
|
+
|
|
98
166
|
export function loadConfig(): AppConfig {
|
|
99
167
|
if (cachedConfig) {
|
|
100
168
|
return cachedConfig;
|
|
@@ -126,7 +194,9 @@ export function loadConfig(): AppConfig {
|
|
|
126
194
|
}
|
|
127
195
|
|
|
128
196
|
export function saveConfig(config: AppConfig) {
|
|
129
|
-
|
|
197
|
+
const normalized = normalizeConfig(config);
|
|
198
|
+
validateRuntimeConfig(normalized);
|
|
199
|
+
cachedConfig = normalized;
|
|
130
200
|
saveModelConfigToDb(cachedConfig);
|
|
131
201
|
}
|
|
132
202
|
|
package/server/http-api.ts
CHANGED
|
@@ -9,12 +9,10 @@ type NextHandleFunction = (req: IncomingMessage, res: ServerResponse, next: Next
|
|
|
9
9
|
|
|
10
10
|
import { execFile, spawn } from 'node:child_process';
|
|
11
11
|
import { appPath } from './paths';
|
|
12
|
-
import { loadConfig, saveConfig, type AppConfig } from './config';
|
|
12
|
+
import { ConfigValidationError, loadConfig, saveConfig, type AppConfig } from './config';
|
|
13
13
|
import { listAppPresetRecords, removeAppPresetRecord, saveAppPresetRecord } from './config-store';
|
|
14
14
|
import { generatePlan } from './script-agent';
|
|
15
|
-
import { importMidsceneModelUsage } from './model-call-usage-importer';
|
|
16
15
|
import { testModelConnection } from './model-tester';
|
|
17
|
-
import { listModelUsageRecords, saveModelUsageRecord } from './model-usage-repository';
|
|
18
16
|
import { DeviceLockConflictError } from './device-locks/types';
|
|
19
17
|
import { acquireDeviceLock, listActiveDeviceLocks, releaseDeviceLock } from './device-locks/service';
|
|
20
18
|
import { ensureDeviceSession, listDeviceSessions } from './device-sessions/service';
|
|
@@ -95,12 +93,38 @@ function writeNdjson(res: ServerResponse, event: RunGeneratedScriptEvent | { typ
|
|
|
95
93
|
res.write(`${JSON.stringify(event)}\n`);
|
|
96
94
|
}
|
|
97
95
|
|
|
96
|
+
function delay(ms: number) {
|
|
97
|
+
return new Promise<void>((resolve) => {
|
|
98
|
+
setTimeout(resolve, ms);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Work around @midscene/android-playground v1.12.x ESM __dirname usage on Node 24.
|
|
98
103
|
const START_ANDROID_PLAYGROUND_WITHOUT_BROWSER = [
|
|
99
|
-
"import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
'
|
|
104
|
+
"import { createRequire } from 'node:module';",
|
|
105
|
+
'const require = createRequire(import.meta.url);',
|
|
106
|
+
"const { androidPlaygroundPlatform, ScrcpyServer } = require('@midscene/android-playground');",
|
|
107
|
+
"const { launchPreparedPlaygroundPlatform } = require('@midscene/playground');",
|
|
108
|
+
'const STABLE_PREVIEW_OPTIONS = {',
|
|
109
|
+
' maxFps: 15,',
|
|
110
|
+
' maxSize: 1024,',
|
|
111
|
+
' videoBitRate: 3000000,',
|
|
112
|
+
" videoCodecOptions: 'i-frame-interval=0',",
|
|
113
|
+
'};',
|
|
114
|
+
'async function main() {',
|
|
115
|
+
' const scrcpyServer = new ScrcpyServer();',
|
|
116
|
+
' const startScrcpy = scrcpyServer.startScrcpy.bind(scrcpyServer);',
|
|
117
|
+
' scrcpyServer.startScrcpy = (adb, options = {}, onProgress) => startScrcpy(adb, {',
|
|
118
|
+
' ...options,',
|
|
119
|
+
' ...STABLE_PREVIEW_OPTIONS,',
|
|
120
|
+
' }, onProgress);',
|
|
121
|
+
' const prepared = await androidPlaygroundPlatform.prepare({ scrcpyServer });',
|
|
122
|
+
' await launchPreparedPlaygroundPlatform(prepared);',
|
|
123
|
+
'}',
|
|
124
|
+
"main().catch((error) => {",
|
|
125
|
+
" console.error(error);",
|
|
126
|
+
" process.exit(1);",
|
|
127
|
+
"});",
|
|
104
128
|
].join('\n');
|
|
105
129
|
|
|
106
130
|
type AndroidDevice = {
|
|
@@ -458,6 +482,24 @@ async function proxyPlaygroundRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
458
482
|
return;
|
|
459
483
|
}
|
|
460
484
|
|
|
485
|
+
if (targetPath === '/mjpeg' && response.body) {
|
|
486
|
+
const reader = response.body.getReader();
|
|
487
|
+
req.once('close', () => {
|
|
488
|
+
reader.cancel().catch(() => undefined);
|
|
489
|
+
});
|
|
490
|
+
try {
|
|
491
|
+
while (true) {
|
|
492
|
+
const { done, value } = await reader.read();
|
|
493
|
+
if (done) break;
|
|
494
|
+
if (value) res.write(Buffer.from(value));
|
|
495
|
+
}
|
|
496
|
+
res.end();
|
|
497
|
+
} catch {
|
|
498
|
+
if (!res.writableEnded) res.end();
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
461
503
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
462
504
|
res.end(buffer);
|
|
463
505
|
}
|
|
@@ -557,6 +599,39 @@ export function createApiMiddleware() {
|
|
|
557
599
|
});
|
|
558
600
|
};
|
|
559
601
|
|
|
602
|
+
const stopAndroidPlayground = async () => {
|
|
603
|
+
const processToStop = playgroundProcess;
|
|
604
|
+
if (!processToStop || processToStop.killed) return;
|
|
605
|
+
|
|
606
|
+
await new Promise<void>((resolve) => {
|
|
607
|
+
const timeout = setTimeout(resolve, 1500);
|
|
608
|
+
processToStop.once('exit', () => {
|
|
609
|
+
clearTimeout(timeout);
|
|
610
|
+
resolve();
|
|
611
|
+
});
|
|
612
|
+
processToStop.kill('SIGTERM');
|
|
613
|
+
});
|
|
614
|
+
if (playgroundProcess === processToStop) {
|
|
615
|
+
playgroundProcess = null;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const waitForUsablePlaygroundTarget = async (selectedDeviceId: string, timeoutMs = 30000) => {
|
|
620
|
+
const deadline = Date.now() + timeoutMs;
|
|
621
|
+
let resolved: PlaygroundTarget = null;
|
|
622
|
+
while (Date.now() < deadline) {
|
|
623
|
+
resolved = await resolvePlaygroundUrl(selectedDeviceId);
|
|
624
|
+
if (resolved && selectedDeviceId && !isUsablePlaygroundTarget(resolved, selectedDeviceId) && !resolved.sessionConnected) {
|
|
625
|
+
resolved = await createPlaygroundSession(resolved, selectedDeviceId) || resolved;
|
|
626
|
+
}
|
|
627
|
+
if (isUsablePlaygroundTarget(resolved, selectedDeviceId)) {
|
|
628
|
+
return resolved;
|
|
629
|
+
}
|
|
630
|
+
await delay(500);
|
|
631
|
+
}
|
|
632
|
+
return resolved;
|
|
633
|
+
};
|
|
634
|
+
|
|
560
635
|
const handler: NextHandleFunction = async (req, res, next) => {
|
|
561
636
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
562
637
|
const pathname = requestUrl.pathname;
|
|
@@ -648,7 +723,7 @@ export function createApiMiddleware() {
|
|
|
648
723
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
649
724
|
res.end(JSON.stringify({ success: true }));
|
|
650
725
|
} catch (error) {
|
|
651
|
-
res.statusCode = 500;
|
|
726
|
+
res.statusCode = error instanceof ConfigValidationError ? 400 : 500;
|
|
652
727
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
653
728
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
654
729
|
}
|
|
@@ -947,16 +1022,53 @@ export function createApiMiddleware() {
|
|
|
947
1022
|
return;
|
|
948
1023
|
}
|
|
949
1024
|
|
|
1025
|
+
if (req.url === '/api/playground-restart' && req.method === 'POST') {
|
|
1026
|
+
try {
|
|
1027
|
+
const payload = await readBody<{ deviceId?: string }>(req);
|
|
1028
|
+
const deviceId = payload.deviceId || selectedDeviceId;
|
|
1029
|
+
if (!deviceId) {
|
|
1030
|
+
throw new Error('请先选择 Android 设备');
|
|
1031
|
+
}
|
|
1032
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
1033
|
+
throw new Error('远程设备不支持重启本地实时预览');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
setSelectedDeviceId(req, res, deviceId);
|
|
1037
|
+
selectedDeviceId = deviceId;
|
|
1038
|
+
playgroundEnsurePromise = null;
|
|
1039
|
+
lastPlaygroundEnsureAt = 0;
|
|
1040
|
+
lastPlaygroundEnsureDeviceId = '';
|
|
1041
|
+
await stopAndroidPlayground();
|
|
1042
|
+
const result = await ensureAndroidPlayground(deviceId);
|
|
1043
|
+
const target = result.target || await waitForUsablePlaygroundTarget(deviceId);
|
|
1044
|
+
|
|
1045
|
+
ensureDeviceSession({
|
|
1046
|
+
deviceId,
|
|
1047
|
+
patch: {
|
|
1048
|
+
playgroundUrl: target?.url || '',
|
|
1049
|
+
previewKind: target?.previewKind || '',
|
|
1050
|
+
sessionConnected: target?.sessionConnected || false,
|
|
1051
|
+
setupState: target?.setupState || result.skippedReason || '',
|
|
1052
|
+
previewError: target?.previewError || '',
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1056
|
+
res.end(JSON.stringify({
|
|
1057
|
+
success: true,
|
|
1058
|
+
started: result.started,
|
|
1059
|
+
available: isUsablePlaygroundTarget(target, deviceId),
|
|
1060
|
+
url: target?.url || '',
|
|
1061
|
+
skippedReason: result.skippedReason,
|
|
1062
|
+
}));
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
res.statusCode = 500;
|
|
1065
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1066
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '重启 Android Playground 失败' }));
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
950
1071
|
if (req.url === '/api/test-model' && req.method === 'POST') {
|
|
951
|
-
const startedAt = Date.now();
|
|
952
|
-
let modelKey: 'midscene' | 'scriptOptimizer' = 'midscene';
|
|
953
|
-
let model: {
|
|
954
|
-
provider?: 'custom' | 'codex';
|
|
955
|
-
baseUrl?: string;
|
|
956
|
-
apiKey?: string;
|
|
957
|
-
name?: string;
|
|
958
|
-
family?: string;
|
|
959
|
-
} = {};
|
|
960
1072
|
try {
|
|
961
1073
|
const parsed = await readBody<{
|
|
962
1074
|
modelKey?: 'midscene' | 'scriptOptimizer';
|
|
@@ -968,8 +1080,7 @@ export function createApiMiddleware() {
|
|
|
968
1080
|
family?: string;
|
|
969
1081
|
};
|
|
970
1082
|
}>(req);
|
|
971
|
-
model = parsed.model || {};
|
|
972
|
-
modelKey = parsed.modelKey === 'scriptOptimizer' ? 'scriptOptimizer' : 'midscene';
|
|
1083
|
+
const model = parsed.model || {};
|
|
973
1084
|
|
|
974
1085
|
const result = await testModelConnection({
|
|
975
1086
|
provider: model.provider,
|
|
@@ -978,42 +1089,8 @@ export function createApiMiddleware() {
|
|
|
978
1089
|
name: model.name || '',
|
|
979
1090
|
family: model.family || '',
|
|
980
1091
|
});
|
|
981
|
-
saveModelUsageRecord({
|
|
982
|
-
modelKey,
|
|
983
|
-
provider: modelKey === 'midscene' ? model.provider || 'custom' : 'custom',
|
|
984
|
-
modelName: model.name || '',
|
|
985
|
-
family: model.family || '',
|
|
986
|
-
success: true,
|
|
987
|
-
durationMs: result.durationMs ?? Date.now() - startedAt,
|
|
988
|
-
promptTokens: result.usage?.promptTokens,
|
|
989
|
-
completionTokens: result.usage?.completionTokens,
|
|
990
|
-
totalTokens: result.usage?.totalTokens,
|
|
991
|
-
});
|
|
992
1092
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
993
1093
|
res.end(JSON.stringify(result));
|
|
994
|
-
} catch (error) {
|
|
995
|
-
saveModelUsageRecord({
|
|
996
|
-
modelKey,
|
|
997
|
-
provider: modelKey === 'midscene' ? model.provider || 'custom' : 'custom',
|
|
998
|
-
modelName: model.name || '',
|
|
999
|
-
family: model.family || '',
|
|
1000
|
-
success: false,
|
|
1001
|
-
durationMs: Date.now() - startedAt,
|
|
1002
|
-
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
|
1003
|
-
});
|
|
1004
|
-
res.statusCode = 500;
|
|
1005
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1006
|
-
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1007
|
-
}
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
if (req.url?.startsWith('/api/model-usage-records') && req.method === 'GET') {
|
|
1012
|
-
try {
|
|
1013
|
-
const requestUrl = new URL(req.url, 'http://localhost');
|
|
1014
|
-
const limit = Number(requestUrl.searchParams.get('limit') || 50);
|
|
1015
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1016
|
-
res.end(JSON.stringify({ records: listModelUsageRecords(limit) }));
|
|
1017
1094
|
} catch (error) {
|
|
1018
1095
|
res.statusCode = 500;
|
|
1019
1096
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
@@ -1175,43 +1252,23 @@ export function createApiMiddleware() {
|
|
|
1175
1252
|
|
|
1176
1253
|
try {
|
|
1177
1254
|
if (req.url === '/api/generate' && req.method === 'POST') {
|
|
1178
|
-
const startedAt = Date.now();
|
|
1179
|
-
const config = loadConfig();
|
|
1180
1255
|
const parsed = await readBody<{
|
|
1181
1256
|
prompt?: string;
|
|
1182
1257
|
}>(req);
|
|
1258
|
+
const generateAbortController = new AbortController();
|
|
1259
|
+
res.on('close', () => {
|
|
1260
|
+
if (!res.writableEnded) {
|
|
1261
|
+
generateAbortController.abort();
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1183
1264
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
saveModelUsageRecord({
|
|
1190
|
-
modelKey: 'scriptOptimizer',
|
|
1191
|
-
provider: 'custom',
|
|
1192
|
-
modelName: config.scriptOptimizer.model.name || '',
|
|
1193
|
-
family: '',
|
|
1194
|
-
success: true,
|
|
1195
|
-
durationMs: result.durationMs || Date.now() - startedAt,
|
|
1196
|
-
promptTokens: result.usage?.promptTokens,
|
|
1197
|
-
completionTokens: result.usage?.completionTokens,
|
|
1198
|
-
totalTokens: result.usage?.totalTokens,
|
|
1199
|
-
});
|
|
1265
|
+
const result = await generatePlan({
|
|
1266
|
+
prompt: parsed.prompt || '',
|
|
1267
|
+
signal: generateAbortController.signal,
|
|
1268
|
+
});
|
|
1200
1269
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
} catch (error) {
|
|
1204
|
-
saveModelUsageRecord({
|
|
1205
|
-
modelKey: 'scriptOptimizer',
|
|
1206
|
-
provider: 'custom',
|
|
1207
|
-
modelName: config.scriptOptimizer.model.name || '',
|
|
1208
|
-
family: '',
|
|
1209
|
-
success: false,
|
|
1210
|
-
durationMs: Date.now() - startedAt,
|
|
1211
|
-
errorMessage: error instanceof Error ? error.message : 'AI 生成失败',
|
|
1212
|
-
});
|
|
1213
|
-
throw error;
|
|
1214
|
-
}
|
|
1270
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1271
|
+
res.end(JSON.stringify(result));
|
|
1215
1272
|
return;
|
|
1216
1273
|
}
|
|
1217
1274
|
|
|
@@ -1281,7 +1338,6 @@ export function createApiMiddleware() {
|
|
|
1281
1338
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
1282
1339
|
writeNdjson(res, { type: 'operation', operation });
|
|
1283
1340
|
|
|
1284
|
-
const usageImportSinceMs = Date.now();
|
|
1285
1341
|
try {
|
|
1286
1342
|
const result = await runGeneratedScript({
|
|
1287
1343
|
code: parsed.code || '',
|
|
@@ -1316,26 +1372,6 @@ export function createApiMiddleware() {
|
|
|
1316
1372
|
writeNdjson(res, event);
|
|
1317
1373
|
},
|
|
1318
1374
|
});
|
|
1319
|
-
try {
|
|
1320
|
-
const usageImportResult = importMidsceneModelUsage({
|
|
1321
|
-
sinceMs: usageImportSinceMs,
|
|
1322
|
-
model: loadConfig().midscene.model,
|
|
1323
|
-
success: result.success,
|
|
1324
|
-
});
|
|
1325
|
-
if (usageImportResult.imported > 0) {
|
|
1326
|
-
recordOperationEvent({
|
|
1327
|
-
operationId: operation.id,
|
|
1328
|
-
eventType: 'model_usage_imported',
|
|
1329
|
-
data: usageImportResult,
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
} catch (error) {
|
|
1333
|
-
recordOperationEvent({
|
|
1334
|
-
operationId: operation.id,
|
|
1335
|
-
eventType: 'model_usage_import_failed',
|
|
1336
|
-
message: error instanceof Error ? error.message : '模型消耗导入失败',
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
1375
|
finishScriptRunOperation({
|
|
1340
1376
|
operationId: operation.id,
|
|
1341
1377
|
status: result.success ? 'succeeded' : runAbortController.signal.aborted ? 'cancelled' : 'failed',
|