fix: make Android tasks use extensions automatically and populate marketplace
This commit is contained in:
1 parent
6663233ab8
commit
9c82a40cbf
32 files changed
+341
-131
No files matched your search
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user