P0 — Model-first ADR compliance:
- Decompose DashboardHubModel (590→496 lines) into Dashboards.FiltersModel,
Dashboards.SelectionModel, Dashboards.GitActionsModel (DG split per plan)
- Decompose AgentChatModel (630→356 lines) into ConnectionManager,
StreamProcessor, LocalStorage, shared types
- Decompose MigrationModel (457→389 lines) into WizardModel, ExecutorModel
P0 — /ui atom compliance:
- Replace all raw <button> with <Button> from /ui in dashboards/+page.svelte
(~20 replacements) and 16 additional routes/ files (~70 replacements total)
P0 — Hierarchical region IDs (ATTN_2):
- Rename all 22 model #region/#endregion IDs from flat to Domain.Name format
- Update @ingroup from generic 'Models' to domain-specific (Dashboards, Git, etc.)
P1 — UX contract compliance:
- Add @UX_STATE declarations to agent/+page.svelte
- Extract Gradio Client.connect from page into AgentChatModel.retryConnection()
All new model files have proper GRACE anchors (#region/#endregion, @ingroup,
@BRIEF, @INVARIANT, @STATE, @ACTION, @RELATION).
Build: npm run build passes.
Tests: DashboardHubModel 112/112, MigrationModel 74/74 pass.
204 lines
7.6 KiB
TypeScript
204 lines
7.6 KiB
TypeScript
// #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<string, string> = $state({});
|
|
screenshotLoadErrors: Record<string, string> = $state({});
|
|
private _screenshotLoadToken: number = $state(0);
|
|
|
|
// Externally set
|
|
taskId: string = $state('');
|
|
|
|
// ── Derived ───────────────────────────────────────────────────
|
|
result = $derived<TaskResult>(this.task?.result || {});
|
|
checkResult = $derived<CheckResult>(this._getDashboardCheckResult(this.result));
|
|
timings = $derived<TimingInfo>(this.result?.timings || {});
|
|
screenshotPaths = $derived<string[]>(
|
|
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<string[]>(
|
|
Array.isArray(this.result?.logs_sent_to_llm) ? this.result.logs_sent_to_llm : [],
|
|
);
|
|
|
|
// ── Actions ───────────────────────────────────────────────────
|
|
|
|
async loadReport(): Promise<void> {
|
|
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<void> {
|
|
const currentToken = ++this._screenshotLoadToken;
|
|
this._cleanupBlobUrls();
|
|
this.screenshotLoadErrors = {};
|
|
|
|
if (!Array.isArray(paths) || paths.length === 0) return;
|
|
|
|
const nextUrls: Record<string, string> = {};
|
|
const nextErrors: Record<string, string> = {};
|
|
|
|
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
|