feat: release 2.4.0 with original cases and automatic human review

This commit is contained in:
chenmingxuan committed 2026-10-28 17:25:08 +08:00
1 parent baf069854b
commit 4dc2057d43
66 files changed
+1390 -206

No files matched your search

@@ -695,13 +695,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">目标:</span>
<span class="detail-value">"{{ getActionTargetText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="目标" [value]="getActionTargetText(item.data)" />
</div>
}
@if (getActionInputText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">{{ getActionInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getActionInputText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="输入" [value]="getActionInputText(item.data)" />
</div>
}
@if (getActionCoords(item.data)) {
@@ -719,13 +719,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) {
<div class="grid-item">
<span class="grid-label">目标</span>
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span>
<app-expandable-value label="目标" [value]="getActionTargetText(item.data)" />
</div>
}
@if (getActionInputText(item.data)) {
<div class="grid-item">
<span class="grid-label">{{ getActionInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getActionInputText(item.data) }}"</span>
<app-expandable-value label="输入" [value]="getActionInputText(item.data)" />
</div>
}
@if (getActionCoords(item.data)) {
@@ -749,19 +749,7 @@ limitations under the License.
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item" [class.full-width]="param.key === 'Result'">
<span class="grid-label">{{ param.key }}</span>
@if (param.key === 'Result') {
<div class="action-result-preview">{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}</div>
<details class="action-result-detail">
<summary>
<span class="result-expand-label">展开完整结果</span>
<span class="result-collapse-label">收起结果</span>
<span class="result-length">{{ param.value.length }} 字符</span>
</summary>
<pre tabindex="0" aria-label="完整操作结果,可选择复制">{{ param.value }}</pre>
</details>
} @else {
<span class="grid-value code-pill">{{ param.value }}</span>
}
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
<!-- Fallback item if all detail fields are empty -->
@@ -1008,7 +996,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) {
<div class="action-detail-row">
<span class="detail-label">命令:</span>
<span class="detail-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
<app-expandable-value class="detail-value" label="命令" [value]="getAdbCommandLine(item.data)" />
</div>
}
@if (getAdbCwd(item.data)) {
@@ -1029,13 +1017,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">目标:</span>
<span class="detail-value">"{{ getToolTargetText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="目标" [value]="getToolTargetText(item.data)" />
</div>
}
@if (getToolInputText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">{{ getToolInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getToolInputText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="输入" [value]="getToolInputText(item.data)" />
</div>
}
@if (getToolCoords(item.data)) {
@@ -1047,13 +1035,13 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">分析:</span>
<span class="detail-value">"{{ getToolAnalysisText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="分析" [value]="getToolAnalysisText(item.data)" />
</div>
}
@if (getToolGenericDetails(item.data)) {
<div class="action-detail-row">
<span class="detail-label">详情:</span>
<span class="detail-value">"{{ getToolGenericDetails(item.data) }}"</span>
<app-expandable-value class="detail-value" label="详情" [value]="getToolGenericDetails(item.data)" />
</div>
}
}
@@ -1067,7 +1055,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) {
<div class="grid-item full-width">
<span class="grid-label">命令</span>
<span class="grid-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
<app-expandable-value label="命令" [value]="getAdbCommandLine(item.data)" />
</div>
}
@if (getAdbCwd(item.data)) {
@@ -1087,7 +1075,7 @@ limitations under the License.
@for (param of plugin.args; track param.key) {
<div class="grid-item">
<span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span>
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
@@ -1101,13 +1089,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) {
<div class="grid-item">
<span class="grid-label">目标</span>
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span>
<app-expandable-value label="目标" [value]="getToolTargetText(item.data)" />
</div>
}
@if (getToolInputText(item.data)) {
<div class="grid-item">
<span class="grid-label">{{ getToolInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getToolInputText(item.data) }}"</span>
<app-expandable-value label="输入" [value]="getToolInputText(item.data)" />
</div>
}
@if (getToolCoords(item.data)) {
@@ -1119,19 +1107,19 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) {
<div class="grid-item">
<span class="grid-label">分析</span>
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span>
<app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
</div>
}
@if (getToolGenericDetails(item.data)) {
<div class="grid-item">
<span class="grid-label">详情</span>
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span>
<app-expandable-value label="详情" [value]="getToolGenericDetails(item.data)" />
</div>
}
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item">
<span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span>
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
}
@@ -6,6 +6,7 @@ import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-sta
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
@@ -250,7 +251,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
imports: [ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { ExpandableValueComponent } from './expandable-value.component';
describe('执行字段折叠', () => {
for (const [label, value] of [['Payload', '{"x":1}'], ['输入', '完整内容'.repeat(100)], ['输出', '行\n'.repeat(6)]]) {
it(`默认折叠 ${label} 并保留完整内容`, () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('label', label);
fixture.componentRef.setInput('value', value);
fixture.detectChanges();
const details: HTMLDetailsElement = fixture.nativeElement.querySelector('details');
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(details.querySelector('pre')!.textContent).toBe(value);
fixture.detectChanges();
expect(details.open).toBeTrue();
});
}
it('直接显示普通短字段', () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('value', '点击设置'); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('点击设置');
});
});
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/** Visual folding only: the full receipt remains selectable and unchanged. */
@Component({
selector: 'app-expandable-value', standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (foldable()) {
<details>
<summary><span>{{ label() }} · {{ value().length }} 字符</span><span class="hint">展开查看</span></summary>
<pre tabindex="0" [attr.aria-label]="label() + '完整内容,可选择复制'">{{ value() }}</pre>
</details>
} @else { <span class="short-value">{{ value() }}</span> }
`,
styles: [`
:host { display:block; min-width:0; max-width:100%; }
details { border:1px solid var(--app-border, #e2e8f0); border-radius:8px; background:var(--app-surface-soft, #f8fafc); }
summary { cursor:pointer; padding:7px 10px; font-size:12px; color:var(--app-text-secondary, #475569); overflow-wrap:anywhere; }
.hint { margin-left:8px; color:var(--app-primary-text, #1a73e8); }
details[open] .hint { display:none; }
pre { margin:0; padding:10px; max-height:24rem; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; user-select:text; }
.short-value { white-space:pre-wrap; overflow-wrap:anywhere; }
summary:focus-visible, pre:focus-visible { outline:2px solid var(--app-focus, #a5b4fc); outline-offset:2px; }
`]
})
export class ExpandableValueComponent {
value = input('');
label = input('详情');
foldable = computed(() => /payload|原始|^result$/i.test(this.label())
|| this.value().length > 240 || this.value().split('\n').length > 4);
}
@@ -122,7 +122,8 @@ limitations under the License.
}
<!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) {
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="agentService.playerMode() === 'steps'"
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="true"
[sessionIds]="reviewSessionIds()" (navigate)="agentService.selectSession($event, true)"
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
<div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@@ -1,5 +1,5 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { HttpTestingController, 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';
@@ -41,6 +41,8 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'),
sessions: signal([{ session_id: 'test', status: 'completed' }]),
selectSession: jasmine.createSpy('selectSession'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
@@ -66,6 +68,9 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: []
});
component = fixture.componentInstance;
video = component.videoRef!.nativeElement;
paused = true;
@@ -100,6 +105,23 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture.detectChanges();
}
for (const mode of ['video', 'steps']) {
it(`shows review controls in ${mode} mode even without a recording`, () => {
agentService.playerMode.set(mode);
agentService.recordingPlaybackStatus.set('unavailable');
component.extensionPanels()!.refresh(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: [{
plugin_id: 'review', name: '原始用例', version: '1.1.0', revision: 0, values: {}, history: [],
workspace: { fields: [], panels: [{ id: 'case', title: '原始用例', side: 'left', kind: 'original_case', width: {minimum: 240, preferred: 340, maximum: 480} }] }
}]
}); fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="展开原始用例"]');
expect(button).not.toBeNull(); button.click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside').textContent).toContain('未记录原始用例');
});
}
it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live');
@@ -51,6 +51,9 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
public extensionWidth = signal(0);
public reviewSessionIds = computed(() => this.agentService.sessions()
.filter(session => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || ''))
.map(session => session.session_id));
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
public windowLeft(): number {
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
@@ -1,15 +1,17 @@
import { Component, computed, input } from '@angular/core';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
/** Readable presentation only: the full receipt and model context stay intact. */
@Component({
selector: 'app-plugin-tool-result',
standalone: true,
imports: [ExpandableValueComponent],
template: `
@if (view().facts.length) {
<dl class="receipt-facts">
@for (fact of view().facts; track fact.key) {
<div><dt>{{ fact.key }}</dt><dd>{{ fact.value }}</dd></div>
<div><dt>{{ fact.key }}</dt><dd><app-expandable-value [label]="fact.key" [value]="fact.value" /></dd></div>
}
</dl>
}
@@ -44,14 +44,21 @@
<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') {
<header><h3>{{ panel.kind === 'original_case' ? (data()?.case_title || panel.title) : panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'original_case' ? '原始用例' : panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'original_case') {
@if (!data()?.original_case) {
<p class="muted">此任务未记录原始用例内容;旧任务不会从 AI 提示词推测补写。</p>
}
<div class="case-section"><h4>预置条件</h4><app-report-markdown [content]="data()?.original_case?.preconditions || '未填写'" /></div>
<div class="case-section"><h4>操作步骤</h4><app-report-markdown [content]="data()?.original_case?.steps || '未填写'" /></div>
<div class="case-section"><h4>预期结果</h4><app-report-markdown [content]="data()?.original_case?.expected || '未填写'" /></div>
} @else 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()">
<fieldset [disabled]="!data()?.editable">
@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>
@@ -73,17 +80,26 @@
}
</div>
} @else {
<textarea rows="2" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
<textarea rows="1" [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="save" [disabled]="!!saving()" (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> }
<span class="muted unsaved">{{ saving() === plugin.plugin_id ? '正在保存…' : drafts[plugin.plugin_id].dirty ? '等待自动保存…' : '修改后自动保存' }}</span>
</div>
</fieldset>
<nav class="review-navigation" aria-label="切换评审任务">
<div class="navigation-buttons">
<button type="button" [disabled]="!navigation().previous || !!saving() || moving()" (click)="move('previous')"><span aria-hidden="true">‹</span>上一条</button>
<span class="review-position">{{ navigation().position }} / {{ navigation().total }}</span>
<button type="button" [disabled]="!navigation().next || !!saving() || moving()" (click)="move('next')">下一条<span aria-hidden="true">›</span></button>
</div>
<span class="muted">{{ navigation().scope === 'batch' ? '同批次已结束任务 · 按执行顺序' : '当前历史列表' }}</span>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted">有待保存的修改,切换前会先保存。</span> }
</nav>
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
@if (plugin.history.length) {
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
@@ -19,13 +19,19 @@ button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-prim
.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; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 14px; overflow-wrap: anywhere; }
h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); }
.panel-kind { font-size: 11px; color: var(--app-text-muted, #64748b); }
.panel-kind { flex-shrink: 0; font-size: 11px; color: var(--app-text-muted, #64748b); }
.case-section { margin: 18px 0; }
.case-section h4 { margin: 0 0 8px; font-size: 13px; color: var(--app-text-strong, #0f172a); }
.review-navigation { margin: 12px 0 0; padding: 12px 0 0; background: var(--app-surface, #fff); border-top: 1px solid var(--app-border, #e2e8f0); }
.navigation-buttons { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-bottom: 4px; }
.navigation-buttons button { white-space: nowrap; }
.review-position { font-size: 11px; color: var(--app-text-muted, #64748b); white-space: nowrap; }
.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; }
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 14px; margin-top: 14px; }
.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); }
@@ -36,7 +42,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.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 { box-sizing: border-box; width: 100%; min-width: 0; padding: 6px 10px; 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: 34px; 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; }
@@ -1,4 +1,4 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks, tick } 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';
@@ -50,7 +50,7 @@ describe('步骤回放工作区扩展', () => {
fixture.detectChanges();
}
it('does not request extensions in video mode and shows no controls without a plugin', () => {
it('does not request disabled extensions and shows no controls without a plugin', () => {
fixture.componentRef.setInput('sessionId', 'task-one');
fixture.detectChanges();
http.expectNone('/api/task-extensions/task-one');
@@ -87,87 +87,92 @@ describe('步骤回放工作区扩展', () => {
}
});
it('allows partial manual selection and keeps unfilled outcomes blank', () => {
it('automatically saves choices and clears while keeping unfilled outcomes blank', fakeAsync(() => {
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')!;
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
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(saved().values).toEqual({ accuracy: 'Pass', note: '', correctness: '' });
pass.click(); fixture.detectChanges(); expect(saved(2).values.accuracy).toBe('');
expect(fixture.nativeElement.textContent).toContain('人工评审已自动保存');
}));
function saved(revision = 1, id = 'task-one') {
const request = http.expectOne(`/api/task-extensions/${id}/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('人工评审已保存');
});
const body = request.request.body, data = snapshot(id);
data.plugins[0].revision = revision; data.plugins[0].values = body.values;
request.flush(data); flushMicrotasks(); fixture.detectChanges(); return body;
}
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' });
it('debounces notes and never posts just from opening a task', fakeAsync(() => {
open(); toggle('right'); tick(700); http.expectNone('/api/task-extensions/task-one/review');
component.edit('review', 'note', '第一段'); tick(400);
component.edit('review', 'note', '完整备注'); tick(599);
http.expectNone('/api/task-extensions/task-one/review'); tick(1);
expect(saved().values.note).toBe('完整备注');
}));
it('does not overwrite an automatic save with an older read response', fakeAsync(() => {
open(); component.edit('review', 'note', '已保存的新内容'); tick(600); saved(2);
component.refresh(); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('已保存的新内容');
expect(component.drafts['review'].revision).toBe(2);
}));
it('flushes a pending note when the review panel is closed', fakeAsync(() => {
open(); component.edit('review', 'note', '关闭前保存'); fixture.destroy();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(request.request.body.values.note).toBe('关闭前保存');
const response = snapshot(); response.plugins[0].revision = 1; response.plugins[0].values = request.request.body.values;
request.flush(response); flushMicrotasks();
}));
it('preserves draft and request receipt on failure, and retries only when requested', fakeAsync(() => {
open(); toggle('right'); const plugin = component.data()!.plugins[0];
component.edit('review', 'accuracy', 'Failed'); void component.save(plugin);
const first = http.expectOne('/api/task-extensions/task-one/review'), requestId = first.request.body.request_id;
first.flush({}, { status: 503, statusText: 'Unavailable' }); flushMicrotasks(); tick(700);
http.expectNone('/api/task-extensions/task-one/review'); void 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' }); flushMicrotasks();
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());
});
component.edit('review', 'accuracy', ''); void component.save(plugin);
const clear = saved(); expect(clear.values.accuracy).toBe(''); expect(clear.request_id).not.toBe(requestId);
}));
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');
it('serializes rapid edits and saves the latest choice using the returned revision', fakeAsync(() => {
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');
});
expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(saved().values.accuracy).toBe('通过');
const second = saved(2); expect(second.values.accuracy).toBe('失败'); expect(second.expected_revision).toBe(1);
expect(component.drafts['review'].dirty).toBeFalse();
}));
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]);
it('does not apply a delayed save response to the task now displayed', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '只属于第一个任务'); void 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('');
});
oldSave.flush(snapshot('task-one')); flushMicrotasks(); 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', '保留草稿');
it('flushes a pending note on direct task switches and hides disabled plugins', fakeAsync(() => {
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'));
const body = saved(1, 'task-one'); expect(body.values.note).toBe('自动保存草稿');
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('保留草稿');
const first = snapshot(); first.plugins[0].revision = 1; first.plugins[0].values = body.values;
http.expectOne('/api/task-extensions/task-one').flush(first); 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();
http.expectOne('/api/task-extensions/task-one').flush({ ...first, plugins: [] }); fixture.detectChanges();
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');
@@ -187,4 +192,30 @@ describe('步骤回放工作区扩展', () => {
expect(component.panels('right').length).toBe(1);
} finally { Object.defineProperty(window, 'innerWidth', original); }
});
it('shows original case sections in order without substituting the AI prompt', () => {
const data = snapshot();
data.plugins[0].workspace.panels[0].kind = 'original_case';
data.case_title = 'CASE-001 检查设置';
data.original_case = { preconditions: '已登录', steps: '1. 打开设置\n2. 核对版本', expected: '版本可见' };
open(data); toggle('left');
const text = fixture.nativeElement.textContent;
expect(text).toContain('CASE-001 检查设置');
expect(text).toMatch(/预置条件[\s\S]*已登录[\s\S]*操作步骤[\s\S]*核对版本[\s\S]*预期结果[\s\S]*版本可见/);
expect(text).not.toContain('原始需求');
});
it('saves before navigation and keeps the current task on save failure', fakeAsync(() => {
const first = snapshot(); first.navigation = { scope: 'batch', position: 1, total: 2, previous: null, next: 'task-two' };
open(first); toggle('right'); const emitted = jasmine.createSpy('navigate'); component.navigate.subscribe(emitted);
const saveButton = fixture.nativeElement.querySelector('button.save'), navigation = fixture.nativeElement.querySelector('.review-navigation');
expect(saveButton.compareDocumentPosition(navigation) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
component.edit('review', 'note', '切换前保存'); void component.move('next'); expect(emitted).not.toHaveBeenCalled();
http.expectOne('/api/task-extensions/task-one/review').flush({}, {status: 409, statusText: 'Conflict'});
flushMicrotasks(); expect(emitted).not.toHaveBeenCalled(); expect(component.drafts['review'].dirty).toBeTrue();
void component.move('next'); saved(); flushMicrotasks(); expect(emitted).toHaveBeenCalledOnceWith('task-two');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
expect(component.panels('right').length).toBe(1);
}));
});
@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, DestroyRef, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { DatePipe, NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service';
@@ -16,11 +17,25 @@ export class TaskExtensionPanelsComponent {
sessionId = input<string | null>(null);
enabled = input(false);
taskStatus = input('');
sessionIds = input<string[]>([]);
navigate = output<string>();
navigation = computed(() => {
if (this.data()?.navigation) return this.data()!.navigation!;
const ids = this.sessionIds(), index = ids.indexOf(this.sessionId() || '');
return { scope: 'history', position: index + 1, total: ids.length,
previous: index > 0 ? ids[index - 1] : null,
next: index >= 0 && index < ids.length - 1 ? ids[index + 1] : null };
});
expandedWidth = output<number>();
headerControls = viewChild<TemplateRef<unknown>>('headerControls');
data = signal<TaskExtensions | null>(null);
error = signal('');
saving = signal<string | null>(null);
private destroyRef = inject(DestroyRef);
private savingKeys = signal<string[]>([]);
saving = computed(() => this.savingKeys().find(key => key.startsWith(`${this.sessionId()}/`))?.split('/')[1] || null);
moving = signal(false);
private scheduled = new Map<string, { timer: ReturnType<typeof setTimeout>; run: () => Promise<boolean> }>();
private inFlight = new Map<string, Promise<boolean>>();
notice = signal('');
opened = signal<Record<string, boolean>>({});
private refreshToken = signal(0);
@@ -29,6 +44,7 @@ export class TaskExtensionPanelsComponent {
drafts: Record<string, ExtensionDraft> = {};
constructor() {
this.destroyRef.onDestroy(() => this.flushScheduled());
effect(onCleanup => {
const id = this.sessionId();
const enabled = this.enabled();
@@ -37,14 +53,14 @@ export class TaskExtensionPanelsComponent {
this.generation++;
const identity = enabled ? id || '' : '';
if (identity !== this.identity) {
this.flushScheduled();
this.identity = identity;
this.data.set(null);
this.opened.set({});
if (!identity) 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 => {
@@ -61,6 +77,27 @@ export class TaskExtensionPanelsComponent {
@HostListener('window:artemis-plugin-change')
refresh(): void { this.refreshToken.update(value => value + 1); }
async move(direction: 'previous' | 'next'): Promise<void> {
const destination = this.navigation()[direction], id = this.sessionId();
if (!destination || !id || !this.data() || this.moving()) return;
this.moving.set(true);
try {
for (const plugin of this.data()!.plugins) {
if (this.drafts[plugin.plugin_id]?.dirty && !await this.save(plugin)) return;
}
if (id === this.sessionId() && !this.destroyRef.destroyed) this.navigate.emit(destination);
} finally { this.moving.set(false); }
}
private cancelScheduled(key: string): void {
const pending = this.scheduled.get(key);
if (pending) clearTimeout(pending.timer);
this.scheduled.delete(key);
}
private flushScheduled(): void {
for (const pending of [...this.scheduled.values()]) void pending.run();
}
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
@@ -94,41 +131,71 @@ export class TaskExtensionPanelsComponent {
}
}
edit(pluginId: string, field: string, value: string): void {
const draft = this.drafts[pluginId];
const id = this.sessionId(), draft = this.drafts[pluginId];
if (!id || !draft || !this.data()?.editable || draft.values[field] === value) return;
draft.values[field] = value;
draft.dirty = true;
draft.requestId = undefined;
this.notice.set('');
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const run = () => this.saveFor(id, pluginId, draft);
this.scheduled.set(key, { run, timer: setTimeout(() => void run(), 600) });
}
choose(pluginId: string, field: string, value: string): void {
this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value);
const plugin = this.data()?.plugins.find(item => item.plugin_id === pluginId);
if (plugin) void this.save(plugin);
}
reload(plugin: TaskExtension): void {
const id = this.sessionId();
if (!id) return;
this.cancelScheduled(`${id}/${plugin.plugin_id}`);
this.service.discard(id, plugin.plugin_id);
this.refresh();
}
save(plugin: TaskExtension): void {
save(plugin: TaskExtension): Promise<boolean> {
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);
if (!id || !this.data()?.editable) return Promise.resolve(false);
return this.saveFor(id, plugin.plugin_id, this.drafts[plugin.plugin_id]);
}
private async saveFor(id: string, pluginId: string, draft: ExtensionDraft): Promise<boolean> {
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const pending = this.inFlight.get(key);
if (pending) {
if (!await pending) return false;
return draft.dirty ? this.saveFor(id, pluginId, draft) : true;
}
if (!draft.dirty) return true;
const submitted = { ...draft.values }, generation = this.generation;
const visible = () => !this.destroyRef.destroyed && id === this.sessionId() && generation === this.generation;
this.savingKeys.update(keys => [...keys, key]);
const request = firstValueFrom(this.service.save(id, pluginId, draft)).then(data => {
const saved = data.plugins.find(plugin => plugin.plugin_id === pluginId);
if (!saved) throw new Error('未收到保存回执');
draft.revision = saved.revision;
draft.dirty = JSON.stringify(draft.values) !== JSON.stringify(submitted);
draft.requestId = undefined;
if (!draft.dirty) draft.values = { ...saved.values };
if (visible()) {
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 : '保存失败,已保留输入,请重试');
this.notice.set(draft.dirty ? '正在保存后续修改…' : '人工评审已自动保存');
}
return true;
}).catch(error => {
this.cancelScheduled(key);
if (visible()) this.notice.set(typeof error.error?.detail === 'string'
? `保存失败:${error.error.detail};输入已保留`
: '保存失败,输入已保留,请点击保存评审重试');
return false;
}).finally(() => {
this.inFlight.delete(key);
this.savingKeys.update(keys => keys.filter(item => item !== key));
});
this.inFlight.set(key, request);
const succeeded = await request;
return succeeded && draft.dirty ? this.saveFor(id, pluginId, draft) : succeeded;
}
}
@@ -6,7 +6,7 @@ export interface ExtensionField {
options: string[]; max_length: number; report: boolean;
}
export interface ExtensionPanel {
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'form';
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'original_case' | 'form';
width: { preferred: number; minimum: number; maximum: number };
}
export interface TaskExtension {
@@ -17,6 +17,9 @@ export interface TaskExtension {
}
export interface TaskExtensions {
session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[];
case_title?: string;
original_case?: { preconditions: string; steps: string; expected: string } | null;
navigation?: { scope: 'batch'; position: number; total: number; previous: string | null; next: string | null } | null;
}
export interface ExtensionDraft {
values: Record<string, string>; revision: number; dirty: boolean; requestId?: string;
@@ -32,7 +35,7 @@ export class TaskExtensionsService {
draft(sessionId: string, plugin: TaskExtension): ExtensionDraft {
const key = `${sessionId}/${plugin.plugin_id}`;
let draft = this.drafts.get(key);
if (!draft?.dirty) {
if (!draft || (!draft.dirty && plugin.revision >= draft.revision)) {
draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false };
this.drafts.set(key, draft);
}