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,3 +1,4 @@
1
+ import { resolveReportSummary } from '../src/appium-recorder/report-summary';
1
2
  // The Midscene Admin HTTP API — extracted from vite.config.ts so the exact
2
3
  // same middleware serves the standalone dev/preview flow AND the DSH plugin.
3
4
  // It is a Connect-style handler: (req, res, next). Callers that have no next
@@ -716,9 +717,17 @@ export function createApiMiddleware() {
716
717
  return;
717
718
  }
718
719
 
719
- if (req.url === '/api/config' && req.method === 'POST') {
720
+ if ((req.url === '/api/config' || req.url === '/api/config/appium') && req.method === 'POST') {
720
721
  try {
721
- const config = await readBody<AppConfig>(req);
722
+ const parsed = await readBody<Partial<AppConfig>>(req);
723
+ if (req.url === '/api/config/appium' && (!parsed.appium || typeof parsed.appium !== 'object' || Array.isArray(parsed.appium))) {
724
+ throw new ConfigValidationError('Appium 配置无效');
725
+ }
726
+ const current = loadConfig();
727
+ const config = req.url === '/api/config/appium'
728
+ ? { ...current, appium: { ...current.appium, ...parsed.appium, model: current.appium.model, promptOptimizer: current.appium.promptOptimizer,
729
+ reportSummary: { ...resolveReportSummary(current.appium.reportSummary), ...parsed.appium?.reportSummary } } }
730
+ : { ...current, runtime: parsed.runtime ?? current.runtime };
722
731
  saveConfig(config);
723
732
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
724
733
  res.end(JSON.stringify({ success: true }));
@@ -1089,9 +1098,10 @@ export function createApiMiddleware() {
1089
1098
  if (req.url === '/api/test-model' && req.method === 'POST') {
1090
1099
  try {
1091
1100
  const parsed = await readBody<{
1092
- modelKey?: 'midscene' | 'scriptOptimizer' | 'appium';
1101
+ save?: boolean;
1102
+ modelKey?: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer';
1093
1103
  model?: {
1094
- provider?: 'custom' | 'codex';
1104
+ provider?: import('../src/config/midscene-model-presets').MidsceneModelProvider;
1095
1105
  baseUrl?: string;
1096
1106
  apiKey?: string;
1097
1107
  name?: string;
@@ -1100,6 +1110,13 @@ export function createApiMiddleware() {
1100
1110
  }>(req);
1101
1111
  const model = parsed.model || {};
1102
1112
 
1113
+ if (parsed.save && !['midscene', 'scriptOptimizer', 'appium', 'promptOptimizer'].includes(parsed.modelKey || '')) {
1114
+ throw new ConfigValidationError('请选择有效的模型配置');
1115
+ }
1116
+ if (parsed.save && parsed.modelKey === 'midscene' && !model.family?.trim()) {
1117
+ throw new ConfigValidationError('请填写 Model Family');
1118
+ }
1119
+
1103
1120
  const result = await testModelConnection({
1104
1121
  provider: model.provider,
1105
1122
  baseUrl: model.baseUrl || '',
@@ -1107,8 +1124,17 @@ export function createApiMiddleware() {
1107
1124
  name: model.name || '',
1108
1125
  family: model.family || '',
1109
1126
  });
1127
+ if (parsed.save) {
1128
+ if (!result.ok) throw new Error('模型测试失败,未保存配置');
1129
+ const key = parsed.modelKey!;
1130
+ // 测试期间其他配置可能已保存,此时重新读取,只更新已通过测试的模型。
1131
+ const current = loadConfig();
1132
+ if (key === 'promptOptimizer') {
1133
+ saveConfig({ ...current, appium: { ...current.appium, promptOptimizer: { model: { baseUrl: model.baseUrl || '', apiKey: model.apiKey || '', name: model.name || '' } } } });
1134
+ } else saveConfig({ ...current, [key]: { ...current[key], model: { ...current[key].model, ...model } } });
1135
+ }
1110
1136
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
1111
- res.end(JSON.stringify(result));
1137
+ res.end(JSON.stringify({ ...result, saved: parsed.save === true }));
1112
1138
  } catch (error) {
1113
1139
  res.statusCode = 500;
1114
1140
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -1,8 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import OpenAI from 'openai';
3
+ import type { MidsceneModelProvider } from '../src/config/midscene-model-presets';
3
4
 
4
5
  type ModelInput = {
5
- provider?: 'custom' | 'codex';
6
+ provider?: MidsceneModelProvider;
6
7
  baseUrl: string;
7
8
  apiKey: string;
8
9
  name: string;
package/src/App.vue CHANGED
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
+ import { resolveReportSummary } from './appium-recorder/report-summary';
2
3
  import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
4
+ import { resolveAiDeduplication } from './appium-recorder/ai-deduplication';
5
+ import { resolveAiPromptPresets } from './appium-recorder/ai-prompt-presets';
3
6
  import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
4
7
  import { ElMessage, ElMessageBox } from 'element-plus';
5
8
  import {
@@ -27,13 +30,14 @@ import {
27
30
  type ScriptStep,
28
31
  } from './script-generator';
29
32
  import {
30
- codexMidsceneModel,
33
+ selectMidsceneModelProvider,
31
34
  type MidsceneModelProvider,
32
35
  } from './config/midscene-model-presets';
33
36
  import * as api from './api';
37
+ import { useAppiumAutoSave } from './config/use-appium-auto-save';
34
38
  import AutomationPage from './pages/AutomationPage.vue';
35
39
  import AppiumPage from './appium-recorder/AppiumPage.vue';
36
- import { DEFAULT_FLOW_BACKGROUND, isHexColor } from './appium-recorder/flow-appearance';
40
+ import { normalizeFlowBackground, normalizeFlowLineColor } from './appium-recorder/flow-appearance';
37
41
  import ConfigPage from './pages/ConfigPage.vue';
38
42
  import GeneratorPage from './pages/GeneratorPage.vue';
39
43
  import type {
@@ -102,6 +106,7 @@ const modelTestStatus = reactive({
102
106
  midscene: '',
103
107
  scriptOptimizer: '',
104
108
  appium: '',
109
+ promptOptimizer: '',
105
110
  });
106
111
  const aiRecognitionModelConfigured = shallowRef(false);
107
112
  const actionDialog = reactive({
@@ -153,7 +158,7 @@ const appPresetForm = reactive({
153
158
  });
154
159
 
155
160
  const configForm = reactive<ConfigForm>({
156
- appium: { model: { baseUrl: '', apiKey: '', name: '' } },
161
+ appium: { reportSummary: resolveReportSummary(), model: { baseUrl: '', apiKey: '', name: '' }, promptOptimizer: { model: { baseUrl: '', apiKey: '', name: '' } }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
157
162
  runtime: {
158
163
  androidSdkPath: '',
159
164
  reportOutputPath: '',
@@ -183,6 +188,12 @@ const lastCustomMidsceneModel = reactive({
183
188
  family: '',
184
189
  });
185
190
 
191
+ const appiumAutoSave = useAppiumAutoSave(configForm, async value => {
192
+ await api.saveAppiumConfig(value);
193
+ }, error => {
194
+ void ElMessageBox.alert(error instanceof Error ? error.message : 'Appium 配置自动保存失败', '自动保存失败', { type: 'error' }).catch(() => {});
195
+ });
196
+
186
197
  const menuItems = [
187
198
  { key: 'generator', label: '测试脚本生成', icon: Operation },
188
199
  { key: 'automation', label: '自动化测试', icon: Monitor },
@@ -662,6 +673,14 @@ const loadConfig = async () => {
662
673
  Object.assign(configForm.midscene.env, payload.midscene.env || {});
663
674
  Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
664
675
  Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
676
+ Object.assign(configForm.appium.promptOptimizer!.model, payload.appium?.promptOptimizer?.model || { baseUrl: '', apiKey: '', name: '' });
677
+ configForm.appium.screenshotReport = payload.appium?.screenshotReport === true;
678
+ configForm.appium.reportSummary = resolveReportSummary(payload.appium?.reportSummary);
679
+ configForm.appium.aiDeduplication = resolveAiDeduplication(payload.appium?.aiDeduplication);
680
+ configForm.appium.aiPromptPresets = resolveAiPromptPresets(payload.appium?.aiPromptPresets);
681
+ configForm.appium.flowBackgroundColor = normalizeFlowBackground(payload.appium?.flowBackgroundColor);
682
+ configForm.appium.flowLineColor = normalizeFlowLineColor(payload.appium?.flowLineColor);
683
+ appiumAutoSave.initialize();
665
684
  aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
666
685
  if (configForm.midscene.model.provider !== 'codex') {
667
686
  rememberCustomMidsceneModel();
@@ -677,15 +696,10 @@ const rememberCustomMidsceneModel = () => {
677
696
  };
678
697
 
679
698
  const updateMidsceneModelProvider = (provider: MidsceneModelProvider) => {
680
- if (provider === 'codex') {
699
+ if (configForm.midscene.model.provider !== 'codex') {
681
700
  rememberCustomMidsceneModel();
682
- Object.assign(configForm.midscene.model, codexMidsceneModel);
683
- } else {
684
- Object.assign(configForm.midscene.model, {
685
- provider: 'custom',
686
- ...lastCustomMidsceneModel,
687
- });
688
701
  }
702
+ Object.assign(configForm.midscene.model, selectMidsceneModelProvider(provider, configForm.midscene.model, lastCustomMidsceneModel));
689
703
  modelTestStatus.midscene = '';
690
704
  };
691
705
 
@@ -699,22 +713,15 @@ const getMidsceneModelConfigError = () => {
699
713
  if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
700
714
 
701
715
  if (!missing.length) return '';
702
- return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后保存。`;
716
+ return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后测试并保存。`;
703
717
  };
704
718
 
705
719
  const saveModelConfig = async () => {
706
- if (!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)) {
707
- ElMessage.warning('流程背景色格式错误,请输入 #RGB 或 #RRGGBB');
708
- return;
709
- }
710
720
  isSavingModelConfig.value = true;
711
721
  errorMessage.value = '';
712
722
  openActionDialog('保存参数配置');
713
723
  try {
714
- // 保存的是本次提交快照,不能把请求期间尚未保存的改动标记为已配置。
715
- const snapshot = JSON.parse(JSON.stringify(configForm)) as ConfigForm;
716
- await api.saveConfig(snapshot);
717
- aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(snapshot.appium.model);
724
+ await api.saveConfig({ runtime: { ...configForm.runtime } });
718
725
  closeActionDialog();
719
726
  ElMessage.success('参数配置已保存');
720
727
  } catch (error) {
@@ -744,9 +751,10 @@ const resetAppPresetForm = () => {
744
751
  };
745
752
 
746
753
  const saveAppPreset = async () => {
754
+ if (isSavingAppPreset.value) return;
755
+ const editing = Boolean(appPresetForm.id);
747
756
  isSavingAppPreset.value = true;
748
757
  errorMessage.value = '';
749
- openActionDialog('保存 App 配置');
750
758
  try {
751
759
  const payload = await api.saveAppPreset({
752
760
  id: appPresetForm.id || undefined,
@@ -758,11 +766,10 @@ const saveAppPreset = async () => {
758
766
  form.appPresetId = payload.app.id;
759
767
  }
760
768
  resetAppPresetForm();
761
- closeActionDialog();
769
+ ElMessage.success(editing ? 'App 配置已更新' : 'App 已添加');
762
770
  } catch (error) {
763
771
  const message = error instanceof Error ? error.message : 'App 配置保存失败';
764
- errorMessage.value = message;
765
- failActionDialog(message);
772
+ void ElMessageBox.alert(message, 'App 配置保存失败', { type: 'error' }).catch(() => {});
766
773
  } finally {
767
774
  isSavingAppPreset.value = false;
768
775
  }
@@ -785,22 +792,33 @@ const removeAppPreset = async (id: string) => {
785
792
  if (form.appPresetId === id) {
786
793
  form.appPresetId = '';
787
794
  }
795
+ if (appPresetForm.id === id) resetAppPresetForm();
788
796
  } catch (error) {
789
- errorMessage.value = error instanceof Error ? error.message : 'App 配置删除失败';
797
+ void ElMessageBox.alert(error instanceof Error ? error.message : 'App 配置删除失败', '删除失败', { type: 'error' }).catch(() => {});
790
798
  }
791
799
  };
792
800
 
793
- const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium') => {
801
+ const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer') => {
802
+ if (testingModelKey.value) return;
794
803
  testingModelKey.value = key;
795
804
  errorMessage.value = '';
796
805
  modelTestStatus[key] = '';
797
- const model = configForm[key].model;
806
+ const model = { ...(key === 'promptOptimizer'
807
+ ? configForm.appium.promptOptimizer!.model
808
+ : configForm[key].model) };
798
809
 
799
810
  try {
800
- const payload = await api.testModel({ modelKey: key, model });
801
- modelTestStatus[key] = payload.content || '连接成功';
811
+ const payload = await api.testModel({ modelKey: key, model, save: true });
812
+ if (!payload.saved) throw new Error('模型未保存,请重试');
813
+ const successMessage = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
814
+ modelTestStatus[key] = '';
815
+ if (key === 'appium') aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(model);
816
+ void ElMessageBox.alert(successMessage, '模型测试成功', {
817
+ type: 'success',
818
+ confirmButtonText: '确定',
819
+ }).catch(() => {});
802
820
  } catch (error) {
803
- modelTestStatus[key] = error instanceof Error ? error.message : '测试失败';
821
+ modelTestStatus[key] = `未保存:${error instanceof Error ? error.message : '测试失败'}`;
804
822
  } finally {
805
823
  testingModelKey.value = '';
806
824
  }
@@ -1485,7 +1503,7 @@ onUnmounted(() => {
1485
1503
  'page-body--appium': activeMenu === 'appium',
1486
1504
  }"
1487
1505
  >
1488
- <el-alert v-if="activeMenu !== 'appium' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
1506
+ <el-alert v-if="activeMenu !== 'appium' && activeMenu !== 'config' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
1489
1507
  <template #title>{{ errorMessage }}</template>
1490
1508
  </el-alert>
1491
1509
 
@@ -1526,11 +1544,13 @@ onUnmounted(() => {
1526
1544
  :is-saving-model-config="isSavingModelConfig"
1527
1545
  :is-saving-app-preset="isSavingAppPreset"
1528
1546
  :model-test-status="modelTestStatus"
1547
+ :appium-save-status="appiumAutoSave.status.value"
1529
1548
  @test-model="testModel"
1530
1549
  @save-model-config="saveModelConfig"
1531
1550
  @save-app-preset="saveAppPreset"
1532
1551
  @edit-app-preset="editAppPreset"
1533
1552
  @delete-app-preset="removeAppPreset"
1553
+ @cancel-app-preset-edit="resetAppPresetForm"
1534
1554
  @update-midscene-model-provider="updateMidsceneModelProvider"
1535
1555
  />
1536
1556
 
@@ -1569,7 +1589,10 @@ onUnmounted(() => {
1569
1589
  :active="activeMenu === 'appium'"
1570
1590
  :app-presets="appPresets"
1571
1591
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
1592
+ :ai-prompt-presets="configForm.appium.aiPromptPresets"
1593
+ :report-summary="configForm.appium.reportSummary"
1572
1594
  :flow-background-color="configForm.appium.flowBackgroundColor"
1595
+ :flow-line-color="configForm.appium.flowLineColor"
1573
1596
  :device-actions="deviceActions"
1574
1597
  :playground-available="playgroundAvailable"
1575
1598
  :playground-device-id="playgroundDeviceId"
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' | 'promptOptimizer'>) {
119
+ return postJson<{ success: boolean }>(`${APP_BASE}/api/config/appium`, { appium });
120
+ }
121
+
117
122
  // 测试指定模型配置是否可用,通常用于保存前验证 baseUrl/apiKey/model。
118
123
  export function testModel(input: {
119
- modelKey: 'midscene' | 'scriptOptimizer' | 'appium';
124
+ save?: boolean;
125
+ modelKey: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer';
120
126
  model: {
121
- provider?: 'custom' | 'codex';
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?: {