@ggui-ai/ui-gen 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/generation-dispatch.d.ts +6 -3
- package/dist/adapters/generation-dispatch.js +5161 -4312
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +8 -5
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +12 -8
- package/dist/advanced/index.js +4685 -3801
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +8 -12
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.d.ts +5 -2
- package/dist/boilerplate.js +120 -13
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +2 -2
- package/dist/check/index.js +72 -0
- package/dist/check/index.js.map +1 -1
- package/dist/compose.js +1 -1
- package/dist/compose.js.map +1 -1
- package/dist/{create-ui-generator-CCIeQaEe.d.ts → create-ui-generator-C8Ki9Wz1.d.ts} +18 -2
- package/dist/{design-mode-BHEE7flj.d.ts → design-mode-DLufhrNp.d.ts} +0 -6
- package/dist/{dispatch-BA5dJcg-.d.ts → dispatch-D2htzi9h.d.ts} +2 -2
- package/dist/evaluation/axis-checks/index.d.ts +4 -4
- package/dist/evaluation/axis-checks/index.js +347 -13
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +2 -2
- package/dist/evaluation/axis-checks/registry.js +302 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +160 -8
- package/dist/evaluation/index.js +752 -81
- package/dist/evaluation/index.js.map +1 -1
- package/dist/fragments/index.js +1 -1
- package/dist/fragments/index.js.map +1 -1
- package/dist/{generate-8L_bc2iP.d.ts → generate-9IeNT4ZY.d.ts} +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +3 -2
- package/dist/harness/check/runtime-render/index.js +8 -12
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/index.d.ts +3 -2
- package/dist/harness/index.js +410 -255
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -2
- package/dist/harness/prompts.js +7 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +3 -3
- package/dist/harness/runtime.d.ts +13 -4
- package/dist/harness/runtime.js +131 -20
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +13 -2
- package/dist/harness/types-public.js +410 -255
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +2 -2
- package/dist/index.d.ts +29 -7
- package/dist/index.js +4691 -3805
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +3 -2
- package/dist/{prompts-k6m6reqe.d.ts → prompts-YU9piOgs.d.ts} +12 -46
- package/dist/styling-profile-CxfvV48g.d.ts +35 -0
- package/dist/tools/get-primitives-ts.d.ts +1 -1
- package/dist/tools/get-primitives-ts.js +1 -1
- package/dist/tools/get-primitives-ts.js.map +1 -1
- package/dist/{types-public-D24JLJgl.d.ts → types-public-DVSssdc-.d.ts} +39 -3
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +3 -2
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +6 -1
- package/src/boilerplate/free-design-prompt.ts +9 -0
- package/src/boilerplate/generate.ts +8 -1
- package/src/boilerplate/hard-sections.ts +45 -1
- package/src/boilerplate/styling-profile.ts +166 -0
- package/src/boilerplate/system-prompt.ts +22 -8
- package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
- package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
- package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
- package/src/check/run-tier0.ts +154 -0
- package/src/contract-context.ts +28 -14
- package/src/create-harness.ts +4 -0
- package/src/create-ui-generator.ts +41 -2
- package/src/design-mode.ts +11 -1
- package/src/design-system-docs.ts +7 -4
- package/src/effort.ts +48 -0
- package/src/evaluation/axis-checks/checks/grid.ts +148 -0
- package/src/evaluation/axis-checks/checks/universal.ts +308 -0
- package/src/evaluation/axis-checks/dispatch.ts +78 -13
- package/src/evaluation/axis-checks/helpers.ts +12 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/design-identity.ts +96 -0
- package/src/evaluation/index.ts +17 -0
- package/src/evaluation/llm-evaluator.ts +13 -1
- package/src/evaluation/theme-css.ts +14 -0
- package/src/evaluation/types-public.ts +35 -1
- package/src/evaluation/visual-evaluator.ts +478 -45
- package/src/fragments/render.ts +1 -1
- package/src/harness/check/runtime-render/prepare-mockup.ts +18 -11
- package/src/harness/coding/init-session.ts +1 -0
- package/src/harness/coding/run-coding-turn.ts +6 -14
- package/src/harness/coding/run-eval-round.ts +31 -3
- package/src/harness/runtime.ts +10 -0
- package/src/harness/types-public.ts +11 -0
- package/src/index.ts +2 -0
- package/src/pin-fixtures.ts +98 -0
- package/src/run-check.ts +10 -10
- package/src/tools/get-primitives-ts.ts +1 -1
- package/src/tools.ts +2 -1
- package/src/validation/primitives.ts +1 -1
|
@@ -11,17 +11,22 @@
|
|
|
11
11
|
// issues only visible when rendered: broken layouts, overlapping elements,
|
|
12
12
|
// poor visual hierarchy, missing whitespace.
|
|
13
13
|
|
|
14
|
+
import { buildStylingProfileJudgeBlock } from '../boilerplate/styling-profile.js';
|
|
15
|
+
import type { GenerationProfileInput } from '../boilerplate/styling-profile.js';
|
|
14
16
|
import { build } from 'esbuild';
|
|
15
|
-
import { getCssTokens } from '@ggui-ai/design/rendering';
|
|
17
|
+
import { fillFitRule, getCssTokens } from '@ggui-ai/design/rendering';
|
|
18
|
+
import { judgeDesignIdentity, type JudgeDesignIdentity } from './design-identity.js';
|
|
16
19
|
import { resolve, dirname } from 'path';
|
|
17
20
|
import { fileURLToPath } from 'url';
|
|
18
|
-
import {
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
19
24
|
import { tmpdir } from 'os';
|
|
20
25
|
import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
|
|
21
26
|
import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
|
|
22
|
-
import type { CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
|
|
27
|
+
import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
|
|
23
28
|
import type { LaunchOptions } from 'puppeteer-core';
|
|
24
|
-
import { CANVAS_VIEWPORTS, type CanvasClass, type CanvasViewport } from '../design-mode.js';
|
|
29
|
+
import { CANVAS_VIEWPORTS, displayModeForCanvas, type CanvasClass, type CanvasViewport } from '../design-mode.js';
|
|
25
30
|
|
|
26
31
|
// ---------------------------------------------------------------------------
|
|
27
32
|
// Types
|
|
@@ -56,6 +61,23 @@ export interface VisualEvalConfig {
|
|
|
56
61
|
* evaluator constructs so its multimodal call never falls back to
|
|
57
62
|
* `process.env` for credentials or model routing.
|
|
58
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* Vision calls per judged canvas on the SAME captured frame, aggregated by
|
|
66
|
+
* the median (ggui#1072). Default 1 — a single judgement, byte-identical to
|
|
67
|
+
* the instrument before this option existed. K is an instrument dial: a
|
|
68
|
+
* run mixes K at its own peril.
|
|
69
|
+
*/
|
|
70
|
+
judgeK?: number;
|
|
71
|
+
/** The canvases sampled `judgeK` times; default = every judged canvas when `judgeK > 1`. */
|
|
72
|
+
judgeKCanvases?: readonly CanvasClass[];
|
|
73
|
+
/**
|
|
74
|
+
* The design `src` tree the judge bundles the component against
|
|
75
|
+
* (ggui#1042): an explicit input, so a judge-only re-judge names the tree
|
|
76
|
+
* it painted with. Default: the design package beside this one.
|
|
77
|
+
*/
|
|
78
|
+
designSrcDir?: string;
|
|
79
|
+
/** The mode the caller composed `cssTokens` in — a receipt on the row, never a switch (ggui#1076: judge and runtime must name the same mode). */
|
|
80
|
+
themeMode?: 'light' | 'dark';
|
|
59
81
|
routeOverride?: AgentConfig['routeOverride'];
|
|
60
82
|
/**
|
|
61
83
|
* Provider-429-retry observer — see `AgentConfig.onRetry`.
|
|
@@ -76,6 +98,84 @@ export interface CanvasVisualResult {
|
|
|
76
98
|
passed: boolean;
|
|
77
99
|
/** The PNG the judge saw — kept for artefact persistence + the human look. */
|
|
78
100
|
screenshotPng: Buffer;
|
|
101
|
+
/**
|
|
102
|
+
* The document's scroll height at this canvas's viewport, CSS px — what
|
|
103
|
+
* the box would have to be for nothing to be hidden (ggui#1027). `null`
|
|
104
|
+
* when the page could not be measured.
|
|
105
|
+
*/
|
|
106
|
+
contentHeight: number | null;
|
|
107
|
+
/** `contentHeight > viewport.height` — a measurement; what it MEANS is {@link canvasFitPolicy}'s. */
|
|
108
|
+
overflow: boolean;
|
|
109
|
+
/** How `score` was reached — always present (ggui#1072). */
|
|
110
|
+
judge: CanvasJudgeRecord;
|
|
111
|
+
/** How the judge composed the mount (ggui#1100): `'fill'` on every fullscreen canvas, absent on the inline card. */
|
|
112
|
+
fit?: 'fill';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** How a canvas is captured for the judge and what an overflow means there (ggui#1027). */
|
|
116
|
+
export type CaptureMode = 'viewport' | 'full-page';
|
|
117
|
+
export interface CanvasFitPolicy {
|
|
118
|
+
/** `viewport` = the judge sees the box the user sees; `full-page` = the whole scrolled document. */
|
|
119
|
+
readonly capture: CaptureMode;
|
|
120
|
+
/** The verdict an overflow earns on this canvas. */
|
|
121
|
+
readonly overflow: 'fail' | 'warn' | 'none';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The inline chat card is a BOX — the bubble bounds the component and it
|
|
126
|
+
* does not scroll — so the judge captures the viewport (a 1289 px hello on
|
|
127
|
+
* a 640 px card was scored 86 from a full-page capture that showed the
|
|
128
|
+
* judge what no user sees) and an overflow is a critical layout issue that
|
|
129
|
+
* fails the canvas. A phone's first screen may scroll, so the overflow is
|
|
130
|
+
* reported as a major issue and the score stands. Pages (`md`/`lg`/`xl`)
|
|
131
|
+
* scroll by design: measured, never judged.
|
|
132
|
+
*/
|
|
133
|
+
export function canvasFitPolicy(canvas: CanvasClass): CanvasFitPolicy {
|
|
134
|
+
switch (canvas) {
|
|
135
|
+
case 'xs-chat-card':
|
|
136
|
+
return { capture: 'viewport', overflow: 'fail' };
|
|
137
|
+
case 'mobile-fullscreen-small':
|
|
138
|
+
return { capture: 'full-page', overflow: 'warn' };
|
|
139
|
+
default:
|
|
140
|
+
return { capture: 'full-page', overflow: 'none' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The mount element of the judge page (ggui#1100). Its class is the scope
|
|
146
|
+
* `fillFitRule` targets — the judge's counterpart of the runtime's mount list.
|
|
147
|
+
*/
|
|
148
|
+
export const JUDGE_SCOPE_CLASS = 'ggui-judge-scope';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* How the judge composes a canvas (ggui#1100): the served runtime stretches
|
|
152
|
+
* the mounted root to the frame on every fullscreen surface (`fit: 'fill'`,
|
|
153
|
+
* ggui#1041/#1073/#1096) and leaves the inline card at its natural height —
|
|
154
|
+
* the judge does the same, or it scores a picture the user never sees (a
|
|
155
|
+
* content-sized hello on an empty 768×1024 page; candidate 34's hellos).
|
|
156
|
+
*/
|
|
157
|
+
export function canvasFit(canvas: CanvasClass): 'fill' | undefined {
|
|
158
|
+
return displayModeForCanvas(canvas) === 'fullscreen' ? 'fill' : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The deterministic fit issue — one per overflowing canvas, in the judge's issue shape so it rides the same channel. */
|
|
162
|
+
export function canvasOverflowIssue(
|
|
163
|
+
canvas: CanvasClass,
|
|
164
|
+
viewport: CanvasViewport,
|
|
165
|
+
contentHeight: number,
|
|
166
|
+
verdict: 'fail' | 'warn',
|
|
167
|
+
): EvaluationIssue {
|
|
168
|
+
const hidden = contentHeight - viewport.height;
|
|
169
|
+
return {
|
|
170
|
+
dimension: 'canvas-overflow',
|
|
171
|
+
severity: verdict === 'fail' ? 'critical' : 'major',
|
|
172
|
+
description:
|
|
173
|
+
`Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${viewport.width}×${viewport.height}) — ` +
|
|
174
|
+
`${hidden}px ${verdict === 'fail' ? 'is cut off: the inline card does not scroll' : 'sits below the first screen'}.`,
|
|
175
|
+
fix:
|
|
176
|
+
'Fit the composition to the canvas: fewer and shorter sections, one compact row of chips, no hero taller than ' +
|
|
177
|
+
'a third of the box, no fixed min-heights or tall paddings — measure against the canvas, not the page.',
|
|
178
|
+
};
|
|
79
179
|
}
|
|
80
180
|
|
|
81
181
|
/**
|
|
@@ -86,6 +186,10 @@ export interface CanvasVisualResult {
|
|
|
86
186
|
*/
|
|
87
187
|
export interface VisualEvaluationResult extends EvaluationResult {
|
|
88
188
|
canvases?: CanvasVisualResult[];
|
|
189
|
+
/** The design tree the judge painted with (ggui#1042) — present on every per-canvas judgement. */
|
|
190
|
+
design?: JudgeDesignIdentity;
|
|
191
|
+
/** The mode `cssTokens` were composed in, when the caller said (ggui#1076). */
|
|
192
|
+
themeMode?: 'light' | 'dark';
|
|
89
193
|
}
|
|
90
194
|
|
|
91
195
|
/** Injection points for `runVisualEvaluation` — screenshot deps plus the judge call (tests). */
|
|
@@ -101,6 +205,8 @@ export interface VisualEvalContext {
|
|
|
101
205
|
originalPrompt: string;
|
|
102
206
|
/** Design system CSS tokens */
|
|
103
207
|
cssTokens?: string;
|
|
208
|
+
/** The app's generation profile (#991) — judged relative to, never against. */
|
|
209
|
+
profile?: GenerationProfileInput;
|
|
104
210
|
}
|
|
105
211
|
|
|
106
212
|
// ---------------------------------------------------------------------------
|
|
@@ -135,14 +241,70 @@ export function resolveUiGenPackageDir(): string {
|
|
|
135
241
|
return resolve(selfDir, '..', '..');
|
|
136
242
|
}
|
|
137
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Where `@ggui-ai/wire` actually is, from ui-gen's OWN module graph —
|
|
246
|
+
* layout-proof: a workspace checkout links it under `node_modules/`, a
|
|
247
|
+
* `pnpm deploy` tree puts it beside ui-gen under `.pnpm/…/node_modules/`,
|
|
248
|
+
* and a plain install nests it; `import.meta.resolve` answers for all
|
|
249
|
+
* three (directory candidates first, module graph last). Aliased
|
|
250
|
+
* explicitly in the judge's bundle so the generated component's wire
|
|
251
|
+
* import never depends on the directory layout the evaluator runs in.
|
|
252
|
+
*/
|
|
253
|
+
export function resolveWirePackageDir(): string | null {
|
|
254
|
+
const uiGenDir = resolveUiGenPackageDir();
|
|
255
|
+
const candidates = [
|
|
256
|
+
resolve(uiGenDir, 'node_modules', '@ggui-ai', 'wire'), // workspace / nested install
|
|
257
|
+
resolve(uiGenDir, '..', 'wire'), // pnpm deploy: `.pnpm/<ui-gen>@<ver>/node_modules/@ggui-ai/{ui-gen,wire}`
|
|
258
|
+
resolve(uiGenDir, '..', '..', '@ggui-ai', 'wire'), // hoisted flat install beside the scope dir
|
|
259
|
+
];
|
|
260
|
+
for (const dir of candidates) {
|
|
261
|
+
if (existsSync(resolve(dir, 'package.json'))) return dir;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
// Last resort: ask the module graph (Node ≥ 20.6). Not every runner
|
|
265
|
+
// supports it synchronously, hence the guarded position.
|
|
266
|
+
const entry = fileURLToPath(import.meta.resolve('@ggui-ai/wire'));
|
|
267
|
+
let dir = dirname(entry);
|
|
268
|
+
for (let i = 0; i < 6; i++) {
|
|
269
|
+
if (existsSync(resolve(dir, 'package.json'))) return dir;
|
|
270
|
+
dir = dirname(dir);
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// fall through — nodePaths still gets a chance
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The `@ggui-ai/design` package root — resolved by MODULE ID, never by counting
|
|
280
|
+
* directories (ggui#1110).
|
|
281
|
+
*
|
|
282
|
+
* The arithmetic this replaced (`…/'..','..','..','design'`) was correct only
|
|
283
|
+
* for the layout it was written against — `node_modules/@ggui-ai/ui-gen/dist/
|
|
284
|
+
* evaluation` → `node_modules/@ggui-ai/` + `design`. Under a deploy-flattened
|
|
285
|
+
* layout it landed one level too high, at `node_modules/design`, a path that is
|
|
286
|
+
* not the design package at all: the judge then walked a directory that does not
|
|
287
|
+
* exist, threw ENOENT inside every evaluation round on that deployment, and a
|
|
288
|
+
* bar refused cards no one could judge. `@ggui-ai/design` exports `./package.json`,
|
|
289
|
+
* so the module resolver answers correctly in every layout — scoped, flattened,
|
|
290
|
+
* hoisted — and the receipt keeps working inside the image.
|
|
291
|
+
*/
|
|
138
292
|
export function resolveDesignPackageDir(): string {
|
|
139
|
-
|
|
140
|
-
|
|
293
|
+
try {
|
|
294
|
+
return dirname(createRequire(import.meta.url).resolve('@ggui-ai/design/package.json'));
|
|
295
|
+
} catch {
|
|
296
|
+
// A runner whose module graph cannot see the package (a bundled judge, a
|
|
297
|
+
// test double). The layout guess is the last resort, and a wrong answer is
|
|
298
|
+
// now a NAMED absent receipt rather than a thrown round (`judgeDesignIdentity`).
|
|
299
|
+
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
300
|
+
return resolve(selfDir, '..', '..', '..', 'design');
|
|
301
|
+
}
|
|
141
302
|
}
|
|
142
303
|
|
|
143
304
|
async function bundleForRendering(
|
|
144
305
|
compiledCode: string,
|
|
145
306
|
sampleProps: Record<string, unknown>,
|
|
307
|
+
designSrc: string = resolve(resolveDesignPackageDir(), 'src'),
|
|
146
308
|
): Promise<string> {
|
|
147
309
|
// Write component + entry to temp files for esbuild
|
|
148
310
|
const tmpDir = resolve(tmpdir(), 'ggui-visual-eval-' + Date.now());
|
|
@@ -150,7 +312,8 @@ async function bundleForRendering(
|
|
|
150
312
|
const componentFile = resolve(tmpDir, 'component.tsx');
|
|
151
313
|
const entryFile = resolve(tmpDir, 'entry.tsx');
|
|
152
314
|
|
|
153
|
-
const designPkgDir =
|
|
315
|
+
const designPkgDir = resolve(designSrc, '..');
|
|
316
|
+
const wirePkgDir = resolveWirePackageDir();
|
|
154
317
|
|
|
155
318
|
// Write the compiled component as a separate module
|
|
156
319
|
writeFileSync(componentFile, compiledCode);
|
|
@@ -212,6 +375,10 @@ try {
|
|
|
212
375
|
jsxImportSource: 'react',
|
|
213
376
|
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
|
214
377
|
alias: {
|
|
378
|
+
// The wire hooks: resolved from ui-gen's own module graph so the
|
|
379
|
+
// alias holds in a workspace checkout AND in a `pnpm deploy` image
|
|
380
|
+
// (where deps are siblings, not children, of this package).
|
|
381
|
+
...(wirePkgDir !== null ? { '@ggui-ai/wire': wirePkgDir } : {}),
|
|
215
382
|
// D1: generated code imports the single `@ggui-ai/design` barrel.
|
|
216
383
|
// The subpath aliases stay for any first-party / legacy code.
|
|
217
384
|
'@ggui-ai/design': resolve(designPkgDir, 'src', 'index.ts'),
|
|
@@ -223,7 +390,10 @@ try {
|
|
|
223
390
|
// Bare imports in the generated component resolve against ui-gen's
|
|
224
391
|
// own node_modules — the temp entry dir has none. Without this every
|
|
225
392
|
// wire-bearing component (`@ggui-ai/wire`) failed to bundle.
|
|
226
|
-
|
|
393
|
+
// Workspace layout (deps under this package's node_modules) and the
|
|
394
|
+
// `pnpm deploy` layout (deps as siblings of this package) — both
|
|
395
|
+
// searched for any other allowlisted bare import.
|
|
396
|
+
nodePaths: [resolve(resolveUiGenPackageDir(), 'node_modules'), resolve(resolveUiGenPackageDir(), '..')],
|
|
227
397
|
logLevel: 'silent',
|
|
228
398
|
});
|
|
229
399
|
|
|
@@ -237,6 +407,7 @@ try {
|
|
|
237
407
|
function buildRenderHTML(
|
|
238
408
|
bundledCode: string,
|
|
239
409
|
cssTokens?: string,
|
|
410
|
+
fit?: 'fill',
|
|
240
411
|
): string {
|
|
241
412
|
// Default to the design tokens production's no-theme branch injects
|
|
242
413
|
// (getCssTokens → default theme, light) — ggui#613: under the s4
|
|
@@ -260,10 +431,11 @@ function buildRenderHTML(
|
|
|
260
431
|
color: var(--ggui-color-neutral-900, #111827);
|
|
261
432
|
}
|
|
262
433
|
.error { color: #dc2626; padding: 16px; font-family: monospace; white-space: pre-wrap; }
|
|
434
|
+
${fit === 'fill' ? fillFitRule(JUDGE_SCOPE_CLASS) : ''}
|
|
263
435
|
</style>
|
|
264
436
|
</head>
|
|
265
437
|
<body>
|
|
266
|
-
<div id="root"></div>
|
|
438
|
+
<div id="root"${fit === 'fill' ? ` class="${JUDGE_SCOPE_CLASS}"` : ''}></div>
|
|
267
439
|
<script type="importmap">
|
|
268
440
|
{
|
|
269
441
|
"imports": {
|
|
@@ -290,6 +462,8 @@ export interface ScreenshotPage {
|
|
|
290
462
|
setContent(html: string, options: { waitUntil: 'load'; timeout: number }): Promise<void>;
|
|
291
463
|
waitForNetworkIdle(options: { idleTime: number; timeout: number }): Promise<void>;
|
|
292
464
|
waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>;
|
|
465
|
+
/** Evaluates a JS expression in the page and returns its serialised value — the fit measurement (ggui#1027). */
|
|
466
|
+
evaluate(expression: string): Promise<unknown>;
|
|
293
467
|
screenshot(options: { type: 'png'; fullPage: boolean }): Promise<Uint8Array>;
|
|
294
468
|
}
|
|
295
469
|
|
|
@@ -375,11 +549,35 @@ async function launchWithPuppeteer(options: LaunchOptions): Promise<ScreenshotBr
|
|
|
375
549
|
* Take a screenshot of the rendered component.
|
|
376
550
|
* Returns the PNG bytes, or null if no browser could be launched.
|
|
377
551
|
*/
|
|
378
|
-
|
|
552
|
+
/** Screenshot plus the reason when none could be taken — the judge's `unavailableReason`. */
|
|
553
|
+
export interface ScreenshotAttempt {
|
|
554
|
+
readonly png: Buffer | null;
|
|
555
|
+
readonly reason?: string;
|
|
556
|
+
/** The document's scroll height at the viewport (ggui#1027); `null` when unmeasurable. */
|
|
557
|
+
readonly contentHeight: number | null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The expression the fit measurement evaluates in the page — the taller of the two scroll heights. */
|
|
561
|
+
export const CONTENT_HEIGHT_EXPRESSION =
|
|
562
|
+
'Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)';
|
|
563
|
+
|
|
564
|
+
/** Measure the rendered document's height; a failed measurement is reported, never a failed capture. */
|
|
565
|
+
async function measureContentHeight(page: ScreenshotPage): Promise<number | null> {
|
|
566
|
+
try {
|
|
567
|
+
const h = await page.evaluate(CONTENT_HEIGHT_EXPRESSION);
|
|
568
|
+
return typeof h === 'number' && Number.isFinite(h) ? Math.round(h) : null;
|
|
569
|
+
} catch (e) {
|
|
570
|
+
console.warn(`[visual-eval] content height unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function captureScreenshotDetailed(
|
|
379
576
|
html: string,
|
|
380
577
|
viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
|
|
381
578
|
deps: ScreenshotDeps = {},
|
|
382
|
-
|
|
579
|
+
capture: CaptureMode = 'full-page',
|
|
580
|
+
): Promise<ScreenshotAttempt> {
|
|
383
581
|
try {
|
|
384
582
|
const launchOptions = await resolveLaunchOptions(viewport, deps);
|
|
385
583
|
const browser = await (deps.launch ?? launchWithPuppeteer)(launchOptions);
|
|
@@ -393,22 +591,32 @@ export async function captureScreenshot(
|
|
|
393
591
|
await page.waitForSelector('#root > *', { timeout: 10000 }).catch(() => {});
|
|
394
592
|
// Wait a bit for CSS/fonts to settle
|
|
395
593
|
await new Promise((r) => setTimeout(r, deps.settleMs ?? 1000));
|
|
396
|
-
const
|
|
397
|
-
|
|
594
|
+
const contentHeight = await measureContentHeight(page);
|
|
595
|
+
const screenshot = await page.screenshot({ type: 'png', fullPage: capture === 'full-page' });
|
|
596
|
+
return { png: Buffer.from(screenshot), contentHeight };
|
|
398
597
|
} finally {
|
|
399
598
|
await browser.close();
|
|
400
599
|
}
|
|
401
600
|
} catch (e) {
|
|
402
|
-
|
|
403
|
-
|
|
601
|
+
const reason = `screenshot failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
602
|
+
console.warn(`[visual-eval] ${reason}`);
|
|
603
|
+
return { png: null, reason, contentHeight: null };
|
|
404
604
|
}
|
|
405
605
|
}
|
|
406
606
|
|
|
607
|
+
export async function captureScreenshot(
|
|
608
|
+
html: string,
|
|
609
|
+
viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
|
|
610
|
+
deps: ScreenshotDeps = {},
|
|
611
|
+
): Promise<Buffer | null> {
|
|
612
|
+
return (await captureScreenshotDetailed(html, viewport, deps)).png;
|
|
613
|
+
}
|
|
614
|
+
|
|
407
615
|
// ---------------------------------------------------------------------------
|
|
408
616
|
// Multimodal LLM evaluation
|
|
409
617
|
// ---------------------------------------------------------------------------
|
|
410
618
|
|
|
411
|
-
const VISUAL_EVAL_PROMPT = `You are a visual quality evaluator for ggui — a platform where AI agents generate React UI components on demand.
|
|
619
|
+
export const VISUAL_EVAL_PROMPT = `You are a visual quality evaluator for ggui — a platform where AI agents generate React UI components on demand.
|
|
412
620
|
|
|
413
621
|
## Context
|
|
414
622
|
The screenshot shows a generated React component built from a design system with CSS variable theming. The component was created by an LLM to fulfill a user's request. Your job is to evaluate whether the generated UI achieves the user's goal AND looks professionally designed.
|
|
@@ -435,6 +643,8 @@ The screenshot shows a generated React component built from a design system with
|
|
|
435
643
|
- Error messages like "Render error" or blank white space = rendering score 0
|
|
436
644
|
- A component that renders but is just a wall of text with no structure should score low on layout and hierarchy
|
|
437
645
|
|
|
646
|
+
List AT MOST 6 issues, most severe first, one sentence each for "description" and "fix" — a longer list is cut off and lost.
|
|
647
|
+
|
|
438
648
|
Respond with ONLY a JSON object (no markdown, no explanation):
|
|
439
649
|
{
|
|
440
650
|
"completeness": <0-100>,
|
|
@@ -457,14 +667,37 @@ Respond with ONLY a JSON object (no markdown, no explanation):
|
|
|
457
667
|
* governs credentials and model routing (including the Anthropic
|
|
458
668
|
* Bedrock path) instead of `process.env`.
|
|
459
669
|
*/
|
|
670
|
+
/**
|
|
671
|
+
* Output budget for the judge's answer. 1500 cut a board's issue list
|
|
672
|
+
* mid-array (`Expected ',' or ']' after array element`, six kanban cells
|
|
673
|
+
* on 2026-09-11) — deterministic on the content, so a same-prompt retry
|
|
674
|
+
* could never clear it. 4096 fits the bounded list the prompt now asks for
|
|
675
|
+
* with room; the salvage path covers the remainder.
|
|
676
|
+
*/
|
|
677
|
+
export const VISUAL_JUDGE_MAX_OUTPUT_TOKENS = 4096;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The judge instrument's name and content digest, for the record a run
|
|
681
|
+
* stamps beside every visual score. The NAME is for readers; the DIGEST
|
|
682
|
+
* is computed from the prompt text at module load, so a stamped row can
|
|
683
|
+
* never claim a prompt version it did not run — change the prompt and the
|
|
684
|
+
* digest moves with it (the pin ties the name to the bounded-list text).
|
|
685
|
+
*/
|
|
686
|
+
export const VISUAL_JUDGE_PROMPT_VERSION = 'v2-bounded-issues';
|
|
687
|
+
export const VISUAL_JUDGE_PROMPT_DIGEST = createHash('sha256').update(VISUAL_EVAL_PROMPT, 'utf8').digest('hex');
|
|
688
|
+
|
|
460
689
|
async function callMultimodalLLM(
|
|
461
690
|
config: VisualEvalConfig,
|
|
462
691
|
model: string,
|
|
463
692
|
prompt: string,
|
|
464
693
|
screenshot: Buffer,
|
|
465
694
|
originalPrompt: string,
|
|
695
|
+
profileBlock = '',
|
|
466
696
|
) {
|
|
467
|
-
const userPrompt =
|
|
697
|
+
const userPrompt =
|
|
698
|
+
`## Original Request\n${originalPrompt}\n\n` +
|
|
699
|
+
(profileBlock.length > 0 ? `${profileBlock}\n\n` : '') +
|
|
700
|
+
'Evaluate the screenshot of the generated component.';
|
|
468
701
|
const agent = createVisionAgent({
|
|
469
702
|
provider: config.provider === 'claude' ? 'anthropic' : config.provider,
|
|
470
703
|
model,
|
|
@@ -476,7 +709,7 @@ async function callMultimodalLLM(
|
|
|
476
709
|
prompt,
|
|
477
710
|
userPrompt,
|
|
478
711
|
{ mediaType: 'image/png', base64: screenshot.toString('base64') },
|
|
479
|
-
|
|
712
|
+
VISUAL_JUDGE_MAX_OUTPUT_TOKENS,
|
|
480
713
|
);
|
|
481
714
|
}
|
|
482
715
|
|
|
@@ -493,19 +726,117 @@ async function callMultimodalLLM(
|
|
|
493
726
|
*
|
|
494
727
|
* Returns null if puppeteer is not available (graceful fallback).
|
|
495
728
|
*/
|
|
729
|
+
/**
|
|
730
|
+
* The judge's answer WITH the reason when it could not judge: `result` is
|
|
731
|
+
* null exactly when no screenshot could be taken (no browser, a launch or
|
|
732
|
+
* in-page failure) or the component could not be bundled; `unavailableReason`
|
|
733
|
+
* names why in words and `canvas` the canvas it happened at. A production
|
|
734
|
+
* caller records the reason in its receipt — a `judge_unavailable` that only
|
|
735
|
+
* says "null" cost a log dive on 2026-09-10 (puppeteer-core missing in the
|
|
736
|
+
* deploy image).
|
|
737
|
+
*/
|
|
738
|
+
export interface VisualEvalDetailed {
|
|
739
|
+
readonly result: VisualEvaluationResult | null;
|
|
740
|
+
readonly unavailableReason?: string;
|
|
741
|
+
readonly canvas?: CanvasClass;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
/** One judge call parsed, retried ONCE on a malformed answer (#1017); the FIRST reason is kept verbatim. */
|
|
746
|
+
type JudgedAnswer =
|
|
747
|
+
| { readonly kind: 'ok'; readonly result: EvaluationResult; readonly inputTokens: number; readonly outputTokens: number }
|
|
748
|
+
| { readonly kind: 'unparsable'; readonly reason: string };
|
|
749
|
+
|
|
750
|
+
/** How many vision calls this canvas gets (ggui#1072): `judgeK` when the canvas is sampled, else 1. */
|
|
751
|
+
function judgeCountFor(config: VisualEvalConfig, canvas: CanvasClass): number {
|
|
752
|
+
const k = Math.max(1, Math.floor(config.judgeK ?? 1));
|
|
753
|
+
if (k === 1) return 1;
|
|
754
|
+
return config.judgeKCanvases === undefined || config.judgeKCanvases.includes(canvas) ? k : 1;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** The median of a non-empty list — the lower middle for an even count, so the decision is always one judge's number. */
|
|
758
|
+
function medianOf(values: readonly number[]): number {
|
|
759
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
760
|
+
return sorted[Math.floor((sorted.length - 1) / 2)]!;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Population σ, rounded to one decimal; 0 for one sample. */
|
|
764
|
+
function populationSigma(values: readonly number[]): number {
|
|
765
|
+
if (values.length < 2) return 0;
|
|
766
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
767
|
+
const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
|
|
768
|
+
return Math.round(Math.sqrt(variance) * 10) / 10;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function judgeAndParse(
|
|
772
|
+
judge: typeof callMultimodalLLM,
|
|
773
|
+
config: VisualEvalConfig,
|
|
774
|
+
model: string,
|
|
775
|
+
screenshot: Buffer,
|
|
776
|
+
originalPrompt: string,
|
|
777
|
+
profileBlock: string,
|
|
778
|
+
canvas?: CanvasClass,
|
|
779
|
+
): Promise<JudgedAnswer> {
|
|
780
|
+
let firstReason: string | undefined;
|
|
781
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
782
|
+
const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, originalPrompt, profileBlock);
|
|
783
|
+
try {
|
|
784
|
+
const result = parseVisualResponse(response.text, config.passThreshold);
|
|
785
|
+
return { kind: 'ok', result, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
|
|
786
|
+
} catch (e) {
|
|
787
|
+
// A TRUNCATED answer (cut inside `issues` at the output cap) is deterministic on
|
|
788
|
+
// the content — retrying the same prompt reproduces it. The four dimensions are
|
|
789
|
+
// emitted before `issues`, so the score is recoverable from the closed prefix.
|
|
790
|
+
const salvaged = salvageTruncatedVisualAnswer(response.text, config.passThreshold);
|
|
791
|
+
if (salvaged !== null) {
|
|
792
|
+
console.warn(`[visual-eval] visual_judge_truncated${canvas ? ` canvas=${canvas}` : ''}: answer cut after ${response.text.length} chars — score recovered from the closed prefix, issues list partial`);
|
|
793
|
+
return { kind: 'ok', result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
|
|
794
|
+
}
|
|
795
|
+
const reason = `judge answer unparsable: ${e instanceof Error ? e.message : String(e)}`;
|
|
796
|
+
if (attempt === 1) {
|
|
797
|
+
firstReason = reason;
|
|
798
|
+
console.warn(`[visual-eval] visual_judge_retry${canvas ? ` canvas=${canvas}` : ''}: ${reason}`);
|
|
799
|
+
} else {
|
|
800
|
+
console.warn(`[visual-eval] visual_judge_retry failed again${canvas ? ` canvas=${canvas}` : ''}: ${reason}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return { kind: 'unparsable', reason: firstReason ?? 'judge answer unparsable' };
|
|
805
|
+
}
|
|
806
|
+
|
|
496
807
|
export async function runVisualEvaluation(
|
|
497
808
|
context: VisualEvalContext,
|
|
498
809
|
config: VisualEvalConfig,
|
|
499
810
|
deps: VisualEvalDeps = {},
|
|
500
811
|
): Promise<VisualEvaluationResult | null> {
|
|
812
|
+
return (await runVisualEvaluationDetailed(context, config, deps)).result;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export async function runVisualEvaluationDetailed(
|
|
816
|
+
context: VisualEvalContext,
|
|
817
|
+
config: VisualEvalConfig,
|
|
818
|
+
deps: VisualEvalDeps = {},
|
|
819
|
+
): Promise<VisualEvalDetailed> {
|
|
501
820
|
const startTime = Date.now();
|
|
502
821
|
const judge = deps.judge ?? callMultimodalLLM;
|
|
822
|
+
// ggui#1042: the design tree is an input and a part of the judgement's identity.
|
|
823
|
+
const designSrc = config.designSrcDir ?? resolve(resolveDesignPackageDir(), 'src');
|
|
824
|
+
const design = judgeDesignIdentity(designSrc);
|
|
825
|
+
const stamp = <T extends VisualEvaluationResult>(r: T): T => ({
|
|
826
|
+
...r,
|
|
827
|
+
...(design !== null ? { design } : {}),
|
|
828
|
+
...(config.themeMode !== undefined ? { themeMode: config.themeMode } : {}),
|
|
829
|
+
});
|
|
503
830
|
|
|
504
831
|
// Bundle component + design system into a single JS file
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
config.sampleProps ?? {},
|
|
508
|
-
)
|
|
832
|
+
let bundledCode: string;
|
|
833
|
+
try {
|
|
834
|
+
bundledCode = await bundleForRendering(context.compiledCode, config.sampleProps ?? {}, designSrc);
|
|
835
|
+
} catch (e) {
|
|
836
|
+
const unavailableReason = `bundle failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
837
|
+
console.warn(`[visual-eval] ${unavailableReason}`);
|
|
838
|
+
return { result: null, unavailableReason };
|
|
839
|
+
}
|
|
509
840
|
console.log(`[visual-eval] bundled: ${bundledCode.length}B (${Date.now() - startTime}ms)`);
|
|
510
841
|
|
|
511
842
|
// Build the HTML page — ONE shell; per-canvas mode re-renders it at each viewport.
|
|
@@ -518,15 +849,51 @@ export async function runVisualEvaluation(
|
|
|
518
849
|
const perCanvasResults: EvaluationResult[] = [];
|
|
519
850
|
for (const canvas of config.canvases) {
|
|
520
851
|
const viewport = CANVAS_VIEWPORTS[canvas];
|
|
521
|
-
const
|
|
852
|
+
const policy = canvasFitPolicy(canvas);
|
|
853
|
+
// ggui#1100: the page is composed per canvas with the runtime's fit — fullscreen canvases fill, the inline card does not.
|
|
854
|
+
const fit = canvasFit(canvas);
|
|
855
|
+
const canvasHtml = fit !== undefined ? buildRenderHTML(bundledCode, context.cssTokens, fit) : html;
|
|
856
|
+
const attempt = await captureScreenshotDetailed(canvasHtml, viewport, deps, policy.capture);
|
|
857
|
+
const screenshot = attempt.png;
|
|
522
858
|
if (!screenshot) {
|
|
523
|
-
|
|
524
|
-
|
|
859
|
+
const unavailableReason = attempt.reason ?? 'no browser available';
|
|
860
|
+
console.warn(`[visual-eval] ${unavailableReason} at canvas ${canvas} — skipping visual evaluation`);
|
|
861
|
+
return { result: null, unavailableReason, canvas };
|
|
862
|
+
}
|
|
863
|
+
// ggui#1072: k vision calls on the SAME frame; the decision value is the median.
|
|
864
|
+
const k = judgeCountFor(config, canvas);
|
|
865
|
+
const profileBlock = buildStylingProfileJudgeBlock(context.profile);
|
|
866
|
+
const answers = await Promise.all(
|
|
867
|
+
Array.from({ length: k }, () => judgeAndParse(judge, config, model, screenshot, context.originalPrompt, profileBlock, canvas)),
|
|
868
|
+
);
|
|
869
|
+
const parsed = answers.filter((a): a is Extract<JudgedAnswer, { kind: 'ok' }> => a.kind === 'ok');
|
|
870
|
+
if (parsed.length === 0) {
|
|
871
|
+
const first = answers.find((a): a is Extract<JudgedAnswer, { kind: 'unparsable' }> => a.kind === 'unparsable');
|
|
872
|
+
return { result: null, unavailableReason: first?.reason ?? 'judge answer unparsable', canvas };
|
|
873
|
+
}
|
|
874
|
+
const samples = parsed.map((a) => a.result.finalScore);
|
|
875
|
+
const median = medianOf(samples);
|
|
876
|
+
const representative = parsed[samples.indexOf(median)]!;
|
|
877
|
+
const result = representative.result;
|
|
878
|
+
result.finalScore = median;
|
|
879
|
+
result.passed = median >= config.passThreshold;
|
|
880
|
+
result.inputTokens = parsed.reduce((sum, a) => sum + a.inputTokens, 0);
|
|
881
|
+
result.outputTokens = parsed.reduce((sum, a) => sum + a.outputTokens, 0);
|
|
882
|
+
const response = { inputTokens: result.inputTokens, outputTokens: result.outputTokens };
|
|
883
|
+
const judgeRecord: CanvasJudgeRecord = {
|
|
884
|
+
k,
|
|
885
|
+
rule: 'median',
|
|
886
|
+
samples,
|
|
887
|
+
sigma: populationSigma(samples),
|
|
888
|
+
notes: parsed.map((a) => a.result.critique ?? ''),
|
|
889
|
+
};
|
|
890
|
+
// The fit verdict (ggui#1027): deterministic, in the judge's issue channel.
|
|
891
|
+
const contentHeight = attempt.contentHeight;
|
|
892
|
+
const overflow = contentHeight !== null && contentHeight > viewport.height;
|
|
893
|
+
if (contentHeight !== null && contentHeight > viewport.height && policy.overflow !== 'none') {
|
|
894
|
+
result.issues.push(canvasOverflowIssue(canvas, viewport, contentHeight, policy.overflow));
|
|
895
|
+
if (policy.overflow === 'fail') result.passed = false;
|
|
525
896
|
}
|
|
526
|
-
const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, context.originalPrompt);
|
|
527
|
-
const result = parseVisualResponse(response.text, config.passThreshold);
|
|
528
|
-
result.inputTokens = response.inputTokens;
|
|
529
|
-
result.outputTokens = response.outputTokens;
|
|
530
897
|
perCanvasResults.push(result);
|
|
531
898
|
perCanvas.push({
|
|
532
899
|
canvas,
|
|
@@ -534,10 +901,15 @@ export async function runVisualEvaluation(
|
|
|
534
901
|
score: result.finalScore,
|
|
535
902
|
passed: result.passed,
|
|
536
903
|
screenshotPng: screenshot,
|
|
904
|
+
contentHeight,
|
|
905
|
+
overflow,
|
|
906
|
+
judge: judgeRecord,
|
|
907
|
+
...(fit !== undefined ? { fit } : {}),
|
|
537
908
|
});
|
|
538
909
|
console.log(
|
|
539
|
-
`[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore} ` +
|
|
540
|
-
`${result.passed ? 'pass' : 'FAIL'} |
|
|
910
|
+
`[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore}${k > 1 ? ` (median of ${judgeRecord.samples.length}/${k}: ${judgeRecord.samples.join(',')} σ=${judgeRecord.sigma})` : ''} ` +
|
|
911
|
+
`${result.passed ? 'pass' : 'FAIL'} | content=${contentHeight ?? '?'}px${overflow ? ` OVERFLOW (${policy.overflow})` : ''} ` +
|
|
912
|
+
`| in=${response.inputTokens} out=${response.outputTokens}`,
|
|
541
913
|
);
|
|
542
914
|
}
|
|
543
915
|
const aggregate = aggregateCanvasResults(perCanvas, perCanvasResults);
|
|
@@ -546,35 +918,40 @@ export async function runVisualEvaluation(
|
|
|
546
918
|
`[visual-eval] score=${aggregate.finalScore} (mean of ${perCanvas.length} canvases; ` +
|
|
547
919
|
`${aggregate.passed ? 'every canvas passed' : `${perCanvas.filter((c) => !c.passed).length} failed`}) (${elapsed}ms)`,
|
|
548
920
|
);
|
|
549
|
-
return aggregate;
|
|
921
|
+
return { result: stamp(aggregate) };
|
|
550
922
|
}
|
|
551
923
|
|
|
552
924
|
// ── Single-shot mode (today's path) ──
|
|
553
|
-
const
|
|
925
|
+
const attempt = await captureScreenshotDetailed(html, config.viewport, deps);
|
|
926
|
+
const screenshot = attempt.png;
|
|
554
927
|
if (!screenshot) {
|
|
555
|
-
|
|
556
|
-
|
|
928
|
+
const unavailableReason = attempt.reason ?? 'no browser available';
|
|
929
|
+
console.warn(`[visual-eval] ${unavailableReason} — skipping visual evaluation`);
|
|
930
|
+
return { result: null, unavailableReason };
|
|
557
931
|
}
|
|
558
932
|
console.log(`[visual-eval] screenshot: ${screenshot.length}B (${Date.now() - startTime}ms)`);
|
|
559
933
|
|
|
560
|
-
// Send to multimodal LLM
|
|
561
|
-
const
|
|
934
|
+
// Send to multimodal LLM (one retry on a malformed answer — #1017)
|
|
935
|
+
const answer = await judgeAndParse(
|
|
936
|
+
judge,
|
|
562
937
|
config,
|
|
563
938
|
model,
|
|
564
|
-
VISUAL_EVAL_PROMPT,
|
|
565
939
|
screenshot,
|
|
566
940
|
context.originalPrompt,
|
|
941
|
+
buildStylingProfileJudgeBlock(context.profile),
|
|
567
942
|
);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
result
|
|
572
|
-
result.
|
|
943
|
+
if (answer.kind === 'unparsable') {
|
|
944
|
+
return { result: null, unavailableReason: answer.reason };
|
|
945
|
+
}
|
|
946
|
+
const result = answer.result;
|
|
947
|
+
result.inputTokens = answer.inputTokens;
|
|
948
|
+
result.outputTokens = answer.outputTokens;
|
|
949
|
+
const response = { inputTokens: answer.inputTokens, outputTokens: answer.outputTokens };
|
|
573
950
|
|
|
574
951
|
const elapsed = Date.now() - startTime;
|
|
575
952
|
console.log(`[visual-eval] score=${result.finalScore} (${elapsed}ms) | in=${response.inputTokens} out=${response.outputTokens}`);
|
|
576
953
|
|
|
577
|
-
return result;
|
|
954
|
+
return { result: stamp(result) };
|
|
578
955
|
}
|
|
579
956
|
|
|
580
957
|
/**
|
|
@@ -628,8 +1005,18 @@ export function summarizeVisualResult(result: VisualEvaluationResult): VisualEva
|
|
|
628
1005
|
viewport: c.viewport,
|
|
629
1006
|
score: c.score,
|
|
630
1007
|
passed: c.passed,
|
|
1008
|
+
contentHeight: c.contentHeight,
|
|
1009
|
+
overflow: c.overflow,
|
|
1010
|
+
judge: c.judge,
|
|
1011
|
+
...(c.fit !== undefined ? { fit: c.fit } : {}),
|
|
631
1012
|
}));
|
|
632
|
-
return {
|
|
1013
|
+
return {
|
|
1014
|
+
score: result.finalScore,
|
|
1015
|
+
passed: result.passed,
|
|
1016
|
+
canvases,
|
|
1017
|
+
...(result.design !== undefined ? { design: result.design } : {}),
|
|
1018
|
+
...(result.themeMode !== undefined ? { themeMode: result.themeMode } : {}),
|
|
1019
|
+
};
|
|
633
1020
|
}
|
|
634
1021
|
|
|
635
1022
|
/**
|
|
@@ -643,6 +1030,52 @@ export { bundleForRendering, buildRenderHTML, callMultimodalLLM };
|
|
|
643
1030
|
// Response parsing
|
|
644
1031
|
// ---------------------------------------------------------------------------
|
|
645
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Recover a judge answer cut off inside its `issues` array: all four
|
|
1035
|
+
* dimensions must be present in the prefix (else null); issue objects
|
|
1036
|
+
* that closed are kept; `critique` (emitted last) is usually lost and is
|
|
1037
|
+
* replaced by a truncation note so the receipt says what happened.
|
|
1038
|
+
*/
|
|
1039
|
+
export function salvageTruncatedVisualAnswer(text: string, passThreshold: number): EvaluationResult | null {
|
|
1040
|
+
const dim = (name: string): number | null => {
|
|
1041
|
+
const m = text.match(new RegExp(`"${name}"\\s*:\\s*(\\d+(?:\\.\\d+)?)`));
|
|
1042
|
+
return m ? Number(m[1]) : null;
|
|
1043
|
+
};
|
|
1044
|
+
const completeness = dim('completeness');
|
|
1045
|
+
const layout = dim('layout');
|
|
1046
|
+
const hierarchy = dim('hierarchy');
|
|
1047
|
+
const aesthetics = dim('aesthetics');
|
|
1048
|
+
if (completeness === null || layout === null || hierarchy === null || aesthetics === null) return null;
|
|
1049
|
+
const issues: Array<{ dimension: string; severity: string; description: string; fix: string }> = [];
|
|
1050
|
+
const issuesAt = text.indexOf('"issues"');
|
|
1051
|
+
if (issuesAt >= 0) {
|
|
1052
|
+
for (const m of text.slice(issuesAt).matchAll(/\{[^{}]*\}/g)) {
|
|
1053
|
+
try {
|
|
1054
|
+
const o = JSON.parse(m[0]) as { dimension?: unknown; severity?: unknown; description?: unknown; fix?: unknown };
|
|
1055
|
+
if (typeof o.description === 'string') {
|
|
1056
|
+
issues.push({
|
|
1057
|
+
dimension: typeof o.dimension === 'string' ? o.dimension : 'layout',
|
|
1058
|
+
severity: typeof o.severity === 'string' ? o.severity : 'minor',
|
|
1059
|
+
description: o.description,
|
|
1060
|
+
fix: typeof o.fix === 'string' ? o.fix : '',
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
} catch {
|
|
1064
|
+
// an unclosed object at the cut — skipped by construction
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const rebuilt = JSON.stringify({
|
|
1069
|
+
completeness,
|
|
1070
|
+
layout,
|
|
1071
|
+
hierarchy,
|
|
1072
|
+
aesthetics,
|
|
1073
|
+
issues,
|
|
1074
|
+
critique: `[truncated judge answer — score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique were cut]`,
|
|
1075
|
+
});
|
|
1076
|
+
return parseVisualResponse(rebuilt, passThreshold);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
646
1079
|
function parseVisualResponse(text: string, passThreshold: number): EvaluationResult {
|
|
647
1080
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
648
1081
|
if (!jsonMatch) {
|