fix: make Android tasks use extensions automatically and populate marketplace

This commit is contained in:
chenmingxuan committed 2026-10-14 22:32:38 +08:00
1 parent 6663233ab8
commit 9c82a40cbf
32 files changed
+341 -131

No files matched your search

@@ -43,7 +43,7 @@
</section> </section>
} }
@if (tab() !== 'reports') { @if (tab() !== 'reports') {
<app-task-target (changed)="taskTarget = $event" /><section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section> <app-task-plugins (changed)="taskPlugins = $event" /><section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
} }
@if (tab() === 'batches') { @if (tab() === 'batches') {
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p> <section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
@@ -68,7 +68,7 @@ describe('AutomationPanelComponent batch deletion', () => {
await new Promise<void>(resolve => setTimeout(resolve, 0)); await new Promise<void>(resolve => setTimeout(resolve, 0));
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches'); const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
expect(request?.[1]).toEqual(jasmine.objectContaining({ expect(request?.[1]).toEqual(jasmine.objectContaining({
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { execution_target: 'android', model: 'mimo-v2.6-pro' } }], cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } }],
repeat_count: 3, device_serial: 'test-device' repeat_count: 3, device_serial: 'test-device'
})); }));
}); });
@@ -9,7 +9,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component'; import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component'; import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { CaseReviewResponse } from '../../core/models/session.model'; import { CaseReviewResponse } from '../../core/models/session.model';
import { TaskTargetComponent, TaskTargetOptions } from '../task-target/task-target.component'; import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component';
interface ArchiveProgress { interface ArchiveProgress {
status: 'idle' | 'preparing' | 'ready' | 'failed'; status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -22,11 +22,11 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskTargetComponent], selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
taskTarget: TaskTargetOptions = { execution_target: 'android' }; taskPlugins: TaskPluginOptions = {};
queueRequested = output<void>(); queueRequested = output<void>();
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient); agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false); tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
@@ -140,7 +140,7 @@ export class AutomationPanelComponent {
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误'); if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
if (!this.selectedCount()) throw Error('请至少勾选一个用例'); if (!this.selectedCount()) throw Error('请至少勾选一个用例');
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`); if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => ({...c, task_config: {...this.taskTarget, ...Object.fromEntries(Object.entries(c.task_config || {}).filter(([,v]) => v !== null && v !== undefined))}})); const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => ({...c, task_config: {...this.taskPlugins, ...Object.fromEntries(Object.entries(c.task_config || {}).filter(([,v]) => v !== null && v !== undefined))}}));
if (!this.device && cases.some((c: any) => c.task_config.execution_target !== 'computer' && !c.task_config.device_serial)) throw Error('请选择执行设备'); if (!this.device && cases.some((c: any) => c.task_config.execution_target !== 'computer' && !c.task_config.device_serial)) throw Error('请选择执行设备');
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device || null,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()})); const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device || null,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
this.batch.set(created); this.batch.set(created);
@@ -0,0 +1,28 @@
<button class="plugin-toggle" type="button" (click)="toggle()" [attr.aria-expanded]="open()">
<span class="material-symbols-outlined" aria-hidden="true">extension</span>
<span>扩展插件</span><span class="mode">{{ summary() }}</span>
<span class="material-symbols-outlined chevron" aria-hidden="true">{{ open() ? 'expand_less' : 'expand_more' }}</span>
</button>
@if (open()) {
<section class="plugin-options" aria-label="扩展插件设置">
<p>照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。</p>
<label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label>
@if (loading()) { <p role="status">正在读取插件…</p> }
@else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> }
@else if (!automatic) {
@for (plugin of available(); track plugin.manifest.id) {
<div class="choice">
<label><input type="checkbox" [checked]="selected[plugin.manifest.id] !== undefined" (change)="choose(plugin, $event)">{{ plugin.manifest.name }}</label>
<select [disabled]="selected[plugin.manifest.id] === undefined" [ngModel]="selected[plugin.manifest.id] || ''" (ngModelChange)="selected[plugin.manifest.id] = $event; emit()" [attr.aria-label]="plugin.manifest.name + '连接配置'">
<option value="" disabled>选择连接配置</option>
@for (profile of plugin.profiles; track profile.name) { @if (profile.authorized) { <option [value]="profile.name">{{ profile.label }}</option> } }
</select>
</div>
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
} @else {
<p class="hint">{{ available().length ? '默认连接用于自动调用,也可以关闭自动选择,指定本次可用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
}
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
</section>
}
@@ -0,0 +1,20 @@
:host { display: block; width: 100%; color: #475569; font-size: 12px; }
.plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #e2e8f0; border-radius: 20px; padding: 5px 10px; background: #f8fafc; color: #64748b; font: inherit; cursor: pointer; transition: background .15s, border-color .15s; }
.plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: #eff6ff; border-color: #bfdbfe; color: #2563eb; }
.material-symbols-outlined { font-size: 16px; }
.mode { color: #2563eb; padding-left: 6px; border-left: 1px solid #dce5ef; }
.chevron { font-size: 15px; }
.plugin-options { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; }
p { margin: 0; line-height: 1.65; }
label { display: flex; align-items: center; gap: 8px; }
.auto-option { font-weight: 500; color: #334155; }
input[type=checkbox] { accent-color: #2563eb; margin: 0; width: 14px; height: 14px; flex-shrink: 0; }
.choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
select { font: inherit; color: #334155; background: #fff; border: 1px solid #dce3eb; border-radius: 8px; padding: 6px 10px; max-width: 100%; }
select:disabled { opacity: .5; }
.hint { color: #64748b; font-size: 11px; }
.error { color: #b91c1c; }
.text-button, .manage-link { color: #2563eb; font: inherit; text-decoration: none; }
.text-button { background: none; border: 0; padding: 0 6px; cursor: pointer; }
.manage-link { display: inline-flex; align-items: center; gap: 4px; justify-self: start; }
:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
@@ -0,0 +1,59 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { TaskPluginsComponent } from './task-plugins.component';
describe('TaskPluginsComponent', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [TaskPluginsComponent], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('starts collapsed and automatic without an execution target selector', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
fixture.detectChanges();
http.expectNone('/api/plugins');
expect(fixture.nativeElement.textContent).toContain('自动');
expect(fixture.nativeElement.textContent).not.toContain('执行目标');
expect(fixture.nativeElement.querySelector('select')).toBeNull();
const emitted = jasmine.createSpy('changed');
fixture.componentInstance.changed.subscribe(emitted);
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
http.expectOne('/api/plugins').flush({ plugins: [] });
await fixture.whenStable(); fixture.detectChanges();
fixture.nativeElement.querySelector('input').click(); fixture.detectChanges();
expect(emitted).toHaveBeenCalledWith({ plugins: [] });
fixture.nativeElement.querySelector('input').click(); fixture.detectChanges();
expect(emitted).toHaveBeenCalledWith({});
});
it('offers authorized cross-device connections and uses the default profile', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
const component = fixture.componentInstance;
component.toggle();
const plugin = { enabled: true, manifest: { id: 'desktop', name: '电脑控制', targets: ['computer', 'mixed'] }, profiles: [
{ name: 'default', label: '旧配置', authorized: false },
{ name: 'lab', label: '实验室', authorized: true, is_default: true }
] };
http.expectOne('/api/plugins').flush({ plugins: [plugin, { ...plugin, enabled: false, manifest: { ...plugin.manifest, id: 'disabled' } }] });
component.automatic = false; fixture.detectChanges(); await fixture.whenStable();
const emitted = jasmine.createSpy('changed'); component.changed.subscribe(emitted);
const checkbox = fixture.nativeElement.querySelector('.choice input');
checkbox.click(); fixture.detectChanges(); await fixture.whenStable();
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'lab' }] });
expect(fixture.nativeElement.querySelectorAll('.choice').length).toBe(1);
expect(fixture.nativeElement.querySelector('select').textContent).not.toContain('旧配置');
});
it('shows a retryable error and keeps automatic mode on lookup failure', () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
fixture.componentInstance.toggle();
http.expectOne('/api/plugins').flush({}, { status: 503, statusText: 'Unavailable' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('重试');
expect(fixture.componentInstance.automatic).toBeTrue();
});
});
@@ -0,0 +1,54 @@
import { Component, inject, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
export interface TaskPluginOptions {
plugins?: { id: string; profile: string; version?: string }[];
}
@Component({
selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink],
templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss'
})
export class TaskPluginsComponent {
changed = output<TaskPluginOptions>();
private api = inject(PluginService);
automatic = true;
selected: Record<string, string> = {};
open = signal(false);
loading = signal(false);
error = signal('');
plugins = signal<InstalledPlugin[]>([]);
summary() {
return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用';
}
available() {
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
}
toggle() {
this.open.update(v => !v);
if (this.open()) this.load();
}
load() {
this.loading.set(true); this.error.set('');
this.api.list().subscribe({
next: value => { this.plugins.set(value.plugins); this.loading.set(false); },
error: () => { this.error.set('暂时无法读取插件。'); this.loading.set(false); }
});
}
choose(plugin: InstalledPlugin, event: Event) {
if ((event.target as HTMLInputElement).checked) {
const profiles = plugin.profiles.filter(p => p.authorized);
this.selected[plugin.manifest.id] = (profiles.find(p => p.is_default) || profiles[0]).name;
} else delete this.selected[plugin.manifest.id];
this.emit();
}
emit() {
this.changed.emit(this.automatic ? {} : {
plugins: Object.entries(this.selected).map(([id, profile]) => ({ id, profile }))
});
}
}
@@ -1,24 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { TaskTargetComponent } from './task-target.component';
describe('TaskTargetComponent', () => {
it('defaults to Android without fetching plugins, and distinguishes auto selection from no plugins', () => {
TestBed.configureTestingModule({ imports: [TaskTargetComponent], providers: [provideHttpClient(), provideHttpClientTesting()] });
const fixture = TestBed.createComponent(TaskTargetComponent);
const http = TestBed.inject(HttpTestingController);
const component = fixture.componentInstance;
fixture.detectChanges();
http.expectNone('/api/plugins');
const emitted = jasmine.createSpy('changed');
component.changed.subscribe(emitted);
component.target = 'computer'; component.targetChanged();
expect(emitted).toHaveBeenCalledWith({ execution_target: 'computer' });
component.automatic = false; component.emit();
expect(emitted).toHaveBeenCalledWith({ execution_target: 'computer', plugins: [] });
component.toggle(); http.expectOne('/api/plugins').flush({ plugins: [] });
expect(component.loading()).toBeFalse();
http.verify();
});
});
@@ -1,61 +0,0 @@
import { Component, inject, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
export interface TaskTargetOptions {
execution_target: 'android' | 'computer' | 'mixed';
plugins?: { id: string; profile: string; version?: string }[];
}
@Component({
selector: 'app-task-target', standalone: true, imports: [FormsModule],
template: `
<div class="target-row">
<label>执行目标 <select aria-label="执行目标" [(ngModel)]="target" (ngModelChange)="targetChanged()">
<option value="android">Android 手机</option><option value="computer">电脑</option><option value="mixed">手机 + 扩展插件</option>
</select></label>
<button type="button" (click)="toggle()" [attr.aria-expanded]="open()">插件配置 {{ open() ? '收起' : '展开' }}</button>
</div>
@if (open()) {
<div class="plugin-choices">
<label><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()"> 自动选择已启用且支持此目标的插件</label>
@if (loading()) { <p role="status">正在读取插件…</p> }
@if (error()) { <p role="alert">{{ error() }}</p> }
@if (!automatic) {
@for (plugin of available(); track plugin.manifest.id) {
<label class="choice"><input type="checkbox" [checked]="selected[plugin.manifest.id] !== undefined" (change)="choose(plugin, $event)">
{{ plugin.manifest.name }}
<select [disabled]="selected[plugin.manifest.id] === undefined" [ngModel]="selected[plugin.manifest.id] || 'default'" (ngModelChange)="selected[plugin.manifest.id] = $event; emit()" [attr.aria-label]="plugin.manifest.name + '连接配置'">
@for (profile of plugin.profiles; track profile.name) { @if (profile.authorized) { <option [value]="profile.name">{{ profile.label }}</option> } }
</select>
</label>
} @empty { <p>没有适用的已启用插件,请先在插件页面安装并授权。</p> }
}
<p class="hint">手机任务默认不占电脑资源。电脑任务无需连接 Android;混合任务会同时占用所需资源。</p>
</div>
}
`,
styles: [`:host{display:block;width:100%;font-size:12px;color:var(--text-secondary,#a0a5b4)}.target-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.target-row label,.choice{display:flex;align-items:center;gap:8px}select,button{font:inherit;color:inherit;background:var(--bg-tertiary,#252935);border:1px solid var(--border-color,#414653);border-radius:6px;padding:5px 8px}button{cursor:pointer}.plugin-choices{padding:10px 0;display:grid;gap:9px}.choice{justify-content:space-between}p{margin:0;line-height:1.5}.hint{opacity:.8}input{accent-color:#72b9d6}`]
})
export class TaskTargetComponent {
changed = output<TaskTargetOptions>();
private api = inject(PluginService);
target: TaskTargetOptions['execution_target'] = 'android';
automatic = true; selected: Record<string, string> = {};
open = signal(false); loading = signal(false); error = signal('');
plugins = signal<InstalledPlugin[]>([]);
available() { return this.plugins().filter(p => p.enabled && p.manifest.targets.includes(this.target)); }
toggle() { this.open.update(v => !v); if (this.open()) this.load(); }
targetChanged() { this.selected = {}; this.emit(); if (this.open()) this.load(); }
load() { this.loading.set(true); this.error.set(''); this.api.list().subscribe({
next: value => { this.plugins.set(value.plugins); this.loading.set(false); },
error: () => { this.error.set('插件列表暂不可用,请重新展开重试。'); this.loading.set(false); }
}); }
choose(plugin: InstalledPlugin, event: Event) {
if ((event.target as HTMLInputElement).checked) this.selected[plugin.manifest.id] = plugin.profiles.find(p => p.authorized)?.name || 'default';
else delete this.selected[plugin.manifest.id];
this.emit();
}
emit() { this.changed.emit({ execution_target: this.target,
...(this.automatic ? {} : { plugins: Object.entries(this.selected).map(([id, profile]) => ({id, profile})) }) }); }
}
@@ -1379,7 +1379,7 @@
<!-- Task composer --> <!-- Task composer -->
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'"> <div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
<app-task-target (changed)="taskTarget.set($event)" /> <app-task-plugins (changed)="taskPlugins.set($event)" />
<!-- Smart Intent Recommendation Strip (if detected) --> <!-- Smart Intent Recommendation Strip (if detected) -->
@if (showIntentSuggestion()) { @if (showIntentSuggestion()) {
@@ -5166,3 +5166,5 @@ $text-light: #94a3b8;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
.composer-card > app-task-plugins { margin-bottom: 8px; }
@@ -18,7 +18,7 @@ import { ModelSettingsComponent } from '../../components/model-settings/model-se
import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { TaskTargetComponent, TaskTargetOptions } from '../../components/task-target/task-target.component'; import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service'; import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
@@ -106,7 +106,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskTargetComponent], imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager, changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss' styleUrl: './home.component.scss'
@@ -320,8 +320,8 @@ export class HomeComponent implements OnInit, OnDestroy {
}); });
// Computed helper states delegating to SystemService // Computed helper states delegating to SystemService
taskTarget = signal<TaskTargetOptions>({ execution_target: 'android' }); taskPlugins = signal<TaskPluginOptions>({});
public isReady = computed(() => this.taskTarget().execution_target === 'computer' || this.systemService.isReady()); public isReady = computed(() => this.systemService.isReady());
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport()); public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
public isLoading = computed(() => this.systemService.isLoading()); public isLoading = computed(() => this.systemService.isLoading());
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb()); public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
@@ -813,7 +813,7 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedProfile() === 'pro' this.selectedProfile() === 'pro'
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id } ? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
: undefined, : undefined,
{ ...this.taskTarget() } { ...this.taskPlugins() }
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -1,6 +1,6 @@
<main class="plugins-page"> <main class="plugins-page">
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为测试接入电脑和其他工具,统一执行并保留证据。</p></div> <header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。</p></div>
<button (click)="refresh(); refreshCatalog()" [disabled]="busy()">刷新</button></header> <button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
@if (error()) { <div class="message error" role="alert">{{error()}}</div> } @if (error()) { <div class="message error" role="alert">{{error()}}</div> }
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> } @if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
@for (resource of blocked(); track resource[0]) { @for (resource of blocked(); track resource[0]) {
@@ -18,7 +18,7 @@
@if (loading()) { <p role="status" class="empty">正在读取插件…</p> } @if (loading()) { <p role="status" class="empty">正在读取插件…</p> }
@else if (!installed().length) { <section class="empty"><span class="material-symbols-outlined">extension</span><h2>还没有安装插件</h2><p>从市场安装经过验证的扩展,或在下方导入自己的插件。</p><button class="primary" (click)="tab.set('market')">浏览插件市场</button></section> } @else if (!installed().length) { <section class="empty"><span class="material-symbols-outlined">extension</span><h2>还没有安装插件</h2><p>从市场安装经过验证的扩展,或在下方导入自己的插件。</p><button class="primary" (click)="tab.set('market')">浏览插件市场</button></section> }
<div class="cards">@for (plugin of installed(); track plugin.manifest.id) { <div class="cards">@for (plugin of installed(); track plugin.manifest.id) {
<article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">extension</span></div><div><h2>{{plugin.manifest.name}}</h2><span class="meta">v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '已验证市场插件' : '本地 / 自定义'}}</span></div><span class="badge" [class.enabled]="plugin.enabled">{{plugin.enabled ? '已启用' : '未启用'}}</span></div> <article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">extension</span></div><div><h2>{{plugin.manifest.name}}</h2><span class="meta">v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '插件市场' : '本地 / 自定义'}}</span></div><span class="badge" [class.enabled]="plugin.enabled">{{plugin.enabled ? '已启用' : '未启用'}}</span></div>
<p>{{plugin.manifest.description || '暂无描述'}}</p><div class="meta">{{plugin.profiles.length}} 套连接配置</div> <p>{{plugin.manifest.description || '暂无描述'}}</p><div class="meta">{{plugin.profiles.length}} 套连接配置</div>
<footer><button (click)="edit(plugin)" [disabled]="busy()">配置</button><button (click)="diagnose(plugin)" [disabled]="busy() || !plugin.enabled">检查连接</button><button [class.primary]="!plugin.enabled" (click)="toggle(plugin)" [disabled]="busy()">{{plugin.enabled ? '停用' : '启用'}}</button><button class="text-button" (click)="removing.set(plugin)" [disabled]="busy()">卸载</button></footer> <footer><button (click)="edit(plugin)" [disabled]="busy()">配置</button><button (click)="diagnose(plugin)" [disabled]="busy() || !plugin.enabled">检查连接</button><button [class.primary]="!plugin.enabled" (click)="toggle(plugin)" [disabled]="busy()">{{plugin.enabled ? '停用' : '启用'}}</button><button class="text-button" (click)="removing.set(plugin)" [disabled]="busy()">卸载</button></footer>
</article> </article>
@@ -28,10 +28,11 @@
<details><summary>添加自定义 MCP / Skill</summary><label>插件清单 JSON<textarea rows="12" [(ngModel)]="customManifest" spellcheck="false"></textarea></label><label>Skill 内容(可选)<textarea rows="4" [(ngModel)]="customSkill"></textarea></label><button (click)="addCustom()" [disabled]="busy()">添加插件</button></details> <details><summary>添加自定义 MCP / Skill</summary><label>插件清单 JSON<textarea rows="12" [(ngModel)]="customManifest" spellcheck="false"></textarea></label><label>Skill 内容(可选)<textarea rows="4" [(ngModel)]="customSkill"></textarea></label><button (click)="addCustom()" [disabled]="busy()">添加插件</button></details>
</details> </details>
} @else { } @else {
@if (catalogError()) { <div class="message error" role="alert">{{catalogError()}} <button (click)="refreshCatalog()">重试</button></div> } @if (catalogLoading()) { <section class="empty" role="status"><span class="material-symbols-outlined loading-icon" aria-hidden="true">progress_activity</span><h2>正在加载插件市场</h2><p>正在获取插件与验证记录…</p></section> }
@else if (!market().length) { <section class="empty"><h2>首批插件验证中</h2><p>验证通过的插件会出现在这里;不会自动安装未经验证的版本。</p></section> } @else if (catalogError()) { <div class="message error" role="alert">{{catalogError()}} <button (click)="refreshCatalog()">重试</button></div> }
@else if (!market().length) { <section class="empty"><h2>暂无可安装插件</h2><p>当前市场尚未收录安装包,可稍后刷新。</p></section> }
<div class="cards">@for (entry of market(); track entry.manifest.id + entry.manifest.version) { <div class="cards">@for (entry of market(); track entry.manifest.id + entry.manifest.version) {
<article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">verified</span></div><div><h2>{{entry.manifest.name}}</h2><span class="meta">v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}</span></div></div><p>{{entry.manifest.description}}</p><details><summary>验证记录</summary><p>{{entry.verification}}</p></details><footer><button class="primary" (click)="install({plugin_id: entry.manifest.id, version: entry.manifest.version})" [disabled]="busy() || installedVersion(entry.manifest.id) === entry.manifest.version">{{installedVersion(entry.manifest.id) === entry.manifest.version ? '已安装' : installedVersion(entry.manifest.id) ? '安装此版本' : '安装插件'}}</button></footer></article> <article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">verified</span></div><div><h2>{{entry.manifest.name}}</h2><span class="meta">v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}</span></div>@if (entry.release_stage === 'preview') { <span class="badge preview">开发预览</span> }</div><p>{{entry.manifest.description}}</p><details><summary>验证范围与限制</summary><p>{{entry.verification}}</p></details><footer><button class="primary" (click)="install({plugin_id: entry.manifest.id, version: entry.manifest.version})" [disabled]="busy() || installedVersion(entry.manifest.id) === entry.manifest.version">{{installedVersion(entry.manifest.id) === entry.manifest.version ? '已安装' : installedVersion(entry.manifest.id) ? '安装此版本' : '安装插件'}}</button></footer></article>
}</div> }</div>
} }
</main> </main>
@@ -5,12 +5,12 @@ header { justify-content: space-between; margin-bottom: 28px; }
h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; } h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; }
p { color: #64748b; line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; } p { color: #64748b; line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
.eyebrow { color: #3b6e9c; font-size: 11px; letter-spacing: 1.4px; font-weight: 600; } .eyebrow { color: #3b6e9c; font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
button { border: 1px solid #dce3eb; border-radius: 8px; background: #fff; color: #334155; padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap; } button { border: 1px solid #dce3eb; border-radius: 20px; background: #fff; color: #334155; padding: 8px 16px; font-size: 13px; cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled) { border-color: #9fb7d3; background: #f0f6ff; } button:disabled { opacity: .5; cursor: not-allowed; } button:hover:not(:disabled) { border-color: #9fb7d3; background: #f0f6ff; } button:disabled { opacity: .5; cursor: not-allowed; }
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: #b91c1c; border-color: #fecaca; } button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: #b91c1c; border-color: #fecaca; }
.text-button { border: 0; margin-left: auto; color: #64748b; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; } .text-button { border: 0; margin-left: auto; color: #64748b; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
.tabs { display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0; padding-bottom: 12px; } .tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 24px; background: #f1f5f9; padding: 4px; border-radius: 24px; }
.tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #eaf1ff; font-weight: 600; }.tabs span { margin-left: 8px; } .tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #fff; box-shadow: 0 1px 4px #0f172a0d; font-weight: 600; }.tabs span { margin-left: 8px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; }
.card { padding: 22px; background: #fff; border: 1px solid #e1e7ef; border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; } .card { padding: 22px; background: #fff; border: 1px solid #e1e7ef; border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: #4477ba; background: #eef4ff; border-radius: 12px; } .plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: #4477ba; background: #eef4ff; border-radius: 12px; }
@@ -22,3 +22,9 @@ details { margin: 16px 0; }summary { cursor: pointer; color: #475569; padding: 8
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid #d5dde7; background: #fff; border-radius: 8px; padding: 10px 12px; color: #1e293b; font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; } label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid #d5dde7; background: #fff; border-radius: 8px; padding: 10px 12px; color: #1e293b; font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: #fff; padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; } .modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: #fff; padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
@media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } } @media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } }
.loading-icon { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .loading-icon { animation: none; } }
.badge.preview { background: #fffbeb; color: #92400e; }
@@ -20,6 +20,29 @@ describe('PluginsComponent', () => {
return fixture; return fixture;
} }
it('distinguishes market loading, failure, empty and available states', async () => {
const fixture = TestBed.createComponent(PluginsComponent);
fixture.detectChanges();
fixture.componentInstance.tab.set('market'); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('正在加载插件市场');
expect(fixture.nativeElement.textContent).not.toContain('暂无可安装插件');
http.expectOne('/api/plugins').flush({ plugins: [] });
http.expectOne('/api/plugins/catalog').flush({ detail: '暂时无法读取插件市场' }, { status: 502, statusText: 'Bad Gateway' });
await fixture.whenStable(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('暂时无法读取插件市场');
expect(fixture.nativeElement.textContent).not.toContain('暂无可安装插件');
const retry = fixture.componentInstance.refreshCatalog();
http.expectOne('/api/plugins/catalog').flush({ plugins: [] });
await retry; fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('暂无可安装插件');
const refresh = fixture.componentInstance.refreshCatalog();
http.expectOne('/api/plugins/catalog').flush({ plugins: [{ manifest: { id: 'desktop', name: '电脑控制', version: '1.0.0', platforms: ['win32'], description: '操作桌面及浏览器' }, verification: '开发预览:已验证基础表单操作' }] });
await refresh; fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('电脑控制');
expect(fixture.nativeElement.textContent).toContain('开发预览');
expect(fixture.nativeElement.textContent).toContain('安装插件');
});
it('shows empty state and requires explicit confirmation before releasing a protected resource', async () => { it('shows empty state and requires explicit confirmation before releasing a protected resource', async () => {
const fixture = setup(); const fixture = setup();
await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges();
@@ -15,6 +15,7 @@ export class PluginsComponent implements OnInit, OnDestroy {
busy = signal(false); busy = signal(false);
error = signal(''); error = signal('');
catalogError = signal(''); catalogError = signal('');
catalogLoading = signal(true);
notice = signal(''); notice = signal('');
blocked = signal<[string, string][]>([]); blocked = signal<[string, string][]>([]);
confirmingResource = signal<string | null>(null); confirmingResource = signal<string | null>(null);
@@ -42,9 +43,10 @@ export class PluginsComponent implements OnInit, OnDestroy {
finally { this.loading.set(false); } finally { this.loading.set(false); }
} }
async refreshCatalog() { async refreshCatalog() {
this.catalogError.set(''); this.catalogLoading.set(true); this.catalogError.set('');
try { this.market.set((await firstValueFrom(this.api.catalog())).plugins); } try { this.market.set((await firstValueFrom(this.api.catalog())).plugins); }
catch (error) { this.catalogError.set(this.message(error)); } catch (error) { this.catalogError.set(this.message(error)); }
finally { this.catalogLoading.set(false); }
} }
installedVersion(id: string) { return this.installed().find(p => p.manifest.id === id)?.manifest.version; } installedVersion(id: string) { return this.installed().find(p => p.manifest.id === id)?.manifest.version; }
fields() { return Object.entries(this.editing()?.manifest.config || {}); } fields() { return Object.entries(this.editing()?.manifest.config || {}); }
@@ -185,7 +185,7 @@ limitations under the License.
</div> </div>
<!-- Bottom Segmented Controls Row --> <!-- Bottom Segmented Controls Row -->
<app-task-target (changed)="taskTarget = $event" /> <app-task-plugins class="dock-plugin-options" (changed)="taskPlugins = $event" />
<div class="card-footer-row"> <div class="card-footer-row">
<div class="footer-left-group"> <div class="footer-left-group">
<!-- Segmented Architecture Toggle (Flash / Pro) --> <!-- Segmented Architecture Toggle (Flash / Pro) -->
@@ -987,3 +987,5 @@ $rose-dark: #dc2626;
animation: none !important; animation: none !important;
} }
} }
.dock-plugin-options { padding: 0 14px 6px; box-sizing: border-box; }
@@ -23,14 +23,14 @@ import { FloatingVideoPlayerComponent } from '../../components/floating-video-pl
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { TaskTargetComponent, TaskTargetOptions } from '../../components/task-target/task-target.component'; import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
@Component({ @Component({
selector: 'app-workspace', selector: 'app-workspace',
standalone: true, standalone: true,
imports: [ imports: [
FormsModule, FormsModule,
TaskTargetComponent, TaskPluginsComponent,
AgentStreamComponent, AgentStreamComponent,
ChatInterfaceComponent, ChatInterfaceComponent,
FloatingVideoPlayerComponent FloatingVideoPlayerComponent
@@ -40,7 +40,7 @@ import { TaskTargetComponent, TaskTargetOptions } from '../../components/task-ta
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class WorkspaceComponent implements OnInit { export class WorkspaceComponent implements OnInit {
taskTarget: TaskTargetOptions = { execution_target: 'android' }; taskPlugins: TaskPluginOptions = {};
public agentService = inject(AgentService); public agentService = inject(AgentService);
private http = inject(HttpClient); private http = inject(HttpClient);
private zone = inject(NgZone); private zone = inject(NgZone);
@@ -328,7 +328,7 @@ export class WorkspaceComponent implements OnInit {
} }
this.isInputFocused.set(false); this.isInputFocused.set(false);
this.agentService.runTask(goal, this.selectedProfile(), undefined, undefined, undefined, { ...this.taskTarget }).subscribe({ this.agentService.runTask(goal, this.selectedProfile(), undefined, undefined, undefined, { ...this.taskPlugins }).subscribe({
next: (res) => { next: (res) => {
this.taskInput = ''; this.taskInput = '';
if (this.dockInputRef?.nativeElement) { if (this.dockInputRef?.nativeElement) {
@@ -16,7 +16,7 @@ export interface PluginProfile {
resources: string[]; authorized: boolean; resources: string[]; authorized: boolean;
} }
export interface InstalledPlugin { manifest: PluginManifest; enabled: boolean; source: string; profiles: PluginProfile[] } export interface InstalledPlugin { manifest: PluginManifest; enabled: boolean; source: string; profiles: PluginProfile[] }
export interface MarketPlugin { manifest: PluginManifest; verification: string } export interface MarketPlugin { manifest: PluginManifest; verification: string; release_stage?: 'stable' | 'preview' }
export interface InstallProgress { id: string; status: 'queued' | 'running' | 'completed' | 'failed'; stage: string; progress: number } export interface InstallProgress { id: string; status: 'queued' | 'running' | 'completed' | 'failed'; stage: string; progress: number }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
+5 -2
View File
@@ -488,8 +488,11 @@ def run_command(
f"[yellow]Daemon routing notice: {exc}. Falling back to local execution...[/yellow]" f"[yellow]Daemon routing notice: {exc}. Falling back to local execution...[/yellow]"
) )
if not is_worker and not is_standalone and (target != "android" or choices): if not is_worker and not is_standalone:
raise typer.BadParameter("插件任务需要统一队列服务,请检查服务后重试;未自动重复提交。") from artemis.plugins.store import PluginStore
if target != "android" or choices or (choices is None and PluginStore().selections(target)):
raise typer.BadParameter("插件任务需要统一队列服务,请检查服务后重试;未自动重复提交。")
adb_client = None adb_client = None
try: try:
+1
View File
@@ -136,6 +136,7 @@ class PluginSelection(PluginModel):
class CatalogEntry(PluginModel): class CatalogEntry(PluginModel):
manifest: PluginManifest manifest: PluginManifest
release_stage: Literal["stable", "preview"] = "stable"
archive_url: str archive_url: str
sha256: str = Field(pattern=r"^[a-f0-9]{64}$") sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
verification: str = Field(min_length=1, max_length=4000) verification: str = Field(min_length=1, max_length=4000)
+3
View File
@@ -84,6 +84,9 @@ class PluginRuntime:
if not self.packages: if not self.packages:
return "" return ""
lines = ["\n可用扩展插件(按需调用 plugin_load 加载说明和工具):"] lines = ["\n可用扩展插件(按需调用 plugin_load 加载说明和工具):"]
lines.append(
"围绕用户测试目标自主选择工具。手机步骤使用手机工具;需要电脑、浏览器或其他外部步骤时才加载相应插件,无需用户切换执行目标。没有相关需求时不要加载或调用插件。"
)
for manifest, profile, _ in self.packages.values(): for manifest, profile, _ in self.packages.values():
lines.append(f"- {manifest.id} / {profile.label}: {manifest.description}") lines.append(f"- {manifest.id} / {profile.label}: {manifest.description}")
lines.append( lines.append(
+10 -2
View File
@@ -236,7 +236,9 @@ class PluginStore:
def selections(self, target: str = "mixed") -> list[PluginSelection]: def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = [] selections = []
for plugin in self.list(): for plugin in self.list():
if not plugin["enabled"] or target not in plugin["manifest"].get("targets", ["mixed"]): if not plugin["enabled"] or not self._supports_task(
plugin["manifest"].get("targets", ["mixed"]), target
):
continue continue
profiles = plugin["profiles"] profiles = plugin["profiles"]
default = next((p for p in profiles if p.get("is_default")), None) default = next((p for p in profiles if p.get("is_default")), None)
@@ -251,12 +253,18 @@ class PluginStore:
) )
return selections return selections
@staticmethod
def _supports_task(targets: list[str], target: str) -> bool:
# Android tasks can call cross-device extensions without a separate mode.
# Computer-only plugins still need to explicitly support mixed use.
return target in targets or (target == "android" and "mixed" in targets)
@serialized @serialized
def pin(self, selections: list[PluginSelection] | None, target: str) -> list[PluginSelection]: def pin(self, selections: list[PluginSelection] | None, target: str) -> list[PluginSelection]:
pinned = [] pinned = []
for choice in self.selections(target) if selections is None else selections: for choice in self.selections(target) if selections is None else selections:
manifest, profile, _ = self.resolve(choice) manifest, profile, _ = self.resolve(choice)
if target not in manifest.targets: if not self._supports_task(manifest.targets, target):
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务") raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
pinned.append( pinned.append(
PluginSelection(id=manifest.id, profile=profile.name, version=manifest.version) PluginSelection(id=manifest.id, profile=profile.name, version=manifest.version)
+14 -5
View File
@@ -22,7 +22,7 @@
- 安装时使用最新已验证且兼容的版本。依赖进入插件专用目录,校验下载文件;安装失败保留原可用版本。 - 安装时使用最新已验证且兼容的版本。依赖进入插件专用目录,校验下载文件;安装失败保留原可用版本。
- 已安装插件提示更新,用户点击升级。任务固定使用启动时的版本,插件空闲时完成升级。 - 已安装插件提示更新,用户点击升级。任务固定使用启动时的版本,插件空闲时完成升级。
- 高级入口允许本地插件和自定义 MCP 接入,标明来源,用户手动启用。 - 高级入口允许本地插件和自定义 MCP 接入,标明来源,用户手动启用。
- 验证记录注明 ARTEMIS、插件、系统版本及覆盖场景。尚未完成验证的插件不进入可安装市场索引。 - 验证记录注明 ARTEMIS、插件、系统版本及覆盖场景。开发预览包列明已完成的验证范围与未覆盖场景,不宣称已完成正式版全面验收。
## 配置、授权与 Skill ## 配置、授权与 Skill
@@ -37,7 +37,7 @@
- 复用后台配置、任务队列、批次、历史和报告存储,不另建调度器。 - 复用后台配置、任务队列、批次、历史和报告存储,不另建调度器。
- 执行上下文从单个 Android 设备扩展为资源集合,按资源路由动作和观察结果。 - 执行上下文从单个 Android 设备扩展为资源集合,按资源路由动作和观察结果。
- 同一桌面或同一路由器上的用例整条串行;独立资源上的任务可并行。仅操作手机的任务不占用桌面资源。 - 同一桌面或同一路由器上的用例整条串行;独立资源上的任务可并行。当前按任务可用插件统一预留资源;显式禁用插件的手机任务不占用桌面资源。按需扩大资源集合的调度尚未实现。
- 多资源获取应避免相互等待;资源身份需要跨插件一致,不能因为通过不同插件访问同一路由器而绕开互斥。 - 多资源获取应避免相互等待;资源身份需要跨插件一致,不能因为通过不同插件访问同一路由器而绕开互斥。
- 暂停先停止派发并确认正在执行的动作停止,再释放资源。恢复重新排队、观察和核对环境,不沿用过期截图或状态假设。 - 暂停先停止派发并确认正在执行的动作停止,再释放资源。恢复重新排队、观察和核对环境,不沿用过期截图或状态假设。
- 网页显示正在控制的资源并提供暂停和指导入口;跨端任务沿用现有用户指导语义。 - 网页显示正在控制的资源并提供暂停和指导入口;跨端任务沿用现有用户指导语义。
@@ -83,11 +83,11 @@ Flash 的扩展调用需要完整处理 MCP 错误及多种输出;Pro 的发
- [x] 市场查询、安装进度、配置、启停、诊断、更新和卸载。 - [x] 市场查询、安装进度、配置、启停、诊断、更新和卸载。
- [x] 下载完整性检查、失败恢复、任务版本固定与升级互斥。 - [x] 下载完整性检查、失败恢复、任务版本固定与升级互斥。
- [ ] 仅发布实际验证过的插件版本与验证记录。 - [x] 发布首个电脑插件开发预览及实际验证范围;正式版全面验收继续。
### 3. 跨端执行 ### 3. 跨端执行
- [x] 适配首发电脑控制插件,支持纯电脑和混合任务入口。 - [x] 适配首发电脑控制插件,安卓任务自主调用插件;保留高级 API 纯电脑入口。
- [x] 多资源互斥、暂停恢复、指导、超时及共享环境变更处理。 - [x] 多资源互斥、暂停恢复、指导、超时及共享环境变更处理。
- [ ] 跨端证据、验收、人工复验和各类导出保持一致。 - [ ] 跨端证据、验收、人工复验和各类导出保持一致。
- [x] 同步 HTTP、工作区 MCP 和自动化 Skill。 - [x] 同步 HTTP、工作区 MCP 和自动化 Skill。
@@ -115,4 +115,13 @@ Flash 的扩展调用需要完整处理 MCP 错误及多种输出;Pro 的发
- 插件截图在首次入库时同时保存本次观察文字,回归确认后续动作不会将新文字补写到前一步图片上。 - 插件截图在首次入库时同时保存本次观察文字,回归确认后续动作不会将新文字补写到前一步图片上。
- 联调还复现 Android 辅助服务缓存旧网页文字。辅助 APK 1.2.1 在 Android 13+ 每次观察前清理节点缓存,修复后同一模型任务终检通过;另有不调用模型的动态网页回归,同时验证文字与像素从初始值更新并恢复。Android 12 及更早版本不使用此 API,仍需单独验收。 - 联调还复现 Android 辅助服务缓存旧网页文字。辅助 APK 1.2.1 在 Android 13+ 每次观察前清理节点缓存,修复后同一模型任务终检通过;另有不调用模型的动态网页回归,同时验证文字与像素从初始值更新并恢复。Android 12 及更早版本不使用此 API,仍需单独验收。
- 最终默认 Python 测试 2451 通过、2 跳过、8 未选中;Ruff 格式/静态检查、质量基线、核心 Pyright 均通过。前端 187 项测试与生产构建通过,真实 Android 动态网页回归通过。历史检索测试改用同步存储以消除测试自身的线程等待竞争,断言保持不变。 - 最终默认 Python 测试 2451 通过、2 跳过、8 未选中;Ruff 格式/静态检查、质量基线、核心 Pyright 均通过。前端 187 项测试与生产构建通过,真实 Android 动态网页回归通过。历史检索测试改用同步存储以消除测试自身的线程等待竞争,断言保持不变。
- 复杂浏览器控制台、多 DPI、多显示器、长程运行、真实批次组合及混合 Pro 模式还需要验收。插件市场不提前发布未完成验收的安装包。 - 复杂浏览器控制台、多 DPI、多显示器、长程运行、真实批次组合及混合 Pro 模式还需要验收。这些未覆盖项不包含在首个电脑插件开发预览包的验证范围内。
### 插件入口修正(2026-10-14)
- 普通安卓任务自动提供已启用且默认连接已授权的扩展简介,由模型按需加载工具和 Skill。无需选择 mixed。仍可通过 plugins: [] 禁用或指定命名配置。
- 首页、工作区和批次导入移除执行目标选择,使用默认收起的浅色「扩展插件 · 自动」入口;保留高级 API 的纯电脑目标。
- 插件市场区分加载、失败、空列表与可安装内容,明确标注开发预览。电脑控制 1.0.0 包和校验文件已上传市场仓库,索引已推送。
- 公开 HTTPS 下载、SHA-256 与清单校验、隔离目录安装通过。新装保持未授权、未启用;不覆盖用户配置。
- 回归覆盖普通安卓任务按需加载跨端插件、默认连接、禁用与未授权边界、MCP 不自动换执行器。默认 Python 2456 通过、2 跳过、8 未选中;前端 190 项通过,生产构建及 Ruff、质量基线、核心类型检查通过。
- 浏览器已检查市场卡片、安装按钮、首页/工作区的折叠插件入口。这一轮未额外运行付费模型任务,真实跨端模型证据沿用上述联调记录。
+8 -6
View File
@@ -8,19 +8,21 @@ ARTEMIS 可通过插件连接 stdio 或 Streamable HTTP MCP 服务,并按需
点击「配置」建立命名连接,例如 `lab-router`。填写字段、工具白名单、允许的参数值和共享资源,勾选授权后保存,再启用插件。密码留空保留已保存的值;页面和 MCP 查询只返回已配置的密钥名称。不要把密钥写进清单、提示词或任务 JSON。 点击「配置」建立命名连接,例如 `lab-router`。填写字段、工具白名单、允许的参数值和共享资源,勾选授权后保存,再启用插件。密码留空保留已保存的值;页面和 MCP 查询只返回已配置的密钥名称。不要把密钥写进清单、提示词或任务 JSON。
每个插件可设置一套默认连接。自动选择只使用符合任务目标、已启用且默认连接已授权的插件。任务中也可以明确选择插件与连接;空列表表示不使用插件。 每个插件可设置一套默认连接。安卓任务默认可使用支持手机扩展(targets 含 android 或 mixed)、已启用且默认连接已授权的插件。AI 先读取能力简介,需要外部操作时才加载对应工具和 Skill。任务中也可以明确选择插件与连接;空列表表示不使用插件。
正在排队、暂停和执行中的任务会固定插件版本,并阻止修改配置、启停、升级或卸载。操作结束后再修改。卸载会移除插件安装文件与连接配置,历史证据仍保留。 正在排队、暂停和执行中的任务会固定插件版本,并阻止修改配置、启停、升级或卸载。操作结束后再修改。卸载会移除插件安装文件与连接配置,历史证据仍保留。
## 执行任务 ## 执行任务
任务输入区和批次导入区有执行目标选项: 任务输入区和批次导入区保持安卓测试入口,无需选择“手机 + 电脑”。照常描述任务,例如“电脑修改路由器参数,再在手机验证并恢复”。默认收起的「扩展插件 · 自动」可展开指定连接,或取消自动选择且不勾选插件,以仅使用手机能力。
高级 JSON、CLI 和 SDK 保留以下执行目标参数:
| 目标 | 含义 | | 目标 | 含义 |
| --- | --- | | --- | --- |
| `android` | 手机任务,默认不使用桌面插件 | | `android` | 默认手机任务,AI 按需调用已授权扩展插件 |
| `computer` | 纯电脑任务,无需 Android;必须选择提供观察能力的插件 | | `computer` | 纯电脑任务,无需 Android;必须选择提供观察能力的插件 |
| `mixed` | 手机和扩展插件组合任务 | | `mixed` | 显式跨端任务;普通安卓任务无需设置此值 |
单任务 `runtime_overrides`、批次每项 `task_config` 都支持: 单任务 `runtime_overrides`、批次每项 `task_config` 都支持:
@@ -40,7 +42,7 @@ SDK:先 `await agent.init(execution_target="computer")`,然后 `agent.new_ta
## 共享资源与中断 ## 共享资源与中断
同一目标应使用同一个资源名,例如 `desktop:local` 或 `router:lab`。资源名不区分大小写。不同插件操作同一路由器时也要使用相同资源名。无资源声明的 MCP 插件按插件与配置名称保守互斥。 同一目标应使用同一个资源名,例如 `desktop:local` 或 `router:lab`。当前采用保守调度:任务启动时统一预留所有可用插件声明的共享资源,避免执行中跨端加锁死锁;工具与进程仍按需加载。需要独立并行的手机任务可设置 plugins: [],不占插件资源。资源名不区分大小写。不同插件操作同一路由器时也要使用相同资源名。无资源声明的 MCP 插件按插件与配置名称保守互斥。
暂停会先取消调用并关闭插件连接,再释放任务资源;恢复排队后重新观察。插件取消/超时的写操作可能已经生效,不能盲目重放。如果远程服务未确认动作停止,页面会显示资源保护及原因,用户核对后点击「已核对,解除保护」。这只解除 ARTEMIS 的保护,不会替用户终止远程操作。 暂停会先取消调用并关闭插件连接,再释放任务资源;恢复排队后重新观察。插件取消/超时的写操作可能已经生效,不能盲目重放。如果远程服务未确认动作停止,页面会显示资源保护及原因,用户核对后点击「已核对,解除保护」。这只解除 ARTEMIS 的保护,不会替用户终止远程操作。
@@ -76,7 +78,7 @@ HTTP 服务使用 `{"transport":"streamable_http","url":"https://example.com/mcp
清单提供的工具和参数范围检查不是操作系统沙箱;第三方进程以当前用户权限运行。市场包使用 HTTPS、SHA-256、平台/版本检查及安全解压,包内应保留许可证和来源记录。 清单提供的工具和参数范围检查不是操作系统沙箱;第三方进程以当前用户权限运行。市场包使用 HTTPS、SHA-256、平台/版本检查及安全解压,包内应保留许可证和来源记录。
市场索引为 `{"schema_version":1,"plugins":[...]}`,每项包含 `manifest`、`archive_url`、`sha256`、`verification`。同一版本不可覆盖,先验证新包再使用新版本号。 市场索引为 `{"schema_version":1,"plugins":[...]}`,每项包含 `manifest`、`archive_url`、`sha256`、`verification`,以及 release_stage(stable 或 preview,默认 stable)。开发预览在市场明确标注,并提供已验证范围和未覆盖场景。同一版本不可覆盖,先验证新包再使用新版本号。
## 环境参数 ## 环境参数
+1
View File
@@ -247,6 +247,7 @@ async def run_task(
print("Running task on agent...") print("Running task on agent...")
task_builder = ( task_builder = (
agent.new_task(goal=task_desc) agent.new_task(goal=task_desc)
.with_plugins([]) # The standalone fallback cannot dispatch plugin work.
.with_name(f"task_{trace_id}") .with_name(f"task_{trace_id}")
.with_trace_recording(enabled=True, path=trace_store.TRACES_DIR) .with_trace_recording(enabled=True, path=trace_store.TRACES_DIR)
) )
+9 -1
View File
@@ -336,6 +336,7 @@ def mobile_run_task(
execution_target ('android', 'computer', 'mixed') and plugins execution_target ('android', 'computer', 'mixed') and plugins
([{id, profile}] references to saved authorized configurations; never secrets). ([{id, profile}] references to saved authorized configurations; never secrets).
Omitted plugins selects matching enabled defaults; [] disables plugins. Omitted plugins selects matching enabled defaults; [] disables plugins.
Default Android tasks can use mixed extensions on demand without changing targets.
Computer tasks require an observation plugin, not an Android device. Computer tasks require an observation plugin, not an Android device.
Query mobile_workspace first; installation/authorization is in the plugin page. Query mobile_workspace first; installation/authorization is in the plugin page.
timeout_seconds (total execution, default 1800; excludes queue/pause), timeout_seconds (total execution, default 1800; excludes queue/pause),
@@ -517,7 +518,14 @@ def mobile_run_task(
} }
# 4. Standalone Fallback: Resolve project root, python executable, and background runner module # 4. Standalone Fallback: Resolve project root, python executable, and background runner module
if execution_target != "android" or (runtime_overrides or {}).get("plugins"): from artemis.plugins.store import PluginStore
plugin_choices = (runtime_overrides or {}).get("plugins")
if (
execution_target != "android"
or plugin_choices
or (plugin_choices is None and PluginStore().selections("android"))
):
error = "插件及电脑任务需要统一任务服务,请启动 ARTEMIS 网页服务后重试;未启动独立任务。" error = "插件及电脑任务需要统一任务服务,请启动 ARTEMIS 网页服务后重试;未启动独立任务。"
trace_store.update_trace_status(trace_id, "failed", error=error) trace_store.update_trace_status(trace_id, "failed", error=error)
return {"trace_id": trace_id, "status": "failed", "error": error} return {"trace_id": trace_id, "status": "failed", "error": error}
+1 -1
View File
@@ -15,7 +15,7 @@ Windows 启动脚本只提示源码更新。升级使用 `update.bat`(国内
先用 `mobile_workspace` 查看实际插件、连接配置与资源保护状态。插件安装、凭据与授权在网页「插件」页完成;MCP 只引用已保存的配置,不接收密钥。网页显示已启用不等于插件已经通过真实场景验证。 先用 `mobile_workspace` 查看实际插件、连接配置与资源保护状态。插件安装、凭据与授权在网页「插件」页完成;MCP 只引用已保存的配置,不接收密钥。网页显示已启用不等于插件已经通过真实场景验证。
单任务 `runtime_overrides` 和每条批次用例的 `task_config` 支持 `execution_target`(`android`、`computer`、`mixed`)及 `plugins`(如 `[{"id":"open-computer-use","profile":"default"}]`)。省略 plugins 自动选择该目标的已授权默认配置;`[]` 禁用插件。纯电脑任务不要求 Android,也不传 device_serial;混合任务仍需选择手机。完整参数与清单见 [插件任务参数](references/plugins.md)。 单任务 `runtime_overrides` 和每条批次用例的 `task_config` 支持 `execution_target`(`android`、`computer`、`mixed`)及 `plugins`(如 `[{"id":"open-computer-use","profile":"default"}]`)。普通安卓任务无需设置 execution_target,AI 可按需调用支持 mixed 的扩展插件。省略 plugins 使用已启用、已授权的默认配置;`[]` 禁用插件。纯电脑任务不要求 Android,也不传 device_serial;混合任务仍需选择手机。完整参数与清单见 [插件任务参数](references/plugins.md)。
同一桌面或路由器上的任务串行。暂停要等插件取消并确认停止后才释放资源;恢复时先观察当前状态。远程操作超时或中断可能已经生效,不自动重试写操作;资源处于保护状态时告知用户核对远程动作后从插件页面解除。插件配置和版本在排队、暂停、运行期间固定,变更范围需结束任务后修改配置。 同一桌面或路由器上的任务串行。暂停要等插件取消并确认停止后才释放资源;恢复时先观察当前状态。远程操作超时或中断可能已经生效,不自动重试写操作;资源处于保护状态时告知用户核对远程动作后从插件页面解除。插件配置和版本在排队、暂停、运行期间固定,变更范围需结束任务后修改配置。
@@ -19,3 +19,5 @@
未提供 `plugins` 表示使用该目标下已启用插件的授权默认配置;空数组明确不用插件。只提供插件 ID 和配置名称,版本由服务在入队时固定。安装、授权、连接凭据和资源保护解除由用户在插件页面处理。 未提供 `plugins` 表示使用该目标下已启用插件的授权默认配置;空数组明确不用插件。只提供插件 ID 和配置名称,版本由服务在入队时固定。安装、授权、连接凭据和资源保护解除由用户在插件页面处理。
插件超时/取消后先查任务状态和错误,不自动重放点击或配置写入。`mobile_workspace.blocked_resources` 非空时说明存在尚未确认停止的资源,不能声称暂停已安全完成。工具返回成功还需从后续画面或结构化读数验证业务结果。 插件超时/取消后先查任务状态和错误,不自动重放点击或配置写入。`mobile_workspace.blocked_resources` 非空时说明存在尚未确认停止的资源,不能声称暂停已安全完成。工具返回成功还需从后续画面或结构化读数验证业务结果。
普通安卓测试无需指定 execution_target 或 mixed:省略 plugins 时,AI 可以按需加载已启用、默认配置已授权且支持 android/mixed 的插件。仅需手机能力或不希望预留桌面资源时使用 plugins: []。网页、MCP 和批次采用相同规则。
+11
View File
@@ -88,6 +88,17 @@ def test_mobile_run_task_invalid_model():
mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model") mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model")
def test_auto_plugins_do_not_silently_fall_back_to_a_different_executor(temp_trace_env):
with (
patch("artemis.plugins.store.PluginStore.selections", return_value=["desktop"]),
patch("mcp_server.tools.task_runner.subprocess.Popen") as popen,
):
result = mobile_run_task(task_desc="手机测试中操作电脑", model="flash")
assert result["status"] == "failed"
assert "统一任务服务" in result["error"]
popen.assert_not_called()
def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env): def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env):
process = MagicMock(pid=43210) process = MagicMock(pid=43210)
with ( with (
+51 -1
View File
@@ -57,6 +57,54 @@ def enable(store, plugin, **profile):
store.set_enabled(plugin.id, True) store.set_enabled(plugin.id, True)
@pytest.mark.asyncio
async def test_android_task_discovers_cross_device_plugins_and_loads_only_on_demand(store):
plugin = manifest(targets=["computer", "mixed"])
enable(store, plugin)
runtime = PluginRuntime(store=store, target="android")
try:
assert list(runtime.packages) == [plugin.id]
assert runtime.loaded == set()
assert runtime.sessions == {}
assert plugin.id in runtime.prompt()
assert (await runtime.call("plugin_load", {"plugin_id": plugin.id})).status == "success"
assert runtime.loaded == {plugin.id}
finally:
await runtime.aclose()
def test_android_auto_selection_respects_enablement_authorization_and_explicit_opt_out(store):
enable(store, manifest(targets=["mixed"]))
assert len(store.pin(None, "android")) == 1
assert store.pin([], "android") == []
store.set_enabled("test-plugin", False)
assert store.pin(None, "android") == []
with pytest.raises(ValueError, match="未启用"):
store.pin([PluginSelection(id="test-plugin")], "android")
store.set_enabled("test-plugin", True)
store.save_profile("test-plugin", PluginProfile(authorized=False))
assert store.pin(None, "android") == []
with pytest.raises(ValueError, match="授权"):
store.pin([PluginSelection(id="test-plugin")], "android")
def test_android_task_uses_authorized_default_and_allows_explicit_profile(store):
enable(store, manifest(targets=["mixed"]))
store.save_profile(
"test-plugin",
PluginProfile(name="lab", is_default=True, authorized=True, secrets={"token": "lab"}),
)
assert store.pin(None, "android")[0].profile == "lab"
assert store.pin([PluginSelection(id="test-plugin")], "android")[0].profile == "default"
def test_computer_only_plugin_is_not_automatically_offered_to_android(store):
enable(store, manifest(targets=["computer"]))
assert store.pin(None, "android") == []
with pytest.raises(ValueError, match="不支持"):
store.pin([PluginSelection(id="test-plugin")], "android")
def test_profile_secrets_are_write_only_and_blank_preserves(store): def test_profile_secrets_are_write_only_and_blank_preserves(store):
enable(store, manifest()) enable(store, manifest())
assert "private-value" not in json.dumps(store.list()) assert "private-value" not in json.dumps(store.list())
@@ -921,7 +969,7 @@ async def test_pro_text_only_observation_never_sends_an_empty_image():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path): async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path, monkeypatch):
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from PIL import Image from PIL import Image
@@ -929,6 +977,8 @@ async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path)
from artemis.data_engine.engine import DataEngine from artemis.data_engine.engine import DataEngine
from artemis.plugins.models import PluginCallResult from artemis.plugins.models import PluginCallResult
monkeypatch.setattr(DataEngine, "_ipc_port_candidates", staticmethod(lambda: []))
monkeypatch.setattr("artemis.data_engine.engine.PAUSE_FILE", tmp_path / "pause")
ctx = MagicMock(spec=ArtemisContext) ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = SimpleNamespace(traces_path=str(tmp_path / "traces")) ctx.execution_setup = SimpleNamespace(traces_path=str(tmp_path / "traces"))
ctx.device = None ctx.device = None