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
+340 -130

No files matched your search

@@ -43,7 +43,7 @@
</section>
}
@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') {
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
@@ -68,7 +68,7 @@ describe('AutomationPanelComponent batch deletion', () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
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'
}));
});
@@ -9,7 +9,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
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 {
status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -22,11 +22,11 @@ interface ArchiveProgress {
}
@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'
})
export class AutomationPanelComponent {
taskTarget: TaskTargetOptions = { execution_target: 'android' };
taskPlugins: TaskPluginOptions = {};
queueRequested = output<void>();
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
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.selectedCount()) throw Error('请至少勾选一个用例');
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('请选择执行设备');
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);
@@ -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 -->
<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) -->
@if (showIntentSuggestion()) {
@@ -5166,3 +5166,5 @@ $text-light: #94a3b8;
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 { 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 { AgentService } from '../../services/agent.service';
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
@@ -106,7 +106,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({
selector: 'app-home',
standalone: true,
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskTargetComponent],
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss'
@@ -320,8 +320,8 @@ export class HomeComponent implements OnInit, OnDestroy {
});
// Computed helper states delegating to SystemService
taskTarget = signal<TaskTargetOptions>({ execution_target: 'android' });
public isReady = computed(() => this.taskTarget().execution_target === 'computer' || this.systemService.isReady());
taskPlugins = signal<TaskPluginOptions>({});
public isReady = computed(() => this.systemService.isReady());
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
public isLoading = computed(() => this.systemService.isLoading());
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
@@ -813,7 +813,7 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedProfile() === 'pro'
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
: undefined,
{ ...this.taskTarget() }
{ ...this.taskPlugins() }
)
.subscribe({
next: () => {
@@ -1,6 +1,6 @@
<main class="plugins-page">
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为测试接入电脑和其他工具,统一执行并保留证据。</p></div>
<button (click)="refresh(); refreshCatalog()" [disabled]="busy()">刷新</button></header>
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。</p></div>
<button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
@for (resource of blocked(); track resource[0]) {
@@ -18,7 +18,7 @@
@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> }
<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>
<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>
@@ -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>
} @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> }
@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 (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) {
<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>
}
</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; }
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; }
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.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; }
.tabs { display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0; padding-bottom: 12px; }
.tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #eaf1ff; font-weight: 600; }.tabs span { margin-left: 8px; }
.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: #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; }
.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; }
@@ -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; }
.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; } }
.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;
}
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 () => {
const fixture = setup();
await fixture.whenStable(); fixture.detectChanges();
@@ -15,6 +15,7 @@ export class PluginsComponent implements OnInit, OnDestroy {
busy = signal(false);
error = signal('');
catalogError = signal('');
catalogLoading = signal(true);
notice = signal('');
blocked = signal<[string, string][]>([]);
confirmingResource = signal<string | null>(null);
@@ -42,9 +43,10 @@ export class PluginsComponent implements OnInit, OnDestroy {
finally { this.loading.set(false); }
}
async refreshCatalog() {
this.catalogError.set('');
this.catalogLoading.set(true); this.catalogError.set('');
try { this.market.set((await firstValueFrom(this.api.catalog())).plugins); }
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; }
fields() { return Object.entries(this.editing()?.manifest.config || {}); }
@@ -185,7 +185,7 @@ limitations under the License.
</div>
<!-- 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="footer-left-group">
<!-- Segmented Architecture Toggle (Flash / Pro) -->
@@ -987,3 +987,5 @@ $rose-dark: #dc2626;
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 { HttpClient } from '@angular/common/http';
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({
selector: 'app-workspace',
standalone: true,
imports: [
FormsModule,
TaskTargetComponent,
TaskPluginsComponent,
AgentStreamComponent,
ChatInterfaceComponent,
FloatingVideoPlayerComponent
@@ -40,7 +40,7 @@ import { TaskTargetComponent, TaskTargetOptions } from '../../components/task-ta
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkspaceComponent implements OnInit {
taskTarget: TaskTargetOptions = { execution_target: 'android' };
taskPlugins: TaskPluginOptions = {};
public agentService = inject(AgentService);
private http = inject(HttpClient);
private zone = inject(NgZone);
@@ -328,7 +328,7 @@ export class WorkspaceComponent implements OnInit {
}
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) => {
this.taskInput = '';
if (this.dockInputRef?.nativeElement) {
@@ -16,7 +16,7 @@ export interface PluginProfile {
resources: string[]; authorized: boolean;
}
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 }
@Injectable({ providedIn: 'root' })
+4 -1
View File
@@ -488,7 +488,10 @@ def run_command(
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:
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
+1
View File
@@ -136,6 +136,7 @@ class PluginSelection(PluginModel):
class CatalogEntry(PluginModel):
manifest: PluginManifest
release_stage: Literal["stable", "preview"] = "stable"
archive_url: str
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
verification: str = Field(min_length=1, max_length=4000)
+3
View File
@@ -84,6 +84,9 @@ class PluginRuntime:
if not self.packages:
return ""
lines = ["\n可用扩展插件(按需调用 plugin_load 加载说明和工具):"]
lines.append(
"围绕用户测试目标自主选择工具。手机步骤使用手机工具;需要电脑、浏览器或其他外部步骤时才加载相应插件,无需用户切换执行目标。没有相关需求时不要加载或调用插件。"
)
for manifest, profile, _ in self.packages.values():
lines.append(f"- {manifest.id} / {profile.label}: {manifest.description}")
lines.append(
+10 -2
View File
@@ -236,7 +236,9 @@ class PluginStore:
def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = []
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
profiles = plugin["profiles"]
default = next((p for p in profiles if p.get("is_default")), None)
@@ -251,12 +253,18 @@ class PluginStore:
)
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
def pin(self, selections: list[PluginSelection] | None, target: str) -> list[PluginSelection]:
pinned = []
for choice in self.selections(target) if selections is None else selections:
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} 任务")
pinned.append(
PluginSelection(id=manifest.id, profile=profile.name, version=manifest.version)
+14 -5
View File
@@ -22,7 +22,7 @@
- 安装时使用最新已验证且兼容的版本。依赖进入插件专用目录,校验下载文件;安装失败保留原可用版本。
- 已安装插件提示更新,用户点击升级。任务固定使用启动时的版本,插件空闲时完成升级。
- 高级入口允许本地插件和自定义 MCP 接入,标明来源,用户手动启用。
- 验证记录注明 ARTEMIS、插件、系统版本及覆盖场景。尚未完成验证的插件不进入可安装市场索引。
- 验证记录注明 ARTEMIS、插件、系统版本及覆盖场景。开发预览包列明已完成的验证范围与未覆盖场景,不宣称已完成正式版全面验收。
## 配置、授权与 Skill
@@ -37,7 +37,7 @@
- 复用后台配置、任务队列、批次、历史和报告存储,不另建调度器。
- 执行上下文从单个 Android 设备扩展为资源集合,按资源路由动作和观察结果。
- 同一桌面或同一路由器上的用例整条串行;独立资源上的任务可并行。仅操作手机的任务不占用桌面资源。
- 同一桌面或同一路由器上的用例整条串行;独立资源上的任务可并行。当前按任务可用插件统一预留资源;显式禁用插件的手机任务不占用桌面资源。按需扩大资源集合的调度尚未实现。
- 多资源获取应避免相互等待;资源身份需要跨插件一致,不能因为通过不同插件访问同一路由器而绕开互斥。
- 暂停先停止派发并确认正在执行的动作停止,再释放资源。恢复重新排队、观察和核对环境,不沿用过期截图或状态假设。
- 网页显示正在控制的资源并提供暂停和指导入口;跨端任务沿用现有用户指导语义。
@@ -83,11 +83,11 @@ Flash 的扩展调用需要完整处理 MCP 错误及多种输出;Pro 的发
- [x] 市场查询、安装进度、配置、启停、诊断、更新和卸载。
- [x] 下载完整性检查、失败恢复、任务版本固定与升级互斥。
- [ ] 仅发布实际验证过的插件版本与验证记录。
- [x] 发布首个电脑插件开发预览及实际验证范围;正式版全面验收继续。
### 3. 跨端执行
- [x] 适配首发电脑控制插件,支持纯电脑和混合任务入口。
- [x] 适配首发电脑控制插件,安卓任务自主调用插件;保留高级 API 纯电脑入口。
- [x] 多资源互斥、暂停恢复、指导、超时及共享环境变更处理。
- [ ] 跨端证据、验收、人工复验和各类导出保持一致。
- [x] 同步 HTTP、工作区 MCP 和自动化 Skill。
@@ -115,4 +115,13 @@ Flash 的扩展调用需要完整处理 MCP 错误及多种输出;Pro 的发
- 插件截图在首次入库时同时保存本次观察文字,回归确认后续动作不会将新文字补写到前一步图片上。
- 联调还复现 Android 辅助服务缓存旧网页文字。辅助 APK 1.2.1 在 Android 13+ 每次观察前清理节点缓存,修复后同一模型任务终检通过;另有不调用模型的动态网页回归,同时验证文字与像素从初始值更新并恢复。Android 12 及更早版本不使用此 API,仍需单独验收。
- 最终默认 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。
每个插件可设置一套默认连接。自动选择只使用符合任务目标、已启用且默认连接已授权的插件。任务中也可以明确选择插件与连接;空列表表示不使用插件。
每个插件可设置一套默认连接。安卓任务默认可使用支持手机扩展(targets 含 android 或 mixed)、已启用且默认连接已授权的插件。AI 先读取能力简介,需要外部操作时才加载对应工具和 Skill。任务中也可以明确选择插件与连接;空列表表示不使用插件。
正在排队、暂停和执行中的任务会固定插件版本,并阻止修改配置、启停、升级或卸载。操作结束后再修改。卸载会移除插件安装文件与连接配置,历史证据仍保留。
## 执行任务
任务输入区和批次导入区有执行目标选项:
任务输入区和批次导入区保持安卓测试入口,无需选择“手机 + 电脑”。照常描述任务,例如“电脑修改路由器参数,再在手机验证并恢复”。默认收起的「扩展插件 · 自动」可展开指定连接,或取消自动选择且不勾选插件,以仅使用手机能力。
高级 JSON、CLI 和 SDK 保留以下执行目标参数:
| 目标 | 含义 |
| --- | --- |
| `android` | 手机任务,默认不使用桌面插件 |
| `android` | 默认手机任务,AI 按需调用已授权扩展插件 |
| `computer` | 纯电脑任务,无需 Android;必须选择提供观察能力的插件 |
| `mixed` | 手机和扩展插件组合任务 |
| `mixed` | 显式跨端任务;普通安卓任务无需设置此值 |
单任务 `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 的保护,不会替用户终止远程操作。
@@ -76,7 +78,7 @@ HTTP 服务使用 `{"transport":"streamable_http","url":"https://example.com/mcp
清单提供的工具和参数范围检查不是操作系统沙箱;第三方进程以当前用户权限运行。市场包使用 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...")
task_builder = (
agent.new_task(goal=task_desc)
.with_plugins([]) # The standalone fallback cannot dispatch plugin work.
.with_name(f"task_{trace_id}")
.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
([{id, profile}] references to saved authorized configurations; never secrets).
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.
Query mobile_workspace first; installation/authorization is in the plugin page.
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
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 网页服务后重试;未启动独立任务。"
trace_store.update_trace_status(trace_id, "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 只引用已保存的配置,不接收密钥。网页显示已启用不等于插件已经通过真实场景验证。
单任务 `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 和配置名称,版本由服务在入队时固定。安装、授权、连接凭据和资源保护解除由用户在插件页面处理。
插件超时/取消后先查任务状态和错误,不自动重放点击或配置写入。`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")
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):
process = MagicMock(pid=43210)
with (
+51 -1
View File
@@ -57,6 +57,54 @@ def enable(store, plugin, **profile):
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):
enable(store, manifest())
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
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 unittest.mock import MagicMock
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.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.execution_setup = SimpleNamespace(traces_path=str(tmp_path / "traces"))
ctx.device = None