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,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
3
- import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
3
+ import { flowBackgroundKey, normalizeFlowBackground, flowLineColorKey, normalizeFlowLineColor } from './flow-appearance';
4
+ import { useCoordinatePicker } from './coordinate-preview';
4
5
  import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
6
+ import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS, type AiPromptPreset } from './ai-prompt-presets';
7
+ import AiRecognitionPromptInput from './components/AiRecognitionPromptInput.vue';
5
8
  import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
6
9
  import { ArrowDown, Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
7
10
  import RunHistoryDialog from './RunHistoryDialog.vue';
@@ -15,9 +18,10 @@ import { insertBeforeSharedStep } from './flow-insert';
15
18
  import VariableExtractionSettings from './components/VariableExtractionSettings.vue';
16
19
  import ScriptParameterSettings from './components/ScriptParameterSettings.vue';
17
20
  import { validateVariables, validateExtraction, validateReturns, type TestVariable } from './variables';
18
- import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
21
+ import { breakLoopTarget, continueLoopTarget, defaultLoopConfig, enclosingLoops, validateLoop, validateLoopSteps } from './bounded-loop';
19
22
  import {
20
23
  clearAppiumDeviceAppData,
24
+ checkReportSummaryModel,
21
25
  deleteAppiumScript,
22
26
  downloadAppiumScript,
23
27
  getAppiumScripts,
@@ -42,6 +46,9 @@ import RecordedSteps from './components/RecordedSteps.vue';
42
46
  import NewScriptDialog from './components/NewScriptDialog.vue';
43
47
  import VisualChangeDialog from './components/VisualChangeDialog.vue';
44
48
  import ImageCheckDialog from './components/ImageCheckDialog.vue';
49
+ import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
50
+ import { validateAiRecognitionPrompt } from './ai-recognition';
51
+ import { DEFAULT_REPORT_SUMMARY_PROMPT, REPORT_SUMMARY_PROMPT_PRESETS, type ReportSummaryConfig } from './report-summary';
45
52
  import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
46
53
  import {
47
54
  createFlowClipboard,
@@ -97,10 +104,14 @@ const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
97
104
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
98
105
  const readonlyFlowSelectedIndexes: number[] = [];
99
106
  const recordReplayVideo = shallowRef(false);
107
+ const selectedReportTemplateName = shallowRef('');
100
108
 
101
109
  const props = defineProps<{
102
110
  aiRecognitionModelConfigured?: boolean;
111
+ aiPromptPresets?: AiPromptPreset[];
112
+ reportSummary?: ReportSummaryConfig;
103
113
  flowBackgroundColor?: string;
114
+ flowLineColor?: string;
104
115
  active: boolean;
105
116
  appPresets: AppPreset[];
106
117
  deviceActions: readonly DeviceAction[];
@@ -126,6 +137,14 @@ const props = defineProps<{
126
137
 
127
138
  // provide/inject 随组件关系传递,放大弹窗 teleport 后也能使用同一颜色。
128
139
  provide(flowBackgroundKey, computed(() => normalizeFlowBackground(props.flowBackgroundColor)));
140
+ provide(flowLineColorKey, computed(() => normalizeFlowLineColor(props.flowLineColor)));
141
+ const availableAiPromptPresets = computed(() => props.aiPromptPresets ?? DEFAULT_AI_PROMPT_PRESETS);
142
+ const availableReportTemplates = computed(() => [
143
+ ...REPORT_SUMMARY_PROMPT_PRESETS,
144
+ ...(props.reportSummary?.customPresets || []),
145
+ ]);
146
+ const selectedReportTemplate = computed(() => availableReportTemplates.value.find(item => item.name === selectedReportTemplateName.value));
147
+ provide(aiPromptPresetsKey, availableAiPromptPresets);
129
148
  const selectedDeviceId = computed(() => props.playgroundDeviceId);
130
149
  const tree = shallowRef<AppiumNode | null>(null);
131
150
  const selectedNode = shallowRef<AppiumNode | null>(null);
@@ -136,6 +155,7 @@ const newScriptRevision = shallowRef(0);
136
155
  const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
137
156
  const activeWorkbenchTab = shallowRef<'recording' | 'scripts' | 'variables'>(storedWorkbenchTab === 'scripts' ? 'scripts' : storedWorkbenchTab === 'variables' ? 'variables' : 'recording');
138
157
  const steps = shallowRef<AppiumRecordedStep[]>([]);
158
+ const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
139
159
  const flowClipboard = shallowRef<FlowClipboard | null>(null);
140
160
  const rawXml = shallowRef('');
141
161
  const currentActivity = shallowRef('');
@@ -170,6 +190,7 @@ const duplicatingScriptId = shallowRef('');
170
190
  const renamingScriptId = shallowRef('');
171
191
  const downloadingScriptId = shallowRef('');
172
192
  const replaying = shallowRef(false);
193
+ const preparingReplay = shallowRef(false);
173
194
  const stoppingReplay = shallowRef(false);
174
195
  const activeReplayDeviceId = shallowRef('');
175
196
  const recordingTap = shallowRef(false);
@@ -231,16 +252,9 @@ const linkedScriptTarget = computed(() => scripts.value.find((script) => script.
231
252
  const linkedScriptPreviewScript = computed(() => (
232
253
  scripts.value.find((script) => script.id === linkedScriptPreviewScriptId.value) || null
233
254
  ));
234
- const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
235
- script.appPackage === form.appPackage
236
- && (
237
- Boolean(linkedScriptBranchTarget.value)
238
- || (
239
- Boolean(linkedScriptExpectedActivity.value)
240
- && script.appActivity === linkedScriptExpectedActivity.value
241
- )
242
- )
243
- )));
255
+ const compatibleLinkableScripts = computed(() => linkableScripts.value.filter(
256
+ (script) => script.appPackage === form.appPackage,
257
+ ));
244
258
  const hasSelectedAppPackage = computed(() => props.appPresets.some((app) => app.packageName === form.appPackage));
245
259
  const scriptActivityMismatch = computed(() => {
246
260
  const expectedActivity = selectedScript.value?.appActivity?.trim();
@@ -252,8 +266,8 @@ const recordingBusy = computed(() => (
252
266
  || resolvingNavigation.value
253
267
  || Boolean(pendingNavigation.value)
254
268
  ));
255
- const recordingLocked = computed(() => recordingBusy.value || replaying.value);
256
- const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
269
+ const recordingLocked = computed(() => recordingBusy.value || replaying.value || preparingReplay.value);
270
+ const newScriptDisabled = computed(() => saving.value || replaying.value || preparingReplay.value || recordingBusy.value || launchingApp.value || importingScript.value);
257
271
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
258
272
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
259
273
  )));
@@ -270,6 +284,13 @@ const visualChangeRegionSelectionEnabled = computed(() => (
270
284
  (visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region') || imageCheckDraft.value?.imageCheck?.target === 'region'
271
285
  ));
272
286
 
287
+ const coordinatePicker = useCoordinatePicker(
288
+ computed(() => Boolean(selectedDeviceId.value) && !recordingLocked.value && !visualChangeRegionSelectionEnabled.value),
289
+ () => workspaceRef.value?.showPreview(),
290
+ );
291
+ const pickingCoordinate = coordinatePicker.picking;
292
+ watch([selectedDeviceId, () => props.active], () => coordinatePicker.cancel());
293
+
273
294
  function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
274
295
  return {
275
296
  ...step,
@@ -886,6 +907,11 @@ async function refreshTree() {
886
907
 
887
908
  async function executeFlowStep(index: number) {
888
909
  const step = steps.value[index];
910
+ if (step?.type === 'aiRecognition') {
911
+ if (replaying.value || recordingBusy.value) { ElMessage.warning('设备正在执行操作,请稍后测试'); return; }
912
+ aiRecognitionTestStep.value = { ...step };
913
+ return;
914
+ }
889
915
  if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
890
916
  if (launchingApp.value) return;
891
917
  if (!selectedDeviceId.value) {
@@ -940,6 +966,10 @@ async function executeFlowStep(index: number) {
940
966
  }
941
967
 
942
968
  function selectNodeFromPoint(point: { x: number; y: number }) {
969
+ if (pickingCoordinate.value) {
970
+ coordinatePicker.select(point);
971
+ return;
972
+ }
943
973
  const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
944
974
  if (node) {
945
975
  selectedNode.value = node;
@@ -1129,16 +1159,8 @@ function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
1129
1159
  }
1130
1160
  linkedScriptBranchTarget.value = branchTarget || null;
1131
1161
  linkedScriptExpectedActivity.value = expectedActivityAfterStep(index);
1132
- if (!linkedScriptBranchTarget.value && !linkedScriptExpectedActivity.value) {
1133
- ElMessage.warning('无法确定当前插入点的 Activity,请先刷新组件树');
1134
- return;
1135
- }
1136
1162
  if (!compatibleLinkableScripts.value.length) {
1137
- ElMessage.warning(
1138
- linkedScriptBranchTarget.value
1139
- ? '没有属于当前 App 的可连接脚本'
1140
- : `没有入口 Activity 为 ${linkedScriptExpectedActivity.value} 的可连接脚本`,
1141
- );
1163
+ ElMessage.warning('没有属于当前 App 的可连接脚本');
1142
1164
  linkedScriptExpectedActivity.value = '';
1143
1165
  linkedScriptBranchTarget.value = null;
1144
1166
  return;
@@ -1162,16 +1184,6 @@ function addLinkedScriptStep() {
1162
1184
  ElMessage.error('连接脚本必须属于当前 App');
1163
1185
  return;
1164
1186
  }
1165
- if (
1166
- !linkedScriptBranchTarget.value
1167
- && (!linkedScriptExpectedActivity.value || script.appActivity !== linkedScriptExpectedActivity.value)
1168
- ) {
1169
- ElMessage.error(
1170
- `无法连接:插入点 Activity 为 ${linkedScriptExpectedActivity.value || '-'},`
1171
- + `目标脚本入口 Activity 为 ${script.appActivity || '-'}`,
1172
- );
1173
- return;
1174
- }
1175
1187
  const editingIndex = linkedScriptEditingId.value ? steps.value.findIndex(step => step.id === linkedScriptEditingId.value) : -1;
1176
1188
  if (linkedScriptEditingId.value && editingIndex < 0) { ElMessage.warning('原节点已删除,请重新打开'); return; }
1177
1189
  if (editingIndex >= 0) {
@@ -1373,6 +1385,31 @@ async function addAction(
1373
1385
  if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
1374
1386
  return;
1375
1387
  }
1388
+ if (action === 'continueLoop') {
1389
+ const step: AppiumRecordedStep = { id: createStepId(), type: 'continueLoop', label: '继续下一次循环', flow: { nodeKind: 'action' } };
1390
+ const scoped = reactive(branchTarget ? stepWithBranchTarget(step, branchTarget) : step);
1391
+ const loops = enclosingLoops(steps.value, scoped);
1392
+ if (!loops.length) { ElMessage.warning('继续下一次循环只能添加在循环体内'); return; }
1393
+ scoped.continueLoopTargetId = loops[0]!.id;
1394
+ const result = await ElMessageBox({
1395
+ title: '添加继续下一次循环',
1396
+ message: h(ElForm, { labelPosition: 'top' }, () => h(BreakLoopSettings, {
1397
+ step: scoped,
1398
+ steps: steps.value,
1399
+ mode: 'continue',
1400
+ onUpdate: (patch) => Object.assign(scoped, patch),
1401
+ })),
1402
+ showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
1403
+ beforeClose: (action, _instance, done) => {
1404
+ if (action === 'confirm') {
1405
+ try { continueLoopTarget(steps.value, scoped); } catch (error) { ElMessage.warning((error as Error).message); return; }
1406
+ }
1407
+ done();
1408
+ },
1409
+ }).catch(() => null);
1410
+ if (result) return insertStep({ ...step, continueLoopTargetId: scoped.continueLoopTargetId }, index, branchTarget);
1411
+ return;
1412
+ }
1376
1413
  if (action === 'imageCheck') {
1377
1414
  imageCheckIndex = index;
1378
1415
  imageCheckBranch = branchTarget;
@@ -1384,8 +1421,27 @@ async function addAction(
1384
1421
  return;
1385
1422
  }
1386
1423
  if (action === 'aiRecognition') {
1387
- ElMessage.warning('AI 识别操作已移除,请使用图像判断或原生组件判断');
1388
- return;
1424
+ const draft = reactive({ value: '', error: '' });
1425
+ const result = await ElMessageBox({
1426
+ title: '添加 AI 识别',
1427
+ message: () => h(ElForm, { labelPosition: 'top', style: { width: 'min(380px, calc(100vw - 64px))' } },
1428
+ () => h(ElFormItem, { label: '识别内容', error: draft.error },
1429
+ () => h(AiRecognitionPromptInput, {
1430
+ modelValue: draft.value, presets: availableAiPromptPresets.value,
1431
+ placeholder: '例如:检查当前画面有没有显示黑屏',
1432
+ 'onUpdate:modelValue': (value: string) => { draft.value = value; draft.error = ''; },
1433
+ }))),
1434
+ showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
1435
+ beforeClose: (action, _instance, done) => {
1436
+ if (action === 'confirm') {
1437
+ try { draft.value = validateAiRecognitionPrompt(draft.value); }
1438
+ catch (error) { draft.error = error instanceof Error ? error.message : '识别内容无效'; return; }
1439
+ }
1440
+ done();
1441
+ },
1442
+ }).catch(() => null);
1443
+ if (!result) return;
1444
+ return insertStep({ id: createStepId(), type: 'aiRecognition', label: 'AI 识别', value: draft.value, aiBranchEnabled: false, timeoutMs: DEFAULT_NODE_TIMEOUT_MS, flow: { nodeKind: 'action' } }, index, branchTarget);
1389
1445
  }
1390
1446
  if (action === 'textClick') {
1391
1447
  const step = reactive<AppiumRecordedStep>({
@@ -1680,6 +1736,14 @@ async function addBranchAction(index: number, branch: BranchName, action: Record
1680
1736
  applyBranchTargetToInsertedStep(inserted, branchTarget);
1681
1737
  }
1682
1738
 
1739
+ function continueOnActivityChange() {
1740
+ if (!pendingNavigation.value || resolvingNavigation.value) return;
1741
+ // 跨页面继续录制时,入口仍是首次录制页面,不能改成跳转后的页面。
1742
+ form.appActivity ||= pendingNavigation.value.beforeActivity;
1743
+ pendingNavigation.value = null;
1744
+ ElMessage.success('已继续录制,后续操作将加入当前脚本');
1745
+ }
1746
+
1683
1747
  async function saveOnActivityChange() {
1684
1748
  const pending = pendingNavigation.value;
1685
1749
  if (!pending || resolvingNavigation.value) return;
@@ -1692,7 +1756,7 @@ async function saveOnActivityChange() {
1692
1756
  id: selectedScriptId.value || undefined,
1693
1757
  name: scriptName,
1694
1758
  appPackage: form.appPackage,
1695
- appActivity: pending.beforeActivity || form.appActivity,
1759
+ appActivity: form.appActivity || pending.beforeActivity,
1696
1760
  deviceId: selectedDeviceId.value,
1697
1761
  steps: steps.value,
1698
1762
  variables: scriptVariables.value,
@@ -2041,6 +2105,7 @@ async function saveScript() {
2041
2105
  }
2042
2106
 
2043
2107
  async function replayScript() {
2108
+ if (preparingReplay.value || replaying.value) return;
2044
2109
  if (!selectedScript.value) {
2045
2110
  ElMessage.warning('请选择已保存脚本');
2046
2111
  return;
@@ -2049,6 +2114,32 @@ async function replayScript() {
2049
2114
  const saved = await saveScript();
2050
2115
  if (!saved || !selectedScript.value) return;
2051
2116
  }
2117
+ let reportSummaryEnabled = false;
2118
+ let reportSummaryPrompt: string | undefined;
2119
+ if (props.reportSummary?.enabled) {
2120
+ preparingReplay.value = true;
2121
+ try {
2122
+ const status = await checkReportSummaryModel();
2123
+ if (status.available) {
2124
+ reportSummaryEnabled = true;
2125
+ reportSummaryPrompt = selectedReportTemplate.value?.prompt || DEFAULT_REPORT_SUMMARY_PROMPT;
2126
+ } else {
2127
+ await ElMessageBox.alert(
2128
+ `当前提示词优化模型不可用${status.message ? `:${status.message}` : ''}。本次将跳过 AI 总结并生成默认格式的基础报告。`,
2129
+ '回放报告总结不可用',
2130
+ { type: 'warning', confirmButtonText: '继续回放' },
2131
+ ).catch(() => {});
2132
+ }
2133
+ } catch (error) {
2134
+ await ElMessageBox.alert(
2135
+ `无法检测提示词优化模型:${error instanceof Error ? error.message : '未知错误'}。本次将跳过 AI 总结并生成默认格式的基础报告。`,
2136
+ '回放报告总结不可用',
2137
+ { type: 'warning', confirmButtonText: '继续回放' },
2138
+ ).catch(() => {});
2139
+ } finally {
2140
+ preparingReplay.value = false;
2141
+ }
2142
+ }
2052
2143
  replaying.value = true;
2053
2144
  stoppingReplay.value = false;
2054
2145
  activeReplayDeviceId.value = selectedDeviceId.value;
@@ -2056,7 +2147,13 @@ async function replayScript() {
2056
2147
  try {
2057
2148
  await pauseAutoTreeRefresh();
2058
2149
  const result = await replayAppiumScript(
2059
- { id: selectedScript.value.id, deviceId: activeReplayDeviceId.value, recordVideo: recordReplayVideo.value },
2150
+ {
2151
+ id: selectedScript.value.id,
2152
+ deviceId: activeReplayDeviceId.value,
2153
+ recordVideo: recordReplayVideo.value,
2154
+ reportSummaryEnabled,
2155
+ reportSummaryPrompt,
2156
+ },
2060
2157
  (line) => {
2061
2158
  replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
2062
2159
  },
@@ -2151,17 +2248,26 @@ watch(
2151
2248
  <el-button
2152
2249
  type="primary"
2153
2250
  :icon="VideoPlay"
2154
- :loading="replaying"
2251
+ :loading="replaying || preparingReplay"
2155
2252
  :disabled="!selectedScript"
2156
2253
  @click="replayScript"
2157
2254
  >
2158
2255
  回放
2159
2256
  </el-button>
2160
- <el-popover trigger="click" placement="bottom" :width="260">
2161
- <template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
2162
- <el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
2163
- <el-checkbox v-model="recordReplayVideo" :disabled="replaying">录制回放视频</el-checkbox>
2164
- </el-tooltip>
2257
+ <el-popover trigger="click" placement="bottom" :width="320">
2258
+ <template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying || preparingReplay" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
2259
+ <div class="replay-settings-panel">
2260
+ <el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
2261
+ <el-checkbox v-model="recordReplayVideo" :disabled="replaying || preparingReplay">录制回放视频</el-checkbox>
2262
+ </el-tooltip>
2263
+ <div class="replay-report-template">
2264
+ <span>报告模板</span>
2265
+ <el-select v-model="selectedReportTemplateName" clearable :disabled="replaying || preparingReplay || !reportSummary?.enabled" placeholder="默认测试报告(未选择时使用)">
2266
+ <el-option v-for="template in availableReportTemplates" :key="template.name" :label="template.name" :value="template.name" />
2267
+ </el-select>
2268
+ <small v-if="!reportSummary?.enabled">需先在参数配置中开启回放报告总结。</small>
2269
+ </div>
2270
+ </div>
2165
2271
  </el-popover>
2166
2272
  </el-button-group>
2167
2273
  <el-button
@@ -2191,6 +2297,8 @@ watch(
2191
2297
  :selected-bounds="selectedBounds"
2192
2298
  :selected-region="visualChangeSelectedRegion"
2193
2299
  :region-selection="visualChangeRegionSelectionEnabled"
2300
+ :point-selection="pickingCoordinate"
2301
+ @cancel-point-selection="coordinatePicker.cancel()"
2194
2302
  :region-draw-mode="visualChangePicking || imageCheckPicking"
2195
2303
  :device-width="deviceWidth"
2196
2304
  :device-height="deviceHeight"
@@ -2231,23 +2339,6 @@ watch(
2231
2339
  />
2232
2340
  </el-select>
2233
2341
  </div>
2234
- <details
2235
- v-if="scriptActivityMismatch"
2236
- class="appium-activity-summary"
2237
- >
2238
- <summary>当前页面与脚本绑定的 Activity 不一致</summary>
2239
- <div class="appium-activity-lock-alert__details">
2240
- <div>
2241
- <strong>脚本绑定</strong>
2242
- <code>{{ selectedScript?.appActivity || '-' }}</code>
2243
- </div>
2244
- <div>
2245
- <strong>当前 Activity</strong>
2246
- <code>{{ currentActivity || '正在获取' }}</code>
2247
- </div>
2248
- <p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
2249
- </div>
2250
- </details>
2251
2342
  </section>
2252
2343
 
2253
2344
  <section class="appium-workbench__section appium-workbench__flow">
@@ -2277,9 +2368,6 @@ watch(
2277
2368
  </div>
2278
2369
  </el-tab-pane>
2279
2370
 
2280
- <el-tab-pane label="预设变量" name="variables" lazy>
2281
- <PresetVariables :key="`${selectedScriptId}:${newScriptRevision}`" v-model:variables="scriptVariables" :script-id="selectedScriptId" :script-name="form.name" :disabled="recordingBusy || saving" @saved="onVariablesSaved" />
2282
- </el-tab-pane>
2283
2371
  <el-tab-pane label="脚本列表" name="scripts">
2284
2372
  <div class="appium-script-list">
2285
2373
  <div class="appium-script-list__toolbar">
@@ -2357,6 +2445,9 @@ watch(
2357
2445
  </template>
2358
2446
  </div>
2359
2447
  </el-tab-pane>
2448
+ <el-tab-pane label="预设变量" name="variables" lazy>
2449
+ <PresetVariables :key="`${selectedScriptId}:${newScriptRevision}`" v-model:variables="scriptVariables" :script-id="selectedScriptId" :script-name="form.name" :disabled="recordingBusy || saving" @saved="onVariablesSaved" />
2450
+ </el-tab-pane>
2360
2451
  </el-tabs>
2361
2452
  </el-card>
2362
2453
  </RecorderWorkspace>
@@ -2372,6 +2463,7 @@ watch(
2372
2463
  />
2373
2464
 
2374
2465
 
2466
+ <AiRecognitionTestDialog v-if="aiRecognitionTestStep" :step="aiRecognitionTestStep" :device-id="selectedDeviceId" :model-configured="Boolean(aiRecognitionModelConfigured)" @close="aiRecognitionTestStep = null" />
2375
2467
  <ImageCheckDialog
2376
2468
  v-if="imageCheckDraft?.imageCheck"
2377
2469
  :timeout-branch="imageCheckDraft.timeoutBranch"
@@ -2402,7 +2494,7 @@ watch(
2402
2494
  :show-close="false"
2403
2495
  >
2404
2496
  <div class="appium-navigation-dialog">
2405
- <p>当前录制仅支持单个 Activity。检测到 Activity 已跳转,请先保存当前脚本。</p>
2497
+ <p>检测到 Activity 已跳转。可以继续在当前脚本中录制后续操作,或保存当前脚本并开始录制新页面。</p>
2406
2498
  <dl>
2407
2499
  <div>
2408
2500
  <dt>点击前</dt>
@@ -2416,6 +2508,7 @@ watch(
2416
2508
  </div>
2417
2509
  <template #footer>
2418
2510
  <div class="appium-navigation-footer">
2511
+ <el-button :disabled="resolvingNavigation" @click="continueOnActivityChange">继续录制</el-button>
2419
2512
  <el-button type="primary" :loading="resolvingNavigation" @click="saveOnActivityChange">保存脚本</el-button>
2420
2513
  </div>
2421
2514
  </template>
@@ -2432,7 +2525,7 @@ watch(
2432
2525
  <el-form-item :label="linkedScriptBranchTarget ? '分支录制 Activity' : '插入点 Activity'">
2433
2526
  <code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
2434
2527
  </el-form-item>
2435
- <el-form-item :label="linkedScriptBranchTarget ? '选择同一 App 的脚本(回放时等待入口 Activity)' : '选择入口 Activity 匹配的脚本'">
2528
+ <el-form-item label="选择同一 App 的脚本">
2436
2529
  <div class="appium-linked-script-picker">
2437
2530
  <el-select
2438
2531
  v-model="linkedScriptTargetId"
@@ -2456,6 +2549,11 @@ watch(
2456
2549
  </el-button>
2457
2550
  </div>
2458
2551
  </el-form-item>
2552
+ <el-alert v-if="linkedScriptTarget" :closable="false" type="info" show-icon
2553
+ :title="linkedScriptTarget.appActivity
2554
+ ? `回放时等待进入 ${linkedScriptTarget.appActivity},请在前序步骤中完成页面跳转。`
2555
+ : '此脚本未绑定入口 Activity,将直接从当前画面执行。请确保前序步骤已进入目标页面。'"
2556
+ />
2459
2557
  <ScriptParameterSettings :step="linkedParameters" @update="Object.assign(linkedParameters, $event)" />
2460
2558
  </el-form>
2461
2559
  <template #footer>
@@ -2554,9 +2652,6 @@ watch(
2554
2652
  <style scoped>
2555
2653
  .appium-script-icon { display: inline-flex; }
2556
2654
  .appium-script-icon .el-button { width: 30px; height: 30px; min-height: 30px; padding: 0; margin: 0; }
2557
- .appium-activity-summary { margin: 8px 0; color: #946000; font-size: 12px; }
2558
- .appium-activity-summary summary { cursor: pointer; line-height: 20px; }
2559
- .appium-activity-summary[open] .appium-activity-lock-alert__details { max-height: 110px; overflow: auto; margin-top: 6px; }
2560
2655
  :deep(.appium-recorder-card--workbench > .el-card__body) { overflow: hidden; }
2561
2656
  :deep(.appium-workbench-tabs > .el-tabs__content) { overflow: hidden; }
2562
2657
  :deep(.appium-workbench-tabs .el-tab-pane) { height: 100%; overflow: auto; }
@@ -2568,4 +2663,8 @@ watch(
2568
2663
  .appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
2569
2664
  .replay-button-group { display: inline-flex; flex-shrink: 0; }
2570
2665
  .replay-settings-toggle { padding-inline: 8px; }
2666
+ .replay-settings-panel { display: grid; gap: 14px; }
2667
+ .replay-report-template { display: grid; gap: 6px; }
2668
+ .replay-report-template > span { color: var(--el-text-color-regular); font-size: 13px; }
2669
+ .replay-report-template small { color: var(--el-text-color-secondary); line-height: 1.5; }
2571
2670
  </style>
@@ -123,7 +123,7 @@ onMounted(load);
123
123
  <p v-if="!run.frames.some(frame => !nodeKey || frame.key === nodeKey)">未捕获到对应截图</p>
124
124
  <div class="history-frames">
125
125
  <figure v-for="(frame, index) in run.frames.filter(frame => !nodeKey || frame.key === nodeKey)" :key="index">
126
- <figcaption>{{ frame.label }} · {{ frame.phase === 'before' ? '执行前' : frame.phase === 'after' ? '执行后' : frame.phase }} · {{ time(frame.capturedAt) }}</figcaption>
126
+ <figcaption>{{ frame.label }} · {{ frame.phase === 'before' ? '执行前' : frame.phase === 'after' ? '执行后' : frame.phase === 'observation' ? '观察采样' : frame.phase }} · {{ time(frame.capturedAt) }}</figcaption>
127
127
  <el-image :src="frame.imageUrl" :preview-src-list="[frame.imageUrl]" preview-teleported fit="contain" loading="lazy" />
128
128
  </figure>
129
129
  </div>
@@ -21,12 +21,13 @@ export const actionDescriptions: Record<InsertAction, string> = {
21
21
  endFlow: '立即结束当前回放流程,后续节点不再执行。',
22
22
  loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
23
23
  breakLoop: '退出指定的当前或外层循环,继续执行该循环的结束路径。',
24
+ continueLoop: '跳过当前轮剩余节点,进入指定的当前或外层循环的下一轮;达到最大次数后执行循环结束路径。',
24
25
  clearAppData: '清除目标应用数据并重置应用状态,可能删除登录信息和本地设置,仅允许在流程开始处添加。',
25
26
  popupCondition: '判断目标组件是否存在,可进一步配置指定文本及精确或模糊匹配,按结果进入是或否分支。',
26
27
  checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
27
28
  radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
28
29
  checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
29
- aiRecognition: '此操作已移除,请使用图像判断或原生组件判断。',
30
+ aiRecognition: '将设备截图发送给视觉模型,支持单帧识别、观察结束后统一分析及检测命中后提前结束。默认顺序执行,启用分支后按判断结果选择分支。需要配置支持图片输入的模型;边观察边检测可能多次调用模型,实际响应受模型速度影响。',
30
31
  textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
31
32
  tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
32
33
  inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
@@ -0,0 +1,34 @@
1
+ export type AiDeduplicationConfig = {
2
+ method: 'none' | 'pixelmatch' | 'opencv';
3
+ pixelmatch: { threshold: number; maxChangedRatio: number };
4
+ opencv: { similarityThreshold: number; maxDimension: number };
5
+ };
6
+
7
+ export function resolveAiDeduplication(value?: unknown): AiDeduplicationConfig {
8
+ const object = (value: unknown): Record<string, unknown> => {
9
+ if (value === undefined) return {};
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('截图去重配置必须为对象');
11
+ return value as Record<string, unknown>;
12
+ };
13
+ const input = object(value), pixel = object(input.pixelmatch), cv = object(input.opencv);
14
+ const method = input.method ?? 'pixelmatch';
15
+ if (method !== 'none' && method !== 'pixelmatch' && method !== 'opencv') throw new Error('截图去重方式必须为 none、pixelmatch 或 opencv');
16
+ const number = (value: unknown, fallback: number, min: number, max: number, label: string, integer = false) => {
17
+ if (value === undefined) return fallback;
18
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) {
19
+ throw new Error(`${label}需为 ${min}~${max} ${integer ? '的整数' : '的数值'}`);
20
+ }
21
+ return value;
22
+ };
23
+ return {
24
+ method,
25
+ pixelmatch: {
26
+ threshold: number(pixel.threshold, 0.1, 0, 1, '像素颜色容差'),
27
+ maxChangedRatio: number(pixel.maxChangedRatio, 0.5, 0, 100, '允许变化面积(%)'),
28
+ },
29
+ opencv: {
30
+ similarityThreshold: number(cv.similarityThreshold, 0.99, 0.5, 1, 'SSIM 相似度阈值'),
31
+ maxDimension: number(cv.maxDimension, 512, 128, 1024, '比较图像最长边', true),
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,34 @@
1
+ import type { InjectionKey, Ref } from 'vue';
2
+ import { validateAiRecognitionPrompt } from './ai-recognition';
3
+
4
+ export type AiPromptPreset = { name: string; prompt: string };
5
+ export const DEFAULT_AI_PROMPT_PRESETS: readonly AiPromptPreset[] = [
6
+ { name: '出图检测', prompt: '检测当前画面有没有出图?请排除黑屏、白屏和加载占位画面。' },
7
+ { name: '广告及跳过关闭按钮检测', prompt: '检查当前画面有没有广告出现,并说明有没有跳过、关闭按钮。升级弹窗、通知弹窗不属于广告。' },
8
+ { name: '广告内容提取', prompt: '识别当前广告的主要内容,并提取广告标题、产品名称和按钮文字。升级弹窗、通知弹窗不属于广告。' },
9
+ { name: '异常提示检测', prompt: '当前画面是否出现网络异常、加载失败或错误提示?' },
10
+ ];
11
+
12
+ export const aiPromptPresetsKey: InjectionKey<Readonly<Ref<readonly AiPromptPreset[]>>> = Symbol('aiPromptPresets');
13
+
14
+ export function resolveAiPromptPresets(value?: unknown): AiPromptPreset[] {
15
+ // 缺少字段时使用默认场景;空数组保留用户删除结果;兼容旧版纯文本预设。
16
+ if (value === undefined) return DEFAULT_AI_PROMPT_PRESETS.map(preset => ({ ...preset }));
17
+ if (!Array.isArray(value)) throw new Error('AI 识别预设提示词必须是列表');
18
+ const names = new Set<string>();
19
+ const prompts = new Set<string>();
20
+ return value.map((item, index) => {
21
+ const legacy = typeof item === 'string';
22
+ const prompt = validateAiRecognitionPrompt(legacy ? item : item?.prompt);
23
+ const name = legacy
24
+ ? DEFAULT_AI_PROMPT_PRESETS.find(preset => preset.prompt === prompt)?.name ?? `自定义场景 ${index + 1}`
25
+ : typeof item?.name === 'string' ? item.name.trim() : '';
26
+ if (!name) throw new Error('请输入测试场景名称');
27
+ if (name.length > 80) throw new Error('测试场景名称不能超过 80 字');
28
+ if (names.has(name)) throw new Error('测试场景名称已存在');
29
+ if (prompts.has(prompt)) throw new Error('该提示词已存在');
30
+ names.add(name);
31
+ prompts.add(prompt);
32
+ return { name, prompt };
33
+ });
34
+ }
@@ -1,7 +1,32 @@
1
+ import type { AiDeduplicationConfig } from './ai-deduplication';
2
+
1
3
  export type AiRecognitionModel = { baseUrl: string; apiKey: string; name: string };
2
- export type AiRecognitionResult = { result: boolean; reason: string; durationMs: number };
4
+ export type AiObservationConfig = { durationMs: number; intervalMs: number; mode?: 'batch' | 'untilMatch' };
5
+ export type AiObservationFrame = { elapsedMs: number; imageDataUrl: string };
6
+ export type AiRecognitionResult = {
7
+ result: boolean | null; reason: string; durationMs: number;
8
+ observation?: { durationMs: number; frames: AiObservationFrame[]; sampleCount?: number; deduplicationMethod?: AiDeduplicationConfig['method']; completion?: 'matched' | 'elapsed'; modelRequestCount?: number };
9
+ };
10
+ export const DEFAULT_AI_OBSERVATION: AiObservationConfig = { durationMs: 15000, intervalMs: 1000 };
11
+ export const MAX_AI_OBSERVATION_FRAMES = 30;
12
+
13
+ export function validateAiObservation(value: unknown): AiObservationConfig | undefined {
14
+ if (value === undefined || value === null) return undefined;
15
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error('持续观察配置无效');
16
+ const { durationMs, intervalMs, mode } = value as AiObservationConfig;
17
+ if (mode !== undefined && mode !== 'batch' && mode !== 'untilMatch') throw new Error('持续观察模式无效');
18
+ if (!Number.isInteger(durationMs) || durationMs < 1000 || durationMs > 60000) throw new Error('观察时长需为 1000~60000 ms');
19
+ if (!Number.isInteger(intervalMs) || intervalMs < 500 || intervalMs > 10000) throw new Error('采样间隔需为 500~10000 ms');
20
+ if (Math.ceil(durationMs / intervalMs) + 1 > MAX_AI_OBSERVATION_FRAMES) throw new Error('单次观察最多采集 30 帧,请增大采样间隔或缩短观察时长');
21
+ return { durationMs, intervalMs, ...(mode ? { mode } : {}) };
22
+ }
23
+
24
+ // 未保存此字段的是旧版判断节点,保留已有分支;新节点显式保存 false。
25
+ export function isAiBranchEnabled(step: { aiBranchEnabled?: boolean }) {
26
+ return step.aiBranchEnabled !== false;
27
+ }
3
28
 
4
- export const AI_MODEL_CONFIG_HINT = '请在参数配置 > Appium配置中填写并保存 AI 识别模型的 Base URL、API Key 和 Model Name';
29
+ export const AI_MODEL_CONFIG_HINT = '请在参数配置 > Appium配置中填写 AI 识别模型的 Base URL、API Key 和 Model Name,配置会自动保存';
5
30
 
6
31
  export function isAiRecognitionModelConfigured(model?: Partial<AiRecognitionModel> | null) {
7
32
  return Boolean(model && [model.baseUrl, model.apiKey, model.name].every(