Files
Artemis-Eapil/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts
T
chenmingxuan fac4ccb760 feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
2026-10-26 00:44:13 +08:00

160 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, inject, OnInit, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { finalize, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
interface Capabilities {
recording?: { codec: string; max_long_edge: number; fps: number; h265_kbps: number; h264_kbps: number; computer_scope: string; minimum_disk_mb: number; hevc_playback: boolean };
adb_auto_connect: boolean;
adb_discovery_interval_seconds: number;
adb_discovery_timeout_seconds: number;
video_enabled: boolean;
recording_enabled: boolean;
outputter_enabled: boolean;
flash_final_check_enabled: boolean;
step_summarizer_enabled: boolean;
screenshot_limit_enabled: boolean;
screenshot_max_long_edge: number;
task_timeout_seconds: number;
observation_timeout_seconds: number;
observation_max_attempts: number;
device_command_timeout_seconds: number;
action_failure_attempts: number;
worker_cleanup_timeout_seconds: number;
}
@Component({
selector: 'app-capability-settings', standalone: true, imports: [FormsModule],
template: `
<section aria-label="功能开关">
<h3>功能开关</h3>
<p>Flash 最终 AI 判定切换后自动保存;其余设置点击底部按钮保存。保存后对尚未开始的任务生效,不影响运行中的任务。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
@if (config.recording; as recording) {
<details class="execution-settings">
<summary>录像留档设置</summary>
<p>默认低码率留档,不影响模型观察截图。仅录实际使用的来源;屏幕模式包含该屏幕其他可见内容。</p>
<div class="number-fields">
<label>编码<select [(ngModel)]="recording.codec" [disabled]="busy() || !loaded()"><option value="auto">自动选择兼容编码</option><option value="h265">优先 H.265</option><option value="h264">H.264</option></select></label>
<label>电脑录制范围<select [(ngModel)]="recording.computer_scope" [disabled]="busy() || !loaded()"><option value="screen">当前控制的屏幕</option><option value="window">仅目标窗口</option></select></label>
<label>最长边(像素)<input type="number" min="320" max="4096" [(ngModel)]="recording.max_long_edge" [disabled]="busy() || !loaded()" /></label>
<label>最高帧率<input type="number" min="5" max="60" [(ngModel)]="recording.fps" [disabled]="busy() || !loaded()" /></label>
<label>H.265 目标码率(kbps)<input type="number" min="128" max="10000" [(ngModel)]="recording.h265_kbps" [disabled]="busy() || !loaded()" /></label>
<label>H.264 目标码率(kbps)<input type="number" min="128" max="20000" [(ngModel)]="recording.h264_kbps" [disabled]="busy() || !loaded()" /></label>
</div>
</details>
}
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<label><input type="checkbox" role="switch" aria-label="Flash 最终 AI 判定与分析" [ngModel]="config.flash_final_check_enabled" (ngModelChange)="saveFlashFinalCheck($event)" [disabled]="busy() || loading() || !loaded()" />
<span>Flash 最终 AI 判定与分析<small>默认关闭。开启后,在 Flash 执行结束时根据用例需求和执行步骤复核结果,会增加耗时。关闭时显示“执行完成 · 未做 AI 判定”,仍保留步骤和报告。不影响 Pro。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
<details class="execution-settings">
<summary>执行保护</summary>
<p>限制故障等待与反复失败;保存后对新任务生效,任务 JSON 可单独覆盖。</p>
<div class="number-fields">
<label>任务总超时(秒)<input type="number" min="30" max="86400" step="1" [(ngModel)]="config.task_timeout_seconds" [disabled]="busy() || !loaded()" /><small>不包含排队和用户暂停时间。</small></label>
<label>屏幕观测超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.observation_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
<label>观测恢复最多尝试次数<input type="number" min="1" max="5" step="1" [(ngModel)]="config.observation_max_attempts" [disabled]="busy() || !loaded()" /></label>
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
<label>设备自动发现间隔(秒)<input type="number" min="1" max="300" [(ngModel)]="config.adb_discovery_interval_seconds" [disabled]="busy() || !loaded()" /></label>
<label>每轮自动连接预算(秒)<input type="number" min="1" max="30" [(ngModel)]="config.adb_discovery_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
</div>
</details>
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
@if (message()) { <p role="status">{{ message() }}</p> }
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
</section>
`,
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333); }
h3 { margin: 0 0 8px; } p, small { color: var(--app-text-muted, #64748b); line-height: 1.6; }
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
input { width: 20px; height: 20px; accent-color: var(--app-info-text, #0284c7); flex-shrink: 0; }
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; } .error { color: var(--app-error-text, #be123c); }
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
.number-fields label { display: block; padding: 0; color: var(--app-text-control, #334155); }
.number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; font: inherit; }
section {font-size:13px;border-color:var(--app-border, #e2e8f0)}h3 {font-size:15px;color:var(--app-text-control, #334155)}
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid var(--app-border, #f1f5f9);gap:20px}section > label > span {flex:1}small {font-size:12px}
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:20px;background:var(--app-surface-raised, #e2e8f0);cursor:pointer;transition:background .15s}
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--app-surface, #fff);box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:3px}input:disabled {opacity:.5;cursor:default}
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec)}
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
})
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
private savedConfig: Capabilities | null = null;
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, flash_final_check_enabled: false, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => { this.savedConfig = structuredClone(config); this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
});
}
saveFlashFinalCheck(enabled: boolean): void {
if (this.busy() || !this.loaded() || !this.savedConfig) return;
const previous = this.savedConfig.flash_final_check_enabled;
this.config.flash_final_check_enabled = enabled;
this.busy.set(true); this.message.set('正在保存 Flash 判定设置…'); this.error.set('');
// Preserve other settings that are still being edited, including invalid drafts.
const update = { ...this.savedConfig, flash_final_check_enabled: enabled };
this.http.post<Capabilities>('/api/system/capability-settings', update).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => {
this.savedConfig = structuredClone(config);
this.config.flash_final_check_enabled = config.flash_final_check_enabled;
this.changed.emit(config);
this.message.set(`Flash 最终 AI 判定已${config.flash_final_check_enabled ? '开启' : '关闭'},尚未开始的任务将使用此设置,无需重启服务。`);
},
error: () => {
this.config.flash_final_check_enabled = previous;
this.message.set('');
this.error.set('Flash 判定设置保存失败,开关已恢复。请检查连接后重新切换。');
}
});
}
save(): void {
if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return;
if (this.config.recording) {
this.config.recording.hevc_playback = !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1"');
}
this.busy.set(true); this.message.set(''); this.error.set('');
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => { this.savedConfig = structuredClone(config); this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
});
}
valid(): boolean {
const c = this.config;
const r = c.recording;
if (r && (!['auto', 'h264', 'h265'].includes(r.codec) || !['screen', 'window'].includes(r.computer_scope)
|| !Number.isInteger(r.max_long_edge) || r.max_long_edge < 320 || r.max_long_edge > 4096
|| !Number.isInteger(r.fps) || r.fps < 5 || r.fps > 60
|| !Number.isInteger(r.h265_kbps) || r.h265_kbps < 128 || r.h265_kbps > 10000
|| !Number.isInteger(r.h264_kbps) || r.h264_kbps < 128 || r.h264_kbps > 20000)) return false;
return Number.isInteger(c.task_timeout_seconds) && c.task_timeout_seconds >= 30 && c.task_timeout_seconds <= 86400
&& c.observation_timeout_seconds > 0 && c.observation_timeout_seconds <= 300
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300
&& c.adb_discovery_interval_seconds >= 1 && c.adb_discovery_interval_seconds <= 300
&& c.adb_discovery_timeout_seconds > 0 && c.adb_discovery_timeout_seconds <= 30;
}
}