+
@if (showIntentSuggestion()) {
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 ff427f3..81a66e5 100644
--- a/apps/showcase_ui/src/app/pages/home/home.component.ts
+++ b/apps/showcase_ui/src/app/pages/home/home.component.ts
@@ -18,6 +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 { Router } from '@angular/router';
import { AgentService } from '../../services/agent.service';
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
@@ -105,7 +106,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({
selector: 'app-home',
standalone: true,
- imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent],
+ imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskTargetComponent],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss'
@@ -319,7 +320,8 @@ export class HomeComponent implements OnInit, OnDestroy {
});
// Computed helper states delegating to SystemService
- public isReady = computed(() => this.systemService.isReady());
+ taskTarget = signal
({ execution_target: 'android' });
+ public isReady = computed(() => this.taskTarget().execution_target === 'computer' || this.systemService.isReady());
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
public isLoading = computed(() => this.systemService.isLoading());
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
@@ -810,7 +812,8 @@ export class HomeComponent implements OnInit, OnDestroy {
undefined,
this.selectedProfile() === 'pro'
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
- : undefined
+ : undefined,
+ { ...this.taskTarget() }
)
.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
new file mode 100644
index 0000000..cbee4bc
--- /dev/null
+++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html
@@ -0,0 +1,57 @@
+
+ ARTEMIS · 扩展能力插件
为测试接入电脑和其他工具,统一执行并保留证据。
+
+ @if (error()) { {{error()}}
}
+ @if (notice()) { {{notice()}}
}
+ @for (resource of blocked(); track resource[0]) {
+ 资源保护 · {{resource[0]}}{{resource[1]}}
+ }
+ @if (progress(); as job) {
+ {{job.stage}}{{job.progress}}%
+
+ }
+
+
+
+
+ @if (tab() === 'installed') {
+ @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 ? '已启用' : '未启用'}}
+ {{plugin.manifest.description || '暂无描述'}}
{{plugin.profiles.length}} 套连接配置
+
+
+ }
+ 高级接入 · 本地插件与自定义 MCP
本地插件需要包含 plugin.json;导入后配置授权范围,再手动启用。
+
+ 添加自定义 MCP / Skill
+
+ } @else {
+ @if (catalogError()) { {{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}}
+ }
+ }
+
+@if (confirmingResource(); as resource) {
+ 确认 {{resource}} 已停止操作
请先核对目标设备或远程服务,确认之前的插件动作已经停止,并检查实际状态。解除后任务才可再次操作此资源。
+}
+@if (editing(); as plugin) {
+
+}
+@if (removing(); as plugin) { 卸载 {{plugin.manifest.name}}?
此插件及其连接配置将不再供新任务使用。
}
diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss b/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss
new file mode 100644
index 0000000..1b5f184
--- /dev/null
+++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss
@@ -0,0 +1,24 @@
+:host { display: block; color: #1e293b; font-size: 14px; }
+.plugins-page { max-width: 1120px; padding: 88px 32px 56px; margin: auto; }
+header, .card-top, footer, .inline, .install-progress > div { display: flex; align-items: center; gap: 12px; }
+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: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; }
+.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; }
+.meta, small { color: #64748b; font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: #f1f5f9; font-size: 11px; color: #64748b; }.badge.enabled { background: #ecfdf5; color: #047857; }
+.empty { padding: 55px 24px; text-align: center; border: 1px dashed #cbd5e1; border-radius: 14px; background: #ffffff80; margin-bottom: 24px; }.empty > span { font-size: 36px; color: #94a3b8; margin-bottom: 16px; }
+.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: #b91c1c; background: #fff1f2; }.success { color: #047857; background: #ecfdf5; }
+.install-progress { background: #fff; border: 1px solid #dbeafe; padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: #2563eb; }
+details { margin: 16px 0; }summary { cursor: pointer; color: #475569; padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid #e2e8f0; border-radius: 12px; margin-top: 24px; }
+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; } }
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
new file mode 100644
index 0000000..e2eaa12
--- /dev/null
+++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts
@@ -0,0 +1,68 @@
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
+import { PluginsComponent } from './plugins.component';
+import { InstalledPlugin } from '../../services/plugin.service';
+
+describe('PluginsComponent', () => {
+ let http: HttpTestingController;
+ beforeEach(() => {
+ TestBed.configureTestingModule({ imports: [PluginsComponent], providers: [provideHttpClient(), provideHttpClientTesting()] });
+ http = TestBed.inject(HttpTestingController);
+ });
+ afterEach(() => http.verify());
+
+ function setup() {
+ const fixture = TestBed.createComponent(PluginsComponent);
+ fixture.detectChanges();
+ http.expectOne('/api/plugins').flush({ plugins: [], blocked_resources: { 'router:lab': '操作结果不确定' } });
+ http.expectOne('/api/plugins/catalog').flush({ plugins: [] });
+ return fixture;
+ }
+
+ it('shows empty state and requires explicit confirmation before releasing a protected resource', async () => {
+ const fixture = setup();
+ await fixture.whenStable(); fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('还没有安装插件');
+ expect(fixture.nativeElement.textContent).toContain('router:lab');
+ const component = fixture.componentInstance;
+ await component.confirmStopped();
+ http.expectNone('/api/plugins/resources/confirm-stopped');
+ component.confirmingResource.set('router:lab');
+ const operation = component.confirmStopped();
+ const request = http.expectOne('/api/plugins/resources/confirm-stopped');
+ expect(request.request.body).toEqual({ resource: 'router:lab', confirmed_stopped: true });
+ request.flush({ ok: true });
+ await Promise.resolve();
+ http.expectOne('/api/plugins').flush({ plugins: [], blocked_resources: {} });
+ await operation;
+ expect(component.blocked()).toEqual([]);
+ });
+
+ it('keeps saved secrets write-only and preserves the configuration form on failure', async () => {
+ const fixture = setup(); await fixture.whenStable();
+ const component = fixture.componentInstance;
+ component.edit({ manifest: { id: 'demo', config: {} }, profiles: [{ name: 'default', label: '默认', values: {}, configured_secrets: ['token'], allowed_tools: null, argument_constraints: {}, resources: [], authorized: true }] } as unknown as InstalledPlugin);
+ const operation = component.save();
+ const request = http.expectOne('/api/plugins/demo/profiles');
+ expect(request.request.body.secrets).toEqual({});
+ expect(request.request.body.configured_secrets).toBeUndefined();
+ request.flush({ detail: '插件正被任务使用' }, { status: 409, statusText: 'Conflict' });
+ await operation;
+ expect(component.editing()).not.toBeNull();
+ expect(component.error()).toContain('任务使用');
+ expect(component.busy()).toBeFalse();
+ });
+
+ it('shows installation failure without pretending that the package was installed', async () => {
+ const fixture = setup(); await fixture.whenStable();
+ const operation = fixture.componentInstance.install({ plugin_id: 'demo' });
+ http.expectOne('/api/plugins/install').flush({ job_id: 'job-1' });
+ await Promise.resolve();
+ http.expectOne('/api/plugins/install/job-1').flush({ id: 'job-1', status: 'failed', stage: '校验失败', progress: 75 });
+ await operation;
+ expect(fixture.componentInstance.error()).toBe('校验失败');
+ expect(fixture.componentInstance.busy()).toBeFalse();
+ expect(fixture.componentInstance.installed()).toEqual([]);
+ });
+});
diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts b/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts
new file mode 100644
index 0000000..665e223
--- /dev/null
+++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts
@@ -0,0 +1,113 @@
+import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { firstValueFrom } from 'rxjs';
+import { InstalledPlugin, InstallProgress, MarketPlugin, PluginProfile, PluginService } from '../../services/plugin.service';
+
+@Component({
+ selector: 'app-plugins', standalone: true, imports: [FormsModule],
+ templateUrl: './plugins.component.html', styleUrl: './plugins.component.scss'
+})
+export class PluginsComponent implements OnInit, OnDestroy {
+ private api = inject(PluginService);
+ installed = signal([]);
+ market = signal([]);
+ loading = signal(true);
+ busy = signal(false);
+ error = signal('');
+ catalogError = signal('');
+ notice = signal('');
+ blocked = signal<[string, string][]>([]);
+ confirmingResource = signal(null);
+ tab = signal<'installed' | 'market'>('installed');
+ progress = signal(null);
+ editing = signal(null);
+ removing = signal(null);
+ profile: PluginProfile = this.emptyProfile();
+ resources = ''; allowedTools = ''; constraints = '{}'; localPath = '';
+ customManifest = JSON.stringify({ schema_version: 1, id: 'my-plugin', name: '我的插件', version: '1.0.0',
+ description: '插件用途', platforms: ['win32'], server: { transport: 'stdio', command: '程序绝对路径', args: [] } }, null, 2);
+ customSkill = '';
+ private timer?: ReturnType;
+ private destroyed = false;
+
+ ngOnInit() { void this.refresh(); void this.refreshCatalog(); }
+ ngOnDestroy() { this.destroyed = true; clearTimeout(this.timer); }
+ emptyProfile(): PluginProfile { return { name: 'default', label: '默认配置', values: {}, secrets: {},
+ allowed_tools: null, argument_constraints: {}, resources: [], authorized: false }; }
+ private message(error: any): string { return typeof error?.error?.detail === 'string' ? error.error.detail : '操作失败,请检查连接后重试。'; }
+ async refresh() {
+ this.loading.set(true);
+ try { const result = await firstValueFrom(this.api.list()); this.installed.set(result.plugins); this.blocked.set(Object.entries(result.blocked_resources || {})); }
+ catch (error) { this.error.set(this.message(error)); }
+ finally { this.loading.set(false); }
+ }
+ async refreshCatalog() {
+ this.catalogError.set('');
+ try { this.market.set((await firstValueFrom(this.api.catalog())).plugins); }
+ catch (error) { this.catalogError.set(this.message(error)); }
+ }
+ installedVersion(id: string) { return this.installed().find(p => p.manifest.id === id)?.manifest.version; }
+ fields() { return Object.entries(this.editing()?.manifest.config || {}); }
+ edit(plugin: InstalledPlugin) { this.editing.set(plugin); this.selectProfile(plugin.profiles[0]?.name || 'default'); }
+ selectProfile(name: string) {
+ const selected = this.editing()?.profiles.find(p => p.name === name);
+ this.profile = selected ? { ...structuredClone(selected), secrets: {} } : this.emptyProfile();
+ this.resources = this.profile.resources.join('\n');
+ this.allowedTools = this.profile.allowed_tools?.join('\n') || '';
+ this.constraints = JSON.stringify(this.profile.argument_constraints, null, 2);
+ }
+ newProfile() { this.profile = { ...this.emptyProfile(), name: '', label: '新配置' }; this.resources = ''; this.allowedTools = ''; this.constraints = '{}'; }
+ async save() {
+ const plugin = this.editing(); if (!plugin || this.busy()) return;
+ this.busy.set(true); this.error.set('');
+ try {
+ const profile = { ...this.profile, resources: this.resources.split('\n').map(v => v.trim()).filter(Boolean),
+ allowed_tools: this.allowedTools.trim() ? this.allowedTools.split('\n').map(v => v.trim()).filter(Boolean) : null,
+ argument_constraints: JSON.parse(this.constraints) };
+ await firstValueFrom(this.api.save(plugin.manifest.id, profile));
+ this.editing.set(null); this.profile = this.emptyProfile(); await this.refresh();
+ this.notice.set('配置已保存。启用后可供后续任务使用。');
+ } catch (error) { this.error.set(error instanceof SyntaxError ? '参数约束不是有效的 JSON。' : this.message(error)); }
+ finally { this.busy.set(false); }
+ }
+ async toggle(plugin: InstalledPlugin) { await this.act(async () => { await firstValueFrom(this.api.enable(plugin.manifest.id, !plugin.enabled)); await this.refresh(); }); }
+ async diagnose(plugin: InstalledPlugin) {
+ await this.act(async () => { const result = await firstValueFrom(this.api.diagnose(plugin.manifest.id, plugin.profiles[0]?.name || 'default'));
+ if (result.ok) this.notice.set(result.message); else this.error.set(result.message); });
+ }
+ async remove() {
+ const plugin = this.removing(); if (!plugin) return;
+ await this.act(async () => { await firstValueFrom(this.api.uninstall(plugin.manifest.id)); this.removing.set(null); await this.refresh(); });
+ }
+ async confirmStopped() {
+ const resource = this.confirmingResource(); if (!resource) return;
+ await this.act(async () => { await firstValueFrom(this.api.confirmStopped(resource)); this.confirmingResource.set(null); await this.refresh(); this.notice.set('已解除保护。继续任务前将重新观察目标状态。'); });
+ }
+ async install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
+ if (this.busy()) return;
+ this.busy.set(true); this.error.set(''); this.notice.set('');
+ this.progress.set({ id: '', status: 'queued', stage: '准备安装', progress: 0 });
+ try { const { job_id } = await firstValueFrom(this.api.install(payload)); await this.poll(job_id); }
+ catch (error) { this.error.set(this.message(error)); this.busy.set(false); this.progress.set(null); }
+ }
+ private async poll(id: string) {
+ if (this.destroyed) return;
+ try {
+ const progress = await firstValueFrom(this.api.progress(id)); if (this.destroyed) return;
+ this.progress.set(progress);
+ if (progress.status === 'failed') { this.error.set(progress.stage); this.busy.set(false); return; }
+ if (progress.status === 'completed') { this.busy.set(false); this.tab.set('installed'); await this.refresh(); return; }
+ this.timer = setTimeout(() => void this.poll(id), 750);
+ } catch (error) { this.error.set(this.message(error)); this.busy.set(false); }
+ }
+ async addCustom() {
+ await this.act(async () => { let manifest: unknown;
+ try { manifest = JSON.parse(this.customManifest); } catch { this.error.set('插件清单不是有效的 JSON。'); return; }
+ await firstValueFrom(this.api.custom(manifest, this.customSkill)); await this.refresh(); this.notice.set('已添加。请配置授权范围并手动启用。'); });
+ }
+ private async act(operation: () => Promise) {
+ if (this.busy()) return;
+ this.busy.set(true); this.error.set(''); this.notice.set('');
+ try { await operation(); } catch (error) { this.error.set(this.message(error)); } finally { this.busy.set(false); }
+ }
+}
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 4cd4265..9a03b2d 100644
--- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html
+++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html
@@ -185,6 +185,7 @@ limitations under the License.
+