feat: release 1.1.0 with responsive task control
This commit is contained in:
1 parent
057f0c3050
commit
bfcd828fa8
37 files changed
+1601
-151
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -331,7 +331,7 @@ limitations under the License.
|
||||
</header>
|
||||
|
||||
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
||||
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
|
||||
@if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
|
||||
<div class="stream-floating-toolbar">
|
||||
<!-- Active Architecture Chip: click opens the run info popover -->
|
||||
@if (agentService.viewedModel(); as model) {
|
||||
@@ -492,6 +492,7 @@ limitations under the License.
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<ng-content select="[task-guidance]"></ng-content>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -568,6 +569,13 @@ limitations under the License.
|
||||
>
|
||||
<!-- Unified Step / Stream Body -->
|
||||
<div class="step-body">
|
||||
@if (block.data?.extra_metadata?.injected_instruction; as guidance) {
|
||||
<div class="guidance-step-note">
|
||||
<div class="guidance-step-title"><span class="material-symbols-outlined">forum</span>用户指导 · 已进入第 {{block.data.step_number}} 步</div>
|
||||
<p>{{ guidance }}</p>
|
||||
<small>{{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }}</small>
|
||||
</div>
|
||||
}
|
||||
<!-- Check block (verifier lane): header, then the declared check items
|
||||
and how each is judged; the chronological stream follows, and the
|
||||
result rows close the block (see check-result below). -->
|
||||
@@ -746,7 +754,7 @@ limitations under the License.
|
||||
@if (getReportStatusExplanation(item.data); as expText) {
|
||||
<div class="grid-item full-width">
|
||||
<span class="grid-label">报告与发现</span>
|
||||
<span class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></span>
|
||||
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1283,6 +1291,23 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (awaitingGuidance().length) {
|
||||
<div class="guidance-queue" aria-label="待处理用户指导">
|
||||
@for (item of awaitingGuidance(); track item.id) {
|
||||
<div class="guidance-queue-item">
|
||||
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
|
||||
<p>{{ item.instruction }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (terminalFailureReason(); as reason) {
|
||||
<div class="session-failure-summary" role="status">
|
||||
<strong>任务执行失败 · 原因</strong>
|
||||
<p>{{ reason }}</p>
|
||||
<button type="button" (click)="isAutomationOpen.set(true)">查看完整报告</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
|
||||
@if (outputterReport(); as outReport) {
|
||||
<div class="outputter-synthesis-stream-card">
|
||||
|
||||
@@ -1921,6 +1921,8 @@ $stream-row-height: 25px;
|
||||
}
|
||||
|
||||
&.task-report-card {
|
||||
width: min(680px, 100%);
|
||||
box-sizing: border-box;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
|
||||
@@ -1967,12 +1969,13 @@ $stream-row-height: 25px;
|
||||
}
|
||||
|
||||
.report-markdown-body {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #1f2937;
|
||||
font-weight: 400;
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
p {
|
||||
margin: 3px 0;
|
||||
@@ -2227,11 +2230,19 @@ $stream-row-height: 25px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.grid-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
.grid-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
|
||||
&.report-markdown-body {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&.code-pill {
|
||||
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
|
||||
@@ -3971,3 +3982,48 @@ $stream-row-height: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.guidance-step-note, .guidance-queue-item {
|
||||
padding: 10px 13px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
.guidance-step-title { display: flex; align-items: center; gap: 6px; font-weight: 650; }
|
||||
.material-symbols-outlined { font-size: 17px; }
|
||||
p { margin: 5px 0; white-space: pre-wrap; color: #1e293b; }
|
||||
small { color: #475569; }
|
||||
}
|
||||
.guidance-queue { display: grid; gap: 8px; padding: 12px 16px; }
|
||||
.guidance-queue-item strong { font-size: 12px; }
|
||||
.session-failure-summary {
|
||||
margin: 12px 16px 28px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fecdd3;
|
||||
border-radius: 10px;
|
||||
background: #fff7f8;
|
||||
color: #881337;
|
||||
strong { font-size: 13px; }
|
||||
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; }
|
||||
}
|
||||
:host ::ng-deep .report-markdown-body {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
> div { margin: 3px 0; }
|
||||
h1, h2, h3 { margin: 12px 0 6px; font-size: 14px; color: #0f172a; }
|
||||
ul, ol { padding-left: 22px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
pre { overflow: auto; white-space: pre-wrap; padding: 10px; border-radius: 7px; background: #e2e8f0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 7px 9px; border: 1px solid #cbd5e1; text-align: left; vertical-align: top; }
|
||||
th { background: #dbeafe; }
|
||||
.md-para-gap { height: 8px; }
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
|
||||
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
|
||||
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
||||
@@ -247,6 +247,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
private http = inject(HttpClient);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private zone = inject(NgZone);
|
||||
public terminalFailureReason = signal('');
|
||||
private selectedTerminalStatus = computed(() => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
return this.agentService.sessions().find(session => session.session_id === id)?.status || '';
|
||||
});
|
||||
|
||||
// Auto-scroll state tracking
|
||||
public isUserAtBottom = true;
|
||||
@@ -474,6 +479,23 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return consolidateLogsToBlocks(this.filteredLogs());
|
||||
});
|
||||
|
||||
public awaitingGuidance = computed<GuidanceEvent[]>(() => {
|
||||
const steps = this.consolidatedBlocks().filter(block => block.type === 'step');
|
||||
return (this.agentService.guidanceEvents?.() || []).filter(event => !steps.some(block =>
|
||||
new Date(block.timestamp).getTime() >= event.timestamp * 1000
|
||||
&& String(block.data?.extra_metadata?.injected_instruction || '').includes(event.instruction)
|
||||
));
|
||||
});
|
||||
|
||||
public guidanceStatusLabel(event: GuidanceEvent): string {
|
||||
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
|
||||
.includes(this.agentService.currentSession()?.status || '');
|
||||
if (event.status === 'pending') {
|
||||
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
|
||||
}
|
||||
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
||||
}
|
||||
|
||||
public phases = computed<PhaseBlock[]>(() => {
|
||||
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||
@@ -541,6 +563,17 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
const status = this.selectedTerminalStatus();
|
||||
this.terminalFailureReason.set('');
|
||||
if (!id || !['failed', 'interrupted'].includes(status || '')) return;
|
||||
const request = this.http.get<{error?: string}>(`/api/automation/reports/${encodeURIComponent(id)}`).subscribe({
|
||||
next: report => this.terminalFailureReason.set(report.error || '任务未完成;请查看已记录的执行步骤。'),
|
||||
error: () => this.terminalFailureReason.set('任务未完成,暂时无法读取异常详情。')
|
||||
});
|
||||
onCleanup(() => request.unsubscribe());
|
||||
});
|
||||
// Run info popover: fetch usage when opened or when the viewed session
|
||||
// changes, and keep refreshing while that session is still running.
|
||||
effect((onCleanup) => {
|
||||
@@ -1327,6 +1360,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return isAndroidAction(action);
|
||||
}
|
||||
|
||||
public hasRecordedDeviceAction(action: any): boolean {
|
||||
const actions = Array.isArray(action) ? action : action ? [action] : [];
|
||||
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
|
||||
}
|
||||
|
||||
public getActionIcon(action: any): string {
|
||||
return getActionIcon(action);
|
||||
}
|
||||
@@ -1813,6 +1851,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public hasVisibleContent(block: any): boolean {
|
||||
if (!block) return false;
|
||||
if (block.type === 'checker') return true;
|
||||
if (block.data?.extra_metadata?.injected_instruction) return true;
|
||||
const hasNative = Boolean(this.getNativeThinking(block));
|
||||
const hasRaw = Boolean(this.getRawThinking(block));
|
||||
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
|
||||
|
||||
+26
-16
@@ -12,27 +12,32 @@
|
||||
</section>
|
||||
<section><h3>当前任务报告</h3>
|
||||
@if (report(); as r) {
|
||||
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
|
||||
<div class="report-overview">
|
||||
<strong class="report-title">{{r.name}}</strong>
|
||||
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div>
|
||||
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||
</div>
|
||||
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</p></div></div>
|
||||
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
|
||||
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
|
||||
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
|
||||
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
|
||||
@if (liveTask()) {
|
||||
<div class="task-guidance">
|
||||
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
||||
<p>运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。</p>
|
||||
<label for="task-guidance-text">补充信息或调整方向</label>
|
||||
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
||||
<div class="actions">
|
||||
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(r.id)">暂停当前任务</button>}
|
||||
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(r.id)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
|
||||
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(r.id)">直接继续</button>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
||||
<pre class="preview">{{r.content}}</pre>
|
||||
<div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div>
|
||||
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
||||
@if (liveTask() && agent.currentSessionId()) {
|
||||
<div class="task-guidance">
|
||||
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
||||
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p>
|
||||
<label for="task-guidance-text">补充信息或调整方向</label>
|
||||
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
||||
<div class="actions">
|
||||
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(agent.currentSessionId()!)">暂停当前任务</button>}
|
||||
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(agent.currentSessionId()!)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
|
||||
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(agent.currentSessionId()!)">直接继续</button>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (tab() !== 'reports') {
|
||||
@@ -53,7 +58,12 @@
|
||||
}
|
||||
</section>
|
||||
<section><h3>批次记录</h3>
|
||||
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>}
|
||||
@for(b of batches(); track b.id){
|
||||
<div class="batch-record">
|
||||
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
|
||||
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button>
|
||||
</div>
|
||||
}
|
||||
@if(!batches().length){<p>暂无批次。</p>}
|
||||
</section>
|
||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||
|
||||
@@ -17,8 +17,26 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
||||
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
||||
.task-guidance label{margin:8px 0 4px;}
|
||||
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
|
||||
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
||||
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
|
||||
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
|
||||
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
|
||||
.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
|
||||
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
|
||||
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
|
||||
h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
|
||||
> div{margin:3px 0;}
|
||||
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
|
||||
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
|
||||
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
|
||||
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
|
||||
.md-para-gap{height:8px;}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { AutomationPanelComponent } from './automation-panel.component';
|
||||
|
||||
describe('AutomationPanelComponent batch deletion', () => {
|
||||
const agent = {
|
||||
currentSessionId: signal<string | null>(null),
|
||||
runningSessionId: signal<string | null>(null),
|
||||
agentStatus: signal('idle'),
|
||||
sessions: signal<any[]>([])
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
|
||||
|
||||
beforeEach(async () => {
|
||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||
http.delete.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutomationPanelComponent],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: agent },
|
||||
{ provide: SystemService, useValue: { connectedDevices: signal([]) } },
|
||||
{ provide: HttpClient, useValue: http }
|
||||
]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('confirms deletion and removes the batch from the visible list', async () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
fixture.componentInstance.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.componentInstance.batches.set([
|
||||
{ id: 'batch-1', name: '批量冒烟', status: 'completed', created_at: 100 }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[aria-label="删除批次:批量冒烟"]')).not.toBeNull();
|
||||
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
const button = fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement;
|
||||
button.click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(http.delete).toHaveBeenCalledWith('/api/automation/batches/batch-1');
|
||||
expect(fixture.componentInstance.batches()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a running batch protected from the delete control', async () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
fixture.componentInstance.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.componentInstance.batches.set([
|
||||
{ id: 'batch-2', name: '进行中', status: 'running', created_at: 100 }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.report.set({
|
||||
id: 'task-1', name: '系统设置检查', status: 'failed', verdict: '未验证',
|
||||
error: '模型连续 3 次未提供可执行工具调用', goal: '查看设置', expected: '',
|
||||
content: '## 结果\n第一行\n第二行\n\n| 项目 | 状态 |\n| --- | --- |\n| 设置 | 未验证 |'
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
|
||||
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
|
||||
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
|
||||
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
||||
@@ -16,8 +17,13 @@ export class AutomationPanelComponent {
|
||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||
batch = signal<any>(null); report = signal<any>(null);
|
||||
guidance = '';
|
||||
taskStatus = signal('');
|
||||
liveTask = signal(false);
|
||||
taskStatus = computed(() => {
|
||||
const sid = this.agent.currentSessionId();
|
||||
if (!sid) return '';
|
||||
return this.agent.sessions().find(s => s.session_id === sid)?.status
|
||||
|| (this.agent.runningSessionId() === sid ? this.agent.agentStatus() : '') || '';
|
||||
});
|
||||
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
|
||||
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
||||
private destroyed = false;
|
||||
constructor() {
|
||||
@@ -33,6 +39,7 @@ 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:'正在生成报告'} as Record<string,string>)[s] || s; }
|
||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||
async action(fn: () => Promise<void>) {
|
||||
if (this.busy()) return;
|
||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||
@@ -46,11 +53,6 @@ export class AutomationPanelComponent {
|
||||
this.batches.set(b);
|
||||
const sid = this.agent.currentSessionId();
|
||||
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
|
||||
if (sid) {
|
||||
const current = this.agent.sessions().find(s => s.session_id === sid);
|
||||
this.taskStatus.set(current?.status || this.report()?.status || '');
|
||||
this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || ''));
|
||||
} else { this.taskStatus.set(''); this.liveTask.set(false); }
|
||||
const id = this.batch()?.id;
|
||||
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
|
||||
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
|
||||
@@ -70,12 +72,25 @@ export class AutomationPanelComponent {
|
||||
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
||||
}); }
|
||||
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
||||
deleteBatch(item: { id: string; name: string }) {
|
||||
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return;
|
||||
void this.action(async () => {
|
||||
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
|
||||
if (this.batch()?.id === item.id) this.batch.set(null);
|
||||
await this.refresh();
|
||||
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看');
|
||||
});
|
||||
}
|
||||
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
||||
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
|
||||
guideTask(id: string) { void this.action(async () => {
|
||||
if (!this.guidance.trim()) throw Error('请先填写指导内容');
|
||||
const status = this.taskStatus();
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
|
||||
this.guidance = ''; this.notice.set('指导已提交;运行中将在下一步生效,已暂停的任务会重新排队恢复'); await this.refresh();
|
||||
this.guidance = '';
|
||||
this.agent.fetchGuidance(id);
|
||||
if (status === 'paused') this.notice.set('模型请求暂停,请在执行日志中恢复模型调用');
|
||||
await this.refresh();
|
||||
}); }
|
||||
continueTask(id: string) { void this.action(async () => {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
|
||||
|
||||
@@ -17,7 +17,43 @@ limitations under the License.
|
||||
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
||||
<!-- Left Main Panel: Raw Agent Log Stream -->
|
||||
<main class="left-panel glass-panel">
|
||||
<app-agent-stream></app-agent-stream>
|
||||
<app-agent-stream>
|
||||
@if (guidanceStatus(); as status) {
|
||||
<div task-guidance class="task-guidance-control">
|
||||
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
|
||||
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
|
||||
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
|
||||
<span class="material-symbols-outlined">edit_note</span>
|
||||
指导任务
|
||||
</button>
|
||||
@if (guidanceOpenId() === agentService.currentSessionId()) {
|
||||
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
|
||||
<div class="guidance-heading">
|
||||
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
|
||||
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
|
||||
</div>
|
||||
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
||||
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
||||
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
|
||||
<div class="guidance-actions">
|
||||
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
||||
@if (status === 'running') {
|
||||
<button type="button" (click)="pauseCurrentTask()"
|
||||
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
|
||||
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
|
||||
</button>
|
||||
}
|
||||
@if (status === 'task_paused') {
|
||||
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
|
||||
}
|
||||
<button type="button" class="guidance-submit" (click)="submitGuidance()"
|
||||
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</app-agent-stream>
|
||||
|
||||
<!-- Bottom Floating Command Bar Dock -->
|
||||
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
||||
|
||||
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.task-guidance-control {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.guidance-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(226, 232, 240, .85);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
.material-symbols-outlined { font-size: 16px; }
|
||||
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
|
||||
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
|
||||
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.guidance-trigger {
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
.material-symbols-outlined { font-size: 16px; }
|
||||
}
|
||||
}
|
||||
|
||||
.task-guidance-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 9px);
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
padding: 15px;
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
|
||||
.guidance-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
|
||||
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
|
||||
}
|
||||
|
||||
label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
max-height: 160px;
|
||||
padding: 9px 10px;
|
||||
resize: vertical;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
|
||||
}
|
||||
|
||||
.guidance-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
span { flex: 1 0 100%; color: #64748b; }
|
||||
button {
|
||||
min-height: 32px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
|
||||
&:disabled { opacity: .55; cursor: not-allowed; }
|
||||
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================= */
|
||||
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
|
||||
/* ========================================================================= */
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
import { WorkspaceComponent } from './workspace.component';
|
||||
|
||||
@Component({ selector: 'app-agent-stream', standalone: true,
|
||||
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
|
||||
class StreamStub {}
|
||||
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
|
||||
class ChatStub {}
|
||||
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
|
||||
class VideoStub {}
|
||||
|
||||
describe('Workspace task guidance', () => {
|
||||
const currentId = signal<string | null>('running-task');
|
||||
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||
const agent = {
|
||||
currentSessionId: currentId,
|
||||
currentSession: computed(() => currentId() === session().session_id ? session() : null),
|
||||
runningSessionId: signal<string | null>('running-task'),
|
||||
agentStatus: signal('running'),
|
||||
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
|
||||
fetchStatus: jasmine.createSpy('fetchStatus'),
|
||||
fetchSessions: jasmine.createSpy('fetchSessions'),
|
||||
fetchGuidance: jasmine.createSpy('fetchGuidance')
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
|
||||
|
||||
beforeEach(async () => {
|
||||
currentId.set('running-task');
|
||||
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||
agent.runningSessionId.set('running-task');
|
||||
http.post.and.returnValue(of({}));
|
||||
http.post.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceComponent],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: agent },
|
||||
{ provide: HttpClient, useValue: http }
|
||||
]
|
||||
}).overrideComponent(WorkspaceComponent, {
|
||||
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
|
||||
add: { imports: [StreamStub, ChatStub, VideoStub] }
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
expect(panel.textContent).toContain('指导当前任务');
|
||||
expect(panel.textContent).toContain('下方输入框用于开始新任务');
|
||||
|
||||
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
|
||||
pause.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
|
||||
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
const input = reopened.querySelector('textarea')!;
|
||||
input.value = '先确认设置首页';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
|
||||
submit.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
|
||||
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
|
||||
|
||||
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
|
||||
});
|
||||
|
||||
it('continues a paused task without creating a new task', async () => {
|
||||
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
|
||||
.find(b => b.textContent?.includes('直接继续'))!;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
|
||||
});
|
||||
|
||||
it('does not offer task resume for a paused model request', () => {
|
||||
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
expect(panel.textContent).toContain('模型调用暂停');
|
||||
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
|
||||
expect(panel.textContent).not.toContain('直接继续');
|
||||
expect(panel.textContent).not.toContain('提交并恢复');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace',
|
||||
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
private zone = inject(NgZone);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||
private guidanceDrafts = new Map<string, string>();
|
||||
public guidanceBusy = signal(false);
|
||||
public guidanceOpenId = signal<string | null>(null);
|
||||
public pauseRequestId = signal<string | null>(null);
|
||||
|
||||
public get taskGuidance(): string {
|
||||
const id = this.agentService.currentSessionId();
|
||||
return id ? this.guidanceDrafts.get(id) || '' : '';
|
||||
}
|
||||
|
||||
public set taskGuidance(value: string) {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (id) this.guidanceDrafts.set(id, value);
|
||||
}
|
||||
|
||||
public guidanceStatus = computed(() => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id) return null;
|
||||
const status = this.agentService.currentSession()?.status
|
||||
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
|
||||
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
|
||||
});
|
||||
|
||||
public toggleGuidance(): void {
|
||||
const id = this.agentService.currentSessionId();
|
||||
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
|
||||
}
|
||||
|
||||
// Expand States (Signals for 0-latency reactivity)
|
||||
public isHoveringCard = signal<boolean>(false);
|
||||
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
|
||||
return this.agentService.isCurrentSessionRunning();
|
||||
});
|
||||
|
||||
public async pauseCurrentTask(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
|
||||
this.pauseRequestId.set(id);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async submitGuidance(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
const instruction = this.taskGuidance.trim();
|
||||
const status = this.guidanceStatus();
|
||||
if (!id || !status || !instruction || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
|
||||
this.guidanceDrafts.delete(id);
|
||||
this.pauseRequestId.set(null);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchGuidance(id);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async continueCurrentTask(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
|
||||
this.pauseRequestId.set(null);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private showGuidanceError(error: unknown): void {
|
||||
const response = error as { error?: { detail?: string }; message?: string };
|
||||
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic tooltip and label indicating which task will be stopped
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,15 @@ export interface StartupProgressEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface GuidanceEvent {
|
||||
id: string;
|
||||
instruction: string;
|
||||
source: string;
|
||||
timestamp: number;
|
||||
status: 'pending' | 'consumed';
|
||||
consumed_at?: number;
|
||||
}
|
||||
|
||||
interface SessionVideoResponse {
|
||||
session_id: string;
|
||||
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||
@@ -201,6 +210,8 @@ export class AgentService {
|
||||
|
||||
public currentSessionId = signal<string | null>(null);
|
||||
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
||||
public guidanceEvents = signal<GuidanceEvent[]>([]);
|
||||
private guidanceSignature = '';
|
||||
public isSessionContentLoading = signal<boolean>(false);
|
||||
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
||||
public runningSessionId = signal<string | null>(null);
|
||||
@@ -725,6 +736,8 @@ export class AgentService {
|
||||
this.isSessionContentLoading.set(false);
|
||||
this.currentSessionId.set(null);
|
||||
this.sessionLogs.set([]);
|
||||
this.guidanceEvents.set([]);
|
||||
this.guidanceSignature = '';
|
||||
this.currentNotes.set({});
|
||||
return;
|
||||
}
|
||||
@@ -740,6 +753,9 @@ export class AgentService {
|
||||
this.isSessionContentLoading.set(true);
|
||||
this.currentSessionId.set(sessionId);
|
||||
this.sessionLogs.set([]); // Reset logs for new session selection
|
||||
this.guidanceEvents.set([]);
|
||||
this.guidanceSignature = '';
|
||||
this.fetchGuidance(sessionId);
|
||||
this.activePauseCardKey = null;
|
||||
this.isPaused.set(false);
|
||||
this.pausedError.set(null);
|
||||
@@ -1279,6 +1295,21 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
public fetchGuidance(sessionId: string): void {
|
||||
if (this.currentSessionId() !== sessionId) return;
|
||||
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
|
||||
next: (items) => {
|
||||
if (this.currentSessionId() !== sessionId) return;
|
||||
const signature = JSON.stringify(items);
|
||||
if (signature !== this.guidanceSignature) {
|
||||
this.guidanceSignature = signature;
|
||||
this.guidanceEvents.set(items);
|
||||
}
|
||||
},
|
||||
error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
|
||||
});
|
||||
}
|
||||
|
||||
private restoreSessionsCache(): void {
|
||||
try {
|
||||
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
||||
@@ -1488,6 +1519,8 @@ export class AgentService {
|
||||
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
||||
if (this.pollCounter % 3 === 0) {
|
||||
this.fetchSessions();
|
||||
const selected = this.currentSessionId();
|
||||
if (selected) this.fetchGuidance(selected);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
|
||||
expect(html).toContain('<span class="verify-badge">verify</span>');
|
||||
expect(html).toContain('status is ok');
|
||||
});
|
||||
|
||||
it('renders Chinese report tables and preserves separate lines safely', () => {
|
||||
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
|
||||
expect(html).toContain('<h2>结论</h2>');
|
||||
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
|
||||
expect(html).toContain('<th>分类</th>');
|
||||
expect(html).toContain('<td><strong>通过</strong></td>');
|
||||
});
|
||||
});
|
||||
@@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string {
|
||||
if (inCodeBlock) {
|
||||
// End code block
|
||||
const codeContent = codeBlockLines.join('\n');
|
||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
|
||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
|
||||
inCodeBlock = false;
|
||||
codeBlockLines = [];
|
||||
codeBlockLang = '';
|
||||
@@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown result tables need real cells to remain readable in reports.
|
||||
if (trimmed.includes('|') && i + 1 < rawLines.length
|
||||
&& /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
|
||||
closeList();
|
||||
const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
||||
const headings = cells(rawLine);
|
||||
result.push('<div class="md-table-scroll"><table><thead><tr>'
|
||||
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
|
||||
+ '</tr></thead><tbody>');
|
||||
i += 2;
|
||||
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
|
||||
const row = cells(rawLines[i]);
|
||||
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
|
||||
i++;
|
||||
}
|
||||
result.push('</tbody></table></div>');
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Headings: # to ######
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
@@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string {
|
||||
|
||||
if (inCodeBlock) {
|
||||
const codeContent = codeBlockLines.join('\n');
|
||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
|
||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
|
||||
}
|
||||
|
||||
closeList();
|
||||
|
||||
Reference in new issue
Block a user