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
+241 -30

No files matched your search

@@ -81,6 +81,7 @@ limitations under the License.
(click)="selectTask(session.session_id, $event)" (click)="selectTask(session.session_id, $event)"
> >
<div class="item-header"> <div class="item-header">
<div class="task-status-group">
<span class="task-badge" [ngClass]="getTaskStatus(session)"> <span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') { @if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span> <span class="status-dot"></span>
@@ -88,6 +89,7 @@ limitations under the License.
{{ getTaskStatus(session) | uiLabel }} {{ getTaskStatus(session) | uiLabel }}
</span> </span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" /> <app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div>
<div class="item-header-actions"> <div class="item-header-actions">
@if (getDeviceSerial(session)) { @if (getDeviceSerial(session)) {
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)"> <span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -3694,10 +3694,13 @@ $stream-row-height: 25px;
.item-header { .item-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
flex-wrap: wrap;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.task-badge { .task-badge {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
@@ -3790,6 +3793,7 @@ $stream-row-height: 25px;
} }
.item-header-actions { .item-header-actions {
margin-left: auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -77,6 +77,7 @@ limitations under the License.
(click)="selectTask(session.session_id)" (click)="selectTask(session.session_id)"
> >
<div class="task-card-header"> <div class="task-card-header">
<div class="task-status-group">
<span class="task-badge" [ngClass]="getTaskStatus(session)"> <span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') { @if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span> <span class="status-dot"></span>
@@ -84,6 +85,7 @@ limitations under the License.
{{ getTaskStatus(session) | uiLabel }} {{ getTaskStatus(session) | uiLabel }}
</span> </span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" /> <app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div>
<div class="task-card-header-actions"> <div class="task-card-header-actions">
@if (getDeviceSerial(session)) { @if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)"> <span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -329,11 +329,14 @@ $text-muted: #a1a1aa;
.task-card-header { .task-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.task-badge { .task-badge {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
@@ -413,6 +416,7 @@ $text-muted: #a1a1aa;
} }
.task-card-header-actions { .task-card-header-actions {
margin-left: auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -13,15 +13,17 @@
@for (plugin of available(); track plugin.manifest.id) { @for (plugin of available(); track plugin.manifest.id) {
<div class="choice"> <div class="choice">
<label><input type="checkbox" [checked]="selected[plugin.manifest.id] !== undefined" (change)="choose(plugin, $event)">{{ plugin.manifest.name }}</label> <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 + '连接配置'"> @if (authorizedProfiles(plugin).length > 1) {
<option value="" disabled>选择连接配置</option> <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 + '使用设置'">
@for (profile of plugin.profiles; track profile.name) { @if (profile.authorized) { <option [value]="profile.name">{{ profile.label }}</option> } } <option value="" disabled>选择要使用的设置</option>
@for (profile of authorizedProfiles(plugin); track profile.name) { <option [value]="profile.name">{{ profile.label }}</option> }
</select> </select>
}
</div> </div>
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> } } @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p> <p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
} @else { } @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> <a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
</section> </section>
@@ -30,7 +30,7 @@ describe('TaskPluginsComponent', () => {
expect(emitted).toHaveBeenCalledWith({}); 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 fixture = TestBed.createComponent(TaskPluginsComponent);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.toggle(); component.toggle();
@@ -45,7 +45,26 @@ describe('TaskPluginsComponent', () => {
checkbox.click(); fixture.detectChanges(); await fixture.whenStable(); checkbox.click(); fixture.detectChanges(); await fixture.whenStable();
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'lab' }] }); expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'lab' }] });
expect(fixture.nativeElement.querySelectorAll('.choice').length).toBe(1); 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', () => { 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) && return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === 'android' || target === 'mixed')); p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
} }
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
toggle() { toggle() {
this.open.update(v => !v); this.open.update(v => !v);
if (this.open()) this.load(); if (this.open()) this.load();
@@ -41,7 +42,7 @@ export class TaskPluginsComponent {
} }
choose(plugin: InstalledPlugin, event: Event) { choose(plugin: InstalledPlugin, event: Event) {
if ((event.target as HTMLInputElement).checked) { 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; this.selected[plugin.manifest.id] = (profiles.find(p => p.is_default) || profiles[0]).name;
} else delete this.selected[plugin.manifest.id]; } else delete this.selected[plugin.manifest.id];
this.emit(); 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> <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) { @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> <progress max="100" [value]="job.progress" [attr.aria-label]="job.stage"></progress></section>
} }
<div class="tabs" role="tablist" aria-label="插件来源"> <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> } @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) { <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> <p>{{plugin.manifest.description || '暂无描述'}}</p><div class="meta">{{hasSettings(plugin) ? '可在设置中调整插件参数。' : '无需填写参数,启用后即可使用。'}}</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> <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> </article>
}</div> }</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> <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><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> </details>
@@ -36,23 +36,38 @@
}</div> }</div>
} }
</main> </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) { @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> <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) { @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="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>
<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>
@for (entry of fields(); track entry[0]) { <label>{{entry[1].label}}{{entry[1].required ? ' *' : ''}} @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]) ? '已保存,留空保留' : '仅保存在本机'"> } @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"> } @else { <input [(ngModel)]="profile.values[entry[0]]" [placeholder]="entry[1].default"> }
@if (entry[1].description) { <small>{{entry[1].description}}</small> } @if (entry[1].description) { <small>{{entry[1].description}}</small> }
</label> } </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> <details [open]="enableAfterSave && !defaultProfile(plugin)"><summary>高级设置 · 多套参数与控制范围</summary>
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.is_default">作为自动选择插件时的默认连接配置</label> <p>仅在需要切换不同设备、账号或限制操作范围时使用。</p>
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.authorized">允许 ARTEMIS 使用此配置执行插件工具</label><p class="meta">插件进程使用当前用户的系统权限。工具与参数范围由 ARTEMIS 检查。</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> } @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> </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; 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 () => { it('distinguishes market loading, failure, empty and available states', async () => {
const fixture = TestBed.createComponent(PluginsComponent); const fixture = TestBed.createComponent(PluginsComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -23,6 +23,8 @@ export class PluginsComponent implements OnInit, OnDestroy {
progress = signal<InstallProgress | null>(null); progress = signal<InstallProgress | null>(null);
editing = signal<InstalledPlugin | null>(null); editing = signal<InstalledPlugin | null>(null);
removing = signal<InstalledPlugin | null>(null); removing = signal<InstalledPlugin | null>(null);
confirmingEnable = signal<InstalledPlugin | null>(null);
enableAfterSave = false;
profile: PluginProfile = this.emptyProfile(); profile: PluginProfile = this.emptyProfile();
resources = ''; allowedTools = ''; constraints = '{}'; localPath = ''; resources = ''; allowedTools = ''; constraints = '{}'; localPath = '';
customManifest = JSON.stringify({ schema_version: 1, id: 'my-plugin', name: '我的插件', version: '1.0.0', 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; } installedVersion(id: string) { return this.installed().find(p => p.manifest.id === id)?.manifest.version; }
fields() { return Object.entries(this.editing()?.manifest.config || {}); } 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) { selectProfile(name: string) {
const selected = this.editing()?.profiles.find(p => p.name === name); const selected = this.editing()?.profiles.find(p => p.name === name);
this.profile = selected ? { ...structuredClone(selected), secrets: {} } : this.emptyProfile(); 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 = '{}'; } newProfile() { this.profile = { ...this.emptyProfile(), name: '', label: '新配置' }; this.resources = ''; this.allowedTools = ''; this.constraints = '{}'; }
async save() { async save() {
const plugin = this.editing(); if (!plugin || this.busy()) return; 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 { try {
const profile = { ...this.profile, resources: this.resources.split('\n').map(v => v.trim()).filter(Boolean), 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, allowed_tools: this.allowedTools.trim() ? this.allowedTools.split('\n').map(v => v.trim()).filter(Boolean) : null,
argument_constraints: JSON.parse(this.constraints) }; argument_constraints: JSON.parse(this.constraints) };
await firstValueFrom(this.api.save(plugin.manifest.id, profile)); 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.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)); } } catch (error) { this.error.set(error instanceof SyntaxError ? '参数约束不是有效的 JSON。' : this.message(error)); }
finally { this.busy.set(false); } 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) { 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); }); if (result.ok) this.notice.set(result.message); else this.error.set(result.message); });
} }
async remove() { async remove() {
@@ -105,7 +150,7 @@ export class PluginsComponent implements OnInit, OnDestroy {
async addCustom() { async addCustom() {
await this.act(async () => { let manifest: unknown; await this.act(async () => { let manifest: unknown;
try { manifest = JSON.parse(this.customManifest); } catch { this.error.set('插件清单不是有效的 JSON。'); return; } 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>) { private async act(operation: () => Promise<void>) {
if (this.busy()) return; if (this.busy()) return;
+5 -3
View File
@@ -6,15 +6,17 @@ ARTEMIS 可通过插件连接 stdio 或 Streamable HTTP MCP 服务,并按需
打开网页导航中的「插件」。可以从市场安装,或在「高级接入」导入包含 `plugin.json` 的目录/ZIP,也可以填写自定义 MCP 清单。安装不执行安装钩子,新装或升级后默认未启用。 打开网页导航中的「插件」。可以从市场安装,或在「高级接入」导入包含 `plugin.json` 的目录/ZIP,也可以填写自定义 MCP 清单。安装不执行安装钩子,新装或升级后默认未启用。
点击「配置」建立命名连接,例如 `lab-router`。填写字段、工具白名单、允许的参数值和共享资源,勾选授权后保存,再启用插件。密码留空保留已保存的值;页面和 MCP 查询只返回已配置的密钥名称。不要把密钥写进清单、提示词或任务 JSON。 普通使用只需「安装 → 启用」。电脑控制插件无需填写参数;首次启用时确认允许 ARTEMIS 操作电脑,授权保存在本机,以后无需重复确认。只有插件声明了必填参数时,才会打开设置表单,填写后勾选允许并「保存并启用」。
每个插件可设置一套默认连接。安卓任务默认可使用支持手机扩展(targets 含 android 或 mixed)、已启用且默认连接已授权的插件。AI 先读取能力简介,需要外部操作时才加载对应工具和 Skill。任务中也可以明确选择插件与连接;空列表表示不使用插件。 需要切换不同设备、账号或限制操作范围时,再展开「高级设置 · 多套参数与控制范围」,管理多套参数、默认设置、工具白名单、允许的参数值和共享资源。密码留空保留已保存的值;页面和 MCP 查询只返回已配置的密钥名称。不要把密钥写进清单、提示词或任务 JSON。
新装插件已有默认设置,无需创建或命名。安卓任务默认可使用支持手机扩展(targets 含 android 或 mixed)、已启用且默认设置已授权的插件。AI 先读取能力简介,需要外部操作时才加载对应工具和 Skill。任务中也可以明确选择插件;只有存在多套已授权设置时,才显示设置选择框。空列表表示不使用插件。
正在排队、暂停和执行中的任务会固定插件版本,并阻止修改配置、启停、升级或卸载。操作结束后再修改。卸载会移除插件安装文件与连接配置,历史证据仍保留。 正在排队、暂停和执行中的任务会固定插件版本,并阻止修改配置、启停、升级或卸载。操作结束后再修改。卸载会移除插件安装文件与连接配置,历史证据仍保留。
## 执行任务 ## 执行任务
任务输入区和批次导入区保持安卓测试入口,无需选择“手机 + 电脑”。照常描述任务,例如“电脑修改路由器参数,再在手机验证并恢复”。默认收起的「扩展插件 · 自动」可展开指定连接,或取消自动选择且不勾选插件,以仅使用手机能力。 任务输入区和批次导入区保持安卓测试入口,无需选择“手机 + 电脑”。照常描述任务,例如“电脑修改路由器参数,再在手机验证并恢复”。默认收起的「扩展插件 · 自动」可展开指定插件,或取消自动选择且不勾选插件,以仅使用手机能力。
高级 JSON、CLI 和 SDK 保留以下执行目标参数: 高级 JSON、CLI 和 SDK 保留以下执行目标参数: