feat(ui): simplify plugin activation and align task status badges

This commit is contained in:
chenmingxuan committed 2026-10-15 00:32:20 +08:00
1 parent 9c82a40cbf
commit 96fedc816f
11 files changed
+242 -31

No files matched your search

@@ -81,6 +81,7 @@ limitations under the License.
(click)="selectTask(session.session_id, $event)"
>
<div class="item-header">
<div class="task-status-group">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
@@ -88,6 +89,7 @@ limitations under the License.
{{ getTaskStatus(session) | uiLabel }}
</span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div>
<div class="item-header-actions">
@if (getDeviceSerial(session)) {
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -3694,10 +3694,13 @@ $stream-row-height: 25px;
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.task-badge {
flex-shrink: 0;
display: inline-flex;
@@ -3790,6 +3793,7 @@ $stream-row-height: 25px;
}
.item-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
@@ -77,6 +77,7 @@ limitations under the License.
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<div class="task-status-group">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
@@ -84,6 +85,7 @@ limitations under the License.
{{ getTaskStatus(session) | uiLabel }}
</span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div>
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -329,11 +329,14 @@ $text-muted: #a1a1aa;
.task-card-header {
display: flex;
justify-content: space-between;
justify-content: flex-start;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.task-badge {
flex-shrink: 0;
display: inline-flex;
@@ -413,6 +416,7 @@ $text-muted: #a1a1aa;
}
.task-card-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
@@ -13,15 +13,17 @@
@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>
@if (authorizedProfiles(plugin).length > 1) {
<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 authorizedProfiles(plugin); track profile.name) { <option [value]="profile.name">{{ profile.label }}</option> }
</select>
}
</div>
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
} @else {
<p class="hint">{{ available().length ? '默认连接用于自动调用,也可以关闭自动选择,指定本次可用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
}
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
</section>
@@ -30,7 +30,7 @@ describe('TaskPluginsComponent', () => {
expect(emitted).toHaveBeenCalledWith({});
});
it('offers authorized cross-device connections and uses the default profile', async () => {
it('uses the single authorized setting without exposing a connection selector', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
const component = fixture.componentInstance;
component.toggle();
@@ -45,7 +45,26 @@ describe('TaskPluginsComponent', () => {
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('旧配置');
expect(fixture.nativeElement.querySelector('select')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('连接配置');
});
it('allows choosing among multiple authorized settings and excludes unauthorized ones', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent); const component = fixture.componentInstance;
component.toggle();
http.expectOne('/api/plugins').flush({ plugins: [{ enabled: true,
manifest: { id: 'desktop', name: '电脑控制', targets: ['mixed'] }, profiles: [
{ name: 'old', label: '旧配置', authorized: false },
{ name: 'lab', label: '实验室', authorized: true, is_default: true },
{ name: 'office', label: '办公室', authorized: true }
] }] });
component.automatic = false; fixture.detectChanges(); await fixture.whenStable();
const emitted = jasmine.createSpy('changed'); component.changed.subscribe(emitted);
fixture.nativeElement.querySelector('.choice input').click(); fixture.detectChanges(); await fixture.whenStable();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
expect(select.value).toBe('lab'); expect(select.textContent).not.toContain('旧配置');
select.value = 'office'; select.dispatchEvent(new Event('change')); fixture.detectChanges(); await fixture.whenStable();
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'office' }] });
});
it('shows a retryable error and keeps automatic mode on lookup failure', () => {
@@ -28,6 +28,7 @@ export class TaskPluginsComponent {
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
}
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
toggle() {
this.open.update(v => !v);
if (this.open()) this.load();
@@ -41,7 +42,7 @@ export class TaskPluginsComponent {
}
choose(plugin: InstalledPlugin, event: Event) {
if ((event.target as HTMLInputElement).checked) {
const profiles = plugin.profiles.filter(p => p.authorized);
const profiles = this.authorizedProfiles(plugin);
this.selected[plugin.manifest.id] = (profiles.find(p => p.is_default) || profiles[0]).name;
} else delete this.selected[plugin.manifest.id];
this.emit();
@@ -7,7 +7,7 @@
<section class="message error" role="alert"><strong>资源保护 · {{resource[0]}}</strong><p>{{resource[1]}}</p><button (click)="confirmingResource.set(resource[0])" [disabled]="busy()">核对并解除保护</button></section>
}
@if (progress(); as job) {
<section class="install-progress" aria-live="polite"><div><span>{{job.stage}}</span><strong>{{job.progress}}%</strong></div>
<section class="install-progress" aria-live="polite"><div><span>{{job.status === 'completed' ? '安装完成,请点击启用。' : job.stage}}</span><strong>{{job.progress}}%</strong></div>
<progress max="100" [value]="job.progress" [attr.aria-label]="job.stage"></progress></section>
}
<div class="tabs" role="tablist" aria-label="插件来源">
@@ -19,11 +19,11 @@
@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>
<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>
<p>{{plugin.manifest.description || '暂无描述'}}</p><div class="meta">{{hasSettings(plugin) ? '可在设置中调整插件参数。' : '无需填写参数,启用后即可使用。'}}</div>
<footer><button [class.primary]="!plugin.enabled" (click)="toggle(plugin)" [disabled]="busy()">{{plugin.enabled ? '停用' : '启用'}}</button><button (click)="diagnose(plugin)" [disabled]="busy() || !plugin.enabled">检查插件</button><button (click)="edit(plugin)" [disabled]="busy()">{{hasSettings(plugin) ? '设置' : '高级设置'}}</button><button class="text-button" (click)="removing.set(plugin)" [disabled]="busy()">卸载</button></footer>
</article>
}</div>
<details class="advanced"><summary>高级接入 · 本地插件与自定义 MCP</summary><p>本地插件需要包含 plugin.json;导入后配置授权范围,再手动启用。</p>
<details class="advanced"><summary>高级接入 · 本地插件与自定义 MCP</summary><p>本地插件需要包含 plugin.json;导入后点击启用,按提示完成设置。</p>
<div class="inline"><input aria-label="本地插件路径" [(ngModel)]="localPath" placeholder="插件 ZIP 文件或目录的完整路径"><button (click)="install({local_path: localPath})" [disabled]="busy() || !localPath.trim()">导入</button></div>
<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>
@@ -36,23 +36,38 @@
}</div>
}
</main>
@if (confirmingEnable(); as plugin) {
<div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="plugin-enable-title">
<h2 id="plugin-enable-title">启用{{plugin.manifest.name}}?</h2>
@if (plugin.manifest.resources.includes('desktop:local')) {
<p>允许 ARTEMIS 在测试需要时查看本机窗口,并操作桌面应用和浏览器。</p>
} @else { <p>允许 ARTEMIS 在测试需要时使用此插件提供的工具。</p> }
<p class="meta">授权会保存在本机,后续任务无需重复确认。你可以随时停用插件,或在高级设置中限制允许的操作。</p>
@if (error()) { <p class="message error" role="alert">{{error()}}</p> }
<footer><button (click)="confirmingEnable.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="confirmEnable()" [disabled]="busy()">{{busy() ? '启用中…' : '允许并启用'}}</button></footer>
</section></div>
}
@if (confirmingResource(); as resource) {
<div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="resource-confirm-title"><h2 id="resource-confirm-title">确认 {{resource}} 已停止操作</h2><p>请先核对目标设备或远程服务,确认之前的插件动作已经停止,并检查实际状态。解除后任务才可再次操作此资源。</p><footer><button (click)="confirmingResource.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="confirmStopped()" [disabled]="busy()">已核对,解除保护</button></footer></section></div>
}
@if (editing(); as plugin) {
<div class="modal-backdrop"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="plugin-config-title"><header><div><h2 id="plugin-config-title">{{plugin.manifest.name}}</h2><p>配置连接与允许控制的范围</p></div><button aria-label="关闭配置" (click)="editing.set(null)" [disabled]="busy()">×</button></header>
<div class="inline"><select aria-label="选择连接配置" [ngModel]="profile.name" (ngModelChange)="selectProfile($event)">@for (p of plugin.profiles; track p.name) { <option [value]="p.name">{{p.label}}</option> }</select><button (click)="newProfile()">新增配置</button></div>
<div class="two-columns"><label>配置标识<input [(ngModel)]="profile.name" placeholder="如 lab-router"></label><label>显示名称<input [(ngModel)]="profile.label" placeholder="如实验室路由器"></label></div>
<div class="modal-backdrop"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="plugin-config-title"><header><div><h2 id="plugin-config-title">{{plugin.manifest.name}} · 设置</h2><p>{{hasSettings(plugin) ? '填写插件需要的信息,高级选项通常无需修改。' : '此插件无需填写参数,高级选项通常无需修改。'}}</p></div><button aria-label="关闭配置" (click)="editing.set(null)" [disabled]="busy()">×</button></header>
@for (entry of fields(); track entry[0]) { <label>{{entry[1].label}}{{entry[1].required ? ' *' : ''}}
@if (entry[1].secret) { <input type="password" autocomplete="new-password" [(ngModel)]="profile.secrets![entry[0]]" [placeholder]="profile.configured_secrets?.includes(entry[0]) ? '已保存,留空保留' : '仅保存在本机'"> }
@else { <input [(ngModel)]="profile.values[entry[0]]" [placeholder]="entry[1].default"> }
@if (entry[1].description) { <small>{{entry[1].description}}</small> }
</label> }
<details><summary>控制范围与共享资源</summary><label>允许工具(每行一个,留空使用全部工具)<textarea rows="3" [(ngModel)]="allowedTools"></textarea></label><label>参数约束 JSON<textarea rows="4" [(ngModel)]="constraints" placeholder="按工具名称配置允许的参数值"></textarea></label><label>共享资源(同一目标使用相同标识,每行一个)<textarea rows="2" [(ngModel)]="resources" placeholder="router:lab"></textarea></label></details>
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.is_default">作为自动选择插件时的默认连接配置</label>
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.authorized">允许 ARTEMIS 使用此配置执行插件工具</label><p class="meta">插件进程使用当前用户的系统权限。工具与参数范围由 ARTEMIS 检查。</p>
<details [open]="enableAfterSave && !defaultProfile(plugin)"><summary>高级设置 · 多套参数与控制范围</summary>
<p>仅在需要切换不同设备、账号或限制操作范围时使用。</p>
@if (enableAfterSave && !defaultProfile(plugin)) { <p>请选择一套设置,并勾选「默认使用这套设置」,以便 AI 自动调用。</p> }
<div class="inline"><select aria-label="选择参数设置" [ngModel]="profile.name" (ngModelChange)="selectProfile($event)">@for (p of plugin.profiles; track p.name) { <option [value]="p.name">{{p.label}}</option> }</select><button (click)="newProfile()">新增一套</button></div>
<div class="two-columns"><label>设置标识<input [(ngModel)]="profile.name" placeholder="如 lab-router"></label><label>显示名称<input [(ngModel)]="profile.label" placeholder="如实验室路由器"></label></div>
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.is_default">默认使用这套设置</label>
<label>允许工具(每行一个,留空使用全部工具)<textarea rows="3" [(ngModel)]="allowedTools"></textarea></label><label>参数约束 JSON<textarea rows="4" [(ngModel)]="constraints" placeholder="按工具名称配置允许的参数值"></textarea></label><label>共享资源(同一目标使用相同标识,每行一个)<textarea rows="2" [(ngModel)]="resources" placeholder="router:lab"></textarea></label>
</details>
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.authorized">允许 ARTEMIS 在测试中使用此插件</label><p class="meta">插件进程使用当前用户的系统权限。工具与参数范围由 ARTEMIS 检查。</p>
@if (error()) { <p class="message error" role="alert">{{error()}}</p> }
<footer><button (click)="editing.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="save()" [disabled]="busy() || !profile.name.trim()">{{busy() ? '保存中…' : '保存配置'}}</button></footer>
<footer><button (click)="editing.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="save()" [disabled]="busy() || !profile.name.trim() || (enableAfterSave && !profile.authorized)">{{busy() ? '保存中…' : enableAfterSave ? '保存并启用' : '保存设置'}}</button></footer>
</section></div>
}
@if (removing(); as plugin) { <div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="remove-title"><h2 id="remove-title">卸载 {{plugin.manifest.name}}?</h2><p>此插件及其连接配置将不再供新任务使用。</p><footer><button (click)="removing.set(null)" [disabled]="busy()">取消</button><button class="danger" (click)="remove()" [disabled]="busy()">确认卸载</button></footer></section></div> }
@if (removing(); as plugin) { <div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="remove-title"><h2 id="remove-title">卸载 {{plugin.manifest.name}}?</h2><p>此插件及其保存的设置将不再供新任务使用。</p><footer><button (click)="removing.set(null)" [disabled]="busy()">取消</button><button class="danger" (click)="remove()" [disabled]="busy()">确认卸载</button></footer></section></div> }
@@ -20,6 +20,121 @@ describe('PluginsComponent', () => {
return fixture;
}
function desktopPlugin(): InstalledPlugin {
return { enabled: false, source: 'marketplace', manifest: {
id: 'desktop', name: '电脑控制', version: '1.0.0', description: '操作桌面与浏览器',
artemis_version: '>=2.0.0', platforms: ['win32'], source: '', config: {},
resources: ['desktop:local'], targets: ['computer', 'mixed']
}, profiles: [{ name: 'default', label: '默认配置', is_default: true, values: {},
configured_secrets: [], allowed_tools: null, argument_constraints: {}, resources: [], authorized: false }] };
}
function clickButton(fixture: ReturnType<typeof setup>, text: string) {
const button = Array.from(fixture.nativeElement.querySelectorAll('button') as NodeListOf<HTMLButtonElement>)
.find(button => button.textContent?.trim() === text);
expect(button).toBeDefined(); button!.click(); fixture.detectChanges();
}
it('offers a simple first-enable confirmation without requiring connection setup, and cancel writes nothing', async () => {
const fixture = setup(); await fixture.whenStable();
const plugin = desktopPlugin(); fixture.componentInstance.installed.set([plugin]); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('无需填写参数');
expect(fixture.nativeElement.textContent).not.toContain('连接配置');
clickButton(fixture, '启用');
expect(fixture.nativeElement.querySelector('[role="dialog"]').textContent).toContain('操作桌面应用和浏览器');
expect(fixture.nativeElement.querySelector('[role="dialog"] input')).toBeNull();
http.expectNone('/api/plugins/desktop/profiles'); http.expectNone('/api/plugins/desktop/enabled');
clickButton(fixture, '取消');
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
expect(plugin.profiles[0].authorized).toBeFalse();
});
it('authorizes only the default settings, preserves restrictions and saved secrets, then enables', async () => {
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
const plugin = desktopPlugin(); const profile = plugin.profiles[0];
profile.allowed_tools = ['observe']; profile.argument_constraints = { observe: { target: ['lab'] } };
profile.resources = ['desktop:lab']; profile.values = { target: 'lab' }; profile.configured_secrets = ['token'];
plugin.manifest.config = { token: { label: '密钥', secret: true, required: true, default: '', description: '' } };
plugin.profiles.unshift({ ...structuredClone(profile), name: 'other', is_default: false });
await component.toggle(plugin); const operation = component.confirmEnable();
const request = http.expectOne('/api/plugins/desktop/profiles');
expect(request.request.body).toEqual({ name: 'default', label: '默认配置', is_default: true,
authorized: true, values: { target: 'lab' }, secrets: {}, allowed_tools: ['observe'],
argument_constraints: { observe: { target: ['lab'] } }, resources: ['desktop:lab'] });
http.expectNone('/api/plugins/desktop/enabled');
request.flush({}); await Promise.resolve();
const enable = http.expectOne('/api/plugins/desktop/enabled'); expect(enable.request.body).toEqual({ enabled: true });
enable.flush({}); await Promise.resolve();
http.expectOne('/api/plugins').flush({ plugins: [{ ...plugin, enabled: true }] });
await operation; fixture.detectChanges();
expect(component.confirmingEnable()).toBeNull(); expect(component.notice()).toContain('插件已启用');
expect(component.installed()[0].enabled).toBeTrue();
expect(plugin.profiles.every(p => !p.authorized)).toBeTrue();
});
for (const failure of ['profiles', 'enabled']) {
it(`keeps first-enable confirmation retryable when ${failure} fails`, async () => {
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
await component.toggle(desktopPlugin()); const operation = component.confirmEnable();
const save = http.expectOne('/api/plugins/desktop/profiles');
if (failure === 'profiles') {
save.flush({ detail: '保存失败' }, { status: 409, statusText: 'Conflict' });
http.expectNone('/api/plugins/desktop/enabled');
} else {
save.flush({}); await Promise.resolve();
http.expectOne('/api/plugins/desktop/enabled').flush({ detail: '启用失败' }, { status: 409, statusText: 'Conflict' });
}
await operation; fixture.detectChanges();
expect(component.confirmingEnable()).not.toBeNull(); expect(component.busy()).toBeFalse();
expect(component.notice()).toBe(''); expect(component.error()).toContain('失败');
expect(fixture.nativeElement.querySelector('[role="dialog"] [role="alert"]').textContent).toContain('失败');
http.expectNone('/api/plugins');
});
}
it('re-enables already authorized settings without asking or saving again', async () => {
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
const plugin = desktopPlugin(); plugin.profiles[0].authorized = true;
const operation = component.toggle(plugin);
expect(component.confirmingEnable()).toBeNull(); expect(component.editing()).toBeNull();
http.expectNone('/api/plugins/desktop/profiles');
http.expectOne('/api/plugins/desktop/enabled').flush({}); await Promise.resolve();
http.expectOne('/api/plugins').flush({ plugins: [{ ...plugin, enabled: true }] });
await operation;
expect(component.notice()).toContain('插件已启用');
});
it('opens required parameters with advanced settings collapsed, then saves and enables', async () => {
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
const plugin = desktopPlugin(); plugin.manifest.config = {
address: { label: '服务地址', required: true, secret: false, default: '', description: '' }
};
await component.toggle(plugin); fixture.detectChanges(); await fixture.whenStable();
expect(component.confirmingEnable()).toBeNull(); expect(component.editing()).toBe(plugin);
expect(fixture.nativeElement.querySelector('[role="dialog"] details').open).toBeFalse();
const address: HTMLInputElement = fixture.nativeElement.querySelector('[role="dialog"] label input');
address.value = 'http://localhost:1234'; address.dispatchEvent(new Event('input'));
const authorize: HTMLInputElement = fixture.nativeElement.querySelector('[role="dialog"] > .checkbox input');
authorize.click(); fixture.detectChanges(); await fixture.whenStable();
const operation = component.save(); const save = http.expectOne('/api/plugins/desktop/profiles');
expect(save.request.body.values).toEqual({ address: 'http://localhost:1234' });
expect(save.request.body.authorized).toBeTrue(); save.flush({}); await Promise.resolve();
http.expectOne('/api/plugins/desktop/enabled').flush({}); await Promise.resolve();
http.expectOne('/api/plugins').flush({ plugins: [{ ...plugin, enabled: true }] }); await operation;
expect(component.editing()).toBeNull(); expect(component.notice()).toContain('插件已启用');
});
it('requires an explicit default when no default exists, rather than silently authorizing another account', async () => {
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
const plugin = desktopPlugin(); plugin.profiles[0].name = 'lab'; plugin.profiles[0].is_default = false;
await component.toggle(plugin); fixture.detectChanges();
expect(component.confirmingEnable()).toBeNull();
expect(fixture.nativeElement.querySelector('[role="dialog"] details').open).toBeTrue();
component.profile.authorized = true; await component.save();
http.expectNone('/api/plugins/desktop/profiles'); http.expectNone('/api/plugins/desktop/enabled');
expect(component.error()).toContain('默认使用这套设置');
});
it('distinguishes market loading, failure, empty and available states', async () => {
const fixture = TestBed.createComponent(PluginsComponent);
fixture.detectChanges();
@@ -23,6 +23,8 @@ export class PluginsComponent implements OnInit, OnDestroy {
progress = signal<InstallProgress | null>(null);
editing = signal<InstalledPlugin | null>(null);
removing = signal<InstalledPlugin | null>(null);
confirmingEnable = signal<InstalledPlugin | null>(null);
enableAfterSave = false;
profile: PluginProfile = this.emptyProfile();
resources = ''; allowedTools = ''; constraints = '{}'; localPath = '';
customManifest = JSON.stringify({ schema_version: 1, id: 'my-plugin', name: '我的插件', version: '1.0.0',
@@ -50,7 +52,18 @@ export class PluginsComponent implements OnInit, OnDestroy {
}
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'); }
defaultProfile(plugin: InstalledPlugin) {
return plugin.profiles.find(p => p.is_default) || plugin.profiles.find(p => p.name === 'default');
}
hasSettings(plugin: InstalledPlugin) { return Object.keys(plugin.manifest.config || {}).length > 0; }
needsSettings(plugin: InstalledPlugin, profile: PluginProfile) {
return Object.entries(plugin.manifest.config || {}).some(([name, field]) => field.required &&
!(field.secret ? profile.configured_secrets?.includes(name) : profile.values[name] ?? field.default));
}
edit(plugin: InstalledPlugin, enableAfterSave = false) {
this.error.set(''); this.notice.set(''); this.enableAfterSave = enableAfterSave;
this.editing.set(plugin); this.selectProfile(this.defaultProfile(plugin)?.name || 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();
@@ -61,20 +74,52 @@ export class PluginsComponent implements OnInit, OnDestroy {
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('');
this.error.set(''); this.notice.set('');
if (this.enableAfterSave && !this.profile.authorized) {
this.error.set('请先允许 ARTEMIS 在测试中使用此插件。'); return;
}
const isDefault = this.profile.is_default || (this.profile.name === 'default' &&
!plugin.profiles.some(p => p.is_default && p.name !== this.profile.name));
if (this.enableAfterSave && !isDefault) {
this.error.set('请在高级设置中勾选「默认使用这套设置」,以便 AI 自动调用。'); return;
}
this.busy.set(true);
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));
if (this.enableAfterSave) await firstValueFrom(this.api.enable(plugin.manifest.id, true));
this.editing.set(null); this.profile = this.emptyProfile(); await this.refresh();
this.notice.set('配置已保存。启用后可供后续任务使用。');
this.notice.set(this.enableAfterSave ? '插件已启用,AI 会在测试需要时自动调用。' : '设置已保存。');
} 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 toggle(plugin: InstalledPlugin) {
if (this.busy()) return;
if (!plugin.enabled) {
const profile = this.defaultProfile(plugin);
if (!profile || this.needsSettings(plugin, profile)) { this.edit(plugin, true); return; }
if (!profile.authorized) { this.error.set(''); this.notice.set(''); this.confirmingEnable.set(plugin); return; }
}
await this.act(async () => {
await firstValueFrom(this.api.enable(plugin.manifest.id, !plugin.enabled)); await this.refresh();
this.notice.set(plugin.enabled ? '插件已停用。' : '插件已启用,AI 会在测试需要时自动调用。');
});
}
async confirmEnable() {
const plugin = this.confirmingEnable(); if (!plugin) return;
const profile = this.defaultProfile(plugin); if (!profile) return;
await this.act(async () => {
// Preserve existing restrictions and saved secrets; only grant the selected default.
await firstValueFrom(this.api.save(plugin.manifest.id, { ...profile, secrets: {}, authorized: true }));
await firstValueFrom(this.api.enable(plugin.manifest.id, true));
this.confirmingEnable.set(null); await this.refresh();
this.notice.set('插件已启用,AI 会在测试需要时自动调用。');
});
}
async diagnose(plugin: InstalledPlugin) {
await this.act(async () => { const result = await firstValueFrom(this.api.diagnose(plugin.manifest.id, plugin.profiles[0]?.name || 'default'));
await this.act(async () => { const result = await firstValueFrom(this.api.diagnose(plugin.manifest.id, this.defaultProfile(plugin)?.name || 'default'));
if (result.ok) this.notice.set(result.message); else this.error.set(result.message); });
}
async remove() {
@@ -105,7 +150,7 @@ export class PluginsComponent implements OnInit, OnDestroy {
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('已添加。请配置授权范围并手动启用。'); });
await firstValueFrom(this.api.custom(manifest, this.customSkill)); await this.refresh(); this.notice.set('已添加,点击启用即可开始设置。'); });
}
private async act(operation: () => Promise<void>) {
if (this.busy()) return;