android-midscene-automation 0.1.27 → 0.1.28

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +10 -2
  2. package/README.md +10 -2
  3. package/package.json +4 -2
  4. package/remote-agent/index.ts +2 -11
  5. package/server/appium-recorder/ai-recognition.ts +67 -0
  6. package/server/appium-recorder/appium-runner.ts +86 -48
  7. package/server/appium-recorder/report.ts +114 -16
  8. package/server/appium-recorder/repository.ts +7 -1
  9. package/server/appium-recorder/routes.ts +31 -32
  10. package/server/appium-recorder/screenshot.ts +21 -0
  11. package/server/appium-recorder/script-export.ts +67 -0
  12. package/server/appium-recorder/tree-dump.ts +22 -0
  13. package/server/config.ts +10 -0
  14. package/server/http-api.ts +1 -1
  15. package/src/App.vue +14 -7
  16. package/src/api.ts +1 -1
  17. package/src/appium-recorder/AppiumPage.vue +195 -25
  18. package/src/appium-recorder/ai-recognition.ts +16 -0
  19. package/src/appium-recorder/api.ts +19 -3
  20. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  21. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  22. package/src/appium-recorder/components/FlowCanvas.vue +2 -0
  23. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  24. package/src/appium-recorder/components/FlowStepEditor.vue +36 -28
  25. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  26. package/src/appium-recorder/components/NestedConditionBranches.vue +31 -33
  27. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  28. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  29. package/src/appium-recorder/components/RecordedSteps.vue +12 -4
  30. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  31. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  32. package/src/appium-recorder/flow-graph.ts +52 -64
  33. package/src/appium-recorder/flow-labels.ts +29 -5
  34. package/src/appium-recorder/flow-normalize.ts +11 -2
  35. package/src/appium-recorder/flow-remove.ts +23 -21
  36. package/src/appium-recorder/long-press.ts +27 -0
  37. package/src/appium-recorder/native-control-state.ts +26 -0
  38. package/src/appium-recorder/stage-log.ts +21 -0
  39. package/src/appium-recorder/text-click.ts +9 -0
  40. package/src/appium-recorder/tree.ts +3 -0
  41. package/src/appium-recorder/types.ts +9 -1
  42. package/src/components/config/AppiumConfigPanel.vue +48 -0
  43. package/src/components/config/BaseConfigPanel.vue +90 -0
  44. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  45. package/src/pages/ConfigPage.vue +107 -265
  46. package/src/style.css +4 -4
  47. package/src/types.ts +2 -0
  48. package/src/ui-refresh.css +5 -0
@@ -1,8 +1,10 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { PNG } from 'pngjs';
3
4
  import { appDataPath } from '../paths';
4
5
  import { loadConfig } from '../config';
