android-midscene-automation 0.1.41 → 0.1.43

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 (77) hide show
  1. package/AI_RECOGNITION_GUIDE.md +370 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +35 -3
  4. package/USAGE.md +18 -3
  5. package/package.json +2 -1
  6. package/remote-agent/index.ts +1 -0
  7. package/server/appium-auto-save.test.ts +61 -0
  8. package/server/appium-config.test.ts +165 -0
  9. package/server/appium-recorder/ai-deduplication.test.ts +80 -0
  10. package/server/appium-recorder/ai-deduplication.ts +74 -0
  11. package/server/appium-recorder/ai-observation.test.ts +125 -0
  12. package/server/appium-recorder/ai-observation.ts +125 -0
  13. package/server/appium-recorder/ai-recognition.ts +118 -22
  14. package/server/appium-recorder/appium-runner.ts +79 -14
  15. package/server/appium-recorder/bounded-loop.test.ts +55 -0
  16. package/server/appium-recorder/bounded-loop.ts +12 -1
  17. package/server/appium-recorder/history-capture.ts +1 -0
  18. package/server/appium-recorder/image-check.ts +1 -1
  19. package/server/appium-recorder/managed-appium.ts +15 -0
  20. package/server/appium-recorder/managed-cleanup.test.ts +82 -0
  21. package/server/appium-recorder/prompt-optimizer.ts +57 -0
  22. package/server/appium-recorder/replay-log.ts +13 -0
  23. package/server/appium-recorder/report-summary.test.ts +78 -0
  24. package/server/appium-recorder/report-summary.ts +60 -0
  25. package/server/appium-recorder/report.ts +45 -14
  26. package/server/appium-recorder/repository.ts +7 -0
  27. package/server/appium-recorder/routes.ts +78 -4
  28. package/server/appium-recorder/screenshot.ts +1 -0
  29. package/server/config.ts +34 -4
  30. package/server/http-api.ts +31 -5
  31. package/server/model-tester.ts +2 -1
  32. package/src/App.vue +53 -30
  33. package/src/api.ts +11 -4
  34. package/src/appium-recorder/AppiumPage.vue +167 -68
  35. package/src/appium-recorder/RunHistoryDialog.vue +1 -1
  36. package/src/appium-recorder/action-descriptions.ts +2 -1
  37. package/src/appium-recorder/ai-deduplication.ts +34 -0
  38. package/src/appium-recorder/ai-prompt-presets.ts +34 -0
  39. package/src/appium-recorder/ai-recognition.ts +27 -2
  40. package/src/appium-recorder/api.ts +36 -2
  41. package/src/appium-recorder/bounded-loop.ts +9 -1
  42. package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
  43. package/src/appium-recorder/components/AiRecognitionSettings.vue +167 -0
  44. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
  45. package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
  46. package/src/appium-recorder/components/ComponentTree.vue +5 -5
  47. package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
  48. package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
  49. package/src/appium-recorder/components/FlowCanvas.vue +3 -2
  50. package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
  51. package/src/appium-recorder/components/FlowStepEditor.vue +17 -3
  52. package/src/appium-recorder/components/NestedConditionBranches.vue +25 -50
  53. package/src/appium-recorder/components/RecordedSteps.vue +23 -6
  54. package/src/appium-recorder/coordinate-preview.ts +37 -0
  55. package/src/appium-recorder/flow-appearance.ts +12 -1
  56. package/src/appium-recorder/flow-copy.ts +1 -0
  57. package/src/appium-recorder/flow-graph.ts +7 -4
  58. package/src/appium-recorder/flow-labels.ts +9 -5
  59. package/src/appium-recorder/flow-merge.ts +1 -1
  60. package/src/appium-recorder/flow-unmerge.ts +2 -2
  61. package/src/appium-recorder/report-summary.ts +60 -0
  62. package/src/appium-recorder/types.ts +6 -0
  63. package/src/components/config/AiDeduplicationSettings.vue +60 -0
  64. package/src/components/config/AiPromptPresetsSettings.vue +63 -0
  65. package/src/components/config/AppiumConfigPanel.vue +76 -3
  66. package/src/components/config/AppiumModelSettings.vue +45 -0
  67. package/src/components/config/BaseConfigPanel.vue +20 -8
  68. package/src/components/config/FlowAppearanceSettings.vue +51 -32
  69. package/src/components/config/MidsceneConfigPanel.vue +84 -86
  70. package/src/components/config/ReplayReportSummarySettings.vue +118 -0
  71. package/src/components/device/DevicePreviewPanel.vue +18 -3
  72. package/src/config/midscene-model-presets.ts +37 -9
  73. package/src/config/use-appium-auto-save.ts +62 -0
  74. package/src/pages/ConfigPage.vue +8 -6
  75. package/src/style.css +5 -55
  76. package/src/types.ts +4 -1
  77. package/src/ui-refresh.css +17 -4
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
+ import CoordinatePointPicker from './CoordinatePointPicker.vue';
3
4
  import type { AppiumRecordedStep, AppiumSelector } from '../types';
