Files
Artemis-Eapil/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
T

2377 lines
85 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
* 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.
*/
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
import {
contextPercent,
cachePercent,
contextWindow,
runMode,
formatCompactTokens,
formatElapsed,
parseRunTuning,
sessionElapsedSeconds,
tuningLabel
} from '../../utils/run-info.util';
export const PLANNING_LOADER_PHRASES: string[] = [
"正在规划下一步…",
"正在分析屏幕坐标…",
"正在请求模型…",
"正在制定操作计划…",
"正在识别界面状态与元素…",
"正在评估执行路径…",
"正在确认下一步操作…",
"正在整理推理结果…",
"正在评估子目标…",
"正在收集屏幕信息…",
"正在分析下一步交互…",
"正在调整执行策略…",
"正在预测操作结果…",
"正在分析任务…",
"正在准备下一条指令…",
"正在规划操作步骤…"
];
export interface StartupWorkItem extends StartupProgressEvent {
isActive: boolean;
elapsed: string;
}
interface StartupWorkStage {
started: string;
completed: string;
completedMessage: string;
/** Sub-steps that replace the live message while the stage is still running. */
liveDetailStages?: string[];
/** A later event whose message is a better completion line than the stage's own. */
completedDetailStage?: string;
}
const STARTUP_WORK_STAGES: StartupWorkStage[] = [
{
started: 'device_check',
completed: 'device_ready',
completedMessage: "Android 设备已连接"
},
{
started: 'uiautomator',
completed: 'uiautomator_ready',
completedMessage: "界面层级服务已就绪",
// First task on a device installs / upgrades the accessibility helper
// (a few seconds): say so instead of a generic "connecting".
liveDetailStages: ['helper_install', 'helper_upgrade'],
// "UI hierarchy source: Artemis accessibility helper v1.1.3" (or UIAutomator2).
completedDetailStage: 'hierarchy_backend'
},
{
started: 'environment',
completed: 'environment_ready',
completedMessage: "设备环境已就绪"
}
];
function formatStartupElapsed(seconds: number): string {
const safeSeconds = Math.max(0, seconds);
return safeSeconds < 10
? `${safeSeconds.toFixed(1)}s`
: `${Math.round(safeSeconds)}s`;
}
/**
* Collapse noisy process-level startup events into the three device preparation
* operations that are useful to someone watching a run.
*/
export function buildStartupWorkItems(
events: StartupProgressEvent[],
nowSeconds: number,
executionHasOutput: boolean,
agentIsActive: boolean
): StartupWorkItem[] {
const byStage = new Map(events.map((event) => [event.stage, event]));
const firstResponse = byStage.get('first_response');
return STARTUP_WORK_STAGES.flatMap((stage, stageIndex) => {
const started = byStage.get(stage.started);
const explicitlyCompleted = byStage.get(stage.completed);
if (!started && !explicitlyCompleted) return [];
const nextStageStarted = STARTUP_WORK_STAGES
.slice(stageIndex + 1)
.map((nextStage) => byStage.get(nextStage.started) || byStage.get(nextStage.completed))
.find((event): event is StartupProgressEvent => Boolean(event));
const inferredCompletion = stage.started === 'environment'
? firstResponse
: nextStageStarted;
const completed = explicitlyCompleted || inferredCompletion;
const isActive = !completed && !executionHasOutput && agentIsActive;
const startTimestamp = started?.timestamp || explicitlyCompleted?.timestamp || nowSeconds;
const endTimestamp = completed?.timestamp
|| (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp);
const liveDetail = (stage.liveDetailStages || [])
.map((detailStage) => byStage.get(detailStage))
.filter((event): event is StartupProgressEvent => Boolean(event))
.filter((event) => event.timestamp >= startTimestamp)
.sort((a, b) => b.timestamp - a.timestamp)[0];
const completedDetail = stage.completedDetailStage
? byStage.get(stage.completedDetailStage)
: undefined;
const message = completed
? (completedDetail?.message || explicitlyCompleted?.message || stage.completedMessage)
: (liveDetail?.message || started!.message);
return [{
...(explicitlyCompleted || started!),
message,
isActive,
elapsed: formatStartupElapsed(endTimestamp - startTimestamp)
}];
});
}
import {
parseNote,
parseNoteLines,
extractCheckerResult,
renderMarkdownToHtml
} from '../../utils/markdown-parser.util';
import { getPluginToolView } from '../../utils/plugin-tool-view.util';
import {
getActionObject,
isAndroidAction,
getActionIcon,
getActionTitle,
getActionTargetText,
getActionInputLabel,
getActionInputText,
getActionCoords,
getActionBounds,
getActionResourceId,
getActionClass,
isActionFailed,
getActionErrorMessage,
extractActionExtraParams,
getStepPreImageUrl,
getStepPostImageUrl,
isReportStatusAction,
getReportStatusValue,
getReportStatusExplanation,
actionEvidenceSource
} from '../../utils/action-formatter.util';
import {
getToolArgs,
isNoteTool,
isVideoTool,
getVideoAnalysisView,
formatVideoTime,
getVideoToolTarget,
isDeviceActionTool,
isFailureAnalyzerActionTool,
getToolAgentName,
shouldShowTool,
getUniqueGenericTools,
getToolKey,
getToolDisplayLabel,
isCompressionTool,
isCompressionWaiting,
getCompressionPhaseLabel,
getToolIcon,
getToolTitle,
getToolTargetText,
getToolInputLabel,
getToolInputText,
getToolCoords,
getToolAnalysisText,
getToolGenericDetails,
isAdbCommandTool,
getAdbCommandLine,
getAdbCwd,
getAdbTerminalId,
isToolFailed,
getToolErrorMessage,
extractToolExtraParams,
isHumanThinking,
cleanErrorMessage
} from '../../utils/tool-formatter.util';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
import {
consolidateLogsToBlocks,
isBackendSwitchNote,
groupBlocksToPhases,
extractBlockTokens,
formatTokenCount,
getSortedStepEvents,
compileSessionSummary,
computeSessionDuration,
checkPlanningLoader
} from '../../utils/stream-aggregator.util';
export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamResetNotice };
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [TaskOutcomeComponent,ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AgentStreamComponent implements AfterViewInit {
conversationMessages = input<ExplorationMessage[]>([]);
demonstrations = input<Demonstration[]>([]);
pendingQuestion = input('');
demoMediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
demoLabel(status: string) { return ({requested:'准备录制',recording:'录制中',analyzing:'分析中',completed:'已分析',failed:'分析失败',cancelled:'已取消',timed_out:'到时已保存'} as Record<string,string>)[status] || status; }
useRecommendation = output<string>();
public agentService = inject(AgentService);
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;
private resizeObserver: ResizeObserver | null = null;
private streamLogsContainer: HTMLElement | null = null;
private autoScrollTimer: ReturnType<typeof setTimeout> | null = null;
private autoScrollStreamBoxes = false;
private onContainerScroll = () => {
const container = this.streamLogsContainer;
if (!container) return;
const threshold = 150;
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
this.isUserAtBottom = distanceToBottom <= threshold;
};
// Dynamic Planning Loader Game-style Phrases
public currentPlanningText = signal<string>(PLANNING_LOADER_PHRASES[0]);
private planningIntervalId: any = null;
private currentPhraseIndex = 0;
// Top Nav Dropdown States
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false);
public deleteError = signal('');
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with.
public isRunInfoOpen = signal<boolean>(false);
public runUsage = signal<SessionUsage | null>(null);
public runUsageLoading = signal(false);
public runUsageError = signal(false);
public runInfoCachePercent = computed(() => cachePercent(this.runUsage()?.cached_tokens, this.runUsage()?.prompt_tokens));
private runInfoClock = signal<number>(Date.now());
public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel()));
public runInfoIsPro = computed(() => this.runInfoMode() === 'pro');
public runInfoContextWindow = computed(() => contextWindow(this.runUsage()?.operator_context_window_tokens));
public runInfoElapsed = computed(() => {
const session = this.agentService.currentSession();
const running = this.agentService.isCurrentSessionRunning();
return formatElapsed(sessionElapsedSeconds(session, running, this.runInfoClock()));
});
public runInfoTuning = computed(() => {
const usage = this.runUsage();
if (usage?.run_tuning && (usage.run_tuning.verification_level || usage.run_tuning.explorer_mode)) {
return usage.run_tuning;
}
return parseRunTuning(this.agentService.currentSession()?.device_info);
});
public runInfoContextPercent = computed(() => {
const usage = this.runUsage();
if (!usage) return null;
return contextPercent(usage.operator_context_tokens, usage.operator_context_window_tokens);
});
// CDK Connected Overlay Positioning Strategy
public readonly dropdownPositions: ConnectedPosition[] = [
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 6,
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -6,
},
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 6,
}
];
// Zoom Modal State
public selectedZoomImage = signal<string | null>(null);
public selectedZoomAction = signal<any | null>(null);
// Signals and properties for managing delayed collapsing of thought streams
public collapsedStreams = signal<Set<string>>(new Set<string>());
private scheduledCollapses = new Set<string>();
// Signals and maps for typewriter effect simulation. All active typewriters
// are driven by ONE requestAnimationFrame loop (run outside the Angular
// zone) that batches every text advance into a single signal update.
public typedTextsSignal = signal<Record<string, string>>({});
private typingTargets = new Map<string, {
blockId: string;
isThinking: boolean;
execId?: string;
fallbackText: string;
}>();
private typingRafId: number | null = null;
private lastTypingTimestamp = 0;
// ≈ legacy pace of 8 characters every 12ms interval tick.
private static readonly TYPING_CHARS_PER_SECOND = 667;
private static readonly TYPING_MIN_FRAME_MS = 28;
// Active reset notices per block (displayed after typewriter rewind finishes)
public activeBlockResets = signal<Map<string, StreamResetNotice[]>>(new Map());
public activeResetNotices = signal<Map<string, string>>(new Map());
private rewindTargets = new Map<string, {
blockId: string;
isThinking: boolean;
execId?: string;
message: string;
}>();
// Fast rewind pace (backspace speed ≈ 4x normal typing speed for snappy deletion)
private static readonly REWIND_CHARS_PER_SECOND = 2600;
// Rendered-markdown memo per template slot: returning the identical string
// instance for unchanged text lets the [innerHTML] binding skip re-sanitizing
// and re-parsing the whole fragment on every change-detection pass.
private markdownHtmlCache = new Map<string, { source: string; html: string }>();
// Thinking-text extraction memo (includes the JSON-detection heuristics).
private thinkingTextCache = new WeakMap<any, { native: string | null; raw: string | null }>();
private deviceSerialCache = new WeakMap<Session, string | null>();
// Set to track expanded and collapsed state of action cards
public expandedActionCards = signal<Set<string>>(new Set<string>());
public collapsedActionCards = signal<Set<string>>(new Set<string>());
public retryClock = signal<number>(Date.now());
public hasModelRequest = signal(false);
// Performance caches for parameter and event extractions
private actionParamsCache = new WeakMap<any, ActionParam[]>();
private toolParamsCache = new WeakMap<any, ActionParam[]>();
private sortedEventsCache = new WeakMap<any, { signature: string; events: StepEvent[] }>();
// Top Nav Task Queue Computed Properties
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s) && this.agentService.isQueueRepresentative(s);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0);
});
});
public historyTasks = computed(() => {
return this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
});
});
// Top Nav Notes Computed Properties
public currentNoteContent = computed(() => {
const notes = this.agentService.currentNotes();
const key = this.agentService.selectedNoteKey();
return notes[key] || '';
});
public noteKeys = computed(() => {
return Object.keys(this.agentService.currentNotes()).filter(key => key.toLowerCase().endsWith('.md'));
});
public outputterReport = computed(() => {
const notes = this.agentService.currentNotes();
return notes['output.md'] || null;
});
public parsedNote = computed<ParsedNote>(() => {
return this.getParsedNote(this.currentNoteContent());
});
// Stream Log Filtering & Aggregation Computed Properties
public filteredLogs = computed(() => {
const logs = this.agentService.sessionLogs();
const currentSessionId = this.agentService.currentSessionId();
if (!currentSessionId) return logs;
return logs.filter((log: any) => {
const logSessionId = log.session_id || log.data?.session_id;
return !logSessionId || logSessionId === currentSessionId;
});
});
public sessionSummary = computed(() => {
return compileSessionSummary(this.filteredLogs());
});
public showPlanningLoader = computed(() => {
if (this.hasModelRequest()) return false;
const isRunning = this.agentService.agentStatus() === 'running';
const currentSessionId = this.agentService.currentSessionId();
const runningSessionId = this.agentService.runningSessionId();
const isCurrentRunningSession = !currentSessionId || currentSessionId === runningSessionId;
return checkPlanningLoader(this.filteredLogs(), isRunning, isCurrentRunningSession);
});
public isViewingPausedTask = computed(() => {
// A live task_paused event and the polled runner status must agree before
// recovery controls are shown. This prevents a stale failure trace from
// offering Resume while the active task is still reported as running.
if (!this.agentService.isPaused() || this.agentService.agentStatus() !== 'paused') return false;
const currentSessionId = this.agentService.currentSessionId();
const pausedSessionId = this.agentService.runningSessionId();
return !currentSessionId || currentSessionId === pausedSessionId;
});
public sessionDuration = computed(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return computeSessionDuration(currentSession?.start_time || 0, this.filteredLogs());
});
public consolidatedBlocks = computed<StepBlock[]>(() => {
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', 'success', 'failed', 'cancelled', 'interrupted', 'skipped']
.includes(this.agentService.currentSession()?.status || '');
if (event.status === 'unapplied' || (event.status === 'pending' && terminal)) return '任务结束,未记录消费';
if (event.status === 'unconfirmed' || (event.status === 'reading' && terminal)) return '任务结束,读取未确认';
if (event.status === 'reading') return '执行器正在读取';
if (event.status === 'pending') return '已接收,等待安全时点读取';
return terminal ? '执行器已读取,未留下后续动作记录' : '执行器已读取,等待动作记录';
}
public phases = computed(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return mergeConversation(groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0), this.conversationMessages(), this.demonstrations());
});
public startupWorkItems = computed(() => {
this.retryClock();
const events = this.agentService.currentStartupProgress();
const agentIsActive = ['running', 'paused'].includes(this.agentService.agentStatus());
return buildStartupWorkItems(
events,
Date.now() / 1000,
this.consolidatedBlocks().length > 0,
agentIsActive
);
});
public startupPreparationIsRunning = computed(() => {
return this.startupWorkItems().some((item) => item.isActive);
});
public startupPreparationIsComplete = computed(() => {
const items = this.startupWorkItems();
return items.length > 0 && !items.some((item) => item.isActive);
});
public startupPreparationDuration = computed(() => {
const items = this.startupWorkItems();
if (items.length === 0) return 0;
const events = this.agentService.currentStartupProgress();
const startedAt = events[0]?.timestamp || items[0].timestamp;
const completedAt = events.find((event) => event.stage === 'environment_ready')?.timestamp
|| events.find((event) => event.stage === 'first_response')?.timestamp;
const endedAt = this.startupPreparationIsRunning()
? Date.now() / 1000
: completedAt || Math.max(...items.map((item) => item.timestamp));
return Math.max(1, Math.round(endedAt - startedAt));
});
private retryablePauseTrace = computed<any | null>(() => {
if (!this.isViewingPausedTask()) return null;
const blocks = this.consolidatedBlocks();
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
const tools = blocks[blockIndex]?.data?.generic_tools;
if (!Array.isArray(tools)) continue;
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
const tool = tools[toolIndex];
// Older persisted traces used the llm_pause name without adding the
// newer payload.pause marker. Treat both contracts consistently.
if (this.isDisplayableLLMFailure(tool)) {
return tool;
}
}
}
return null;
});
public hasVisibleBlocks(phase: any): boolean {
if (!phase || !phase.blocks) return false;
return phase.blocks.some((b: any) => this.hasVisibleContent(b));
}
constructor() {
const conversationRevision = computed(() => JSON.stringify([
this.conversationMessages().map(message => message.id),
this.demonstrations().map(demo => [demo.id,demo.status])
]));
effect(() => { conversationRevision(); untracked(() => this.scheduleAutoScroll(false)); });
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) => {
const open = this.isRunInfoOpen();
const sessionId = this.agentService.currentSessionId();
const running = this.agentService.isCurrentSessionRunning();
if (!open || !sessionId) return;
if (untracked(() => this.runUsage())?.session_id !== sessionId) {
this.runUsage.set(null);
}
let request: Subscription | null = null;
const refresh = () => {
if (request && !request.closed) return;
request = this.loadRunUsage(sessionId);
};
refresh();
const usageInterval = running ? setInterval(refresh, 3000) : null;
onCleanup(() => {
if (usageInterval !== null) clearInterval(usageInterval);
request?.unsubscribe();
});
});
effect((onCleanup) => {
if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return;
this.runInfoClock.set(Date.now());
const clockInterval = setInterval(() => this.runInfoClock.set(Date.now()), 1000);
onCleanup(() => clearInterval(clockInterval));
});
// The retry clock only needs to tick while something 在屏幕上 is timing
// (startup progress, LLM retry countdowns). An idle page stays quiet.
this.zone.runOutsideAngular(() => {
const retryClockInterval = setInterval(() => {
const status = this.agentService.agentStatus();
if (status === 'running' || status === 'paused' || this.agentService.isRetrying()) {
this.retryClock.set(Date.now());
}
}, 1000);
this.destroyRef.onDestroy(() => clearInterval(retryClockInterval));
});
// Auto scroll stream box during active text streaming
effect(() => {
const logs = this.filteredLogs();
const activeStream = logs.find(log => log.type === 'llm_stream' && !log.data?.isCompleted);
if (activeStream) {
this.scheduleAutoScroll(true);
}
});
// Auto scroll main log list when new logs are added or typewriter updates
effect(() => {
this.consolidatedBlocks();
this.typedTextsSignal();
this.scheduleAutoScroll(false);
});
// Listen for stream reset events to trigger fast typewriter rewind
effect(() => {
const resetEvent = this.agentService.streamResetEvent();
if (!resetEvent) return;
untracked(() => {
this.triggerStreamRewind(
resetEvent.stream_execution_id || resetEvent.stream_exec_id,
resetEvent.message || DEFAULT_STREAM_RESET_MESSAGE
);
});
});
// Typewriter drive logic: triggers typing when block appears or expands
effect(() => {
const blocks = this.consolidatedBlocks();
blocks.forEach(block => {
// Check blocks render their per-turn segments directly (no typewriter).
if (block.type === 'checker') return;
const blockId = block.id;
// If the block is flagged as reset, ensure typewriter rewind is active or reset notice is set
if (block.data?.isReset) {
untracked(() => {
const resetMsg = block.data.resetMessage || DEFAULT_STREAM_RESET_MESSAGE;
const currentRecord = this.typedTextsSignal();
const hasText = (currentRecord[blockId]?.length || 0) > 0 || (currentRecord[blockId + '-native']?.length || 0) > 0;
if (hasText) {
if (!this.rewindTargets.has(blockId) && !this.rewindTargets.has(blockId + '-native')) {
this.triggerStreamRewind(block.data.execution_id, resetMsg);
}
} else {
const activeResets = this.activeBlockResets().get(blockId) || [];
if (activeResets.length === 0) {
this.upsertBlockResetNotice(blockId, {
id: block.data.execution_id || `${blockId}-text`,
message: resetMsg,
isWaiting: !block.data?.isCompleted,
streamType: 'text'
});
}
if (!this.activeResetNotices().has(blockId)) {
this.activeResetNotices.update(m => {
const next = new Map(m);
next.set(blockId, resetMsg);
return next;
});
}
}
});
return;
}
const rawText = this.getRawThinking(block) || '';
const nativeText = this.getNativeThinking(block) || '';
untracked(() => {
// Handle Raw Thinking (Work)
const currentRecord = this.typedTextsSignal();
if (rawText) {
if (!(blockId in currentRecord)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText }));
} else {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: '' }));
this.startTyping(blockId, rawText, false);
}
} else {
const currentVal = currentRecord[blockId] || '';
if (currentVal.length < rawText.length && !this.typingTargets.has(blockId)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText }));
} else {
this.startTyping(blockId, rawText, false);
}
}
}
}
// Handle Native Thinking (Thought)
const nativeKey = blockId + '-native';
if (nativeText) {
const execId = block.data?.execution_id || block.data?.step_id || blockId;
if (!(nativeKey in currentRecord)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText }));
this.collapsedStreams.update(set => {
const newSet = new Set(set);
newSet.add(execId);
return newSet;
});
} else {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: '' }));
this.startTyping(nativeKey, nativeText, true, execId);
}
} else {
const currentVal = currentRecord[nativeKey] || '';
if (currentVal.length < nativeText.length && !this.typingTargets.has(nativeKey)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText }));
this.collapsedStreams.update(set => {
const newSet = new Set(set);
newSet.add(execId);
return newSet;
});
} else {
this.startTyping(nativeKey, nativeText, true, execId);
}
}
}
}
});
});
});
// Reset typewriter and collapse states when session changes
effect(() => {
this.agentService.currentSessionId();
untracked(() => {
this.stopTypingLoop();
this.typedTextsSignal.set({});
this.activeResetNotices.set(new Map());
this.activeBlockResets.set(new Map());
this.markdownHtmlCache.clear();
this.scheduledCollapses.clear();
this.collapsedStreams.set(new Set<string>());
this.isUserAtBottom = true;
setTimeout(() => {
const container = document.querySelector('.stream-logs-content');
if (container) {
container.scrollTop = container.scrollHeight;
}
}, 100);
});
});
// Periodically rotate loading text like game loading screen hints
effect(() => {
const isLoading = this.showPlanningLoader();
if (isLoading) {
if (!this.planningIntervalId) {
this.currentPhraseIndex = Math.floor(Math.random() * PLANNING_LOADER_PHRASES.length);
this.currentPlanningText.set(PLANNING_LOADER_PHRASES[this.currentPhraseIndex]);
this.planningIntervalId = setInterval(() => {
this.currentPhraseIndex = (this.currentPhraseIndex + 1) % PLANNING_LOADER_PHRASES.length;
this.currentPlanningText.set(PLANNING_LOADER_PHRASES[this.currentPhraseIndex]);
}, 2800);
}
} else {
if (this.planningIntervalId) {
clearInterval(this.planningIntervalId);
this.planningIntervalId = null;
}
}
});
this.destroyRef.onDestroy(() => {
if (this.planningIntervalId) {
clearInterval(this.planningIntervalId);
this.planningIntervalId = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.stopTypingLoop();
if (this.autoScrollTimer) {
clearTimeout(this.autoScrollTimer);
this.autoScrollTimer = null;
}
if (this.streamLogsContainer) {
this.streamLogsContainer.removeEventListener('scroll', this.onContainerScroll);
this.streamLogsContainer = null;
}
});
}
public ngAfterViewInit(): void {
const container = document.querySelector('.stream-logs-content') as HTMLElement | null;
this.streamLogsContainer = container;
if (!container) return;
// The scroll listener lives outside the Angular zone: tracking the
// user's scroll position must not trigger change detection per frame.
this.zone.runOutsideAngular(() => {
container.addEventListener('scroll', this.onContainerScroll, { passive: true });
});
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
if (this.isUserAtBottom) {
this.scheduleAutoScroll(false);
}
});
this.resizeObserver.observe(container);
}
}
/**
* Coalesce every auto-scroll request into one deferred pass that batches all
* DOM reads before all writes, instead of thrashing layout per update.
*/
private scheduleAutoScroll(includeStreamBoxes: boolean): void {
this.autoScrollStreamBoxes = this.autoScrollStreamBoxes || includeStreamBoxes;
if (this.autoScrollTimer) return;
this.zone.runOutsideAngular(() => {
this.autoScrollTimer = setTimeout(() => {
this.autoScrollTimer = null;
const includeBoxes = this.autoScrollStreamBoxes;
this.autoScrollStreamBoxes = false;
const boxes = includeBoxes
? (Array.from(document.getElementsByClassName('stream-box')) as HTMLElement[])
: [];
const boxTargets = boxes.map((el) => el.scrollHeight);
const container = this.streamLogsContainer;
const containerTarget = container && this.isUserAtBottom ? container.scrollHeight : null;
boxes.forEach((el, i) => { el.scrollTop = boxTargets[i]; });
if (container && containerTarget !== null) {
container.scrollTop = containerTarget;
}
}, 50);
});
}
// Top Nav Action Methods
public openBatchQueue(): void {
this.isAutomationOpen.set(false);
this.isNotesDropdownOpen.set(false);
this.isTaskDropdownOpen.set(true);
}
public toggleTaskDropdown(event: Event): void {
event.stopPropagation();
this.isTaskDropdownOpen.update(v => !v);
if (this.isTaskDropdownOpen()) {
this.isNotesDropdownOpen.set(false);
}
}
public toggleNotesDropdown(event: Event): void {
event.stopPropagation();
this.isNotesDropdownOpen.update((v) => !v);
if (this.isNotesDropdownOpen()) {
this.isTaskDropdownOpen.set(false);
}
}
public openOutputterNote(event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectedNoteKey.set('output.md');
this.isNotesDropdownOpen.set(true);
this.isTaskDropdownOpen.set(false);
}
public closeAllDropdowns(): void {
this.isTaskDropdownOpen.set(false);
this.isNotesDropdownOpen.set(false);
this.isRunInfoOpen.set(false);
}
public toggleRunInfo(event: Event): void {
event.stopPropagation();
this.isRunInfoOpen.update((v) => !v);
if (this.isRunInfoOpen()) {
this.isTaskDropdownOpen.set(false);
this.isNotesDropdownOpen.set(false);
}
}
private loadRunUsage(sessionId: string): Subscription {
this.runUsageLoading.set(true);
this.runUsageError.set(false);
return this.agentService.getSessionUsage(sessionId).subscribe({
next: (usage) => {
if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsage.set(usage);
},
error: () => {
if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsageError.set(true);
}
});
}
public formatRunElapsed(seconds: number): string {
return formatElapsed(seconds);
}
public formatRunTokens(tokens: number | null | undefined): string {
return formatCompactTokens(tokens);
}
public runTuningLabel(kind: 'verify' | 'explore', id: string | null | undefined): string {
return tuningLabel(kind, id);
}
public getTaskStatus(session: Session): string {
const exploration = this.agentService.explorationQueueStatus(session);
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
if (session.status) {
const s = session.status.toLowerCase();
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
return s === 'success' ? 'completed' : s;
}
if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
return s;
}
}
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
return 'completed';
}
public taskPhaseLabel(session: Session): string | null {
if (['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.getTaskStatus(session))) return null;
return ({ waiting_resources: '等待资源', paused: '已暂停', demonstrating: '人工示范中', analyzing_demo: '分析示范中' } as Record<string, string>)[session.phase || ''] || null;
}
/**
* Determine the device serial number for the session
*/
public getDeviceSerial(session: Session): string | null {
if (this.deviceSerialCache.has(session)) {
return this.deviceSerialCache.get(session) ?? null;
}
let resolved: string | null = null;
const serial = session.device_serial || session.device_id;
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
resolved = serial;
} else if (session.device_info) {
try {
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
const s = info?.device_id || info?.device_serial;
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
resolved = s;
}
} catch {
// ignore
}
}
this.deviceSerialCache.set(session, resolved);
return resolved;
}
public selectTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectSession(sessionId, true);
this.isTaskDropdownOpen.set(false);
}
public stopTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.stopTask(sessionId, false);
}
public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return;
}
this.deleteError.set('');
this.agentService.deleteSession(sessionId).subscribe({
error: (err) => {
this.deleteError.set(err.error?.detail || "删除失败,请稍后重试");
}
});
}
public selectNote(key: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectedNoteKey.set(key);
}
public getParsedNote(content: string): ParsedNote {
return parseNote(content);
}
public getParsedNoteLines(content: string): MarkdownLine[] {
return parseNoteLines(content);
}
// Typewriter & Delayed Collapse
private upsertBlockResetNotice(blockId: string, notice: StreamResetNotice): void {
this.activeBlockResets.update(m => {
const next = new Map(m);
const list = [...(next.get(blockId) || [])];
const existingIdx = list.findIndex(r => r.id === notice.id);
if (existingIdx > -1) {
list[existingIdx] = notice;
} else {
list.push(notice);
}
next.set(blockId, list);
return next;
});
}
private startTyping(key: string, targetText: string, isThinking: boolean = false, execId?: string) {
const blockId = key.endsWith('-native') ? key.replace('-native', '') : key;
// If there was an active rewind, cancel it, reset text to empty, and record its reset notice as completed waiting
const activeRewind = this.rewindTargets.get(key);
if (activeRewind) {
this.rewindTargets.delete(key);
this.typedTextsSignal.update(r => ({ ...r, [key]: '' }));
this.upsertBlockResetNotice(blockId, {
id: activeRewind.execId || `${blockId}-${isThinking ? 'thinking' : 'text'}`,
message: activeRewind.message,
isWaiting: false,
streamType: isThinking ? 'thinking' : 'text'
});
}
// Cancel any active rewind on this key
this.rewindTargets.delete(key);
// When retry stream starts outputting content, change waiting dot to normal dot (isWaiting: false)
// The previous prompt sentence DOES NOT disappear.
this.activeBlockResets.update(m => {
const list = m.get(blockId);
if (!list || list.length === 0) return m;
const targetType = isThinking ? 'thinking' : 'text';
const updated = list.map(r => r.streamType === targetType ? { ...r, isWaiting: false } : r);
const next = new Map(m);
next.set(blockId, updated);
return next;
});
this.typingTargets.set(key, { blockId, isThinking, execId, fallbackText: targetText });
this.ensureTypingLoop();
}
private ensureTypingLoop(): void {
if (this.typingRafId !== null) return;
this.lastTypingTimestamp = performance.now();
this.zone.runOutsideAngular(() => {
const step = (now: number) => {
this.typingRafId = null;
const elapsed = now - this.lastTypingTimestamp;
if (elapsed >= AgentStreamComponent.TYPING_MIN_FRAME_MS) {
this.lastTypingTimestamp = now;
this.advanceTyping(elapsed);
}
if (this.typingTargets.size > 0 || this.rewindTargets.size > 0) {
this.typingRafId = requestAnimationFrame(step);
}
};
this.typingRafId = requestAnimationFrame(step);
});
}
private stopTypingLoop(): void {
this.typingTargets.clear();
this.rewindTargets.clear();
if (this.typingRafId !== null) {
cancelAnimationFrame(this.typingRafId);
this.typingRafId = null;
}
}
/**
* Advance every active typewriter by the elapsed wall time and publish all
* changes as one signal update (one render pass per animation frame at most).
*/
private advanceTyping(elapsedMs: number): void {
const blocks = untracked(() => this.consolidatedBlocks());
const currentRecord = untracked(() => this.typedTextsSignal());
const chars = Math.max(
1,
Math.round(AgentStreamComponent.TYPING_CHARS_PER_SECOND * elapsedMs / 1000)
);
const rewindChars = Math.max(
2,
Math.round(AgentStreamComponent.REWIND_CHARS_PER_SECOND * elapsedMs / 1000)
);
const updates: Record<string, string> = {};
let hasUpdates = false;
// 1. Advance forward typing targets
for (const [key, typing] of this.typingTargets) {
const block = blocks.find((b) => b.id === typing.blockId);
let target = typing.fallbackText;
if (block) {
target = (typing.isThinking ? this.getNativeThinking(block) : this.getRawThinking(block))
|| typing.fallbackText;
}
const current = currentRecord[key] || '';
if (current.length < target.length) {
updates[key] = target.slice(0, Math.min(current.length + chars, target.length));
hasUpdates = true;
} else if (block?.data?.isCompleted) {
this.typingTargets.delete(key);
if (typing.isThinking && typing.execId) {
this.triggerDelayedCollapse(typing.execId);
}
}
}
// 2. Advance fast rewind targets (backspacing for mid-stream resets)
const completedRewinds: Array<{ blockId: string; execId?: string; isThinking: boolean; message: string }> = [];
for (const [key, rewind] of this.rewindTargets) {
const current = updates[key] !== undefined ? updates[key] : (currentRecord[key] || '');
if (current.length > 0) {
const nextLen = Math.max(0, current.length - rewindChars);
updates[key] = current.slice(0, nextLen);
hasUpdates = true;
if (nextLen === 0) {
this.rewindTargets.delete(key);
completedRewinds.push({
blockId: rewind.blockId,
execId: rewind.execId,
isThinking: rewind.isThinking,
message: rewind.message
});
}
} else {
this.rewindTargets.delete(key);
completedRewinds.push({
blockId: rewind.blockId,
execId: rewind.execId,
isThinking: rewind.isThinking,
message: rewind.message
});
}
}
if (completedRewinds.length > 0) {
for (const item of completedRewinds) {
this.upsertBlockResetNotice(item.blockId, {
id: item.execId || `${item.blockId}-${item.isThinking ? 'thinking' : 'text'}`,
message: item.message,
isWaiting: true,
streamType: item.isThinking ? 'thinking' : 'text'
});
}
this.activeResetNotices.update((notices) => {
const next = new Map(notices);
for (const item of completedRewinds) {
next.set(item.blockId, item.message);
}
return next;
});
}
if (hasUpdates) {
this.typedTextsSignal.update((r) => ({ ...r, ...updates }));
}
}
/**
* Triggers fast typewriter rewind on a stream execution when a mid-stream reset occurs.
* Once characters rewind to zero, the block displays the graceful retry notification.
*/
public triggerStreamRewind(execId: string | undefined, message: string): void {
const blocks = untracked(() => this.consolidatedBlocks());
const currentTexts = untracked(() => this.typedTextsSignal());
const targetBlock = blocks.find(b =>
b.data?.execution_id === execId
|| b.id === `stream-${execId}`
|| b.data?.step_id === execId
|| (Array.isArray(b.data?.stream_segments) && b.data.stream_segments.some((s: any) => s.execution_id === execId))
) || blocks.find(b => b.data?.isReset);
if (!targetBlock) return;
const blockId = targetBlock.id;
const keysToCheck: Array<{ key: string; isThinking: boolean }> = [
{ key: blockId, isThinking: false },
{ key: blockId + '-native', isThinking: true }
];
let startedRewind = false;
for (const { key, isThinking } of keysToCheck) {
this.typingTargets.delete(key);
const currentText = currentTexts[key] || '';
if (currentText.length > 0) {
this.rewindTargets.set(key, {
blockId,
isThinking,
execId,
message
});
startedRewind = true;
}
}
if (startedRewind) {
this.ensureTypingLoop();
} else {
this.upsertBlockResetNotice(blockId, {
id: execId || `${blockId}-text`,
message,
isWaiting: true,
streamType: 'text'
});
this.activeResetNotices.update(notices => {
const next = new Map(notices);
next.set(blockId, message);
return next;
});
}
}
public getBlockResets(block: any, isThinking: boolean = false): StreamResetNotice[] {
if (!block) return [];
const blockId = block.id;
const targetStreamType = isThinking ? 'thinking' : 'text';
// 1. From activeBlockResets signal
const activeList = (this.activeBlockResets().get(blockId) || [])
.filter(r => r.streamType === targetStreamType);
// 2. From block.data.stream_resets (aggregated from logs)
const blockDataResets = (Array.isArray(block.data?.stream_resets) ? block.data.stream_resets : [])
.filter((r: any) => (r.streamType || 'text') === targetStreamType);
// Fast path: if both lists are empty, check fallback or return empty array immediately
if (activeList.length === 0 && blockDataResets.length === 0) {
if (!isThinking && block.data?.isReset && block.data?.resetMessage) {
return [{
id: block.data.execution_id || blockId,
message: block.data.resetMessage,
isWaiting: !block.data?.isCompleted,
streamType: 'text'
}];
}
return [];
}
// Merge by id, giving precedence to activeList
const mergedMap = new Map<string, StreamResetNotice>();
for (const r of blockDataResets) {
mergedMap.set(r.id, {
id: r.id,
message: r.message,
isWaiting: block.data?.isCompleted ? false : (r.isWaiting ?? false),
streamType: r.streamType || targetStreamType
});
}
for (const r of activeList) {
mergedMap.set(r.id, {
...r,
isWaiting: block.data?.isCompleted ? false : r.isWaiting
});
}
return Array.from(mergedMap.values());
}
public hasWaitingReset(block: any, isThinking: boolean = false): boolean {
const resets = this.getBlockResets(block, isThinking);
return resets.some(r => r.isWaiting);
}
public hasBlockResets(block: any, isThinking: boolean = false): boolean {
return this.getBlockResets(block, isThinking).length > 0;
}
public formatResetMessage(message: string, isWaiting: boolean): string {
if (!message) return '';
if (isWaiting) return message;
return message
.replace(/[,.\s]*(?:Retrying automatically(?:\.{3}|\.\.\.)?)\s*$/i, '')
.trim();
}
public getCheckerSegmentResetMessage(item: any): string {
return item?.data?.resetMessage || DEFAULT_STREAM_RESET_MESSAGE;
}
public getResetNotice(block: any): string | null {
if (!block) return null;
const resets = this.getBlockResets(block, false);
if (resets.length > 0) {
const last = resets[resets.length - 1];
return this.formatResetMessage(last.message, last.isWaiting);
}
const blockId = block.id;
const active = this.activeResetNotices().get(blockId);
if (active) return this.formatResetMessage(active, !block.data?.isCompleted);
if (block.data?.isReset && block.data?.resetMessage) {
return this.formatResetMessage(block.data.resetMessage, !block.data?.isCompleted);
}
return null;
}
public isStreamRewinding(key: string): boolean {
return this.rewindTargets.has(key);
}
public isCheckerSegmentResetNotice(item: any): boolean {
if (!item?.data?.isReset) return false;
const text = item.data.text;
return !text || text.trim().length === 0;
}
public getStreamDisplayText(key: string, fallback: string = ''): string {
const typed = this.typedTextsSignal()[key];
return typed !== undefined ? typed : fallback;
}
private triggerDelayedCollapse(execId: string) {
if (!this.scheduledCollapses.has(execId)) {
this.scheduledCollapses.add(execId);
setTimeout(() => {
this.collapsedStreams.update((set: Set<string>) => {
const newSet = new Set(set);
newSet.add(execId);
return newSet;
});
}, 1000);
}
}
public onDetailsToggle(event: Event, execId: string): void {
const details = event.target as HTMLDetailsElement;
const isOpen = details.open;
this.collapsedStreams.update((set: Set<string>) => {
const newSet = new Set(set);
if (isOpen) {
newSet.delete(execId);
} else {
newSet.add(execId);
}
return newSet;
});
}
public toggleCollapse(execId: string): void {
this.collapsedStreams.update((set: Set<string>) => {
const newSet = new Set(set);
if (newSet.has(execId)) {
newSet.delete(execId);
} else {
newSet.add(execId);
}
return newSet;
});
}
// Android Action Delegation Methods
public getActionObject(action: any): any {
return getActionObject(action);
}
public isAndroidAction(action: any): boolean {
return isAndroidAction(action);
}
public formatTaskDuration(start?: number, end?: number): string {
if (!start || !Number.isFinite(start)) return '—';
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
}
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);
}
public getActionTitle(action: any): string {
return getActionTitle(action);
}
public getActionTargetText(action: any): string {
return getActionTargetText(action);
}
public getActionInputLabel(action: any): string {
return getActionInputLabel(action);
}
public getActionInputText(action: any): string {
return getActionInputText(action);
}
public getActionCoords(action: any): string {
return getActionCoords(action);
}
public isActionFailed(action: any, stepData?: any): boolean {
return isActionFailed(action, stepData);
}
public getActionErrorMessage(action: any, stepData?: any): string {
return getActionErrorMessage(action, stepData);
}
public isActionExpanded(cardId: string, defaultExpanded: boolean = false): boolean {
if (this.collapsedActionCards().has(cardId)) return false;
if (this.expandedActionCards().has(cardId)) return true;
return defaultExpanded;
}
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
if (event) {
event.stopPropagation();
const selection = window.getSelection();
if (selection && !selection.isCollapsed && event.currentTarget instanceof Node
&& selection.containsNode(event.currentTarget, true)) {
return;
}
}
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
if (isCurrentlyExpanded) {
this.expandedActionCards.update(set => {
const newSet = new Set(set);
newSet.delete(cardId);
return newSet;
});
this.collapsedActionCards.update(set => {
const newSet = new Set(set);
newSet.add(cardId);
return newSet;
});
} else {
this.collapsedActionCards.update(set => {
const newSet = new Set(set);
newSet.delete(cardId);
return newSet;
});
this.expandedActionCards.update(set => {
const newSet = new Set(set);
newSet.add(cardId);
return newSet;
});
}
}
public getActionBounds(action: any): string {
return getActionBounds(action);
}
public getActionResourceId(action: any): string {
return getActionResourceId(action);
}
public getActionClass(action: any): string {
return getActionClass(action);
}
public getActionExtraParams(action: any): ActionParam[] {
return extractActionExtraParams(action, this.actionParamsCache);
}
public getToolExtraParams(toolData: any): ActionParam[] {
return extractToolExtraParams(toolData, this.toolParamsCache);
}
public getStepPreImageUrl(stepData: any, actionData?: any): string | null {
return getStepPreImageUrl(stepData, actionData);
}
public getStepPostImageUrl(stepData: any, actionData?: any): string | null {
return getStepPostImageUrl(stepData, actionData);
}
// Zoom Modal & Overlay Methods
public openImageModal(url: string, actionObj?: any, event?: Event): void {
if (event) {
event.stopPropagation();
}
this.selectedZoomImage.set(url);
this.selectedZoomAction.set(actionObj || null);
}
public closeImageModal(event?: Event): void {
if (event) {
event.stopPropagation();
}
this.selectedZoomImage.set(null);
this.selectedZoomAction.set(null);
}
public onImageLoad(img: HTMLImageElement, overlay: HTMLElement, actionObj: any): void {
if (!img || !overlay) return;
try {
this.drawActionCoordinatesOnOverlay(img, overlay, actionObj);
} catch (e) {
console.warn('Failed to draw action overlay:', e);
}
}
public drawActionCoordinatesOnOverlay(img: HTMLImageElement, overlay: HTMLElement, actionObj: any): void {
drawActionCoordinatesOnOverlay(img, overlay, actionObj);
}
// Generic Tool Delegation Methods
/**
* First save_note call per note key, derived once per blocks change instead
* of re-scanning every block for every rendered tool row.
*/
private firstSaveNoteByKey = computed<Map<string, any>>(() => {
const firstByKey = new Map<string, any>();
for (const block of this.consolidatedBlocks()) {
if (block.type === 'step' && block.data.generic_tools) {
for (const t of this.getUniqueGenericTools(block.data.generic_tools)) {
if (t.name && t.name.toLowerCase() === 'save_note') {
const key = this.getToolKey(t);
if (key && !firstByKey.has(key)) {
firstByKey.set(key, t);
}
}
}
}
}
return firstByKey;
});
public isFirstSaveNoteForKey(tool: any): boolean {
if (!tool || !tool.name || tool.name.toLowerCase() !== 'save_note') {
return false;
}
const key = this.getToolKey(tool);
if (!key) return true;
const firstCall = this.firstSaveNoteByKey().get(key);
if (!firstCall) return true;
return (tool.trace_id && firstCall.trace_id)
? tool.trace_id === firstCall.trace_id
: tool === firstCall;
}
public getToolDisplayLabel(tool: any): string {
return getToolDisplayLabel(tool, this.isFirstSaveNoteForKey(tool));
}
/** Short plain-language phase for a compress_history line (hover title). */
public getCompressionPhaseTitle(tool: any): string | null {
return isCompressionTool(tool) ? getCompressionPhaseLabel(tool) : null;
}
/**
* A generic tool line pulses only while work is in flight. A compression
* whose summary is ready but held back by the context gate is waiting, not
* running, even though its trace status is still `running`.
*/
public isToolLineRunning(item: any, block: any): boolean {
return this.isItemRunning(item, block) && !isCompressionWaiting(item?.data);
}
public isNoteTool(tool: any): boolean {
return isNoteTool(tool);
}
public isVideoTool(tool: any): boolean {
return isVideoTool(tool);
}
public getVideoToolTarget(tool: any): string | null {
return getVideoToolTarget(tool);
}
public getVideoAnalysisLabel(tool: any): string {
return getVideoAnalysisView(tool)?.title || "正在分析屏幕录像";
}
public getVideoAnalysisRangeLabel(tool: any): string {
const range = getVideoAnalysisView(tool)?.requestedRange;
if (!range) return "屏幕录像";
return `${formatVideoTime(range.start)}–${formatVideoTime(range.end)}`;
}
public getVideoAnalysisDetail(tool: any): string {
const view = getVideoAnalysisView(tool);
if (!view || view.totalCount <= 1) return '';
if (view.outcome === 'running' || view.outcome === 'recovering') {
return view.completedCount > 0
? `${view.completedCount}/${view.totalCount} 段已保存`
: '';
}
if (view.outcome === 'partial') {
return `${view.completedCount}/${view.totalCount} 段已保存`;
}
return '';
}
public isVideoAnalysisAttention(tool: any): boolean {
const outcome = getVideoAnalysisView(tool)?.outcome;
return outcome === 'partial' || outcome === 'failed';
}
public onVideoToolClick(toolData: any): void {
const curSessionId = this.agentService.currentSessionId();
if (curSessionId) {
const start = getVideoAnalysisView(toolData)?.requestedRange?.start;
this.agentService.openVideoPlayer(curSessionId, undefined, undefined, start);
}
}
public evidenceSourceLabel(item: any): string { return actionEvidenceSource(item).label; }
public openEvidenceVideo(item: any, step: any, event: Event): void {
event.stopPropagation();
const started = this.agentService.currentSession()?.start_time;
const value = item?.timestamp || item?.start_time || step?.timestamp;
const timestamp = typeof value === 'number' ? value : Date.parse(value) / 1000;
if (!started || !Number.isFinite(timestamp)) return;
this.agentService.openVideoPlayer(undefined, undefined, undefined, Math.max(0, timestamp - started), undefined, actionEvidenceSource(item).id);
}
public getScreenRecordingButtonTitle(): string {
if (this.agentService.isCurrentSessionRunning()) {
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
}
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
return "正在准备屏幕录像…";
}
if (this.agentService.currentSessionVideoUrl()) {
return "播放屏幕录像";
}
if (this.agentService.hasCurrentSessionStepFrames()) {
return this.agentService.currentSessionRecordingStatus() === 'failed'
? "录屏失败,点击逐步回放截图"
: "逐步回放截图";
}
if (this.agentService.currentSessionRecordingStatus() === 'failed') {
return "屏幕录像生成失败";
}
return "屏幕录像";
}
public isDeviceActionTool(tool: any): boolean {
return isDeviceActionTool(tool);
}
public getPluginToolView = getPluginToolView;
public isEvidenceTool(tool: any): boolean {
return isDeviceActionTool(tool) || getPluginToolView(tool) !== null;
}
public isFailureAnalyzerActionTool(tool: any): boolean {
return isDeviceActionTool(tool);
}
public getToolAgentName(tool: any): string | null {
return getToolAgentName(tool);
}
public shouldShowTool(tool: any, stepData?: any): boolean {
return shouldShowTool(tool, stepData);
}
public getUniqueGenericTools(tools: any[] | undefined): any[] {
return getUniqueGenericTools(tools);
}
public getSortedStepEvents(stepData: any): StepEvent[] {
return getSortedStepEvents(stepData, this.sortedEventsCache);
}
/**
* Memoized per block-data object: templates call these many times per
* change-detection pass, and the raw-thinking path runs JSON-detection
* heuristics over the full text.
*/
private getThinkingTexts(data: any): { native: string | null; raw: string | null } {
let entry = this.thinkingTextCache.get(data);
if (!entry) {
const nativeText = data.operator_native_thinking || (data.stream_type === 'thinking' ? data.text : null);
const rawText = data.operator_raw_thinking || (data.stream_type === 'text' ? data.text : null);
entry = {
native: nativeText && nativeText.trim() ? nativeText : null,
raw: rawText && rawText.trim() && this.isHumanThinking(rawText) ? rawText : null
};
this.thinkingTextCache.set(data, entry);
}
return entry;
}
public getNativeThinking(block: any): string | null {
if (!block || !block.data) return null;
return this.getThinkingTexts(block.data).native;
}
public getRawThinking(block: any): string | null {
if (!block || !block.data) return null;
return this.getThinkingTexts(block.data).raw;
}
// --- Check blocks (verifier lane) ----------------------------------------------
public isCheckerBlock(block: any): boolean {
return block?.type === 'checker';
}
public isCheckerPhase(phase: any): boolean {
const blocks: any[] = Array.isArray(phase?.blocks) ? phase.blocks : [];
return blocks.length > 0 && blocks.every((b) => this.isCheckerBlock(b));
}
public getCheckerPhaseLabel(block: any): string {
switch (block?.data?.phase) {
case 'final':
return "最终检查";
case 'outcome':
return "结果";
default:
return "勾选";
}
}
public getCheckerVerdicts(block: any): any[] {
return Array.isArray(block?.data?.verdicts) ? block.data.verdicts : [];
}
/** Declared check items; persisted attempts only carry verdicts, so derive them. */
public getCheckerItems(block: any): any[] {
const d = block?.data || {};
if (Array.isArray(d.items) && d.items.length > 0) return d.items;
return this.getCheckerVerdicts(block).map((v) => ({ kind: v.kind, text: v.item_text, when: v.when }));
}
/** How the item is judged, in plain words (verify = must pass, assert = recorded test result). */
public getCheckMethodLabel(item: any, block: any): string {
const parts: string[] = [item?.kind === 'assert' ? "测试断言" : "必须通过"];
if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push("结束时");
return parts.join(' · ');
}
/** Superseded / unchecked attempts collapse to their header line. */
public isCheckerMuted(block: any): boolean {
const status = block?.data?.status;
return status === 'superseded' || status === 'unchecked';
}
/** Number of the check item a verdict belongs to (1-based, matches the list above the stream). */
public getVerdictItemNumber(block: any, verdict: any, fallbackIndex: number): number {
const idx = this.getCheckerItems(block).findIndex((i) => i.text === verdict?.item_text);
return (idx > -1 ? idx : fallbackIndex) + 1;
}
public getVerdictStatusText(status: string): string {
switch (status) {
case 'passed':
return "已通过";
case 'failed':
return "失败";
case 'inconclusive':
return "无法确认";
case 'superseded':
return "已被较新的检查替代";
case 'unchecked':
return "未检查";
default:
return status || '';
}
}
public hasCheckerResult(block: any): boolean {
if (!this.isCheckerBlock(block) || this.isBlockRunning(block) || this.isCheckerMuted(block)) return false;
if (block.data?.phase === 'outcome') return this.getCheckerNotes(block).length > 0;
return this.getCheckerVerdicts(block).length > 0 || this.getCheckerNotes(block).length > 0;
}
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
public getCheckerNotes(block: any): string[] {
const d = block?.data || {};
if (d.phase === 'outcome') {
return [d.test_result?.reason, ...(d.test_result?.evidence || []), ...(Array.isArray(d.last_findings) ? d.last_findings : [])].filter(Boolean);
}
const notes: string[] = [];
if (Array.isArray(d.unmet_subgoals)) {
for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`);
}
if (d.reverted) notes.push("此步骤已退回重新执行。");
if (d.error) notes.push(String(d.error));
return notes;
}
// Streamed turns of a check: the last one is live while the block runs;
// finished Thought turns fold unless the reader opens them.
public openCheckSegments = signal<Set<string>>(new Set<string>());
public isCheckerSegmentRunning(block: any, itemIdx: number): boolean {
if (!this.isBlockRunning(block)) return false;
return itemIdx === this.getSortedStepEvents(block.data).length - 1;
}
public isCheckerSegmentCollapsed(block: any, itemIdx: number): boolean {
if (this.isCheckerSegmentRunning(block, itemIdx)) return false;
return !this.openCheckSegments().has(`${block.id}-${itemIdx}`);
}
public toggleCheckerSegment(block: any, itemIdx: number): void {
const key = `${block.id}-${itemIdx}`;
this.openCheckSegments.update((set) => {
const next = new Set(set);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
public isCheckerTextVisible(item: any): boolean {
if (item?.data?.isReset) return true;
const text = item?.data?.text;
return typeof text === 'string' && text.trim().length > 0 && this.isHumanThinking(text);
}
public getCheckerTone(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
if (d.test_result) return d.test_result.status === 'blocked' ? 'inconclusive' : d.test_result.status;
if (d.tests?.failed) return 'failed';
return d.tests?.passed && !d.tests?.unchecked && !d.tests?.inconclusive ? 'passed' : 'inconclusive';
}
if (d.isCompleted === false) return 'running';
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
if (d.status === 'error') return 'inconclusive';
const verdicts = this.getCheckerVerdicts(block);
if (verdicts.some((v) => v.status === 'failed')) return 'failed';
if (verdicts.some((v) => v.status === 'inconclusive')) return 'inconclusive';
return 'passed';
}
public getCheckerStatusLabel(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
const t = d.tests || {};
const label = d.test_result ? TEST_RESULT_LABELS[d.test_result.status as keyof typeof TEST_RESULT_LABELS]
: (d.task_status === 'completed' ? "执行完成" : (d.task_status === 'blocked' ? "执行受阻" : "部分完成"));
const counts = [
[t.passed, '项通过'],
[t.failed, '项不通过'],
[t.inconclusive, '项无法确认'],
[t.unchecked, "未检查"]
]
.filter(([n]) => Number(n) > 0)
.map(([n, word]) => `${n} ${word}`);
return counts.length > 0 ? `${label} · ${counts.join(' · ')}` : label;
}
if (d.isCompleted === false) return "正在检查…";
switch (d.status) {
case 'superseded':
return "已替代";
case 'unchecked':
return "未检查";
case 'error':
return "暂无结论";
}
const verdicts = this.getCheckerVerdicts(block);
const failed = verdicts.filter((v) => v.status === 'failed').length;
if (failed > 0) return `${failed} failed`;
if (verdicts.some((v) => v.status === 'inconclusive')) return "无法确定";
return verdicts.length > 0 ? "已通过" : "完成";
}
public getVerdictIcon(status: string): string {
switch (status) {
case 'passed':
return 'check_circle';
case 'failed':
return 'cancel';
case 'inconclusive':
return 'help';
case 'superseded':
return 'history';
case 'unchecked':
return 'radio_button_unchecked';
default:
return 'pending';
}
}
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);
const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) =>
this.shouldShowTool(t, block.data)
|| this.isDisplayableLLMFailure(t)
|| this.isLLMRetry(t)
|| this.isReportStatusAction(t)
|| this.isBackendSwitchNote(t)
);
const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken));
return hasNative || hasRaw || hasReset || hasVisibleTools || hasAndroidActions;
}
public isReportStatusAction(action: any): boolean {
return isReportStatusAction(action);
}
public getReportStatusValue(action: any): string {
return getReportStatusValue(action);
}
public getReportStatusExplanation(action: any): string {
return getReportStatusExplanation(action);
}
public isHumanThinking(text: string | null): boolean {
return isHumanThinking(text);
}
public isTerminalLLMFailure(tool: any): boolean {
return !!tool && this.retryablePauseTrace() === tool;
}
public isDisplayableLLMFailure(tool: any): boolean {
return tool?.type === 'llm_call'
&& tool?.status === 'failed'
&& (tool?.name === 'llm_pause' || tool?.payload?.pause === true);
}
/** Mid-run change of the UI-hierarchy source (helper <-> UIAutomator2). */
public isBackendSwitchNote(tool: any): boolean {
return isBackendSwitchNote(tool);
}
public getBackendSwitchMessage(tool: any): string {
return String(tool?.payload?.message || "界面层级来源已变更");
}
public resumePausedTask(event: Event): void {
event.stopPropagation();
this.agentService.resumeTask();
}
public getLLMErrorText(tool: any): string {
const noDetails = "模型请求失败,接口未返回错误详情。";
if (!tool) return noDetails;
const rawError = tool.payload?.error || tool.error;
if (!rawError) return noDetails;
const cleaned = this.cleanErrorMessage(rawError);
return cleaned === "未知错误" ? noDetails : cleaned;
}
public isLLMRetry(tool: any): boolean {
return tool?.type === 'llm_call'
&& tool?.status === 'retrying'
&& tool?.payload?.source === 'provider_sdk'
&& ['google', 'gemini'].includes(String(tool?.payload?.provider || '').toLowerCase());
}
public getLLMRetryEntries(tool: any): any[] {
const retries = tool?.payload?.retries;
return Array.isArray(retries) && retries.length > 0 ? retries : [tool?.payload || tool];
}
public getLLMFailureRetryEntries(tool: any): any[] {
const retries = tool?.payload?.retries;
if (!Array.isArray(retries)) return [];
return retries.filter((retry: any) =>
retry?.source === 'provider_sdk'
&& ['google', 'gemini'].includes(String(retry?.provider || '').toLowerCase())
);
}
public hasLLMFailureRetryEntries(tool: any): boolean {
return this.getLLMFailureRetryEntries(tool).length > 0;
}
public getLLMRetryEntryDelay(entry: any): string | null {
const delay = Number(entry?.delay);
if (!Number.isFinite(delay) || delay <= 0) return null;
return `${delay.toFixed(2).replace(/\.00$/, '')}s`;
}
public getLLMRetryEntryWaited(entry: any): string {
const delay = Number(entry?.delay);
if (!Number.isFinite(delay) || delay <= 0) return '0s';
const rawStartedAt = Number(entry?.scheduled_at ?? entry?.timestamp);
if (!Number.isFinite(rawStartedAt) || rawStartedAt <= 0) {
return this.formatLLMDuration(delay);
}
const startedAtMs = rawStartedAt < 1e11 ? rawStartedAt * 1000 : rawStartedAt;
const elapsed = Math.min(delay, Math.max(0, (this.retryClock() - startedAtMs) / 1000));
return this.formatLLMDuration(elapsed);
}
public isLLMRetryEntryWaiting(entry: any): boolean {
const delay = Number(entry?.delay);
const rawStartedAt = Number(entry?.scheduled_at ?? entry?.timestamp);
if (!Number.isFinite(delay) || delay <= 0 || !Number.isFinite(rawStartedAt)) return false;
const startedAtMs = rawStartedAt < 1e11 ? rawStartedAt * 1000 : rawStartedAt;
return this.retryClock() < startedAtMs + delay * 1000;
}
public getLLMFailureWaited(tool: any): string | null {
const waited = Number(tool?.payload?.waited_seconds);
if (!Number.isFinite(waited) || waited < 0) return null;
return this.formatLLMDuration(waited);
}
public formatLLMDuration(seconds: number): string {
if (seconds < 0.05) return '0s';
if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
return `${Math.round(seconds)}s`;
}
public cleanErrorMessage(rawError: any): string {
return cleanErrorMessage(rawError);
}
public getToolKey(tool: any): string | null {
return getToolKey(tool);
}
public getToolCallName(action: any): string {
const act = this.getActionObject(action);
if (!act) return "工具";
const label = this.getToolDisplayLabel(act);
const key = this.getToolKey(act);
return key ? `${label}: ${key}` : label;
}
public getToolCallLabel(action: any): string {
return this.getToolCallName(action);
}
public isToolFailed(tool: any): boolean {
return isToolFailed(tool);
}
public getToolErrorMessage(tool: any): string {
return getToolErrorMessage(tool);
}
public getToolArgs(tool: any): any {
return getToolArgs(tool);
}
public getToolIcon(tool: any): string {
const plugin = getPluginToolView(tool);
return plugin ? (plugin.auxiliary ? 'visibility' : 'extension') : getToolIcon(tool);
}
public getToolTitle(tool: any): string {
return getPluginToolView(tool)?.title || getToolTitle(tool);
}
public getToolTargetText(tool: any): string {
return getToolTargetText(tool);
}
public getToolInputLabel(tool: any): string {
return getToolInputLabel(tool);
}
public getToolInputText(tool: any): string {
return getToolInputText(tool);
}
public getToolCoords(tool: any): string {
return getToolCoords(tool);
}
public getToolAnalysisText(tool: any): string {
return getToolAnalysisText(tool);
}
public getToolGenericDetails(tool: any): string {
return getToolGenericDetails(tool);
}
public isAdbCommandTool(tool: any): boolean {
return isAdbCommandTool(tool);
}
public getAdbCommandLine(tool: any): string {
return getAdbCommandLine(tool);
}
public getAdbCwd(tool: any): string {
return getAdbCwd(tool);
}
public getAdbTerminalId(tool: any): string {
return getAdbTerminalId(tool);
}
public onSessionChange(event: Event): void {
const selectElement = event.target as HTMLSelectElement;
const sessionId = selectElement.value;
if (sessionId) {
this.agentService.selectSession(sessionId, true);
}
}
public isStepEvent(eventType: string): boolean {
return eventType === 'step_recorded' || eventType === 'step_updated';
}
public getCheckerResult(text: string): CheckerResult | null {
return extractCheckerResult(text);
}
public isShortContent(data: any): boolean {
if (!data) return true;
const str = typeof data === 'string' ? data : JSON.stringify(data);
return str.length < 200;
}
public formatJson(data: any): string {
if (typeof data === 'string') {
try {
return JSON.stringify(JSON.parse(data), null, 2);
} catch {
return data;
}
}
return JSON.stringify(data, null, 2);
}
public getStatusLabel(status: string): string {
switch (status) {
case 'idle':
return "空闲";
case 'running':
return "执行中";
case 'completed':
return "已完成";
case 'offline':
return "离线";
default:
return status.toUpperCase();
}
}
/**
* Render markdown with a per-slot memo. Returning the identical cached
* string instance for unchanged text means the [innerHTML] binding sees the
* same reference and skips DOM/sanitizer work entirely.
*/
public renderMarkdown(text: string, cacheKey?: string): string {
if (!text) return '';
if (!cacheKey) {
return renderMarkdownToHtml(text);
}
const cached = this.markdownHtmlCache.get(cacheKey);
if (cached && cached.source === text) {
return cached.html;
}
const html = renderMarkdownToHtml(text);
this.markdownHtmlCache.set(cacheKey, { source: text, html });
return html;
}
public onToolKeyClick(key: string): void {
this.agentService.selectedNoteKey.set(key);
this.agentService.activeTab.set('notes');
const sessionId = this.agentService.currentSessionId();
if (sessionId) {
this.agentService.fetchNotes(sessionId);
}
}
public trackSession(index: number, session: Session): string {
return session.session_id;
}
public trackNoteKey(index: number, key: string): string {
return key;
}
public trackMilestone(index: number, milestone: NoteMilestone): number {
return milestone.index;
}
public trackMarkdownLine(index: number, line: MarkdownLine): number {
return index;
}
public trackPhase(index: number, phase: any): string {
return phase.id;
}
public trackBlock(index: number, block: any): string {
return block.id;
}
public trackTool(index: number, tool: any): string {
return tool.trace_id || index.toString();
}
public trackStepEvent(index: number, item: { type: string; data: any; timestamp?: number }): string {
if (!item || !item.data) return index.toString();
// A step has at most one thinking and one text event; keying them by type
// alone keeps their DOM stable while the streamed text grows (keying by
// text length would tear down and rebuild the node on every chunk).
if (item.type === 'thinking' || item.type === 'text') {
return item.type;
}
return item.type + '-' + (item.data.trace_id
|| item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id
|| ((item.data.action || item.data.name || '') + '-' + index));
}
public trackParam(index: number, param: { key: string; value: string }): string {
return param.key;
}
public getModelDisplayName(name: string | undefined | null): string {
if (!name) return 'Flash';
const lower = name.toLowerCase();
if (lower.includes('pro')) return 'Pro';
if (lower.includes('flash')) return 'Flash';
return name.replace(/^artemis\s+/i, '');
}
public getModelIcon(name: string | undefined | null): string {
if (!name) return 'bolt';
const lower = name.toLowerCase();
if (lower === '探索') return 'explore';
if (lower.includes('pro')) return 'diamond';
if (lower.includes('flash')) return 'bolt';
return 'smart_toy';
}
public getModelClass(name: string | undefined | null): string {
if (!name) return 'is-flash';
const lower = name.toLowerCase();
if (lower.includes('pro')) return 'is-pro';
if (lower.includes('flash')) return 'is-flash';
return '';
}
public getArchitectureTooltip(model?: ModelInfo | null): string {
if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
if (model.architecture === 'explore') return `探索模式(可交流与人工示范) · LLM: ${model.id}`;
const name = this.getModelDisplayName(model.name);
const isPro = name.toLowerCase().includes('pro');
const archDesc = isPro
? "ARTEMIS Pro(多智能体规划)"
: "ARTEMIS Flash(快速响应)";
if (model.id) {
return `智能体架构:${archDesc} · LLM: ${model.id} (${model.provider || 'google'})`;
}
return `智能体架构:${archDesc}`;
}
public formatTokenCount(tokens?: number): string {
return formatTokenCount(tokens);
}
public getTokenTooltip(phase: PhaseBlock): string {
if (!phase.tokens) return '';
if (phase.promptTokens && phase.completionTokens) {
return `已使用:${phase.tokens.toLocaleString()} tokens (${phase.promptTokens.toLocaleString()} 输入 / ${phase.completionTokens.toLocaleString()} 输出)`;
}
return `已使用:${phase.tokens.toLocaleString()} tokens`;
}
public isCurrentSessionActive(): boolean {
const status = this.agentService.agentStatus();
if (status !== 'running') return false;
const currentId = this.agentService.currentSessionId();
const runningId = this.agentService.runningSessionId();
if (currentId && runningId && currentId !== runningId) {
return false;
}
const currentSession = this.agentService.sessions().find(s => s.session_id === currentId);
if (currentSession && (currentSession.status === 'completed' || currentSession.status === 'failed' || currentSession.status === 'cancelled')) {
return false;
}
return true;
}
public isItemRunning(item: any, block: any): boolean {
if (!item || !item.data) return false;
if (!this.isCurrentSessionActive()) return false;
if (item.data.status === 'success' || item.data.status === 'failed' || item.data.status === 'cancelled') return false;
if (this.isReportStatusAction(item.data) || this.isReportStatusAction(item.data?.action_taken)) return false;
if (item.data.status === 'running') return true;
// Check if session is currently running and this is the active trailing item
const blocks = this.consolidatedBlocks();
const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
if (lastBlock && (lastBlock.id === block?.id || !block?.data?.isCompleted)) {
const events = this.getSortedStepEvents(block.data);
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
if (lastEvent && (lastEvent.data?.trace_id === item.data?.trace_id || lastEvent === item)) {
return true;
}
}
return false;
}
public isBlockRunning(block: any): boolean {
if (!block || !block.data) return false;
if (!this.isCurrentSessionActive()) return false;
if (block.type === 'checker') return block.data.isCompleted === false;
if (this.isReportStatusAction(block.data.action_taken) || block.data?.status === 'completed' || block.data?.status === 'failed' || block.data?.status === 'cancelled') return false;
if (block.data.isCompleted === false) return true;
const blocks = this.consolidatedBlocks();
const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
return lastBlock?.id === block.id;
}
}