5
6
  import { saveAppiumReplayReport, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
7
+ import { flowBranchLabel } from '../../src/appium-recorder/flow-labels';
6
8
 
7
9
  export type AppiumReplayFrame = {
8
10
  sequence: number;
@@ -11,6 +13,7 @@ export type AppiumReplayFrame = {
11
13
  nodeNumber: number;
12
14
  nodeLabel: string;
13
15
  nodeType: string;
16
+ logContent?: string;
14
17
  note: string;
15
18
  selector: string;
16
19
  phase: 'before' | 'after' | 'error' | 'stopped';
@@ -45,6 +48,9 @@ export type AppiumReplayVisualCheck = {
45
48
  diffBase64: string;
46
49
  };
47
50
 
51
+ const HTML_REPORT_IMAGE_MAX_WIDTH = 540;
52
+ const HTML_REPORT_IMAGE_MAX_HEIGHT = 1200;
53
+
48
54
  function pad(value: number, length = 2) {
49
55
  return String(value).padStart(length, '0');
50
56
  }
@@ -81,6 +87,54 @@ function jsonForHtml(value: unknown) {
81
87
  .replace(/&/g, '\\u0026');
82
88
  }
83
89
 
90
+ function compressHtmlPngBase64(base64: string) {
91
+ if (!base64) return base64;
92
+
93
+ try {
94
+ const source = PNG.sync.read(Buffer.from(base64, 'base64'));
95
+ const scale = Math.min(
96
+ 1,
97
+ HTML_REPORT_IMAGE_MAX_WIDTH / source.width,
98
+ HTML_REPORT_IMAGE_MAX_HEIGHT / source.height,
99
+ );
100
+
101
+ if (scale >= 1) return base64;
102
+
103
+ const width = Math.max(1, Math.round(source.width * scale));
104
+ const height = Math.max(1, Math.round(source.height * scale));
105
+ const target = new PNG({ width, height });
106
+ const scaleX = source.width / width;
107
+ const scaleY = source.height / height;
108
+
109
+ // HTML 回放只用于预览,降采样能明显降低报告体积,不影响实际检测结果。
110
+ for (let y = 0; y < height; y += 1) {
111
+ const sourceY = Math.min(source.height - 1, Math.floor(y * scaleY));
112
+ for (let x = 0; x < width; x += 1) {
113
+ const sourceX = Math.min(source.width - 1, Math.floor(x * scaleX));
114
+ const sourceIndex = (sourceY * source.width + sourceX) << 2;
115
+ const targetIndex = (y * width + x) << 2;
116
+ target.data[targetIndex] = source.data[sourceIndex];
117
+ target.data[targetIndex + 1] = source.data[sourceIndex + 1];
118
+ target.data[targetIndex + 2] = source.data[sourceIndex + 2];
119
+ target.data[targetIndex + 3] = source.data[sourceIndex + 3];
120
+ }
121
+ }
122
+
123
+ return PNG.sync.write(target).toString('base64');
124
+ } catch {
125
+ return base64;
126
+ }
127
+ }
128
+
129
+ function htmlImageUrl(base64: string, cache: Map<string, string>) {
130
+ const cached = cache.get(base64);
131
+ if (cached) return cached;
132
+
133
+ const imageUrl = `data:image/png;base64,${compressHtmlPngBase64(base64)}`;
134
+ cache.set(base64, imageUrl);
135
+ return imageUrl;
136
+ }
137
+
84
138
  function markdownValue(value: unknown) {
85
139
  if (value === undefined || value === null || value === '') return '-';
86
140
  return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
@@ -99,10 +153,12 @@ function stepExecutionLines(outputLines: string[], index: number) {
99
153
 
100
154
  function executionStatus(lines: string[]) {
101
155
  if (!lines.length) return '未执行';
102
- if (lines.some((line) => line.includes('失败:') || line.includes('失败分支:'))) return '失败';
103
- if (lines.some((line) => line.includes('跳过:'))) return '跳过';
104
- if (lines.some((line) => line.includes('判断:'))) return '判断完成';
105
- if (lines.some((line) => line.includes('完成:'))) return '成功';
156
+ // 只识别执行器的状态前缀,用户日志中的“失败”等文字不是执行结果。
157
+ const messages = lines.map((line) => line.replace(/^\[(?:节点|步骤) \d+\] /, ''));
158
+ if (messages.some((line) => line.startsWith('失败:') || line.startsWith('失败分支:'))) return '失败';
159
+ if (messages.some((line) => line.startsWith('跳过:'))) return '跳过';
160
+ if (messages.some((line) => line.startsWith('判断:'))) return '判断完成';
161
+ if (messages.some((line) => line.startsWith('完成:'))) return '成功';
106
162
  return '中断';
107
163
  }
108
164
 
@@ -157,9 +213,8 @@ function stepSection(
157
213
  `| 上下文 Selector | ${markdownValue(selectorText(step.contextSelector))} |`,
158
214
  `| 输入/目标值 | ${markdownValue(step.value)} |`,
159
215
  `| 超时时间 | ${markdownValue(step.timeoutMs === undefined ? '-' : `${step.timeoutMs}ms`)} |`,
160
- `| 可选步骤 | ${step.optional ? '是' : '否'} |`,
161
- `| 是分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
162
- `| 否分支 | ${markdownValue(step.flow?.noTargetId)} |`,
216
+ `| ${flowBranchLabel(step, 'yes')} 分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
217
+ `| ${flowBranchLabel(step, 'no')} 分支 | ${markdownValue(step.flow?.noTargetId)} |`,
163
218
  `| 成功后续节点 | ${markdownValue(step.flow?.successTargetId)} |`,
164
219
  `| 执行状态 | **${executionStatus(executionLines)}** |`,
165
220
  '',
@@ -192,19 +247,20 @@ function createReplayHtml(input: {
192
247
  visualChecks: AppiumReplayVisualCheck[];
193
248
  output: string;
194
249
  }) {
250
+ const imageCache = new Map<string, string>();
195
251
  const payload = jsonForHtml({
196
252
  startedAt: input.startedAt.toISOString(),
197
253
  durationMs: input.durationMs,
198
254
  frames: input.frames.map((frame) => ({
199
255
  ...frame,
200
- imageUrl: `data:image/png;base64,${frame.imageBase64}`,
256
+ imageUrl: htmlImageUrl(frame.imageBase64, imageCache),
201
257
  imageBase64: undefined,
202
258
  })),
203
259
  visualChecks: input.visualChecks.map((check) => ({
204
260
  ...check,
205
- baselineImageUrl: `data:image/png;base64,${check.baselineBase64}`,
206
- comparisonImageUrl: `data:image/png;base64,${check.comparisonBase64}`,
207
- diffImageUrl: `data:image/png;base64,${check.diffBase64}`,
261
+ baselineImageUrl: htmlImageUrl(check.baselineBase64, imageCache),
262
+ comparisonImageUrl: htmlImageUrl(check.comparisonBase64, imageCache),
263
+ diffImageUrl: htmlImageUrl(check.diffBase64, imageCache),
208
264
  baselineBase64: undefined,
209
265
  comparisonBase64: undefined,
210
266
  diffBase64: undefined,
@@ -246,6 +302,8 @@ function createReplayHtml(input: {
246
302
  .frame-title, .frame-subtitle { display: block; overflow-wrap: anywhere; }
247
303
  .frame-title { font-size: 13px; font-weight: 600; }
248
304
  .frame-subtitle, .frame-time { margin-top: 3px; color: #7a818b; font-size: 11px; }
305
+ .frame-log, #node-log { white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
306
+ .frame-log { display: block; margin-top: 6px; color: #4b515a; font-size: 12px; line-height: 1.5; }
249
307
  .workspace { display: grid; grid-template-rows: 178px minmax(0, 1fr); min-width: 0; min-height: 0; }
250
308
  .timeline-panel { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid #dfe3e8; background: #fff; }
251
309
  .timeline-track { position: relative; height: 178px; min-width: 100%; cursor: pointer; user-select: none; }
@@ -322,6 +380,7 @@ function createReplayHtml(input: {
322
380
  <dt>节点</dt><dd id="node">-</dd>
323
381
  <dt>阶段</dt><dd id="phase">-</dd>
324
382
  <dt>结果</dt><dd id="frame-status">-</dd>
383
+ <dt id="node-log-label" hidden>日志内容</dt><dd id="node-log" hidden></dd>
325
384
  <dt>备注</dt><dd id="note">-</dd>
326
385
  <dt>定位</dt><dd id="selector">-</dd>
327
386
  <dt>时间</dt><dd id="time">-</dd>
@@ -368,6 +427,24 @@ function createReplayHtml(input: {
368
427
  offsetMs: Math.max(0, (Date.parse(frame.capturedAt) || reportStartedAt) - reportStartedAt),
369
428
  }));
370
429
  const durationMs = Math.max(1000, Number(data.durationMs) || 0, frames.at(-1)?.offsetMs || 0);
430
+ // 按一次执行配对,而不是按节点 ID 全局去重;连接脚本和重复执行仍保留独立步骤。
431
+ const steps = [];
432
+ const frameSteps = [];
433
+ const pendingSteps = new Map();
434
+ frames.forEach((frame, index) => {
435
+ const key = JSON.stringify([frame.scriptName, frame.nodeId, frame.nodeNumber]);
436
+ const pending = pendingSteps.get(key) || [];
437
+ let stepIndex = frame.phase === 'before' ? undefined : pending.pop();
438
+ if (stepIndex === undefined) {
439
+ stepIndex = steps.length;
440
+ steps.push({ firstIndex: index, lastIndex: index });
441
+ } else {
442
+ steps[stepIndex].lastIndex = index;
443
+ }
444
+ if (frame.phase === 'before') pending.push(stepIndex);
445
+ pendingSteps.set(key, pending);
446
+ frameSteps[index] = stepIndex;
447
+ });
371
448
  const elements = Object.fromEntries(['screen','empty','previous','play','next','seek','clock','caption','title','script','node','phase','frame-status','note','selector','time','step-list','timeline-track','ruler','playhead','visual-checks','log'].map((id) => [id, document.querySelector('#' + id)]));
372
449
  const imagePreview = document.querySelector('#image-preview');
373
450
  const imagePreviewImage = document.querySelector('#image-preview-image');
@@ -387,6 +464,7 @@ function createReplayHtml(input: {
387
464
  let animationFrame = 0;
388
465
  let playbackStartedAt = 0;
389
466
  const phaseLabels = { before: '执行前', after: '执行后', error: '失败', stopped: '已终止' };
467
+ const visualCheckStatusLabel = (status) => status === 'passed' ? '有变化' : '无明显变化';
390
468
  const formatTime = (milliseconds) => {
391
469
  const seconds = Math.max(0, Math.floor(milliseconds / 1000));
392
470
  return String(Math.floor(seconds / 60)).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
@@ -410,6 +488,10 @@ function createReplayHtml(input: {
410
488
  elements.node.textContent = String(frame.nodeNumber || '-');
411
489
  elements.phase.textContent = phaseLabels[frame.phase] || frame.phase || '-';
412
490
  elements['frame-status'].textContent = frame.status || '-';
491
+ const logContent = frames[steps[frameSteps[currentFrame]].lastIndex].logContent;
492
+ document.querySelector('#node-log-label').hidden = !logContent;
493
+ document.querySelector('#node-log').hidden = !logContent;
494
+ document.querySelector('#node-log').textContent = logContent || '';
413
495
  elements.note.textContent = frame.note || '-';
414
496
  elements.selector.textContent = frame.selector || '-';
415
497
  elements.time.textContent = new Date(frame.capturedAt).toLocaleString();
@@ -421,7 +503,8 @@ function createReplayHtml(input: {
421
503
  elements.seek.value = String(Math.round(currentMs / durationMs * 1000));
422
504
  elements.clock.textContent = formatTime(currentMs) + ' / ' + formatTime(durationMs);
423
505
  elements.playhead.style.left = (currentMs / durationMs * 100) + '%';
424
- document.querySelectorAll('.frame-item, .thumbnail').forEach((item) => item.classList.toggle('active', Number(item.dataset.index) === currentFrame));
506
+ document.querySelectorAll('.frame-item').forEach((item) => item.classList.toggle('active', Number(item.dataset.step) === frameSteps[currentFrame]));
507
+ document.querySelectorAll('.thumbnail').forEach((item) => item.classList.toggle('active', Number(item.dataset.index) === currentFrame));
425
508
  if (scrollStep) document.querySelector('.frame-item.active')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
426
509
  };
427
510
  const pause = () => {
@@ -453,11 +536,13 @@ function createReplayHtml(input: {
453
536
  tick.append(label);
454
537
  elements.ruler.append(tick);
455
538
  }
456
- frames.forEach((frame, index) => {
539
+ steps.forEach((step, index) => {
540
+ const frame = frames[step.firstIndex];
541
+ const result = frames[step.lastIndex];
457
542
  const button = document.createElement('button');
458
543
  button.type = 'button';
459
544
  button.className = 'frame-item';
460
- button.dataset.index = String(index);
545
+ button.dataset.step = String(index);
461
546
  const badge = document.createElement('span');
462
547
  badge.className = 'frame-index';
463
548
  badge.textContent = String(index + 1);
@@ -467,15 +552,28 @@ function createReplayHtml(input: {
467
552
  title.textContent = frame.nodeNumber + '. ' + frame.nodeLabel;
468
553
  const subtitle = document.createElement('span');
469
554
  subtitle.className = 'frame-subtitle';
470
- subtitle.textContent = (phaseLabels[frame.phase] || frame.phase) + ' · ' + frame.status;
555
+ subtitle.textContent = result.phase === 'before' ? '未完成' : result.status;
471
556
  content.append(title, subtitle);
557
+ if (frame.nodeType === 'textClick') {
558
+ const text = document.createElement('span');
559
+ text.className = 'frame-log frame-click-text';
560
+ text.textContent = frame.selector || '未设置点击文字';
561
+ content.append(text);
562
+ }
563
+ if (result.logContent) {
564
+ const log = document.createElement('span');
565
+ log.className = 'frame-log';
566
+ log.textContent = result.logContent;
567
+ content.append(log);
568
+ }
472
569
  const time = document.createElement('span');
473
570
  time.className = 'frame-time';
474
571
  time.textContent = formatTime(frame.offsetMs);
475
572
  button.append(badge, content, time);
476
573
  button.addEventListener('click', () => { pause(); seekTo(frame.offsetMs); });
477
574
  elements['step-list'].append(button);
478
-
575
+ });
576
+ frames.forEach((frame, index) => {
479
577
  const thumbnail = document.createElement('button');
480
578
  thumbnail.type = 'button';
481
579
  thumbnail.className = 'thumbnail';
@@ -19,6 +19,10 @@ export type AppiumRecordedStepRecord = {
19
19
  | 'backIfExists'
20
20
  | 'waitFor'
21
21
  | 'assertExists'
22
+ | 'checkboxState'
23
+ | 'radioButtonState'
24
+ | 'aiRecognition'
25
+ | 'textClick'
22
26
  | 'key'
23
27
  | 'waitActivity'
24
28
  | 'delay'
@@ -34,6 +38,7 @@ export type AppiumRecordedStepRecord = {
34
38
  | 'pinch'
35
39
  | 'runScript'
36
40
  | 'noop'
41
+ | 'log'
37
42
  | 'visualChange';
38
43
  label: string;
39
44
  note?: string;
@@ -74,9 +79,10 @@ export type AppiumRecordedStepRecord = {
74
79
  source?: 'node' | 'ancestor' | 'fallback';
75
80
  };
76
81
  value?: string;
77
- optional?: boolean;
82
+ logPrefix?: string;
78
83
  keyCode?: number;
79
84
  timeoutMs?: number;
85
+ longPressMode?: 'element' | 'coordinates';
80
86
  flow?: {
81
87
  nodeKind?: 'action' | 'condition' | 'assertion';
82
88
  yesTargetId?: string;
@@ -1,7 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
- import fs from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
2
+ import { recognizeDeviceScreen } from './ai-recognition';
3
+ import { readFreshWindowHierarchy } from './tree-dump';
5
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
5
  import {
7
6
  deleteAppiumRecordedScript,
@@ -14,6 +13,7 @@ import {
14
13
  import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
15
14
  import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
16
15
  import { getAdbCommand } from '../android-sdk';
16
+ import { createAppiumScriptExport } from './script-export';
17
17
 
18
18
  const treeDumpTasks = new Map<string, Promise<string>>();
19
19
  const replayingDevices = new Set<string>();
@@ -25,13 +25,6 @@ function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
25
25
  res.end(JSON.stringify(payload));
26
26
  }
27
27
 
28
- function sendJsonDownload(res: ServerResponse, payload: unknown, fileName: string) {
29
- res.statusCode = 200;
30
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
31
- res.setHeader('Content-Disposition', `attachment; filename="appium-script.json"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
32
- res.end(JSON.stringify(payload, null, 2));
33
- }
34
-
35
28
  function sendStreamEvent(res: ServerResponse, payload: unknown) {
36
29
  res.write(`${JSON.stringify(payload)}\n`);
37
30
  }
@@ -64,17 +57,7 @@ async function dumpWindowHierarchy(deviceId: string) {
64
57
  const activeTask = treeDumpTasks.get(deviceId);
65
58
  if (activeTask) return activeTask;
66
59
 
67
- const task = (async () => {
68
- const localPath = path.join(os.tmpdir(), `midscene-appium-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
69
- const remotePath = '/data/local/tmp/midscene_appium_uidump.xml';
70
- await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
71
- await execFileText(getAdbCommand(), ['-s', deviceId, 'pull', remotePath, localPath]);
72
- try {
73
- return await fs.readFile(localPath, 'utf8');
74
- } finally {
75
- await fs.unlink(localPath).catch(() => undefined);
76
- }
77
- })();
60
+ const task = readFreshWindowHierarchy(deviceId, getAdbCommand(), execFileText);
78
61
  treeDumpTasks.set(deviceId, task);
79
62
  try {
80
63
  return await task;
@@ -134,6 +117,7 @@ export async function handleAppiumRecorderRequest(
134
117
  };
135
118
 
136
119
  if (pathname === '/api/appium-recorder/tree' && req.method === 'GET') {
120
+ res.setHeader('Cache-Control', 'no-store');
137
121
  const deviceId = requestUrl.searchParams.get('deviceId')?.trim() || selectedDeviceId;
138
122
  if (!deviceId) throw new Error('未检测到可用设备');
139
123
  assertDeviceAllowed(deviceId);
@@ -192,6 +176,24 @@ export async function handleAppiumRecorderRequest(
192
176
  return true;
193
177
  }
194
178
 
179
+ if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
180
+ const parsed = await readBody<{ deviceId?: string; prompt?: unknown; timeoutMs?: number }>(req);
181
+ const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
182
+ if (!deviceId) throw new Error('请选择设备');
183
+ assertDeviceAllowed(deviceId);
184
+ if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持 AI 识别测试');
185
+ if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,请稍后测试' }, 409); return true; }
186
+ const controller = new AbortController();
187
+ const abort = () => controller.abort();
188
+ res.once('close', abort);
189
+ try {
190
+ sendJson(res, await recognizeDeviceScreen({ deviceId, prompt: parsed.prompt, timeoutMs: parsed.timeoutMs, signal: controller.signal }));
191
+ } finally {
192
+ res.off('close', abort);
193
+ }
194
+ return true;
195
+ }
196
+
195
197
  if (pathname === '/api/appium-recorder/launch-app' && req.method === 'POST') {
196
198
  const parsed = await readBody<{ deviceId?: string; packageName?: string }>(req);
197
199
  const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
@@ -277,17 +279,14 @@ export async function handleAppiumRecorderRequest(
277
279
  if (exportMatch && req.method === 'GET') {
278
280
  const script = getAppiumRecordedScript(decodeURIComponent(exportMatch[1]));
279
281
  if (!script) throw new Error('Appium 录制脚本不存在');
280
- sendJsonDownload(res, {
281
- schemaVersion: 1,
282
- exportedAt: new Date().toISOString(),
283
- script: {
284
- name: script.name,
285
- appPackage: script.appPackage,
286
- appActivity: script.appActivity,
287
- deviceId: script.deviceId,
288
- steps: script.steps,
289
- },
290
- }, `${script.name}.json`);
282
+ const exported = await createAppiumScriptExport(script, getAppiumRecordedScript);
283
+ const fileName = encodeURIComponent(exported.fileName)
284
+ .replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
285
+ res.statusCode = 200;
286
+ res.setHeader('Content-Type', exported.contentType);
287
+ res.setHeader('Content-Disposition', `attachment; filename="appium-script.${exported.contentType === 'application/zip' ? 'zip' : 'json'}"; filename*=UTF-8''${fileName}`);
288
+ res.setHeader('Content-Length', exported.body.length);
289
+ res.end(exported.body);
291
290
  return true;
292
291
  }
293
292
 
@@ -0,0 +1,21 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getAdbCommand } from '../android-sdk';
3
+
4
+ // 直接读取设备截图,不使用预览缓存,也不进入 Appium 命令队列。
5
+ export function adbScreenshotBase64(deviceId: string, signal?: AbortSignal) {
6
+ return new Promise<string>((resolve, reject) => {
7
+ execFile(
8
+ getAdbCommand(),
9
+ ['-s', deviceId, 'exec-out', 'screencap', '-p'],
10
+ { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024, timeout: 8000, signal },
11
+ (error, stdout, stderr) => {
12
+ if (error) {
13
+ reject(new Error(stderr?.toString('utf8').trim() || error.message || 'ADB 截图失败'));
14
+ return;
15
+ }
16
+ if (!stdout.length) { reject(new Error('ADB 未返回截图内容')); return; }
17
+ resolve(stdout.toString('base64'));
18
+ },
19
+ );
20
+ });
21
+ }
@@ -0,0 +1,67 @@
1
+ import { buffer } from 'node:stream/consumers';
2
+ import archiver from 'archiver';
3
+ import type { AppiumRecordedScriptRecord } from './repository';
4
+
5
+ function safeExportName(name: string) {
6
+ const base = Array.from(name.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_'))
7
+ .slice(0, 80).join('').replace(/[. ]+$/, '') || 'script';
8
+ return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(base) ? `_${base}` : base;
9
+ }
10
+
11
+ export async function createAppiumScriptExport(
12
+ root: AppiumRecordedScriptRecord,
13
+ getScript: (id: string) => AppiumRecordedScriptRecord | null,
14
+ ) {
15
+ const scripts = [root];
16
+ const visited = new Set([root.id]);
17
+ // 按脚本 ID 去重,既保留所有分支的依赖,也避免重复引用和循环连接无限展开。
18
+ for (const script of scripts) {
19
+ for (const step of script.steps) {
20
+ if (step.type !== 'runScript') continue;
21
+ const targetId = step.value || '';
22
+ if (!targetId) throw new Error(`脚本「${script.name}」的「${step.label}」缺少连接脚本 ID,无法导出`);
23
+ if (visited.has(targetId)) continue;
24
+ const target = getScript(targetId);
25
+ if (!target) throw new Error(`脚本「${script.name}」连接的脚本不存在(${targetId}),无法导出完整脚本包`);
26
+ visited.add(targetId);
27
+ scripts.push(target);
28
+ }
29
+ }
30
+
31
+ const exportedAt = new Date().toISOString();
32
+ const serialize = (script: AppiumRecordedScriptRecord) => JSON.stringify({
33
+ schemaVersion: 1,
34
+ exportedAt,
35
+ script: {
36
+ id: script.id,
37
+ name: script.name,
38
+ appPackage: script.appPackage,
39
+ appActivity: script.appActivity,
40
+ deviceId: script.deviceId,
41
+ steps: script.steps,
42
+ },
43
+ }, null, 2);
44
+ const baseName = safeExportName(root.name);
45
+ if (!root.steps.some((step) => step.type === 'runScript')) {
46
+ return {
47
+ fileName: `${baseName}.json`,
48
+ contentType: 'application/json; charset=utf-8',
49
+ body: Buffer.from(serialize(root)),
50
+ };
51
+ }
52
+
53
+ const archive = archiver('zip', { zlib: { level: 9 } });
54
+ const usedNames = new Set<string>();
55
+ for (const script of scripts) {
56
+ const name = safeExportName(script.name);
57
+ let fileName = `${name}.json`;
58
+ let suffix = 2;
59
+ // 清理非法字符后可能重名,Windows 解压时还需忽略大小写进行去重。
60
+ while (usedNames.has(fileName.toLowerCase())) fileName = `${name} (${suffix++}).json`;
61
+ usedNames.add(fileName.toLowerCase());
62
+ archive.append(serialize(script), { name: fileName });
63
+ }
64
+ // 生成完整压缩包后再发送响应,压缩失败仍可返回普通 JSON 错误。
65
+ const [body] = await Promise.all([buffer(archive), archive.finalize()]);
66
+ return { fileName: `${baseName}.zip`, contentType: 'application/zip', body };
67
+ }
@@ -0,0 +1,22 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ type ExecText = (command: string, args: string[]) => Promise<string>;
4
+
5
+ export async function readFreshWindowHierarchy(deviceId: string, adb: string, execText: ExecText) {
6
+ // dump 失败可能仍以 0 退出。每次独立文件,避免误读上一次成功抓取的树。
7
+ const remotePath = `/data/local/tmp/midscene_uidump_${randomUUID()}.xml`;
8
+ const run = (args: string[]) => execText(adb, ['-s', deviceId, ...args]);
9
+ try {
10
+ const output = await run(['shell', 'uiautomator', 'dump', remotePath]);
11
+ if (/ERROR:|could not|failed/i.test(output)) throw new Error(output.trim());
12
+ const xml = await run(['shell', 'cat', remotePath]);
13
+ if (!xml.includes('<hierarchy') || !xml.includes('</hierarchy>')) {
14
+ throw new Error('设备未返回有效的组件树');
15
+ }
16
+ return xml;
17
+ } catch (error) {
18
+ throw new Error(`刷新组件树失败:${error instanceof Error ? error.message : String(error)}`);
19
+ } finally {
20
+ await run(['shell', 'rm', '-f', remotePath]).catch(() => undefined);
21
+ }
22
+ }
package/server/config.ts CHANGED
@@ -4,8 +4,10 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { appDataPath } from './paths';
6
6
  import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
7
+ import type { AiRecognitionModel } from '../src/appium-recorder/ai-recognition';
7
8
 
8
9
  export type AppConfig = {
10
+ appium: { model: AiRecognitionModel };
9
11
  runtime: {
10
12
  androidSdkPath: string;
11
13
  reportOutputPath: string;
@@ -43,6 +45,7 @@ export class ConfigValidationError extends Error {
43
45
 
44
46
  function defaultConfig(): AppConfig {
45
47
  return {
48
+ appium: { model: { baseUrl: '', apiKey: '', name: '' } },
46
49
  runtime: {
47
50
  androidSdkPath: '',
48
51
  reportOutputPath: '',
@@ -78,6 +81,13 @@ function normalizeEnv(env: unknown): Record<string, string> {
78
81
  function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConfig {
79
82
  const fallback = defaultConfig();
80
83
  return {
84
+ // 旧配置没有 Appium 模型时保留空值,不借用其他模型或改变已有配置。
85
+ appium: {
86
+ model: Object.fromEntries(['baseUrl', 'apiKey', 'name'].map((key) => {
87
+ const value = config?.appium?.model?.[key as keyof AiRecognitionModel];
88
+ return [key, typeof value === 'string' ? value.trim() : ''];
89
+ })) as AiRecognitionModel,
90
+ },
81
91
  runtime: {
82
92
  androidSdkPath: config?.runtime?.androidSdkPath?.trim() || fallback.runtime.androidSdkPath,
83
93
  reportOutputPath: config?.runtime?.reportOutputPath?.trim() || fallback.runtime.reportOutputPath,
@@ -1070,7 +1070,7 @@ export function createApiMiddleware() {
1070
1070
  if (req.url === '/api/test-model' && req.method === 'POST') {
1071
1071
  try {
1072
1072
  const parsed = await readBody<{
1073
- modelKey?: 'midscene' | 'scriptOptimizer';
1073
+ modelKey?: 'midscene' | 'scriptOptimizer' | 'appium';
1074
1074
  model?: {
1075
1075
  provider?: 'custom' | 'codex';
1076
1076
  baseUrl?: string;
package/src/App.vue CHANGED
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
2
3
  import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
3
4
  import { ElMessage, ElMessageBox } from 'element-plus';
4
5
  import {
@@ -99,7 +100,9 @@ const appPresets = ref<AppPreset[]>([]);
99
100
  const modelTestStatus = reactive({
100
101
  midscene: '',
101
102
  scriptOptimizer: '',
103
+ appium: '',
102
104
  });
105
+ const aiRecognitionModelConfigured = shallowRef(false);
103
106
  const actionDialog = reactive({
104
107
  visible: false,
105
108
  title: '',
@@ -149,6 +152,7 @@ const appPresetForm = reactive({
149
152
  });
150
153
 
151
154
  const configForm = reactive<ConfigForm>({
155
+ appium: { model: { baseUrl: '', apiKey: '', name: '' } },
152
156
  runtime: {
153
157
  androidSdkPath: '',
154
158
  reportOutputPath: '',
@@ -656,6 +660,8 @@ const loadConfig = async () => {
656
660
  });
657
661
  Object.assign(configForm.midscene.env, payload.midscene.env || {});
658
662
  Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
663
+ Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
664
+ aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
659
665
  if (configForm.midscene.model.provider !== 'codex') {
660
666
  rememberCustomMidsceneModel();
661
667
  }
@@ -692,7 +698,7 @@ const getMidsceneModelConfigError = () => {
692
698
  if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
693
699
 
694
700
  if (!missing.length) return '';
695
- return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后保存。`;
701
+ return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后保存。`;
696
702
  };
697
703
 
698
704
  const saveModelConfig = async () => {
@@ -700,7 +706,10 @@ const saveModelConfig = async () => {
700
706
  errorMessage.value = '';
701
707
  openActionDialog('保存参数配置');
702
708
  try {
703
- await api.saveConfig(configForm);
709
+ // 保存的是本次提交快照,不能把请求期间尚未保存的改动标记为已配置。
710
+ const snapshot = JSON.parse(JSON.stringify(configForm)) as ConfigForm;
711
+ await api.saveConfig(snapshot);
712
+ aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(snapshot.appium.model);
704
713
  closeActionDialog();
705
714
  ElMessage.success('参数配置已保存');
706
715
  } catch (error) {
@@ -776,14 +785,11 @@ const removeAppPreset = async (id: string) => {
776
785
  }
777
786
  };
778
787
 
779
- const testModel = async (key: 'midscene' | 'scriptOptimizer') => {
788
+ const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium') => {
780
789
  testingModelKey.value = key;
781
790
  errorMessage.value = '';
782
791
  modelTestStatus[key] = '';
783
- const model =
784
- key === 'midscene'
785
- ? configForm.midscene.model
786
- : configForm.scriptOptimizer.model;
792
+ const model = configForm[key].model;
787
793
 
788
794
  try {
789
795
  const payload = await api.testModel({ modelKey: key, model });
@@ -1557,6 +1563,7 @@ onUnmounted(() => {
1557
1563
  v-show="activeMenu === 'appium'"
1558
1564
  :active="activeMenu === 'appium'"
1559
1565
  :app-presets="appPresets"
1566
+ :ai-recognition-model-configured="aiRecognitionModelConfigured"
1560
1567
  :device-actions="deviceActions"
1561
1568
  :playground-available="playgroundAvailable"
1562
1569
  :playground-device-id="playgroundDeviceId"
package/src/api.ts CHANGED
@@ -116,7 +116,7 @@ export function saveConfig(input: ConfigForm) {
116
116
 
117
117
  // 测试指定模型配置是否可用,通常用于保存前验证 baseUrl/apiKey/model。
118
118
  export function testModel(input: {
119
- modelKey: 'midscene' | 'scriptOptimizer';
119
+ modelKey: 'midscene' | 'scriptOptimizer' | 'appium';
120
120
  model: {
121
121
  provider?: 'custom' | 'codex';
122
122
  baseUrl: string;