1276 lines
86 KiB
HTML
1276 lines
86 KiB
HTML
<!--
|
||
Copyright 2026 Google LLC
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
-->
|
||
|
||
<div class="agent-stream-container">
|
||
<!-- VS Code Style Navigation Header (for responsive small screens) -->
|
||
<header class="vscode-nav-header">
|
||
<div class="vscode-tab-container">
|
||
<button
|
||
#taskQueueOrigin="cdkOverlayOrigin"
|
||
cdkOverlayOrigin
|
||
class="vscode-tab-btn"
|
||
[class.active]="isTaskDropdownOpen()"
|
||
(click)="toggleTaskDropdown($event)"
|
||
title="任务队列"
|
||
aria-label="任务队列"
|
||
>
|
||
<span class="material-symbols-outlined tab-icon">list_alt</span>
|
||
<span class="tab-label">任务队列</span>
|
||
</button>
|
||
<button
|
||
#notesOrigin="cdkOverlayOrigin"
|
||
cdkOverlayOrigin
|
||
class="vscode-tab-btn"
|
||
[class.active]="isNotesDropdownOpen()"
|
||
(click)="toggleNotesDropdown($event)"
|
||
title="笔记与计划"
|
||
aria-label="笔记与计划"
|
||
>
|
||
<span class="material-symbols-outlined tab-icon">description</span>
|
||
<span class="tab-label">笔记与计划</span>
|
||
</button>
|
||
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
|
||
</div>
|
||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:var(--app-text-muted, #64748b);cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
|
||
|
||
<!-- Dropdown Popover Panel for Task Queue -->
|
||
<ng-template
|
||
cdkConnectedOverlay
|
||
[cdkConnectedOverlayOrigin]="taskQueueOrigin"
|
||
[cdkConnectedOverlayOpen]="isTaskDropdownOpen()"
|
||
[cdkConnectedOverlayHasBackdrop]="true"
|
||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||
(backdropClick)="isTaskDropdownOpen.set(false)"
|
||
[cdkConnectedOverlayPositions]="dropdownPositions"
|
||
>
|
||
<div class="vscode-dropdown-menu task-dropdown">
|
||
<div class="popover-title-header">
|
||
<span class="material-symbols-outlined">list_alt</span>
|
||
<span>任务队列</span>
|
||
</div>
|
||
<app-task-batch-filter />
|
||
@if (deleteError()) { <p class="item-error" role="alert">{{ deleteError() }}</p> }
|
||
<div class="dropdown-section-label">
|
||
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
||
<span>当前队列 ({{ activeQueue().length }})</span>
|
||
</div>
|
||
@if (activeQueue().length === 0) {
|
||
<div class="dropdown-empty-state">
|
||
|
||
没有正在执行或排队的任务
|
||
</div>
|
||
}
|
||
@for (session of activeQueue(); track trackSession($index, session)) {
|
||
<div
|
||
class="dropdown-item-card"
|
||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||
[class.running]="getTaskStatus(session) === 'running'"
|
||
[ngClass]="getTaskStatus(session)"
|
||
(click)="selectTask(session.session_id, $event)"
|
||
>
|
||
<div class="item-header">
|
||
<div class="task-status-group">
|
||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||
@if (getTaskStatus(session) === 'running') {
|
||
<span class="status-dot"></span>
|
||
}
|
||
{{ taskPhaseLabel(session) || (getTaskStatus(session) | uiLabel) }}
|
||
</span>
|
||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||
</div>
|
||
<div class="item-header-actions">
|
||
@if (getDeviceSerial(session)) {
|
||
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||
</span>
|
||
}
|
||
@if (getDeviceSerial(session)) {
|
||
<span class="item-time-divider">·</span>
|
||
}
|
||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||
@if (!agentService.canDeleteSession(session) && ['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||
<button
|
||
type="button"
|
||
class="btn-stop-card"
|
||
(click)="stopTask(session.session_id, $event)"
|
||
title="停止此任务"
|
||
aria-label="停止此任务"
|
||
>
|
||
<span class="material-symbols-outlined">stop_circle</span>
|
||
</button>
|
||
}
|
||
@if (agentService.canDeleteSession(session)) {
|
||
<button
|
||
type="button"
|
||
class="btn-delete-card"
|
||
(click)="deleteTask(session.session_id, $event)"
|
||
title="删除此任务"
|
||
aria-label="删除此任务"
|
||
>
|
||
<span class="material-symbols-outlined">delete</span>
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="item-goal" [title]="session.initial_goal || ''">
|
||
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||
</div>
|
||
<app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
|
||
<div class="item-footer">
|
||
<app-task-id-copy [taskId]="session.session_id" />
|
||
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
<app-task-history />
|
||
</div>
|
||
</ng-template>
|
||
|
||
<!-- Dropdown Popover Panel for Notes & Plans -->
|
||
<ng-template
|
||
cdkConnectedOverlay
|
||
[cdkConnectedOverlayOrigin]="notesOrigin"
|
||
[cdkConnectedOverlayOpen]="isNotesDropdownOpen()"
|
||
[cdkConnectedOverlayHasBackdrop]="true"
|
||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||
(backdropClick)="isNotesDropdownOpen.set(false)"
|
||
[cdkConnectedOverlayPositions]="dropdownPositions"
|
||
>
|
||
<div class="vscode-dropdown-menu notes-dropdown">
|
||
<div class="popover-title-header">
|
||
<span class="material-symbols-outlined">description</span>
|
||
<span>笔记与计划</span>
|
||
</div>
|
||
<!-- Notes Selector / Pills (Shown if there are note files) -->
|
||
@if (noteKeys().length > 1) {
|
||
<div class="notes-selector-container">
|
||
<div class="notes-selector-list">
|
||
@for (key of noteKeys(); track trackNoteKey($index, key)) {
|
||
<button
|
||
class="note-selector-pill"
|
||
[class.active]="agentService.selectedNoteKey() === key"
|
||
(click)="selectNote(key, $event)"
|
||
>
|
||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||
{{ key }}
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (noteKeys().length === 0) {
|
||
<div class="dropdown-empty-state">
|
||
<span class="material-symbols-outlined empty-icon">description</span>
|
||
<span>此会话暂无笔记。</span>
|
||
</div>
|
||
}
|
||
<!-- Rich Notes Content Viewer -->
|
||
@if (noteKeys().length > 0) {
|
||
<div class="dropdown-notes-content">
|
||
<app-report-markdown [content]="currentNoteContent()" />
|
||
</div>
|
||
}
|
||
</div>
|
||
</ng-template>
|
||
</header>
|
||
|
||
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
||
@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) {
|
||
<button
|
||
type="button"
|
||
#runInfoOrigin="cdkOverlayOrigin"
|
||
cdkOverlayOrigin
|
||
class="model-info-chip"
|
||
[ngClass]="getModelClass(model.name)"
|
||
[class.active]="isRunInfoOpen()"
|
||
[title]="getArchitectureTooltip(model)"
|
||
aria-haspopup="dialog"
|
||
[attr.aria-expanded]="isRunInfoOpen()"
|
||
(click)="toggleRunInfo($event)"
|
||
>
|
||
<span class="material-symbols-outlined model-icon">{{ getModelIcon(model.name) }}</span>
|
||
<span class="model-label">执行模式:</span>
|
||
<span class="model-name">{{ getModelDisplayName(model.name) }}</span>
|
||
</button>
|
||
|
||
<ng-template
|
||
cdkConnectedOverlay
|
||
[cdkConnectedOverlayOrigin]="runInfoOrigin"
|
||
[cdkConnectedOverlayOpen]="isRunInfoOpen()"
|
||
[cdkConnectedOverlayHasBackdrop]="true"
|
||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||
(backdropClick)="isRunInfoOpen.set(false)"
|
||
[cdkConnectedOverlayPositions]="dropdownPositions"
|
||
>
|
||
<div class="vscode-dropdown-menu run-info-dropdown" role="dialog" aria-label="运行信息">
|
||
<div class="run-info-header">
|
||
<span class="material-symbols-outlined" [class.is-pro]="runInfoIsPro()">{{ getModelIcon(model.name) }}</span>
|
||
<span class="run-info-title">{{ getModelDisplayName(model.name) }}</span>
|
||
@if (model.id) {
|
||
<span class="run-info-model" [title]="model.id">{{ model.id }}</span>
|
||
}
|
||
</div>
|
||
|
||
<div class="run-info-row">
|
||
<span class="run-info-label">已用时间</span>
|
||
<span class="run-info-value">{{ runInfoElapsed() }}</span>
|
||
</div>
|
||
|
||
<div class="run-info-row">
|
||
<span class="run-info-label">Token 用量</span>
|
||
@if (runUsage(); as usage) {
|
||
<span class="run-info-value">
|
||
{{ formatRunTokens(usage.total_tokens) }}
|
||
<span class="run-info-sub">{{ formatRunTokens(usage.prompt_tokens) }} 输入 · {{ formatRunTokens(usage.completion_tokens) }} 输出</span>
|
||
</span>
|
||
} @else {
|
||
<span class="run-info-value is-muted">—</span>
|
||
}
|
||
</div>
|
||
|
||
<div class="run-info-row" title="本任务累计缓存输入 ÷ 累计输入,包含所有模型调用;缓存 Token 已计入输入总量。未上报的缓存输入按 0 统计。">
|
||
<span class="run-info-label">缓存命中率</span>
|
||
@if (runInfoCachePercent() !== null) {
|
||
<span class="run-info-value">
|
||
{{ runInfoCachePercent() }}%
|
||
<span class="run-info-sub">{{ formatRunTokens(runUsage()?.cached_tokens) }} 缓存 Token</span>
|
||
</span>
|
||
} @else {
|
||
<span class="run-info-value is-muted">{{ runUsageLoading() ? '正在读取…' : runUsageError() ? '读取失败' : '—' }}</span>
|
||
}
|
||
</div>
|
||
@if (runUsageError() && runUsage()) {
|
||
<div class="run-info-note" role="status">用量刷新失败,显示上次统计。</div>
|
||
}
|
||
|
||
<div class="run-info-row is-stacked">
|
||
<div class="run-info-line">
|
||
<span class="run-info-label">上下文</span>
|
||
@if (runInfoContextPercent() !== null) {
|
||
<span class="run-info-value">
|
||
{{ runInfoContextPercent() }}%
|
||
<span class="run-info-sub-inline">/ {{ formatRunTokens(runInfoContextWindow()) }}</span>
|
||
</span>
|
||
} @else {
|
||
<span class="run-info-value is-muted">—</span>
|
||
}
|
||
</div>
|
||
<div
|
||
class="run-info-meter"
|
||
[class.is-high]="(runInfoContextPercent() ?? 0) >= 70"
|
||
[class.is-critical]="(runInfoContextPercent() ?? 0) >= 90"
|
||
>
|
||
<div class="run-info-meter-fill" [style.width.%]="runInfoContextPercent() ?? 0"></div>
|
||
</div>
|
||
</div>
|
||
|
||
@if (runInfoIsPro()) {
|
||
<div class="run-info-row">
|
||
<span class="run-info-label">结果检查</span>
|
||
<span class="run-info-value">{{ runTuningLabel('verify', runInfoTuning()?.verification_level) }}</span>
|
||
</div>
|
||
<div class="run-info-row">
|
||
<span class="run-info-label">屏幕识别</span>
|
||
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
|
||
</div>
|
||
} @else if (runInfoMode() === 'explore') {
|
||
<div class="run-info-note">探索支持对话指导与人工示范,模型和时限使用独立的探索设置。</div>
|
||
} @else if (runInfoMode() === 'flash') {
|
||
<div class="run-info-note">Flash 无需额外设置。</div>
|
||
}
|
||
</div>
|
||
</ng-template>
|
||
}
|
||
<!-- Screen Recording Action Button (Interactive Button) -->
|
||
<button
|
||
class="btn-screen-recording"
|
||
[class.active]="agentService.isVideoWindowOpen()"
|
||
[class.recording]="agentService.isCurrentSessionRunning() && agentService.currentSession()?.recording_status === 'recording'"
|
||
[class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
|
||
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
|
||
[class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
|
||
[class.failed]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed'"
|
||
(click)="agentService.toggleVideoPlayer()"
|
||
[title]="getScreenRecordingButtonTitle()"
|
||
>
|
||
@if (agentService.isCurrentSessionRunning()) {
|
||
<span class="material-symbols-outlined video-icon">
|
||
{{ agentService.currentSession()?.recording_status === 'recording' ? 'videocam' : 'progress_activity' }}
|
||
</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
||
<span class="material-symbols-outlined video-icon spin-animation">
|
||
progress_activity
|
||
</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()) {
|
||
<span class="material-symbols-outlined video-icon play-icon">
|
||
smart_display
|
||
</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()) {
|
||
<span class="material-symbols-outlined video-icon steps-icon">
|
||
slideshow
|
||
</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed') {
|
||
<span class="material-symbols-outlined video-icon error-icon">
|
||
videocam_off
|
||
</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() !== 'failed') {
|
||
<span class="material-symbols-outlined video-icon">
|
||
videocam
|
||
</span>
|
||
}
|
||
@if (agentService.isCurrentSessionRunning()) {
|
||
<span class="btn-text">{{ agentService.currentSession()?.recording_status === 'recording' ? '正在录制…' : '任务执行中…' }}</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
||
<span class="btn-text">正在准备…</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()) {
|
||
<span class="btn-text">播放录像</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()) {
|
||
<span class="btn-text">步骤回放</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed') {
|
||
<span class="btn-text">录屏失败</span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() !== 'failed') {
|
||
<span class="btn-text">屏幕录像</span>
|
||
}
|
||
@if (agentService.isCurrentSessionRunning()) {
|
||
<span class="video-pulse-dot red"></span>
|
||
}
|
||
@if (!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()) {
|
||
<span class="steps-count-badge">
|
||
{{ agentService.currentSessionStepFrames().length }}
|
||
</span>
|
||
}
|
||
</button>
|
||
<ng-content select="[task-guidance]"></ng-content>
|
||
</div>
|
||
}
|
||
|
||
<!-- Event Logs Section -->
|
||
<main class="stream-logs-content">
|
||
<ng-content select="[task-summary]"></ng-content>
|
||
<app-task-lifecycle [value]="agentService.currentLifecycle()" />
|
||
<app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" />
|
||
@if (agentService.isSessionContentLoading()) {
|
||
<div class="session-loading-state" aria-live="polite">
|
||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||
<span>正在加载执行历史…</span>
|
||
</div>
|
||
}
|
||
|
||
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0 && awaitingGuidance().length === 0) {
|
||
<div class="empty-state">
|
||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||
<h3>{{ agentService.currentSessionId() ? '暂无步骤记录' : '尚未选择执行记录' }}</h3>
|
||
<p>{{ agentService.currentSessionId() ? '此任务尚未留下执行步骤。' : '请从右侧任务列表选择会话,查看实时执行记录。' }}</p>
|
||
</div>
|
||
}
|
||
|
||
<!-- The phase-based session logs list -->
|
||
@if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0 || awaitingGuidance().length > 0)) {
|
||
<div class="logs-list-container">
|
||
@if (startupWorkItems().length > 0) {
|
||
<div class="phase-container startup-phase" aria-live="polite">
|
||
<div class="phase-header first-phase">
|
||
<span class="phase-worked-time">准备耗时 {{ startupPreparationDuration() }} 秒</span>
|
||
</div>
|
||
<div class="phase-body" aria-label="任务启动进度">
|
||
<div class="block-item type-stream">
|
||
<div class="step-body">
|
||
<div class="step-actions-container">
|
||
<div class="llm-retry-notes">
|
||
@for (item of startupWorkItems(); track item) {
|
||
<div class="llm-retry-note">
|
||
<span class="stream-status-dot" [class.streaming]="item.isActive"></span>
|
||
<div class="llm-retry-note-text">
|
||
<span class="llm-retry-status-line">
|
||
{{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
@for (phase of phases(); track trackPhase($index, phase); let first = $first) {
|
||
@if(phase.demonstration; as demo) {
|
||
<article class="exploration-message demonstration"><strong>人工示范 · {{demo.source === 'android' ? '安卓' : demo.source === 'computer' ? '电脑' : '安卓 + 电脑'}} · {{demoLabel(demo.status)}}</strong>
|
||
@if(demo.analysis){<div class="markdown-content" [innerHTML]="renderMarkdown(demo.analysis.summary, phase.id)"></div>}
|
||
@if(demo.error){<p class="error">{{demo.error}}</p>}
|
||
@for(track of demo.media; track $index){@for(segment of track.segments; track segment.file){<details><summary>{{track.kind === 'android' ? '安卓' : '电脑'}}示范录像 · 片段 {{$index + 1}}</summary><video controls preload="none" [src]="demoMediaUrl(demo.run_id,segment.file)"></video><a [href]="demoMediaUrl(demo.run_id,segment.file)" download>下载录像</a></details>}}
|
||
</article>
|
||
} @else if(phase.conversation; as message) {
|
||
<article class="exploration-message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'" [class.question]="pendingQuestion() === message.id" [class.system]="message.role === 'system'">
|
||
<small><span class="material-symbols-outlined">{{message.role === 'user' ? 'person' : message.role === 'assistant' ? 'auto_awesome' : 'info'}}</span>{{message.role === 'user' ? '你的指导' : message.role === 'assistant' ? '探索助手' : '探索记录'}} · {{message.created_at * 1000 | date:'HH:mm:ss'}} @if(pendingQuestion() === message.id){<b>等待你回答</b>}</small>
|
||
<div class="markdown-content" [innerHTML]="renderMarkdown(message.text, phase.id)"></div>
|
||
@if(message.recommendation){<div class="recommendation">建议回复:{{message.recommendation}} @if(pendingQuestion() === message.id){<button (click)="useRecommendation.emit(message.recommendation)">填入回复</button>}</div>}
|
||
</article>
|
||
} @else {
|
||
<div class="phase-container">
|
||
@if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
|
||
<div
|
||
class="phase-header"
|
||
[class.first-phase]="first && startupWorkItems().length === 0"
|
||
>
|
||
<span class="phase-worked-time">{{ isCheckerPhase(phase) ? 'Checked' : 'Worked' }} for {{ phase.durationSeconds }}s</span>
|
||
@if (phase.tokens) {
|
||
<span class="phase-token-usage" [title]="getTokenTooltip(phase)">
|
||
<span class="token-dot">·</span>
|
||
<span class="token-text">{{ formatTokenCount(phase.tokens) }}</span>
|
||
</span>
|
||
}
|
||
</div>
|
||
}
|
||
@if (hasVisibleBlocks(phase)) {
|
||
<div class="phase-body">
|
||
@for (block of phase.blocks; track trackBlock($index, block)) {
|
||
@if (hasVisibleContent(block)) {
|
||
<div
|
||
class="block-item"
|
||
[ngClass]="['type-' + block.type]"
|
||
>
|
||
<!-- 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). -->
|
||
@if (isCheckerBlock(block)) {
|
||
@if (block.data.phase === 'outcome' && block.data.test_result) {
|
||
<app-test-result-card [result]="agentService.currentSession()?.test_result || block.data.test_result" [sessionId]="agentService.currentSessionId()" [executionStatus]="agentService.currentSession()?.status || ''" (reviewSaved)="agentService.applyCaseReview($event)" />
|
||
} @else {
|
||
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
||
<div class="check-header">
|
||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||
<span class="check-title">{{ getCheckerPhaseLabel(block) }}</span>
|
||
@if (block.data.phase !== 'outcome' && block.data.subgoal_text) {
|
||
<span class="check-subject" [title]="block.data.subgoal_text">{{ block.data.subgoal_text }}</span>
|
||
}
|
||
<span class="check-status">{{ getCheckerStatusLabel(block) }}</span>
|
||
</div>
|
||
@if (!isCheckerMuted(block) && getCheckerItems(block).length > 0) {
|
||
<ol class="check-items">
|
||
@for (item of getCheckerItems(block); track $index) {
|
||
<li class="check-item">
|
||
<span class="check-index">{{ $index + 1 }}</span>
|
||
<span class="check-text">{{ item.text }}</span>
|
||
<span class="check-method">{{ getCheckMethodLabel(item, block) }}</span>
|
||
</li>
|
||
}
|
||
</ol>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
||
@if (getSortedStepEvents(block.data).length > 0) {
|
||
<div class="step-actions-container">
|
||
@for (item of getSortedStepEvents(block.data); track trackStepEvent(itemIdx, item); let itemIdx = $index) {
|
||
<!-- Check block: one Thought / Work entry per streamed turn, in time order with its tool calls -->
|
||
@if (isCheckerBlock(block) && item.type === 'thinking') {
|
||
<div
|
||
class="thinking-stream-details native-thinking-details"
|
||
[class.is-running]="isCheckerSegmentRunning(block, itemIdx) && !isCheckerSegmentResetNotice(item)"
|
||
[class.is-resetting]="isCheckerSegmentResetNotice(item)"
|
||
[class.collapsed]="isCheckerSegmentCollapsed(block, itemIdx)">
|
||
<div class="details-summary" (click)="toggleCheckerSegment(block, itemIdx)">
|
||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx) && !isCheckerSegmentResetNotice(item)" [class.resetting]="isCheckerSegmentResetNotice(item)"></span>
|
||
<span class="header-label">{{ isCheckerSegmentResetNotice(item) ? getCheckerSegmentResetMessage(item) : '思考过程' }}</span>
|
||
</div>
|
||
@if (item.data.text && item.data.text.trim()) {
|
||
<div class="details-content-wrapper">
|
||
<div class="details-content stream-box">
|
||
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
@if (isCheckerBlock(block) && item.type === 'text' && isCheckerTextVisible(item)) {
|
||
@if (item.data.isReset) {
|
||
<div class="llm-retry-notes">
|
||
<div class="llm-retry-note">
|
||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
|
||
<div class="llm-retry-note-text">
|
||
<span class="llm-retry-status-line">{{ formatResetMessage(getCheckerSegmentResetMessage(item), isCheckerSegmentRunning(block, itemIdx)) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (item.data.text && item.data.text.trim()) {
|
||
<div
|
||
class="step-raw-thinking-message"
|
||
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)">
|
||
<div class="report-header">
|
||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
|
||
<span class="header-label">执行过程</span>
|
||
</div>
|
||
<div class="report-content stream-box">
|
||
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
<!-- Native Thinking (Thought) -->
|
||
@if (!isCheckerBlock(block) && item.type === 'thinking') {
|
||
@if (getBlockResets(block, true).length > 0) {
|
||
<div class="llm-retry-notes">
|
||
@for (reset of getBlockResets(block, true); track reset.id || $index) {
|
||
<div class="llm-retry-note">
|
||
<span class="stream-status-dot" [class.streaming]="reset.isWaiting"></span>
|
||
<div class="llm-retry-note-text">
|
||
<span class="llm-retry-status-line">{{ formatResetMessage(reset.message, reset.isWaiting) }}</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
@if (getStreamDisplayText(block.id + '-native', item.data.text) || (isBlockRunning(block) && !isStreamRewinding(block.id + '-native') && !hasWaitingReset(block, true))) {
|
||
<div
|
||
class="thinking-stream-details native-thinking-details"
|
||
[class.is-running]="isBlockRunning(block)"
|
||
[class.collapsed]="collapsedStreams().has(block.data.execution_id || block.data.step_id || block.id)">
|
||
<div class="details-summary" (click)="toggleCollapse(block.data.execution_id || block.data.step_id || block.id)">
|
||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||
<span class="header-label">思考过程</span>
|
||
</div>
|
||
@if (getStreamDisplayText(block.id + '-native', item.data.text)) {
|
||
<div class="details-content-wrapper">
|
||
<div class="details-content stream-box">
|
||
<div class="stream-text" [innerHTML]="renderMarkdown(getStreamDisplayText(block.id + '-native', item.data.text), block.id + '-native')"></div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
<!-- Raw Thinking / Plan / Message (Work) -->
|
||
@if (!isCheckerBlock(block) && item.type === 'text') {
|
||
@if (getBlockResets(block, false).length > 0) {
|
||
<div class="llm-retry-notes">
|
||
@for (reset of getBlockResets(block, false); track reset.id || $index) {
|
||
<div class="llm-retry-note">
|
||
<span class="stream-status-dot" [class.streaming]="reset.isWaiting"></span>
|
||
<div class="llm-retry-note-text">
|
||
<span class="llm-retry-status-line">{{ formatResetMessage(reset.message, reset.isWaiting) }}</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
@if (getStreamDisplayText(block.id, item.data.text) || (isBlockRunning(block) && !isStreamRewinding(block.id) && !hasWaitingReset(block, false))) {
|
||
<div
|
||
class="step-raw-thinking-message"
|
||
[class.is-running]="isBlockRunning(block)">
|
||
<div class="report-header">
|
||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||
<span class="header-label">执行过程</span>
|
||
</div>
|
||
@if (getStreamDisplayText(block.id, item.data.text)) {
|
||
<div class="report-content stream-box">
|
||
<div class="stream-text" [innerHTML]="renderMarkdown(getStreamDisplayText(block.id, item.data.text), block.id)"></div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
<!-- Task Submission / Report Card (when item is report_task_status action or tool) -->
|
||
@if (isReportStatusAction(item.data)) {
|
||
<div class="step-android-action-card task-report-card"
|
||
[class.is-completed]="getReportStatusValue(item.data) === 'completed' || getReportStatusValue(item.data) === 'success'"
|
||
[class.is-failed]="getReportStatusValue(item.data) === 'failed'"
|
||
[class.is-expanded]="isActionExpanded('action-' + block.id + '-' + itemIdx)"
|
||
>
|
||
<div class="action-card-header" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">
|
||
<span class="material-symbols-outlined action-icon report-icon" [class.is-failed]="getReportStatusValue(item.data) === 'failed'">
|
||
{{ getReportStatusValue(item.data) === 'failed' ? 'cancel' : 'task_alt' }}
|
||
</span>
|
||
<span class="action-title">
|
||
{{ getReportStatusValue(item.data) === 'failed' ? '任务执行失败' : '任务已完成' }}
|
||
</span>
|
||
<span class="action-status-badge" [class.is-failed]="getReportStatusValue(item.data) === 'failed'" [class.is-completed]="getReportStatusValue(item.data) !== 'failed'">
|
||
<span class="material-symbols-outlined status-icon">
|
||
{{ getReportStatusValue(item.data) === 'failed' ? 'error' : 'check_circle' }}
|
||
</span>
|
||
{{ getReportStatusValue(item.data) === 'failed' ? '失败' : '已完成' }}
|
||
</span>
|
||
<span class="action-expand-icon material-symbols-outlined">
|
||
{{ isActionExpanded('action-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||
</span>
|
||
</div>
|
||
<!-- Compact preview body (when collapsed, matches standard action card) -->
|
||
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) {
|
||
<div class="action-card-body">
|
||
<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) -->
|
||
@if (isActionExpanded('action-' + block.id + '-' + itemIdx)) {
|
||
<div class="action-card-expanded-details" (click)="$event.stopPropagation()">
|
||
<div class="expanded-grid">
|
||
@if (getReportStatusExplanation(item.data); as expText) {
|
||
<div class="grid-item full-width">
|
||
<span class="grid-label">报告与发现</span>
|
||
<app-report-markdown [content]="expText" />
|
||
</div>
|
||
}
|
||
</div>
|
||
<!-- Final Verified Screenshot -->
|
||
@if (getStepPreImageUrl(block.data, item.data) || getStepPostImageUrl(block.data, item.data)) {
|
||
<div class="expanded-screenshots-container">
|
||
@if (getStepPreImageUrl(block.data, item.data)) {
|
||
<div class="screenshot-column">
|
||
<span class="screenshot-label">最终验证状态</span>
|
||
<div class="image-wrapper" (click)="openImageModal(getStepPreImageUrl(block.data, item.data)!, item.data, $event)">
|
||
<div class="img-relative-box">
|
||
<img [src]="getStepPreImageUrl(block.data, item.data)" alt="最终屏幕截图" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (getStepPostImageUrl(block.data, item.data)) {
|
||
<div class="screenshot-column">
|
||
<span class="screenshot-label">操作后验证</span>
|
||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||
<div class="img-relative-box">
|
||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Android Action Card (when item is an action) -->
|
||
@if (item.type === 'action' && !isReportStatusAction(item.data) && (isAndroidAction(item.data) || item.data?.type === 'action')) {
|
||
<div class="step-android-action-card"
|
||
[class.is-running]="isItemRunning(item, block)"
|
||
[class.is-failed]="isActionFailed(item.data, block.data)"
|
||
[class.is-expanded]="isActionExpanded('action-' + block.id + '-' + itemIdx)"
|
||
>
|
||
<div class="action-card-header" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">
|
||
<span class="material-symbols-outlined action-icon" [class.is-failed]="isActionFailed(item.data, block.data)">{{ getActionIcon(item.data) }}</span>
|
||
<span class="action-title">{{ getActionTitle(item.data) }}</span>
|
||
<button class="evidence-source" [title]="evidenceSourceLabel(item.data) + ' · 查看对应录像'" (click)="openEvidenceVideo(item.data, block.data, $event)">{{ evidenceSourceLabel(item.data) }}</button>
|
||
@if (isActionFailed(item.data, block.data)) {
|
||
<span class="action-status-badge is-failed">
|
||
<span class="material-symbols-outlined status-icon">error</span>
|
||
|
||
失败
|
||
</span>
|
||
}
|
||
<span class="action-expand-icon material-symbols-outlined">
|
||
{{ isActionExpanded('action-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||
</span>
|
||
</div>
|
||
<!-- Compact preview body (when collapsed) -->
|
||
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && (getActionTargetText(item.data) || getActionInputText(item.data) || getActionCoords(item.data))) {
|
||
<div class="action-card-body">
|
||
@if (getActionTargetText(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">目标:</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>
|
||
<app-expandable-value class="detail-value" label="输入" [value]="getActionInputText(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getActionCoords(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">坐标:</span>
|
||
<span class="detail-value coordinate-badge">{{ getActionCoords(item.data) }}</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Detailed expanded view (when clicked) -->
|
||
@if (isActionExpanded('action-' + block.id + '-' + itemIdx)) {
|
||
<div class="action-card-expanded-details" (click)="$event.stopPropagation()">
|
||
<div class="expanded-grid">
|
||
@if (getActionTargetText(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">目标</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>
|
||
<app-expandable-value label="输入" [value]="getActionInputText(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getActionCoords(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">坐标</span>
|
||
<span class="grid-value code-pill">{{ getActionCoords(item.data) }}</span>
|
||
</div>
|
||
}
|
||
@if (getActionBounds(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">目标边界</span>
|
||
<span class="grid-value code-pill">[{{ getActionBounds(item.data) }}]</span>
|
||
</div>
|
||
}
|
||
@if (getActionClass(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">目标类型</span>
|
||
<span class="grid-value code-pill">{{ getActionClass(item.data) }}</span>
|
||
</div>
|
||
}
|
||
@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>
|
||
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||
</div>
|
||
}
|
||
<!-- Fallback item if all detail fields are empty -->
|
||
@if (!getActionTargetText(item.data) && !getActionInputText(item.data) && !getActionCoords(item.data) && !getActionBounds(item.data) && !getActionClass(item.data) && getActionExtraParams(item.data).length === 0) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">操作</span>
|
||
<span class="grid-value">{{ getActionTitle(item.data) }}</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
<!-- Action Screenshots (Pre & Post Action) -->
|
||
@if (getStepPreImageUrl(block.data, item.data) || getStepPostImageUrl(block.data, item.data)) {
|
||
<div class="expanded-screenshots-container">
|
||
@if (getStepPreImageUrl(block.data, item.data)) {
|
||
<div class="screenshot-column">
|
||
<span class="screenshot-label">操作前</span>
|
||
<div class="image-wrapper" (click)="openImageModal(getStepPreImageUrl(block.data, item.data)!, item.data, $event)">
|
||
<div class="img-relative-box">
|
||
<img #preImg [src]="getStepPreImageUrl(block.data, item.data)" (load)="onImageLoad(preImg, preOverlay, item.data)" alt="操作前截图" />
|
||
<div #preOverlay class="overlay-layer"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (getStepPostImageUrl(block.data, item.data)) {
|
||
<div class="screenshot-column">
|
||
<span class="screenshot-label">操作后</span>
|
||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||
<div class="img-relative-box">
|
||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
@if (isActionFailed(item.data, block.data)) {
|
||
<div class="expanded-error-banner">
|
||
<span class="material-symbols-outlined icon">error</span>
|
||
<span class="error-text">{{ getActionErrorMessage(item.data, block.data) }}</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Generic Tools & LLM Calls (when item is a tool) -->
|
||
@if (item.type === 'tool') {
|
||
<!-- UI-hierarchy source changed mid-run (helper <-> UIAutomator2). -->
|
||
@if (isBackendSwitchNote(item.data)) {
|
||
<div class="llm-retry-notes">
|
||
<div class="llm-retry-note">
|
||
<span class="stream-status-dot"></span>
|
||
<div class="llm-retry-note-text">
|
||
<span class="llm-retry-status-line">{{ getBackendSwitchMessage(item.data) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Only SDK-observed Gemini retries are shown as quiet stream notes. -->
|
||
@if (isLLMRetry(item.data)) {
|
||
<div class="llm-retry-notes">
|
||
@for (retry of getLLMRetryEntries(item.data); track retry; let retryIndex = $index) {
|
||
<div class="llm-retry-note">
|
||
<span class="stream-status-dot"
|
||
[class.streaming]="retryIndex === getLLMRetryEntries(item.data).length - 1 && isLLMRetryEntryWaiting(retry)"></span>
|
||
<div class="llm-retry-note-text">
|
||
@if (isLLMRetryEntryWaiting(retry)) {
|
||
<span class="llm-retry-status-line">
|
||
|
||
模型暂时不可用
|
||
@if (getLLMRetryEntryDelay(retry); as retryDelay) {
|
||
· Retrying in {{ retryDelay }}…
|
||
}
|
||
</span>
|
||
<span class="llm-retry-guidance">
|
||
|
||
频繁重试可能是接口限流导致,可提高调用额度或降低并发数。
|
||
</span>
|
||
}
|
||
@if (!isLLMRetryEntryWaiting(retry)) {
|
||
<span class="llm-retry-status-line">
|
||
Retrying LLM request · Waited {{ getLLMRetryEntryWaited(retry) }}
|
||
</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
|
||
@if (isDisplayableLLMFailure(item.data)) {
|
||
<div class="llm-failure-stack">
|
||
<div class="step-android-action-card tool-action-card is-failed"
|
||
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
|
||
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
|
||
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)">
|
||
<div class="action-card-header llm-failure-header">
|
||
<span class="material-symbols-outlined action-icon is-failed">error</span>
|
||
<span class="action-title">模型请求失败</span>
|
||
@if (isTerminalLLMFailure(item.data)) {
|
||
<span class="action-status-badge is-paused">
|
||
<span class="material-symbols-outlined status-icon">pause_circle</span>
|
||
|
||
已暂停
|
||
</span>
|
||
} @else {
|
||
<span class="action-status-badge is-failed">
|
||
<span class="material-symbols-outlined status-icon">warning</span>
|
||
|
||
失败
|
||
</span>
|
||
}
|
||
@if (hasLLMFailureRetryEntries(item.data)) {
|
||
<span class="action-expand-icon material-symbols-outlined">
|
||
{{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||
</span>
|
||
}
|
||
</div>
|
||
<div class="action-card-body">
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">发生了什么:</span>
|
||
<span class="detail-value">自动重试后仍无法完成模型请求。</span>
|
||
</div>
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">失败原因:</span>
|
||
<span class="detail-value">{{ getLLMErrorText(item.data) }}</span>
|
||
</div>
|
||
@if (getLLMFailureWaited(item.data); as waited) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">已等待:</span>
|
||
<span class="detail-value">{{ waited }}</span>
|
||
</div>
|
||
}
|
||
@if (isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)) {
|
||
<div class="failure-retry-details">
|
||
<div class="failure-retry-title">重试详情</div>
|
||
@for (retry of getLLMFailureRetryEntries(item.data); track retry; let retryIndex = $index) {
|
||
<div class="failure-retry-row">
|
||
<span>Retry {{ retryIndex + 1 }}</span>
|
||
<span>
|
||
Planned {{ getLLMRetryEntryDelay(retry) || '0s' }}
|
||
· Waited {{ getLLMRetryEntryWaited(retry) }}
|
||
</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
@if (isTerminalLLMFailure(item.data)) {
|
||
<div class="llm-resume-row">
|
||
<button
|
||
type="button"
|
||
class="btn-continue-task"
|
||
(click)="resumePausedTask($event)"
|
||
title="继续已暂停的任务"
|
||
aria-label="继续已暂停的任务"
|
||
>
|
||
<span class="material-symbols-outlined continue-icon">play_arrow</span>
|
||
<span>继续任务</span>
|
||
<span class="material-symbols-outlined continue-arrow">arrow_forward</span>
|
||
</button>
|
||
<span class="llm-resume-hint">任务已暂停 · 当前没有执行中的步骤</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
|
||
@if (shouldShowTool(item.data, block.data) && !isEvidenceTool(item.data)) {
|
||
<div class="step-generic-tool"
|
||
[class.is-running]="isToolLineRunning(item, block)"
|
||
[class.is-note-tool]="isNoteTool(item.data)"
|
||
[class.is-video-tool]="isVideoTool(item.data)"
|
||
[class.is-video-attention]="isVideoTool(item.data) && isVideoAnalysisAttention(item.data)"
|
||
[attr.title]="getCompressionPhaseTitle(item.data)"
|
||
>
|
||
<div class="tool-call-label">
|
||
<span class="stream-status-dot" [class.streaming]="isToolLineRunning(item, block)"></span>
|
||
<div class="tool-call-text">
|
||
<span>{{ isVideoTool(item.data) ? getVideoAnalysisLabel(item.data) : getToolDisplayLabel(item.data) }}{{ ((isNoteTool(item.data) && getToolKey(item.data)) || isVideoTool(item.data)) ? ':' : '' }}</span>
|
||
<!-- Note Tool Pill -->
|
||
@if (isNoteTool(item.data) && getToolKey(item.data); as key) {
|
||
<span
|
||
class="tool-key-pill"
|
||
(click)="onToolKeyClick(key)"
|
||
title="点击查看笔记内容"
|
||
>
|
||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||
{{ key }}
|
||
</span>
|
||
}
|
||
<!-- Video Tool Pill -->
|
||
@if (isVideoTool(item.data)) {
|
||
<span
|
||
class="tool-key-pill"
|
||
(click)="onVideoToolClick(item.data)"
|
||
[title]="'Open recording at ' + getVideoAnalysisRangeLabel(item.data)"
|
||
>
|
||
<span class="material-symbols-outlined pill-icon">videocam</span>
|
||
{{ getVideoAnalysisRangeLabel(item.data) }}
|
||
</span>
|
||
@if (getVideoAnalysisDetail(item.data); as detail) {
|
||
<span class="video-status-detail">· {{ detail }}</span>
|
||
}
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Device actions and plugin calls share evidence cards with explicit sources. -->
|
||
@if (shouldShowTool(item.data, block.data) && isEvidenceTool(item.data)) {
|
||
<div class="step-android-action-card tool-action-card"
|
||
[class.plugin-tool-card]="getPluginToolView(item.data)"
|
||
[class.is-auxiliary]="getPluginToolView(item.data)?.auxiliary"
|
||
[class.is-running]="isItemRunning(item, block)"
|
||
[class.has-no-details]="isAdbCommandTool(item.data) ? !getAdbCommandLine(item.data) : (!getToolTargetText(item.data) && !getToolInputText(item.data) && !getToolCoords(item.data) && !getToolAnalysisText(item.data) && !getToolGenericDetails(item.data) && getToolExtraParams(item.data).length === 0)"
|
||
[class.is-failed]="isToolFailed(item.data)"
|
||
[class.is-expanded]="isActionExpanded('tool-' + block.id + '-' + itemIdx)"
|
||
>
|
||
<div class="action-card-header" (click)="toggleActionExpanded('tool-' + block.id + '-' + itemIdx, $event)">
|
||
<span class="material-symbols-outlined action-icon" [class.is-failed]="isToolFailed(item.data)">{{ getToolIcon(item.data) }}</span>
|
||
<span class="action-title">{{ getToolTitle(item.data) }}</span>
|
||
<button class="evidence-source" [title]="evidenceSourceLabel(item.data) + ' · 查看对应证据'" (click)="openEvidenceVideo(item.data, block.data, $event)">{{ evidenceSourceLabel(item.data) }}</button>
|
||
@if (getPluginToolView(item.data); as plugin) {
|
||
<span class="plugin-call-kind">{{ plugin.kind }} · {{ plugin.status }}</span>
|
||
}
|
||
@if (isToolFailed(item.data)) {
|
||
<span class="action-status-badge is-failed">
|
||
<span class="material-symbols-outlined status-icon">error</span>
|
||
|
||
失败
|
||
</span>
|
||
}
|
||
<span class="action-expand-icon material-symbols-outlined">
|
||
{{ isActionExpanded('tool-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||
</span>
|
||
</div>
|
||
<!-- Compact preview body (when collapsed) -->
|
||
@if (!getPluginToolView(item.data) && !isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) {
|
||
<div class="action-card-body">
|
||
<!-- ADB command details -->
|
||
@if (isAdbCommandTool(item.data)) {
|
||
@if (getAdbCommandLine(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">命令:</span>
|
||
<app-expandable-value class="detail-value" label="命令" [value]="getAdbCommandLine(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getAdbCwd(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">工作目录:</span>
|
||
<span class="detail-value">"{{ getAdbCwd(item.data) }}"</span>
|
||
</div>
|
||
}
|
||
@if (getAdbTerminalId(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">终端 ID:</span>
|
||
<span class="detail-value">"{{ getAdbTerminalId(item.data) }}"</span>
|
||
</div>
|
||
}
|
||
}
|
||
<!-- Standard details -->
|
||
@if (!isAdbCommandTool(item.data)) {
|
||
@if (getToolTargetText(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">目标:</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>
|
||
<app-expandable-value class="detail-value" label="输入" [value]="getToolInputText(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getToolCoords(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">坐标:</span>
|
||
<span class="detail-value coordinate-badge">{{ getToolCoords(item.data) }}</span>
|
||
</div>
|
||
}
|
||
@if (getToolAnalysisText(item.data)) {
|
||
<div class="action-detail-row">
|
||
<span class="detail-label">分析:</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>
|
||
<app-expandable-value class="detail-value" label="详情" [value]="getToolGenericDetails(item.data)" />
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Detailed expanded view (when clicked) -->
|
||
@if (isActionExpanded('tool-' + block.id + '-' + itemIdx)) {
|
||
<div class="action-card-expanded-details" (click)="$event.stopPropagation()">
|
||
<div class="expanded-grid">
|
||
@if (isAdbCommandTool(item.data)) {
|
||
@if (getAdbCommandLine(item.data)) {
|
||
<div class="grid-item full-width">
|
||
<span class="grid-label">命令</span>
|
||
<app-expandable-value label="命令" [value]="getAdbCommandLine(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getAdbCwd(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">工作目录</span>
|
||
<span class="grid-value">"{{ getAdbCwd(item.data) }}"</span>
|
||
</div>
|
||
}
|
||
@if (getAdbTerminalId(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">终端 ID</span>
|
||
<span class="grid-value code-pill">{{ getAdbTerminalId(item.data) }}</span>
|
||
</div>
|
||
}
|
||
}
|
||
@if (getPluginToolView(item.data); as plugin) {
|
||
@for (param of plugin.args; track param.key) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">{{ param.key }}</span>
|
||
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||
</div>
|
||
}
|
||
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
|
||
<div class="grid-item full-width">
|
||
<span class="grid-label">实际结果</span>
|
||
<app-plugin-tool-result [view]="plugin" />
|
||
</div>
|
||
}
|
||
}
|
||
@if (!isAdbCommandTool(item.data) && !getPluginToolView(item.data)) {
|
||
@if (getToolTargetText(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">目标</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>
|
||
<app-expandable-value label="输入" [value]="getToolInputText(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getToolCoords(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">坐标</span>
|
||
<span class="grid-value code-pill">{{ getToolCoords(item.data) }}</span>
|
||
</div>
|
||
}
|
||
@if (getToolAnalysisText(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">分析</span>
|
||
<app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
|
||
</div>
|
||
}
|
||
@if (getToolGenericDetails(item.data)) {
|
||
<div class="grid-item">
|
||
<span class="grid-label">详情</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>
|
||
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
<!-- Action Screenshots (Pre & Post Action) -->
|
||
@if (getStepPreImageUrl(block.data, item.data) || getStepPostImageUrl(block.data, item.data)) {
|
||
<div class="expanded-screenshots-container">
|
||
@if (getStepPreImageUrl(block.data, item.data)) {
|
||
<div class="screenshot-column">
|
||
<span class="screenshot-label">操作前</span>
|
||
<div class="image-wrapper" (click)="openImageModal(getStepPreImageUrl(block.data, item.data)!, item.data, $event)">
|
||
<div class="img-relative-box">
|
||
<img #preImgTool [src]="getStepPreImageUrl(block.data, item.data)" (load)="onImageLoad(preImgTool, preOverlayTool, item.data)" alt="操作前截图" />
|
||
<div #preOverlayTool class="overlay-layer"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (getStepPostImageUrl(block.data, item.data)) {
|
||
<div class="screenshot-column">
|
||
<span class="screenshot-label">{{ getPluginToolView(item.data)?.auxiliary ? '观察截图' : '操作后' }}</span>
|
||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||
<div class="img-relative-box">
|
||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
@if (isToolFailed(item.data)) {
|
||
<div class="expanded-error-banner">
|
||
<span class="material-symbols-outlined icon">error</span>
|
||
<span class="error-text">{{ getToolErrorMessage(item.data) }}</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Check block result: one row per check item, keyed by its number above -->
|
||
@if (hasCheckerResult(block) && !(block.data.phase === 'outcome' && block.data.test_result)) {
|
||
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
|
||
@for (verdict of getCheckerVerdicts(block); track $index) {
|
||
<div class="check-verdict" [ngClass]="verdict.status">
|
||
<span class="check-index">{{ getVerdictItemNumber(block, verdict, $index) }}</span>
|
||
<span class="material-symbols-outlined verdict-icon">{{ getVerdictIcon(verdict.status) }}</span>
|
||
<span class="verdict-body">
|
||
<span class="verdict-evidence">{{ verdict.evidence || getVerdictStatusText(verdict.status) }}</span>
|
||
@if (verdict.suggestion) {
|
||
<span class="verdict-suggestion">{{ verdict.suggestion }}</span>
|
||
}
|
||
</span>
|
||
</div>
|
||
}
|
||
@for (note of getCheckerNotes(block); track note) {
|
||
<div class="check-note">{{ note }}</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
@if (!conversationMessages().length && awaitingGuidance().length) {
|
||
<div class="guidance-queue" aria-label="指导接收与消费记录">
|
||
@for (item of awaitingGuidance(); track item.id) {
|
||
<div class="guidance-queue-item">
|
||
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
|
||
<small>接收 {{ (item.accepted_at || item.timestamp) * 1000 | date:'HH:mm:ss' }}@if (item.consumed_at) { · 执行器读取 {{ item.consumed_at * 1000 | date:'HH:mm:ss' }} }</small>
|
||
<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">
|
||
<div class="synthesis-card-header">
|
||
<div class="header-left">
|
||
<span class="material-symbols-outlined icon-sparkle">assignment_turned_in</span>
|
||
<span class="main-title">任务报告</span>
|
||
</div>
|
||
<div class="header-right">
|
||
<button type="button" class="btn-open-note" (click)="openOutputterNote($event)">
|
||
<span class="material-symbols-outlined">visibility</span>
|
||
<span>在笔记中查看</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="synthesis-card-preview">
|
||
<app-report-markdown [content]="outReport" />
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Planning Loader -->
|
||
@if (showPlanningLoader() && (startupWorkItems().length === 0 || startupPreparationIsComplete() || consolidatedBlocks().length > 0)) {
|
||
<div class="planning-loader-container">
|
||
<div class="planning-loader">
|
||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||
<span class="loader-text">{{ currentPlanningText() }}</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
<ng-content select="[task-result]"></ng-content>
|
||
</main>
|
||
|
||
<!-- Fullscreen Image Zoom Lightbox Modal -->
|
||
@if (selectedZoomImage()) {
|
||
<div class="image-modal-backdrop" (click)="closeImageModal($event)">
|
||
<div class="image-modal-content" (click)="$event.stopPropagation()">
|
||
<button class="image-modal-close" (click)="closeImageModal($event)" aria-label="关闭图片预览">
|
||
<span class="material-symbols-outlined">close</span>
|
||
</button>
|
||
<div class="img-relative-box">
|
||
<img #modalImg [src]="selectedZoomImage()" (load)="onImageLoad(modalImg, modalOverlay, selectedZoomAction())" alt="放大截图" />
|
||
<div #modalOverlay class="overlay-layer"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|