Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
2184 lines
82 KiB
TypeScript
2184 lines
82 KiB
TypeScript
/**
|
|
* 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 { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { Observable, finalize, tap, timeout } from 'rxjs';
|
|
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
|
import { SharedLiveStream } from './shared-live-stream';
|
|
|
|
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
|
|
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
|
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
|
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
|
import { persistedStreamToSegments } from '../utils/stream-aggregator.util';
|
|
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice };
|
|
|
|
const SESSION_CACHE_KEY = 'artemis.sessions.v1';
|
|
|
|
export interface VideoSegment {
|
|
url: string;
|
|
/** Back-to-back playlist timeline start (seconds); gaps between segments are not represented. */
|
|
start: number;
|
|
duration: number;
|
|
width: number;
|
|
height: number;
|
|
/** Session-relative first-frame offset in milliseconds (manifest v2). */
|
|
offset_ms?: number;
|
|
duration_ms?: number;
|
|
}
|
|
|
|
export interface RecordingSource {
|
|
id: string;
|
|
label: string;
|
|
kind: string;
|
|
target_ids: string[];
|
|
status: string;
|
|
message?: string;
|
|
codec?: string;
|
|
segments: VideoSegment[];
|
|
url?: string;
|
|
}
|
|
|
|
export type RecordingPlaybackStatus =
|
|
| 'idle'
|
|
| 'live'
|
|
| 'processing'
|
|
| 'ready'
|
|
| 'failed'
|
|
| 'unavailable';
|
|
|
|
export interface StartupProgressEvent {
|
|
session_id?: string;
|
|
stage: string;
|
|
message: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface GuidanceEvent {
|
|
id: string;
|
|
instruction: string;
|
|
source: string;
|
|
timestamp: number;
|
|
status: 'pending' | 'consumed';
|
|
consumed_at?: number;
|
|
}
|
|
|
|
interface SessionVideoResponse {
|
|
sources?: RecordingSource[];
|
|
session_timeline?: boolean;
|
|
session_duration?: number;
|
|
session_id: string;
|
|
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
|
has_video: boolean;
|
|
video_url: string | null;
|
|
video_segments?: VideoSegment[];
|
|
retry_after_ms?: number;
|
|
message?: string;
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class AgentService {
|
|
private http = inject(HttpClient);
|
|
private zone = inject(NgZone);
|
|
private activePauseCardKey: string | null = null;
|
|
|
|
// Signals to expose state to components
|
|
private rawSessions = signal<Session[]>([]);
|
|
private pendingQueue = signal<Session[]>([]);
|
|
public activeTasks = signal<any[]>([]);
|
|
public batchFilter = signal('');
|
|
public resultRevision = signal(0);
|
|
|
|
public matchesBatchFilter(session: Session): boolean {
|
|
const filter = this.batchFilter();
|
|
if (!filter) return true;
|
|
if (filter === '__batch__') return !!session.batch_id;
|
|
if (filter === '__single__') return !session.batch_id && session.exploration_kind !== 'exploration';
|
|
if (filter === '__exploration__') return session.exploration_kind === 'exploration';
|
|
return session.batch_id === filter;
|
|
}
|
|
|
|
public explorationSessionFields(item: any): Partial<Session> {
|
|
if (!item?.exploration_id) return {};
|
|
return {
|
|
exploration_id:item.exploration_id, exploration_kind:item.exploration_kind,
|
|
exploration_latest_run_id:item.exploration_latest_run_id, exploration_status:item.exploration_status,
|
|
exploration_run_count:item.exploration_run_count, case_version:item.case_version,
|
|
...(item.display_name ? {display_name:item.display_name} : {}),
|
|
...(item.exploration_kind === 'exploration' ? {model_info:{name:'探索',id:item.exploration_model,provider:'openai',architecture:'explore'}} : {})
|
|
};
|
|
}
|
|
public isQueueRepresentative(session: Session): boolean {
|
|
return session.exploration_kind !== 'exploration' || !session.exploration_latest_run_id || session.exploration_latest_run_id === session.session_id;
|
|
}
|
|
public explorationQueueStatus(session: Session): string | null {
|
|
if (session.exploration_kind !== 'exploration') return null;
|
|
const status = session.exploration_status;
|
|
if (status === 'finalizing') return 'running';
|
|
if (['paused','waiting_user','demonstrating','analyzing_demo','interrupted'].includes(status || '')) return 'paused';
|
|
if (['draft','queued','reserved'].includes(status || '')) return 'pending';
|
|
return status || null;
|
|
}
|
|
|
|
public explorationStatusLabel(session: Session): string | null {
|
|
if(session.exploration_kind !== 'exploration') return null;
|
|
return ({waiting_user:'等待你回答',demonstrating:'人工示范中',analyzing_demo:'分析示范中',paused:'已暂停 · 可继续',finalizing:'正在提炼用例',running:'探索中',completed:'探索结束'} as Record<string,string>)[session.exploration_status || ''] || null;
|
|
}
|
|
|
|
public applyCaseReview(review: CaseReviewResponse): void {
|
|
this.rawSessions.update(items => items.map(session => session.session_id === review.id
|
|
? {...session, test_result: review.test_result} : session));
|
|
this.resultRevision.update(value => value + 1);
|
|
this.fetchSessions();
|
|
}
|
|
// Persistent tracking of active/pending sessions across polling boundaries
|
|
private activeSessionTracking = new Map<string, Session>();
|
|
|
|
public sessions = computed(() => {
|
|
const raw = this.rawSessions();
|
|
const pending = this.pendingQueue();
|
|
const status = this.agentStatus();
|
|
const goal = this.runningGoal();
|
|
const runId = this.runningSessionId();
|
|
const activeList = this.activeTasks();
|
|
|
|
const sessionMap = new Map<string, Session>();
|
|
// Lookup maps keep the per-session merge O(1) instead of scanning the
|
|
// pending/active lists multiple times for every raw session.
|
|
const pendingById = new Map(pending.map((p) => [p.session_id, p]));
|
|
const activeById = new Map(activeList.map((at) => [at.session_id, at]));
|
|
|
|
// 1. First add raw sessions from DB
|
|
raw.forEach((s) => {
|
|
const activeMatch = activeById.get(s.session_id);
|
|
const pendingMatch = pendingById.get(s.session_id);
|
|
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
|
|| !!activeMatch;
|
|
const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
|
|
let sStatus = s.status;
|
|
if (!isTerminal) {
|
|
const isPending = !!pendingMatch && !isCurrentActive;
|
|
sStatus = isCurrentActive
|
|
? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running'))
|
|
: (isPending ? 'pending' : s.status);
|
|
} else {
|
|
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
|
}
|
|
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
|
|
if (!serial && s.device_info) {
|
|
try {
|
|
const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info;
|
|
serial = info?.device_id || info?.device_serial || null;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
const finalSession: Session = {
|
|
...s,
|
|
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
|
status: sStatus,
|
|
device_serial: serial,
|
|
model_info: !s.exploration_id && isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
|
};
|
|
sessionMap.set(s.session_id, finalSession);
|
|
if (isTerminal) {
|
|
this.activeSessionTracking.delete(s.session_id);
|
|
} else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') {
|
|
this.activeSessionTracking.set(s.session_id, finalSession);
|
|
}
|
|
});
|
|
|
|
// 2. Add pending queue sessions if not yet in raw sessions
|
|
pending.forEach((p) => {
|
|
if (!sessionMap.has(p.session_id)) {
|
|
const activeMatch = activeById.get(p.session_id);
|
|
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|
|
|| !!activeMatch;
|
|
const finalPending: Session = {
|
|
...p,
|
|
batch_id: p.batch_id || activeMatch?.batch_id || null,
|
|
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
|
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
|
};
|
|
sessionMap.set(p.session_id, finalPending);
|
|
this.activeSessionTracking.set(p.session_id, finalPending);
|
|
}
|
|
});
|
|
|
|
// 3. Ensure all currently active running sessions are present (multi-device & external runs)
|
|
if (activeList.length > 0) {
|
|
activeList.forEach((at) => {
|
|
const sid = at.session_id || `active-${at.device_id}`;
|
|
const existing = sessionMap.get(sid);
|
|
if (!existing) {
|
|
const newSession: Session = {
|
|
...this.explorationSessionFields(at),
|
|
session_id: sid,
|
|
batch_id: at.batch_id || null,
|
|
display_name: at.display_name,
|
|
initial_goal: at.goal || goal || '',
|
|
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
|
status: at.status || 'running',
|
|
model_info: this.explorationSessionFields(at).model_info || this.activeModel() || undefined,
|
|
device_serial: at.device_id || null
|
|
};
|
|
sessionMap.set(sid, newSession);
|
|
this.activeSessionTracking.set(sid, newSession);
|
|
} else if (existing.status === 'pending') {
|
|
const updated: Session = {
|
|
...existing,
|
|
status: 'running',
|
|
device_serial: at.device_id || existing.device_serial
|
|
};
|
|
sessionMap.set(sid, updated);
|
|
this.activeSessionTracking.set(sid, updated);
|
|
}
|
|
});
|
|
} else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
|
const activeSession: Session = {
|
|
session_id: runId,
|
|
initial_goal: goal || '',
|
|
start_time: Date.now() / 1000,
|
|
status,
|
|
model_info: this.activeModel() || undefined
|
|
};
|
|
sessionMap.set(runId, activeSession);
|
|
this.activeSessionTracking.set(runId, activeSession);
|
|
}
|
|
|
|
// 4. Bridge transient queue-to-running transition gaps
|
|
this.activeSessionTracking.forEach((ts, sid) => {
|
|
if (!sessionMap.has(sid)) {
|
|
sessionMap.set(sid, {
|
|
...ts,
|
|
status: 'running'
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
|
|
});
|
|
|
|
public currentSessionId = signal<string | null>(null);
|
|
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
|
public guidanceEvents = signal<GuidanceEvent[]>([]);
|
|
private guidanceSignature = '';
|
|
public isSessionContentLoading = signal<boolean>(false);
|
|
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
|
public runningSessionId = signal<string | null>(null);
|
|
public runningGoal = signal<string | null>(null);
|
|
public isPaused = signal<boolean>(false);
|
|
public pausedError = signal<string | null>(null);
|
|
public isRetrying = signal<boolean>(false);
|
|
public retryMessage = signal<string | null>(null);
|
|
/** Architecture / LLM of the task the status poll reports as running. */
|
|
public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
|
|
public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
|
|
public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
|
|
private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
|
|
|
|
public currentStartupProgress = computed(() => {
|
|
const sessionId = this.currentSessionId();
|
|
if (sessionId) {
|
|
const normalized = String(sessionId).trim().toLowerCase();
|
|
const bySession = this.startupProgressBySession();
|
|
const direct = bySession[sessionId];
|
|
if (direct?.length) return direct;
|
|
const matched = Object.entries(bySession).find(
|
|
([key]) => key.trim().toLowerCase() === normalized
|
|
);
|
|
if (matched?.[1]?.length) return matched[1];
|
|
}
|
|
return this.pendingStartupProgress();
|
|
});
|
|
|
|
// Notes and Tab States
|
|
public currentNotes = signal<Record<string, string>>({});
|
|
public selectedNoteKey = signal<string>('task_plan.md');
|
|
public activeTab = signal<'tasks' | 'notes' | 'automation'>('tasks');
|
|
|
|
// Video Replay Floating Window States
|
|
public isVideoWindowOpen = signal<boolean>(false);
|
|
public isVideoMinimized = signal<boolean>(false);
|
|
public activeVideoUrl = signal<string | null>(null);
|
|
public activeVideoSegments = signal<VideoSegment[]>([]);
|
|
public recordingSources = signal<RecordingSource[]>([]);
|
|
public selectedRecordingSource = signal<string>('');
|
|
public usesSessionTimeline = signal(false);
|
|
public recordingSessionDuration = signal(0);
|
|
|
|
public selectRecordingSource(id: string): boolean {
|
|
const source = this.recordingSources().find(s => s.id === id || s.target_ids?.includes(id));
|
|
if (!source) return false;
|
|
if (this.selectedRecordingSource() === source.id) return true;
|
|
this.selectedRecordingSource.set(source.id);
|
|
this.activeVideoSegments.set(source.segments);
|
|
this.activeVideoUrl.set(source.url || source.segments[0]?.url || null);
|
|
this.recordingPlaybackMessage.set(source.message || '');
|
|
return true;
|
|
}
|
|
public activeVideoTitle = signal<string>('');
|
|
public isVideoLoading = signal<boolean>(false);
|
|
public recordingPlaybackStatus = signal<RecordingPlaybackStatus>('idle');
|
|
public recordingPlaybackMessage = signal<string>('');
|
|
public shouldAutoplayVideo = signal<boolean>(false);
|
|
public videoSeekRequest = signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null);
|
|
public stepSeekRequest = signal<{ index: number; requestId: number } | null>(null);
|
|
public playerMode = signal<'video' | 'steps'>('video');
|
|
public streamResetEvent = signal<LLMStreamResetEventData | null>(null);
|
|
private activeVideoSessionId: string | null = null;
|
|
private videoSeekRequestId = 0;
|
|
private stepSeekRequestId = 0;
|
|
private videoRequestGeneration = 0;
|
|
private videoRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private videoWaitStartedAt = 0;
|
|
|
|
/**
|
|
* Step logs only. Streaming text chunks cannot change replay frames, so the
|
|
* custom equality keeps downstream frame extraction from re-running on every
|
|
* llm_stream update of the sessionLogs signal.
|
|
*/
|
|
private stepLogsForReplay = computed<any[]>(
|
|
() => this.sessionLogs().filter(
|
|
(log) => log && (log.type === 'step_updated' || log.type === 'step_recorded')
|
|
),
|
|
{ equal: (a, b) => a.length === b.length && a.every((log, i) => log === b[i]) }
|
|
);
|
|
|
|
/**
|
|
* Computed step replay frames from current session logs
|
|
*/
|
|
public currentSessionStepFrames = computed<StepReplayFrame[]>(() => {
|
|
return extractStepReplayFrames(this.stepLogsForReplay());
|
|
});
|
|
|
|
public hasCurrentSessionStepFrames = computed<boolean>(() => {
|
|
return this.currentSessionStepFrames().length > 0;
|
|
});
|
|
|
|
public setPlayerMode(mode: 'video' | 'steps'): void {
|
|
this.playerMode.set(mode);
|
|
}
|
|
|
|
public togglePlayerMode(): void {
|
|
this.playerMode.update((mode) => (mode === 'video' ? 'steps' : 'video'));
|
|
}
|
|
|
|
public requestStepSeek(index: number): void {
|
|
this.stepSeekRequestId++;
|
|
this.stepSeekRequest.set({ index, requestId: this.stepSeekRequestId });
|
|
}
|
|
|
|
/**
|
|
* Clear user-pinned selection so subsequent runs automatically follow active runner
|
|
*/
|
|
public clearUserPinnedSession(): void {
|
|
this.userPinnedSessionId.set(null);
|
|
}
|
|
|
|
/**
|
|
* Computed active/viewed session
|
|
*/
|
|
public currentSession = computed(() => {
|
|
const curId = this.currentSessionId();
|
|
if (!curId) return null;
|
|
return this.sessions().find(s => s.session_id === curId) || null;
|
|
});
|
|
|
|
/**
|
|
* Architecture / LLM of the session being viewed.
|
|
*
|
|
* Each session row carries its own `model_info` (resolved by the backend),
|
|
* so a Pro session stays "Pro" while a Flash task runs elsewhere. Only a
|
|
* session that has not reached the list yet falls back to the running model.
|
|
*/
|
|
public viewedModel = computed<ModelInfo | null>(() => {
|
|
const current = this.currentSession();
|
|
if (current?.model_info) return current.model_info;
|
|
return this.activeModel();
|
|
});
|
|
|
|
/**
|
|
* Computed whether the currently viewed session is actively running or paused.
|
|
* Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
|
|
*/
|
|
public isCurrentSessionRunning = computed(() => {
|
|
const curId = this.currentSessionId();
|
|
if (!curId) return false;
|
|
const session = this.currentSession();
|
|
if (session) {
|
|
return session.status === 'running' || session.status === 'paused';
|
|
}
|
|
// Fallback if session was just submitted/selected but not yet merged into sessions list
|
|
const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused';
|
|
if (isActiveStatus && this.runningSessionId() === curId) {
|
|
return true;
|
|
}
|
|
if (this.activeTasks().some((at) => at.session_id === curId)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
/**
|
|
* Computed video URL for the currently viewed session
|
|
*/
|
|
public currentSessionVideoUrl = computed(() => {
|
|
const curId = this.currentSessionId();
|
|
if (!curId) return null;
|
|
const session = this.sessions().find(s => s.session_id === curId);
|
|
return session?.video_url || null;
|
|
});
|
|
|
|
public currentSessionRecordingStatus = computed(() => {
|
|
const session = this.currentSession();
|
|
const status = session?.recording_status;
|
|
return status === 'recording' || status === 'finalizing' || status === 'processing'
|
|
? 'processing'
|
|
: status;
|
|
});
|
|
|
|
private eventSource: SharedLiveStream | null = null;
|
|
private destroyed = false;
|
|
private pageHidden = false;
|
|
private statusRequestPending = false;
|
|
private sessionsRequestPending = false;
|
|
private guidanceRequestsPending = new Set<string>();
|
|
private statusInterval: any = null;
|
|
private sessionLoadGeneration = 0;
|
|
private sessionSnapshotRequestId = 0;
|
|
private sessionSnapshotAppliedId = 0;
|
|
private pendingSnapshotRequests = new Set<number>();
|
|
|
|
// Streaming chunks are coalesced into one signal update per short window so a
|
|
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
|
|
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; sessionId?: string | null; parentTraceId?: any; streamType: string; chunk: string }>();
|
|
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private lastQueueSignature: string | null = null;
|
|
private lastActiveTasksSignature: string | null = null;
|
|
private lastPersistedSessionsJson: string | null = null;
|
|
private onVisibilityChange = () => {
|
|
if (document.hidden || this.pageHidden) {
|
|
this.closeLiveStream();
|
|
} else {
|
|
this.fetchStatus();
|
|
this.fetchSessions();
|
|
this.ensureLiveStream();
|
|
}
|
|
};
|
|
private onPageHide = () => {
|
|
this.pageHidden = true;
|
|
this.closeLiveStream();
|
|
};
|
|
private onPageShow = () => {
|
|
this.pageHidden = false;
|
|
this.onVisibilityChange();
|
|
};
|
|
|
|
constructor() {
|
|
this.restoreSessionsCache();
|
|
this.fetchSessions();
|
|
this.startStatusPolling();
|
|
this.ensureLiveStream();
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
window.addEventListener('pagehide', this.onPageHide);
|
|
window.addEventListener('pageshow', this.onPageShow);
|
|
}
|
|
inject(DestroyRef).onDestroy(() => this.destroy());
|
|
}
|
|
|
|
/**
|
|
* Check if any task is currently running
|
|
*/
|
|
public isRunningTask = computed(() => {
|
|
if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true;
|
|
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
|
|
});
|
|
|
|
/**
|
|
* Effective Pro tuning defaults (verification level, explorer mode) from the
|
|
* backend agent config, so the launcher sliders start where the config is.
|
|
*/
|
|
public getProTuningDefaults(): Observable<ProTuningDefaults> {
|
|
return this.http.get<ProTuningDefaults>('/api/run/defaults');
|
|
}
|
|
|
|
/** Session-wide token totals, live executor context size and the run's tuning. */
|
|
public getSessionUsage(sessionId: string): Observable<SessionUsage> {
|
|
return this.http.get<SessionUsage>(`/api/sessions/${encodeURIComponent(sessionId)}/usage`);
|
|
}
|
|
|
|
/**
|
|
* Run a new task by submitting to backend queue
|
|
*/
|
|
public runTask(
|
|
goal: string,
|
|
profile: string = 'flash',
|
|
expectedOutput?: string,
|
|
enableOutputter?: boolean,
|
|
proTuning?: ProTuningOptions,
|
|
runtimeOverrides?: Record<string, unknown>
|
|
): Observable<any> {
|
|
return new Observable((obs) => {
|
|
const submittedEvent: StartupProgressEvent = {
|
|
stage: 'submitting',
|
|
message: "正在提交任务",
|
|
timestamp: Date.now() / 1000
|
|
};
|
|
this.pendingStartupProgress.set([submittedEvent]);
|
|
const payload: any = { goal, profile };
|
|
if (runtimeOverrides) payload.runtime_overrides = runtimeOverrides;
|
|
if (expectedOutput && expectedOutput.trim()) {
|
|
payload.expected_output = expectedOutput.trim();
|
|
}
|
|
if (enableOutputter !== undefined) {
|
|
payload.enable_outputter = enableOutputter;
|
|
}
|
|
if (proTuning?.verificationLevel) {
|
|
payload.verification_level = proTuning.verificationLevel;
|
|
}
|
|
if (proTuning?.explorerMode) {
|
|
payload.explorer_mode = proTuning.explorerMode;
|
|
}
|
|
this.clearUserPinnedSession();
|
|
this.http.post<any>('/api/run', payload).subscribe({
|
|
next: (res) => {
|
|
if (res && res.tasks && res.tasks.length > 0) {
|
|
const newSessionId = res.tasks[0].session_id;
|
|
if (newSessionId) {
|
|
this.appendStartupProgress(
|
|
{ ...submittedEvent, session_id: newSessionId },
|
|
newSessionId
|
|
);
|
|
this.pendingStartupProgress.set([]);
|
|
const isCurrentlyRunning =
|
|
this.agentStatus() === 'running' ||
|
|
this.sessions().some((s) => s.status === 'running');
|
|
const activeSessionId = this.runningSessionId()
|
|
|| this.sessions().find((s) => s.status === 'running' || s.status === 'paused')?.session_id;
|
|
// The status poll can observe the new runner before /api/run
|
|
// returns. In that case it is still the task we just submitted,
|
|
// not an older task that should remain selected.
|
|
if (!isCurrentlyRunning || activeSessionId === newSessionId) {
|
|
this.selectSession(newSessionId, false);
|
|
}
|
|
}
|
|
}
|
|
this.pendingStartupProgress.set([]);
|
|
obs.next(res);
|
|
obs.complete();
|
|
},
|
|
error: (err) => {
|
|
this.pendingStartupProgress.set([]);
|
|
obs.error(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop the currently viewed task (or specified task), and optionally clear queue.
|
|
* Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK).
|
|
*/
|
|
public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void {
|
|
let targetSessionId: string | null = null;
|
|
let effectiveStopAll = stopAll;
|
|
|
|
if (typeof targetOrStopAll === 'boolean') {
|
|
effectiveStopAll = targetOrStopAll;
|
|
} else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) {
|
|
targetSessionId = targetOrStopAll.trim();
|
|
} else {
|
|
// Default to currently viewed session, or currently running session
|
|
targetSessionId = this.currentSessionId() || this.runningSessionId() || null;
|
|
}
|
|
|
|
if (!effectiveStopAll && !targetSessionId) {
|
|
targetSessionId = this.sessions().find(
|
|
(session) => session.status === 'running' || session.status === 'paused'
|
|
)?.session_id || null;
|
|
}
|
|
|
|
// Keep the task's terminal state stable while the stop request and the
|
|
// persisted session update cross the network. Clearing the global runner
|
|
// state first used to make the session merger infer "completed" from the
|
|
// still-stale DB row, causing a brief completed -> cancelled flicker.
|
|
if (targetSessionId) {
|
|
this.setSessionStatus(targetSessionId, 'cancelled');
|
|
}
|
|
|
|
// Determine if other active sessions are still executing across multiple devices
|
|
const otherRunningSessions = this.sessions().filter(
|
|
(s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId
|
|
);
|
|
|
|
// Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running
|
|
if (effectiveStopAll || otherRunningSessions.length === 0) {
|
|
this.agentStatus.set('idle');
|
|
this.runningSessionId.set(null);
|
|
this.runningGoal.set(null);
|
|
} else if (this.runningSessionId() === targetSessionId) {
|
|
this.runningSessionId.set(otherRunningSessions[0].session_id);
|
|
this.runningGoal.set(otherRunningSessions[0].initial_goal || null);
|
|
}
|
|
|
|
this.isPaused.set(false);
|
|
this.pausedError.set(null);
|
|
this.isRetrying.set(false);
|
|
this.invalidateStatusSignatures();
|
|
if (effectiveStopAll) {
|
|
this.pendingQueue.set([]);
|
|
}
|
|
|
|
// Mark active streaming / pending logs as finished if viewing target session
|
|
if (!targetSessionId || this.currentSessionId() === targetSessionId) {
|
|
this.flushStreamChunks();
|
|
this.sessionLogs.update((logs) => {
|
|
if (!logs.some((l) => l.type === 'llm_stream' && !l.data?.isCompleted)) {
|
|
return logs;
|
|
}
|
|
return logs.map((l) => {
|
|
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
|
|
return { ...l, data: { ...l.data, isCompleted: true } };
|
|
}
|
|
return l;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Optimistically remove from activeTasks
|
|
if (targetSessionId) {
|
|
this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId));
|
|
}
|
|
|
|
let url = `/api/stop?all=${effectiveStopAll}`;
|
|
if (!effectiveStopAll && targetSessionId) {
|
|
url += `&session_id=${encodeURIComponent(targetSessionId)}`;
|
|
}
|
|
|
|
const payload: any = { all: effectiveStopAll };
|
|
if (targetSessionId) {
|
|
payload.session_id = targetSessionId;
|
|
}
|
|
|
|
this.http.post<any>(url, payload).subscribe({
|
|
next: () => {
|
|
this.fetchStatus();
|
|
this.fetchSessions();
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to stop task:', err);
|
|
this.fetchSessions();
|
|
this.fetchStatus();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resume paused task
|
|
*/
|
|
public resumeTask(): void {
|
|
this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
|
|
next: (response) => {
|
|
if (response?.status !== 'resumed') {
|
|
// A stale recovery control must not optimistically change the task
|
|
// back to running when the backend says there is nothing to resume.
|
|
this.fetchStatus();
|
|
return;
|
|
}
|
|
const resumedSessionId = this.runningSessionId();
|
|
this.isPaused.set(false);
|
|
this.pausedError.set(null);
|
|
this.agentStatus.set('running');
|
|
this.setSessionStatus(resumedSessionId, 'running');
|
|
this.fetchStatus();
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to resume task:', err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch all past and active sessions from the backend
|
|
*/
|
|
public fetchSessions(): void {
|
|
if (this.destroyed || this.sessionsRequestPending) return;
|
|
this.sessionsRequestPending = true;
|
|
this.http.get<Session[]>('/api/sessions').pipe(
|
|
timeout(UI_READ_TIMEOUT_MS),
|
|
finalize(() => this.sessionsRequestPending = false)
|
|
).subscribe({
|
|
next: (data) => {
|
|
this.rawSessions.set(data);
|
|
this.persistSessionsCache(data);
|
|
// On initial load, if nothing is selected, not pinned, not running, and sessions exist, select latest
|
|
if (!this.currentSessionId() && !this.userPinnedSessionId() && this.agentStatus() !== 'running' && data.length > 0) {
|
|
this.selectSession(data[0].session_id, false);
|
|
}
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to fetch sessions from backend:', err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete an individual session / task
|
|
*/
|
|
public canDeleteSession(session: Pick<Session, 'status'>): boolean {
|
|
return ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || '');
|
|
}
|
|
|
|
public deletionPrompt(sessionId: string, explorationKind?: string): string {
|
|
const kind = explorationKind || this.rawSessions().find(s => s.session_id === sessionId)?.exploration_kind;
|
|
return kind === 'exploration'
|
|
? '确定删除此探索的对话、用例版本及全部探索执行记录和文件?已生成的复跑与批次任务将保留。此操作无法撤销。'
|
|
: '确定删除此历史任务?此操作无法撤销。';
|
|
}
|
|
|
|
public deleteSession(sessionId: string): Observable<any> {
|
|
// Keep the task selected until the server confirms the entire deletion.
|
|
return this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).pipe(tap(res => {
|
|
const deleted = new Set<string>(res.deleted_session_ids || [sessionId]);
|
|
this.invalidateStatusSignatures();
|
|
for (const id of deleted) this.activeSessionTracking.delete(id);
|
|
this.rawSessions.update(list => list.filter(s => !deleted.has(s.session_id)));
|
|
this.pendingQueue.update(list => list.filter(s => !deleted.has(s.session_id)));
|
|
this.persistSessionsCache(this.rawSessions());
|
|
if (deleted.has(this.userPinnedSessionId() || '')) this.userPinnedSessionId.set(null);
|
|
if (deleted.has(this.currentSessionId() || '')) {
|
|
this.selectSession('', false);
|
|
if (this.isVideoWindowOpen()) this.closeVideoPlayer();
|
|
const runningId = this.runningSessionId();
|
|
if (runningId && !deleted.has(runningId) && this.agentStatus() === 'running') {
|
|
this.selectSession(runningId, false);
|
|
}
|
|
}
|
|
this.resultRevision.update(value => value + 1);
|
|
this.fetchSessions();
|
|
this.fetchStatus();
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Clear all sessions, tasks, and history
|
|
*/
|
|
public clearAllHistory(): Observable<any> {
|
|
// 1. Optimistically clear all local session state immediately
|
|
this.invalidateStatusSignatures();
|
|
this.userPinnedSessionId.set(null);
|
|
this.rawSessions.set([]);
|
|
this.clearSessionsCache();
|
|
this.pendingQueue.set([]);
|
|
this.selectSession('', false);
|
|
if (this.isVideoWindowOpen()) {
|
|
this.closeVideoPlayer();
|
|
}
|
|
|
|
// 2. Send API request
|
|
return new Observable((obs) => {
|
|
this.http.post<any>('/api/cleanup', {}).subscribe({
|
|
next: (res) => {
|
|
this.fetchSessions();
|
|
this.fetchStatus();
|
|
obs.next(res);
|
|
obs.complete();
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to cleanup history:', err);
|
|
this.fetchSessions();
|
|
this.fetchStatus();
|
|
obs.error(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Connect to the Server-Sent Events stream for a specific session
|
|
* @param sessionId The ID of the session to select
|
|
* @param isUserAction Whether this selection was explicitly triggered by user interaction
|
|
*/
|
|
public selectSession(sessionId: string, isUserAction: boolean = false): void {
|
|
if (isUserAction) {
|
|
if (sessionId && sessionId === this.runningSessionId() && this.agentStatus() === 'running') {
|
|
// User explicitly clicked the currently running task -> follow live runner
|
|
this.userPinnedSessionId.set(null);
|
|
} else if (sessionId) {
|
|
// User explicitly clicked a non-running or historical task -> pin to it
|
|
this.userPinnedSessionId.set(sessionId);
|
|
} else {
|
|
this.userPinnedSessionId.set(null);
|
|
}
|
|
}
|
|
|
|
if (!sessionId) {
|
|
this.sessionLoadGeneration++;
|
|
this.pendingSnapshotRequests.clear();
|
|
this.discardPendingStreamChunks();
|
|
this.isSessionContentLoading.set(false);
|
|
this.currentSessionId.set(null);
|
|
this.sessionLogs.set([]);
|
|
this.guidanceEvents.set([]);
|
|
this.guidanceSignature = '';
|
|
this.currentNotes.set({});
|
|
return;
|
|
}
|
|
|
|
if (this.currentSessionId() === sessionId) {
|
|
return; // Already selected
|
|
}
|
|
|
|
const loadGeneration = ++this.sessionLoadGeneration;
|
|
this.pendingSnapshotRequests.clear();
|
|
this.discardPendingStreamChunks();
|
|
this.sessionSnapshotAppliedId = 0;
|
|
this.isSessionContentLoading.set(true);
|
|
this.currentSessionId.set(sessionId);
|
|
this.sessionLogs.set([]); // Reset logs for new session selection
|
|
this.guidanceEvents.set([]);
|
|
this.guidanceSignature = '';
|
|
this.fetchGuidance(sessionId);
|
|
this.activePauseCardKey = null;
|
|
this.isPaused.set(false);
|
|
this.pausedError.set(null);
|
|
this.fetchNotes(sessionId);
|
|
this.fetchChecks(sessionId);
|
|
|
|
// If video window is currently open, dynamically sync/refresh video for new session
|
|
if (this.isVideoWindowOpen()) {
|
|
this.openVideoPlayer(sessionId);
|
|
}
|
|
|
|
console.debug(`Selecting session: ${sessionId}`);
|
|
// Start reading the persisted snapshot immediately
|
|
this.backfillSessionSteps(sessionId, loadGeneration);
|
|
this.ensureLiveStream();
|
|
}
|
|
|
|
/**
|
|
* Keep a live connection only while this page can display updates. Hidden
|
|
* tabs otherwise exhaust HTTP/1.1's per-origin connection pool, blocking
|
|
* configuration and history requests in every tab of the same browser.
|
|
*/
|
|
public ensureLiveStream(): void {
|
|
if (this.destroyed || this.pageHidden || document.hidden || this.eventSource) {
|
|
return;
|
|
}
|
|
|
|
console.debug('Establishing persistent unified live stream via /api/stream');
|
|
const eventTypes = [
|
|
'llm_stream', 'llm_stream_reset', 'trace_recorded', 'step_recorded',
|
|
'step_updated', 'background_tasks_updated', 'task_paused', 'task_resumed',
|
|
'llm_retrying', 'startup_progress', 'session_started', 'session_ended',
|
|
'recording_ready', 'recording_failed', 'checker_event'
|
|
];
|
|
// The stream is registered outside the Angular zone: high-frequency SSE
|
|
// callbacks must not schedule a change-detection pass each. Signal writes
|
|
// still notify the render scheduler, so the UI stays live.
|
|
this.zone.runOutsideAngular(() => {
|
|
this.eventSource = SharedLiveStream.open('/api/stream', ['info', 'keep-alive', ...eventTypes]);
|
|
const source = this.eventSource;
|
|
|
|
this.eventSource.addEventListener('info', () => {
|
|
if (this.eventSource !== source) return;
|
|
// Events may have been missed while hidden or during a server restart.
|
|
this.fetchStatus();
|
|
this.fetchSessions();
|
|
const curId = this.currentSessionId();
|
|
if (curId) {
|
|
this.backfillSessionSteps(curId);
|
|
this.fetchChecks(curId);
|
|
this.fetchNotes(curId);
|
|
this.fetchGuidance(curId);
|
|
}
|
|
if (this.isVideoWindowOpen() && ['live', 'processing'].includes(this.recordingPlaybackStatus())) {
|
|
this.refreshActiveRecording(false);
|
|
}
|
|
});
|
|
|
|
this.eventSource.addEventListener('keep-alive', () => {
|
|
// Keep-alive tick
|
|
});
|
|
|
|
eventTypes.forEach((eventType) => {
|
|
this.eventSource?.addEventListener(eventType, ((event: MessageEvent) => {
|
|
if (this.eventSource !== source) return;
|
|
try {
|
|
const parsedData = JSON.parse(event.data);
|
|
const evtSessionId = parsedData?.session_id;
|
|
// Request snapshots have a separate compact panel. They must not end
|
|
// a live text stream or appear as duplicate tool cards.
|
|
if (eventType === 'trace_recorded' && parsedData?.type === 'llm_diagnostic') return;
|
|
|
|
// Preserve ordering: buffered stream text must land before any
|
|
// non-stream event is appended to the session logs.
|
|
if (eventType !== 'llm_stream') {
|
|
this.flushStreamChunks();
|
|
}
|
|
|
|
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
|
|
if (
|
|
this.isVideoWindowOpen()
|
|
&& evtSessionId
|
|
&& String(evtSessionId) === String(this.activeVideoSessionId)
|
|
) {
|
|
if (eventType === 'recording_ready') {
|
|
this.refreshActiveRecording(true);
|
|
} else {
|
|
this.cancelVideoRetry();
|
|
this.isVideoLoading.set(false);
|
|
this.activeVideoUrl.set(null);
|
|
this.activeVideoSegments.set([]);
|
|
this.recordingPlaybackStatus.set('failed');
|
|
this.recordingPlaybackMessage.set(
|
|
parsedData?.error || "录屏收尾失败。"
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'background_tasks_updated') {
|
|
this.fetchStatus();
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'session_started') {
|
|
this.agentStatus.set('running');
|
|
if (parsedData?.session_id) {
|
|
this.runningSessionId.set(parsedData.session_id);
|
|
}
|
|
if (parsedData?.initial_goal) {
|
|
this.runningGoal.set(parsedData.initial_goal);
|
|
}
|
|
this.fetchSessions();
|
|
|
|
// Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session
|
|
if (!this.userPinnedSessionId() && parsedData?.session_id) {
|
|
this.selectSession(parsedData.session_id, false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'session_ended') {
|
|
const endedId = evtSessionId || this.runningSessionId();
|
|
if (endedId) {
|
|
this.applySessionEndedStatus(endedId, parsedData);
|
|
this.invalidateStatusSignatures();
|
|
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
|
|
}
|
|
const remaining = this.activeTasks();
|
|
if (remaining.length > 0) {
|
|
this.agentStatus.set('running');
|
|
this.runningSessionId.set(remaining[0].session_id);
|
|
this.runningGoal.set(remaining[0].goal || null);
|
|
} else if (this.pendingQueue().length > 0) {
|
|
const nextPending = this.pendingQueue()[0];
|
|
this.agentStatus.set('running');
|
|
this.runningSessionId.set(nextPending.session_id);
|
|
this.runningGoal.set(nextPending.initial_goal || null);
|
|
} else {
|
|
this.agentStatus.set('idle');
|
|
this.runningSessionId.set(null);
|
|
this.runningGoal.set(null);
|
|
}
|
|
this.isRetrying.set(false);
|
|
this.isPaused.set(false);
|
|
this.fetchSessions();
|
|
this.fetchStatus();
|
|
if (endedId) {
|
|
this.fetchNotes(endedId);
|
|
if (String(endedId) === String(this.currentSessionId() || '')) {
|
|
// The exit final review / run outcome may have landed while the
|
|
// stream was reconnecting: reconcile from the persisted ledger.
|
|
this.fetchChecks(endedId);
|
|
}
|
|
if (
|
|
this.isVideoWindowOpen()
|
|
&& this.activeVideoSessionId === endedId
|
|
&& this.recordingPlaybackStatus() === 'live'
|
|
) {
|
|
this.beginRecordingFinalization(endedId);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'startup_progress') {
|
|
const targetSid = evtSessionId || this.currentSessionId();
|
|
if (targetSid) {
|
|
this.appendStartupProgress(parsedData, targetSid);
|
|
const curId = this.currentSessionId();
|
|
if (
|
|
!this.userPinnedSessionId() &&
|
|
(!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase())
|
|
) {
|
|
this.agentStatus.set('running');
|
|
this.runningSessionId.set(targetSid);
|
|
this.selectSession(targetSid, false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If the event is for a different session than what user is currently viewing, ignore for logs
|
|
const curId = this.currentSessionId();
|
|
if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) {
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'llm_retrying') {
|
|
this.isRetrying.set(true);
|
|
const attempt = Number(parsedData.attempt || 0);
|
|
const max = Number(parsedData.max_retries || 0);
|
|
const delay = Number(parsedData.delay || 0);
|
|
const attemptText = attempt && max ? `(第 ${attempt}/${max} 次重试)` : '';
|
|
const delayText = delay > 0 ? `;重试等待 ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
|
|
this.retryMessage.set(`${parsedData.error || '模型请求未完成'}${attemptText}${delayText}...`);
|
|
if (curId) {
|
|
this.appendLiveLLMRetryTrace(parsedData, curId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'task_paused') {
|
|
this.isPaused.set(true);
|
|
this.isRetrying.set(false);
|
|
this.agentStatus.set('paused');
|
|
if (curId) {
|
|
this.runningSessionId.set(curId);
|
|
this.setSessionStatus(curId, 'paused');
|
|
const pauseError = parsedData.error || "模型调用失败";
|
|
this.pausedError.set(pauseError);
|
|
this.appendPausedErrorCard(
|
|
pauseError,
|
|
curId,
|
|
parsedData.timestamp,
|
|
parsedData.step_id,
|
|
parsedData
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'task_resumed') {
|
|
this.isPaused.set(false);
|
|
this.isRetrying.set(false);
|
|
this.pausedError.set(null);
|
|
if (curId && this.runningSessionId() === curId) {
|
|
this.agentStatus.set('running');
|
|
this.setSessionStatus(curId, 'running');
|
|
}
|
|
this.activePauseCardKey = null;
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'step_updated' || eventType === 'step_recorded' || eventType === 'llm_stream') {
|
|
this.isRetrying.set(false);
|
|
}
|
|
|
|
if (eventType === 'llm_stream_reset') {
|
|
const streamExecId = parsedData?.stream_exec_id || parsedData?.stream_execution_id;
|
|
if (streamExecId) {
|
|
for (const key of Array.from(this.pendingStreamChunks.keys())) {
|
|
if (key.startsWith(`${streamExecId}|`) || key === streamExecId) {
|
|
this.pendingStreamChunks.delete(key);
|
|
}
|
|
}
|
|
}
|
|
const resetMessage = parsedData?.message || DEFAULT_STREAM_RESET_MESSAGE;
|
|
this.streamResetEvent.set({
|
|
stream_exec_id: streamExecId,
|
|
stream_execution_id: streamExecId,
|
|
step_id: parsedData?.step_id,
|
|
session_id: parsedData?.session_id,
|
|
action: parsedData?.action || 'discard',
|
|
reason: parsedData?.reason || 'mid_stream_failure',
|
|
category: parsedData?.category,
|
|
error: parsedData?.error,
|
|
message: resetMessage,
|
|
retry_attempt: parsedData?.retry_attempt,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
this.sessionLogs.update((logs) => {
|
|
let updated = false;
|
|
const next = logs.map((log) => {
|
|
if (log.type === 'llm_stream' && log.data?.execution_id === streamExecId) {
|
|
updated = true;
|
|
return {
|
|
...log,
|
|
data: {
|
|
...log.data,
|
|
isReset: true,
|
|
resetMessage
|
|
}
|
|
};
|
|
}
|
|
return log;
|
|
});
|
|
if (!updated && streamExecId) {
|
|
next.push({
|
|
type: 'llm_stream',
|
|
timestamp: new Date().toISOString(),
|
|
session_id: parsedData?.session_id,
|
|
data: {
|
|
execution_id: streamExecId,
|
|
step_id: parsedData?.step_id,
|
|
stream_type: 'text',
|
|
text: '',
|
|
isCompleted: false,
|
|
isReset: true,
|
|
resetMessage
|
|
}
|
|
});
|
|
}
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'llm_stream') {
|
|
this.queueStreamChunk(parsedData);
|
|
} else {
|
|
this.sessionLogs.update((logs) => {
|
|
// Copying every log object on every event is wasted work when no
|
|
// stream is open; only rewrite entries that actually change.
|
|
const hasOpenStream = logs.some(
|
|
(l) => l.type === 'llm_stream' && !l.data.isCompleted
|
|
);
|
|
const updatedLogs = !hasOpenStream ? [...logs] : logs.map((l) => {
|
|
if (l.type === 'llm_stream' && !l.data.isCompleted) {
|
|
return {
|
|
...l,
|
|
data: { ...l.data, isCompleted: true }
|
|
};
|
|
}
|
|
return l;
|
|
});
|
|
|
|
const evtTime = typeof parsedData?.timestamp === 'number'
|
|
? new Date(parsedData.timestamp * 1000).toISOString()
|
|
: new Date().toISOString();
|
|
|
|
const nextLog = {
|
|
type: eventType,
|
|
timestamp: evtTime,
|
|
data: parsedData
|
|
};
|
|
|
|
if (eventType === 'trace_recorded' && parsedData?.trace_id) {
|
|
const existingTraceIndex = updatedLogs.findIndex((log) =>
|
|
log.type === 'trace_recorded'
|
|
&& log.data?.trace_id === parsedData.trace_id
|
|
);
|
|
if (existingTraceIndex > -1) {
|
|
const deduplicatedLogs = [...updatedLogs];
|
|
deduplicatedLogs[existingTraceIndex] = nextLog;
|
|
return deduplicatedLogs;
|
|
}
|
|
}
|
|
|
|
return [...updatedLogs, nextLog];
|
|
});
|
|
}
|
|
|
|
if (eventType === 'trace_recorded' && parsedData) {
|
|
const trName = parsedData.name || '';
|
|
if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
|
|
const curSessionId = this.currentSessionId();
|
|
if (curSessionId) {
|
|
this.fetchNotes(curSessionId);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to parse ${eventType} event data:`, e);
|
|
this.sessionLogs.update((logs) => [
|
|
...logs,
|
|
{
|
|
type: eventType,
|
|
timestamp: new Date().toISOString(),
|
|
data: event.data
|
|
}
|
|
]);
|
|
}
|
|
}) as EventListener);
|
|
});
|
|
|
|
this.eventSource.onerror = (err) => {
|
|
console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
|
|
};
|
|
});
|
|
}
|
|
|
|
private closeLiveStream(): void {
|
|
this.eventSource?.close();
|
|
this.eventSource = null;
|
|
this.flushStreamChunks();
|
|
}
|
|
|
|
/**
|
|
* Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
|
|
* once per short window (and immediately before any non-stream event so the
|
|
* relative ordering of stream text and step events is preserved).
|
|
*/
|
|
private queueStreamChunk(parsedData: any): void {
|
|
const execId = parsedData.execution_id;
|
|
const stepId = parsedData.step_id;
|
|
const sessionId = parsedData.session_id || null;
|
|
// The emitting agent's trace: lets the aggregator route a concurrent
|
|
// agent's stream (e.g. the Checker) to its own block instead of the
|
|
// Operator step that happens to be current.
|
|
const parentTraceId = parsedData.parent_trace_id || null;
|
|
const streamType = parsedData.stream_type || 'text';
|
|
const key = `${execId}|${streamType}`;
|
|
const chunk = parsedData.chunk !== undefined ? parsedData.chunk : (parsedData.text || '');
|
|
const pending = this.pendingStreamChunks.get(key);
|
|
if (pending) {
|
|
pending.chunk += chunk;
|
|
if (stepId) pending.stepId = stepId;
|
|
if (sessionId) pending.sessionId = sessionId;
|
|
if (parentTraceId) pending.parentTraceId = parentTraceId;
|
|
} else {
|
|
this.pendingStreamChunks.set(key, { execId, stepId, sessionId, parentTraceId, streamType, chunk });
|
|
}
|
|
if (!this.streamFlushTimer) {
|
|
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
|
|
this.streamFlushTimer = setTimeout(() => this.flushStreamChunks(), delay);
|
|
}
|
|
}
|
|
|
|
private discardPendingStreamChunks(): void {
|
|
this.pendingStreamChunks?.clear();
|
|
if (this.streamFlushTimer) {
|
|
clearTimeout(this.streamFlushTimer);
|
|
this.streamFlushTimer = null;
|
|
}
|
|
}
|
|
|
|
private flushStreamChunks(): void {
|
|
if (this.streamFlushTimer) {
|
|
clearTimeout(this.streamFlushTimer);
|
|
this.streamFlushTimer = null;
|
|
}
|
|
if (!this.pendingStreamChunks?.size) return;
|
|
const batches = Array.from(this.pendingStreamChunks.values());
|
|
this.pendingStreamChunks.clear();
|
|
|
|
this.sessionLogs.update((logs) => {
|
|
let next = [...logs];
|
|
for (const batch of batches) {
|
|
const existingIndex = next.findIndex(
|
|
(l) => l.type === 'llm_stream' &&
|
|
l.data.execution_id === batch.execId &&
|
|
(l.data.stream_type || 'text') === batch.streamType
|
|
);
|
|
|
|
if (existingIndex > -1) {
|
|
const existingLog = next[existingIndex];
|
|
next[existingIndex] = {
|
|
...existingLog,
|
|
session_id: batch.sessionId || existingLog.session_id,
|
|
data: {
|
|
...existingLog.data,
|
|
session_id: batch.sessionId || existingLog.data?.session_id,
|
|
text: (existingLog.data.text || '') + batch.chunk,
|
|
step_id: batch.stepId || existingLog.data.step_id,
|
|
parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null
|
|
}
|
|
};
|
|
} else {
|
|
// A new execution closes the previous open stream of the same agent
|
|
// lane only; concurrent agents (Operator vs. Checker) keep theirs.
|
|
next = next.map((l) => {
|
|
if (l.type === 'llm_stream' &&
|
|
(l.data.stream_type || 'text') === batch.streamType &&
|
|
!l.data.isCompleted &&
|
|
(l.data.parent_trace_id || null) === (batch.parentTraceId || null)) {
|
|
return { ...l, data: { ...l.data, isCompleted: true } };
|
|
}
|
|
return l;
|
|
});
|
|
next.push({
|
|
type: 'llm_stream',
|
|
timestamp: new Date().toISOString(),
|
|
session_id: batch.sessionId || undefined,
|
|
data: {
|
|
execution_id: batch.execId,
|
|
session_id: batch.sessionId || undefined,
|
|
step_id: batch.stepId,
|
|
parent_trace_id: batch.parentTraceId || null,
|
|
text: batch.chunk,
|
|
stream_type: batch.streamType,
|
|
isCompleted: false
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load a fresh persisted snapshot without replacing events already received
|
|
* from SSE. Snapshot logs are replaceable, while live logs remain append-only;
|
|
* the stream aggregator merges matching steps and trace IDs within each step.
|
|
*/
|
|
private backfillSessionSteps(sessionId: string, loadGeneration = this.sessionLoadGeneration): void {
|
|
const requestId = ++this.sessionSnapshotRequestId;
|
|
this.pendingSnapshotRequests.add(requestId);
|
|
this.http.get<StepItemData[]>(`/api/sessions/${sessionId}/steps`).subscribe({
|
|
next: (steps) => {
|
|
this.pendingSnapshotRequests.delete(requestId);
|
|
if (
|
|
this.currentSessionId() !== sessionId
|
|
|| loadGeneration !== this.sessionLoadGeneration
|
|
|| requestId < this.sessionSnapshotAppliedId
|
|
) return;
|
|
this.sessionSnapshotAppliedId = requestId;
|
|
const historicalLogs = steps.map((step) => ({
|
|
type: 'step_updated',
|
|
session_id: sessionId,
|
|
timestamp: new Date((step.timestamp || Date.now() / 1000) * 1000).toISOString(),
|
|
data: step,
|
|
history_snapshot: true
|
|
}));
|
|
|
|
this.sessionLogs.update((logs) => [
|
|
...historicalLogs,
|
|
...logs.filter((log) => !log.history_snapshot)
|
|
]);
|
|
this.isSessionContentLoading.set(false);
|
|
},
|
|
error: (err) => {
|
|
this.pendingSnapshotRequests.delete(requestId);
|
|
console.error('Failed to backfill session steps:', err);
|
|
if (
|
|
this.currentSessionId() === sessionId
|
|
&& loadGeneration === this.sessionLoadGeneration
|
|
&& this.pendingSnapshotRequests.size === 0
|
|
) {
|
|
this.isSessionContentLoading.set(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.http.get<StartupProgressEvent[]>(`/api/sessions/${sessionId}/startup_progress`).subscribe({
|
|
next: (events) => {
|
|
if (Array.isArray(events) && events.length > 0) {
|
|
this.startupProgressBySession.update((current) => {
|
|
const existing = current[sessionId] || [];
|
|
const merged = [...existing];
|
|
for (const ev of events) {
|
|
if (!merged.some((m) => m.stage === ev.stage)) {
|
|
merged.push(ev);
|
|
}
|
|
}
|
|
return {
|
|
...current,
|
|
[sessionId]: merged
|
|
};
|
|
});
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
public fetchGuidance(sessionId: string): void {
|
|
if (this.destroyed || this.currentSessionId() !== sessionId || this.guidanceRequestsPending.has(sessionId)) return;
|
|
this.guidanceRequestsPending.add(sessionId);
|
|
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).pipe(
|
|
timeout(UI_READ_TIMEOUT_MS),
|
|
finalize(() => this.guidanceRequestsPending.delete(sessionId))
|
|
).subscribe({
|
|
next: (items) => {
|
|
if (this.currentSessionId() !== sessionId) return;
|
|
const signature = JSON.stringify(items);
|
|
if (signature !== this.guidanceSignature) {
|
|
this.guidanceSignature = signature;
|
|
this.guidanceEvents.set(items);
|
|
}
|
|
},
|
|
error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
|
|
});
|
|
}
|
|
|
|
private restoreSessionsCache(): void {
|
|
try {
|
|
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
|
if (!cached) return;
|
|
const sessions = JSON.parse(cached);
|
|
if (Array.isArray(sessions)) {
|
|
this.lastPersistedSessionsJson = cached;
|
|
this.rawSessions.set(sessions);
|
|
}
|
|
} catch {
|
|
this.clearSessionsCache();
|
|
}
|
|
}
|
|
|
|
private persistSessionsCache(sessions: Session[]): void {
|
|
try {
|
|
const serialized = JSON.stringify(sessions);
|
|
if (serialized === this.lastPersistedSessionsJson) return;
|
|
this.lastPersistedSessionsJson = serialized;
|
|
localStorage.setItem(SESSION_CACHE_KEY, serialized);
|
|
} catch {
|
|
// Storage can be unavailable in private browsing or embedded contexts.
|
|
}
|
|
}
|
|
|
|
private clearSessionsCache(): void {
|
|
this.lastPersistedSessionsJson = null;
|
|
try {
|
|
localStorage.removeItem(SESSION_CACHE_KEY);
|
|
} catch {
|
|
// No-op when browser storage is unavailable.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert the dedicated live retry event into the same trace shape used by
|
|
* history APIs. This keeps live streaming and post-refresh rendering on one
|
|
* data contract instead of maintaining a separate retry UI path.
|
|
*/
|
|
private appendLiveLLMRetryTrace(data: any, sessionId: string): void {
|
|
const timestampSeconds = typeof data?.timestamp === 'number'
|
|
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
|
|
: Date.now() / 1000;
|
|
const traceId = data?.trace_id
|
|
|| `llm-retry-live-${data?.request_id || 'unknown'}-${timestampSeconds}`;
|
|
const payload = {
|
|
error: data?.error,
|
|
delay: data?.delay,
|
|
attempt: data?.attempt,
|
|
max_retries: data?.max_retries,
|
|
provider: data?.provider,
|
|
source: data?.source,
|
|
recoverable: data?.recoverable,
|
|
request_id: data?.request_id,
|
|
scheduled_at: data?.scheduled_at ?? timestampSeconds
|
|
};
|
|
const retryTrace = {
|
|
trace_id: traceId,
|
|
session_id: sessionId,
|
|
step_id: data?.step_id || null,
|
|
type: 'llm_call',
|
|
name: 'llm_retry',
|
|
timestamp: timestampSeconds,
|
|
status: 'retrying',
|
|
payload
|
|
};
|
|
|
|
this.sessionLogs.update((logs) => {
|
|
const existingIndex = logs.findIndex((log) =>
|
|
log.type === 'trace_recorded'
|
|
&& (
|
|
log.data?.trace_id === traceId
|
|
|| (
|
|
data?.request_id
|
|
&& log.data?.payload?.request_id === data.request_id
|
|
&& Number(log.data?.payload?.scheduled_at) === Number(payload.scheduled_at)
|
|
)
|
|
)
|
|
);
|
|
const retryLog = {
|
|
type: 'trace_recorded',
|
|
session_id: sessionId,
|
|
timestamp: new Date(timestampSeconds * 1000).toISOString(),
|
|
data: retryTrace
|
|
};
|
|
if (existingIndex < 0) return [...logs, retryLog];
|
|
|
|
const updatedLogs = [...logs];
|
|
updatedLogs[existingIndex] = retryLog;
|
|
return updatedLogs;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Preserve pause failures in the normal failed-LLM card stream. New runners
|
|
* emit a persisted failed trace first; this fallback also supports older
|
|
* runners whose task_paused event only carried an error string.
|
|
*/
|
|
private appendPausedErrorCard(
|
|
error: unknown,
|
|
sessionId: string,
|
|
timestamp?: number | string,
|
|
stepId?: string | null,
|
|
details?: any
|
|
): void {
|
|
const errorText = typeof error === 'string' ? error : JSON.stringify(error);
|
|
const pauseKey = `${sessionId}:${errorText}`;
|
|
if (this.activePauseCardKey === pauseKey) return;
|
|
|
|
this.sessionLogs.update((logs) => {
|
|
const alreadyRecorded = logs.some((log) => {
|
|
const traces = log?.type === 'trace_recorded'
|
|
? [log.data]
|
|
: (Array.isArray(log?.data?.generic_tools) ? log.data.generic_tools : []);
|
|
return traces.some((trace: any) =>
|
|
trace?.type === 'llm_call'
|
|
&& trace?.status === 'failed'
|
|
&& String(trace?.payload?.error ?? trace?.error ?? '') === errorText
|
|
);
|
|
});
|
|
if (alreadyRecorded) return logs;
|
|
|
|
const latestStepId = stepId || [...logs].reverse().find((log) =>
|
|
(log.type === 'step_updated' || log.type === 'step_recorded') && log.data?.step_id
|
|
)?.data?.step_id || null;
|
|
const timestampMs = typeof timestamp === 'number'
|
|
? (timestamp < 1e11 ? timestamp * 1000 : timestamp)
|
|
: (timestamp ? new Date(timestamp).getTime() : Date.now());
|
|
|
|
return [
|
|
...logs,
|
|
{
|
|
type: 'trace_recorded',
|
|
session_id: sessionId,
|
|
timestamp: new Date(timestampMs).toISOString(),
|
|
data: {
|
|
trace_id: `task-paused-${sessionId}-${timestampMs}`,
|
|
session_id: sessionId,
|
|
step_id: latestStepId,
|
|
type: 'llm_call',
|
|
name: 'llm_pause',
|
|
status: 'failed',
|
|
timestamp: timestampMs / 1000,
|
|
payload: {
|
|
error: errorText,
|
|
pause: true,
|
|
request_id: details?.request_id,
|
|
provider: details?.provider,
|
|
waited_seconds: details?.waited_seconds,
|
|
retries: Array.isArray(details?.retries) ? details.retries : []
|
|
}
|
|
}
|
|
}
|
|
];
|
|
});
|
|
this.activePauseCardKey = pauseKey;
|
|
}
|
|
|
|
private pollCounter: number = 0;
|
|
|
|
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
|
|
if (!sessionId) return;
|
|
|
|
this.invalidateStatusSignatures();
|
|
this.rawSessions.update((sessions) => sessions.map((session) =>
|
|
session.session_id === sessionId ? { ...session, status } : session
|
|
));
|
|
this.pendingQueue.update((sessions) => sessions.map((session) =>
|
|
session.session_id === sessionId ? { ...session, status } : session
|
|
));
|
|
}
|
|
|
|
private applySessionEndedStatus(sessionId: unknown, data: any): void {
|
|
if (!sessionId) return;
|
|
|
|
const reportedStatus = String(data?.status || '').toLowerCase();
|
|
const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
|
|
? 'cancelled'
|
|
: reportedStatus === 'failed'
|
|
? 'failed'
|
|
: reportedStatus === 'completed' || reportedStatus === 'success'
|
|
? 'completed'
|
|
: null;
|
|
|
|
if (status) {
|
|
this.activeSessionTracking.delete(String(sessionId));
|
|
this.setSessionStatus(String(sessionId), status);
|
|
this.invalidateStatusSignatures();
|
|
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start periodic status polling from the backend
|
|
*/
|
|
private startStatusPolling(): void {
|
|
this.fetchStatus();
|
|
// The interval runs outside the Angular zone so a poll tick alone never
|
|
// schedules change detection; only actual signal updates do.
|
|
this.zone.runOutsideAngular(() => {
|
|
this.statusInterval = setInterval(() => {
|
|
if (typeof document !== 'undefined' && document.hidden) {
|
|
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
|
}
|
|
this.fetchStatus();
|
|
const selected = this.currentSessionId();
|
|
if (selected) this.fetchGuidance(selected);
|
|
this.pollCounter++;
|
|
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
|
if (this.pollCounter % 3 === 0) {
|
|
this.fetchSessions();
|
|
}
|
|
}, 2000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Optimistic local mutations of queue/active state must force the next poll
|
|
* to re-apply backend data even when its payload has not changed.
|
|
*/
|
|
private invalidateStatusSignatures(): void {
|
|
this.lastQueueSignature = null;
|
|
this.lastActiveTasksSignature = null;
|
|
}
|
|
|
|
/**
|
|
* Fetch current agent runner process status
|
|
*/
|
|
public fetchStatus(): void {
|
|
if (this.destroyed || this.statusRequestPending) return;
|
|
this.statusRequestPending = true;
|
|
this.http.get<any>('/api/status').pipe(
|
|
timeout(UI_READ_TIMEOUT_MS),
|
|
finalize(() => this.statusRequestPending = false)
|
|
).subscribe({
|
|
next: (data) => {
|
|
if (data && data.status) {
|
|
const oldStatus = this.agentStatus();
|
|
const oldRunningSessionId = this.runningSessionId();
|
|
const isActive = data.status === 'running' || data.status === 'paused';
|
|
this.agentStatus.set(data.status);
|
|
this.runningSessionId.set(data.session_id || null);
|
|
this.runningGoal.set(data.goal || null);
|
|
if (data.model_info) {
|
|
this.activeModel.set(data.model_info);
|
|
}
|
|
this.isPaused.set(data.status === 'paused');
|
|
|
|
if (data.status === 'paused') {
|
|
this.isRetrying.set(false);
|
|
const pauseError = data.paused_error
|
|
|| this.pausedError()
|
|
|| "模型请求失败,任务已暂停。";
|
|
this.pausedError.set(pauseError);
|
|
if (data.session_id && this.currentSessionId() === data.session_id) {
|
|
this.appendPausedErrorCard(pauseError, data.session_id);
|
|
}
|
|
} else {
|
|
this.pausedError.set(null);
|
|
this.activePauseCardKey = null;
|
|
}
|
|
|
|
// Skip signal updates when backend payloads did not change: setting a
|
|
// fresh array reference every 2s poll would force the whole sessions
|
|
// computed chain to re-derive for identical data.
|
|
const queueSignature = JSON.stringify(data.queue || []);
|
|
if (queueSignature !== this.lastQueueSignature) {
|
|
this.lastQueueSignature = queueSignature;
|
|
// Map backend pending queue to real Session / TaskQueueItem objects
|
|
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
|
if (typeof item === 'object' && item !== null) {
|
|
return {
|
|
...this.explorationSessionFields(item),
|
|
session_id: item.session_id || `pending-task-${index}`,
|
|
batch_id: item.batch_id || null,
|
|
display_name: item.display_name,
|
|
initial_goal: item.goal || '',
|
|
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
|
status: item.status || 'pending',
|
|
device_serial: item.device_serial || item.device_id || null
|
|
};
|
|
}
|
|
return {
|
|
session_id: `task-queued-${index}`,
|
|
initial_goal: String(item),
|
|
start_time: (Date.now() / 1000) + index,
|
|
status: 'pending'
|
|
};
|
|
});
|
|
this.pendingQueue.set(pending);
|
|
}
|
|
const activeTasksSignature = JSON.stringify(data.active_tasks || []);
|
|
if (activeTasksSignature !== this.lastActiveTasksSignature) {
|
|
this.lastActiveTasksSignature = activeTasksSignature;
|
|
this.activeTasks.set(data.active_tasks || []);
|
|
}
|
|
|
|
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
|
|
this.fetchSessions();
|
|
}
|
|
|
|
if (
|
|
!isActive
|
|
&& (oldStatus === 'running' || oldStatus === 'paused')
|
|
&& oldRunningSessionId
|
|
&& this.isVideoWindowOpen()
|
|
&& this.activeVideoSessionId === oldRunningSessionId
|
|
&& this.recordingPlaybackStatus() === 'live'
|
|
) {
|
|
this.beginRecordingFinalization(oldRunningSessionId);
|
|
}
|
|
|
|
// Auto-select the active session if user has not explicitly pinned a historical session
|
|
if (isActive && data.session_id) {
|
|
const currentId = this.currentSessionId();
|
|
if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) {
|
|
this.selectSession(data.session_id, false);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to fetch status from backend:', err);
|
|
this.agentStatus.set('offline');
|
|
this.runningSessionId.set(null);
|
|
this.runningGoal.set(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up event source connection and timers on service destruction
|
|
*/
|
|
public destroy(): void {
|
|
this.destroyed = true;
|
|
if (this.statusInterval) {
|
|
clearInterval(this.statusInterval);
|
|
this.statusInterval = null;
|
|
}
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
this.discardPendingStreamChunks();
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
window.removeEventListener('pagehide', this.onPageHide);
|
|
window.removeEventListener('pageshow', this.onPageShow);
|
|
}
|
|
this.cancelVideoRetry();
|
|
}
|
|
|
|
/**
|
|
* Fetch notes for a specific session
|
|
*/
|
|
/**
|
|
* Backfill the Checker's attempts and run outcome from the persisted verdict
|
|
* ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so
|
|
* historical sessions show the same timeline blocks as live ones. Live
|
|
* events for attempts already known are merged by attempt id in the
|
|
* aggregator, so re-fetching is idempotent.
|
|
*/
|
|
public fetchChecks(sessionId: string): void {
|
|
if (!sessionId) return;
|
|
this.http.get<{ records?: any[]; streams?: PersistedCheckerStream[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
|
|
next: (res) => {
|
|
if (this.currentSessionId() !== sessionId) return;
|
|
const snapshot = this.buildCheckerSnapshotLogs(
|
|
sessionId, res?.records || [], res?.run_outcome || null, res?.streams || []
|
|
);
|
|
this.sessionLogs.update((logs) => [
|
|
...logs.filter((log) => !log.checks_snapshot),
|
|
...snapshot
|
|
]);
|
|
},
|
|
error: (err) => {
|
|
console.error(`Failed to fetch checks for session ${sessionId}:`, err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `streams` are the attempts' persisted transcripts (what the Checker
|
|
* streamed while reaching its verdict): they become the attempt's
|
|
* `stream_segments`, the same shape the live `llm_stream` chunks build, so
|
|
* a reopened session interleaves Thought/Work text with tool rows exactly
|
|
* like the live view did.
|
|
*/
|
|
private buildCheckerSnapshotLogs(
|
|
sessionId: string,
|
|
records: any[],
|
|
runOutcome: any,
|
|
streams: PersistedCheckerStream[] = []
|
|
): any[] {
|
|
const byAttempt = new Map<string, any>();
|
|
const segmentsByAttempt = new Map<string, StreamSegment[]>();
|
|
for (const stream of streams || []) {
|
|
if (!stream || !stream.attempt_id) continue;
|
|
const segments = persistedStreamToSegments(stream);
|
|
if (segments.length > 0) segmentsByAttempt.set(String(stream.attempt_id), segments);
|
|
}
|
|
for (const rec of records) {
|
|
if (!rec || !rec.attempt_id) continue;
|
|
const attemptId = String(rec.attempt_id);
|
|
const checkpointId = String(rec.checkpoint_id || '');
|
|
let attempt = byAttempt.get(attemptId);
|
|
if (!attempt) {
|
|
attempt = {
|
|
event: 'attempt_finished',
|
|
phase: checkpointId === 'final' ? 'final' : 'checkpoint',
|
|
attempt_id: attemptId,
|
|
checkpoint_id: checkpointId,
|
|
subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
|
|
? "根据用户原始目标进行最终检查"
|
|
: `子目标 ${checkpointId.slice(0, 8)}`)),
|
|
anchor_step_id: rec.anchor_step_id ?? null,
|
|
trace_id: rec.trace_id ?? null,
|
|
status: 'done',
|
|
verdicts: [],
|
|
findings: [],
|
|
ts: typeof rec.ts === 'number' ? rec.ts : undefined,
|
|
session_id: sessionId
|
|
};
|
|
byAttempt.set(attemptId, attempt);
|
|
}
|
|
if (typeof rec.ts === 'number') {
|
|
attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts);
|
|
}
|
|
if (!attempt.trace_id && rec.trace_id) {
|
|
attempt.trace_id = rec.trace_id;
|
|
}
|
|
attempt.verdicts.push({
|
|
item_text: String(rec.item_text || ''),
|
|
kind: String(rec.kind || ''),
|
|
status: String(rec.status || ''),
|
|
evidence: String(rec.evidence || ''),
|
|
suggestion: rec.suggestion ? String(rec.suggestion) : '',
|
|
when: rec.when ? String(rec.when) : undefined
|
|
});
|
|
}
|
|
|
|
const logs: any[] = [];
|
|
for (const attempt of byAttempt.values()) {
|
|
const statuses = new Set(attempt.verdicts.map((v: any) => v.status));
|
|
if (statuses.size === 1) {
|
|
const only = [...statuses][0];
|
|
if (only === 'superseded' || only === 'unchecked') attempt.status = only;
|
|
}
|
|
const ts = attempt.ts ?? Date.now() / 1000;
|
|
attempt.timestamp = ts;
|
|
const segments = segmentsByAttempt.get(attempt.attempt_id);
|
|
if (segments) attempt.stream_segments = segments;
|
|
logs.push({
|
|
type: 'checker_event',
|
|
session_id: sessionId,
|
|
timestamp: new Date(ts * 1000).toISOString(),
|
|
checks_snapshot: true,
|
|
data: attempt
|
|
});
|
|
}
|
|
if (runOutcome && typeof runOutcome === 'object') {
|
|
const lastTs = logs.length > 0
|
|
? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000))
|
|
: Date.now() / 1000;
|
|
const ts = lastTs + 0.001;
|
|
logs.push({
|
|
type: 'checker_event',
|
|
session_id: sessionId,
|
|
timestamp: new Date(ts * 1000).toISOString(),
|
|
checks_snapshot: true,
|
|
data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId }
|
|
});
|
|
}
|
|
return logs;
|
|
}
|
|
|
|
public fetchNotes(sessionId: string): void {
|
|
if (!sessionId) {
|
|
this.currentNotes.set({});
|
|
return;
|
|
}
|
|
this.http.get<any>(`/api/sessions/${sessionId}/notes`).subscribe({
|
|
next: (res) => {
|
|
if (res && res.notes) {
|
|
this.currentNotes.set(res.notes);
|
|
|
|
// Default to task_plan.md if available, otherwise first note
|
|
const keys = Object.keys(res.notes);
|
|
if (keys.length > 0) {
|
|
const currentSelected = this.selectedNoteKey();
|
|
if (!keys.includes(currentSelected)) {
|
|
if (keys.includes('task_plan.md')) {
|
|
this.selectedNoteKey.set('task_plan.md');
|
|
} else {
|
|
this.selectedNoteKey.set(keys[0]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
error: (err) => {
|
|
console.error(`Failed to fetch notes for session ${sessionId}:`, err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open the video player for a given session or the current session
|
|
*/
|
|
public openVideoPlayer(
|
|
sessionId?: string,
|
|
videoUrl?: string,
|
|
title?: string,
|
|
seekSeconds?: number,
|
|
stepIndex?: number,
|
|
sourceId?: string
|
|
): void {
|
|
const targetSessionId = sessionId || this.currentSessionId();
|
|
const session = this.sessions().find(s => s.session_id === targetSessionId);
|
|
const targetUrl = videoUrl || session?.video_url || null;
|
|
const goalTitle = title || session?.initial_goal || (targetSessionId ? `任务:${targetSessionId.slice(0, 8)}...` : "屏幕录像");
|
|
|
|
this.activeVideoTitle.set(goalTitle);
|
|
this.isVideoWindowOpen.set(true);
|
|
this.isVideoMinimized.set(false);
|
|
this.activeVideoSessionId = targetSessionId;
|
|
this.cancelVideoRetry();
|
|
this.videoRequestGeneration++;
|
|
this.shouldAutoplayVideo.set(false);
|
|
this.recordingPlaybackMessage.set('');
|
|
this.activeVideoSegments.set([]);
|
|
this.recordingSources.set([]);
|
|
this.selectedRecordingSource.set('');
|
|
this.usesSessionTimeline.set(false);
|
|
this.recordingSessionDuration.set(0);
|
|
if (Number.isFinite(seekSeconds)) this.requestVideoSeek(Number(seekSeconds), sourceId);
|
|
else this.videoSeekRequest.set(null);
|
|
if (Number.isFinite(stepIndex)) {
|
|
this.playerMode?.set('steps');
|
|
this.requestStepSeek(Number(stepIndex));
|
|
}
|
|
if (!targetSessionId) {
|
|
this.activeVideoUrl.set(null);
|
|
this.isVideoLoading.set(false);
|
|
this.recordingPlaybackStatus.set('unavailable');
|
|
return;
|
|
}
|
|
|
|
if (session?.status === 'running' || session?.status === 'paused') {
|
|
this.activeVideoUrl.set(null);
|
|
this.isVideoLoading.set(false);
|
|
this.recordingPlaybackStatus.set('live');
|
|
return;
|
|
}
|
|
|
|
this.activeVideoUrl.set(targetUrl);
|
|
this.shouldAutoplayVideo.set(true);
|
|
this.videoWaitStartedAt = Date.now();
|
|
this.isVideoLoading.set(true);
|
|
this.recordingPlaybackStatus.set('processing');
|
|
this.recordingPlaybackMessage.set("正在加载屏幕录像…");
|
|
if (targetUrl) {
|
|
this.playerMode?.set('video');
|
|
} else if (this.hasCurrentSessionStepFrames?.()) {
|
|
this.playerMode?.set('steps');
|
|
} else {
|
|
this.playerMode?.set('video');
|
|
}
|
|
this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
|
|
}
|
|
|
|
private appendStartupProgress(data: any, sessionId: string): void {
|
|
if (!data?.stage || !data?.message) return;
|
|
|
|
const timestamp = typeof data.timestamp === 'number'
|
|
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
|
|
: Date.now() / 1000;
|
|
const event: StartupProgressEvent = {
|
|
session_id: String(data.session_id || sessionId),
|
|
stage: String(data.stage),
|
|
message: String(data.message),
|
|
timestamp
|
|
};
|
|
|
|
const rawKey = String(data.session_id || sessionId).trim();
|
|
this.startupProgressBySession.update((allEvents) => {
|
|
const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])];
|
|
const existingIndex = current.findIndex((item) => item.stage === event.stage);
|
|
if (existingIndex >= 0) {
|
|
current[existingIndex] = event;
|
|
} else {
|
|
current.push(event);
|
|
}
|
|
current.sort((a, b) => a.timestamp - b.timestamp);
|
|
const updated = { ...allEvents, [rawKey]: current };
|
|
if (sessionId && sessionId !== rawKey) {
|
|
updated[sessionId] = current;
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
private beginRecordingFinalization(sessionId: string): void {
|
|
if (this.activeVideoSessionId !== sessionId) return;
|
|
this.cancelVideoRetry();
|
|
this.videoRequestGeneration++;
|
|
this.videoWaitStartedAt = Date.now();
|
|
this.activeVideoUrl.set(null);
|
|
this.activeVideoSegments.set([]);
|
|
this.shouldAutoplayVideo.set(true);
|
|
this.isVideoLoading.set(true);
|
|
this.recordingPlaybackStatus.set('processing');
|
|
this.recordingPlaybackMessage.set("正在完成录屏处理…");
|
|
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
|
}
|
|
|
|
private requestSessionVideo(sessionId: string, generation: number): void {
|
|
this.http.get<SessionVideoResponse>(`/api/sessions/${sessionId}/video`).subscribe({
|
|
next: (res) => {
|
|
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
|
|
return;
|
|
}
|
|
const status = res.status || (res.has_video && res.video_url ? 'ready' : 'unavailable');
|
|
this.recordingSources.set(res.sources || []);
|
|
this.usesSessionTimeline.set(!!res.session_timeline);
|
|
this.recordingSessionDuration.set(res.session_duration || 0);
|
|
if (status === 'ready' && res.video_url) {
|
|
this.cancelVideoRetry();
|
|
this.isVideoLoading.set(false);
|
|
this.recordingPlaybackStatus.set('ready');
|
|
this.recordingPlaybackMessage.set('');
|
|
this.activeVideoUrl.set(res.video_url);
|
|
this.activeVideoSegments.set(res.video_segments || []);
|
|
this.selectedRecordingSource.set(res.sources?.find(s => s.segments.length || s.url)?.id || '');
|
|
this.playerMode?.set('video');
|
|
this.rawSessions.update((list) =>
|
|
list.map((s) => s.session_id === sessionId
|
|
? { ...s, video_url: res.video_url || undefined, recording_status: 'ready' }
|
|
: s)
|
|
);
|
|
return;
|
|
}
|
|
if (status === 'processing') {
|
|
this.activeVideoUrl.set(null);
|
|
this.activeVideoSegments.set([]);
|
|
this.isVideoLoading.set(true);
|
|
this.recordingPlaybackStatus.set('processing');
|
|
this.recordingPlaybackMessage.set("正在完成录屏处理…");
|
|
this.scheduleVideoRetry(sessionId, generation, res.retry_after_ms);
|
|
return;
|
|
}
|
|
|
|
this.cancelVideoRetry();
|
|
this.isVideoLoading.set(false);
|
|
this.activeVideoUrl.set(null);
|
|
this.activeVideoSegments.set([]);
|
|
this.recordingPlaybackStatus.set(status === 'failed' ? 'failed' : 'unavailable');
|
|
this.recordingPlaybackMessage.set(
|
|
res.message || (status === 'failed'
|
|
? "录屏收尾失败。"
|
|
: "此任务暂无可用录像。")
|
|
);
|
|
if (this.hasCurrentSessionStepFrames?.()) {
|
|
this.playerMode?.set('steps');
|
|
}
|
|
},
|
|
error: () => {
|
|
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
|
|
return;
|
|
}
|
|
if (this.recordingPlaybackStatus() === 'processing') {
|
|
this.scheduleVideoRetry(sessionId, generation, 1000);
|
|
return;
|
|
}
|
|
this.isVideoLoading.set(false);
|
|
this.recordingPlaybackStatus.set('failed');
|
|
this.recordingPlaybackMessage.set("无法加载屏幕录像。");
|
|
if (this.hasCurrentSessionStepFrames?.()) {
|
|
this.playerMode?.set('steps');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private scheduleVideoRetry(sessionId: string, generation: number, retryAfterMs = 1000): void {
|
|
this.cancelVideoRetry();
|
|
if (Date.now() - this.videoWaitStartedAt > 120_000) {
|
|
this.isVideoLoading.set(false);
|
|
this.recordingPlaybackStatus.set('failed');
|
|
this.recordingPlaybackMessage.set("录屏处理超时,可以重试。");
|
|
return;
|
|
}
|
|
const delay = Math.max(500, Math.min(3000, retryAfterMs));
|
|
this.videoRetryTimer = setTimeout(() => {
|
|
this.videoRetryTimer = null;
|
|
this.requestSessionVideo(sessionId, generation);
|
|
}, delay);
|
|
}
|
|
|
|
private cancelVideoRetry(): void {
|
|
if (this.videoRetryTimer) {
|
|
clearTimeout(this.videoRetryTimer);
|
|
this.videoRetryTimer = null;
|
|
}
|
|
}
|
|
|
|
private refreshActiveRecording(autoplay: boolean): void {
|
|
const sessionId = this.activeVideoSessionId;
|
|
if (!sessionId) return;
|
|
this.cancelVideoRetry();
|
|
this.videoRequestGeneration++;
|
|
if (autoplay) this.shouldAutoplayVideo.set(true);
|
|
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
|
}
|
|
|
|
public retryVideoRecording(): void {
|
|
const sessionId = this.activeVideoSessionId;
|
|
if (!sessionId) return;
|
|
this.beginRecordingFinalization(sessionId);
|
|
}
|
|
|
|
public consumeVideoAutoplay(): boolean {
|
|
const shouldAutoplay = this.shouldAutoplayVideo();
|
|
this.shouldAutoplayVideo.set(false);
|
|
return shouldAutoplay;
|
|
}
|
|
|
|
public requestVideoSeek(seconds: number, sourceId?: string): void {
|
|
if (!Number.isFinite(seconds)) return;
|
|
this.videoSeekRequest.set({
|
|
seconds: Math.max(0, seconds),
|
|
sourceId,
|
|
requestId: ++this.videoSeekRequestId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggle the video player for the current session
|
|
*/
|
|
public toggleVideoPlayer(): void {
|
|
if (this.isVideoWindowOpen()) {
|
|
this.isVideoWindowOpen.set(false);
|
|
} else {
|
|
this.openVideoPlayer();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close the video player
|
|
*/
|
|
public closeVideoPlayer(): void {
|
|
this.isVideoWindowOpen.set(false);
|
|
this.cancelVideoRetry();
|
|
this.videoRequestGeneration++;
|
|
this.activeVideoSessionId = null;
|
|
}
|
|
}
|