android-midscene-automation 0.1.41 → 0.1.42
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/CHANGELOG.md +11 -0
- package/README.md +13 -0
- package/USAGE.md +2 -0
- package/package.json +2 -1
- package/remote-agent/index.ts +1 -0
- package/server/appium-auto-save.test.ts +60 -0
- package/server/appium-config.test.ts +118 -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 +59 -14
- 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/replay-log.ts +13 -0
- package/server/appium-recorder/report.ts +14 -12
- package/server/appium-recorder/repository.ts +5 -0
- package/server/appium-recorder/routes.ts +26 -2
- package/server/appium-recorder/screenshot.ts +1 -0
- package/server/config.ts +19 -4
- package/server/http-api.ts +26 -4
- package/server/model-tester.ts +2 -1
- package/src/App.vue +41 -29
- package/src/api.ts +10 -3
- package/src/appium-recorder/AppiumPage.vue +41 -6
- package/src/appium-recorder/RunHistoryDialog.vue +1 -1
- package/src/appium-recorder/action-descriptions.ts +1 -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 +14 -0
- package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
- package/src/appium-recorder/components/AiRecognitionSettings.vue +143 -0
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
- 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 +8 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +7 -1
- package/src/appium-recorder/components/RecordedSteps.vue +7 -1
- package/src/appium-recorder/flow-appearance.ts +12 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +6 -4
- package/src/appium-recorder/types.ts +4 -0
- package/src/components/config/AiDeduplicationSettings.vue +59 -0
- package/src/components/config/AiPromptPresetsSettings.vue +66 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -3
- package/src/components/config/BaseConfigPanel.vue +20 -8
- package/src/components/config/FlowAppearanceSettings.vue +51 -31
- package/src/components/config/MidsceneConfigPanel.vue +84 -86
- package/src/config/midscene-model-presets.ts +37 -9
- package/src/config/use-appium-auto-save.ts +54 -0
- package/src/pages/ConfigPage.vue +4 -4
- package/src/style.css +5 -10
- package/src/types.ts +3 -1
- package/src/ui-refresh.css +17 -4
package/src/api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ScriptStep } from './script-generator';
|
|
2
|
+
import type { MidsceneModelProvider } from './config/midscene-model-presets';
|
|
2
3
|
import type {
|
|
3
4
|
AndroidDevice,
|
|
4
5
|
AppPreset,
|
|
@@ -109,16 +110,21 @@ export async function getConfig() {
|
|
|
109
110
|
return readJson<ConfigForm>(response);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
//
|
|
113
|
-
export function saveConfig(input: ConfigForm) {
|
|
113
|
+
// 运行配置独立保存,不携带尚未测试的模型草稿。
|
|
114
|
+
export function saveConfig(input: Pick<ConfigForm, 'runtime'>) {
|
|
114
115
|
return postJson<{ success?: boolean } | ConfigForm>(`${APP_BASE}/api/config`, input);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
export function saveAppiumConfig(appium: Omit<ConfigForm['appium'], 'model'>) {
|
|
119
|
+
return postJson<{ success: boolean }>(`${APP_BASE}/api/config/appium`, { appium });
|
|
120
|
+
}
|
|
121
|
+
|
|
117
122
|
// 测试指定模型配置是否可用,通常用于保存前验证 baseUrl/apiKey/model。
|
|
118
123
|
export function testModel(input: {
|
|
124
|
+
save?: boolean;
|
|
119
125
|
modelKey: 'midscene' | 'scriptOptimizer' | 'appium';
|
|
120
126
|
model: {
|
|
121
|
-
provider?:
|
|
127
|
+
provider?: MidsceneModelProvider;
|
|
122
128
|
baseUrl: string;
|
|
123
129
|
apiKey: string;
|
|
124
130
|
name: string;
|
|
@@ -126,6 +132,7 @@ export function testModel(input: {
|
|
|
126
132
|
};
|
|
127
133
|
}) {
|
|
128
134
|
return postJson<{
|
|
135
|
+
saved?: boolean;
|
|
129
136
|
content?: string;
|
|
130
137
|
durationMs?: number;
|
|
131
138
|
usage?: {
|
|
@@ -1,7 +1,9 @@
|
|
|
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
4
|
import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
|
|
5
|
+
import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS, type AiPromptPreset } from './ai-prompt-presets';
|
|
6
|
+
import AiRecognitionPromptInput from './components/AiRecognitionPromptInput.vue';
|
|
5
7
|
import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
|
|
6
8
|
import { ArrowDown, Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
7
9
|
import RunHistoryDialog from './RunHistoryDialog.vue';
|
|
@@ -42,6 +44,8 @@ import RecordedSteps from './components/RecordedSteps.vue';
|
|
|
42
44
|
import NewScriptDialog from './components/NewScriptDialog.vue';
|
|
43
45
|
import VisualChangeDialog from './components/VisualChangeDialog.vue';
|
|
44
46
|
import ImageCheckDialog from './components/ImageCheckDialog.vue';
|
|
47
|
+
import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
|
|
48
|
+
import { validateAiRecognitionPrompt } from './ai-recognition';
|
|
45
49
|
import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
|
|
46
50
|
import {
|
|
47
51
|
createFlowClipboard,
|
|
@@ -100,7 +104,9 @@ const recordReplayVideo = shallowRef(false);
|
|
|
100
104
|
|
|
101
105
|
const props = defineProps<{
|
|
102
106
|
aiRecognitionModelConfigured?: boolean;
|
|
107
|
+
aiPromptPresets?: AiPromptPreset[];
|
|
103
108
|
flowBackgroundColor?: string;
|
|
109
|
+
flowLineColor?: string;
|
|
104
110
|
active: boolean;
|
|
105
111
|
appPresets: AppPreset[];
|
|
106
112
|
deviceActions: readonly DeviceAction[];
|
|
@@ -126,6 +132,9 @@ const props = defineProps<{
|
|
|
126
132
|
|
|
127
133
|
// provide/inject 随组件关系传递,放大弹窗 teleport 后也能使用同一颜色。
|
|
128
134
|
provide(flowBackgroundKey, computed(() => normalizeFlowBackground(props.flowBackgroundColor)));
|
|
135
|
+
provide(flowLineColorKey, computed(() => normalizeFlowLineColor(props.flowLineColor)));
|
|
136
|
+
const availableAiPromptPresets = computed(() => props.aiPromptPresets ?? DEFAULT_AI_PROMPT_PRESETS);
|
|
137
|
+
provide(aiPromptPresetsKey, availableAiPromptPresets);
|
|
129
138
|
const selectedDeviceId = computed(() => props.playgroundDeviceId);
|
|
130
139
|
const tree = shallowRef<AppiumNode | null>(null);
|
|
131
140
|
const selectedNode = shallowRef<AppiumNode | null>(null);
|
|
@@ -136,6 +145,7 @@ const newScriptRevision = shallowRef(0);
|
|
|
136
145
|
const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
|
|
137
146
|
const activeWorkbenchTab = shallowRef<'recording' | 'scripts' | 'variables'>(storedWorkbenchTab === 'scripts' ? 'scripts' : storedWorkbenchTab === 'variables' ? 'variables' : 'recording');
|
|
138
147
|
const steps = shallowRef<AppiumRecordedStep[]>([]);
|
|
148
|
+
const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
|
|
139
149
|
const flowClipboard = shallowRef<FlowClipboard | null>(null);
|
|
140
150
|
const rawXml = shallowRef('');
|
|
141
151
|
const currentActivity = shallowRef('');
|
|
@@ -886,6 +896,11 @@ async function refreshTree() {
|
|
|
886
896
|
|
|
887
897
|
async function executeFlowStep(index: number) {
|
|
888
898
|
const step = steps.value[index];
|
|
899
|
+
if (step?.type === 'aiRecognition') {
|
|
900
|
+
if (replaying.value || recordingBusy.value) { ElMessage.warning('设备正在执行操作,请稍后测试'); return; }
|
|
901
|
+
aiRecognitionTestStep.value = { ...step };
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
889
904
|
if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
|
|
890
905
|
if (launchingApp.value) return;
|
|
891
906
|
if (!selectedDeviceId.value) {
|
|
@@ -1384,8 +1399,27 @@ async function addAction(
|
|
|
1384
1399
|
return;
|
|
1385
1400
|
}
|
|
1386
1401
|
if (action === 'aiRecognition') {
|
|
1387
|
-
|
|
1388
|
-
|
|
1402
|
+
const draft = reactive({ value: '', error: '' });
|
|
1403
|
+
const result = await ElMessageBox({
|
|
1404
|
+
title: '添加 AI 识别',
|
|
1405
|
+
message: () => h(ElForm, { labelPosition: 'top', style: { width: 'min(380px, calc(100vw - 64px))' } },
|
|
1406
|
+
() => h(ElFormItem, { label: '识别内容', error: draft.error },
|
|
1407
|
+
() => h(AiRecognitionPromptInput, {
|
|
1408
|
+
modelValue: draft.value, presets: availableAiPromptPresets.value,
|
|
1409
|
+
placeholder: '例如:检查当前画面有没有显示黑屏',
|
|
1410
|
+
'onUpdate:modelValue': (value: string) => { draft.value = value; draft.error = ''; },
|
|
1411
|
+
}))),
|
|
1412
|
+
showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1413
|
+
beforeClose: (action, _instance, done) => {
|
|
1414
|
+
if (action === 'confirm') {
|
|
1415
|
+
try { draft.value = validateAiRecognitionPrompt(draft.value); }
|
|
1416
|
+
catch (error) { draft.error = error instanceof Error ? error.message : '识别内容无效'; return; }
|
|
1417
|
+
}
|
|
1418
|
+
done();
|
|
1419
|
+
},
|
|
1420
|
+
}).catch(() => null);
|
|
1421
|
+
if (!result) return;
|
|
1422
|
+
return insertStep({ id: createStepId(), type: 'aiRecognition', label: 'AI 识别', value: draft.value, aiBranchEnabled: false, timeoutMs: DEFAULT_NODE_TIMEOUT_MS, flow: { nodeKind: 'action' } }, index, branchTarget);
|
|
1389
1423
|
}
|
|
1390
1424
|
if (action === 'textClick') {
|
|
1391
1425
|
const step = reactive<AppiumRecordedStep>({
|
|
@@ -2277,9 +2311,6 @@ watch(
|
|
|
2277
2311
|
</div>
|
|
2278
2312
|
</el-tab-pane>
|
|
2279
2313
|
|
|
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
2314
|
<el-tab-pane label="脚本列表" name="scripts">
|
|
2284
2315
|
<div class="appium-script-list">
|
|
2285
2316
|
<div class="appium-script-list__toolbar">
|
|
@@ -2357,6 +2388,9 @@ watch(
|
|
|
2357
2388
|
</template>
|
|
2358
2389
|
</div>
|
|
2359
2390
|
</el-tab-pane>
|
|
2391
|
+
<el-tab-pane label="预设变量" name="variables" lazy>
|
|
2392
|
+
<PresetVariables :key="`${selectedScriptId}:${newScriptRevision}`" v-model:variables="scriptVariables" :script-id="selectedScriptId" :script-name="form.name" :disabled="recordingBusy || saving" @saved="onVariablesSaved" />
|
|
2393
|
+
</el-tab-pane>
|
|
2360
2394
|
</el-tabs>
|
|
2361
2395
|
</el-card>
|
|
2362
2396
|
</RecorderWorkspace>
|
|
@@ -2372,6 +2406,7 @@ watch(
|
|
|
2372
2406
|
/>
|
|
2373
2407
|
|
|
2374
2408
|
|
|
2409
|
+
<AiRecognitionTestDialog v-if="aiRecognitionTestStep" :step="aiRecognitionTestStep" :device-id="selectedDeviceId" :model-configured="Boolean(aiRecognitionModelConfigured)" @close="aiRecognitionTestStep = null" />
|
|
2375
2410
|
<ImageCheckDialog
|
|
2376
2411
|
v-if="imageCheckDraft?.imageCheck"
|
|
2377
2412
|
:timeout-branch="imageCheckDraft.timeoutBranch"
|
|
@@ -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>
|
|
@@ -26,7 +26,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
|
|
|
26
26
|
checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
|
|
27
27
|
radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
|
|
28
28
|
checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
|
|
29
|
-
aiRecognition: '
|
|
29
|
+
aiRecognition: '将设备截图发送给视觉模型,支持单帧识别、观察结束后统一分析及检测命中后提前结束。默认顺序执行,启用分支后按判断结果选择分支。需要配置支持图片输入的模型;边观察边检测可能多次调用模型,实际响应受模型速度影响。',
|
|
30
30
|
textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
|
|
31
31
|
tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
|
|
32
32
|
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(
|
|
@@ -1,8 +1,22 @@
|
|
|
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
|
+
|
|
6
20
|
export function captureImageCheckRegion(deviceId: string, region: AppiumVisualChangeRegion, screenWidth: number, screenHeight: number) {
|
|
7
21
|
return postJson<{ base64: string; screenWidth: number; screenHeight: number }>(`${APP_BASE}/api/appium-recorder/image-check/capture`, { deviceId, region, screenWidth, screenHeight });
|
|
8
22
|
}
|
|
@@ -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,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, inject, onBeforeUnmount, ref, 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 { 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 error = ref('');
|
|
16
|
+
const branch = computed(() => isAiBranchEnabled(props.step));
|
|
17
|
+
const untilMatch = computed(() => props.step.aiObservation?.mode === 'untilMatch');
|
|
18
|
+
const branchChoice = ref(branch.value);
|
|
19
|
+
watch(branch, value => { branchChoice.value = value; });
|
|
20
|
+
let controller: AbortController | undefined;
|
|
21
|
+
watch(() => props.step.value, value => { prompt.value = value || ''; });
|
|
22
|
+
onBeforeUnmount(() => controller?.abort());
|
|
23
|
+
|
|
24
|
+
function updateObservation(patch: Partial<AiObservationConfig>) {
|
|
25
|
+
try {
|
|
26
|
+
const aiObservation = validateAiObservation({ ...DEFAULT_AI_OBSERVATION, ...props.step.aiObservation, ...patch });
|
|
27
|
+
error.value = '';
|
|
28
|
+
emit('update', { aiObservation });
|
|
29
|
+
} catch (cause) { error.value = cause instanceof Error ? cause.message : '持续观察配置无效'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function toggleBranch(enabled: boolean) {
|
|
33
|
+
branchChoice.value = enabled;
|
|
34
|
+
await save(enabled);
|
|
35
|
+
branchChoice.value = branch.value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function save(enabled: boolean, observation?: AiObservationConfig) {
|
|
39
|
+
if (busy.value || props.disabled) return;
|
|
40
|
+
error.value = '';
|
|
41
|
+
if (!enabled && branch.value && (props.step.flow?.yesTargetId || props.step.flow?.noTargetId
|
|
42
|
+
|| props.steps.some(step => step.flow?.parentConditionId === props.step.id))) {
|
|
43
|
+
error.value = '请先移除当前 AI 节点的子分支,再关闭分支模式';
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
controller = new AbortController();
|
|
47
|
+
const signal = controller.signal;
|
|
48
|
+
busy.value = true;
|
|
49
|
+
try {
|
|
50
|
+
const value = validateAiRecognitionPrompt(prompt.value);
|
|
51
|
+
if (enabled || (observation ?? props.step.aiObservation)?.mode === 'untilMatch') {
|
|
52
|
+
const validation = await validateAiBranchQuestion(value, signal);
|
|
53
|
+
if (!validation.result) throw new Error(`${enabled ? '无法进入分支' : '命中条件无效'}:${validation.reason || '请填写可用 true/false 判断的问题'}`);
|
|
54
|
+
}
|
|
55
|
+
if (signal.aborted) return;
|
|
56
|
+
emit('update', { value, aiBranchEnabled: enabled, ...(observation ? { aiObservation: observation } : {}), flow: { ...props.step.flow, nodeKind: enabled ? 'condition' : 'action' } });
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
if (!signal.aborted) error.value = `${cause instanceof Error ? cause.message : '问题验证失败'}(未保存本次修改)`;
|
|
59
|
+
} finally { busy.value = false; }
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<el-form-item label="识别方式">
|
|
65
|
+
<template #label>
|
|
66
|
+
<span class="recognition-label">识别方式
|
|
67
|
+
<el-tooltip placement="top" :show-after="200">
|
|
68
|
+
<template #content><div class="recognition-help-content">单帧识别:截取当前画面并分析。持续观察:按采样间隔连续截图,判断目标是否在观察期间出现过。建议紧接启动 App 或触发广告的操作放置本节点。</div></template>
|
|
69
|
+
<el-button class="recognition-help" text :icon="QuestionFilled" aria-label="识别方式说明" @click.prevent />
|
|
70
|
+
</el-tooltip>
|
|
71
|
+
</span>
|
|
72
|
+
</template>
|
|
73
|
+
<el-radio-group :model-value="Boolean(step.aiObservation)" :disabled="disabled || busy" @update:model-value="emit('update', { aiObservation: $event ? { ...DEFAULT_AI_OBSERVATION } : undefined }); error = ''">
|
|
74
|
+
<el-radio-button :value="false">单帧识别</el-radio-button>
|
|
75
|
+
<el-radio-button :value="true">持续观察</el-radio-button>
|
|
76
|
+
</el-radio-group>
|
|
77
|
+
</el-form-item>
|
|
78
|
+
<template v-if="step.aiObservation">
|
|
79
|
+
<el-form-item label="观察模式">
|
|
80
|
+
<template #label>
|
|
81
|
+
<span class="recognition-label">观察模式
|
|
82
|
+
<el-tooltip placement="top" :show-after="200">
|
|
83
|
+
<template #content>
|
|
84
|
+
<div class="recognition-help-content">
|
|
85
|
+
<p>观察结束后统一分析:完成采样后,将保留的截图一次提交给模型。</p>
|
|
86
|
+
<p>检测命中后提前结束:采样期间逐次识别,确认命中后停止观察并执行下一节点;启用分支时走“是”分支。达到最长观察时间后停止采样,等待剩余关键帧判断,未命中则继续下一节点或走“否”分支。</p>
|
|
87
|
+
<p>最多采样 30 帧,实际速度取决于设备,截图按去重配置保留。观察时长与模型请求超时分别计时;提前结束模式可能多次调用模型,实际结束时间包含模型等待,识别错误不会视为未命中。</p>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
<el-button class="recognition-help" text :icon="QuestionFilled" aria-label="观察模式说明" @click.prevent />
|
|
91
|
+
</el-tooltip>
|
|
92
|
+
</span>
|
|
93
|
+
</template>
|
|
94
|
+
<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' })">
|
|
95
|
+
<el-radio-button value="batch">观察结束后统一分析</el-radio-button>
|
|
96
|
+
<el-radio-button value="untilMatch">检测命中后提前结束</el-radio-button>
|
|
97
|
+
</el-radio-group>
|
|
98
|
+
</el-form-item>
|
|
99
|
+
<el-form-item :label="untilMatch ? '最长观察时间 ms' : '观察时长 ms'">
|
|
100
|
+
<el-input-number :model-value="step.aiObservation.durationMs" :min="1000" :max="60000" :step="1000" :disabled="disabled || busy" controls-position="right" @change="updateObservation({ durationMs: $event })" />
|
|
101
|
+
</el-form-item>
|
|
102
|
+
<el-form-item label="采样间隔 ms">
|
|
103
|
+
<el-input-number :model-value="step.aiObservation.intervalMs" :min="500" :max="10000" :step="500" :disabled="disabled || busy" controls-position="right" @change="updateObservation({ intervalMs: $event })" />
|
|
104
|
+
</el-form-item>
|
|
105
|
+
</template>
|
|
106
|
+
<el-form-item :label="untilMatch ? '命中条件' : '识别内容'">
|
|
107
|
+
<template #label>
|
|
108
|
+
<span class="recognition-label">{{ untilMatch ? '命中条件' : '识别内容' }}
|
|
109
|
+
<el-tooltip v-if="untilMatch" placement="top" :show-after="200">
|
|
110
|
+
<template #content><div class="recognition-help-content">填写能用“是 / 否”判断的具体条件,例如“当前画面是否出现广告弹窗?”。模型判断为“是”即命中并提前结束观察;修改条件后需验证并保存。“广告内容是什么”等开放式问题请使用统一分析模式。</div></template>
|
|
111
|
+
<el-button class="recognition-help" text :icon="QuestionFilled" aria-label="命中条件说明" @click.prevent />
|
|
112
|
+
</el-tooltip>
|
|
113
|
+
</span>
|
|
114
|
+
</template>
|
|
115
|
+
<AiRecognitionPromptInput v-model="prompt" :presets="promptPresets" :placeholder="untilMatch ? '例如:当前画面是否出现广告弹窗?' : ''" :disabled="disabled || busy" @change="error = ''; !branch && !untilMatch && save(false)" />
|
|
116
|
+
<div v-if="(branch || untilMatch) && prompt !== step.value" class="recognition-prompt-actions">
|
|
117
|
+
<el-button :disabled="disabled || busy" @click="save(branch)">验证并保存问题</el-button>
|
|
118
|
+
</div>
|
|
119
|
+
</el-form-item>
|
|
120
|
+
<el-form-item>
|
|
121
|
+
<el-checkbox :model-value="branchChoice" :disabled="disabled || busy" @update:model-value="toggleBranch($event === true)">启用分支</el-checkbox>
|
|
122
|
+
<span v-if="busy" class="validation-status" role="status">
|
|
123
|
+
<el-icon class="is-loading" aria-hidden="true"><Loading /></el-icon>
|
|
124
|
+
正在验证…
|
|
125
|
+
</span>
|
|
126
|
+
</el-form-item>
|
|
127
|
+
<el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
|
|
128
|
+
</template>
|
|
129
|
+
|
|
130
|
+
<style scoped>
|
|
131
|
+
.recognition-label { display: inline-flex; align-items: center; gap: 4px; }
|
|
132
|
+
.recognition-help { width: 28px; height: 28px; padding: 0; font-size: 16px; }
|
|
133
|
+
.recognition-help-content { max-width: min(360px, calc(100vw - 40px)); line-height: 1.6; overflow-wrap: anywhere; }
|
|
134
|
+
.recognition-help-content p { margin: 0 0 8px; }
|
|
135
|
+
.recognition-help-content p:last-child { margin-bottom: 0; }
|
|
136
|
+
.recognition-prompt-actions { display: flex; justify-content: flex-end; width: 100%; margin-top: 10px; }
|
|
137
|
+
.validation-status {
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 6px;
|
|
141
|
+
margin-left: 8px;
|
|
142
|
+
}
|
|
143
|
+
</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>
|
|
@@ -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
|
|
179
|
-
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI
|
|
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>
|