@uibot/vlm-scorer 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,63 @@
1
+ import type { ModelGenerateRequest, ModelGenerateResult, PrototypeSpec, VisualEvaluationEvidence } from "@uibot/contracts";
2
+ import { type RenderScreenshotOptions } from "./render-screenshot.js";
3
+ /**
4
+ * Generate adapter. The optional `context` argument carries non-serializable
5
+ * runtime concerns (currently just `signal` for AbortController-based
6
+ * cancellation). Honoring it is best-effort: providers that ignore the signal
7
+ * still work, but the in-flight HTTP request will keep running after timeout.
8
+ */
9
+ export interface ModelGenerateFnContext {
10
+ signal?: AbortSignal;
11
+ }
12
+ export type ModelGenerateFn = (req: ModelGenerateRequest, context?: ModelGenerateFnContext) => Promise<ModelGenerateResult>;
13
+ export type HandoffVisualCritiqueSource = {
14
+ kind: "prototype";
15
+ prototypeId: string;
16
+ prototype: PrototypeSpec;
17
+ } | {
18
+ kind: "html" | "deck";
19
+ html: string;
20
+ title?: string;
21
+ designMdId?: string;
22
+ };
23
+ export interface HandoffVisualCritiqueInput {
24
+ intent: string;
25
+ draftYaml: string;
26
+ source: HandoffVisualCritiqueSource;
27
+ }
28
+ export type HandoffVisualCritiqueResult = {
29
+ status: "scored";
30
+ evidence?: VisualEvaluationEvidence;
31
+ score: number;
32
+ reasoning?: string;
33
+ raw?: unknown;
34
+ } | {
35
+ status: "unavailable";
36
+ evidence?: VisualEvaluationEvidence;
37
+ reason?: string;
38
+ };
39
+ export type HandoffRenderHtmlFn = (input: HandoffVisualCritiqueInput) => Promise<string> | string;
40
+ export interface CreateHandoffVisualCritiqueProviderOptions {
41
+ generate: ModelGenerateFn;
42
+ model: string;
43
+ /**
44
+ * Optional preview renderer. When supplied, the provider screenshots the
45
+ * rendered HTML and sends an image block to the VLM. When omitted, the VLM
46
+ * scores the intent + YAML text only.
47
+ */
48
+ renderHtml?: HandoffRenderHtmlFn;
49
+ screenshot?: RenderScreenshotOptions;
50
+ /** Shared deadline for HTML preparation, screenshot capture and model assessment. */
51
+ timeoutMs?: number;
52
+ }
53
+ export type HandoffVisualCritiqueProvider = (input: HandoffVisualCritiqueInput) => Promise<HandoffVisualCritiqueResult>;
54
+ export declare function createHandoffVisualCritiqueProvider(options: CreateHandoffVisualCritiqueProviderOptions): HandoffVisualCritiqueProvider;
55
+ /**
56
+ * Route a handoff to the screenshot provider when its source already carries
57
+ * HTML (html / deck), and to the text provider for prototypes, which have no
58
+ * HTML to render. Keeps prototype handoffs scored when screenshots are on.
59
+ */
60
+ export declare function createRoutedHandoffCritiqueProvider(providers: {
61
+ text: HandoffVisualCritiqueProvider;
62
+ screenshot: HandoffVisualCritiqueProvider;
63
+ }): HandoffVisualCritiqueProvider;
@@ -0,0 +1,4 @@
1
+ export { createVlmVisualScoreProvider, type CreateVlmVisualScoreProviderOptions, type ModelGenerateFn, type GenerateHtmlFn, } from "./vlm-score-provider.js";
2
+ export { createModelHtmlGenerator } from "./model-html-generator.js";
3
+ export { renderSpecToPng, type RenderScreenshotOptions } from "./render-screenshot.js";
4
+ export { createHandoffVisualCritiqueProvider, createRoutedHandoffCritiqueProvider, type CreateHandoffVisualCritiqueProviderOptions, type HandoffVisualCritiqueInput, type HandoffVisualCritiqueProvider, type HandoffVisualCritiqueResult, type HandoffVisualCritiqueSource, type HandoffRenderHtmlFn, } from "./handoff-visual-critique.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // @bun
2
+ import{createHash as C}from"crypto";function m(e,t){return C("sha256").update(JSON.stringify([e,t])).digest("hex")}function y(e){return typeof e==="number"&&Number.isFinite(e)&&e>=0&&e<=1}var b="title|text|label|content|description|heading|subtitle|placeholder|body|caption|name",ge=new RegExp(`["']?\\b(?:${b})["']?\\s*:\\s*["']([^"'\\n]*)["']`,"gi"),fe=new RegExp(`^\\s*-?\\s*(?:${b})\\s*:\\s*([^\\s"'{\\[][^\\n]*)$`,"gim");function H(e){let t=e.trim();if(t.startsWith("{")&&t.endsWith("}"))return t;let r=0,a=-1,n=!1,s=!1;for(let o=0;o<e.length;o+=1){let i=e[o];if(n){if(s)s=!1;else if(i==="\\")s=!0;else if(i==='"')n=!1;continue}if(i==='"')n=!0;else if(i==="{"){if(r===0)a=o;r+=1}else if(i==="}"){if(r-=1,r===0&&a!==-1)return e.slice(a,o+1)}}return null}function f(e){let t=H(e);if(t===null)return null;let r;try{r=JSON.parse(t)}catch{return null}if(typeof r.score!=="number"||!Number.isFinite(r.score))return null;if(r.score<0||r.score>1)return null;return typeof r.reasoning==="string"?{score:r.score,reasoning:r.reasoning}:{score:r.score}}function O(e,t){return`<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <script src="${t}"></script>
8
+ <style>body { margin: 0; background: white; }</style>
9
+ </head>
10
+ <body>${e}</body>
11
+ </html>`}var M="https://cdn.tailwindcss.com";async function g(e,t={}){let r;try{r=await import("puppeteer-core")}catch{return console.warn("[vlm-scorer] puppeteer-core not installed; screenshots unavailable"),null}let a=t.width??1200,n=t.height??800,s=t.deviceScaleFactor??1,o=t.settleMs??500,i=/<html[\s>]/i.test(e)?e:O(e,t.tailwindPlayUrl??M),u=null;try{u=await r.launch({headless:!0,...t.chromePath?{executablePath:t.chromePath}:{},args:["--no-sandbox","--disable-setuid-sandbox"]});let c=await u.newPage();if(await c.setViewport({width:a,height:n,deviceScaleFactor:s}),await c.setContent(i,{waitUntil:"networkidle0",timeout:15000}),o>0)await new Promise((l)=>setTimeout(l,o));let d=await c.screenshot({type:"png",fullPage:!1});return Buffer.isBuffer(d)?d:Buffer.from(d)}catch(c){return console.warn(`[vlm-scorer] screenshot failed: ${c instanceof Error?c.message:String(c)}`),null}finally{if(u)await u.close().catch(()=>{return})}}var T=30000,F=["You are a UI quality evaluator.","Score how well the provided UI satisfies the given intent on a 0\u20131 scale.","Consider: layout clarity, visual hierarchy, contrast, component choice, content coherence.",'Reply with a single JSON object: {"score": 0.xx, "reasoning": "\u2026"}.',"Score must be a number in [0, 1]. Do not output anything outside the JSON."].join(" ");function A(e){let t=e.timeoutMs??T,r=async(n,s)=>{let o=m(n,s),i=e.generateHtml?"unassessed":"model-text",u=(p)=>({status:"unavailable",method:i,subjectHash:o,reason:p});if(!n.trim())return u("empty-input");let c=new AbortController,d,l=new Promise((p)=>{d=setTimeout(()=>{c.abort(),p(u("timeout"))},t)}),P=async()=>{try{let p=e.generateHtml?await _(e,n,s,c.signal):L(n,s);if(c.signal.aborted)return u("timeout");if(p===null)return u("screenshot-unavailable");if(e.generateHtml)i="model-screenshot";let v=await e.generate({model:e.model,system:F,messages:[{role:"user",content:p}],responseFormat:{type:"json_object"}},{signal:c.signal}),w=typeof v.message.content==="string"?v.message.content:v.message.content.filter((h)=>h.type==="text").map((h)=>h.text).join(""),x=f(w);return x?{status:"scored",method:i,subjectHash:o,score:x.score}:u("invalid-score")}catch{return u(c.signal.aborted?"timeout":"provider-failed")}};try{return await Promise.race([P(),l])}finally{clearTimeout(d)}},a=async(n,s)=>{let o=await r(n,s);if(o.status==="scored")return o.score;if(e.fallback){let i=await e.fallback(n,s);if(y(i))return i}throw Error(`visual-evaluation-unavailable: ${o.reason}`)};return a.evaluate=r,a}async function _(e,t,r,a){if(!e.generateHtml)throw Error("generateHtml required for image content path");let n=await e.generateHtml(t,r);if(a.aborted)return null;let s=await g(n,e.screenshot);if(s===null)return null;let o=s.toString("base64");return[{type:"text",text:`Intent: ${r}
12
+
13
+ Evaluate the screenshot.`},{type:"image_base64",mediaType:"image/png",data:o}]}function L(e,t){return[`Intent: ${t}`,"","Skill definition (SKILL.md body) that produces the UI:","---",e,"---","","Rate this skill definition's UI quality 0\u20131 for the given intent."].join(`
14
+ `)}var q=["You turn a UI skill description and a user intent into one self-contained HTML fragment.","Use Tailwind utility classes only, no external assets, no scripts.","Output only the HTML, with no explanation and no code fences."].join(" "),j=/^```(?:html)?\s*\n?([\s\S]*?)\n?```\s*$/i;function N(e){return async(t,r)=>{let n=(await e.generate({model:e.model,system:q,messages:[{role:"user",content:`Intent:
15
+ ${r}
16
+
17
+ Skill:
18
+ ${t}`}]})).message.content,o=(typeof n==="string"?n:n.filter((u)=>u.type==="text").map((u)=>u.text).join("")).trim(),i=(j.exec(o)?.[1]??o).trim();if(!i)throw Error("model-html-generator: empty reply");return i}}var B=30000,G=["You are a UI visual critique evaluator for UIBot handoff drafts.","Score whether the generated UIBotSpec draft preserves the prototype/demo intent.","Consider visual hierarchy, density, contrast, layout clarity, component choice, and interaction affordance.",'Reply with one JSON object: {"score": 0.xx, "reasoning": "short reason"}.',"Score must be a number in [0, 1]. Do not output anything outside JSON."].join(" ");function U(e){return typeof e.message.content==="string"?e.message.content:e.message.content.filter((t)=>t.type==="text").map((t)=>t.text).join("")}function Q(e,t,r,a){return new Promise((n,s)=>{let o=setTimeout(()=>{a.abort(),s(Error(`${r} timeout after ${t}ms`))},t);e.then((i)=>{clearTimeout(o),n(i)},(i)=>{clearTimeout(o),s(i)})})}function R(e){if(e.kind==="prototype")return`prototype:${e.prototypeId} title:${e.prototype.title}`;return`${e.kind}:${e.title??"(untitled)"}${e.designMdId?` designMd:${e.designMdId}`:""}`}function W(e){return[`Source: ${R(e.source)}`,"","Prototype/demo intent:",e.intent,"","Generated UIBotSpec YAML draft:","```yaml",e.draftYaml,"```","","Return a JSON score for how well the draft preserves the intended visual result."].join(`
19
+ `)}async function Y(e,t,r,a){let n=await t(e);r.throwIfAborted();let s=await g(n,a);if(r.throwIfAborted(),s===null)return null;return[{type:"text",text:[`Source: ${R(e.source)}`,"","Prototype/demo intent:",e.intent,"","Generated UIBotSpec YAML draft:",e.draftYaml,"","Evaluate the screenshot and YAML together."].join(`
20
+ `)},{type:"image_base64",mediaType:"image/png",data:s.toString("base64")}]}function J(e){let t=e.timeoutMs??B;return async function(a){let n=new AbortController,s=m(a.draftYaml,a.intent),o=e.renderHtml?"unassessed":"model-text",i=(c)=>({status:"unavailable",reason:c,evidence:{status:"unavailable",method:o,subjectHash:s,reason:c}});if(!a.draftYaml.trim())return i("empty-input");let u=async()=>{let c=e.renderHtml?await Y(a,e.renderHtml,n.signal,e.screenshot):W(a);if(c===null)return i("screenshot-unavailable");if(e.renderHtml)o="model-screenshot";let d=await e.generate({model:e.model,system:G,messages:[{role:"user",content:c}],responseFormat:{type:"json_object"}},{signal:n.signal}),l=f(U(d));if(!l)return i("invalid-score");return{status:"scored",score:l.score,evidence:{status:"scored",method:o,subjectHash:s,score:l.score},...l.reasoning?{reasoning:l.reasoning}:{}}};try{return await Q(u(),t,"handoff visual critique",n)}catch{return i(n.signal.aborted?"timeout":"provider-failed")}}}function z(e){return(t)=>t.source.kind==="prototype"?e.text(t):e.screenshot(t)}export{J as createHandoffVisualCritiqueProvider,N as createModelHtmlGenerator,z as createRoutedHandoffCritiqueProvider,A as createVlmVisualScoreProvider,g as renderSpecToPng};
@@ -0,0 +1,11 @@
1
+ import type { GenerateHtmlFn, ModelGenerateFn } from "./vlm-score-provider.js";
2
+ /**
3
+ * Build the `generateHtml` hook for screenshot scoring from a model call: the
4
+ * skill body and intent go in, an HTML fragment comes out for rendering.
5
+ * Throws on an empty reply so the scorer reports `provider-failed` rather than
6
+ * screenshotting a blank page.
7
+ */
8
+ export declare function createModelHtmlGenerator(options: {
9
+ generate: ModelGenerateFn;
10
+ model: string;
11
+ }): GenerateHtmlFn;
@@ -0,0 +1,6 @@
1
+ interface ParsedCritique {
2
+ score: number;
3
+ reasoning?: string;
4
+ }
5
+ export declare function parseScore(text: string): ParsedCritique | null;
6
+ export {};
@@ -0,0 +1,16 @@
1
+ export interface RenderScreenshotOptions {
2
+ width?: number;
3
+ height?: number;
4
+ deviceScaleFactor?: number;
5
+ /** Optional Chrome executable path (e.g. "/usr/bin/google-chrome-stable"). */
6
+ chromePath?: string;
7
+ /** ms to wait after setContent before screenshotting. Default 500. */
8
+ settleMs?: number;
9
+ /**
10
+ * Tailwind Play script URL. Defaults to the public CDN. Override with a
11
+ * self-hosted copy (e.g. `http://127.0.0.1:3200/tailwind-play.js`) to avoid
12
+ * CDN dependency in production or offline environments.
13
+ */
14
+ tailwindPlayUrl?: string;
15
+ }
16
+ export declare function renderSpecToPng(input: string, options?: RenderScreenshotOptions): Promise<Buffer | null>;
@@ -0,0 +1,28 @@
1
+ import type { ModelGenerateRequest, ModelGenerateResult } from "@uibot/contracts";
2
+ import { type VisualScoreProvider } from "@uibot/evaluation";
3
+ import { type RenderScreenshotOptions } from "./render-screenshot.js";
4
+ export type ModelGenerateFn = (req: ModelGenerateRequest, context?: {
5
+ signal?: AbortSignal;
6
+ }) => Promise<ModelGenerateResult>;
7
+ /** Produce HTML from a skill body + intent. Consumer-supplied because this
8
+ * typically calls an LLM; the VLM scorer does not assume a generation flow. */
9
+ export type GenerateHtmlFn = (skillBody: string, intent: string) => Promise<string>;
10
+ export interface CreateVlmVisualScoreProviderOptions {
11
+ /** Runs a `ModelGenerateRequest` against a vision-capable model. */
12
+ generate: ModelGenerateFn;
13
+ /** Vision-capable model id, e.g. "qwen-vl-max-latest" or "claude-sonnet-4-6". */
14
+ model: string;
15
+ /**
16
+ * Optional: generate HTML from skill body + intent. When provided, the scorer
17
+ * renders the HTML to a PNG and sends an image block to the VLM. When omitted,
18
+ * the scorer sends the raw skill body text to the VLM for structural scoring.
19
+ */
20
+ generateHtml?: GenerateHtmlFn;
21
+ /** Screenshot options passed to `renderHtmlToPng`. */
22
+ screenshot?: RenderScreenshotOptions;
23
+ /** Explicit compatibility fallback for direct numeric calls only. Evidence remains unavailable. */
24
+ fallback?: VisualScoreProvider;
25
+ /** Deadline for rendering and model assessment (ms). Default 30_000. */
26
+ timeoutMs?: number;
27
+ }
28
+ export declare function createVlmVisualScoreProvider(options: CreateVlmVisualScoreProviderOptions): VisualScoreProvider;
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@uibot/vlm-scorer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Foundj/uibot.git",
8
+ "directory": "packages/vlm-scorer"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "!dist/**/*.map",
23
+ "!dist/**/*.test.d.ts",
24
+ "!dist/**/*.spec.d.ts"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@uibot/contracts": "^0.2.1",
31
+ "@uibot/evaluation": "^0.2.1",
32
+ "@uibot/compiler": "^0.2.1",
33
+ "@uibot/renderer-react": "^0.2.1",
34
+ "react-dom": "19.2.1"
35
+ },
36
+ "peerDependencies": {
37
+ "puppeteer-core": "^23.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "puppeteer-core": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build": "rm -rf dist && bun build --minify ./src/index.ts --outdir ./dist --format esm --target bun && bun x tsc -p tsconfig.json",
46
+ "typecheck": "bun x tsc -p tsconfig.json --noEmit",
47
+ "test": "bun test"
48
+ }
49
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.