Files
ss-tools/frontend/src/lib/models/LLMReportModel.svelte.ts
busya 149c05bf1c fix(frontend): full ADR compliance — Model-View concept, model decomposition, button migration
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.
2026-06-17 14:02:17 +03:00

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