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.
- package/AI_RECOGNITION_GUIDE.md +370 -0
- package/CHANGELOG.md +16 -0
- package/README.md +35 -3
- package/USAGE.md +18 -3
- package/package.json +2 -1
- package/remote-agent/index.ts +1 -0
- package/server/appium-auto-save.test.ts +61 -0
- package/server/appium-config.test.ts +165 -0
- package/server/appium-recorder/ai-deduplication.test.ts +80 -0
- package/server/appium-recorder/ai-deduplication.ts +74 -0
- package/server/appium-recorder/ai-observation.test.ts +125 -0
- package/server/appium-recorder/ai-observation.ts +125 -0
- package/server/appium-recorder/ai-recognition.ts +118 -22
- package/server/appium-recorder/appium-runner.ts +79 -14
- package/server/appium-recorder/bounded-loop.test.ts +55 -0
- package/server/appium-recorder/bounded-loop.ts +12 -1
- package/server/appium-recorder/history-capture.ts +1 -0
- package/server/appium-recorder/image-check.ts +1 -1
- package/server/appium-recorder/managed-appium.ts +15 -0
- package/server/appium-recorder/managed-cleanup.test.ts +82 -0
- package/server/appium-recorder/prompt-optimizer.ts +57 -0
- package/server/appium-recorder/replay-log.ts +13 -0
- package/server/appium-recorder/report-summary.test.ts +78 -0
- package/server/appium-recorder/report-summary.ts +60 -0
- package/server/appium-recorder/report.ts +45 -14
- package/server/appium-recorder/repository.ts +7 -0
- package/server/appium-recorder/routes.ts +78 -4
- package/server/appium-recorder/screenshot.ts +1 -0
- package/server/config.ts +34 -4
- package/server/http-api.ts +31 -5
- package/server/model-tester.ts +2 -1
- package/src/App.vue +53 -30
- package/src/api.ts +11 -4
- package/src/appium-recorder/AppiumPage.vue +167 -68
- package/src/appium-recorder/RunHistoryDialog.vue +1 -1
- package/src/appium-recorder/action-descriptions.ts +2 -1
- package/src/appium-recorder/ai-deduplication.ts +34 -0
- package/src/appium-recorder/ai-prompt-presets.ts +34 -0
- package/src/appium-recorder/ai-recognition.ts +27 -2
- package/src/appium-recorder/api.ts +36 -2
- package/src/appium-recorder/bounded-loop.ts +9 -1
- package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
- package/src/appium-recorder/components/AiRecognitionSettings.vue +167 -0
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
- package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
- package/src/appium-recorder/components/ComponentTree.vue +5 -5
- package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
- package/src/appium-recorder/components/FlowCanvas.vue +3 -2
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
- package/src/appium-recorder/components/FlowStepEditor.vue +17 -3
- package/src/appium-recorder/components/NestedConditionBranches.vue +25 -50
- package/src/appium-recorder/components/RecordedSteps.vue +23 -6
- package/src/appium-recorder/coordinate-preview.ts +37 -0
- package/src/appium-recorder/flow-appearance.ts +12 -1
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +7 -4
- package/src/appium-recorder/flow-labels.ts +9 -5
- package/src/appium-recorder/flow-merge.ts +1 -1
- package/src/appium-recorder/flow-unmerge.ts +2 -2
- package/src/appium-recorder/report-summary.ts +60 -0
- package/src/appium-recorder/types.ts +6 -0
- package/src/components/config/AiDeduplicationSettings.vue +60 -0
- package/src/components/config/AiPromptPresetsSettings.vue +63 -0
- package/src/components/config/AppiumConfigPanel.vue +76 -3
- package/src/components/config/AppiumModelSettings.vue +45 -0
- package/src/components/config/BaseConfigPanel.vue +20 -8
- package/src/components/config/FlowAppearanceSettings.vue +51 -32
- package/src/components/config/MidsceneConfigPanel.vue +84 -86
- package/src/components/config/ReplayReportSummarySettings.vue +118 -0
- package/src/components/device/DevicePreviewPanel.vue +18 -3
- package/src/config/midscene-model-presets.ts +37 -9
- package/src/config/use-appium-auto-save.ts +62 -0
- package/src/pages/ConfigPage.vue +8 -6
- package/src/style.css +5 -55
- package/src/types.ts +4 -1
- 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,
|
|
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(
|
|
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
|
-
|
|
1388
|
-
|
|
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:
|
|
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
|
-
{
|
|
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="
|
|
2161
|
-
<template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
|
|
2162
|
-
<
|
|
2163
|
-
<el-
|
|
2164
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|