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.
Files changed (58) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -0
  3. package/USAGE.md +2 -0
  4. package/package.json +2 -1
  5. package/remote-agent/index.ts +1 -0
  6. package/server/appium-auto-save.test.ts +60 -0
  7. package/server/appium-config.test.ts +118 -0
  8. package/server/appium-recorder/ai-deduplication.test.ts +80 -0
  9. package/server/appium-recorder/ai-deduplication.ts +74 -0
  10. package/server/appium-recorder/ai-observation.test.ts +125 -0
  11. package/server/appium-recorder/ai-observation.ts +125 -0
  12. package/server/appium-recorder/ai-recognition.ts +118 -22
  13. package/server/appium-recorder/appium-runner.ts +59 -14
  14. package/server/appium-recorder/history-capture.ts +1 -0
  15. package/server/appium-recorder/image-check.ts +1 -1
  16. package/server/appium-recorder/managed-appium.ts +15 -0
  17. package/server/appium-recorder/managed-cleanup.test.ts +82 -0
  18. package/server/appium-recorder/replay-log.ts +13 -0
  19. package/server/appium-recorder/report.ts +14 -12
  20. package/server/appium-recorder/repository.ts +5 -0
  21. package/server/appium-recorder/routes.ts +26 -2
  22. package/server/appium-recorder/screenshot.ts +1 -0
  23. package/server/config.ts +19 -4
  24. package/server/http-api.ts +26 -4
  25. package/server/model-tester.ts +2 -1
  26. package/src/App.vue +41 -29
  27. package/src/api.ts +10 -3
  28. package/src/appium-recorder/AppiumPage.vue +41 -6
  29. package/src/appium-recorder/RunHistoryDialog.vue +1 -1
  30. package/src/appium-recorder/action-descriptions.ts +1 -1
  31. package/src/appium-recorder/ai-deduplication.ts +34 -0
  32. package/src/appium-recorder/ai-prompt-presets.ts +34 -0
  33. package/src/appium-recorder/ai-recognition.ts +27 -2
  34. package/src/appium-recorder/api.ts +14 -0
  35. package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
  36. package/src/appium-recorder/components/AiRecognitionSettings.vue +143 -0
  37. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
  38. package/src/appium-recorder/components/FlowCanvas.vue +3 -2
  39. package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
  40. package/src/appium-recorder/components/FlowStepEditor.vue +8 -2
  41. package/src/appium-recorder/components/NestedConditionBranches.vue +7 -1
  42. package/src/appium-recorder/components/RecordedSteps.vue +7 -1
  43. package/src/appium-recorder/flow-appearance.ts +12 -1
  44. package/src/appium-recorder/flow-graph.ts +2 -2
  45. package/src/appium-recorder/flow-labels.ts +6 -4
  46. package/src/appium-recorder/types.ts +4 -0
  47. package/src/components/config/AiDeduplicationSettings.vue +59 -0
  48. package/src/components/config/AiPromptPresetsSettings.vue +66 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -3
  50. package/src/components/config/BaseConfigPanel.vue +20 -8
  51. package/src/components/config/FlowAppearanceSettings.vue +51 -31
  52. package/src/components/config/MidsceneConfigPanel.vue +84 -86
  53. package/src/config/midscene-model-presets.ts +37 -9
  54. package/src/config/use-appium-auto-save.ts +54 -0
  55. package/src/pages/ConfigPage.vue +4 -4
  56. package/src/style.css +5 -10
  57. package/src/types.ts +3 -1
  58. package/src/ui-refresh.css +17 -4
