From 9c82a40cbf368632f93decbf11cc922035e59ff2 Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Wed, 14 Oct 2026 22:32:38 +0800 Subject: [PATCH] fix: make Android tasks use extensions automatically and populate marketplace --- .../automation-panel.component.html | 2 +- .../automation-panel.component.spec.ts | 2 +- .../automation-panel.component.ts | 8 +-- .../task-plugins/task-plugins.component.html | 28 +++++++++ .../task-plugins/task-plugins.component.scss | 20 ++++++ .../task-plugins.component.spec.ts | 59 ++++++++++++++++++ .../task-plugins/task-plugins.component.ts | 54 ++++++++++++++++ .../task-target/task-target.component.spec.ts | 24 -------- .../task-target/task-target.component.ts | 61 ------------------- .../src/app/pages/home/home.component.html | 2 +- .../src/app/pages/home/home.component.scss | 2 + .../src/app/pages/home/home.component.ts | 10 +-- .../app/pages/plugins/plugins.component.html | 13 ++-- .../app/pages/plugins/plugins.component.scss | 12 +++- .../pages/plugins/plugins.component.spec.ts | 23 +++++++ .../app/pages/plugins/plugins.component.ts | 4 +- .../pages/workspace/workspace.component.html | 2 +- .../pages/workspace/workspace.component.scss | 2 + .../pages/workspace/workspace.component.ts | 8 +-- .../src/app/services/plugin.service.ts | 2 +- artemis/interfaces/cli/commands/run.py | 7 ++- artemis/plugins/models.py | 1 + artemis/plugins/runtime.py | 3 + artemis/plugins/store.py | 12 +++- docs/plans/2.0.0-plugins.md | 19 ++++-- docs/plugins.md | 14 +++-- mcp_server/background/task_runner.py | 1 + mcp_server/tools/task_runner.py | 10 ++- skills/artemis-automation/SKILL.md | 2 +- .../artemis-automation/references/plugins.md | 2 + tests/unit/mcp/test_mcp_tools.py | 11 ++++ tests/unit/test_plugins.py | 52 +++++++++++++++- 32 files changed, 341 insertions(+), 131 deletions(-) create mode 100644 apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html create mode 100644 apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.scss create mode 100644 apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts create mode 100644 apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts delete mode 100644 apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts delete mode 100644 apps/showcase_ui/src/app/components/task-target/task-target.component.ts diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html index 7d8f2eb..f231ddc 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html @@ -43,7 +43,7 @@ } @if (tab() !== 'reports') { -
+
} @if (tab() === 'batches') {

导入任务

每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。

diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts index 551eb9a..11e6ad2 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts @@ -68,7 +68,7 @@ describe('AutomationPanelComponent batch deletion', () => { await new Promise(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' })); }); diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts index bbc57a9..bb3b070 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts @@ -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(); 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); diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html new file mode 100644 index 0000000..f1e8176 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html @@ -0,0 +1,28 @@ + +@if (open()) { +
+

照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。

+ + @if (loading()) {

正在读取插件…

} + @else if (error()) { } + @else if (!automatic) { + @for (plugin of available(); track plugin.manifest.id) { +
+ + +
+ } @empty {

还没有可用插件,请先安装、授权并启用。

} +

不勾选任何插件时,本次任务只使用手机能力。

+ } @else { +

{{ available().length ? '默认连接用于自动调用,也可以关闭自动选择,指定本次可用的插件。' : '还没有可用插件,手机测试不受影响。' }}

+ } + 管理插件 +
+} diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.scss b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.scss new file mode 100644 index 0000000..70d81d1 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.scss @@ -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; } diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts new file mode 100644 index 0000000..6acb54e --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts @@ -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(); + }); +}); diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts new file mode 100644 index 0000000..7840a1d --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts @@ -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(); + private api = inject(PluginService); + automatic = true; + selected: Record = {}; + open = signal(false); + loading = signal(false); + error = signal(''); + plugins = signal([]); + + 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 })) + }); + } +} diff --git a/apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts b/apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts deleted file mode 100644 index 585f7b9..0000000 --- a/apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/apps/showcase_ui/src/app/components/task-target/task-target.component.ts b/apps/showcase_ui/src/app/components/task-target/task-target.component.ts deleted file mode 100644 index 9ceec76..0000000 --- a/apps/showcase_ui/src/app/components/task-target/task-target.component.ts +++ /dev/null @@ -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: ` -
- - -
- @if (open()) { -
- - @if (loading()) {

正在读取插件…

} - @if (error()) {

{{ error() }}

} - @if (!automatic) { - @for (plugin of available(); track plugin.manifest.id) { - - } @empty {

没有适用的已启用插件,请先在插件页面安装并授权。

} - } -

手机任务默认不占电脑资源。电脑任务无需连接 Android;混合任务会同时占用所需资源。

-
- } - `, - 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(); - private api = inject(PluginService); - target: TaskTargetOptions['execution_target'] = 'android'; - automatic = true; selected: Record = {}; - open = signal(false); loading = signal(false); error = signal(''); - plugins = signal([]); - 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})) }) }); } -} diff --git a/apps/showcase_ui/src/app/pages/home/home.component.html b/apps/showcase_ui/src/app/pages/home/home.component.html index 0ed9969..5c11ddd 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.html +++ b/apps/showcase_ui/src/app/pages/home/home.component.html @@ -1379,7 +1379,7 @@
- + @if (showIntentSuggestion()) { diff --git a/apps/showcase_ui/src/app/pages/home/home.component.scss b/apps/showcase_ui/src/app/pages/home/home.component.scss index c1a5e68..4fb2884 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.scss +++ b/apps/showcase_ui/src/app/pages/home/home.component.scss @@ -5166,3 +5166,5 @@ $text-light: #94a3b8; transform: translateY(0) scale(1); } } + +.composer-card > app-task-plugins { margin-bottom: 8px; } diff --git a/apps/showcase_ui/src/app/pages/home/home.component.ts b/apps/showcase_ui/src/app/pages/home/home.component.ts index 81a66e5..664eddd 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.ts +++ b/apps/showcase_ui/src/app/pages/home/home.component.ts @@ -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({ execution_target: 'android' }); - public isReady = computed(() => this.taskTarget().execution_target === 'computer' || this.systemService.isReady()); + taskPlugins = signal({}); + 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: () => { diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.html b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html index cbee4bc..af354aa 100644 --- a/apps/showcase_ui/src/app/pages/plugins/plugins.component.html +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html @@ -1,6 +1,6 @@
-
ARTEMIS · 扩展能力

插件

为测试接入电脑和其他工具,统一执行并保留证据。

-
+
ARTEMIS · 扩展能力

插件

为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。

+
@if (error()) { } @if (notice()) {
{{notice()}}
} @for (resource of blocked(); track resource[0]) { @@ -18,7 +18,7 @@ @if (loading()) {

正在读取插件…

} @else if (!installed().length) {
extension

还没有安装插件

从市场安装经过验证的扩展,或在下方导入自己的插件。

}
@for (plugin of installed(); track plugin.manifest.id) { -
extension

{{plugin.manifest.name}}

v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '已验证市场插件' : '本地 / 自定义'}}
{{plugin.enabled ? '已启用' : '未启用'}}
+
extension

{{plugin.manifest.name}}

v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '插件市场' : '本地 / 自定义'}}
{{plugin.enabled ? '已启用' : '未启用'}}

