fix: preserve cross-device evidence and render recorded reports

This commit is contained in:
chenmingxuan committed 2026-10-17 16:05:58 +08:00
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());
});
@@ -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()){