feat: release 2.3.0 with workspace plugin extensions
This commit is contained in:
1 parent
8380057548
commit
bb873ec0e0
44 files changed
+1497
-48
No files matched your search
+9
-1
@@ -27,7 +27,9 @@ limitations under the License.
|
||||
class="floating-video-wrapper"
|
||||
[class.minimized]="agentService.isVideoMinimized()"
|
||||
[class.theater]="isTheaterMode()"
|
||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||
[style.--extension-width]="extensionWidth() + 'px'"
|
||||
[style.left.px]="!isTheaterMode() ? windowLeft() : null"
|
||||
[style.width.px]="isTheaterMode() || agentService.isVideoMinimized() ? null : 400 + extensionWidth()"
|
||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||
[style.--player-top]="posY() + 'px'"
|
||||
>
|
||||
@@ -42,6 +44,9 @@ limitations under the License.
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
@if (extensionPanels(); as panels) {
|
||||
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
|
||||
}
|
||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
|
||||
<div class="mode-toggle-group">
|
||||
@@ -117,6 +122,8 @@ limitations under the License.
|
||||
}
|
||||
<!-- Video Main Content Area -->
|
||||
@if (!agentService.isVideoMinimized()) {
|
||||
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="agentService.playerMode() === 'steps'"
|
||||
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
|
||||
<div class="video-body">
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'live') {
|
||||
@@ -488,6 +495,7 @@ limitations under the License.
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-task-extension-panels>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+8
-2
@@ -28,6 +28,7 @@
|
||||
}
|
||||
|
||||
.floating-video-wrapper {
|
||||
container-type: inline-size;
|
||||
position: fixed;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 32px);
|
||||
@@ -60,8 +61,8 @@
|
||||
left: 50% !important;
|
||||
top: 50% !important;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 85vw;
|
||||
max-width: 960px;
|
||||
width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px));
|
||||
max-width: calc(960px + var(--extension-width, 0px));
|
||||
height: auto;
|
||||
max-height: 88vh;
|
||||
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
|
||||
@@ -805,16 +806,21 @@
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.controls-left, .controls-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
flex-shrink: 0;
|
||||
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
|
||||
@@ -59,7 +61,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
||||
agentService = createAgentService();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FloatingVideoPlayerComponent],
|
||||
providers: [{ provide: AgentService, useValue: agentService }]
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||
|
||||
+12
-3
@@ -26,10 +26,13 @@ import {
|
||||
HostListener,
|
||||
effect,
|
||||
untracked,
|
||||
OnDestroy
|
||||
OnDestroy,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
|
||||
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||
@@ -40,13 +43,19 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
|
||||
templateUrl: './floating-video-player.component.html',
|
||||
styleUrl: './floating-video-player.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public agentService = inject(AgentService);
|
||||
public extensionWidth = signal(0);
|
||||
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
|
||||
public windowLeft(): number {
|
||||
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
|
||||
return Math.max(16, Math.min(this.posX(), window.innerWidth - Math.min(width, window.innerWidth - 32) - 16));
|
||||
}
|
||||
private zone = inject(NgZone);
|
||||
|
||||
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
@@ -327,7 +336,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.isDragging = true;
|
||||
this.dragStartX = event.clientX;
|
||||
this.dragStartY = event.clientY;
|
||||
this.initialPosX = this.posX();
|
||||
this.initialPosX = this.windowLeft();
|
||||
this.initialPosY = this.posY();
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
li { margin: 4px 0; }
|
||||
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid var(--app-primary-border, #93c5fd); background: var(--app-surface-soft, #f8fafc); }
|
||||
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: var(--app-surface-soft, #f1f5f9); }
|
||||
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; }
|
||||
pre { box-sizing: border-box; max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; }
|
||||
pre code { padding: 0; background: none; }
|
||||
.md-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
@if (enabled() && error()) {
|
||||
<div class="extension-notice" role="status">{{ error() }} <button type="button" (click)="refresh()">重试</button></div>
|
||||
}
|
||||
<ng-template #headerControls>
|
||||
@if (data()?.plugins?.length) {
|
||||
<nav class="panel-toggles" aria-label="任务扩展面板">
|
||||
@for (side of ['left', 'right']; track side) {
|
||||
@for (plugin of data()!.plugins; track plugin.plugin_id) {
|
||||
@for (panel of plugin.workspace.panels; track panel.id) {
|
||||
@if (panel.side === side) {
|
||||
<button type="button" class="panel-toggle" [class.active]="opened()[key(plugin, panel)]" [attr.aria-expanded]="!!opened()[key(plugin, panel)]"
|
||||
[attr.aria-label]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
|
||||
[title]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
|
||||
[attr.aria-controls]="'extension-' + key(plugin, panel)" (click)="toggle(plugin, panel)">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="3" /><path [attr.d]="side === 'left' ? 'M7 3v14' : 'M13 3v14'" /></svg>
|
||||
<span class="panel-toggle-label">{{ panel.title }}</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{{ (side === 'left') !== !!opened()[key(plugin, panel)] ? 'chevron_left' : 'chevron_right' }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="extension-layout" [class.has-left]="panels('left').length" [class.has-right]="panels('right').length"
|
||||
[style.--left-width]="width('left') + 'px'" [style.--right-width]="width('right') + 'px'">
|
||||
@if (panels('left').length) {
|
||||
<aside class="panel-column left-column" aria-label="任务左侧扩展">
|
||||
@for (item of panels('left'); track key(item.plugin, item.panel)) {
|
||||
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
<div class="player-content"><ng-content /></div>
|
||||
@if (panels('right').length) {
|
||||
<aside class="panel-column right-column" aria-label="任务右侧扩展">
|
||||
@for (item of panels('right'); track key(item.plugin, item.panel)) {
|
||||
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-template #panelContent let-plugin="plugin" let-panel="panel">
|
||||
<section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title">
|
||||
<header><h3>{{ panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
|
||||
@if (panel.kind === 'initial_prompt') {
|
||||
@if (data()?.initial_prompt) {
|
||||
<app-report-markdown [content]="data()!.initial_prompt" />
|
||||
} @else { <p class="muted">该任务未记录初始需求提示词。</p> }
|
||||
} @else {
|
||||
@if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> }
|
||||
<fieldset [disabled]="!data()?.editable || !!saving()">
|
||||
@for (field of plugin.workspace.fields; track field.id) {
|
||||
<div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
@if (field.description) { <span class="muted">{{ field.description }}</span> }
|
||||
@if (field.kind === 'choice') {
|
||||
<div class="choice-group" role="group" [attr.aria-label]="field.label">
|
||||
@for (option of field.options; track option) {
|
||||
<button type="button" class="choice-button" [class.selected]="drafts[plugin.plugin_id].values[field.id] === option"
|
||||
[class.pass]="option === 'Pass' || option === '通过'" [class.failed]="option === 'Failed' || option === '失败'"
|
||||
[attr.aria-pressed]="drafts[plugin.plugin_id].values[field.id] === option" title="再次点击已选项可清除"
|
||||
(click)="choose(plugin.plugin_id, field.id, option)">
|
||||
@if (option === 'Pass' || option === 'Failed' || option === '通过' || option === '失败') {
|
||||
<svg class="choice-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path [attr.d]="option === 'Pass' || option === '通过' ? 'M5 12l4 4L19 6' : 'M6 6l12 12M18 6L6 18'" />
|
||||
</svg>
|
||||
}
|
||||
<span class="choice-label">{{ option }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<textarea rows="2" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
|
||||
[ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button type="button" class="save" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
|
||||
<button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button>
|
||||
@if (drafts[plugin.plugin_id].dirty) { <span class="muted unsaved">有未保存内容</span> }
|
||||
</div>
|
||||
</fieldset>
|
||||
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
|
||||
@if (plugin.history.length) {
|
||||
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
|
||||
@for (entry of plugin.history; track entry.revision) {
|
||||
<div class="history-entry"><strong>第 {{ entry.revision }} 次 · {{ entry.saved_at * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</strong>
|
||||
@for (field of entry.fields; track field.id) { <p>{{ field.label }}:{{ entry.values[field.id] || '未填写' }}</p> }
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</ng-template>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
:host { display: block; width: 100%; min-width: 0; min-height: 0; overflow: auto; container-type: inline-size; color: var(--app-text-control, #1e293b); }
|
||||
.panel-toggles { display: flex; gap: 4px; padding-right: 8px; margin-right: 4px; border-right: 1px solid var(--app-border, #e2e8f0); }
|
||||
button { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; border: 1px solid var(--app-border-control, #dbe3ec); border-radius: 8px; color: inherit; background: var(--app-surface, #fff); font: inherit; font-size: 12px; line-height: 20px; cursor: pointer; }
|
||||
.panel-toggle { height: 28px; padding: 4px 7px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); gap: 4px; }
|
||||
.panel-toggle:hover { background: var(--app-surface-soft, #f1f5f9); }
|
||||
.panel-toggle.active { background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); }
|
||||
.panel-toggle svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.4; }
|
||||
.panel-toggle .material-symbols-outlined { font-size: 15px; }
|
||||
button.save { background: #1a73e8; border-color: #1a73e8; color: white; padding: 8px 16px; font-weight: 500; }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
|
||||
.material-symbols-outlined { font-size: 18px; }
|
||||
.extension-layout { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
|
||||
.has-left { grid-template-columns: minmax(0, min(var(--left-width), 55%)) minmax(0, 1fr); }
|
||||
.has-right { grid-template-columns: minmax(0, 1fr) minmax(0, min(var(--right-width), 55%)); }
|
||||
.has-left.has-right { grid-template-columns: minmax(0, min(var(--left-width), 32%)) minmax(0, 1fr) minmax(0, min(var(--right-width), 32%)); }
|
||||
.player-content { min-width: 0; overflow: hidden; }
|
||||
.panel-column { min-width: 0; max-height: min(70dvh, 680px); overflow: auto; padding: 20px; user-select: text; background: var(--app-surface, #fff); scrollbar-width: thin; scrollbar-color: var(--app-border-control, #cbd5e1) transparent; }
|
||||
.left-column { border-right: 1px solid var(--app-border, #e2e8f0); }
|
||||
.right-column { border-left: 1px solid var(--app-border, #e2e8f0); }
|
||||
section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 20px; }
|
||||
h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); }
|
||||
.panel-kind { font-size: 11px; color: var(--app-text-muted, #64748b); }
|
||||
.muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; }
|
||||
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
.field { display: grid; gap: 8px; margin: 0 0 14px; }
|
||||
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 22px; margin-top: 22px; }
|
||||
.field-label { font-size: 13px; font-weight: 600; }
|
||||
.note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); }
|
||||
.choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
|
||||
.choice-button { flex: 1; min-height: 34px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); font-size: 13px; transition: background .15s, color .15s; }
|
||||
.choice-icon { display: block; flex: 0 0 16px; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.choice-label { display: block; line-height: 20px; }
|
||||
.choice-button:hover { background: var(--app-surface-raised, #e2e8f0); }
|
||||
.choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; }
|
||||
.choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
|
||||
.choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); }
|
||||
textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 66px; max-height: 200px; }
|
||||
textarea::placeholder { color: var(--app-text-muted, #94a3b8); }
|
||||
.form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); }
|
||||
.form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; }
|
||||
.unsaved { grid-column: 1 / -1; }
|
||||
.reload { border-color: transparent; background: transparent; color: var(--app-text-muted, #64748b); }
|
||||
.notice, .extension-notice { font-size: 12px; padding: 8px; overflow-wrap: anywhere; line-height: 1.6; }
|
||||
.history { margin-top: 18px; font-size: 12px; color: var(--app-text-secondary, #64748b); }
|
||||
summary { cursor: pointer; }
|
||||
.history-entry { border-top: 1px solid var(--app-border, #e2e8f0); padding: 10px 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
@container (max-width: 640px) {
|
||||
.panel-toggle-label { display: none; }
|
||||
.panel-toggles { padding-right: 4px; margin-right: 0; gap: 2px; }
|
||||
.panel-toggle { padding: 4px; }
|
||||
.panel-column { padding: 16px; }
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TaskExtensionPanelsComponent } from './task-extension-panels.component';
|
||||
import { TaskExtensions } from '../../services/task-extensions.service';
|
||||
|
||||
function snapshot(sessionId = 'task-one'): TaskExtensions {
|
||||
return {
|
||||
session_id: sessionId, initial_prompt: '# 原始需求\n\n| 项目 | 要求 |\n| --- | --- |\n| 页面 | 可见 |', editable: true,
|
||||
plugins: [{
|
||||
plugin_id: 'review', name: '人工评审', version: '1.0.0', revision: 0, values: { accuracy: '', note: '', correctness: '' }, history: [],
|
||||
workspace: {
|
||||
panels: [
|
||||
{ id: 'prompt', title: '任务需求', side: 'left', kind: 'initial_prompt', width: { minimum: 240, preferred: 360, maximum: 480 } },
|
||||
{ id: 'review', title: '人工评审', side: 'right', kind: 'form', width: { minimum: 260, preferred: 380, maximum: 520 } }
|
||||
],
|
||||
fields: [
|
||||
{ id: 'accuracy', label: 'AI执行准确性', description: '人工核对步骤', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true },
|
||||
{ id: 'note', label: '执行备注', description: '', kind: 'text', options: [], max_length: 2000, report: true },
|
||||
{ id: 'correctness', label: '测试结果正确性', description: '', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true }
|
||||
]
|
||||
}
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
describe('步骤回放工作区扩展', () => {
|
||||
let fixture: ComponentFixture<TaskExtensionPanelsComponent>;
|
||||
let component: TaskExtensionPanelsComponent;
|
||||
let http: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
fixture = TestBed.createComponent(TaskExtensionPanelsComponent);
|
||||
component = fixture.componentInstance;
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => { http.verify(); fixture.destroy(); });
|
||||
|
||||
function open(data = snapshot()) {
|
||||
fixture.componentRef.setInput('sessionId', data.session_id);
|
||||
fixture.componentRef.setInput('enabled', true);
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`/api/task-extensions/${data.session_id}`).flush(data);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
function toggle(side: 'left' | 'right') {
|
||||
const plugin = component.data()!.plugins[0];
|
||||
component.toggle(plugin, plugin.workspace.panels.find(panel => panel.side === side)!);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('does not request extensions in video mode and shows no controls without a plugin', () => {
|
||||
fixture.componentRef.setInput('sessionId', 'task-one');
|
||||
fixture.detectChanges();
|
||||
http.expectNone('/api/task-extensions/task-one');
|
||||
open({ ...snapshot(), plugins: [] });
|
||||
expect(fixture.nativeElement.querySelector('nav')).toBeNull();
|
||||
});
|
||||
|
||||
it('starts collapsed, renders the full initial Markdown prompt and honors declared widths', () => {
|
||||
open();
|
||||
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
|
||||
toggle('left');
|
||||
expect(fixture.nativeElement.querySelector('h1').textContent).toBe('原始需求');
|
||||
expect(fixture.nativeElement.querySelector('table td').textContent).toBe('页面');
|
||||
expect(component.width('left')).toBe(360);
|
||||
expect(fixture.nativeElement.querySelector('.report-prose.compact')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps either single panel beside playback below the former stacking breakpoint', () => {
|
||||
open();
|
||||
for (const width of [740, 600]) {
|
||||
fixture.nativeElement.style.width = `${width}px`;
|
||||
for (const side of ['left', 'right'] as const) {
|
||||
toggle(side);
|
||||
const panel = fixture.nativeElement.querySelector('aside').getBoundingClientRect();
|
||||
const player = fixture.nativeElement.querySelector('.player-content').getBoundingClientRect();
|
||||
const layout = fixture.nativeElement.querySelector('.extension-layout').getBoundingClientRect();
|
||||
expect(panel.top).toBe(player.top);
|
||||
expect(panel.width).toBeGreaterThan(0);
|
||||
expect(player.width).toBeGreaterThan(0);
|
||||
expect(side === 'left' ? panel.right : player.right).toBeCloseTo(side === 'left' ? player.left : panel.left, 0);
|
||||
expect(Math.max(panel.right, player.right)).toBeLessThanOrEqual(layout.right + 1);
|
||||
toggle(side);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('allows partial manual selection and keeps unfilled outcomes blank', () => {
|
||||
open(); toggle('right');
|
||||
expect(fixture.nativeElement.querySelector('select')).toBeNull();
|
||||
const groups: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll('.choice-group');
|
||||
expect(fixture.nativeElement.querySelector('[aria-pressed="true"]')).toBeNull();
|
||||
const pass = groups[0].querySelector<HTMLButtonElement>('.pass')!;
|
||||
const failed = groups[0].querySelector<HTMLButtonElement>('.failed')!;
|
||||
pass.click(); fixture.detectChanges();
|
||||
expect(pass.getAttribute('aria-pressed')).toBe('true');
|
||||
pass.click(); fixture.detectChanges();
|
||||
expect(component.drafts['review'].values['accuracy']).toBe('');
|
||||
failed.click(); pass.click(); fixture.detectChanges();
|
||||
expect(failed.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(groups[1].querySelector('[aria-pressed="true"]')).toBeNull();
|
||||
const note: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea');
|
||||
note.value = '人工核对'; note.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('button.save').click();
|
||||
const request = http.expectOne('/api/task-extensions/task-one/review');
|
||||
expect(request.request.method).toBe('PUT');
|
||||
expect(request.request.body.values).toEqual({ accuracy: 'Pass', note: '人工核对', correctness: '' });
|
||||
expect(request.request.body.expected_revision).toBe(0);
|
||||
const data = snapshot(); data.plugins[0].revision = 1; data.plugins[0].values = request.request.body.values;
|
||||
request.flush(data); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('人工评审已保存');
|
||||
});
|
||||
|
||||
it('preserves draft and request receipt after a failed save, and permits clearing a result', () => {
|
||||
open(); toggle('right');
|
||||
const plugin = component.data()!.plugins[0];
|
||||
component.edit('review', 'accuracy', 'Failed'); component.save(plugin);
|
||||
const first = http.expectOne('/api/task-extensions/task-one/review');
|
||||
const requestId = first.request.body.request_id;
|
||||
first.flush({}, { status: 503, statusText: 'Unavailable' });
|
||||
component.save(plugin);
|
||||
const retry = http.expectOne('/api/task-extensions/task-one/review');
|
||||
expect(retry.request.body.request_id).toBe(requestId);
|
||||
retry.flush({}, { status: 409, statusText: 'Conflict' });
|
||||
expect(component.drafts['review'].values['accuracy']).toBe('Failed');
|
||||
component.edit('review', 'accuracy', ''); component.save(plugin);
|
||||
const clear = http.expectOne('/api/task-extensions/task-one/review');
|
||||
expect(clear.request.body.values.accuracy).toBe('');
|
||||
expect(clear.request.body.request_id).not.toBe(requestId);
|
||||
clear.flush(snapshot());
|
||||
});
|
||||
|
||||
it('renders Chinese outcomes as mutually exclusive buttons and saves their declared values', () => {
|
||||
const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败'];
|
||||
open(data); toggle('right');
|
||||
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
|
||||
const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed');
|
||||
expect(pass.textContent).toContain('通过'); expect(failed.textContent).toContain('失败');
|
||||
pass.click(); failed.click(); fixture.detectChanges();
|
||||
expect(component.drafts['review'].values['accuracy']).toBe('失败');
|
||||
expect(pass.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(failed.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not apply a delayed save response to the task now displayed', () => {
|
||||
open(); toggle('right');
|
||||
component.edit('review', 'note', '只属于第一个任务'); component.save(component.data()!.plugins[0]);
|
||||
const oldSave = http.expectOne('/api/task-extensions/task-one/review');
|
||||
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
|
||||
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
|
||||
oldSave.flush(snapshot('task-one')); fixture.detectChanges();
|
||||
expect(component.data()!.session_id).toBe('task-two');
|
||||
expect(component.drafts['review'].values['note']).toBe('');
|
||||
});
|
||||
|
||||
it('keeps unsaved drafts across task switches and removes panels on plugin disable', () => {
|
||||
open(); toggle('right'); component.edit('review', 'note', '保留草稿');
|
||||
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
|
||||
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two'));
|
||||
fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges();
|
||||
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
|
||||
expect(component.drafts['review'].values['note']).toBe('保留草稿');
|
||||
window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges();
|
||||
http.expectOne('/api/task-extensions/task-one').flush({ ...snapshot(), plugins: [] }); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('nav')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps running tasks read-only then enables editing when the task finishes', () => {
|
||||
open({ ...snapshot(), editable: false }); toggle('right');
|
||||
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeTrue();
|
||||
component.save(component.data()!.plugins[0]); http.expectNone('/api/task-extensions/task-one/review');
|
||||
fixture.componentRef.setInput('taskStatus', 'completed'); fixture.detectChanges();
|
||||
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('collapses the opposite panel on narrow screens', () => {
|
||||
const original = Object.getOwnPropertyDescriptor(window, 'innerWidth')!;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
|
||||
try {
|
||||
open(); toggle('left'); toggle('right');
|
||||
expect(component.panels('left').length).toBe(0);
|
||||
expect(component.panels('right').length).toBe(1);
|
||||
} finally { Object.defineProperty(window, 'innerWidth', original); }
|
||||
});
|
||||
});
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, effect, inject, input, output, signal, viewChild } from '@angular/core';
|
||||
import { DatePipe, NgTemplateOutlet } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||
import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-task-extension-panels', standalone: true,
|
||||
imports: [FormsModule, NgTemplateOutlet, DatePipe, ReportMarkdownComponent],
|
||||
templateUrl: './task-extension-panels.component.html',
|
||||
styleUrl: './task-extension-panels.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TaskExtensionPanelsComponent {
|
||||
private service = inject(TaskExtensionsService);
|
||||
sessionId = input<string | null>(null);
|
||||
enabled = input(false);
|
||||
taskStatus = input('');
|
||||
expandedWidth = output<number>();
|
||||
headerControls = viewChild<TemplateRef<unknown>>('headerControls');
|
||||
data = signal<TaskExtensions | null>(null);
|
||||
error = signal('');
|
||||
saving = signal<string | null>(null);
|
||||
notice = signal('');
|
||||
opened = signal<Record<string, boolean>>({});
|
||||
private refreshToken = signal(0);
|
||||
private identity = '';
|
||||
private generation = 0;
|
||||
drafts: Record<string, ExtensionDraft> = {};
|
||||
|
||||
constructor() {
|
||||
effect(onCleanup => {
|
||||
const id = this.sessionId();
|
||||
const enabled = this.enabled();
|
||||
this.refreshToken();
|
||||
this.taskStatus();
|
||||
this.generation++;
|
||||
const identity = enabled ? id || '' : '';
|
||||
if (identity !== this.identity) {
|
||||
this.identity = identity;
|
||||
this.data.set(null);
|
||||
this.opened.set({});
|
||||
this.expandedWidth.emit(0);
|
||||
}
|
||||
this.error.set('');
|
||||
this.notice.set('');
|
||||
this.saving.set(null);
|
||||
if (!id || !enabled) return;
|
||||
const subscription = this.service.read(id).subscribe({
|
||||
next: data => {
|
||||
this.drafts = Object.fromEntries(data.plugins.map(plugin => [plugin.plugin_id, this.service.draft(id, plugin)]));
|
||||
this.data.set(data);
|
||||
this.expandedWidth.emit(this.width('left') + this.width('right'));
|
||||
},
|
||||
error: () => this.error.set('暂时无法加载任务扩展,请重试')
|
||||
});
|
||||
onCleanup(() => subscription.unsubscribe());
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:artemis-plugin-change')
|
||||
refresh(): void { this.refreshToken.update(value => value + 1); }
|
||||
|
||||
key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; }
|
||||
panels(side: 'left' | 'right') {
|
||||
return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels
|
||||
.filter(panel => panel.side === side && this.opened()[this.key(plugin, panel)])
|
||||
.map(panel => ({ plugin, panel })));
|
||||
}
|
||||
width(side: 'left' | 'right'): number {
|
||||
return Math.max(0, ...this.panels(side).map(({ panel }) =>
|
||||
Math.max(panel.width.minimum, Math.min(panel.width.preferred, panel.width.maximum))));
|
||||
}
|
||||
toggle(plugin: TaskExtension, panel: ExtensionPanel): void {
|
||||
const key = this.key(plugin, panel);
|
||||
const next = { ...this.opened(), [key]: !this.opened()[key] };
|
||||
if (next[key] && window.innerWidth < 1000) {
|
||||
for (const other of this.data()?.plugins || []) {
|
||||
for (const sibling of other.workspace.panels) {
|
||||
if (sibling.side !== panel.side) next[this.key(other, sibling)] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.opened.set(next);
|
||||
this.expandedWidth.emit(this.width('left') + this.width('right'));
|
||||
}
|
||||
@HostListener('window:resize')
|
||||
resize(): void {
|
||||
if (window.innerWidth < 1000 && this.panels('left').length && this.panels('right').length) {
|
||||
const next = { ...this.opened() };
|
||||
for (const { plugin, panel } of this.panels('left')) next[this.key(plugin, panel)] = false;
|
||||
this.opened.set(next);
|
||||
this.expandedWidth.emit(this.width('left') + this.width('right'));
|
||||
}
|
||||
}
|
||||
edit(pluginId: string, field: string, value: string): void {
|
||||
const draft = this.drafts[pluginId];
|
||||
draft.values[field] = value;
|
||||
draft.dirty = true;
|
||||
draft.requestId = undefined;
|
||||
this.notice.set('');
|
||||
}
|
||||
choose(pluginId: string, field: string, value: string): void {
|
||||
this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value);
|
||||
}
|
||||
reload(plugin: TaskExtension): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.service.discard(id, plugin.plugin_id);
|
||||
this.refresh();
|
||||
}
|
||||
save(plugin: TaskExtension): void {
|
||||
const id = this.sessionId();
|
||||
if (!id || !this.data()?.editable || this.saving()) return;
|
||||
this.saving.set(plugin.plugin_id);
|
||||
const generation = this.generation;
|
||||
this.notice.set('');
|
||||
this.service.save(id, plugin.plugin_id, this.drafts[plugin.plugin_id]).subscribe({
|
||||
next: data => {
|
||||
this.service.discard(id, plugin.plugin_id);
|
||||
if (id !== this.sessionId() || generation !== this.generation) return;
|
||||
for (const item of data.plugins) this.drafts[item.plugin_id] = this.service.draft(id, item);
|
||||
this.data.set(data);
|
||||
this.saving.set(null);
|
||||
this.notice.set('人工评审已保存,下次导出将包含最新内容');
|
||||
},
|
||||
error: error => {
|
||||
if (id !== this.sessionId() || generation !== this.generation) return;
|
||||
this.saving.set(null);
|
||||
this.notice.set(typeof error.error?.detail === 'string' ? error.error.detail : '保存失败,已保留输入,请重试');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class TaskPluginsComponent implements OnChanges {
|
||||
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) &&
|
||||
return this.plugins().filter(p => p.enabled && (!p.manifest.workspace || !!p.manifest.server || !!p.manifest.skill) && p.profiles.some(profile => profile.authorized) &&
|
||||
p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
|
||||
}
|
||||
ngOnChanges() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<main class="plugins-page">
|
||||
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。</p></div>
|
||||
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>扩展任务操作能力,也可为回放界面和报告添加人工评审等功能。</p></div>
|
||||
<button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
|
||||
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
|
||||
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { tap } from 'rxjs';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string; name: string; version: string; description: string;
|
||||
artemis_version: string; platforms: string[]; source: string;
|
||||
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
|
||||
workspace?: unknown; server?: unknown; skill?: string;
|
||||
resources: string[];
|
||||
targets: ('android' | 'computer' | 'mixed')[];
|
||||
}
|
||||
@@ -28,13 +30,13 @@ export class PluginService {
|
||||
install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
|
||||
return this.http.post<{ job_id: string }>('/api/plugins/install', payload);
|
||||
}
|
||||
progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`); }
|
||||
progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
|
||||
save(id: string, profile: PluginProfile) {
|
||||
const { configured_secrets, ...payload } = profile;
|
||||
return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload);
|
||||
}
|
||||
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }); }
|
||||
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
|
||||
diagnose(id: string, profile: string) { return this.http.post<{ ok: boolean; message: string }>(`/api/plugins/${encodeURIComponent(id)}/diagnose`, { profile }); }
|
||||
uninstall(id: string) { return this.http.delete(`/api/plugins/${encodeURIComponent(id)}`); }
|
||||
uninstall(id: string) { return this.http.delete(`/api/plugins/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
|
||||
custom(manifest: unknown, skill_content: string) { return this.http.post('/api/plugins/custom', { manifest, skill_content }); }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
export interface ExtensionField {
|
||||
id: string; label: string; description: string; kind: 'choice' | 'text';
|
||||
options: string[]; max_length: number; report: boolean;
|
||||
}
|
||||
export interface ExtensionPanel {
|
||||
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'form';
|
||||
width: { preferred: number; minimum: number; maximum: number };
|
||||
}
|
||||
export interface TaskExtension {
|
||||
plugin_id: string; name: string; version: string; revision: number;
|
||||
workspace: { panels: ExtensionPanel[]; fields: ExtensionField[] };
|
||||
values: Record<string, string>;
|
||||
history: { revision: number; saved_at: number; values: Record<string, string>; fields: ExtensionField[] }[];
|
||||
}
|
||||
export interface TaskExtensions {
|
||||
session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[];
|
||||
}
|
||||
export interface ExtensionDraft {
|
||||
values: Record<string, string>; revision: number; dirty: boolean; requestId?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TaskExtensionsService {
|
||||
private http = inject(HttpClient);
|
||||
private drafts = new Map<string, ExtensionDraft>();
|
||||
read(sessionId: string) {
|
||||
return this.http.get<TaskExtensions>(`/api/task-extensions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
draft(sessionId: string, plugin: TaskExtension): ExtensionDraft {
|
||||
const key = `${sessionId}/${plugin.plugin_id}`;
|
||||
let draft = this.drafts.get(key);
|
||||
if (!draft?.dirty) {
|
||||
draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false };
|
||||
this.drafts.set(key, draft);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
discard(sessionId: string, pluginId: string) { this.drafts.delete(`${sessionId}/${pluginId}`); }
|
||||
save(sessionId: string, pluginId: string, draft: ExtensionDraft) {
|
||||
draft.requestId ||= crypto.randomUUID();
|
||||
return this.http.put<TaskExtensions>(`/api/task-extensions/${encodeURIComponent(sessionId)}/${encodeURIComponent(pluginId)}`, {
|
||||
values: { ...draft.values }, expected_revision: draft.revision, request_id: draft.requestId
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user