@@ -0,0 +1,82 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import childProcess from 'node:child_process';
4
+ import { EventEmitter } from 'node:events';
5
+ import { PassThrough } from 'node:stream';
6
+ import { syncBuiltinESMExports } from 'node:module';
7
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ test('owned device instrumentation is cleaned after success, abort and failed session creation; external servers are untouched', async () => {
12
+ const root = await mkdtemp(join(tmpdir(), 'managed-cleanup-'));
13
+ const env = { ...process.env };
14
+ const original = { execFile: childProcess.execFile, spawn: childProcess.spawn, fetch: globalThis.fetch };
15
+ const calls: string[][] = [];
16
+ let mode = 'success', stopped = false;
17
+ let controller = new AbortController();
18
+ try {
19
+ const sdk = join(root, 'sdk');
20
+ await mkdir(join(sdk, 'platform-tools'), { recursive: true });
21
+ await writeFile(join(sdk, 'platform-tools', 'adb'), '');
22
+ Object.assign(process.env, { ANDROID_MIDSCENE_DATA_ROOT: root, ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk });
23
+ delete process.env.APPIUM_SERVER_URL;
24
+ childProcess.spawn = (() => {
25
+ stopped = false;
26
+ const child = Object.assign(new EventEmitter(), {
27
+ pid: 12345, exitCode: null as number | null, stdout: new PassThrough(), stderr: new PassThrough(),
28
+ kill: () => { stopped = true; child.exitCode = 0; child.emit('exit', 0); return true; },
29
+ });
30
+ return child;
31
+ }) as unknown as typeof childProcess.spawn;
32
+ childProcess.execFile = ((_command, args, options, callback) => {
33
+ if (args.includes('force-stop')) {
34
+ assert.equal(stopped, true, 'stop owned host server before device cleanup');
35
+ assert.equal(options.signal, undefined, 'cleanup must not inherit an aborted signal');
36
+ assert.equal(options.timeout, 3000);
37
+ calls.push(args);
38
+ }
39
+ queueMicrotask(() => callback(mode === 'cleanup-failure' && args.includes('force-stop') ? new Error('offline') : null, '', ''));
40
+ return {};
41
+ }) as typeof childProcess.execFile;
42
+ syncBuiltinESMExports();
43
+ globalThis.fetch = async (input, init) => {
44
+ const path = new URL(String(input)).pathname;
45
+ if (path === '/session' && init?.method === 'POST') {
46
+ if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
47
+ return Response.json({ value: { sessionId: 'test-session' } });
48
+ }
49
+ return Response.json({ value: {} });
50
+ };
51
+ const { replayAppiumScript } = await import('./appium-runner');
52
+ const script = {
53
+ id: 'cleanup', name: 'cleanup', deviceId: 'test-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
54
+ steps: [{ id: 'log', type: 'log' as const, label: 'log', value: 'ok' }],
55
+ };
56
+ for (mode of ['success', 'abort', 'creation-failure', 'cleanup-failure', 'external']) {
57
+ calls.length = 0;
58
+ controller = new AbortController();
59
+ if (mode === 'external') process.env.APPIUM_SERVER_URL = 'http://external.invalid';
60
+ const result = await replayAppiumScript(script, 'test-device', line => {
61
+ if (mode === 'abort' && line.includes('Appium session 已创建')) controller.abort();
62
+ }, controller.signal);
63
+ assert.equal(calls.length, mode === 'external' ? 0 : 2);
64
+ if (mode !== 'external') {
65
+ assert.deepEqual(calls.map(args => args.slice(0, 5)), Array(2).fill(['-s', 'test-device', 'shell', 'am', 'force-stop']));
66
+ assert.deepEqual(calls.map(args => args.at(-1)).sort(), ['io.appium.uiautomator2.server', 'io.appium.uiautomator2.server.test']);
67
+ }
68
+ if (mode === 'abort') assert.equal(result.stopped, true);
69
+ if (mode === 'creation-failure') assert.equal(result.success, false);
70
+ if (mode === 'cleanup-failure') assert.match(result.output, /UiAutomator2 清理失败/);
71
+ }
72
+ } finally {
73
+ childProcess.execFile = original.execFile;
74
+ childProcess.spawn = original.spawn;
75
+ globalThis.fetch = original.fetch;
76
+ syncBuiltinESMExports();
77
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
78
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
79
+ }
80
+ await rm(root, { recursive: true, force: true });
81
+ }
82
+ });
@@ -0,0 +1,13 @@
1
+ const TIME_PREFIX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}:\d{3} - /;
2
+
3
+ export function stripReplayLogTime(line: string) {
4
+ return line.replace(TIME_PREFIX, '');
5
+ }
6
+
7
+ export function timestampReplayLog(message: string, now = new Date()) {
8
+ const date = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map(value => String(value).padStart(2, '0')).join('-');
9
+ const time = [now.getHours(), now.getMinutes(), now.getSeconds()].map(value => String(value).padStart(2, '0')).join(':');
10
+ const prefix = `${date} ${time}:${String(now.getMilliseconds()).padStart(3, '0')} - `;
11
+ // Appium 原始日志保留原时间,多行操作信息逐行补齐时间。
12
+ return message.split(/\r?\n/).map(line => !line || TIME_PREFIX.test(line) ? line : prefix + line).join('\n');
13
+ }
@@ -1,4 +1,5 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { stripReplayLogTime } from './replay-log';
2
3
  import { join, relative } from 'node:path';
3
4
  import type { ReplayVideo } from './replay-video';
4
5
  import { PNG } from 'pngjs';
