// #region Translate.LLMReportModel [C:3] [TYPE Model] [SEMANTICS llm,report,validation,screenshot,model] // @ingroup Translate // @BRIEF Screen model for the LLM validation report page — loads task details, logs, and thumbnail screenshots. // @INVARIANT taskId is derived from route params; loadReport() must only fire when taskId is truthy. // @INVARIANT screenshot loading is token-gated to prevent stale blob URLs from overwriting valid ones. // @STATE idle — No taskId available, nothing loaded. // @STATE loading — API calls in progress, skeleton visible. // @STATE loaded — Task data and logs fetched, report rendered. // @STATE error — API call failed, error message displayed. // @ACTION loadReport() — Fetches task + logs from API. // @ACTION loadScreenshotBlobs() — Fetches blob URLs for screenshot paths. // @ACTION openScreenshot(path) — Opens screenshot blob URL in new tab. // @ACTION cleanupBlobs() — Revokes all screenshot blob URLs. // @ACTION refresh() — Re-runs loadReport(). // @RELATION DEPENDS_ON -> [ApiModule] // @RELATION CALLS -> [CotLogger] import { api } from '$lib/api.js'; import { log } from '$lib/cot-logger'; import type { TaskLogEntry } from '$lib/api.js'; import { SvelteDate } from "svelte/reactivity"; // ── Types ─────────────────────────────────────────────────────── type ScreenState = 'idle' | 'loading' | 'loaded' | 'error'; interface TimingInfo { validation_duration_ms?: number; [key: string]: unknown; } interface CheckIssue { severity?: string; message?: string; location?: string; } interface TaskResult { status?: string; summary?: string; issues?: CheckIssue[]; screenshot_paths?: string[]; screenshot_path?: string; logs_sent_to_llm?: string[]; timings?: TimingInfo; } interface TaskData { id: string; status?: string; started_at?: string; finished_at?: string; result?: TaskResult; } interface CheckResult { label: string; level: 'fail' | 'warn' | 'pass' | 'unknown'; icon: string; } export class LLMReportModel { // ── Atoms ───────────────────────────────────────────────────── task: TaskData | null = $state(null); logs: TaskLogEntry[] = $state([]); screenState: ScreenState = $state('idle'); error: string | null = $state(null); screenshotBlobUrls: Record = $state({}); screenshotLoadErrors: Record = $state({}); private _screenshotLoadToken: number = $state(0); // Externally set taskId: string = $state(''); // ── Derived ─────────────────────────────────────────────────── result = $derived(this.task?.result || {}); checkResult = $derived(this._getDashboardCheckResult(this.result)); timings = $derived(this.result?.timings || {}); screenshotPaths = $derived( Array.isArray(this.result?.screenshot_paths) && this.result.screenshot_paths.length > 0 ? this.result.screenshot_paths : this.result?.screenshot_path ? [this.result.screenshot_path] : [], ); sentLogs = $derived( Array.isArray(this.result?.logs_sent_to_llm) ? this.result.logs_sent_to_llm : [], ); // ── Actions ─────────────────────────────────────────────────── async loadReport(): Promise { if (!this.taskId) return; log('LLMReportModel', 'REASON', 'Loading LLM report', { taskId: this.taskId }); this.screenState = 'loading'; this.error = null; try { const [taskPayload, logsPayload] = await Promise.all([ api.getTask(this.taskId), api.getTaskLogs(this.taskId, { limit: 1000 }), ]); this.task = taskPayload as TaskData; this.logs = Array.isArray(logsPayload) ? logsPayload : []; this.screenState = 'loaded'; log('LLMReportModel', 'REFLECT', 'LLM report loaded', { taskId: this.taskId, status: this.task?.status, logCount: this.logs.length, }); } catch (err: unknown) { this.error = err instanceof Error ? err.message : 'Failed to load LLM report'; this.screenState = 'error'; log('LLMReportModel', 'EXPLORE', 'Failed to load LLM report', { taskId: this.taskId }, String(this.error)); } } async loadScreenshotBlobs(paths: string[]): Promise { const currentToken = ++this._screenshotLoadToken; this._cleanupBlobUrls(); this.screenshotLoadErrors = {}; if (!Array.isArray(paths) || paths.length === 0) return; const nextUrls: Record = {}; const nextErrors: Record = {}; await Promise.all( paths.map(async (path) => { try { const blob = await api.getStorageFileBlob(path); nextUrls[path] = URL.createObjectURL(blob); } catch (err: unknown) { nextErrors[path] = err instanceof Error ? err.message : 'Failed to load screenshot'; } }), ); if (currentToken !== this._screenshotLoadToken) { for (const url of Object.values(nextUrls)) URL.revokeObjectURL(url); return; } this.screenshotBlobUrls = nextUrls; this.screenshotLoadErrors = nextErrors; } openScreenshot(path: string): void { const url = this.screenshotBlobUrls[path]; if (!url) return; window.open(url, '_blank', 'noopener,noreferrer'); } cleanupBlobs(): void { this._cleanupBlobUrls(); } refresh(): void { this.loadReport(); } // ── Private ─────────────────────────────────────────────────── private _cleanupBlobUrls(): void { for (const url of Object.values(this.screenshotBlobUrls)) { if (url) URL.revokeObjectURL(url); } this.screenshotBlobUrls = {}; } private _getDashboardCheckResult(resultPayload: TaskResult): CheckResult { const rawStatus = String(resultPayload?.status || '').toUpperCase(); if (rawStatus === 'FAIL') return { label: 'FAIL', level: 'fail', icon: '!' }; if (rawStatus === 'WARN') return { label: 'WARN', level: 'warn', icon: '!' }; if (rawStatus === 'PASS') return { label: 'PASS', level: 'pass', icon: 'OK' }; return { label: 'UNKNOWN', level: 'unknown', icon: '?' }; } // ── Static utils (pure, no state access) ────────────────────── static formatDate(value: unknown): string { if (!value) return '-'; const parsed = new SvelteDate(value as string); if (Number.isNaN(parsed.getTime())) return '-'; return `${parsed.toLocaleDateString()} ${parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`; } static formatMs(value: unknown): string { const ms = Number(value); if (!Number.isFinite(ms) || ms < 0) return '-'; if (ms < 1000) return `${ms} ms`; return `${(ms / 1000).toFixed(2)} s`; } static getCheckResultClasses(level: CheckResult['level']): string { if (level === 'fail') return 'bg-destructive-light text-destructive border-destructive-ring'; if (level === 'warn') return 'bg-warning-light text-warning border-warning'; if (level === 'pass') return 'bg-success-light text-success border-success'; return 'bg-surface-muted text-text border-border'; } } // #endregion Translate.LLMReportModel