{{plugin.manifest.description || '暂无描述'}}

{{plugin.profiles.length}} 套连接配置
@@ -28,10 +28,11 @@
添加自定义 MCP / Skill
} @else { - @if (catalogError()) { } - @else if (!market().length) {

首批插件验证中

验证通过的插件会出现在这里;不会自动安装未经验证的版本。

} + @if (catalogLoading()) {

正在加载插件市场

正在获取插件与验证记录…

} + @else if (catalogError()) { } + @else if (!market().length) {

暂无可安装插件

当前市场尚未收录安装包,可稍后刷新。

}
@for (entry of market(); track entry.manifest.id + entry.manifest.version) { -
verified

{{entry.manifest.name}}

v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}

{{entry.manifest.description}}

验证记录

{{entry.verification}}

+
verified

{{entry.manifest.name}}

v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}
@if (entry.release_stage === 'preview') { 开发预览 }

{{entry.manifest.description}}

验证范围与限制

{{entry.verification}}

}
}
diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss b/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss index 1b5f184..aba1650 100644 --- a/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss @@ -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; } diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts b/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts index e2eaa12..cf31cf7 100644 --- a/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts @@ -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(); diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts b/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts index 665e223..251dc5a 100644 --- a/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts @@ -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(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 || {}); } diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html index 9a03b2d..815b90f 100644 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html @@ -185,7 +185,7 @@ limitations under the License.
- +