feat: release 2.1.0 with optional Flash final AI review
Default Flash final review to off and persist the setting across the UI, MCP, SDK and queued tasks. Keep execution separate from test verdicts. Bundle UI fonts and licenses to remove Google Fonts from startup builds. Align backend/frontend versions and document installation and upgrades.
This commit is contained in:
1 parent
b584dae80a
commit
83bcfd54d5
52 files changed
+898
-33
No files matched your search
@@ -12,6 +12,7 @@ interface Capabilities {
|
||||
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;
|
||||
@@ -28,7 +29,7 @@ interface Capabilities {
|
||||
template: `
|
||||
<section aria-label="功能开关">
|
||||
<h3>功能开关</h3>
|
||||
<p>按需启用,保存后对新任务生效。</p>
|
||||
<p>按需启用,保存后对尚未开始的任务生效,不影响运行中的任务。</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()" />
|
||||
@@ -51,6 +52,8 @@ interface Capabilities {
|
||||
}
|
||||
<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" [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()" />
|
||||
@@ -96,7 +99,7 @@ interface Capabilities {
|
||||
export class CapabilitySettingsComponent implements OnInit {
|
||||
changed = output<Capabilities>();
|
||||
private http = inject(HttpClient);
|
||||
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, 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 };
|
||||
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({
|
||||
|
||||
@@ -220,4 +220,37 @@ describe('持久化模型与功能设置', () => {
|
||||
fixture.componentInstance.save();
|
||||
http.expectNone('/api/system/capability-settings');
|
||||
});
|
||||
|
||||
it('Flash 最终 AI 判定默认关闭,保存失败保留选择,成功后重新打开仍保留', async () => {
|
||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
const capabilities = { ...fixture.componentInstance.config };
|
||||
fixture.detectChanges();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
const toggle = root.querySelector<HTMLInputElement>('[aria-label="Flash 最终 AI 判定与分析"]')!;
|
||||
await Promise.resolve();
|
||||
expect(toggle.disabled).toBeTrue();
|
||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||
fixture.detectChanges(); await fixture.whenStable();
|
||||
expect(toggle.checked).toBeFalse();
|
||||
expect(root.textContent).toContain('不影响 Pro');
|
||||
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||
root.querySelector<HTMLButtonElement>('button')!.click();
|
||||
let request = http.expectOne('/api/system/capability-settings');
|
||||
expect(request.request.body.flash_final_check_enabled).toBeTrue();
|
||||
request.flush({}, {status:500,statusText:'Error'});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeTrue();
|
||||
expect(fixture.componentInstance.error()).toContain('保存失败');
|
||||
root.querySelector<HTMLButtonElement>('button')!.click();
|
||||
request = http.expectOne('/api/system/capability-settings');
|
||||
const saved = request.request.body;
|
||||
request.flush(saved);
|
||||
fixture.destroy();
|
||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
reopened.detectChanges();
|
||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||
reopened.detectChanges(); await reopened.whenStable();
|
||||
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
|
||||
reopened.destroy();
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -5,10 +5,14 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
passed: '测试通过', failed: '测试不通过', inconclusive: '未验证', blocked: '测试阻塞'
|
||||
};
|
||||
|
||||
export function testResultSourceLabel(result: CaseTestResult | null | undefined): string {
|
||||
return result?.source === 'human' ? '人工确认' : result?.verification_skipped ? 'AI 判定已关闭' : 'AI确认';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-test-result-badge',
|
||||
standalone: true,
|
||||
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="sourceLabel() + ':' + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
styles: [`
|
||||
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
||||
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
||||
@@ -29,8 +33,10 @@ export class TestResultBadgeComponent {
|
||||
compact = input(false);
|
||||
prominent = input(false);
|
||||
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
|
||||
sourceLabel = computed(() => testResultSourceLabel(this.result()));
|
||||
label = computed(() => this.result()?.source === 'human'
|
||||
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
|
||||
: this.status() === 'inconclusive' && this.result()?.verification_skipped ? '未做 AI 判定'
|
||||
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
|
||||
}
|
||||
+20
@@ -62,4 +62,24 @@ describe('用例测试结果卡片', () => {
|
||||
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('跳过 Flash AI 判定时不显示通过或 AI 确认,仍允许人工复验', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
const skipped = {status:'inconclusive',reason:'执行完成 · 未做 AI 判定',evidence:[],verification_skipped:true,source:'system'};
|
||||
fixture.componentRef.setInput('result', skipped);
|
||||
fixture.componentRef.setInput('sessionId','flash-skipped');
|
||||
fixture.componentRef.setInput('executionStatus','completed');
|
||||
fixture.detectChanges();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
expect(root.querySelector('.prominent')?.textContent).toContain('未做 AI 判定');
|
||||
expect(root.querySelector('.caption')?.textContent).toContain('AI 判定已关闭');
|
||||
expect(root.querySelector('.prominent')?.getAttribute('title')).not.toContain('AI确认');
|
||||
expect(root.querySelector('.review-toolbar')).not.toBeNull();
|
||||
fixture.componentRef.setInput('result', {status:'passed',reason:'人工核对完成',evidence:[],source:'human',ai_result:skipped});
|
||||
fixture.detectChanges();
|
||||
expect(root.textContent).toContain('人工通过');
|
||||
expect(root.textContent).toContain('原始结果 · 未做 AI 判定');
|
||||
expect(root.textContent).not.toContain('原 AI 结论');
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
@@ -4,17 +4,17 @@ import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
import { TestResultBadgeComponent, testResultSourceLabel } from './test-result-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-test-result-card',
|
||||
standalone: true,
|
||||
imports: [ReportMarkdownComponent, TestResultBadgeComponent, DatePipe, FormsModule],
|
||||
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<header><span class="caption">用例测试结果 <small>{{sourceLabel()}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<app-report-markdown class="reason" [content]="result().reason" />
|
||||
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
|
||||
@if(result().ai_result; as original){<details><summary>{{original.verification_skipped ? '原始结果 · 未做 AI 判定' : '原 AI 结论 · ' + (original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证')}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
|
||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol></details>}
|
||||
@if(canReview()){
|
||||
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||
@@ -53,6 +53,7 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
export class TestResultCardComponent {
|
||||
private http = inject(HttpClient);
|
||||
result = input.required<CaseTestResult>();
|
||||
sourceLabel = computed(() => testResultSourceLabel(this.result()));
|
||||
sessionId = input<string | null>(null);
|
||||
executionStatus = input('');
|
||||
reviewSaved = output<CaseReviewResponse>();
|
||||
|
||||
@@ -25,7 +25,8 @@ export interface CaseTestResult {
|
||||
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
||||
reason: string;
|
||||
evidence: string[];
|
||||
source?: 'ai' | 'human';
|
||||
source?: 'ai' | 'human' | 'system';
|
||||
verification_skipped?: boolean;
|
||||
reviewed_at?: number;
|
||||
ai_result?: CaseTestResult | null;
|
||||
}
|
||||
|
||||
@@ -1423,7 +1423,7 @@
|
||||
<span class="mode-name">Flash</span>
|
||||
<span class="bullet">·</span>
|
||||
<span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span>
|
||||
<span class="limitation-tag">(轻量执行,结束时判断测试结果)</span>
|
||||
<span class="limitation-tag">(最终 AI 判定默认关闭,可在功能配置中开启)</span>
|
||||
</p>
|
||||
} @else if(selectedProfile() === 'exploration') {
|
||||
<p class="desc-line"><span class="mode-name">探索</span><span class="bullet">·</span><span>边执行边交流,支持人工示范,成功后生成可复用用例。</span></p>
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
<title>artemis</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Roboto:wght@400;500;700&display=swap" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/logo.png?v=2">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=2">
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg?v=2">
|
||||
|
||||
@@ -14,10 +14,42 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Roboto:wght@400;500;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
||||
@import '@angular/cdk/overlay-prebuilt.css';
|
||||
|
||||
// Keep fonts local so production builds and icons do not depend on Google Fonts.
|
||||
// Sources and licenses: public/fonts/README.md.
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/outfit-latin-wght-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/roboto-latin-wght-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
font-display: block;
|
||||
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
|
||||
}
|
||||
|
||||
.cdk-overlay-container {
|
||||
z-index: 1200 !important;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user