@@ -20,7 +21,7 @@ export type AppiumReplayFrame = {
20
21
  logContent?: string;
21
22
  note: string;
22
23
  selector: string;
23
- phase: 'before' | 'after' | 'error' | 'stopped';
24
+ phase: 'before' | 'after' | 'error' | 'stopped' | 'observation';
24
25
  status: string;
25
26
  capturedAt: string;
26
27
  imageBase64: string;
@@ -152,13 +153,13 @@ function selectorText(selector?: AppiumRecordedStepRecord['selector']) {
152
153
 
153
154
  function stepExecutionLines(outputLines: string[], index: number) {
154
155
  const prefixes = [`[节点 ${index + 1}]`, `[步骤 ${index + 1}]`];
155
- return outputLines.filter((line) => prefixes.some((prefix) => line.startsWith(prefix)));
156
+ return outputLines.filter((line) => prefixes.some((prefix) => stripReplayLogTime(line).startsWith(prefix)));
156
157
  }
157
158
 
158
159
  function executionStatus(lines: string[]) {
159
160
  if (!lines.length) return '未执行';
160
161
  // 只识别执行器的状态前缀,用户日志中的“失败”等文字不是执行结果。
161
- const messages = lines.map((line) => line.replace(/^\[(?:节点|步骤) \d+\] /, ''));
162
+ const messages = lines.map((line) => stripReplayLogTime(line).replace(/^\[(?:节点|步骤) \d+\] /, ''));
162
163
  if (messages.some((line) => line.startsWith('失败:') || line.startsWith('失败分支:'))) return '失败';
163
164
  if (messages.some((line) => line.startsWith('跳过:'))) return '跳过';
164
165
  if (messages.some((line) => line.startsWith('判断:'))) return '判断完成';
@@ -467,7 +468,7 @@ function createReplayHtml(input: {
467
468
  frames.forEach((frame, index) => {
468
469
  const key = JSON.stringify([frame.scriptName, frame.nodeId, frame.nodeNumber]);
469
470
  const pending = pendingSteps.get(key) || [];
470
- let stepIndex = frame.phase === 'before' ? undefined : pending.pop();
471
+ let stepIndex = frame.phase === 'before' ? undefined : frame.phase === 'observation' ? pending.at(-1) : pending.pop();
471
472
  if (stepIndex === undefined) {
472
473
  stepIndex = steps.length;
473
474
  steps.push({ firstIndex: index, lastIndex: index });
@@ -496,7 +497,7 @@ function createReplayHtml(input: {
496
497
  let currentMs = 0;
497
498
  let animationFrame = 0;
498
499
  let playbackStartedAt = 0;
499
- const phaseLabels = { before: '执行前', after: '执行后', error: '失败', stopped: '已终止' };
500
+ const phaseLabels = { before: '执行前', after: '执行后', observation: '观察采样', error: '失败', stopped: '已终止' };
500
501
  const visualCheckStatusLabel = (status) => status === 'passed' ? '有变化' : '无明显变化';
501
502
  const formatTime = (milliseconds) => {
502
503
  const seconds = Math.max(0, Math.floor(milliseconds / 1000));
@@ -802,6 +803,7 @@ function createReplayHtml(input: {
802
803
  }
803
804
 
804
805
  export async function createAppiumReplayReport(input: {
806
+ screenshotReport?: boolean;
805
807
  video?: ReplayVideo;
806
808
  script: AppiumRecordedScriptRecord;
807
809
  deviceId: string;
@@ -820,14 +822,14 @@ export async function createAppiumReplayReport(input: {
820
822
  const baseName = `${fileDateTime(input.completedAt)}-${safeFileName(input.script.name)}`;
821
823
  const fileName = `${baseName}.md`;
822
824
  const logFileName = `${baseName}.log`;
823
- const htmlFileName = `${baseName}.html`;
825
+ const htmlFileName = (input.screenshotReport ?? loadConfig().appium.screenshotReport) === true ? `${baseName}.html` : '';
824
826
  const filePath = join(outputDir, fileName);
825
827
  const logPath = join(outputDir, logFileName);
826
- const htmlReportPath = join(outputDir, htmlFileName);
828
+ const htmlReportPath = htmlFileName ? join(outputDir, htmlFileName) : '';
827
829
  const persistedOutput = [
828
830
  input.output,
829
831
  `回放报告:${filePath}`,
830
- `截图回放:${htmlReportPath}`,
832
+ htmlReportPath ? `截图回放:${htmlReportPath}` : '',
831
833
  `回放日志:${logPath}`,
832
834
  ].filter(Boolean).join('\n');
833
835
  const outputLines = persistedOutput.split(/\r?\n/);
@@ -836,7 +838,7 @@ export async function createAppiumReplayReport(input: {
836
838
  const visualChecks = input.visualChecks || [];
837
839
  const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
838
840
  const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
839
- const html = createReplayHtml({
841
+ const html = htmlReportPath ? createReplayHtml({
840
842
  video: input.video ? {
841
843
  startedAt: input.video.startedAt,
842
844
  url: relative(outputDir, input.video.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/'),
@@ -855,7 +857,7 @@ export async function createAppiumReplayReport(input: {
855
857
  visualChecks,
856
858
  imageChecks: input.imageChecks,
857
859
  output: persistedOutput,
858
- });
860
+ }) : '';
859
861
  const markdown = [
860
862
  '# Appium 回放报告',
861
863
  '',
@@ -873,7 +875,7 @@ export async function createAppiumReplayReport(input: {
873
875
  `| 节点数量 | ${input.script.steps.length} |`,
874
876
  `| 截图帧数 | ${(input.frames || []).length} |`,
875
877
  `| 视觉检测未达预期 | ${visualFailureCount} |`,
876
- `| 截图回放 | ${markdownValue(htmlReportPath)} |`,
878
+ `| 截图回放 | ${htmlReportPath ? markdownValue(htmlReportPath) : '未开启'} |`,
877
879
  '',
878
880
  '## 节点明细',
879
881
  '',
@@ -888,7 +890,7 @@ export async function createAppiumReplayReport(input: {
888
890
  await Promise.all([
889
891
  writeFile(filePath, markdown, 'utf8'),
890
892
  writeFile(logPath, `${persistedOutput}\n`, 'utf8'),
891
- writeFile(htmlReportPath, html, 'utf8'),
893
+ ...(htmlReportPath ? [writeFile(htmlReportPath, html, 'utf8')] : []),
892
894
  ]);
893
895
  const record = saveAppiumReplayReport({
894
896
  scriptId: input.script.id,
@@ -1,3 +1,4 @@
1
+ import { validateAiObservation, type AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
1
2
  import {
2
3
  createId,
3
4
  querySql,
@@ -99,6 +100,9 @@ export type AppiumRecordedStepRecord = {
99
100
  returns?: ScriptReturn[];
100
101
  keyCode?: number;
101
102
  timeoutMs?: number;
103
+ aiTimeoutEnabled?: boolean;
104
+ aiBranchEnabled?: boolean;
105
+ aiObservation?: AiObservationConfig;
102
106
  timeoutBranch?: 'stop' | 'yes' | 'no';
103
107
  longPressMode?: 'element' | 'coordinates';
104
108
  breakLoopTargetId?: string;
@@ -376,6 +380,7 @@ export function saveAppiumRecordedScript(input: {
376
380
  if (input.variables !== undefined) validateVariables(input.variables);
377
381
  for (const step of steps) {
378
382
  // 读取旧脚本必须容错,让用户能打开并修正无效配置;仅在写入时校验。
383
+ if (step.type === 'aiRecognition') validateAiObservation(step.aiObservation);
379
384
  if (step.type === 'imageCheck') validateImageCheck(step.imageCheck);
380
385
  if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
381
386
  if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
@@ -1,5 +1,8 @@
1
+ import type { AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { sendReplayVideo } from './video-response';
4
+ import { loadConfig } from '../config';
5
+ import { recognizeDeviceScreen, validateAiBranchQuestion } from './ai-recognition';
3
6
  import { getPresetVariables, savePresetVariables } from './variable-store';
4
7
  import type { TestVariable } from '../../src/appium-recorder/variables';
5
8
  import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
@@ -199,8 +202,29 @@ export async function handleAppiumRecorderRequest(
199
202
  return true;
200
203
  }
201
204
 
205
+ if (pathname === '/api/appium-recorder/ai-recognition/validate-question' && req.method === 'POST') {
206
+ const parsed = await readBody<{ prompt?: unknown }>(req);
207
+ const controller = new AbortController();
208
+ const abort = () => controller.abort();
209
+ res.once('close', abort);
210
+ try { sendJson(res, await validateAiBranchQuestion(parsed.prompt, controller.signal)); }
211
+ finally { res.off('close', abort); }
212
+ return true;
213
+ }
214
+
202
215
  if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
203
- sendJson(res, { message: 'AI 识别操作已移除,请使用图像判断或原生组件判断' }, 410);
216
+ const parsed = await readBody<{ deviceId?: string; prompt?: unknown; timeoutMs?: number; aiTimeoutEnabled?: boolean; aiBranchEnabled?: boolean; aiObservation?: AiObservationConfig }>(req);
217
+ const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
218
+ if (!deviceId) throw new Error('请选择设备');
219
+ assertDeviceAllowed(deviceId);
220
+ if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持 AI 识别测试');
221
+ if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,请稍后测试' }, 409); return true; }
222
+ const controller = new AbortController();
223
+ const abort = () => controller.abort();
224
+ res.once('close', abort);
225
+ try {
226
+ sendJson(res, await recognizeDeviceScreen({ deviceId, prompt: parsed.prompt, timeoutMs: parsed.timeoutMs, aiTimeoutEnabled: parsed.aiTimeoutEnabled === true, aiBranchEnabled: parsed.aiBranchEnabled, aiObservation: parsed.aiObservation, signal: controller.signal }));
227
+ } finally { res.off('close', abort); }
204
228
  return true;
205
229
  }
206
230
 
@@ -375,7 +399,7 @@ export async function handleAppiumRecorderRequest(
375
399
  res.flushHeaders();
376
400
  }
377
401
  if (isRemoteDeviceId(deviceId)) {
378
- const result = await sendRemoteCommand(deviceId, 'replay', { script, parameters: parsed.parameters, globalVariables: getPresetVariables(), linkedScripts: linkedScriptSnapshot(script) }) as Awaited<ReturnType<typeof replayAppiumScript>>;
402
+ const result = await sendRemoteCommand(deviceId, 'replay', { script, screenshotReport: loadConfig().appium.screenshotReport === true, parameters: parsed.parameters, globalVariables: getPresetVariables(), linkedScripts: linkedScriptSnapshot(script) }) as Awaited<ReturnType<typeof replayAppiumScript>>;
379
403
  if (result.history) {
380
404
  try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
381
405
  catch { result.output += '\n历史记录保存失败'; }
@@ -9,6 +9,7 @@ export function adbScreenshotBase64(deviceId: string, signal?: AbortSignal) {
9
9
  ['-s', deviceId, 'exec-out', 'screencap', '-p'],
10
10
  { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024, timeout: 8000, signal },
11
11
  (error, stdout, stderr) => {
12
+ if (signal?.aborted) { reject(signal.reason); return; }
12
13
  if (error) {
13
14
  reject(new Error(stderr?.toString('utf8').trim() || error.message || 'ADB 截图失败'));
14
15
  return;
package/server/config.ts CHANGED
@@ -1,21 +1,25 @@
1
+ import type { AiPromptPreset } from '../src/appium-recorder/ai-prompt-presets';
1
2
  import fs from 'node:fs';
2
- import { isHexColor, normalizeFlowBackground } from '../src/appium-recorder/flow-appearance';
3
+ import { isHexColor, normalizeFlowBackground, normalizeFlowLineColor } from '../src/appium-recorder/flow-appearance';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import YAML from 'yaml';
6
7
  import { appDataPath } from './paths';
7
8
  import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
8
9
  import type { AiRecognitionModel } from '../src/appium-recorder/ai-recognition';
10
+ import { resolveAiPromptPresets } from '../src/appium-recorder/ai-prompt-presets';
11
+ import { resolveAiDeduplication, type AiDeduplicationConfig } from '../src/appium-recorder/ai-deduplication';
12
+ import type { MidsceneModelProvider } from '../src/config/midscene-model-presets';
9
13
 
10
14
  export type AppConfig = {
11
- appium: { model: AiRecognitionModel; flowBackgroundColor?: string };
15
+ appium: { model: AiRecognitionModel; aiPromptPresets?: AiPromptPreset[]; aiDeduplication?: AiDeduplicationConfig; flowBackgroundColor?: string; flowLineColor?: string; screenshotReport?: boolean };
12
16
  runtime: {
13
17
  androidSdkPath: string;
14
18
  reportOutputPath: string;
15
19
  };
16
20
  midscene: {
17
21
  model: {
18
- provider: 'custom' | 'codex';
22
+ provider: MidsceneModelProvider;
19
23
  baseUrl: string;
20
24
  apiKey: string;
21
25
  name: string;
@@ -46,7 +50,7 @@ export class ConfigValidationError extends Error {
46
50
 
47
51
  function defaultConfig(): AppConfig {
48
52
  return {
49
- appium: { model: { baseUrl: '', apiKey: '', name: '' } },
53
+ appium: { model: { baseUrl: '', apiKey: '', name: '' }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
50
54
  runtime: {
51
55
  androidSdkPath: '',
52
56
  reportOutputPath: '',
@@ -84,7 +88,11 @@ function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConf
84
88
  return {
85
89
  // 旧配置没有 Appium 模型时保留空值,不借用其他模型或改变已有配置。
86
90
  appium: {
91
+ aiPromptPresets: resolveAiPromptPresets(config?.appium?.aiPromptPresets),
92
+ aiDeduplication: resolveAiDeduplication(config?.appium?.aiDeduplication),
93
+ screenshotReport: config?.appium?.screenshotReport === true,
87
94
  flowBackgroundColor: normalizeFlowBackground(config?.appium?.flowBackgroundColor),
95
+ flowLineColor: normalizeFlowLineColor(config?.appium?.flowLineColor),
88
96
  model: Object.fromEntries(['baseUrl', 'apiKey', 'name'].map((key) => {
89
97
  const value = config?.appium?.model?.[key as keyof AiRecognitionModel];
90
98
  return [key, typeof value === 'string' ? value.trim() : ''];
@@ -206,9 +214,16 @@ export function loadConfig(): AppConfig {
206
214
  }
207
215
 
208
216
  export function saveConfig(config: AppConfig) {
217
+ try { resolveAiPromptPresets(config.appium?.aiPromptPresets); }
218
+ catch (error) { throw new ConfigValidationError(error instanceof Error ? error.message : 'AI 识别预设提示词无效'); }
219
+ try { resolveAiDeduplication(config.appium?.aiDeduplication); }
220
+ catch (error) { throw new ConfigValidationError(error instanceof Error ? error.message : '截图去重配置无效'); }
209
221
  if (config.appium?.flowBackgroundColor !== undefined && !isHexColor(config.appium.flowBackgroundColor)) {
210
222
  throw new ConfigValidationError('流程背景色必须是有效的十六进制颜色(#RGB 或 #RRGGBB)');
211
223
  }
224
+ if (config.appium?.flowLineColor !== undefined && !isHexColor(config.appium.flowLineColor)) {
225
+ throw new ConfigValidationError('连接线条颜色必须是有效的十六进制颜色(#RGB 或 #RRGGBB)');
226
+ }
212
227
  const normalized = normalizeConfig(config);
213
228
  validateRuntimeConfig(normalized);
214
229
  cachedConfig = normalized;
@@ -716,9 +716,16 @@ export function createApiMiddleware() {
716
716
  return;
717
717
  }
718
718
 
719
- if (req.url === '/api/config' && req.method === 'POST') {
719
+ if ((req.url === '/api/config' || req.url === '/api/config/appium') && req.method === 'POST') {
720
720
  try {
721
- const config = await readBody<AppConfig>(req);
721
+ const parsed = await readBody<Partial<AppConfig>>(req);
722
+ if (req.url === '/api/config/appium' && (!parsed.appium || typeof parsed.appium !== 'object' || Array.isArray(parsed.appium))) {
723
+ throw new ConfigValidationError('Appium 配置无效');
724
+ }
725
+ const current = loadConfig();
726
+ const config = req.url === '/api/config/appium'
727
+ ? { ...current, appium: { ...current.appium, ...parsed.appium, model: current.appium.model } }
728
+ : { ...current, runtime: parsed.runtime ?? current.runtime };
722
729
  saveConfig(config);
723
730
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
724
731
  res.end(JSON.stringify({ success: true }));
@@ -1089,9 +1096,10 @@ export function createApiMiddleware() {
1089
1096
  if (req.url === '/api/test-model' && req.method === 'POST') {
1090
1097
  try {
1091
1098
  const parsed = await readBody<{
1099
+ save?: boolean;
1092
1100
  modelKey?: 'midscene' | 'scriptOptimizer' | 'appium';
1093
1101
  model?: {
1094
- provider?: 'custom' | 'codex';
1102
+ provider?: import('../src/config/midscene-model-presets').MidsceneModelProvider;
1095
1103
  baseUrl?: string;
1096
1104
  apiKey?: string;
1097
1105
  name?: string;
@@ -1100,6 +1108,13 @@ export function createApiMiddleware() {
1100
1108
  }>(req);
1101
1109
  const model = parsed.model || {};
1102
1110
 
1111
+ if (parsed.save && !['midscene', 'scriptOptimizer', 'appium'].includes(parsed.modelKey || '')) {
1112
+ throw new ConfigValidationError('请选择有效的模型配置');
1113
+ }
1114
+ if (parsed.save && parsed.modelKey === 'midscene' && !model.family?.trim()) {
1115
+ throw new ConfigValidationError('请填写 Model Family');
1116
+ }
1117
+
1103
1118
  const result = await testModelConnection({
1104
1119
  provider: model.provider,
1105
1120
  baseUrl: model.baseUrl || '',
@@ -1107,8 +1122,15 @@ export function createApiMiddleware() {
1107
1122
  name: model.name || '',
1108
1123
  family: model.family || '',
1109
1124
  });
1125
+ if (parsed.save) {
1126
+ if (!result.ok) throw new Error('模型测试失败,未保存配置');
1127
+ const key = parsed.modelKey!;
1128
+ // 测试期间其他配置可能已保存,此时重新读取,只更新已通过测试的模型。
1129
+ const current = loadConfig();
1130
+ saveConfig({ ...current, [key]: { ...current[key], model: { ...current[key].model, ...model } } });
1131
+ }
1110
1132
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
1111
- res.end(JSON.stringify(result));
1133
+ res.end(JSON.stringify({ ...result, saved: parsed.save === true }));
1112
1134
  } catch (error) {
1113
1135
  res.statusCode = 500;
1114
1136
  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,7 @@
1
1
  <script setup lang="ts">
2
2
  import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
3
+ import { resolveAiDeduplication } from './appium-recorder/ai-deduplication';
4
+ import { resolveAiPromptPresets } from './appium-recorder/ai-prompt-presets';
3
5
  import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
4
6
  import { ElMessage, ElMessageBox } from 'element-plus';
5
7
  import {
@@ -27,13 +29,14 @@ import {
27
29
  type ScriptStep,
28
30
  } from './script-generator';
29
31
  import {
30
- codexMidsceneModel,
32
+ selectMidsceneModelProvider,
31
33
  type MidsceneModelProvider,
32
34
  } from './config/midscene-model-presets';
33
35
  import * as api from './api';
36
+ import { useAppiumAutoSave } from './config/use-appium-auto-save';
34
37
  import AutomationPage from './pages/AutomationPage.vue';
35
38
  import AppiumPage from './appium-recorder/AppiumPage.vue';
36
- import { DEFAULT_FLOW_BACKGROUND, isHexColor } from './appium-recorder/flow-appearance';
39
+ import { normalizeFlowBackground, normalizeFlowLineColor } from './appium-recorder/flow-appearance';
37
40
  import ConfigPage from './pages/ConfigPage.vue';
38
41
  import GeneratorPage from './pages/GeneratorPage.vue';
39
42
  import type {
@@ -153,7 +156,7 @@ const appPresetForm = reactive({
153
156
  });
154
157
 
155
158
  const configForm = reactive<ConfigForm>({
156
- appium: { model: { baseUrl: '', apiKey: '', name: '' } },
159
+ appium: { model: { baseUrl: '', apiKey: '', name: '' }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
157
160
  runtime: {
158
161
  androidSdkPath: '',
159
162
  reportOutputPath: '',
@@ -183,6 +186,12 @@ const lastCustomMidsceneModel = reactive({
183
186
  family: '',
184
187
  });
185
188
 
189
+ const appiumAutoSave = useAppiumAutoSave(configForm, async value => {
190
+ await api.saveAppiumConfig(value);
191
+ }, error => {
192
+ void ElMessageBox.alert(error instanceof Error ? error.message : 'Appium 配置自动保存失败', '自动保存失败', { type: 'error' }).catch(() => {});
193
+ });
194
+
186
195
  const menuItems = [
187
196
  { key: 'generator', label: '测试脚本生成', icon: Operation },
188
197
  { key: 'automation', label: '自动化测试', icon: Monitor },
@@ -662,6 +671,12 @@ const loadConfig = async () => {
662
671
  Object.assign(configForm.midscene.env, payload.midscene.env || {});
663
672
  Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
664
673
  Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
674
+ configForm.appium.screenshotReport = payload.appium?.screenshotReport === true;
675
+ configForm.appium.aiDeduplication = resolveAiDeduplication(payload.appium?.aiDeduplication);
676
+ configForm.appium.aiPromptPresets = resolveAiPromptPresets(payload.appium?.aiPromptPresets);
677
+ configForm.appium.flowBackgroundColor = normalizeFlowBackground(payload.appium?.flowBackgroundColor);
678
+ configForm.appium.flowLineColor = normalizeFlowLineColor(payload.appium?.flowLineColor);
679
+ appiumAutoSave.initialize();
665
680
  aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
666
681
  if (configForm.midscene.model.provider !== 'codex') {
667
682
  rememberCustomMidsceneModel();
@@ -677,15 +692,10 @@ const rememberCustomMidsceneModel = () => {
677
692
  };
678
693
 
679
694
  const updateMidsceneModelProvider = (provider: MidsceneModelProvider) => {
680
- if (provider === 'codex') {
695
+ if (configForm.midscene.model.provider !== 'codex') {
681
696
  rememberCustomMidsceneModel();
682
- Object.assign(configForm.midscene.model, codexMidsceneModel);
683
- } else {
684
- Object.assign(configForm.midscene.model, {
685
- provider: 'custom',
686
- ...lastCustomMidsceneModel,
687
- });
688
697
  }
698
+ Object.assign(configForm.midscene.model, selectMidsceneModelProvider(provider, configForm.midscene.model, lastCustomMidsceneModel));
689
699
  modelTestStatus.midscene = '';
690
700
  };
691
701
 
@@ -699,22 +709,15 @@ const getMidsceneModelConfigError = () => {
699
709
  if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
700
710
 
701
711
  if (!missing.length) return '';
702
- return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后保存。`;
712
+ return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后测试并保存。`;
703
713
  };
704
714
 
705
715
  const saveModelConfig = async () => {
706
- if (!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)) {
707
- ElMessage.warning('流程背景色格式错误,请输入 #RGB 或 #RRGGBB');
708
- return;
709
- }
710
716
  isSavingModelConfig.value = true;
711
717
  errorMessage.value = '';
712
718
  openActionDialog('保存参数配置');
713
719
  try {
714
- // 保存的是本次提交快照,不能把请求期间尚未保存的改动标记为已配置。
715
- const snapshot = JSON.parse(JSON.stringify(configForm)) as ConfigForm;
716
- await api.saveConfig(snapshot);
717
- aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(snapshot.appium.model);
720
+ await api.saveConfig({ runtime: { ...configForm.runtime } });
718
721
  closeActionDialog();
719
722
  ElMessage.success('参数配置已保存');
720
723
  } catch (error) {
@@ -744,9 +747,10 @@ const resetAppPresetForm = () => {
744
747
  };
745
748
 
746
749
  const saveAppPreset = async () => {
750
+ if (isSavingAppPreset.value) return;
751
+ const editing = Boolean(appPresetForm.id);
747
752
  isSavingAppPreset.value = true;
748
753
  errorMessage.value = '';
749
- openActionDialog('保存 App 配置');
750
754
  try {
751
755
  const payload = await api.saveAppPreset({
752
756
  id: appPresetForm.id || undefined,
@@ -758,11 +762,10 @@ const saveAppPreset = async () => {
758
762
  form.appPresetId = payload.app.id;
759
763
  }
760
764
  resetAppPresetForm();
761
- closeActionDialog();
765
+ ElMessage.success(editing ? 'App 配置已更新' : 'App 已添加');
762
766
  } catch (error) {
763
767
  const message = error instanceof Error ? error.message : 'App 配置保存失败';
764
- errorMessage.value = message;
765
- failActionDialog(message);
768
+ void ElMessageBox.alert(message, 'App 配置保存失败', { type: 'error' }).catch(() => {});
766
769
  } finally {
767
770
  isSavingAppPreset.value = false;
768
771
  }
@@ -785,22 +788,27 @@ const removeAppPreset = async (id: string) => {
785
788
  if (form.appPresetId === id) {
786
789
  form.appPresetId = '';
787
790
  }
791
+ if (appPresetForm.id === id) resetAppPresetForm();
788
792
  } catch (error) {
789
- errorMessage.value = error instanceof Error ? error.message : 'App 配置删除失败';
793
+ void ElMessageBox.alert(error instanceof Error ? error.message : 'App 配置删除失败', '删除失败', { type: 'error' }).catch(() => {});
790
794
  }
791
795
  };
792
796
 
793
797
  const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium') => {
798
+ if (testingModelKey.value) return;
794
799
  testingModelKey.value = key;
795
800
  errorMessage.value = '';
796
801
  modelTestStatus[key] = '';
797
- const model = configForm[key].model;
802
+ const model = { ...configForm[key].model };
798
803
 
799
804
  try {
800
- const payload = await api.testModel({ modelKey: key, model });
801
- modelTestStatus[key] = payload.content || '连接成功';
805
+ const payload = await api.testModel({ modelKey: key, model, save: true });
806
+ if (!payload.saved) throw new Error('模型未保存,请重试');
807
+ modelTestStatus[key] = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
808
+ if (key === 'appium') aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(model);
809
+ ElMessage.success('测试通过,模型配置已保存');
802
810
  } catch (error) {
803
- modelTestStatus[key] = error instanceof Error ? error.message : '测试失败';
811
+ modelTestStatus[key] = `未保存:${error instanceof Error ? error.message : '测试失败'}`;
804
812
  } finally {
805
813
  testingModelKey.value = '';
806
814
  }
@@ -1485,7 +1493,7 @@ onUnmounted(() => {
1485
1493
  'page-body--appium': activeMenu === 'appium',
1486
1494
  }"
1487
1495
  >
1488
- <el-alert v-if="activeMenu !== 'appium' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
1496
+ <el-alert v-if="activeMenu !== 'appium' && activeMenu !== 'config' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
1489
1497
  <template #title>{{ errorMessage }}</template>
1490
1498
  </el-alert>
1491
1499
 
@@ -1526,11 +1534,13 @@ onUnmounted(() => {
1526
1534
  :is-saving-model-config="isSavingModelConfig"
1527
1535
  :is-saving-app-preset="isSavingAppPreset"
1528
1536
  :model-test-status="modelTestStatus"
1537
+ :appium-save-status="appiumAutoSave.status.value"
1529
1538
  @test-model="testModel"
1530
1539
  @save-model-config="saveModelConfig"
1531
1540
  @save-app-preset="saveAppPreset"
1532
1541
  @edit-app-preset="editAppPreset"
1533
1542
  @delete-app-preset="removeAppPreset"
1543
+ @cancel-app-preset-edit="resetAppPresetForm"
1534
1544
  @update-midscene-model-provider="updateMidsceneModelProvider"
1535
1545
  />
1536
1546
 
@@ -1569,7 +1579,9 @@ onUnmounted(() => {
1569
1579
  :active="activeMenu === 'appium'"
1570
1580
  :app-presets="appPresets"
1571
1581
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
1582
+ :ai-prompt-presets="configForm.appium.aiPromptPresets"
1572
1583
  :flow-background-color="configForm.appium.flowBackgroundColor"
1584
+ :flow-line-color="configForm.appium.flowLineColor"
1573
1585
  :device-actions="deviceActions"
1574
1586
  :playground-available="playgroundAvailable"
1575
1587
  :playground-device-id="playgroundDeviceId"