4
5
  import { normalizeVisualChangeConfig } from '../visual-change';
5
6
  import { longPressMode } from '../long-press';
@@ -13,6 +14,7 @@ import LoopSettings from './LoopSettings.vue';
13
14
  import BreakLoopSettings from './BreakLoopSettings.vue';
14
15
  import VariableExtractionSettings from './VariableExtractionSettings.vue';
15
16
  import ScriptParameterSettings from './ScriptParameterSettings.vue';
17
+ import AiRecognitionSettings from './AiRecognitionSettings.vue';
16
18
 
17
19
  type FlowKind = 'action' | 'condition' | 'assertion';
18
20
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
@@ -98,6 +100,12 @@ function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefin
98
100
  ? { label: `点击坐标 ${fallback.centerX},${fallback.centerY}` } : {}) });
99
101
  }
100
102
 
103
+ function applyTapPoint(point: { x: number; y: number }) {
104
+ const fallback = { ...props.step.fallback, strategy: 'bounds' as const, centerX: point.x, centerY: point.y };
105
+ patchStep({ fallback, ...(/^点击坐标 \d+,\d+$/.test(props.step.label)
106
+ ? { label: `点击坐标 ${point.x},${point.y}` } : {}) });
107
+ }
108
+
101
109
  function patchVisualChange(patch: Partial<VisualChangeConfig>) {
102
110
  patchStep({
103
111
  visualChange: normalizeVisualChangeConfig({
@@ -154,17 +162,21 @@ function patchSelector(patch: Partial<AppiumSelector>) {
154
162
  />
155
163
  </el-form-item>
156
164
  <div>
157
- <el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
165
+ <el-form-item v-if="step.type === 'aiRecognition'">
166
+ <el-checkbox :model-value="step.aiTimeoutEnabled === true" :disabled="disabled" @update:model-value="patchStep({ aiTimeoutEnabled: $event === true })">启用超时</el-checkbox>
167
+ </el-form-item>
168
+ <el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop', 'continueLoop'].includes(step.type) && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :label="step.type === 'aiRecognition' && step.aiObservation ? '模型请求超时 ms' : '超时时间 ms'">
158
169
  <el-input-number
159
170
  :model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
160
171
  :disabled="disabled"
161
172
  :min="0"
162
173
  :max="999999"
174
+ :step="1000"
163
175
  controls-position="right"
164
176
  @update:model-value="patchTimeout($event)"
165
177
  />
166
178
  </el-form-item>
167
- <BranchTimeoutSettings v-if="defaultKind() === 'condition'" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
179
+ <BranchTimeoutSettings v-if="defaultKind() === 'condition' && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
168
180
  </div>
169
181
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
170
182
  <el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
@@ -191,12 +203,14 @@ function patchSelector(patch: Partial<AppiumSelector>) {
191
203
  <el-input-number :model-value="step.fallback?.centerY ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 Y" @update:model-value="patchTapCoordinate('centerY', $event)" />
192
204
  </el-form-item>
193
205
  </div>
206
+ <CoordinatePointPicker v-if="step.type === 'coordinateTap'" :disabled="disabled" @select="applyTapPoint" />
194
207
  <StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
195
208
  <VariableExtractionSettings v-if="step.type === 'extractVariable'" :step="step" :disabled="disabled" @update="patchStep" />
196
209
  <ScriptParameterSettings v-if="step.type === 'runScript'" :step="step" :disabled="disabled" @update="patchStep" />
197
210
  <LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
198
- <BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
211
+ <BreakLoopSettings v-if="step.type === 'breakLoop' || step.type === 'continueLoop'" :step="step" :steps="steps" :disabled="disabled" :mode="step.type === 'continueLoop' ? 'continue' : 'break'" @update="patchStep" />
199
212
  <TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
213
+ <AiRecognitionSettings v-if="step.type === 'aiRecognition'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
200
214
  <el-form-item
201
215
  v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
202
216
  label="文本内容"
@@ -6,53 +6,10 @@ import type { AppiumRecordedStep } from '../types';
6
6
  import { buildConditionLayouts, FLOW_BRANCH_GAP, FLOW_NODE_WIDTH } from '../flow-layout';
7
7
  import FlowStepEditor from './FlowStepEditor.vue';
8
8
  import FlowActionMenu from './FlowActionMenu.vue';
9
+ import type { FlowActionGroup, InsertAction } from '../flow-graph';
9
10
 
10
11
  type BranchName = 'yes' | 'no';
11
12
  type FlowKind = 'action' | 'condition' | 'assertion';
12
- type InsertAction =
13
- | 'delay'
14
- | 'tap'
15
- | 'input'
16
- | 'clearInput'
17
- | 'coordinateTap'
18
- | 'longPress'
19
- | 'keyBack'
20
- | 'keyHome'
21
- | 'keyRecent'
22
- | 'keyPower'
23
- | 'swipe'
24
- | 'pinch'
25
- | 'launchApp'
26
- | 'stopApp'
27
- | 'openGallery'
28
- | 'endFlow'
29
- | 'loop'
30
- | 'breakLoop'
31
- | 'clearAppData'
32
- | 'popupCondition'
33
- | 'checkboxState'
34
- | 'checkedState'
35
- | 'radioButtonState'
36
- | 'aiRecognition'
37
- | 'imageCheck'
38
- | 'textClick'
39
- | 'tapIfExists'
40
- | 'inputIfExists'
41
- | 'clearIfExists'
42
- | 'backIfExists'
43
- | 'waitFor'
44
- | 'assertExists'
45
- | 'assertText'
46
- | 'waitDisappear'
47
- | 'waitActivity'
48
- | 'runScript'
49
- | 'extractVariable'
50
- | 'noop'
51
- | 'log'
52
- | 'visualChangeStart'
53
- | 'visualChangeEnd'
54
- | 'visualChange';
55
-
56
13
  const PASTE_COMMAND = '__paste_flow_nodes__';
57
14
 
58
15
  const props = defineProps<{
@@ -86,7 +43,7 @@ const layout = computed(() => buildConditionLayouts(props.steps).get(props.condi
86
43
  yesAxis: 25,
87
44
  noAxis: 75,
88
45
  });
89
- const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction; label: string }> }> = [
46
+ const actionGroups: FlowActionGroup[] = [
90
47
  {
91
48
  title: '组件操作',
92
49
  actions: [
@@ -119,7 +76,6 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
119
76
  actions: [
120
77
  { type: 'delay', label: '添加延时' },
121
78
  { type: 'popupCondition', label: '判断存在' },
122
- { type: 'imageCheck', label: '图像判断' },
123
79
  { type: 'tapIfExists', label: '存在则点击' },
124
80
  { type: 'inputIfExists', label: '存在则输入' },
125
81
  { type: 'clearIfExists', label: '存在则清空' },
@@ -127,8 +83,20 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
127
83
  { type: 'waitFor', label: '等待出现' },
128
84
  { type: 'waitDisappear', label: '等待元素消失' },
129
85
  { type: 'waitActivity', label: '等待 Activity' },
130
- { type: 'visualChangeStart', label: '检测画面变化开始' },
131
- { type: 'visualChangeEnd', label: '检测画面变化结束' },
86
+ {
87
+ label: '检测画面变化',
88
+ actions: [
89
+ { type: 'visualChangeStart', label: '开始检测' },
90
+ { type: 'visualChangeEnd', label: '结束检测' },
91
+ ],
92
+ },
93
+ ],
94
+ },
95
+ {
96
+ title: '视觉识别',
97
+ actions: [
98
+ { type: 'aiRecognition', label: 'AI 识别' },
99
+ { type: 'imageCheck', label: '图像判断' },
132
100
  ],
133
101
  },
134
102
  {
@@ -136,8 +104,14 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
136
104
  actions: [
137
105
  { type: 'noop', label: '空节点' },
138
106
  { type: 'endFlow', label: '终止流程' },
139
- { type: 'loop', label: '有界循环' },
140
- { type: 'breakLoop', label: '退出循环' },
107
+ {
108
+ label: '循环',
109
+ actions: [
110
+ { type: 'loop', label: '有界循环' },
111
+ { type: 'continueLoop', label: '继续下一次循环' },
112
+ { type: 'breakLoop', label: '退出循环' },
113
+ ],
114
+ },
141
115
  { type: 'log', label: '输出日志' },
142
116
  { type: 'runScript', label: '连接脚本' },
143
117
  ],
@@ -160,6 +134,7 @@ function typeLabel(step: AppiumRecordedStep) {
160
134
  endFlow: '终止流程',
161
135
  loop: '有界循环',
162
136
  breakLoop: '退出循环',
137
+ continueLoop: '继续下一次循环',
163
138
  log: '输出日志',
164
139
  checkboxState: 'Checkbox 状态',
165
140
  checkedState: '判断勾选',
@@ -89,7 +89,6 @@ const insertActionGroups: FlowActionGroup[] = [
89
89
  actions: [
90
90
  { type: 'delay', label: '添加延时' },
91
91
  { type: 'popupCondition', label: '判断存在' },
92
- { type: 'imageCheck', label: '图像判断' },
93
92
  { type: 'tapIfExists', label: '存在则点击' },
94
93
  { type: 'inputIfExists', label: '存在则输入' },
95
94
  { type: 'clearIfExists', label: '存在则清空' },
@@ -97,8 +96,20 @@ const insertActionGroups: FlowActionGroup[] = [
97
96
  { type: 'waitFor', label: '等待出现' },
98
97
  { type: 'waitDisappear', label: '等待元素消失' },
99
98
  { type: 'waitActivity', label: '等待 Activity' },
100
- { type: 'visualChangeStart', label: '检测画面变化开始节点' },
101
- { type: 'visualChangeEnd', label: '检测画面变化结束节点' },
99
+ {
100
+ label: '检测画面变化',
101
+ actions: [
102
+ { type: 'visualChangeStart', label: '开始检测' },
103
+ { type: 'visualChangeEnd', label: '结束检测' },
104
+ ],
105
+ },
106
+ ],
107
+ },
108
+ {
109
+ title: '视觉识别',
110
+ actions: [
111
+ { type: 'aiRecognition', label: 'AI 识别' },
112
+ { type: 'imageCheck', label: '图像判断' },
102
113
  ],
103
114
  },
104
115
  {
@@ -106,8 +117,14 @@ const insertActionGroups: FlowActionGroup[] = [
106
117
  actions: [
107
118
  { type: 'noop', label: '空节点' },
108
119
  { type: 'endFlow', label: '终止流程' },
109
- { type: 'loop', label: '有界循环' },
110
- { type: 'breakLoop', label: '退出循环' },
120
+ {
121
+ label: '循环',
122
+ actions: [
123
+ { type: 'loop', label: '有界循环' },
124
+ { type: 'continueLoop', label: '继续下一次循环' },
125
+ { type: 'breakLoop', label: '退出循环' },
126
+ ],
127
+ },
111
128
  { type: 'log', label: '输出日志' },
112
129
  { type: 'runScript', label: '连接脚本' },
113
130
  ],
@@ -116,7 +133,7 @@ const insertActionGroups: FlowActionGroup[] = [
116
133
 
117
134
  const mainActionGroups = insertActionGroups.map((group) => ({
118
135
  ...group,
119
- actions: group.actions.filter((action) => action.type !== 'clearAppData'),
136
+ actions: group.actions.filter((action) => !('type' in action) || action.type !== 'clearAppData'),
120
137
  }));
121
138
  const startActionGroups = insertActionGroups;
122
139
  const hasClearAppDataStep = computed(() => props.steps.some((step) => step.type === 'clearAppData'));
@@ -0,0 +1,37 @@
1
+ import { computed, provide, shallowRef, watch, type InjectionKey, type Ref } from 'vue';
2
+
3
+ type Point = { x: number; y: number };
4
+ interface CoordinatePicker {
5
+ enabled: Readonly<Ref<boolean>>;
6
+ owner: Readonly<Ref<symbol | undefined>>;
7
+ start: (owner: symbol, select: (point: Point) => void) => void;
8
+ cancel: (owner?: symbol) => void;
9
+ }
10
+ export const coordinatePreviewKey: InjectionKey<CoordinatePicker> = Symbol('coordinate-preview');
11
+
12
+ export function useCoordinatePicker(enabled: Readonly<Ref<boolean>>, showPreview: () => void) {
13
+ const session = shallowRef<{ owner: symbol; select: (point: Point) => void }>();
14
+ function cancel(owner?: symbol) {
15
+ if (!owner || session.value?.owner === owner) session.value = undefined;
16
+ }
17
+ provide(coordinatePreviewKey, {
18
+ enabled,
19
+ owner: computed(() => session.value?.owner),
20
+ start(owner, select) {
21
+ if (!enabled.value) return;
22
+ session.value = { owner, select };
23
+ showPreview();
24
+ },
25
+ cancel,
26
+ });
27
+ watch(enabled, (value) => { if (!value) cancel(); });
28
+ return {
29
+ picking: computed(() => Boolean(session.value)),
30
+ cancel,
31
+ select(point: Point) {
32
+ const current = session.value;
33
+ cancel();
34
+ if (enabled.value) current?.select(point);
35
+ },
36
+ };
37
+ }
@@ -3,13 +3,24 @@ import type { InjectionKey, Ref } from 'vue';
3
3
  export const DEFAULT_FLOW_BACKGROUND = '#d4e8dd';
4
4
  export const FLOW_BACKGROUND_PRESETS = ['#d4e8dd', '#d5e6f5', '#e4dcf2', '#f2dedc', '#d5eae9', '#e8e4cc'];
5
5
  export const flowBackgroundKey: InjectionKey<Readonly<Ref<string>>> = Symbol('flowBackground');
6
+ export const DEFAULT_FLOW_LINE_COLOR = '#719985';
7
+ export const FLOW_LINE_COLOR_PRESETS = ['#719985', '#4b83c4', '#8b6bb1', '#c78a46', '#7b8794', '#374151'];
8
+ export const flowLineColorKey: InjectionKey<Readonly<Ref<string>>> = Symbol('flowLineColor');
6
9
 
7
10
  export function isHexColor(value: unknown): value is string {
8
11
  return typeof value === 'string' && /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(value.trim());
9
12
  }
10
13
 
11
14
  export function normalizeFlowBackground(value: unknown) {
12
- if (!isHexColor(value)) return DEFAULT_FLOW_BACKGROUND;
15
+ return normalizeFlowColor(value, DEFAULT_FLOW_BACKGROUND);
16
+ }
17
+
18
+ export function normalizeFlowLineColor(value: unknown) {
19
+ return normalizeFlowColor(value, DEFAULT_FLOW_LINE_COLOR);
20
+ }
21
+
22
+ function normalizeFlowColor(value: unknown, fallback: string) {
23
+ if (!isHexColor(value)) return fallback;
13
24
  const color = value.trim().toLowerCase();
14
25
  return color.length === 4 ? '#' + [...color.slice(1)].map((char) => char + char).join('') : color;
15
26
  }
@@ -184,6 +184,7 @@ export function pasteFlowClipboard(
184
184
  delete step.mergeUndo;
185
185
  step.id = idMap.get(source.id) || createId();
186
186
  if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
187
+ if (step.continueLoopTargetId) step.continueLoopTargetId = idMap.get(step.continueLoopTargetId) || step.continueLoopTargetId;
187
188
  const flow = { ...(step.flow || {}) };
188
189
  for (const key of targetKeys) {
189
190
  const mappedId = flow[key] ? idMap.get(flow[key] || '') : '';
@@ -29,6 +29,7 @@ export type InsertAction =
29
29
  | 'endFlow'
30
30
  | 'loop'
31
31
  | 'breakLoop'
32
+ | 'continueLoop'
32
33
  | 'clearAppData'
33
34
  | 'popupCondition'
34
35
  | 'checkboxState'
@@ -70,9 +71,11 @@ const FLOW_BRANCH_LABEL_GAP = 58;
70
71
  const FLOW_BRANCH_MIN_SPREAD = 260;
71
72
  const FLOW_BRANCH_SUBTREE_GAP = 80;
72
73
 
74
+ export type FlowAction = { type: InsertAction; label: string };
75
+ export type FlowActionSubgroup = { label: string; actions: FlowAction[] };
73
76
  export type FlowActionGroup = {
74
77
  title: string;
75
- actions: Array<{ type: InsertAction; label: string }>;
78
+ actions: Array<FlowAction | FlowActionSubgroup>;
76
79
  };
77
80
 
78
81
  export type FlowGraphNodeData =
@@ -283,7 +286,7 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
283
286
  }
284
287
 
285
288
  function withLaunchAppAction(groups: FlowActionGroup[]) {
286
- if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
289
+ if (groups.some((group) => group.actions.some((action) => 'type' in action && action.type === 'launchApp'))) return groups;
287
290
  const launchAction = { type: 'launchApp' as const, label: '启动 App' };
288
291
  let added = false;
289
292
  const nextGroups = groups.map((group) => {
@@ -460,8 +463,8 @@ export function buildFlowGraph(
460
463
  && item.step.type !== 'clearAppData'
461
464
  && item.step.visualChange?.role !== 'end',
462
465
  canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
463
- canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
464
- missingAiModel: item.step.type === 'aiRecognition',
466
+ canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
467
+ missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
465
468
  },
466
469
  });
467
470
  };
@@ -5,15 +5,17 @@ import { longPressMode } from './long-press';
5
5
  import { isNativeStateCondition } from './native-control-state';
6
6
  import { DEFAULT_LOG_PREFIX } from './stage-log';
7
7
  import { imageCheckSummary } from './image-check';
8
+ import { isAiBranchEnabled } from './ai-recognition';
8
9
 
9
- export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
10
- return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick' || step.type === 'imageCheck';
10
+ export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type' | 'aiBranchEnabled'>) {
11
+ return isNativeStateCondition(step) || (step.type === 'aiRecognition' && isAiBranchEnabled(step)) || step.type === 'textClick' || step.type === 'imageCheck';
11
12
  }
12
13
 
13
14
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
15
+ if (step.type === 'aiRecognition') return isAiBranchEnabled(step) ? 'condition' : 'action';
14
16
  if (step.type === 'extractVariable') return 'action';
15
17
  if (step.type === 'loop') return 'condition';
16
- if (step.type === 'breakLoop') return 'action';
18
+ if (step.type === 'breakLoop' || step.type === 'continueLoop') return 'action';
17
19
  if (step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow') return 'action';
18
20
  if (isBooleanCondition(step)) return 'condition';
19
21
  if (step.flow?.nodeKind) return step.flow.nodeKind;
@@ -47,7 +49,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
47
49
  checkboxState: 'Checkbox 状态',
48
50
  checkedState: '判断勾选',
49
51
  radioButtonState: 'RadioButton 状态',
50
- aiRecognition: 'AI 识别(已移除)',
52
+ aiRecognition: 'AI 识别',
51
53
  imageCheck: '图像判断',
52
54
  textClick: '文字点击',
53
55
  assertText: '断言文本',
@@ -63,6 +65,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
63
65
  endFlow: '终止流程',
64
66
  loop: '有界循环',
65
67
  breakLoop: '退出循环',
68
+ continueLoop: '继续下一次循环',
66
69
  clearAppData: '清理 APP 缓存',
67
70
  longPress: '长按',
68
71
  pinch: '双指缩放',
@@ -80,8 +83,9 @@ export function flowStepMeta(step: AppiumRecordedStep) {
80
83
  if (step.type === 'extractVariable') return `${step.extractVariable?.attribute || 'text'} → ${step.extractVariable?.name || '未设置变量名'}`;
81
84
  if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
82
85
  if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
86
+ if (step.type === 'continueLoop') return step.continueLoopTargetId ? '结束本轮,进入指定循环的下一轮' : '结束本轮,进入当前循环的下一轮';
83
87
  if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
84
- if (step.type === 'aiRecognition') return '此操作已移除,请替换为图像判断或原生组件判断';
88
+ if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}`;
85
89
  if (step.type === 'longPress') {
86
90
  const target = longPressMode(step) === 'element'
87
91
  ? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
@@ -63,7 +63,7 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
63
63
  }
64
64
  }
65
65
  function connectEnd(step: typeof next[number]) {
66
- if (step.type === 'endFlow' || step.type === 'breakLoop') return;
66
+ if (step.type === 'endFlow' || step.type === 'breakLoop' || step.type === 'continueLoop') return;
67
67
  step.flow.successTargetId = commonId;
68
68
  if (defaultFlowKind(step) === 'condition') {
69
69
  for (const branch of (step.type === 'loop' ? ['no'] : ['yes', 'no']) as Branch[]) {
@@ -28,7 +28,7 @@ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string
28
28
  const shared = new Set([...common, ...added.map(step => step.id)]);
29
29
  // 普通新增操作可分配到任一侧;控制节点和跨节点配对不做猜测性复制。
30
30
  if (added.some(step => defaultFlowKind(step) === 'condition'
31
- || ['visualChange', 'breakLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
31
+ || ['visualChange', 'breakLoop', 'continueLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
32
32
  throw new Error('公共流程新增了判断、循环、终止或配对节点,请先移出这些节点再取消合并');
33
33
  }
34
34
  if (added.some(step => edges.some(key => step.flow?.[key]
@@ -98,7 +98,7 @@ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string
98
98
  const siblings = branchSteps(next, conditionId, other);
99
99
  const appended = siblings.slice(-added.length);
100
100
  const previous = siblings[siblings.length - appended.length - 1];
101
- if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop'].includes(previous.type))) {
101
+ if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop', 'continueLoop'].includes(previous.type))) {
102
102
  throw new Error('另一侧末尾是判断、循环或终止操作,新增节点不能安全追加,请仅保留在原来源分支');
103
103
  }
104
104
  if (previous?.flow?.successTargetId) {
@@ -0,0 +1,60 @@
1
+ export const DEFAULT_REPORT_SUMMARY_PROMPT = `请根据提供的回放日志、执行元数据和脚本配置,生成中文 Markdown 测试报告。默认包含:
2
+ 1. 测试概览:脚本名称、开始时间、结束时间、执行结果(成功/失败/终止)、总时长、设备和 App 信息。
3
+ 2. 执行配置:主脚本及连接脚本名称、节点类型、参数、超时、分支、循环、AI 识别条件等,用表格展示关键配置。
4
+ 3. 用例描述表格:序号、所属脚本、测试场景/用例描述、操作步骤、预期结果、实际结果、通过/失败/未执行;循环多次执行时区分尝试次数。
5
+ 4. 失败详情:失败节点、错误日志证据、实际与预期差异、可能原因及排查建议;没有失败则明确说明。
6
+ 5. 汇总:执行情况、主要问题、重试结果及建议。
7
+ 严格依据日志,未执行节点不能计为通过;AI 判断 false 与执行异常要区分。无法确认的信息写“日志未提供”,推测原因明确标为推测。不要编造截图内容或测试结果。只输出 Markdown 正文,不要在全文外包裹代码块。`;
8
+
9
+ export const IMAGE_GENERATION_TIME_REPORT_SUMMARY_PROMPT = `${DEFAULT_REPORT_SUMMARY_PROMPT}
10
+
11
+ 另外必须包含“出图时间分析”:
12
+ - 定位每次触发图片生成的操作,以及其后用于判断已出图的 AI 识别或图像判断节点。
13
+ - 首次出图时间必须采用日志中“距观察开始 Nms”或首次命中帧的明确时间,换算为秒并保留两位小数。
14
+ - AI 识别耗时或图像判断耗时是包含截图采样、模型请求和响应等待的检测节点总耗时,不能直接作为首次出图时间。
15
+ - 如果没有首次命中帧时间,首次出图时间写“日志未提供”,并单独展示检测节点总耗时;不得根据观察时长、超时时间或节点顺序推测。
16
+ - 多次生成、循环或重试须逐次列出,表格列为:次数、触发出图操作、检测条件、首次出图时间、检测节点总耗时、判定结果、日志证据。
17
+ - 汇总成功出图次数、失败或超时次数、最快、最慢和平均首次出图时间;只有至少两次具有明确时间时才计算平均值。`;
18
+
19
+ export type ReportSummaryPromptPreset = { name: string; prompt: string };
20
+
21
+ export const REPORT_SUMMARY_PROMPT_PRESETS: readonly ReportSummaryPromptPreset[] = [
22
+ { name: '默认测试报告', prompt: DEFAULT_REPORT_SUMMARY_PROMPT },
23
+ { name: '出图时间分析', prompt: IMAGE_GENERATION_TIME_REPORT_SUMMARY_PROMPT },
24
+ ] as const;
25
+
26
+ export interface ReportSummaryConfig {
27
+ enabled: boolean;
28
+ prompt: string;
29
+ customPresets: ReportSummaryPromptPreset[];
30
+ }
31
+
32
+ export function validateReportSummaryPrompt(value: unknown) {
33
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > 20000) {
34
+ throw new Error('报告提示词不能为空,且不能超过 20000 字');
35
+ }
36
+ return value.trim();
37
+ }
38
+
39
+ export function resolveReportSummary(value?: Partial<ReportSummaryConfig>): ReportSummaryConfig {
40
+ if (value != null && (typeof value !== 'object' || Array.isArray(value))) throw new Error('回放报告总结配置无效');
41
+ if (value?.enabled !== undefined && typeof value.enabled !== 'boolean') throw new Error('回放报告总结开关无效');
42
+ const prompt = validateReportSummaryPrompt(value?.prompt ?? DEFAULT_REPORT_SUMMARY_PROMPT);
43
+ const rawPresets = value?.customPresets;
44
+ if (rawPresets !== undefined && !Array.isArray(rawPresets)) throw new Error('报告测试场景必须是列表');
45
+ if ((rawPresets?.length || 0) > 50) throw new Error('报告测试场景不能超过 50 个');
46
+ const reservedNames = new Set(REPORT_SUMMARY_PROMPT_PRESETS.map(preset => preset.name));
47
+ const names = new Set<string>();
48
+ const customPresets = (rawPresets || []).map(item => {
49
+ const name = typeof item?.name === 'string' ? item.name.trim() : '';
50
+ if (!name || name.length > 80) throw new Error('报告测试场景名称不能为空,且不能超过 80 字');
51
+ if (reservedNames.has(name) || names.has(name)) throw new Error(`报告测试场景名称已存在:${name}`);
52
+ names.add(name);
53
+ return { name, prompt: validateReportSummaryPrompt(item?.prompt) };
54
+ });
55
+ return {
56
+ enabled: value?.enabled === true,
57
+ prompt,
58
+ customPresets,
59
+ };
60
+ }
@@ -1,3 +1,4 @@
1
+ import type { AiObservationConfig } from './ai-recognition';
1
2
  import type { TestVariable, VariableExtraction, ScriptReturn } from './variables';
2
3
  import type { ImageCheckConfig } from './image-check';
3
4
 
@@ -98,6 +99,7 @@ export type AppiumRecordedStep = {
98
99
  | 'endFlow'
99
100
  | 'loop'
100
101
  | 'breakLoop'
102
+ | 'continueLoop'
101
103
  | 'clearAppData'
102
104
  | 'waitDisappear'
103
105
  | 'assertText'
@@ -121,9 +123,13 @@ export type AppiumRecordedStep = {
121
123
  returns?: ScriptReturn[];
122
124
  keyCode?: number;
123
125
  timeoutMs?: number;
126
+ aiTimeoutEnabled?: boolean;
127
+ aiBranchEnabled?: boolean;
128
+ aiObservation?: AiObservationConfig;
124
129
  timeoutBranch?: 'stop' | 'yes' | 'no';
125
130
  longPressMode?: 'element' | 'coordinates';
126
131
  breakLoopTargetId?: string;
132
+ continueLoopTargetId?: string;
127
133
  loop?: {
128
134
  maxIterations: number;
129
135
  exitWhen: 'never' | 'exists' | 'notExists';
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { resolveAiDeduplication, type AiDeduplicationConfig } from '../../appium-recorder/ai-deduplication';
4
+
5
+ const props = defineProps<{ modelValue?: AiDeduplicationConfig; disabled?: boolean }>();
6
+ const emit = defineEmits<{ 'update:modelValue': [value: AiDeduplicationConfig] }>();
7
+ const settings = computed(() => props.modelValue ?? resolveAiDeduplication());
8
+ function patch(value: Partial<AiDeduplicationConfig>) {
9
+ emit('update:modelValue', { ...settings.value, ...value });
10
+ }
11
+ </script>
12
+
13
+ <template>
14
+ <section class="ai-dedup-settings" aria-label="持续观察截图去重">
15
+ <p class="ai-dedup-description">相似截图在本地过滤后再提交模型,减少重复图片和识别开销。</p>
16
+ <el-form-item label="去重方式">
17
+ <el-radio-group :model-value="settings.method" :disabled="disabled" @update:model-value="patch({ method: $event as AiDeduplicationConfig['method'] })">
18
+ <el-radio-button value="none">不去重</el-radio-button>
19
+ <el-radio-button value="pixelmatch">pixelmatch(默认)</el-radio-button>
20
+ <el-radio-button value="opencv">OpenCV(SSIM)</el-radio-button>
21
+ </el-radio-group>
22
+ </el-form-item>
23
+ <template v-if="settings.method === 'pixelmatch'">
24
+ <el-form-item label="像素颜色容差">
25
+ <el-input-number :model-value="settings.pixelmatch.threshold" :disabled="disabled" :min="0" :max="1" :step="0.01" :precision="2" controls-position="right" @update:model-value="patch({ pixelmatch: { ...settings.pixelmatch, threshold: $event ?? 0.1 } })" />
26
+ <p class="ai-dedup-help">默认 0.10。越大越容易忽略颜色细微差异;设为 0 时逐像素严格比较。</p>
27
+ </el-form-item>
28
+ <el-form-item label="允许变化面积(%)">
29
+ <el-input-number :model-value="settings.pixelmatch.maxChangedRatio" :disabled="disabled" :min="0" :max="100" :step="0.1" :precision="2" controls-position="right" @update:model-value="patch({ pixelmatch: { ...settings.pixelmatch, maxChangedRatio: $event ?? 0.5 } })" />
30
+ <p class="ai-dedup-help">默认 0.50%。变化像素占比不超过此值时去重。越大保留越少,但可能忽略小弹窗或文字变化。</p>
31
+ </el-form-item>
32
+ </template>
33
+ <template v-else-if="settings.method === 'opencv'">
34
+ <el-form-item label="SSIM 相似度阈值">
35
+ <el-input-number :model-value="settings.opencv.similarityThreshold" :disabled="disabled" :min="0.5" :max="1" :step="0.005" :precision="3" controls-position="right" @update:model-value="patch({ opencv: { ...settings.opencv, similarityThreshold: $event ?? 0.99 } })" />
36
+ <p class="ai-dedup-help">默认 0.990。相似度达到此值时去重;越接近 1 越严格、保留越多,调低可减少提交图片。</p>
37
+ </el-form-item>
38
+ <el-form-item label="比较图像最长边(px)">
39
+ <el-input-number :model-value="settings.opencv.maxDimension" :disabled="disabled" :min="128" :max="1024" :step="128" :precision="0" controls-position="right" @update:model-value="patch({ opencv: { ...settings.opencv, maxDimension: $event ?? 512 } })" />
40
+ <p class="ai-dedup-help">默认 512。仅缩小本地比较图像;越大越能保留细节,也更耗本地计算资源,不改变提交模型的图片尺寸。</p>
41
+ </el-form-item>
42
+ </template>
43
+ <div class="ai-dedup-explanation">
44
+ <h4>去重方式说明</h4>
45
+ <p><strong>不去重:</strong>全部采样截图交给模型,保留信息最多,图片输入消耗也最高。</p>
46
+ <p><strong>pixelmatch:</strong>比较原图的像素颜色和变化面积,适合同设备的静态画面,对细节变化敏感。</p>
47
+ <p><strong>OpenCV(SSIM):</strong>缩小图片后比较彩色画面的局部亮度、对比度和结构,更侧重整体相似性;可能忽略小文字,不能识别语义,也不自动对齐位移。</p>
48
+ <p>仅对持续观察生效。首帧始终保留,后续与上一张保留的截图比较;两种去重均在本地完成,不调用模型。切换方式会保留各自参数,用于后续回放和识别测试。</p>
49
+ </div>
50
+ </section>
51
+ </template>
52
+
53
+ <style scoped>
54
+ .ai-dedup-description { margin: 0 0 18px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
55
+ .ai-dedup-help { width: 100%; margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
56
+ .ai-dedup-explanation { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--ui-border); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.7; }
57
+ .ai-dedup-explanation h4 { margin: 0 0 8px; color: var(--el-text-color-primary); font-size: 13px; }
58
+ .ai-dedup-explanation p { margin: 6px 0; }
59
+ .ai-dedup-settings :deep(.el-radio-group) { gap: 4px 0; }
60
+ </style>