fix: preserve cross-device evidence and render recorded reports
This commit is contained in:
1 parent
a66803651a
commit
1440a1f508
28 files changed
+759
-128
No files matched your search
@@ -182,6 +182,9 @@ limitations under the License.
|
||||
<!-- Rich Notes Content Viewer -->
|
||||
@if (noteKeys().length > 0) {
|
||||
<div class="dropdown-notes-content">
|
||||
@if (agentService.selectedNoteKey() !== 'task_plan.md') {
|
||||
<app-report-markdown [content]="currentNoteContent()" />
|
||||
} @else {
|
||||
@if (parsedNote().title) {
|
||||
<h3 class="note-title">{{ parsedNote().title }}</h3>
|
||||
}
|
||||
@@ -332,6 +335,7 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -770,10 +774,8 @@ limitations under the License.
|
||||
<!-- Compact preview body (when collapsed, matches standard action card) -->
|
||||
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) {
|
||||
<div class="action-card-body">
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">摘要:</span>
|
||||
<span class="detail-value">"{{ getReportStatusExplanation(item.data) }}"</span>
|
||||
</div>
|
||||
<app-report-markdown [content]="getReportStatusExplanation(item.data)" [compact]="true" />
|
||||
<button type="button" class="report-expand-button" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">查看完整报告</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Detailed expanded view (when clicked, matches standard action card structure) -->
|
||||
@@ -783,7 +785,7 @@ limitations under the License.
|
||||
@if (getReportStatusExplanation(item.data); as expText) {
|
||||
<div class="grid-item full-width">
|
||||
<span class="grid-label">报告与发现</span>
|
||||
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
|
||||
<app-report-markdown [content]="expText" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1375,62 +1377,7 @@ limitations under the License.
|
||||
</div>
|
||||
</div>
|
||||
<div class="synthesis-card-preview">
|
||||
@if (parsedOutputReport(); as parsedOut) {
|
||||
<div class="parsed-note-container">
|
||||
@if (parsedOut.title) {
|
||||
<h3 class="parsed-note-title">{{ parsedOut.title }}</h3>
|
||||
}
|
||||
@if (parsedOut.milestones.length > 0) {
|
||||
<div class="parsed-milestones">
|
||||
@for (m of parsedOut.milestones; track trackMilestone($index, m)) {
|
||||
<div class="milestone-item" [class]="'status-' + m.type">
|
||||
<span class="material-symbols-outlined status-icon">
|
||||
{{ m.type === 'checked' ? 'check_circle' : (m.type === 'progress' ? 'pending' : 'radio_button_unchecked') }}
|
||||
</span>
|
||||
<span class="milestone-text">
|
||||
@for (seg of m.segments; track seg) {
|
||||
@if (seg.bold) {
|
||||
<strong>{{ seg.text }}</strong>
|
||||
}
|
||||
@if (seg.code) {
|
||||
<code>{{ seg.text }}</code>
|
||||
}
|
||||
@if (!seg.bold && !seg.code) {
|
||||
<span>{{ seg.text }}</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (parsedOut.otherLines.length > 0) {
|
||||
<div class="parsed-other-lines">
|
||||
@for (line of parsedOut.otherLines; track trackMarkdownLine($index, line)) {
|
||||
<p class="other-line" [class]="'line-' + line.type">
|
||||
@if (line.type === 'verify') {
|
||||
<span class="verify-badge" [class.assert]="line.checkKind === 'assert'">{{ line.checkKind || 'verify' }}{{ line.atEnd ? '@end' : '' }}</span>
|
||||
}
|
||||
@if (line.type === 'finding') {
|
||||
<span class="finding-badge">finding</span>
|
||||
}
|
||||
@for (seg of line.segments; track seg) {
|
||||
@if (seg.bold) {
|
||||
<strong>{{ seg.text }}</strong>
|
||||
}
|
||||
@if (seg.code) {
|
||||
<code>{{ seg.text }}</code>
|
||||
}
|
||||
@if (!seg.bold && !seg.code) {
|
||||
<span>{{ seg.text }}</span>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<app-report-markdown [content]="outReport" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4034,6 +4034,19 @@ $stream-row-height: 25px;
|
||||
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
}
|
||||
.report-expand-button {
|
||||
align-self: flex-start;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
&:hover { background: #f1f5f9; }
|
||||
&:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
}
|
||||
:host ::ng-deep .report-markdown-body {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mergeConversation } from '../../utils/exploration-timeline.util';
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||
import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
|
||||
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
/**
|
||||
@@ -247,7 +248,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
||||
@Component({
|
||||
selector: 'app-agent-stream',
|
||||
standalone: true,
|
||||
imports: [LlmRequestStatusComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||
imports: [ReportMarkdownComponent, LlmRequestStatusComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||
templateUrl: './agent-stream.component.html',
|
||||
styleUrl: './agent-stream.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -445,13 +446,6 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return notes['output.md'] || null;
|
||||
});
|
||||
|
||||
// Memoized so the Task Report card is not re-parsed (and its DOM rebuilt)
|
||||
// on every change-detection pass while streams are typing.
|
||||
public parsedOutputReport = computed<ParsedNote | null>(() => {
|
||||
const report = this.outputterReport();
|
||||
return report ? parseNote(report) : null;
|
||||
});
|
||||
|
||||
public parsedNote = computed<ParsedNote>(() => {
|
||||
return this.getParsedNote(this.currentNoteContent());
|
||||
});
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
|
||||
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
|
||||
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
||||
<div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div>
|
||||
<div class="report-content"><h3>结论与发现</h3><app-report-markdown class="report-body" [content]="r.content" /></div>
|
||||
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
||||
@if (liveTask() && agent.currentSessionId()) {
|
||||
<div class="task-guidance">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
||||
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||
import { CaseReviewResponse } from '../../core/models/session.model';
|
||||
@@ -22,7 +22,7 @@ interface ArchiveProgress {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
|
||||
selector: 'app-automation-panel', standalone: true, imports: [ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
|
||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||
})
|
||||
export class AutomationPanelComponent {
|
||||
@@ -61,7 +61,6 @@ export class AutomationPanelComponent {
|
||||
}
|
||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
|
||||
openBatchTask(id: string) { this.agent.selectSession(id, true); }
|
||||
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReportMarkdownComponent } from './report-markdown.component';
|
||||
|
||||
describe('报告 Markdown 展示', () => {
|
||||
it('renders report headings, emphasis, lists, tables and code in both preview and full view', () => {
|
||||
const fixture = TestBed.createComponent(ReportMarkdownComponent);
|
||||
fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n\n| 区域 | 结论 |\n| --- | --- |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```');
|
||||
fixture.componentRef.setInput('compact', true);
|
||||
fixture.detectChanges();
|
||||
const view: HTMLElement = fixture.nativeElement;
|
||||
expect(view.querySelector('h3')?.textContent).toBe('双端布局报告');
|
||||
expect(view.querySelector('li strong')?.textContent).toBe('手机');
|
||||
expect(view.querySelector('li code')?.textContent).toBe('/zh');
|
||||
expect(view.querySelector('td')?.textContent).toBe('页底');
|
||||
expect(view.querySelector('pre code')?.textContent).toBe('<原始证据>');
|
||||
expect(view.querySelector('.compact')).not.toBeNull();
|
||||
fixture.componentRef.setInput('compact', false);
|
||||
fixture.detectChanges();
|
||||
expect(view.querySelector('.compact')).toBeNull();
|
||||
expect(view.querySelectorAll('td').length).toBe(2);
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('keeps HTML in model output inert and updates when the selected report changes', () => {
|
||||
const fixture = TestBed.createComponent(ReportMarkdownComponent);
|
||||
fixture.componentRef.setInput('content', '<img src=x onerror=alert(1)>\n<script>alert(1)</script>');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('img,script')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('<img');
|
||||
fixture.componentRef.setInput('content', '## 新报告\n**待确认**');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('新报告');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('alert');
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
|
||||
/** Shared, sanitized presentation for report prose (not the editable task plan). */
|
||||
@Component({
|
||||
selector: 'app-report-markdown',
|
||||
standalone: true,
|
||||
template: `<div class="report-prose" [class.compact]="compact()" [innerHTML]="html()"></div>`,
|
||||
styles: [`
|
||||
:host { display: block; min-width: 0; max-width: 100%; }
|
||||
.report-prose { color: inherit; font-size: 13px; font-weight: 400; line-height: 1.7; white-space: normal; overflow-wrap: anywhere; word-break: normal; }
|
||||
.compact { max-height: 170px; overflow: hidden; }
|
||||
:host ::ng-deep .report-prose {
|
||||
> div, p { margin: 4px 0; }
|
||||
> :first-child { margin-top: 0; }
|
||||
> :last-child { margin-bottom: 0; }
|
||||
h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: #0f172a; }
|
||||
h1 { font-size: 18px; } h2 { font-size: 16px; } h3,h4,h5,h6 { font-size: 14px; }
|
||||
strong { font-weight: 600; }
|
||||
ul,ol { margin: 6px 0; padding-left: 24px; }
|
||||
li { margin: 4px 0; }
|
||||
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid #93c5fd; background: #f8fafc; }
|
||||
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: #f1f5f9; }
|
||||
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: #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; }
|
||||
th,td { border: 1px solid #cbd5e1; padding: 7px 9px; text-align: left; vertical-align: top; }
|
||||
th { background: #eff6ff; font-weight: 600; }
|
||||
.md-para-gap { height: 4px; margin: 0; }
|
||||
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 12px 0; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ReportMarkdownComponent {
|
||||
content = input('');
|
||||
compact = input(false);
|
||||
html = computed(() => renderMarkdownToHtml(this.content()));
|
||||
}
|
||||
@@ -3,18 +3,19 @@ import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-test-result-card',
|
||||
standalone: true,
|
||||
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
|
||||
imports: [ReportMarkdownComponent, TestResultBadgeComponent, DatePipe, FormsModule],
|
||||
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<p class="reason">{{result().reason}}</p>
|
||||
<app-report-markdown class="reason" [content]="result().reason" />
|
||||
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
|
||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
||||
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
|
||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol></details>}
|
||||
@if(canReview()){
|
||||
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||
@if(reviewing()){
|
||||
|
||||
Reference in new issue
Block a user