@ggui-ai/ui-gen 0.19.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -188,9 +188,9 @@
188
188
  "puppeteer-core": "^24.40.0 || ^25.0.0",
189
189
  "react": "^18.0.0 || ^19.0.0",
190
190
  "react-dom": "^18.0.0 || ^19.0.0",
191
- "@ggui-ai/mcp-server-core": "0.19.0",
192
- "@ggui-ai/project-config": "0.19.0",
193
- "@ggui-ai/protocol": "0.19.0"
191
+ "@ggui-ai/project-config": "0.20.0",
192
+ "@ggui-ai/mcp-server-core": "0.20.0",
193
+ "@ggui-ai/protocol": "0.20.0"
194
194
  },
195
195
  "peerDependenciesMeta": {
196
196
  "@google/adk": {
@@ -229,11 +229,11 @@
229
229
  "openai": "^7.9.0",
230
230
  "typescript": "^5.0.0",
231
231
  "zod": "^4.3.6",
232
- "@ggui-ai/design": "0.19.0",
233
- "@ggui-ai/gadgets": "0.19.0",
234
- "@ggui-ai/sandbox": "0.19.0",
235
- "@ggui-ai/wire": "0.19.0",
236
- "@ggui-ai/ui-visual-tester": "0.19.0"
232
+ "@ggui-ai/gadgets": "0.20.0",
233
+ "@ggui-ai/design": "0.20.0",
234
+ "@ggui-ai/sandbox": "0.20.0",
235
+ "@ggui-ai/ui-visual-tester": "0.20.0",
236
+ "@ggui-ai/wire": "0.20.0"
237
237
  },
238
238
  "devDependencies": {
239
239
  "@google/adk": "^2.0.0",
@@ -248,9 +248,9 @@
248
248
  "react-dom": "^19.0.0",
249
249
  "tsup": "^8.5.0",
250
250
  "vitest": "^3.2.6",
251
- "@ggui-ai/mcp-server-core": "0.19.0",
252
- "@ggui-ai/project-config": "0.19.0",
253
- "@ggui-ai/protocol": "0.19.0"
251
+ "@ggui-ai/project-config": "0.20.0",
252
+ "@ggui-ai/protocol": "0.20.0",
253
+ "@ggui-ai/mcp-server-core": "0.20.0"
254
254
  },
255
255
  "engines": {
256
256
  "node": ">=22.0.0"
@@ -10,6 +10,7 @@ import { GeneratorAdapter, hasCredentials } from '../base';
10
10
  import type { AdapterConfig, GenerateParams } from '../base';
11
11
  import type { AdapterResult, ToolDefinition, ProviderName, AdapterMode } from '../types';
12
12
  import { zodToJsonSchema } from '../tool-bridge';
13
+ import { splitOpenAiUsage } from './tokens';
13
14
  import {
14
15
  createCapture,
15
16
  captureSourceCode,
@@ -54,6 +55,9 @@ export class OpenAiRawAdapter extends GeneratorAdapter {
54
55
  const capture = createCapture();
55
56
  let totalInputTokens = 0;
56
57
  let totalOutputTokens = 0;
58
+ // OpenAI's input token count INCLUDES cached tokens; track the cached
59
+ // subset so tokens.input reports NON-cached input (ggui#1186).
60
+ let totalCachedInputTokens = 0;
57
61
  let turnsUsed = 0;
58
62
  let allTextOutput = '';
59
63
 
@@ -74,6 +78,8 @@ export class OpenAiRawAdapter extends GeneratorAdapter {
74
78
  if (response.usage) {
75
79
  totalInputTokens += response.usage.input_tokens ?? 0;
76
80
  totalOutputTokens += response.usage.output_tokens ?? 0;
81
+ totalCachedInputTokens +=
82
+ response.usage.input_tokens_details?.cached_tokens ?? 0;
77
83
  }
78
84
 
79
85
  // Process output items
@@ -156,11 +162,11 @@ export class OpenAiRawAdapter extends GeneratorAdapter {
156
162
  sourceCode: capture.sourceCode,
157
163
  stream: capture.stream,
158
164
  generatorMeta: capture.generatorMeta,
159
- tokens: {
160
- input: totalInputTokens,
161
- output: totalOutputTokens,
162
- total: totalInputTokens + totalOutputTokens,
163
- },
165
+ ...splitOpenAiUsage(
166
+ totalInputTokens,
167
+ totalOutputTokens,
168
+ totalCachedInputTokens,
169
+ ),
164
170
  generationTimeMs: Date.now() - startTime,
165
171
  turnsUsed,
166
172
  };
@@ -6,6 +6,7 @@
6
6
  import { GeneratorAdapter, hasCredentials } from '../base';
7
7
  import type { AdapterConfig, GenerateParams } from '../base';
8
8
  import type { AdapterResult, ProviderName, AdapterMode } from '../types';
9
+ import { splitOpenAiUsage } from './tokens';
9
10
  import {
10
11
  createCapture,
11
12
  captureSourceCode,
@@ -87,17 +88,19 @@ export class OpenAiSdkAdapter extends GeneratorAdapter {
87
88
  const usage = result.state.usage;
88
89
  const inputTokens = usage.inputTokens;
89
90
  const outputTokens = usage.outputTokens;
91
+ // OpenAI's input token count INCLUDES cached tokens; sum the cached
92
+ // subset across per-request details (ggui#1186).
93
+ const cachedInputTokens = (usage.inputTokensDetails ?? []).reduce(
94
+ (sum, details) => sum + (details.cached_tokens ?? 0),
95
+ 0,
96
+ );
90
97
 
91
98
  return {
92
99
  compiledCode: capture.compiledCode,
93
100
  sourceCode: capture.sourceCode,
94
101
  stream: capture.stream,
95
102
  generatorMeta: capture.generatorMeta,
96
- tokens: {
97
- input: inputTokens,
98
- output: outputTokens,
99
- total: inputTokens + outputTokens,
100
- },
103
+ ...splitOpenAiUsage(inputTokens, outputTokens, cachedInputTokens),
101
104
  generationTimeMs: Date.now() - startTime,
102
105
  turnsUsed: result.rawResponses?.length ?? 0,
103
106
  };
@@ -0,0 +1,49 @@
1
+ import type { AdapterResult } from '../types';
2
+
3
+ /**
4
+ * Split an OpenAI usage report into the shared cross-adapter token
5
+ * convention (ggui#1186).
6
+ *
7
+ * OpenAI reports the cached prompt tokens as a SUBSET already counted
8
+ * inside its input token total (`usage.input_tokens` on the Responses
9
+ * API, `usage.inputTokens` on the Agents SDK, both INCLUDING cached
10
+ * tokens). Anthropic (and OpenRouter's Anthropic passthrough) instead
11
+ * report `input_tokens` EXCLUDING cache reads, with cache reads as a
12
+ * separate field.
13
+ *
14
+ * The whole fleet reads one convention: **`tokens.input` is the
15
+ * NON-cached input; `cacheReadTokens` is the cache-hit subset.** Cost
16
+ * and observability consumers price/aggregate `input` at the full input
17
+ * rate and `cacheReadTokens` at the cache-read rate, so the cached
18
+ * portion must appear in exactly one of the two — never both. Reporting
19
+ * OpenAI's cache-inclusive `input_tokens` as `tokens.input` while also
20
+ * populating `cacheReadTokens` would double-count the cached tokens (it
21
+ * inflates `$/gen` and breaks `render_cache_metric`'s `cacheReadPct`
22
+ * denominator). So the OpenAI adapters subtract the cached subset here.
23
+ *
24
+ * `total` is the full processed footprint (non-cached input + cache
25
+ * reads + output = `inputTokensInclCached + outputTokens`), matching the
26
+ * Claude adapter's `total` (input + cacheCreation + cacheRead + output);
27
+ * OpenAI has no explicit cache-creation step, so there is no
28
+ * `cacheCreationTokens` term.
29
+ */
30
+ export function splitOpenAiUsage(
31
+ inputTokensInclCached: number,
32
+ outputTokens: number,
33
+ cachedTokens: number,
34
+ ): { tokens: AdapterResult['tokens']; cacheReadTokens: number } {
35
+ // Never let a malformed usage report drive input negative.
36
+ const cacheRead = Math.min(
37
+ Math.max(cachedTokens, 0),
38
+ Math.max(inputTokensInclCached, 0),
39
+ );
40
+ const nonCachedInput = Math.max(inputTokensInclCached, 0) - cacheRead;
41
+ return {
42
+ tokens: {
43
+ input: nonCachedInput,
44
+ output: outputTokens,
45
+ total: nonCachedInput + cacheRead + outputTokens,
46
+ },
47
+ cacheReadTokens: cacheRead,
48
+ };
49
+ }
@@ -88,7 +88,7 @@ export interface AdapterResult {
88
88
  subagentsUsed?: string[];
89
89
  /** Tokens written to prompt cache (Claude-specific) */
90
90
  cacheCreationTokens?: number;
91
- /** Tokens read from prompt cache (Claude-specific) */
91
+ /** Tokens read from prompt cache (Claude / OpenRouter / OpenAI). */
92
92
  cacheReadTokens?: number;
93
93
  /** SDK session ID for evaluation loop resume (Claude SDK-specific) */
94
94
  sdkSessionId?: string;
@@ -8,6 +8,7 @@ import { LUCIDE_ICON_NAMES, maxWidth as CONTAINER_MAX_WIDTH } from "@ggui-ai/des
8
8
  import { CANVAS_VIEWPORTS, type CanvasClass } from "../../../design-mode.js";
9
9
  import type { EvalIssue } from "../../types-public.js";
10
10
  import type { AxisCheck, AxisCheckInput } from "../types.js";
11
+ import type { DataContract } from "@ggui-ai/protocol";
11
12
  import {
12
13
  getRequiredPropNames,
13
14
  mkIssue,
@@ -499,6 +500,91 @@ function runRootWidthCap(input: AxisCheckInput): EvalIssue[] {
499
500
  ];
500
501
  }
501
502
 
503
+ // ── universal.action_label_dropped (ggui#1190 — the transcription-drop guard) ─
504
+ // The GUARD half of #1190: the model transcribes an actionSpec label onto a
505
+ // control but DROPS a special character ("Confirm & Schedule" → "Confirm
506
+ // Schedule"), because the verbatim label is not at the write-site. This check
507
+ // NAMES that class on the bench and guards regression; the fix (carry the
508
+ // verbatim label / render it from data) lands separately, so #1190 stays open.
509
+ // WARN-class, deterministic, and tuned to flag ONLY the high-confidence drop.
510
+
511
+ const LABEL_SPECIAL_CHAR = /[^a-z0-9\s]/i;
512
+ const normText = (s: string): string => s.toLowerCase().replace(/\s+/g, " ").trim();
513
+ const alnumText = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "");
514
+
515
+ /**
516
+ * Extract the VISIBLE rendered text of a component — JSX text nodes (with `{…}`
517
+ * expression containers stripped, nesting-aware) plus visible string
518
+ * attributes (aria-label / placeholder / title / alt). Deliberately excludes
519
+ * identifiers and call args: `useAction('confirmSchedule')` and the `const
520
+ * confirmSchedule` binding must NOT count as the label "Confirm & Schedule"
521
+ * appearing, or every action would false-match its own camelCase name.
522
+ */
523
+ function visibleText(sourceCode: string): string {
524
+ // JSX text nodes: `>text<`. Excluding `>` from the run (`[^<>]`) skips the
525
+ // `=>` arrow-body conflation — an arrow's `>` is followed by code then a
526
+ // `>`, never text then a `<`, so only true tag-close→text→tag runs match,
527
+ // and identifiers (`const confirmSchedule`, `useAction('confirmSchedule')`)
528
+ // are never between `>` and `<`. `{…}` expression children are stripped so an
529
+ // expression's identifiers don't count as visible text.
530
+ const jsx = (sourceCode.match(/>([^<>]*)</g) ?? [])
531
+ .map((m) => m.slice(1, -1).replace(/\{[^}]*\}/g, " "))
532
+ .join(" ");
533
+ let attrs = "";
534
+ const attrRx = /\b(?:aria-label|placeholder|title|alt)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
535
+ let m: RegExpExecArray | null;
536
+ while ((m = attrRx.exec(sourceCode)) !== null) attrs += " " + (m[1] ?? m[2] ?? "");
537
+ return jsx + " " + attrs;
538
+ }
539
+
540
+ /**
541
+ * Find actionSpec labels whose special-character content was dropped from the
542
+ * generated source. FALSE-POSITIVE-SAFE by construction — flags ONLY when the
543
+ * label's alphanumeric words appear CONTIGUOUSLY in the source but the full
544
+ * label (with its "&", "/", "+" …) does not, i.e. a clean transcription drop.
545
+ * Every ambiguous shape is exempt: labels rendered from data (`.label`
546
+ * anywhere), labels with no special char (nothing to drop), labels present
547
+ * verbatim, entity-encoded `&` (`&amp;` — the words stop being contiguous, so
548
+ * no flag), and very short labels. Presence-ANYWHERE, not wiring-scoped, on
549
+ * purpose: if the label's text appears anywhere we never flag.
550
+ */
551
+ export function findDroppedActionLabels(
552
+ sourceCode: string,
553
+ contract: DataContract | undefined,
554
+ ): Array<{ readonly name: string; readonly label: string }> {
555
+ const actionSpec = contract?.actionSpec;
556
+ if (actionSpec === undefined) return [];
557
+ // Component renders any label from data → it does not transcribe → exempt all.
558
+ if (/\.label\b/.test(sourceCode)) return [];
559
+ const vis = visibleText(sourceCode);
560
+ const normVis = normText(vis);
561
+ const alnumVis = alnumText(vis);
562
+ const out: Array<{ name: string; label: string }> = [];
563
+ for (const [name, entry] of Object.entries(actionSpec)) {
564
+ const label = entry.label;
565
+ if (typeof label !== "string" || label.length === 0 || label === name) continue;
566
+ if (!LABEL_SPECIAL_CHAR.test(label)) continue; // no special char → nothing to drop
567
+ if (alnumText(label).length < 3) continue; // too short to match confidently
568
+ if (normVis.includes(normText(label))) continue; // present verbatim in visible text → correct
569
+ // The label's words appear contiguously in the VISIBLE text but the full
570
+ // label (with its punctuation) does not → a character was dropped.
571
+ if (alnumVis.includes(alnumText(label))) out.push({ name, label });
572
+ }
573
+ return out;
574
+ }
575
+
576
+ function runActionLabelDropped(input: AxisCheckInput): EvalIssue[] {
577
+ if (input.compiledCode === null) return [];
578
+ return findDroppedActionLabels(input.sourceCode, input.contract).map(({ name, label }) =>
579
+ mkIssue(
580
+ "universal.action_label_dropped",
581
+ `The action "${name}" declares label "${label}", but the rendered control appears to drop its "${label.replace(/[a-z0-9\s]/gi, "").trim()}": the label's words are in the generated source, the full label is not — a transcription drop (ggui#1190).`,
582
+ `Render the label EXACTLY as the contract states it — button text should be the verbatim \`actionSpec.${name}.label\`, or render it from data (\`{x.label}\`), never a re-typed copy that drops "&", "/", "+", etc.`,
583
+ "warn",
584
+ ),
585
+ );
586
+ }
587
+
502
588
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
503
589
  {
504
590
  id: "universal.icon_name_known",
@@ -524,6 +610,12 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
524
610
  values: ALL_RENDER_VALUES,
525
611
  run: runRootWidthCap,
526
612
  },
613
+ {
614
+ id: "universal.action_label_dropped",
615
+ axis: "render",
616
+ values: ALL_RENDER_VALUES,
617
+ run: runActionLabelDropped,
618
+ },
527
619
  {
528
620
  id: "universal.terminal_action_unguarded",
529
621
  axis: "render",
@@ -45,6 +45,7 @@ import {
45
45
  type ParentLink,
46
46
  } from "./host-boundary.js";
47
47
  import { hostGlobals, openRecord } from "../../../internal/open-record.js";
48
+ import { primeInputs } from "@ggui-ai/ui-visual-tester/prime-inputs";
48
49
 
49
50
  /**
50
51
  * Module-namespace-shaped value injected into `loadComponent`'s
@@ -504,6 +505,20 @@ export async function runRenderCheckInProcess(
504
505
  let streamsChecked = 0;
505
506
 
506
507
  try {
508
+ // ggui#1187: prime form inputs before the action-wiring probe. A wired
509
+ // Send/Submit gated on a non-empty input (the chat-interface shape) is a
510
+ // no-op on empty, so an un-primed synthetic click never dispatches and
511
+ // the probe false-warns "did not dispatch it". Shared with the visual
512
+ // tester's primeInputs (#1021/#1040) so both prime identically. Priming
513
+ // sets input VALUES (not textContent) and fires input/change before the
514
+ // per-candidate fire-log window is opened, so it neither perturbs the
515
+ // prop-sensitivity text baseline nor false-positives the wiring probe.
516
+ // Best-effort: a priming failure must never abort the check.
517
+ try {
518
+ primeInputs(renderResult.container);
519
+ } catch {
520
+ // Un-primeable DOM falls back to the prior (un-primed) behavior.
521
+ }
507
522
  // ── Check 2: Action wiring (BLOCK / unverified-as-warn) ───────────
508
523
  if (input.contract?.actionSpec) {
509
524
  const actionSpec = input.contract.actionSpec;