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,8 +1,32 @@
1
1
  import { APP_BASE } from '../api';
2
+ import type { AiObservationConfig, AiRecognitionResult } from './ai-recognition';
2
3
  import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
3
4
  import type { TestVariable } from './variables';
4
5
  import type { AppiumVisualChangeRegion } from './types';
5
6
 
7
+ export async function testAiRecognition(input: { deviceId: string; prompt: string; timeoutMs?: number; aiTimeoutEnabled?: boolean; aiBranchEnabled?: boolean; aiObservation?: AiObservationConfig }, signal?: AbortSignal) {
8
+ const response = await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/test`, {
9
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
10
+ });
11
+ return readJson<AiRecognitionResult & { imageBase64: string }>(response);
12
+ }
13
+
14
+ export async function validateAiBranchQuestion(prompt: string, signal?: AbortSignal) {
15
+ return readJson<{ result: boolean; reason: string }>(await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/validate-question`, {
16
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), signal,
17
+ }));
18
+ }
19
+
20
+ export async function optimizeAppiumPrompt(input: {
21
+ kind: 'aiRecognition' | 'reportSummary';
22
+ prompt: string;
23
+ condition?: boolean;
24
+ }, signal?: AbortSignal) {
25
+ return readJson<{ prompt: string }>(await fetch(`${APP_BASE}/api/appium-recorder/prompts/optimize`, {
26
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
27
+ }));
28
+ }
29
+
6
30
  export function captureImageCheckRegion(deviceId: string, region: AppiumVisualChangeRegion, screenWidth: number, screenHeight: number) {
7
31
  return postJson<{ base64: string; screenWidth: number; screenHeight: number }>(`${APP_BASE}/api/appium-recorder/image-check/capture`, { deviceId, region, screenWidth, screenHeight });
8
32
  }
@@ -124,7 +148,7 @@ type ReplayStreamEvent =
124
148
  | { type: 'error'; message: string };
125
149
 
126
150
  export async function replayAppiumScript(
127
- input: { id: string; deviceId?: string; recordVideo?: boolean; parameters?: import('./variables').TestVariable[] },
151
+ input: { id: string; deviceId?: string; recordVideo?: boolean; reportSummaryEnabled?: boolean; reportSummaryPrompt?: string; parameters?: import('./variables').TestVariable[] },
128
152
  onOutput?: (line: string) => void,
129
153
  ) {
130
154
  const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
@@ -133,7 +157,13 @@ export async function replayAppiumScript(
133
157
  'Content-Type': 'application/json',
134
158
  Accept: 'application/x-ndjson',
135
159
  },
136
- body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters, recordVideo: input.recordVideo }),
160
+ body: JSON.stringify({
161
+ deviceId: input.deviceId,
162
+ parameters: input.parameters,
163
+ recordVideo: input.recordVideo,
164
+ reportSummaryEnabled: input.reportSummaryEnabled,
165
+ reportSummaryPrompt: input.reportSummaryPrompt,
166
+ }),
137
167
  });
138
168
  if (!response.ok || !response.body) return readJson<ReplayResult>(response);
139
169
 
@@ -163,6 +193,10 @@ export async function replayAppiumScript(
163
193
  return result;
164
194
  }
165
195
 
