@ggui-ai/ui-gen 0.15.0 → 0.16.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 +21 -4
- package/dist/adapters/generation-dispatch.js +913 -350
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +26 -22
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +17 -12
- package/dist/advanced/index.js +966 -374
- package/dist/advanced/index.js.map +1 -1
- package/dist/boilerplate.d.ts +16 -1
- package/dist/boilerplate.js +579 -245
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +17 -3
- package/dist/check/index.js +32 -25
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-D6TKuv9V.d.ts → create-ui-generator-CCIeQaEe.d.ts} +77 -10
- package/dist/design-mode-BHEE7flj.d.ts +67 -0
- package/dist/{dispatch-BuZfTh9r.d.ts → dispatch-BA5dJcg-.d.ts} +4 -1
- package/dist/evaluation/axis-checks/index.d.ts +4 -3
- package/dist/evaluation/axis-checks/index.js +3 -1
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +2 -1
- package/dist/evaluation/axis-checks/registry.js +1 -0
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +143 -3
- package/dist/evaluation/index.js +3866 -92
- package/dist/evaluation/index.js.map +1 -1
- package/dist/{generate-BTfvELgl.d.ts → generate-8L_bc2iP.d.ts} +10 -1
- package/dist/harness/check/runtime-render/index.d.ts +2 -1
- package/dist/harness/index.d.ts +2 -1
- package/dist/harness/index.js +589 -248
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +3 -3
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -1
- package/dist/harness/prompts.js +3 -0
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +17 -5
- package/dist/harness/result-types.js.map +1 -1
- package/dist/harness/runtime.d.ts +19 -3
- package/dist/harness/runtime.js +602 -261
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +28 -1
- package/dist/harness/types-public.js +589 -248
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +2 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +973 -372
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +2 -1
- package/dist/{prompts-BVhtH3M1.d.ts → prompts-k6m6reqe.d.ts} +11 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js.map +1 -1
- 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-CkNWEov9.d.ts → types-public-D24JLJgl.d.ts} +49 -5
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +4 -4
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +2 -1
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +57 -4
- package/src/adapters/provider-router.ts +7 -6
- package/src/adapters/tools.ts +5 -5
- package/src/advanced/generator.ts +11 -10
- package/src/advanced/index.ts +5 -4
- package/src/boilerplate/free-design-prompt.ts +329 -0
- package/src/boilerplate/generate.ts +10 -1
- package/src/boilerplate/hard-sections.ts +242 -0
- package/src/boilerplate/render.ts +32 -8
- package/src/boilerplate/system-prompt.ts +81 -229
- package/src/boilerplate/templates/base-free.tsx.tmpl +24 -0
- package/src/check/run-tier0.ts +80 -20
- package/src/coding-agent/tools.ts +8 -2
- package/src/contract-context.ts +28 -0
- package/src/create-harness.ts +11 -0
- package/src/create-ui-generator.ts +71 -25
- package/src/design-mode.ts +182 -0
- package/src/design-system-docs.ts +17 -13
- package/src/evaluation/axis-checks/dispatch.ts +4 -0
- package/src/evaluation/axis-checks/extras.ts +5 -0
- package/src/evaluation/cost-tracker.ts +2 -3
- package/src/evaluation/index.ts +46 -2
- package/src/evaluation/llm-evaluator.ts +77 -21
- package/src/evaluation/types-public.ts +103 -8
- package/src/evaluation/visual-evaluator.ts +301 -29
- package/src/harness/coding/dupe-break.ts +0 -0
- package/src/harness/coding/init-session.ts +1 -0
- package/src/harness/coding/run-coding-turn.ts +3 -0
- package/src/harness/coding/run-eval-round.ts +15 -2
- package/src/harness/llm-trace-sink.ts +3 -3
- package/src/harness/result-types.ts +15 -3
- package/src/harness/runtime.ts +18 -0
- package/src/harness/types-public.ts +27 -0
- package/src/index.ts +26 -0
- package/src/internal/extract-call-signatures.ts +2 -2
- package/src/providers/bedrock.ts +4 -4
- package/src/run-check.ts +1 -0
- package/src/tools/get-primitives-ts.ts +1 -1
- package/src/validation/allowed-imports.ts +8 -2
- package/src/validation/primitives.ts +1 -1
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
export type Priority = "P0" | "P1" | "P2";
|
|
22
22
|
|
|
23
|
+
import type { CanvasClass, DesignMode } from "../design-mode.js";
|
|
24
|
+
|
|
23
25
|
// ─── Issue shape ───────────────────────────────────────────────────────────
|
|
24
26
|
|
|
25
27
|
export type EvalTier = 0 | 1 | 2;
|
|
@@ -174,6 +176,34 @@ export interface EvalResult {
|
|
|
174
176
|
* probe runner; absent on eval paths that never invoke the probe.
|
|
175
177
|
*/
|
|
176
178
|
runtimeProbe?: RuntimeProbeMeta;
|
|
179
|
+
/**
|
|
180
|
+
* Per-canvas visual verdicts. Stamped by the harness ONLY when the
|
|
181
|
+
* visual leg ran with `visualEvaluation.canvases` set (arm-neutral —
|
|
182
|
+
* the same judge in every design mode); absent on the single-shot
|
|
183
|
+
* visual path and whenever the visual leg did not run. PNGs stay on
|
|
184
|
+
* `runVisualEvaluation`'s own result (`VisualEvaluationResult.canvases[*].screenshotPng`),
|
|
185
|
+
* never here — this object is serialized into reports.
|
|
186
|
+
*/
|
|
187
|
+
visual?: VisualEvalSummary;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One canvas's visual verdict, PNG-free — see `EvalResult.visual`. */
|
|
191
|
+
export interface CanvasVisualSummary {
|
|
192
|
+
canvas: CanvasClass;
|
|
193
|
+
viewport: { width: number; height: number };
|
|
194
|
+
/** The judge's weighted score at this canvas (0-100). */
|
|
195
|
+
score: number;
|
|
196
|
+
/** `score >= passThreshold`. */
|
|
197
|
+
passed: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The visual leg's per-canvas summary — see `EvalResult.visual`. */
|
|
201
|
+
export interface VisualEvalSummary {
|
|
202
|
+
/** Mean of the canvas scores (rounded). */
|
|
203
|
+
score: number;
|
|
204
|
+
/** Every canvas passed. */
|
|
205
|
+
passed: boolean;
|
|
206
|
+
canvases: CanvasVisualSummary[];
|
|
177
207
|
}
|
|
178
208
|
|
|
179
209
|
// ─── Quality mode ──────────────────────────────────────────────────────────
|
|
@@ -209,6 +239,13 @@ export interface AxisCheckInput {
|
|
|
209
239
|
originalPrompt: string;
|
|
210
240
|
/** Full classification — checks may read sibling axes. */
|
|
211
241
|
classification: Classification;
|
|
242
|
+
/**
|
|
243
|
+
* Which triad produced the source. Checks that assert a design-package
|
|
244
|
+
* component (e.g. `layout.multi_step.stepper_adopted`) read it and
|
|
245
|
+
* stand down in `free` mode, where the primitive is optional. Absent
|
|
246
|
+
* means `constrained`.
|
|
247
|
+
*/
|
|
248
|
+
designMode?: DesignMode;
|
|
212
249
|
}
|
|
213
250
|
|
|
214
251
|
/**
|
|
@@ -506,26 +543,84 @@ Only list ACTUAL violations. Use 'fail' only for pervasive violations.`,
|
|
|
506
543
|
},
|
|
507
544
|
] as const;
|
|
508
545
|
|
|
546
|
+
/**
|
|
547
|
+
* `designMode: 'free'` overrides — ONLY the two criteria whose text
|
|
548
|
+
* encodes the design vocabulary. `tokens` narrows to COLOR (the free arm
|
|
549
|
+
* relaxes spacing / typography / radius / shadow to literals but keeps
|
|
550
|
+
* brand-bearing color on the closed `--ggui-color-*` manifest, no
|
|
551
|
+
* fallbacks); `visual` becomes arm-neutral (composition, hierarchy,
|
|
552
|
+
* consistency, fit to canvas — no reward or penalty for design-system
|
|
553
|
+
* usage). Every other criterion is shared verbatim between the modes.
|
|
554
|
+
* Constraint alignment: what the free prompt says, the free evaluator
|
|
555
|
+
* checks — and nothing more.
|
|
556
|
+
*/
|
|
557
|
+
const FREE_DESIGN_CRITERIA_OVERRIDES: readonly EvalCriterion[] = [
|
|
558
|
+
{
|
|
559
|
+
id: "tokens",
|
|
560
|
+
name: "Color tokens",
|
|
561
|
+
priority: "P1",
|
|
562
|
+
tier: 0,
|
|
563
|
+
failOutcome: "warn",
|
|
564
|
+
codingGuidance:
|
|
565
|
+
"Every color is a bare var(--ggui-color-*) from the token manifest — no hex, rgb()/hsl(), CSS named colors or literal fallbacks, in style props and <style> blocks alike. Spacing, typography, radius and shadow geometry may be literals.",
|
|
566
|
+
evalInstruction:
|
|
567
|
+
"Flag hardcoded hex colors, rgba/hsl functions, CSS named colors, and literal fallbacks inside var(--ggui-*) references. Do NOT flag literal spacing, font sizes, radii or pixel values — they are allowed in this mode.",
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
id: "visual",
|
|
571
|
+
name: "Visual composition",
|
|
572
|
+
priority: "P2",
|
|
573
|
+
tier: 2,
|
|
574
|
+
failOutcome: "warn",
|
|
575
|
+
codingGuidance:
|
|
576
|
+
"Compose deliberately: one clear focal point, consistent spacing rhythm, a coherent theme-token palette, a fit to the rendering canvas.",
|
|
577
|
+
evalInstruction: `Evaluate VISUAL COMPOSITION: Is the component composed deliberately for its canvas?
|
|
578
|
+
|
|
579
|
+
Judge: visual hierarchy (one clear focal point, headings distinct from body text), consistent spacing rhythm and alignment, a coherent palette drawn from the theme tokens, fit to the canvas (fluid width, no fixed widths, nothing cramped or stretched).
|
|
580
|
+
Do NOT reward or penalise the use of design-system primitives — raw HTML with inline CSS is a first-class choice here. Literal spacing / typography / radius values are fine.
|
|
581
|
+
|
|
582
|
+
Only list ACTUAL composition problems. Use 'fail' only for a fundamentally incoherent composition.`,
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The criteria registry for a design mode. `constrained` IS `CRITERIA`
|
|
588
|
+
* (same array, byte-identical summary); `free` swaps in the two
|
|
589
|
+
* overrides above by id, preserving order.
|
|
590
|
+
*/
|
|
591
|
+
export function criteriaFor(designMode: DesignMode = "constrained"): readonly EvalCriterion[] {
|
|
592
|
+
if (designMode === "constrained") return CRITERIA;
|
|
593
|
+
return CRITERIA.map(
|
|
594
|
+
(c) => FREE_DESIGN_CRITERIA_OVERRIDES.find((o) => o.id === c.id) ?? c,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
509
598
|
/** Get all criteria for a specific priority level */
|
|
510
|
-
export function getCriteriaByPriority(
|
|
511
|
-
|
|
599
|
+
export function getCriteriaByPriority(
|
|
600
|
+
priority: Priority,
|
|
601
|
+
designMode: DesignMode = "constrained",
|
|
602
|
+
): EvalCriterion[] {
|
|
603
|
+
return criteriaFor(designMode).filter((c) => c.priority === priority);
|
|
512
604
|
}
|
|
513
605
|
|
|
514
606
|
/** Get a specific criterion by ID */
|
|
515
|
-
export function getCriterionById(
|
|
516
|
-
|
|
607
|
+
export function getCriterionById(
|
|
608
|
+
id: string,
|
|
609
|
+
designMode: DesignMode = "constrained",
|
|
610
|
+
): EvalCriterion | undefined {
|
|
611
|
+
return criteriaFor(designMode).find((c) => c.id === id);
|
|
517
612
|
}
|
|
518
613
|
|
|
519
614
|
/** Get all LLM-evaluated criteria (tier 1 + 2) */
|
|
520
|
-
export function getLLMCriteria(): EvalCriterion[] {
|
|
521
|
-
return
|
|
615
|
+
export function getLLMCriteria(designMode: DesignMode = "constrained"): EvalCriterion[] {
|
|
616
|
+
return criteriaFor(designMode).filter((c) => c.tier > 0);
|
|
522
617
|
}
|
|
523
618
|
|
|
524
619
|
/**
|
|
525
620
|
* Build the coding agent's criteria summary from the single source of truth.
|
|
526
621
|
* Grouped by priority for the P0→P1→P2 hierarchy.
|
|
527
622
|
*/
|
|
528
|
-
export function buildCodingCriteriaSummary(): string {
|
|
623
|
+
export function buildCodingCriteriaSummary(designMode: DesignMode = "constrained"): string {
|
|
529
624
|
const lines: string[] = ["## Priority (P0 first, then P1, then P2)", ""];
|
|
530
625
|
|
|
531
626
|
for (const priority of ["P0", "P1", "P2"] as Priority[]) {
|
|
@@ -535,7 +630,7 @@ export function buildCodingCriteriaSummary(): string {
|
|
|
535
630
|
: priority === "P1"
|
|
536
631
|
? "Should (safety)"
|
|
537
632
|
: "Nice (quality)";
|
|
538
|
-
const criteria = getCriteriaByPriority(priority);
|
|
633
|
+
const criteria = getCriteriaByPriority(priority, designMode);
|
|
539
634
|
lines.push(`**${priority} — ${label}:**`);
|
|
540
635
|
for (const c of criteria) {
|
|
541
636
|
lines.push(`- ${c.codingGuidance}`);
|
|
@@ -19,7 +19,9 @@ import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
|
19
19
|
import { tmpdir } from 'os';
|
|
20
20
|
import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
|
|
21
21
|
import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
|
|
22
|
-
import type { EvalIssue } from './types-public.js';
|
|
22
|
+
import type { CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
|
|
23
|
+
import type { LaunchOptions } from 'puppeteer-core';
|
|
24
|
+
import { CANVAS_VIEWPORTS, type CanvasClass, type CanvasViewport } from '../design-mode.js';
|
|
23
25
|
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
27
|
// Types
|
|
@@ -36,6 +38,18 @@ export interface VisualEvalConfig {
|
|
|
36
38
|
sampleProps?: Record<string, unknown>;
|
|
37
39
|
/** Viewport dimensions for screenshot */
|
|
38
40
|
viewport?: { width: number; height: number };
|
|
41
|
+
/**
|
|
42
|
+
* Per-canvas judging — ARM-NEUTRAL (the same judge, prompt and
|
|
43
|
+
* threshold in every design mode). When set, the SAME rendered HTML
|
|
44
|
+
* shell is screenshotted once per class at `CANVAS_VIEWPORTS[class]`
|
|
45
|
+
* and judged once per class; the result carries one entry per class
|
|
46
|
+
* in `canvases`, and the single-shot fields aggregate: `finalScore` =
|
|
47
|
+
* mean of the canvas scores (rounded), `passed` = every canvas passed,
|
|
48
|
+
* `dimensions` = per-dimension means, `issues` = every canvas's issues
|
|
49
|
+
* (description prefixed `[<canvas>]`). Unset (or empty) = today's
|
|
50
|
+
* single screenshot at `viewport`; no `canvases` field on the result.
|
|
51
|
+
*/
|
|
52
|
+
canvases?: readonly CanvasClass[];
|
|
39
53
|
/**
|
|
40
54
|
* Optional provider-routing override — see
|
|
41
55
|
* `AgentConfig.routeOverride`. Threaded onto the agent this
|
|
@@ -52,6 +66,34 @@ export interface VisualEvalConfig {
|
|
|
52
66
|
onRetry?: AgentConfig['onRetry'];
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
/** One canvas's verdict — screenshot + judge score at that class's viewport. */
|
|
70
|
+
export interface CanvasVisualResult {
|
|
71
|
+
canvas: CanvasClass;
|
|
72
|
+
viewport: CanvasViewport;
|
|
73
|
+
/** The judge's weighted score for this canvas (0-100). */
|
|
74
|
+
score: number;
|
|
75
|
+
/** `score >= passThreshold`. */
|
|
76
|
+
passed: boolean;
|
|
77
|
+
/** The PNG the judge saw — kept for artefact persistence + the human look. */
|
|
78
|
+
screenshotPng: Buffer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `runVisualEvaluation`'s result. Identical to `EvaluationResult` when
|
|
83
|
+
* `config.canvases` is unset; with canvases set, `canvases` holds the
|
|
84
|
+
* per-class verdicts and the base fields aggregate them (see
|
|
85
|
+
* `VisualEvalConfig.canvases`).
|
|
86
|
+
*/
|
|
87
|
+
export interface VisualEvaluationResult extends EvaluationResult {
|
|
88
|
+
canvases?: CanvasVisualResult[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Injection points for `runVisualEvaluation` — screenshot deps plus the judge call (tests). */
|
|
92
|
+
export interface VisualEvalDeps extends ScreenshotDeps {
|
|
93
|
+
/** The multimodal judge call (default: `callMultimodalLLM`). */
|
|
94
|
+
judge?: typeof callMultimodalLLM;
|
|
95
|
+
}
|
|
96
|
+
|
|
55
97
|
export interface VisualEvalContext {
|
|
56
98
|
/** Compiled JavaScript code (ESM with imports) */
|
|
57
99
|
compiledCode: string;
|
|
@@ -81,6 +123,18 @@ export interface VisualEvalContext {
|
|
|
81
123
|
* `<pkg>/dist/evaluation`) is `oss/packages/`; the design package is
|
|
82
124
|
* its direct child.
|
|
83
125
|
*/
|
|
126
|
+
/**
|
|
127
|
+
* The on-disk root of `@ggui-ai/ui-gen` itself. The bundle's entry lives
|
|
128
|
+
* in a temp dir, so bare imports in the GENERATED component (`@ggui-ai/wire`
|
|
129
|
+
* and every other allowlisted package) resolve nowhere from there — they
|
|
130
|
+
* resolve from this package's `node_modules` instead (`nodePaths`), the
|
|
131
|
+
* same place the harness's own imports come from.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveUiGenPackageDir(): string {
|
|
134
|
+
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
135
|
+
return resolve(selfDir, '..', '..');
|
|
136
|
+
}
|
|
137
|
+
|
|
84
138
|
export function resolveDesignPackageDir(): string {
|
|
85
139
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
86
140
|
return resolve(selfDir, '..', '..', '..', 'design');
|
|
@@ -102,15 +156,43 @@ async function bundleForRendering(
|
|
|
102
156
|
writeFileSync(componentFile, compiledCode);
|
|
103
157
|
|
|
104
158
|
// Entry file imports the component and renders it
|
|
159
|
+
// The generated component is rendered the way a host renders it: inside
|
|
160
|
+
// `GguiWireProvider` with a stub config (dispatch/subscribe are no-ops —
|
|
161
|
+
// the judge sees the first paint, not the wire round-trip). Without the
|
|
162
|
+
// provider every `useAction` / `useStream` bearer threw
|
|
163
|
+
// "useWireContext must be used within a WireProvider", React unmounted
|
|
164
|
+
// the root and the judge scored a blank page. An error boundary turns a
|
|
165
|
+
// render throw into visible "Render error" text (which the rubric
|
|
166
|
+
// scores 0) instead of the same blank.
|
|
105
167
|
const entryCode = `
|
|
106
168
|
import React from 'react';
|
|
107
169
|
import { createRoot } from 'react-dom/client';
|
|
170
|
+
import { GguiWireProvider } from '@ggui-ai/wire';
|
|
108
171
|
import Component from './component.tsx';
|
|
109
172
|
|
|
110
173
|
const props = ${JSON.stringify(sampleProps)};
|
|
174
|
+
const wireConfig = {
|
|
175
|
+
app: { appId: 'visual-eval', appName: 'Visual evaluation' },
|
|
176
|
+
render: { sessionId: 'visual-eval', isConnected: true },
|
|
177
|
+
auth: { isAuthenticated: false },
|
|
178
|
+
dispatch: () => {},
|
|
179
|
+
subscribe: () => () => {},
|
|
180
|
+
};
|
|
181
|
+
class RenderErrorBoundary extends React.Component<{ children: React.ReactNode }, { message: string | null }> {
|
|
182
|
+
state = { message: null as string | null };
|
|
183
|
+
static getDerivedStateFromError(err: unknown) { return { message: err instanceof Error ? err.message : String(err) }; }
|
|
184
|
+
render() {
|
|
185
|
+
return this.state.message === null
|
|
186
|
+
? this.props.children
|
|
187
|
+
: React.createElement('div', { className: 'error' }, 'Render error: ' + this.state.message);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
111
190
|
const root = createRoot(document.getElementById('root')!);
|
|
112
191
|
try {
|
|
113
|
-
root.render(
|
|
192
|
+
root.render(
|
|
193
|
+
React.createElement(GguiWireProvider, { config: wireConfig },
|
|
194
|
+
React.createElement(RenderErrorBoundary, null, React.createElement(Component, props))),
|
|
195
|
+
);
|
|
114
196
|
} catch (err) {
|
|
115
197
|
root.render(React.createElement('div', { className: 'error' }, 'Render error: ' + (err as Error).message));
|
|
116
198
|
}
|
|
@@ -138,6 +220,10 @@ try {
|
|
|
138
220
|
'@ggui-ai/design/compositions': resolve(designPkgDir, 'src', 'compositions', 'index.ts'),
|
|
139
221
|
'@ggui-ai/design/interact': resolve(designPkgDir, 'src', 'interact', 'index.ts'),
|
|
140
222
|
},
|
|
223
|
+
// Bare imports in the generated component resolve against ui-gen's
|
|
224
|
+
// own node_modules — the temp entry dir has none. Without this every
|
|
225
|
+
// wire-bearing component (`@ggui-ai/wire`) failed to bundle.
|
|
226
|
+
nodePaths: [resolve(resolveUiGenPackageDir(), 'node_modules')],
|
|
141
227
|
logLevel: 'silent',
|
|
142
228
|
});
|
|
143
229
|
|
|
@@ -199,27 +285,104 @@ ${bundledCode}
|
|
|
199
285
|
// Screenshot capture (optional puppeteer dependency)
|
|
200
286
|
// ---------------------------------------------------------------------------
|
|
201
287
|
|
|
288
|
+
/** The page surface the capture uses — a structural subset of puppeteer's `Page`. */
|
|
289
|
+
export interface ScreenshotPage {
|
|
290
|
+
setContent(html: string, options: { waitUntil: 'load'; timeout: number }): Promise<void>;
|
|
291
|
+
waitForNetworkIdle(options: { idleTime: number; timeout: number }): Promise<void>;
|
|
292
|
+
waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>;
|
|
293
|
+
screenshot(options: { type: 'png'; fullPage: boolean }): Promise<Uint8Array>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The browser surface the capture uses — a structural subset of puppeteer's `Browser`. */
|
|
297
|
+
export interface ScreenshotBrowser {
|
|
298
|
+
newPage(): Promise<ScreenshotPage>;
|
|
299
|
+
close(): Promise<void>;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export type ScreenshotLauncher = (options: LaunchOptions) => Promise<ScreenshotBrowser>;
|
|
303
|
+
|
|
304
|
+
/** The two members of `@sparticuz/chromium` the fallback launch path reads. */
|
|
305
|
+
export interface ChromiumProvider {
|
|
306
|
+
readonly args: string[];
|
|
307
|
+
executablePath(): Promise<string>;
|
|
308
|
+
}
|
|
309
|
+
|
|
202
310
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
311
|
+
* Injection points for the capture — every default is the production
|
|
312
|
+
* path; tests replace them to pin branch selection without launching a
|
|
313
|
+
* browser.
|
|
205
314
|
*/
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
315
|
+
export interface ScreenshotDeps {
|
|
316
|
+
/** Launches the browser (default: `puppeteer-core`'s `launch`). */
|
|
317
|
+
launch?: ScreenshotLauncher;
|
|
318
|
+
/** Environment the executable-path override is read from (default: `process.env`). */
|
|
319
|
+
env?: NodeJS.ProcessEnv;
|
|
320
|
+
/** Loads `@sparticuz/chromium` (default: dynamic import) — only consulted when no override is set. */
|
|
321
|
+
loadChromium?: () => Promise<ChromiumProvider>;
|
|
322
|
+
/** Post-load settle time before the screenshot, ms (default 1000). */
|
|
323
|
+
settleMs?: number;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Chromium flags the `PUPPETEER_EXECUTABLE_PATH` launch adds: a system
|
|
328
|
+
* Chromium inside a container has no user namespace for the sandbox and
|
|
329
|
+
* `/dev/shm` is too small for a render surface.
|
|
330
|
+
*/
|
|
331
|
+
export const EXECUTABLE_PATH_LAUNCH_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
|
|
214
332
|
|
|
215
|
-
|
|
216
|
-
|
|
333
|
+
const DEFAULT_SCREENSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
|
|
334
|
+
|
|
335
|
+
async function loadSparticuzChromium(): Promise<ChromiumProvider> {
|
|
336
|
+
const chromium = await import('@sparticuz/chromium');
|
|
337
|
+
return chromium.default;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Pick the browser binary. `PUPPETEER_EXECUTABLE_PATH` (a system
|
|
342
|
+
* Chromium — the bench runner image, a dev box) wins and skips the
|
|
343
|
+
* `@sparticuz/chromium` load entirely; otherwise the bundled Lambda
|
|
344
|
+
* Chromium supplies both binary and flags, as before.
|
|
345
|
+
*/
|
|
346
|
+
export async function resolveLaunchOptions(
|
|
347
|
+
viewport: { width: number; height: number },
|
|
348
|
+
deps: Pick<ScreenshotDeps, 'env' | 'loadChromium'> = {},
|
|
349
|
+
): Promise<LaunchOptions> {
|
|
350
|
+
const env = deps.env ?? process.env;
|
|
351
|
+
const override = env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
|
352
|
+
if (override) {
|
|
353
|
+
return {
|
|
354
|
+
executablePath: override,
|
|
355
|
+
args: [...EXECUTABLE_PATH_LAUNCH_ARGS],
|
|
217
356
|
defaultViewport: viewport,
|
|
218
|
-
executablePath: await chromium.default.executablePath(),
|
|
219
357
|
headless: true,
|
|
220
358
|
};
|
|
359
|
+
}
|
|
360
|
+
const chromium = await (deps.loadChromium ?? loadSparticuzChromium)();
|
|
361
|
+
return {
|
|
362
|
+
args: chromium.args,
|
|
363
|
+
defaultViewport: viewport,
|
|
364
|
+
executablePath: await chromium.executablePath(),
|
|
365
|
+
headless: true,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
221
368
|
|
|
222
|
-
|
|
369
|
+
async function launchWithPuppeteer(options: LaunchOptions): Promise<ScreenshotBrowser> {
|
|
370
|
+
const puppeteer = await import('puppeteer-core');
|
|
371
|
+
return puppeteer.default.launch(options);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Take a screenshot of the rendered component.
|
|
376
|
+
* Returns the PNG bytes, or null if no browser could be launched.
|
|
377
|
+
*/
|
|
378
|
+
export async function captureScreenshot(
|
|
379
|
+
html: string,
|
|
380
|
+
viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
|
|
381
|
+
deps: ScreenshotDeps = {},
|
|
382
|
+
): Promise<Buffer | null> {
|
|
383
|
+
try {
|
|
384
|
+
const launchOptions = await resolveLaunchOptions(viewport, deps);
|
|
385
|
+
const browser = await (deps.launch ?? launchWithPuppeteer)(launchOptions);
|
|
223
386
|
try {
|
|
224
387
|
const page = await browser.newPage();
|
|
225
388
|
// puppeteer ≥24.43 narrowed setContent's waitUntil to
|
|
@@ -229,9 +392,9 @@ async function captureScreenshot(
|
|
|
229
392
|
await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
|
|
230
393
|
await page.waitForSelector('#root > *', { timeout: 10000 }).catch(() => {});
|
|
231
394
|
// Wait a bit for CSS/fonts to settle
|
|
232
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
233
|
-
const screenshot = await page.screenshot({ type: 'png', fullPage: true })
|
|
234
|
-
return screenshot;
|
|
395
|
+
await new Promise((r) => setTimeout(r, deps.settleMs ?? 1000));
|
|
396
|
+
const screenshot = await page.screenshot({ type: 'png', fullPage: true });
|
|
397
|
+
return Buffer.from(screenshot);
|
|
235
398
|
} finally {
|
|
236
399
|
await browser.close();
|
|
237
400
|
}
|
|
@@ -333,8 +496,10 @@ async function callMultimodalLLM(
|
|
|
333
496
|
export async function runVisualEvaluation(
|
|
334
497
|
context: VisualEvalContext,
|
|
335
498
|
config: VisualEvalConfig,
|
|
336
|
-
|
|
499
|
+
deps: VisualEvalDeps = {},
|
|
500
|
+
): Promise<VisualEvaluationResult | null> {
|
|
337
501
|
const startTime = Date.now();
|
|
502
|
+
const judge = deps.judge ?? callMultimodalLLM;
|
|
338
503
|
|
|
339
504
|
// Bundle component + design system into a single JS file
|
|
340
505
|
const bundledCode = await bundleForRendering(
|
|
@@ -343,11 +508,49 @@ export async function runVisualEvaluation(
|
|
|
343
508
|
);
|
|
344
509
|
console.log(`[visual-eval] bundled: ${bundledCode.length}B (${Date.now() - startTime}ms)`);
|
|
345
510
|
|
|
346
|
-
// Build the HTML page
|
|
511
|
+
// Build the HTML page — ONE shell; per-canvas mode re-renders it at each viewport.
|
|
347
512
|
const html = buildRenderHTML(bundledCode, context.cssTokens);
|
|
513
|
+
const model = config.model ?? getDefaultVisualModel(config.provider);
|
|
348
514
|
|
|
349
|
-
//
|
|
350
|
-
|
|
515
|
+
// ── Per-canvas mode: one screenshot + one judge call per class ──
|
|
516
|
+
if (config.canvases !== undefined && config.canvases.length > 0) {
|
|
517
|
+
const perCanvas: CanvasVisualResult[] = [];
|
|
518
|
+
const perCanvasResults: EvaluationResult[] = [];
|
|
519
|
+
for (const canvas of config.canvases) {
|
|
520
|
+
const viewport = CANVAS_VIEWPORTS[canvas];
|
|
521
|
+
const screenshot = await captureScreenshot(html, viewport, deps);
|
|
522
|
+
if (!screenshot) {
|
|
523
|
+
console.warn(`[visual-eval] no browser available at canvas ${canvas} — skipping visual evaluation`);
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
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
|
+
perCanvasResults.push(result);
|
|
531
|
+
perCanvas.push({
|
|
532
|
+
canvas,
|
|
533
|
+
viewport,
|
|
534
|
+
score: result.finalScore,
|
|
535
|
+
passed: result.passed,
|
|
536
|
+
screenshotPng: screenshot,
|
|
537
|
+
});
|
|
538
|
+
console.log(
|
|
539
|
+
`[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore} ` +
|
|
540
|
+
`${result.passed ? 'pass' : 'FAIL'} | in=${response.inputTokens} out=${response.outputTokens}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const aggregate = aggregateCanvasResults(perCanvas, perCanvasResults);
|
|
544
|
+
const elapsed = Date.now() - startTime;
|
|
545
|
+
console.log(
|
|
546
|
+
`[visual-eval] score=${aggregate.finalScore} (mean of ${perCanvas.length} canvases; ` +
|
|
547
|
+
`${aggregate.passed ? 'every canvas passed' : `${perCanvas.filter((c) => !c.passed).length} failed`}) (${elapsed}ms)`,
|
|
548
|
+
);
|
|
549
|
+
return aggregate;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── Single-shot mode (today's path) ──
|
|
553
|
+
const screenshot = await captureScreenshot(html, config.viewport, deps);
|
|
351
554
|
if (!screenshot) {
|
|
352
555
|
console.warn('[visual-eval] no browser available — skipping visual evaluation');
|
|
353
556
|
return null;
|
|
@@ -355,8 +558,7 @@ export async function runVisualEvaluation(
|
|
|
355
558
|
console.log(`[visual-eval] screenshot: ${screenshot.length}B (${Date.now() - startTime}ms)`);
|
|
356
559
|
|
|
357
560
|
// Send to multimodal LLM
|
|
358
|
-
const
|
|
359
|
-
const response = await callMultimodalLLM(
|
|
561
|
+
const response = await judge(
|
|
360
562
|
config,
|
|
361
563
|
model,
|
|
362
564
|
VISUAL_EVAL_PROMPT,
|
|
@@ -375,6 +577,61 @@ export async function runVisualEvaluation(
|
|
|
375
577
|
return result;
|
|
376
578
|
}
|
|
377
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Fold per-canvas verdicts into the single-shot fields: `finalScore` =
|
|
582
|
+
* rounded mean, `passed` = every canvas passed, `dimensions` = rounded
|
|
583
|
+
* per-dimension means, `issues` = union (description prefixed with the
|
|
584
|
+
* canvas), `critique` = one line per canvas, tokens summed.
|
|
585
|
+
*/
|
|
586
|
+
function aggregateCanvasResults(
|
|
587
|
+
perCanvas: readonly CanvasVisualResult[],
|
|
588
|
+
results: readonly EvaluationResult[],
|
|
589
|
+
): VisualEvaluationResult {
|
|
590
|
+
const n = results.length;
|
|
591
|
+
const mean = (pick: (r: EvaluationResult) => number): number =>
|
|
592
|
+
Math.round(results.reduce((sum, r) => sum + pick(r), 0) / n);
|
|
593
|
+
const dimensions: DimensionScores = {
|
|
594
|
+
completeness: mean((r) => r.dimensions.completeness),
|
|
595
|
+
visualPolish: mean((r) => r.dimensions.visualPolish),
|
|
596
|
+
interactivity: mean((r) => r.dimensions.interactivity),
|
|
597
|
+
accessibility: mean((r) => r.dimensions.accessibility),
|
|
598
|
+
codeQuality: mean((r) => r.dimensions.codeQuality),
|
|
599
|
+
};
|
|
600
|
+
const issues: EvaluationIssue[] = [];
|
|
601
|
+
const critiques: string[] = [];
|
|
602
|
+
let inputTokens = 0;
|
|
603
|
+
let outputTokens = 0;
|
|
604
|
+
results.forEach((r, i) => {
|
|
605
|
+
const canvas = perCanvas[i]!.canvas;
|
|
606
|
+
for (const issue of r.issues) issues.push({ ...issue, description: `[${canvas}] ${issue.description}` });
|
|
607
|
+
if (r.critique) critiques.push(`${canvas}: ${r.critique}`);
|
|
608
|
+
inputTokens += r.inputTokens ?? 0;
|
|
609
|
+
outputTokens += r.outputTokens ?? 0;
|
|
610
|
+
});
|
|
611
|
+
return {
|
|
612
|
+
passed: perCanvas.every((c) => c.passed),
|
|
613
|
+
finalScore: mean((r) => r.finalScore),
|
|
614
|
+
dimensions,
|
|
615
|
+
issues,
|
|
616
|
+
...(critiques.length > 0 ? { critique: critiques.join('\n') } : {}),
|
|
617
|
+
inputTokens,
|
|
618
|
+
outputTokens,
|
|
619
|
+
canvases: [...perCanvas],
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The per-canvas verdicts without the PNGs — what the harness stamps on `EvalResult.visual`. */
|
|
624
|
+
export function summarizeVisualResult(result: VisualEvaluationResult): VisualEvalSummary | undefined {
|
|
625
|
+
if (result.canvases === undefined) return undefined;
|
|
626
|
+
const canvases: CanvasVisualSummary[] = result.canvases.map((c) => ({
|
|
627
|
+
canvas: c.canvas,
|
|
628
|
+
viewport: c.viewport,
|
|
629
|
+
score: c.score,
|
|
630
|
+
passed: c.passed,
|
|
631
|
+
}));
|
|
632
|
+
return { score: result.finalScore, passed: result.passed, canvases };
|
|
633
|
+
}
|
|
634
|
+
|
|
378
635
|
/**
|
|
379
636
|
* Exported for testing — bundles and builds the HTML for rendering;
|
|
380
637
|
* `callMultimodalLLM` so the #504 routing contract (config →
|
|
@@ -448,17 +705,30 @@ function getDefaultVisualModel(provider: 'claude' | 'google'): string {
|
|
|
448
705
|
// Tier 2 adapter — returns EvalIssue[]
|
|
449
706
|
// ---------------------------------------------------------------------------
|
|
450
707
|
|
|
708
|
+
/** What the harness's eval round consumes from the visual leg. */
|
|
709
|
+
export interface VisualEvalOutcome {
|
|
710
|
+
/** Tier-2 issues (empty when no browser is available). */
|
|
711
|
+
issues: EvalIssue[];
|
|
712
|
+
/**
|
|
713
|
+
* Per-canvas summary — present ONLY when `config.canvases` was set and
|
|
714
|
+
* the leg ran; the harness stamps it on `EvalResult.visual`. Absent on
|
|
715
|
+
* the single-shot path (byte-identical to the pre-canvas result).
|
|
716
|
+
*/
|
|
717
|
+
summary?: VisualEvalSummary;
|
|
718
|
+
}
|
|
719
|
+
|
|
451
720
|
/**
|
|
452
|
-
* Run visual evaluation and return tier 2 EvalIssues
|
|
721
|
+
* Run visual evaluation and return tier 2 EvalIssues (+ the per-canvas
|
|
722
|
+
* summary when canvases were requested).
|
|
453
723
|
*/
|
|
454
724
|
export async function runVisualEval(
|
|
455
725
|
context: VisualEvalContext,
|
|
456
726
|
config: VisualEvalConfig,
|
|
457
|
-
): Promise<
|
|
727
|
+
): Promise<VisualEvalOutcome> {
|
|
458
728
|
const result = await runVisualEvaluation(context, config);
|
|
459
|
-
if (!result) return []; // puppeteer not available
|
|
729
|
+
if (!result) return { issues: [] }; // puppeteer not available
|
|
460
730
|
|
|
461
|
-
|
|
731
|
+
const issues: EvalIssue[] = (result.issues || []).map(issue => ({
|
|
462
732
|
tier: 2 as const,
|
|
463
733
|
result: (issue.severity === 'critical' ? 'fail' : 'warn') as 'fail' | 'warn',
|
|
464
734
|
category: 'visual' as const,
|
|
@@ -467,4 +737,6 @@ export async function runVisualEval(
|
|
|
467
737
|
description: issue.description,
|
|
468
738
|
fix: issue.fix || '',
|
|
469
739
|
}));
|
|
740
|
+
const summary = summarizeVisualResult(result);
|
|
741
|
+
return summary === undefined ? { issues } : { issues, summary };
|
|
470
742
|
}
|
|
Binary file
|
|
@@ -674,6 +674,9 @@ ${closingInstruction}`;
|
|
|
674
674
|
// typecheck overlays the real wrapper declarations and a generated
|
|
675
675
|
// direct gadget import gets strict option/return narrowing.
|
|
676
676
|
harness.what.gadgetTypes,
|
|
677
|
+
// Which tier-0 legs fire — the design-vocabulary legs stand down in
|
|
678
|
+
// `free` mode; every contract leg runs in both.
|
|
679
|
+
harness.designMode,
|
|
677
680
|
);
|
|
678
681
|
const toolMs = Date.now() - toolStart;
|
|
679
682
|
|
|
@@ -22,7 +22,7 @@ import { listContractGadgets } from "@ggui-ai/protocol";
|
|
|
22
22
|
import type { Classification } from "../../classifier/index.js";
|
|
23
23
|
import type { AgentWorkspace } from "../../coding-agent/workspace.js";
|
|
24
24
|
import type { CostTracker } from "../../evaluation/cost-tracker.js";
|
|
25
|
-
import type { EvalIssue, EvalResult, RuntimeProbeMeta } from "../../evaluation/types-public.js";
|
|
25
|
+
import type { EvalIssue, EvalResult, RuntimeProbeMeta, VisualEvalSummary } from "../../evaluation/types-public.js";
|
|
26
26
|
import { notApplicableCoverage } from "../../evaluation/types-public.js";
|
|
27
27
|
import { mapProviderForEvaluator } from "../enforced-coding.js";
|
|
28
28
|
import { runCheck } from "../index.js";
|
|
@@ -453,6 +453,10 @@ export async function runEvalRound(
|
|
|
453
453
|
sourceCode: currentSource,
|
|
454
454
|
originalPrompt: userPrompt,
|
|
455
455
|
contract,
|
|
456
|
+
// Criteria + mother prompt follow the triad that produced
|
|
457
|
+
// the source (free mode: color-only tokens, arm-neutral
|
|
458
|
+
// visual judging).
|
|
459
|
+
designMode: harness.designMode,
|
|
456
460
|
},
|
|
457
461
|
{
|
|
458
462
|
provider: mapProviderForEvaluator(evaluationAgent.provider),
|
|
@@ -483,6 +487,10 @@ export async function runEvalRound(
|
|
|
483
487
|
passThreshold: visualThreshold,
|
|
484
488
|
sampleProps: visualEvaluation?.sampleProps,
|
|
485
489
|
viewport: visualEvaluation?.viewport,
|
|
490
|
+
// Per-canvas judging (arm-neutral) — absent = single shot.
|
|
491
|
+
...(visualEvaluation?.canvases !== undefined
|
|
492
|
+
? { canvases: visualEvaluation.canvases }
|
|
493
|
+
: {}),
|
|
486
494
|
// #484/#489 — same threading as the LLM-eval leg above:
|
|
487
495
|
// the visual eval's multimodal call runs inside the same
|
|
488
496
|
// concurrent generation, so it needs the same routing
|
|
@@ -497,7 +505,8 @@ export async function runEvalRound(
|
|
|
497
505
|
]);
|
|
498
506
|
evalLlmMs = Date.now() - evalLlmStart;
|
|
499
507
|
llmResult = llm;
|
|
500
|
-
visualIssues = visual;
|
|
508
|
+
visualIssues = visual?.issues ?? null;
|
|
509
|
+
const visualSummary: VisualEvalSummary | undefined = visual?.summary;
|
|
501
510
|
|
|
502
511
|
// Track costs from LLM eval calls
|
|
503
512
|
if (llmResult) {
|
|
@@ -524,6 +533,10 @@ export async function runEvalRound(
|
|
|
524
533
|
...(llmResult?.criteriaCoverage !== undefined
|
|
525
534
|
? { criteriaCoverage: llmResult.criteriaCoverage }
|
|
526
535
|
: {}),
|
|
536
|
+
// Per-canvas visual summary — present only when canvases were
|
|
537
|
+
// requested and the leg ran; every later re-stamp spreads this
|
|
538
|
+
// base so it survives to the assembled result.
|
|
539
|
+
...(visualSummary !== undefined ? { visual: visualSummary } : {}),
|
|
527
540
|
};
|
|
528
541
|
|
|
529
542
|
// ── Log merged results, with mode-check subcategory breakdown ──
|