196
+ export function checkReportSummaryModel() {
197
+ return postJson<{ enabled: boolean; available: boolean; message?: string }>(`${APP_BASE}/api/appium-recorder/report-summary/check`);
198
+ }
199
+
166
200
  export function stopAppiumReplay(deviceId?: string) {
167
201
  return postJson<{ success: boolean; stopped: boolean }>(
168
202
  `${APP_BASE}/api/appium-recorder/replay/stop`,
@@ -42,8 +42,15 @@ export function breakLoopTarget(steps: readonly AppiumRecordedStep[], step: Appi
42
42
  return target;
43
43
  }
44
44
 
45
+ export function continueLoopTarget(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep) {
46
+ const loops = enclosingLoops(steps, step);
47
+ const target = step.continueLoopTargetId ? loops.find((loop) => loop.id === step.continueLoopTargetId) : loops[0];
48
+ if (!target) throw new Error('继续循环目标必须是当前节点所属的循环体,目标可能已删除或不在当前分支');
49
+ return target;
50
+ }
51
+
45
52
  export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
46
- if (!steps.some((step) => step.type === 'loop' || step.type === 'breakLoop')) return;
53
+ if (!steps.some((step) => ['loop', 'breakLoop', 'continueLoop'].includes(step.type))) return;
47
54
  const byId = new Map(steps.map((step) => [step.id, step]));
48
55
  if (byId.size !== steps.length) throw new Error('流程节点 ID 重复');
49
56
  for (const step of steps) {
@@ -58,6 +65,7 @@ export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
58
65
  }
59
66
  if (step.type === 'loop') validateLoop(step);
60
67
  if (step.type === 'breakLoop') breakLoopTarget(steps, step);
68
+ if (step.type === 'continueLoop') continueLoopTarget(steps, step);
61
69
  if (step.type === 'loop' && step.flow?.yesTargetId) {
62
70
  const target = byId.get(step.flow.yesTargetId);
63
71
  if (!target || target.flow?.parentConditionId !== step.id || target.flow.parentBranch !== 'yes') throw new Error('循环入口必须指向所属循环体');
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import type { AiPromptPreset } from '../ai-prompt-presets';
4
+ const props = defineProps<{ modelValue: string; presets: readonly AiPromptPreset[]; disabled?: boolean; placeholder?: string }>();
5
+ const selectedPreset = computed(() => props.presets.find(preset => preset.prompt === props.modelValue));
6
+ const dropdownOpen = ref(false);
7
+ const emit = defineEmits<{ 'update:modelValue': [value: string]; change: [value: string] }>();
8
+ function selectPreset(value: string) {
9
+ const preset = props.presets.find(preset => preset.name === value);
10
+ if (!preset) return;
11
+ emit('update:modelValue', preset.prompt);
12
+ emit('change', preset.prompt);
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <div class="recognition-prompt-input">
18
+ <el-tooltip :disabled="!selectedPreset || dropdownOpen" :show-after="300" placement="top">
19
+ <template #content><div class="recognition-preset-detail">{{ selectedPreset?.prompt }}</div></template>
20
+ <el-select :model-value="selectedPreset?.name" :disabled="disabled" filterable placeholder="选择测试场景,或在下方输入" aria-label="选择测试场景" @change="selectPreset" @visible-change="dropdownOpen = $event">
21
+ <el-option v-for="preset in presets" :key="preset.name" :label="preset.name" :value="preset.name" class="recognition-preset-option">
22
+ <el-tooltip :show-after="300" placement="right">
23
+ <template #content><div class="recognition-preset-detail">{{ preset.prompt }}</div></template>
24
+ <span class="recognition-preset-name">{{ preset.name }}</span>
25
+ </el-tooltip>
26
+ </el-option>
27
+ <template #empty><div class="recognition-preset-empty">暂无匹配的预设,可在 AI 识别配置中添加</div></template>
28
+ </el-select>
29
+ </el-tooltip>
30
+ <el-input :model-value="modelValue" type="textarea" :rows="3" maxlength="4000" :placeholder="placeholder" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" @change="emit('change', $event)" />
31
+ </div>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .recognition-prompt-input { width: 100%; }
36
+ .recognition-prompt-input .el-select { width: 100%; margin-bottom: 10px; }
37
+ .recognition-preset-name { display: block; }
38
+ .recognition-preset-detail { max-width: min(360px, calc(100vw - 40px)); white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
39
+ .recognition-preset-option { height: auto; min-height: 34px; white-space: normal; line-height: 1.6; padding-top: 8px; padding-bottom: 8px; overflow-wrap: anywhere; }
40
+ .recognition-preset-empty { padding: 12px; color: var(--el-text-color-secondary); font-size: 12px; }
41
+ </style>
@@ -0,0 +1,167 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
3
+ import { Loading, QuestionFilled } from '@element-plus/icons-vue';
4
+ import { isAiBranchEnabled, validateAiRecognitionPrompt, DEFAULT_AI_OBSERVATION, validateAiObservation, type AiObservationConfig } from '../ai-recognition';
5
+ import { optimizeAppiumPrompt, validateAiBranchQuestion } from '../api';
6
+ import type { AppiumRecordedStep } from '../types';
7
+ import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS } from '../ai-prompt-presets';
8
+ import AiRecognitionPromptInput from './AiRecognitionPromptInput.vue';
9
+
10
+ const props = defineProps<{ step: AppiumRecordedStep; steps: AppiumRecordedStep[]; disabled?: boolean }>();
11
+ const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
12
+ const prompt = ref(props.step.value || '');
13
+ const promptPresets = inject(aiPromptPresetsKey, ref(DEFAULT_AI_PROMPT_PRESETS));
14
+ const busy = ref(false);
15
+ const optimizingPrompt = shallowRef(false);
16
+ const error = ref('');
17
+ const branch = computed(() => isAiBranchEnabled(props.step));
18
+ const untilMatch = computed(() => props.step.aiObservation?.mode === 'untilMatch');
19
+ const branchChoice = ref(branch.value);
20
+ watch(branch, value => { branchChoice.value = value; });
21
+ let controller: AbortController | undefined;
22
+ let optimizeController: AbortController | undefined;
23
+ watch(() => props.step.value, value => { prompt.value = value || ''; });
24
+ onBeforeUnmount(() => { controller?.abort(); optimizeController?.abort(); });
25
+
26
+ function updateObservation(patch: Partial<AiObservationConfig>) {
27
+ try {
28
+ const aiObservation = validateAiObservation({ ...DEFAULT_AI_OBSERVATION, ...props.step.aiObservation, ...patch });
29
+ error.value = '';
30
+ emit('update', { aiObservation });
31
+ } catch (cause) { error.value = cause instanceof Error ? cause.message : '持续观察配置无效'; }
32
+ }
33
+
34
+ async function toggleBranch(enabled: boolean) {
35
+ branchChoice.value = enabled;
36
+ await save(enabled);
37
+ branchChoice.value = branch.value;
38
+ }
39
+
40
+ async function optimizePrompt() {
41
+ if (busy.value || optimizingPrompt.value || props.disabled) return;
42
+ error.value = '';
43
+ optimizeController = new AbortController();
44
+ optimizingPrompt.value = true;
45
+ let optimized = '';
46
+ try {
47
+ optimized = (await optimizeAppiumPrompt({
48
+ kind: 'aiRecognition',
49
+ prompt: prompt.value,
50
+ condition: branch.value || untilMatch.value,
51
+ }, optimizeController.signal)).prompt;
52
+ prompt.value = optimized;
53
+ } catch (cause) {
54
+ if (!optimizeController.signal.aborted) error.value = cause instanceof Error ? cause.message : '提示词优化失败';
55
+ } finally {
56
+ optimizingPrompt.value = false;
57
+ }
58
+ if (optimized) await save(branch.value);
59
+ }
60
+
61
+ async function save(enabled: boolean, observation?: AiObservationConfig) {
62
+ if (busy.value || props.disabled) return;
63
+ error.value = '';
64
+ if (!enabled && branch.value && (props.step.flow?.yesTargetId || props.step.flow?.noTargetId
65
+ || props.steps.some(step => step.flow?.parentConditionId === props.step.id))) {
66
+ error.value = '请先移除当前 AI 节点的子分支,再关闭分支模式';
67
+ return;
68
+ }
69
+ controller = new AbortController();
70
+ const signal = controller.signal;
71
+ busy.value = true;
72
+ try {
73
+ const value = validateAiRecognitionPrompt(prompt.value);
74
+ if (enabled || (observation ?? props.step.aiObservation)?.mode === 'untilMatch') {
75
+ const validation = await validateAiBranchQuestion(value, signal);
76
+ if (!validation.result) throw new Error(`${enabled ? '无法进入分支' : '命中条件无效'}:${validation.reason || '请填写可用 true/false 判断的问题'}`);
77
+ }
78
+ if (signal.aborted) return;
79
+ emit('update', { value, aiBranchEnabled: enabled, ...(observation ? { aiObservation: observation } : {}), flow: { ...props.step.flow, nodeKind: enabled ? 'condition' : 'action' } });
80
+ } catch (cause) {
81
+ if (!signal.aborted) error.value = `${cause instanceof Error ? cause.message : '问题验证失败'}(未保存本次修改)`;
82
+ } finally { busy.value = false; }
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <el-form-item label="识别方式">
88
+ <template #label>
89
+ <span class="recognition-label">识别方式
90
+ <el-tooltip placement="top" :show-after="200">
91
+ <template #content><div class="recognition-help-content">单帧识别:截取当前画面并分析。持续观察:按采样间隔连续截图,判断目标是否在观察期间出现过。建议紧接启动 App 或触发广告的操作放置本节点。</div></template>
92
+ <el-button class="recognition-help" text :icon="QuestionFilled" aria-label="识别方式说明" @click.prevent />
93
+ </el-tooltip>
94
+ </span>
95
+ </template>
96
+ <el-radio-group :model-value="Boolean(step.aiObservation)" :disabled="disabled || busy" @update:model-value="emit('update', { aiObservation: $event ? { ...DEFAULT_AI_OBSERVATION } : undefined }); error = ''">
97
+ <el-radio-button :value="false">单帧识别</el-radio-button>
98
+ <el-radio-button :value="true">持续观察</el-radio-button>
99
+ </el-radio-group>
100
+ </el-form-item>
101
+ <template v-if="step.aiObservation">
102
+ <el-form-item label="观察模式">
103
+ <template #label>
104
+ <span class="recognition-label">观察模式
105
+ <el-tooltip placement="top" :show-after="200">
106
+ <template #content>
107
+ <div class="recognition-help-content">
108
+ <p>观察结束后统一分析:完成采样后,将保留的截图一次提交给模型。</p>
109
+ <p>检测命中后提前结束:采样期间逐次识别,确认命中后停止观察并执行下一节点;启用分支时走“是”分支。达到最长观察时间后停止采样,等待剩余关键帧判断,未命中则继续下一节点或走“否”分支。</p>
110
+ <p>最多采样 30 帧,实际速度取决于设备,截图按去重配置保留。观察时长与模型请求超时分别计时;提前结束模式可能多次调用模型,实际结束时间包含模型等待,识别错误不会视为未命中。</p>
111
+ </div>
112
+ </template>
113
+ <el-button class="recognition-help" text :icon="QuestionFilled" aria-label="观察模式说明" @click.prevent />
114
+ </el-tooltip>
115
+ </span>
116
+ </template>
117
+ <el-radio-group :model-value="step.aiObservation.mode ?? 'batch'" :disabled="disabled || busy" @update:model-value="$event === 'untilMatch' ? save(branch, { ...step.aiObservation!, mode: 'untilMatch' }) : updateObservation({ mode: 'batch' })">
118
+ <el-radio-button value="batch">观察结束后统一分析</el-radio-button>
119
+ <el-radio-button value="untilMatch">检测命中后提前结束</el-radio-button>
120
+ </el-radio-group>
121
+ </el-form-item>
122
+ <el-form-item :label="untilMatch ? '最长观察时间 ms' : '观察时长 ms'">
123
+ <el-input-number :model-value="step.aiObservation.durationMs" :min="1000" :max="60000" :step="1000" :disabled="disabled || busy" controls-position="right" @change="updateObservation({ durationMs: $event })" />
124
+ </el-form-item>
125
+ <el-form-item label="采样间隔 ms">
126
+ <el-input-number :model-value="step.aiObservation.intervalMs" :min="500" :max="10000" :step="500" :disabled="disabled || busy" controls-position="right" @change="updateObservation({ intervalMs: $event })" />
127
+ </el-form-item>
128
+ </template>
129
+ <el-form-item :label="untilMatch ? '命中条件' : '识别内容'">
130
+ <template #label>
131
+ <span class="recognition-label">{{ untilMatch ? '命中条件' : '识别内容' }}
132
+ <el-tooltip v-if="untilMatch" placement="top" :show-after="200">
133
+ <template #content><div class="recognition-help-content">填写能用“是 / 否”判断的具体条件,例如“当前画面是否出现广告弹窗?”。模型判断为“是”即命中并提前结束观察;修改条件后需验证并保存。“广告内容是什么”等开放式问题请使用统一分析模式。</div></template>
134
+ <el-button class="recognition-help" text :icon="QuestionFilled" aria-label="命中条件说明" @click.prevent />
135
+ </el-tooltip>
136
+ </span>
137
+ </template>
138
+ <AiRecognitionPromptInput v-model="prompt" :presets="promptPresets" :placeholder="untilMatch ? '例如:当前画面是否出现广告弹窗?' : ''" :disabled="disabled || busy || optimizingPrompt" @change="error = ''; !branch && !untilMatch && save(false)" />
139
+ <div class="recognition-prompt-actions">
140
+ <el-button :loading="optimizingPrompt" :disabled="disabled || busy || !prompt.trim()" @click="optimizePrompt">优化提示词</el-button>
141
+ <el-button v-if="(branch || untilMatch) && prompt !== step.value" :disabled="disabled || busy || optimizingPrompt" @click="save(branch)">验证并保存问题</el-button>
142
+ </div>
143
+ </el-form-item>
144
+ <el-form-item>
145
+ <el-checkbox :model-value="branchChoice" :disabled="disabled || busy" @update:model-value="toggleBranch($event === true)">启用分支</el-checkbox>
146
+ <span v-if="busy" class="validation-status" role="status">
147
+ <el-icon class="is-loading" aria-hidden="true"><Loading /></el-icon>
148
+ 正在验证…
149
+ </span>
150
+ </el-form-item>
151
+ <el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
152
+ </template>
153
+
154
+ <style scoped>
155
+ .recognition-label { display: inline-flex; align-items: center; gap: 4px; }
156
+ .recognition-help { width: 28px; height: 28px; padding: 0; font-size: 16px; }
157
+ .recognition-help-content { max-width: min(360px, calc(100vw - 40px)); line-height: 1.6; overflow-wrap: anywhere; }
158
+ .recognition-help-content p { margin: 0 0 8px; }
159
+ .recognition-help-content p:last-child { margin-bottom: 0; }
160
+ .recognition-prompt-actions { display: flex; justify-content: flex-end; width: 100%; margin-top: 10px; }
161
+ .validation-status {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ margin-left: 8px;
166
+ }
167
+ </style>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, shallowRef } from 'vue';
3
+ import { Refresh } from '@element-plus/icons-vue';
4
+ import { testAiRecognition } from '../api';
5
+ import { AI_MODEL_CONFIG_HINT, validateAiRecognitionPrompt, isAiBranchEnabled } from '../ai-recognition';
6
+ import type { AppiumRecordedStep } from '../types';
7
+
8
+ const props = defineProps<{ step: AppiumRecordedStep; deviceId: string; modelConfigured: boolean }>();
9
+ defineEmits<{ close: [] }>();
10
+ const loading = shallowRef(false);
11
+ const error = shallowRef('');
12
+ const result = shallowRef<Awaited<ReturnType<typeof testAiRecognition>> | null>(null);
13
+ let controller: AbortController | undefined;
14
+
15
+ async function runTest() {
16
+ if (loading.value) return;
17
+ error.value = '';
18
+ result.value = null;
19
+ controller = new AbortController();
20
+ loading.value = true;
21
+ try {
22
+ if (!props.modelConfigured) throw new Error(AI_MODEL_CONFIG_HINT);
23
+ if (!props.deviceId) throw new Error('请选择设备');
24
+ const prompt = validateAiRecognitionPrompt(props.step.value);
25
+ result.value = await testAiRecognition({ deviceId: props.deviceId, prompt, timeoutMs: props.step.timeoutMs, aiTimeoutEnabled: props.step.aiTimeoutEnabled === true, aiBranchEnabled: isAiBranchEnabled(props.step), aiObservation: props.step.aiObservation }, controller.signal);
26
+ } catch (cause) {
27
+ if (!controller.signal.aborted) error.value = cause instanceof Error ? cause.message : 'AI 识别测试失败';
28
+ } finally {
29
+ loading.value = false;
30
+ }
31
+ }
32
+
33
+ onMounted(runTest);
34
+ onBeforeUnmount(() => controller?.abort());
35
+ </script>
36
+
37
+ <template>
38
+ <el-dialog :model-value="true" title="测试 AI 识别" width="min(560px, calc(100vw - 32px))" append-to-body @close="$emit('close')">
39
+ <div class="ai-recognition-test" aria-live="polite">
40
+ <p class="ai-recognition-test__prompt">{{ step.value }}</p>
41
+ <el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
42
+ <p v-if="loading">{{ step.aiObservation?.mode === 'untilMatch' ? `边观察边检测中…(最长采样 ${step.aiObservation.durationMs / 1000} 秒,命中即结束,未命中需等待剩余识别)` : step.aiObservation ? `持续观察并分析中…(观察 ${step.aiObservation.durationMs / 1000} 秒,之后等待模型返回)` : '识别中…' }}</p>
43
+ <template v-if="result">
44
+ <div class="ai-recognition-test__result">
45
+ <strong>{{ result.result === null ? '识别完成' : String(result.result) }}</strong>
46
+ <span>{{ result.durationMs }}ms</span>
47
+ </div>
48
+ <p>{{ result.reason }}</p>
49
+ <template v-if="result.observation">
50
+ <p v-if="result.observation.completion">{{ result.observation.completion === 'matched' ? '检测命中,已结束观察' : '观察结束,未命中条件' }} · 模型请求 {{ result.observation.modelRequestCount }} 次</p>
51
+ <p>实际观察 {{ (result.observation.durationMs / 1000).toFixed(1) }} 秒,采集 {{ result.observation.sampleCount ?? result.observation.frames.length }} 帧,提交模型 {{ result.observation.frames.length }} 帧</p>
52
+ <p v-if="result.observation.deduplicationMethod">去重方式:{{ result.observation.deduplicationMethod === 'none' ? '不去重' : result.observation.deduplicationMethod === 'opencv' ? 'OpenCV(SSIM)' : 'pixelmatch' }};下方为实际提交画面。</p>
53
+ <div class="ai-observation-frames">
54
+ <figure v-for="(frame, index) in result.observation.frames" :key="index">
55
+ <el-image :src="frame.imageDataUrl" :preview-src-list="result.observation.frames.map(item => item.imageDataUrl)" :initial-index="index" preview-teleported fit="contain" :alt="`观察第 ${index + 1} 帧`" />
56
+ <figcaption>第 {{ index + 1 }} 帧 · {{ (frame.elapsedMs / 1000).toFixed(1) }}s</figcaption>
57
+ </figure>
58
+ </div>
59
+ </template>
60
+ <el-image
61
+ v-else-if="result.imageBase64"
62
+ class="ai-recognition-test__image" fit="contain"
63
+ :src="`data:image/png;base64,${result.imageBase64}`"
64
+ :preview-src-list="[`data:image/png;base64,${result.imageBase64}`]"
65
+ preview-teleported alt="AI 识别使用的设备截图"
66
+ />
67
+ </template>
68
+ </div>
69
+ <template #footer>
70
+ <el-button @click="$emit('close')">关闭</el-button>
71
+ <el-button type="primary" :icon="Refresh" :loading="loading" :disabled="!modelConfigured || !deviceId" @click="runTest">重新测试</el-button>
72
+ </template>
73
+ </el-dialog>
74
+ </template>
75
+
76
+ <style scoped>
77
+ .ai-observation-frames { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-height: 360px; overflow-y: auto; }
78
+ .ai-observation-frames figure { margin: 0; }
79
+ .ai-observation-frames .el-image { width: 100%; height: 140px; }
80
+ .ai-observation-frames figcaption { font-size: 12px; text-align: center; }
81
+ .ai-recognition-test { overflow-wrap: anywhere; }
82
+ .ai-recognition-test__prompt { white-space: pre-wrap; margin-top: 0; }
83
+ .ai-recognition-test__result { display: flex; align-items: center; gap: 16px; }
84
+ .ai-recognition-test__result strong { font-size: 20px; }
85
+ .ai-recognition-test__image { display: block; width: 100%; height: 280px; }
86
+ </style>
@@ -3,14 +3,30 @@ import { computed } from 'vue';
3
3
  import type { AppiumRecordedStep } from '../types';
4
4
  import { enclosingLoops } from '../bounded-loop';
5
5
 
6
- const props = defineProps<{ step: AppiumRecordedStep; steps: AppiumRecordedStep[]; disabled?: boolean }>();
6
+ const props = defineProps<{
7
+ step: AppiumRecordedStep;
8
+ steps: AppiumRecordedStep[];
9
+ disabled?: boolean;
10
+ mode?: 'break' | 'continue';
11
+ }>();
7
12
  const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
8
13
  const loops = computed(() => enclosingLoops(props.steps, props.step));
14
+ const isContinue = computed(() => props.mode === 'continue');
15
+ const fieldLabel = computed(() => isContinue.value ? '继续目标循环' : '退出目标循环');
16
+ const targetId = computed(() => (
17
+ isContinue.value ? props.step.continueLoopTargetId : props.step.breakLoopTargetId
18
+ ) || loops.value[0]?.id);
19
+
20
+ function updateTarget(value: string) {
21
+ emit('update', isContinue.value
22
+ ? { continueLoopTargetId: value }
23
+ : { breakLoopTargetId: value });
24
+ }
9
25
  </script>
10
26
 
11
27
  <template>
12
- <el-form-item label="退出目标循环">
13
- <el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="step.breakLoopTargetId || loops[0]?.id" :disabled="disabled" aria-label="退出目标循环" @update:model-value="emit('update', { breakLoopTargetId: $event })">
28
+ <el-form-item :label="fieldLabel">
29
+ <el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="targetId" :disabled="disabled" :aria-label="fieldLabel" @update:model-value="updateTarget">
14
30
  <el-option class="break-loop-target-option" v-for="(loop, index) in loops" :key="loop.id" :value="loop.id" :label="`${loop.label}(${index === 0 ? '当前循环' : `外层 ${index}`} · 节点 ${steps.findIndex(item => item.id === loop.id) + 1})`" />
15
31
  </el-select>
16
32
  </el-form-item>
@@ -69,9 +69,9 @@ watch(
69
69
  </template>
70
70
 
71
71
  <style scoped>
72
- .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
73
- .appium-tree { flex: 1; height: auto; overflow-x: hidden; }
74
- .appium-tree :deep(.el-tree) { min-width: 0; }
75
- .appium-tree-node { flex: 1; min-width: 0; overflow: hidden; }
76
- .appium-tree-node__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
73
+ .appium-tree { flex: 1; height: auto; overflow: auto; }
74
+ .appium-tree :deep(.el-tree) { width: max-content; min-width: 100%; }
75
+ .appium-tree-node { flex: 1 0 auto; }
76
+ .appium-tree-node__label { white-space: nowrap; }
77
77
  </style>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, onBeforeUnmount, watch } from 'vue';
3
+ import { Aim } from '@element-plus/icons-vue';
4
+ import { coordinatePreviewKey } from '../coordinate-preview';
5
+
6
+ const props = defineProps<{ disabled?: boolean }>();
7
+ const emit = defineEmits<{ select: [point: { x: number; y: number }] }>();
8
+ const picker = inject(coordinatePreviewKey, undefined);
9
+ const owner = Symbol('coordinate-editor');
10
+ const picking = computed(() => picker?.owner.value === owner);
11
+ function cancel() { picker?.cancel(owner); }
12
+ watch(() => props.disabled, (disabled) => { if (disabled) cancel(); });
13
+ onBeforeUnmount(cancel);
14
+ </script>
15
+
16
+ <template>
17
+ <el-form-item>
18
+ <el-button :icon="Aim" :disabled="disabled || !picker?.enabled.value" @click="picker?.start(owner, point => emit('select', point))">
19
+ {{ picking ? '请在左侧设备预览点击' : '获取点击坐标' }}
20
+ </el-button>
21
+ <el-button v-if="picking" text @click="cancel">取消取点</el-button>
22
+ </el-form-item>
23
+ </template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { nextTick, shallowRef } from 'vue';
3
3
  import type { MenuInstance, PopoverInstance } from 'element-plus';
4
- import { PASTE_COMMAND, type FlowActionGroup, type InsertAction } from '../flow-graph';
4
+ import { PASTE_COMMAND, type FlowActionGroup, type FlowActionSubgroup, type InsertAction } from '../flow-graph';
5
5
  import { actionDescriptions } from '../action-descriptions';
6
6
 
7
7
  const props = defineProps<{
@@ -21,6 +21,10 @@ const menuRef = shallowRef<HTMLElement>();
21
21
  const menuControlRef = shallowRef<MenuInstance>();
22
22
  const menuMounted = shallowRef(false);
23
23
 
24
+ function isSubgroup(item: FlowActionGroup['actions'][number]): item is FlowActionSubgroup {
25
+ return 'actions' in item;
26
+ }
27
+
24
28
  async function openMenu() {
25
29
  // 每次打开重新创建面板,避免上次选中的操作被当成当前值而无法重复添加。
26
30
  menuMounted.value = true;
@@ -55,7 +59,7 @@ async function handleMenuKeydown(event: KeyboardEvent) {
55
59
  } else if (group) {
56
60
  menuControlRef.value?.open(group);
57
61
  await nextTick();
58
- target.querySelector<HTMLElement>('.el-menu-item:not(.is-disabled)')?.focus();
62
+ target.querySelector<HTMLElement>('[role="menuitem"]:not(.is-disabled)')?.focus();
59
63
  } else if (event.key !== 'ArrowRight') {
60
64
  target.click();
61
65
  }
@@ -119,25 +123,51 @@ function selectAction(value: string) {
119
123
  @click="openGroupOnClick($event, group.title)"
120
124
  >
121
125
  <template #title>{{ group.title }}</template>
122
- <el-tooltip
123
- v-for="action in group.actions"
124
- :key="action.type"
125
- :content="actionDescriptions[action.type]"
126
- placement="right"
127
- effect="dark"
128
- :show-after="350"
129
- :hide-after="0"
130
- :enterable="false"
131
- :popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
132
- popper-class="appium-action-description"
133
- >
134
- <el-menu-item
135
- :index="action.type"
136
- :disabled="isActionDisabled(action.type)"
126
+ <template v-for="item in group.actions" :key="isSubgroup(item) ? item.label : item.type">
127
+ <el-sub-menu
128
+ v-if="isSubgroup(item)"
129
+ :index="`${group.title}/${item.label}`"
130
+ :data-group="`${group.title}/${item.label}`"
131
+ :aria-label="item.label"
132
+ tabindex="-1"
133
+ :teleported="true"
134
+ popper-class="appium-action-submenu appium-action-submenu--third"
135
+ @click.stop="openGroupOnClick($event, `${group.title}/${item.label}`)"
136
+ >
137
+ <template #title>{{ item.label }}</template>
138
+ <el-tooltip
139
+ v-for="action in item.actions"
140
+ :key="action.type"
141
+ :content="actionDescriptions[action.type]"
142
+ placement="right"
143
+ effect="dark"
144
+ :show-after="350"
145
+ :hide-after="0"
146
+ :enterable="false"
147
+ :popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
148
+ popper-class="appium-action-description"
149
+ >
150
+ <el-menu-item :index="action.type" :disabled="isActionDisabled(action.type)">
151
+ {{ action.label }}
152
+ </el-menu-item>
153
+ </el-tooltip>
154
+ </el-sub-menu>
155
+ <el-tooltip
156
+ v-else
157
+ :content="actionDescriptions[item.type]"
158
+ placement="right"
159
+ effect="dark"
160
+ :show-after="350"
161
+ :hide-after="0"
162
+ :enterable="false"
163
+ :popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
164
+ popper-class="appium-action-description"
137
165
  >
138
- {{ action.label }}
139
- </el-menu-item>
140
- </el-tooltip>
166
+ <el-menu-item :index="item.type" :disabled="isActionDisabled(item.type)">
167
+ {{ item.label }}
168
+ </el-menu-item>
169
+ </el-tooltip>
170
+ </template>
141
171
  </el-sub-menu>
142
172
  <el-menu-item v-if="clipboardCount" :index="PASTE_COMMAND" class="appium-action-paste">
143
173
  粘贴 {{ clipboardCount }} 个节点
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, inject, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue';
3
- import { DEFAULT_FLOW_BACKGROUND, flowBackgroundKey } from '../flow-appearance';
3
+ import { DEFAULT_FLOW_BACKGROUND, flowBackgroundKey, DEFAULT_FLOW_LINE_COLOR, flowLineColorKey } from '../flow-appearance';
4
4
  import { useVueFlow, VueFlow } from '@vue-flow/core';
5
5
  import FlowNodeCard from './FlowNodeCard.vue';
6
6
  import FlowRoundedEdge from './FlowRoundedEdge.vue';
@@ -14,6 +14,7 @@ import {
14
14
  import type { AppiumRecordedStep } from '../types';
15
15
 
16
16
  const flowBackground = inject(flowBackgroundKey, computed(() => DEFAULT_FLOW_BACKGROUND));
17
+ const flowLineColor = inject(flowLineColorKey, computed(() => DEFAULT_FLOW_LINE_COLOR));
17
18
 
18
19
  const props = defineProps<{
19
20
  aiRecognitionModelConfigured?: boolean;
@@ -195,7 +196,7 @@ function handleInsert(payload: {
195
196
  </script>
196
197
 
197
198
  <template>
198
- <div class="appium-flow-canvas appium-flow-canvas--vue" :style="{ '--flow-background': flowBackground }">
199
+ <div class="appium-flow-canvas appium-flow-canvas--vue" :style="{ '--flow-background': flowBackground, '--appium-flow-line-color': flowLineColor }">
199
200
  <VueFlow
200
201
  :id="id"
201
202
  class="appium-vue-flow"
@@ -175,8 +175,8 @@ watch(() => props.data, () => {
175
175
  @click="handleStepClick"
176
176
  >
177
177
  <span class="appium-flow-step-card__content">
178
- <el-tooltip v-if="data.missingAiModel" content="AI 识别操作已移除,请删除此节点并改用图像判断或原生组件判断" placement="top">
179
- <el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别操作已移除" tabindex="0">
178
+ <el-tooltip v-if="data.missingAiModel" content="请在参数配置 > Appium配置中配置 AI 识别模型" placement="top">
179
+ <el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别模型未配置" tabindex="0">
180
180
  <WarningFilled />
181
181
  </el-icon>
182
182
  </el-tooltip>