feat(macos): add Canvas A2UI renderer

This commit is contained in:
Peter Steinberger
2025-12-17 11:35:06 +01:00
parent 1cdebb68a0
commit cdb5ddb2da
408 changed files with 73598 additions and 32 deletions

View File

@@ -0,0 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
lib/
output/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.genkit

View File

@@ -0,0 +1,76 @@
# A2UI Protocol Message Validation Logic
This document outlines the validation rules implemented in the `validateSchema` function. The purpose of this validator is to check for constraints that are not easily expressed in the JSON schema itself, such as conditional requirements and reference integrity.
An A2UI message is a JSON object that can have a `surfaceId` and one of the following properties, defining the message type: `beginRendering`, `surfaceUpdate`, `dataModelUpdate`, or `deleteSurface`.
## Common Properties
- **`surfaceId`**: An optional string that identifies the UI surface the message applies to.
## `BeginRendering` Message Rules
- **Required**: Must have a `root` property, which is the ID of the root component to render.
## `SurfaceUpdate` Message Rules
### 1. Component ID Integrity
- **Uniqueness**: All component `id`s within the `components` array must be unique.
- **Reference Validity**: Any property that references a component ID (e.g., `child`, `children`, `entryPointChild`, `contentChild`) must point to an ID that actually exists in the `components` array.
### 2. Component-Specific Property Rules
For each component in the `components` array, the following rules apply:
- **General**:
- A component must have an `id` and a `componentProperties` object.
- The `componentProperties` object must contain exactly one key, which defines the component's type (e.g., "Heading", "Text").
- **Heading**:
- **Required**: Must have a `text` property.
- **Text**:
- **Required**: Must have a `text` property.
- **Image**:
- **Required**: Must have a `url` property.
- **Video**:
- **Required**: Must have a `url` property.
- **AudioPlayer**:
- **Required**: Must have a `url` property.
- **TextField**:
- **Required**: Must have a `label` property.
- **DateTimeInput**:
- **Required**: Must have a `value` property.
- **MultipleChoice**:
- **Required**: Must have a `selections` property.
- **Slider**:
- **Required**: Must have a `value` property.
- **Container Components** (`Row`, `Column`, `List`):
- **Required**: Must have a `children` property.
- The `children` object must contain _either_ `explicitList` _or_ `template`, but not both.
- **Card**:
- **Required**: Must have a `child` property.
- **Tabs**:
- **Required**: Must have a `tabItems` property, which must be an array.
- Each item in `tabItems` must have a `title` and a `child`.
- **Modal**:
- **Required**: Must have both `entryPointChild` and `contentChild` properties.
- **Button**:
- **Required**: Must have `label` and `action` properties.
- **CheckBox**:
- **Required**: Must have `label` and `value` properties.
- **Divider**:
- No required properties.
## `DataModelUpdate` Message Rules
- **Required**: A `DataModelUpdate` message must have a `contents` property.
- The `path` property is optional.
- If `path` is not present, the `contents` object will replace the entire data model.
- If `path` is present, the `contents` will be set at that location in the data model.
- No other properties besides `path` and `contents` are allowed.
## `DeleteSurface` Message Rules
- **Required**: Must have a `delete` property set to `true`.
- No other properties are allowed.

View File

@@ -0,0 +1,61 @@
# Genkit Eval Framework for UI generation
This is for evaluating A2UI (v0.8) against various LLMs.
## Setup
To use the models, you need to set the following environment variables with your API keys:
- `GEMINI_API_KEY`
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
You can set these in a `.env` file in the root of the project, or in your shell's configuration file (e.g., `.bashrc`, `.zshrc`).
You also need to install dependencies before running:
```bash
pnpm install
```
## Running all evals (warning: can use *lots* of model quota)
To run the flow, use the following command:
```bash
pnpm run evalAll
```
## Running a Single Test
You can run the script for a single model and data point by using the `--model` and `--prompt` command-line flags. This is useful for quick tests and debugging.
### Syntax
```bash
pnpm run eval -- --model='<model_name>' --prompt=<prompt_name>
```
### Example
To run the test with the `gpt-5-mini (reasoning: minimal)` model and the `generateDogUIs` prompt, use the following command:
```bash
pnpm run eval -- --model='gpt-5-mini (reasoning: minimal)' --prompt=generateDogUIs
```
## Controlling Output
By default, the script only prints the summary table and any errors that occur during generation. To see the full JSON output for each successful generation, use the `--verbose` flag.
To keep the input and output for each run in separate files, specify the `--keep=<output_dir>` flag, which will create a directory hierarchy with the input and output for each LLM call in separate files.
### Example
```bash
pnpm run evalAll -- --verbose
```
```bash
pnpm run evalAll -- --keep=output
```

View File

@@ -0,0 +1,24 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { googleAI } from "@genkit-ai/google-genai";
import { configure } from "genkit";
export default configure({
plugins: [googleAI()],
logLevel: "debug",
enableTracingAndMetrics: true,
});

View File

@@ -0,0 +1,36 @@
{
"name": "a2ui_genkit_eval",
"version": "1.0.0",
"main": "lib/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"eval": "dotenv -- tsx src/index.ts",
"evalAll": "pnpm run eval",
"evalGemini": "pnpm run eval -- --model=gemini-2.5-flash --prompt=travelItinerary",
"evalGpt": "pnpm run eval -- --model=gpt-5-mini --prompt=travelItinerary",
"evalClaude": "pnpm run eval -- --model=claude-4-sonnet --prompt=travelItinerary",
"start": "genkit start",
"genkit:dev": "genkit start -- tsx --watch src/dev.ts",
"format": "prettier --write src/**/*.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/yargs": "^17.0.34",
"dotenv-cli": "^10.0.0",
"prettier": "^3.6.2",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
},
"dependencies": {
"@genkit-ai/compat-oai": "^1.19.2",
"@genkit-ai/google-genai": "^1.19.3",
"@genkit-ai/vertexai": "^1.19.3",
"genkit": "^1.19.2",
"genkitx-anthropic": "^0.25.0"
}
}

4885
vendor/a2ui/specification/0.8/eval/pnpm-lock.yaml generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
onlyBuiltDependencies:
- '@firebase/util'
- esbuild
- protobufjs

View File

@@ -0,0 +1,65 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SchemaMatcher, ValidationResult } from "./schema_matcher";
export class BasicSchemaMatcher extends SchemaMatcher {
constructor(
public propertyPath: string,
public propertyValue?: any,
) {
super();
}
validate(schema: any): ValidationResult {
if (!schema) {
const result: ValidationResult = {
success: false,
error: "Schema is undefined.",
};
return result;
}
const pathParts = this.propertyPath.split(".");
let actualValue = schema;
for (const part of pathParts) {
if (actualValue && typeof actualValue === "object") {
actualValue = actualValue[part];
} else {
actualValue = undefined;
break;
}
}
if (actualValue === undefined) {
const error = `Failed to find property '${this.propertyPath}'.`;
return { success: false, error };
}
if (this.propertyValue !== undefined) {
if (JSON.stringify(actualValue) !== JSON.stringify(this.propertyValue)) {
const error = `Property '${
this.propertyPath
}' has value '${JSON.stringify(
actualValue,
)}', but expected '${JSON.stringify(this.propertyValue)}'.`;
return { success: false, error };
}
}
return { success: true };
}
}

View File

@@ -0,0 +1,17 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "./flows";

View File

@@ -0,0 +1,71 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { googleAI } from "@genkit-ai/google-genai";
import { genkit, z } from "genkit";
import { openAI } from "@genkit-ai/compat-oai/openai";
import { anthropic } from "genkitx-anthropic";
const plugins = [];
if (process.env.GEMINI_API_KEY) {
console.log("Initializing Google AI plugin...");
plugins.push(
googleAI({
apiKey: process.env.GEMINI_API_KEY!,
experimental_debugTraces: true,
}),
);
}
if (process.env.OPENAI_API_KEY) {
console.log("Initializing OpenAI plugin...");
plugins.push(openAI());
}
if (process.env.ANTHROPIC_API_KEY) {
console.log("Initializing Anthropic plugin...");
plugins.push(anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }));
}
export const ai = genkit({
plugins,
});
// Define a UI component generator flow
export const componentGeneratorFlow = ai.defineFlow(
{
name: "componentGeneratorFlow",
inputSchema: z.object({
prompt: z.string(),
model: z.any(),
config: z.any().optional(),
schema: z.any(),
}),
outputSchema: z.any(),
},
async ({ prompt, model, config, schema }) => {
// Generate structured component data using the schema from the file
const { output } = await ai.generate({
prompt,
model,
output: { contentType: "application/json", jsonSchema: schema },
config,
});
if (!output) throw new Error("Failed to generate component");
return output;
},
);

View File

@@ -0,0 +1,363 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { componentGeneratorFlow, ai } from "./flows";
import * as fs from "fs";
import * as path from "path";
import { modelsToTest } from "./models";
import { prompts, TestPrompt } from "./prompts";
import { validateSchema } from "./validator";
interface InferenceResult {
modelName: string;
prompt: TestPrompt;
component: any;
error: any;
latency: number;
validationResults: string[];
runNumber: number;
}
function generateSummary(
resultsByModel: Record<string, InferenceResult[]>,
results: InferenceResult[],
): string {
const promptNameWidth = 40;
const latencyWidth = 20;
const failedRunsWidth = 15;
const toolErrorRunsWidth = 20;
let summary = "# Evaluation Summary";
for (const modelName in resultsByModel) {
summary += `\n\n## Model: ${modelName}\n\n`;
const header = `| ${"Prompt Name".padEnd(
promptNameWidth,
)} | ${"Avg Latency (ms)".padEnd(latencyWidth)} | ${"Failed Runs".padEnd(
failedRunsWidth,
)} | ${"Tool Error Runs".padEnd(toolErrorRunsWidth)} |`;
const divider = `|${"-".repeat(promptNameWidth + 2)}|${"-".repeat(
latencyWidth + 2,
)}|${"-".repeat(failedRunsWidth + 2)}|${"-".repeat(
toolErrorRunsWidth + 2,
)}|`;
summary += header;
summary += `\n${divider}`;
const promptsInModel = resultsByModel[modelName].reduce(
(acc, result) => {
if (!acc[result.prompt.name]) {
acc[result.prompt.name] = [];
}
acc[result.prompt.name].push(result);
return acc;
},
{} as Record<string, InferenceResult[]>,
);
let totalModelFailedRuns = 0;
for (const promptName in promptsInModel) {
const runs = promptsInModel[promptName];
const totalRuns = runs.length;
const errorRuns = runs.filter((r) => r.error).length;
const failedRuns = runs.filter(
(r) => r.error || r.validationResults.length > 0,
).length;
const totalLatency = runs.reduce((acc, r) => acc + r.latency, 0);
const avgLatency = (totalLatency / totalRuns).toFixed(0);
totalModelFailedRuns += failedRuns;
const failedRunsStr =
failedRuns > 0 ? `${failedRuns} / ${totalRuns}` : "";
const errorRunsStr = errorRuns > 0 ? `${errorRuns} / ${totalRuns}` : "";
summary += `\n| ${promptName.padEnd(
promptNameWidth,
)} | ${avgLatency.padEnd(latencyWidth)} | ${failedRunsStr.padEnd(
failedRunsWidth,
)} | ${errorRunsStr.padEnd(toolErrorRunsWidth)} |`;
}
const totalRunsForModel = resultsByModel[modelName].length;
summary += `\n\n**Total failed runs:** ${totalModelFailedRuns} / ${totalRunsForModel}`;
}
summary += "\n\n---\n\n## Overall Summary\n";
const totalRuns = results.length;
const totalToolErrorRuns = results.filter((r) => r.error).length;
const totalRunsWithAnyFailure = results.filter(
(r) => r.error || r.validationResults.length > 0,
).length;
const modelsWithFailures = [
...new Set(
results
.filter((r) => r.error || r.validationResults.length > 0)
.map((r) => r.modelName),
),
].join(", ");
summary += `\n- **Number of tool error runs:** ${totalToolErrorRuns} / ${totalRuns}`;
summary += `\n- **Number of runs with any failure (tool error or validation):** ${totalRunsWithAnyFailure} / ${totalRuns}`;
const latencies = results.map((r) => r.latency).sort((a, b) => a - b);
const totalLatency = latencies.reduce((acc, l) => acc + l, 0);
const meanLatency = (totalLatency / totalRuns).toFixed(0);
let medianLatency = 0;
if (latencies.length > 0) {
const mid = Math.floor(latencies.length / 2);
if (latencies.length % 2 === 0) {
medianLatency = (latencies[mid - 1] + latencies[mid]) / 2;
} else {
medianLatency = latencies[mid];
}
}
summary += `\n- **Mean Latency:** ${meanLatency} ms`;
summary += `\n- **Median Latency:** ${medianLatency} ms`;
if (modelsWithFailures) {
summary += `\n- **Models with at least one failure:** ${modelsWithFailures}`;
}
return summary;
}
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
// Run the flow
async function main() {
const argv = await yargs(hideBin(process.argv))
.option("verbose", {
alias: "v",
type: "boolean",
description: "Run with verbose logging",
default: false,
})
.option("keep", {
type: "string",
description:
"Directory to keep output files. If no path is provided, a temporary directory will be created.",
coerce: (arg) => (arg === undefined ? true : arg),
})
.option("runs-per-prompt", {
type: "number",
description: "Number of times to run each prompt",
default: 1,
})
.option("model", {
type: "string",
array: true,
description: "Filter models by exact name",
default: [],
choices: modelsToTest.map((m) => m.name),
})
.option("prompt", {
type: "string",
description: "Filter prompts by name prefix",
})
.help()
.alias("h", "help").argv;
const verbose = argv.verbose;
const keep = argv.keep;
let outputDir: string | null = null;
if (keep) {
if (typeof keep === "string") {
outputDir = keep;
} else {
outputDir = fs.mkdtempSync(path.join(process.cwd(), "a2ui-eval-"));
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log(`Keeping output in: ${outputDir}`);
}
const runsPerPrompt = argv["runs-per-prompt"];
let filteredModels = modelsToTest;
if (argv.model && argv.model.length > 0) {
const modelNames = argv.model as string[];
filteredModels = modelsToTest.filter((m) => modelNames.includes(m.name));
if (filteredModels.length === 0) {
console.error(`No models found matching: ${modelNames.join(", ")}.`);
process.exit(1);
}
}
let filteredPrompts = prompts;
if (argv.prompt) {
filteredPrompts = prompts.filter((p) =>
p.name.startsWith(argv.prompt as string)
);
if (filteredPrompts.length === 0) {
console.error(`No prompt found with prefix "${argv.prompt}".`);
process.exit(1);
}
}
const generationPromises: Promise<InferenceResult>[] = [];
for (const prompt of filteredPrompts) {
const schemaString = fs.readFileSync(
path.join(__dirname, prompt.schemaPath),
"utf-8"
);
const schema = JSON.parse(schemaString);
for (const modelConfig of filteredModels) {
const modelDirName = modelConfig.name.replace(/[\/:]/g, "_");
const modelOutputDir = outputDir
? path.join(outputDir, modelDirName)
: null;
if (modelOutputDir && !fs.existsSync(modelOutputDir)) {
fs.mkdirSync(modelOutputDir, { recursive: true });
}
for (let i = 1; i <= runsPerPrompt; i++) {
console.log(
`Queueing generation for model: ${modelConfig.name}, prompt: ${prompt.name} (run ${i})`
);
const startTime = Date.now();
generationPromises.push(
componentGeneratorFlow({
prompt: prompt.promptText,
model: modelConfig.model,
config: modelConfig.config,
schema,
})
.then((component) => {
if (modelOutputDir) {
const inputPath = path.join(
modelOutputDir,
`${prompt.name}.input.txt`
);
fs.writeFileSync(inputPath, prompt.promptText);
const outputPath = path.join(
modelOutputDir,
`${prompt.name}.output.json`
);
fs.writeFileSync(
outputPath,
JSON.stringify(component, null, 2)
);
}
const validationResults = validateSchema(
component,
prompt.schemaPath,
prompt.matchers
);
return {
modelName: modelConfig.name,
prompt,
component,
error: null,
latency: Date.now() - startTime,
validationResults,
runNumber: i,
};
})
.catch((error) => {
if (modelOutputDir) {
const inputPath = path.join(
modelOutputDir,
`${prompt.name}.input.txt`
);
fs.writeFileSync(inputPath, prompt.promptText);
const errorPath = path.join(
modelOutputDir,
`${prompt.name}.error.json`
);
const errorOutput = {
message: error.message,
stack: error.stack,
...error,
};
fs.writeFileSync(
errorPath,
JSON.stringify(errorOutput, null, 2)
);
}
return {
modelName: modelConfig.name,
prompt,
component: null,
error,
latency: Date.now() - startTime,
validationResults: [],
runNumber: i,
};
})
);
}
}
}
const results = await Promise.all(generationPromises);
const resultsByModel: Record<string, InferenceResult[]> = {};
for (const result of results) {
if (!resultsByModel[result.modelName]) {
resultsByModel[result.modelName] = [];
}
resultsByModel[result.modelName].push(result);
}
console.log("\n--- Generation Results ---");
for (const modelName in resultsByModel) {
for (const result of resultsByModel[modelName]) {
const hasError = !!result.error;
const hasValidationFailures = result.validationResults.length > 0;
const hasComponent = !!result.component;
if (hasError || hasValidationFailures || (verbose && hasComponent)) {
console.log(`\n----------------------------------------`);
console.log(`Model: ${modelName}`);
console.log(`----------------------------------------`);
console.log(`\nQuery: ${result.prompt.name} (run ${result.runNumber})`);
if (hasError) {
console.error("Error generating component:", result.error);
} else if (hasComponent) {
if (hasValidationFailures) {
console.log("Validation Failures:");
result.validationResults.forEach((failure) =>
console.log(`- ${failure}`)
);
}
if (verbose) {
if (hasValidationFailures) {
console.log("Generated schema:");
console.log(JSON.stringify(result.component, null, 2));
}
}
}
}
}
}
const summary = generateSummary(resultsByModel, results);
console.log(summary);
if (outputDir) {
const summaryPath = path.join(outputDir, "summary.md");
fs.writeFileSync(summaryPath, summary);
}
}
if (require.main === module) {
main().catch(console.error);
}

View File

@@ -0,0 +1,50 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SchemaMatcher, ValidationResult } from "./schema_matcher";
/**
* A concrete matcher that verifies the top-level message type.
*/
export class MessageTypeMatcher extends SchemaMatcher {
constructor(private messageType: string) {
super();
}
validate(response: object): ValidationResult {
if (!response || typeof response !== "object") {
return {
success: false,
error: "Response is not a valid object.",
};
}
const keys = Object.keys(response);
if (keys.length === 1 && keys[0] === this.messageType) {
return { success: true };
} else {
return {
success: false,
error: `Expected top-level message type to be '${
this.messageType
}', but found '${keys.join(", ")}'`,
};
}
}
get description(): string {
return `Expected top-level message type to be '${this.messageType}'`;
}
}

View File

@@ -0,0 +1,68 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { googleAI } from "@genkit-ai/google-genai";
import { openAI } from "@genkit-ai/compat-oai/openai";
import { claude35Haiku, claude4Sonnet } from "genkitx-anthropic";
export interface ModelConfiguration {
model: any;
name: string;
config?: any;
}
export const modelsToTest: ModelConfiguration[] = [
{
model: openAI.model("gpt-5"),
name: "gpt-5",
config: { reasoning_effort: "minimal" },
},
{
model: openAI.model("gpt-5-mini"),
name: "gpt-5-mini",
config: { reasoning_effort: "minimal" },
},
{
model: openAI.model("gpt-4.1"),
name: "gpt-4.1",
config: {},
},
{
model: googleAI.model("gemini-2.5-pro"),
name: "gemini-2.5-pro-thinking",
config: { thinkingConfig: { thinkingBudget: 1000 } },
},
{
model: googleAI.model("gemini-2.5-flash"),
name: "gemini-2.5-flash",
config: { thinkingConfig: { thinkingBudget: 0 } },
},
{
model: googleAI.model("gemini-2.5-flash-lite"),
name: "gemini-2.5-flash-lite",
config: { thinkingConfig: { thinkingBudget: 0 } },
},
{
model: claude4Sonnet,
name: "claude-4-sonnet",
config: {},
},
{
model: claude35Haiku,
name: "claude-35-haiku",
config: {},
},
];

View File

@@ -0,0 +1,493 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { BasicSchemaMatcher } from "./basic_schema_matcher";
import { MessageTypeMatcher } from "./message_type_matcher";
import { SchemaMatcher } from "./schema_matcher";
import { SurfaceUpdateSchemaMatcher } from "./surface_update_schema_matcher";
export interface TestPrompt {
name: string;
description: string;
schemaPath: string;
promptText: string;
matchers: SchemaMatcher[];
}
const schemaPath = "../../json/server_to_client_with_standard_catalog.json";
export const prompts: TestPrompt[] = [
{
name: "deleteSurface",
description: "A DeleteSurface message to remove a UI surface.",
schemaPath,
promptText: `Generate a JSON message containing a deleteSurface for the surface 'dashboard-surface-1'.`,
matchers: [
new MessageTypeMatcher("deleteSurface"),
new BasicSchemaMatcher("deleteSurface"),
new BasicSchemaMatcher("deleteSurface.surfaceId", "dashboard-surface-1"),
],
},
{
name: "dogBreedGenerator",
description:
"A prompt to generate a UI for a dog breed information and generator tool.",
schemaPath,
promptText: `Generate a JSON message containing a surfaceUpdate to describe the following UI:
A root node has already been created with ID "root".
A vertical list with:
Dog breed information
Dog generator
The dog breed information is a card, which contains a title “Famous Dog breeds”, a header image, and a carousel of different dog breeds. The carousel information should be in the data model at /carousel.
The dog generator is another card which is a form that generates a fictional dog breed with a description
- Title
- Description text explaining what it is
- Dog breed name (text input)
- Number of legs (number input)
- Skills (checkboxes)
- Button called “Generate” which takes the data above and generates a new dog description
- A divider
- A section which shows the generated content
`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher(
"TextField",
"label",
"Dog breed name",
true
),
new SurfaceUpdateSchemaMatcher(
"TextField",
"label",
"Number of legs",
true
),
new SurfaceUpdateSchemaMatcher("Button", "label", "Generate"),
],
},
{
name: "loginForm",
description:
'A simple login form with username, password, a "remember me" checkbox, and a submit button.',
schemaPath,
promptText: `Generate a JSON message containing a surfaceUpdate for a login form. It should have a "Login" heading, two text fields for username and password (bound to /login/username and /login/password), a checkbox for "Remember Me" (bound to /login/rememberMe), and a "Sign In" button. The button should trigger a 'login' action, passing the username, password, and rememberMe status in the dynamicContext.`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Login"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "username", true),
new SurfaceUpdateSchemaMatcher("TextField", "label", "password", true),
new SurfaceUpdateSchemaMatcher("CheckBox", "label", "Remember Me"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Sign In"),
],
},
{
name: "productGallery",
description: "A gallery of products using a list with a template.",
schemaPath,
promptText: `Generate a JSON message containing a surfaceUpdate for a product gallery. It should display a list of products from the data model at '/products'. Use a template for the list items. Each item should be a Card containing an Image (from '/products/item/imageUrl'), a Text component for the product name (from '/products/item/name'), and a Button labeled "Add to Cart". The button's action should be 'addToCart' and include a staticContext with the product ID, for example, 'productId': 'product123'. You should create a template component and then a list that uses it.`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Card"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("Text"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Add to Cart"),
],
},
{
name: "productGalleryData",
description:
"A DataModelUpdate message to populate the product gallery data.",
schemaPath,
promptText: `Generate a JSON message containing a dataModelUpdate to populate the data model for the product gallery. The update should target the path '/products' and include at least two products. Each product in the map should have keys 'id', 'name', and 'imageUrl'. For example:
{
"key": "product1",
"valueMap": [
{ "key": "id", "valueString": "product1" },
{ "key": "name", "valueString": "Awesome Gadget" },
{ "key": "imageUrl", "valueString": "https://example.com/gadget.jpg" }
]
}`,
matchers: [
new MessageTypeMatcher("dataModelUpdate"),
new BasicSchemaMatcher("dataModelUpdate.path", "/products"),
new BasicSchemaMatcher("dataModelUpdate.contents.0.key"), // Check that the first product key exists
new BasicSchemaMatcher("dataModelUpdate.contents.0.valueMap"), // Check that valueMap exists
],
},
{
name: "settingsPage",
description: "A settings page with tabs and a modal dialog.",
schemaPath,
promptText: `Generate a JSON message containing a surfaceUpdate for a user settings page. Use a Tabs component with two tabs: "Profile" and "Notifications". The "Profile" tab should contain a simple column with a text field for the user's name. The "Notifications" tab should contain a checkbox for "Enable email notifications". Also, include a Modal component. The modal's entry point should be a button labeled "Delete Account", and its content should be a column with a confirmation text and two buttons: "Confirm Deletion" and "Cancel".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "name", true),
new SurfaceUpdateSchemaMatcher(
"CheckBox",
"label",
"Enable email notifications"
),
new SurfaceUpdateSchemaMatcher("Button", "label", "Delete Account"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Confirm Deletion"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Cancel"),
],
},
{
name: "dataModelUpdate",
description: "A DataModelUpdate message to update user data.",
schemaPath,
promptText: `Generate a JSON message with a 'dataModelUpdate' property. This is used to update the client's data model. The scenario is that a user has just logged in, and we need to populate their profile information. Create a single data model update message to set '/user/name' to "John Doe" and '/user/email' to "john.doe@example.com".`,
matchers: [new MessageTypeMatcher("dataModelUpdate")],
},
{
name: "uiRoot",
description: "A UIRoot message to set the initial UI and data roots.",
schemaPath,
promptText: `Generate a JSON message with a 'beginRendering' property. This message tells the client where to start rendering the UI. Set the UI root to a component with ID "mainLayout".`,
matchers: [new MessageTypeMatcher("beginRendering")],
},
{
name: "animalKingdomExplorer",
description: "A simple, explicit UI to display a hierarchy of animals.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a simplified UI explorer for the Animal Kingdom.
The UI must have a main 'Heading' with the text "Simple Animal Explorer".
Below the heading, create a 'Tabs' component with exactly three tabs: "Mammals", "Birds", and "Reptiles".
Each tab's content should be a 'Column'. The first item in each column must be a 'TextField' with the label "Search...". Below the search field, display the hierarchy for that tab using nested 'Card' components.
The exact hierarchy to create is as follows:
**1. "Mammals" Tab:**
- A 'Card' for the Class "Mammalia".
- Inside the "Mammalia" card, create two 'Card's for the following Orders:
- A 'Card' for the Order "Carnivora". Inside this, create 'Card's for these three species: "Lion", "Tiger", "Wolf".
- A 'Card' for the Order "Artiodactyla". Inside this, create 'Card's for these two species: "Giraffe", "Hippopotamus".
**2. "Birds" Tab:**
- A 'Card' for the Class "Aves".
- Inside the "Aves" card, create three 'Card's for the following Orders:
- A 'Card' for the Order "Accipitriformes". Inside this, create a 'Card' for the species: "Bald Eagle".
- A 'Card' for the Order "Struthioniformes". Inside this, create a 'Card' for the species: "Ostrich".
- A 'Card' for the Order "Sphenisciformes". Inside this, create a 'Card' for the species: "Penguin".
**3. "Reptiles" Tab:**
- A 'Card' for the Class "Reptilia".
- Inside the "Reptilia" card, create two 'Card's for the following Orders:
- A 'Card' for the Order "Crocodilia". Inside this, create a 'Card' for the species: "Nile Crocodile".
- A 'Card' for the Order "Squamata". Inside this, create 'Card's for these two species: "Komodo Dragon", "Ball Python".
Each species card must contain a 'Row' with an 'Image' and a 'Text' component for the species name. Do not add any other components.
Each Class and Order card must contain a 'Column' with a 'Text' component with the name, and then the children cards below.
IMPORTANT: Do not skip any of the classes, orders, or species above. Include every item that is mentioned.
`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher(
"Heading",
"text",
"Simple Animal Explorer"
),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Search..."),
new SurfaceUpdateSchemaMatcher("Text", "text", "Class: Mammalia"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Carnivora"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Lion"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Tiger"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Wolf"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Artiodactyla"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Giraffe"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Hippopotamus"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Class: Aves"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Accipitriformes"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Bald Eagle"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Struthioniformes"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Ostrich"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Sphenisciformes"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Penguin"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Class: Reptilia"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Crocodilia"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Nile Crocodile"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Squamata"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Komodo Dragon"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Ball Python"),
],
},
{
name: "recipeCard",
description: "A UI to display a recipe with ingredients and instructions.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a recipe card. It should have a 'Heading' for the recipe title, "Classic Lasagna". Below the title, an 'Image' of the lasagna. Then, a 'Row' containing two 'Column's. The first column has a 'Text' heading "Ingredients" and a 'List' of ingredients. The second column has a 'Text' heading "Instructions" and a 'List' of step-by-step instructions. Finally, a 'Button' at the bottom labeled "Watch Video Tutorial".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Classic Lasagna"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Ingredients"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Instructions"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Watch Video Tutorial"),
],
},
{
name: "musicPlayer",
description: "A simple music player UI.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a music player. It should be a 'Card' containing a 'Column'. Inside the column, there's an 'Image' for the album art, a 'Text' for the song title "Bohemian Rhapsody", another 'Text' for the artist "Queen", a 'Slider' for the song progress, and a 'Row' with three 'Button's: "Previous", "Play", and "Next".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Bohemian Rhapsody"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Queen"),
new SurfaceUpdateSchemaMatcher("Slider"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Previous"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Play"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Next"),
],
},
{
name: "weatherForecast",
description: "A UI to display the weather forecast.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a weather forecast UI. It should have a 'Heading' with the city name, "New York". Below it, a 'Row' with the current temperature as a 'Text' component ("68°F") and an 'Image' for the weather icon (e.g., a sun). Below that, a 'Divider'. Then, a 'List' component to display the 5-day forecast. Each item in the list should be a 'Row' with the day, an icon, and high/low temperatures.`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "New York"),
new SurfaceUpdateSchemaMatcher("Text", "text", "68°F"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("List"),
],
},
{
name: "surveyForm",
description: "A customer feedback survey form.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a survey form. It should have a 'Heading' "Customer Feedback". Then a 'MultipleChoice' question "How would you rate our service?" with options "Excellent", "Good", "Average", "Poor". Then a 'CheckBox' section for "What did you like?" with options "Product Quality", "Price", "Customer Support". Finally, a 'TextField' with the label "Any other comments?" and a 'Button' labeled "Submit Feedback".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Customer Feedback"),
new SurfaceUpdateSchemaMatcher("MultipleChoice", "options", "Excellent"),
new SurfaceUpdateSchemaMatcher("CheckBox", "label", "Product Quality"),
new SurfaceUpdateSchemaMatcher(
"TextField",
"label",
"Any other comments?"
),
new SurfaceUpdateSchemaMatcher("Button", "label", "Submit Feedback"),
],
},
{
name: "flightBooker",
description: "A form to search for flights.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a flight booking form. It should have a 'Heading' "Book a Flight". Use a 'Row' for two 'TextField's: "Departure City" and "Arrival City". Below that, another 'Row' for two 'DateTimeInput's: "Departure Date" and "Return Date". Add a 'CheckBox' for "One-way trip". Finally, a 'Button' labeled "Search Flights".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Book a Flight"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Departure City"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Arrival City"),
new SurfaceUpdateSchemaMatcher("DateTimeInput"),
new SurfaceUpdateSchemaMatcher("CheckBox", "label", "One-way trip"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Search Flights"),
],
},
{
name: "dashboard",
description: "A simple dashboard with statistics.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a simple dashboard. It should have a 'Heading' "Sales Dashboard". Below, a 'Row' containing three 'Card's. The first card has a 'Text' "Revenue" and another 'Text' "$50,000". The second card has "New Customers" and "1,200". The third card has "Conversion Rate" and "4.5%".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Sales Dashboard"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Revenue"),
new SurfaceUpdateSchemaMatcher("Text", "text", "$50,000"),
new SurfaceUpdateSchemaMatcher("Text", "text", "New Customers"),
new SurfaceUpdateSchemaMatcher("Text", "text", "1,200"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Conversion Rate"),
new SurfaceUpdateSchemaMatcher("Text", "text", "4.5%"),
],
},
{
name: "contactCard",
description: "A UI to display contact information.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a contact card. It should be a 'Card' with a 'Row'. The row contains an 'Image' (as an avatar) and a 'Column'. The column contains a 'Text' for the name "Jane Doe", a 'Text' for the email "jane.doe@example.com", and a 'Text' for the phone number "(123) 456-7890". Below the main row, add a 'Button' labeled "View on Map".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Jane Doe"),
new SurfaceUpdateSchemaMatcher("Text", "text", "jane.doe@example.com"),
new SurfaceUpdateSchemaMatcher("Text", "text", "(123) 456-7890"),
new SurfaceUpdateSchemaMatcher("Button", "label", "View on Map"),
],
},
{
name: "calendarEventCreator",
description: "A form to create a new calendar event.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a calendar event creation form. It should have a 'Heading' "New Event". Include a 'TextField' for the "Event Title". Use a 'Row' for two 'DateTimeInput's for "Start Time" and "End Time". Add a 'CheckBox' labeled "All-day event". Finally, a 'Row' with two 'Button's: "Save" and "Cancel".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "New Event"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Event Title"),
new SurfaceUpdateSchemaMatcher("DateTimeInput"),
new SurfaceUpdateSchemaMatcher("CheckBox", "label", "All-day event"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Save"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Cancel"),
],
},
{
name: "checkoutPage",
description: "A simplified e-commerce checkout page.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a checkout page. It should have a 'Heading' "Checkout". Create a 'Column' for "Shipping Information" with 'TextField's for "Full Name" and "Address". Create another 'Column' for "Payment Information" with 'TextField's for "Card Number" and "Expiry Date". Add a 'Divider'. Show an order summary with a 'Text' component: "Total: $99.99". Finally, a 'Button' labeled "Place Order".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Checkout"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Full Name"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Address"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Card Number"),
new SurfaceUpdateSchemaMatcher("TextField", "label", "Expiry Date"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Total: $99.99"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Place Order"),
],
},
{
name: "socialMediaPost",
description: "A component representing a social media post.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a social media post. It should be a 'Card' containing a 'Column'. The first item is a 'Row' with an 'Image' (user avatar) and a 'Text' (username "user123"). Below that, a 'Text' component for the post content: "Enjoying the beautiful weather today!". Then, an 'Image' for the main post picture. Finally, a 'Row' with three 'Button's: "Like", "Comment", and "Share".`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("Text", "text", "user123"),
new SurfaceUpdateSchemaMatcher(
"Text",
"text",
"Enjoying the beautiful weather today!"
),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Like"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Comment"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Share"),
],
},
{
name: "eCommerceProductPage",
description: "A detailed product page for an e-commerce website.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a product details page.
The main layout should be a 'Row'.
The left side of the row is a 'Column' containing a large main 'Image' of the product, and below it, a 'Row' of three smaller thumbnail 'Image' components.
The right side of the row is another 'Column' for product information:
- A 'Heading' for the product name, "Premium Leather Jacket".
- A 'Text' component for the price, "$299.99".
- A 'Divider'.
- A 'Text' heading "Select Size", followed by a 'MultipleChoice' component with options "S", "M", "L", "XL".
- A 'Text' heading "Select Color", followed by another 'MultipleChoice' component with options "Black", "Brown", "Red".
- A 'Button' with the label "Add to Cart".
- A 'Text' component for the product description below the button.`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher(
"Heading",
"text",
"Premium Leather Jacket"
),
new SurfaceUpdateSchemaMatcher("Text", "text", "$299.99"),
new SurfaceUpdateSchemaMatcher("Image"),
new SurfaceUpdateSchemaMatcher("MultipleChoice", "options", "S"),
new SurfaceUpdateSchemaMatcher("MultipleChoice", "options", "Black"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Add to Cart"),
],
},
{
name: "interactiveDashboard",
description: "A dashboard with filters and data cards.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for an interactive analytics dashboard.
At the top, a 'Heading' "Company Dashboard".
Below the heading, a 'Card' containing a 'Row' of filter controls:
- A 'DateTimeInput' with a label for "Start Date".
- A 'DateTimeInput' with a label for "End Date".
- A 'Button' labeled "Apply Filters".
Below the filters card, a 'Row' containing two 'Card's for key metrics:
- The first 'Card' has a 'Heading' "Total Revenue" and a 'Text' component showing "$1,234,567".
- The second 'Card' has a 'Heading' "New Users" and a 'Text' component showing "4,321".
Finally, a large 'Card' at the bottom with a 'Heading' "Revenue Over Time" and a placeholder 'Image' to represent a line chart.`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Company Dashboard"),
new SurfaceUpdateSchemaMatcher("DateTimeInput"),
new SurfaceUpdateSchemaMatcher("Button", "label", "Apply Filters"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Total Revenue"),
new SurfaceUpdateSchemaMatcher("Text", "text", "$1,234,567"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "New Users"),
new SurfaceUpdateSchemaMatcher("Text", "text", "4,321"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Revenue Over Time"),
new SurfaceUpdateSchemaMatcher("Image"),
],
},
{
name: "travelItinerary",
description: "A multi-day travel itinerary display.",
schemaPath,
promptText: `Generate a JSON message with a surfaceUpdate property for a travel itinerary for a trip to Paris.
It should have a main 'Heading' "Paris Adventure".
Below, use a 'List' to display three days. Each item in the list should be a 'Card'.
- The first 'Card' (Day 1) should contain a 'Heading' "Day 1: Arrival & Eiffel Tower", and a 'List' of activities for that day: "Check into hotel", "Lunch at a cafe", "Visit the Eiffel Tower".
- The second 'Card' (Day 2) should contain a 'Heading' "Day 2: Museums & Culture", and a 'List' of activities: "Visit the Louvre Museum", "Walk through Tuileries Garden", "See the Arc de Triomphe".
- The third 'Card' (Day 3) should contain a 'Heading' "Day 3: Art & Departure", and a 'List' of activities: "Visit Musée d'Orsay", "Explore Montmartre", "Depart from CDG".
Each activity in the inner lists should be a 'Row' containing a 'CheckBox' (to mark as complete) and a 'Text' component with the activity description.`,
matchers: [
new MessageTypeMatcher("surfaceUpdate"),
new SurfaceUpdateSchemaMatcher("Heading", "text", "Paris Adventure"),
new SurfaceUpdateSchemaMatcher(
"Heading",
"text",
"Day 1: Arrival & Eiffel Tower"
),
new SurfaceUpdateSchemaMatcher(
"Heading",
"text",
"Day 2: Museums & Culture"
),
new SurfaceUpdateSchemaMatcher(
"Heading",
"text",
"Day 3: Art & Departure"
),
new SurfaceUpdateSchemaMatcher("Column"),
new SurfaceUpdateSchemaMatcher("CheckBox"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Visit the Eiffel Tower"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Visit the Louvre Museum"),
new SurfaceUpdateSchemaMatcher("Text", "text", "Explore Montmartre"),
],
},
];

View File

@@ -0,0 +1,24 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface ValidationResult {
success: boolean;
error?: string;
}
export abstract class SchemaMatcher {
abstract validate(schema: any): ValidationResult;
}

View File

@@ -0,0 +1,207 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SchemaMatcher, ValidationResult } from "./schema_matcher";
/**
* A schema matcher that validates the presence of a component type within a
* `surfaceUpdate` message, and optionally validates the presence and value of
* a property on that component.
*/
export class SurfaceUpdateSchemaMatcher extends SchemaMatcher {
constructor(
public componentType: string,
public propertyName?: string,
public propertyValue?: any,
public caseInsensitive: boolean = false
) {
super();
}
private getComponentById(components: any[], id: string): any | undefined {
return components.find((c: any) => c.id === id);
}
validate(schema: any): ValidationResult {
if (!schema.surfaceUpdate) {
return {
success: false,
error: `Expected a 'surfaceUpdate' message but found none.`,
};
}
if (!Array.isArray(schema.surfaceUpdate.components)) {
return {
success: false,
error: `'surfaceUpdate' message does not contain a 'components' array.`,
};
}
const components = schema.surfaceUpdate.components;
for (const c of components) {
if (c.component && Object.keys(c.component).length > 1) {
return {
success: false,
error: `Component ID '${c.id}' has multiple component types defined: ${Object.keys(c.component).join(", ")}`,
};
}
}
const matchingComponents = components.filter(
(c: any) => c.component && c.component[this.componentType]
);
if (matchingComponents.length === 0) {
return {
success: false,
error: `Failed to find component of type '${this.componentType}'.`,
};
}
if (!this.propertyName) {
return { success: true };
}
for (const component of matchingComponents) {
const properties = component.component[this.componentType];
if (properties) {
// Check for property directly on the component
if (properties[this.propertyName] !== undefined) {
if (this.propertyValue === undefined) {
return { success: true };
}
const actualValue = properties[this.propertyName];
if (this.valueMatches(actualValue, this.propertyValue)) {
return { success: true };
}
}
// Specifically for Buttons, check for label in a child Text component
if (
this.componentType === "Button" &&
this.propertyName === "label" &&
properties.child
) {
const childComponent = this.getComponentById(
components,
properties.child
);
if (
childComponent &&
childComponent.component &&
childComponent.component.Text
) {
const textValue = childComponent.component.Text.text;
if (this.valueMatches(textValue, this.propertyValue)) {
return { success: true };
}
}
}
}
}
if (this.propertyValue !== undefined) {
return {
success: false,
error: `Failed to find component of type '${this.componentType}' with property '${this.propertyName}' containing value ${JSON.stringify(this.propertyValue)}.`,
};
} else {
return {
success: false,
error: `Failed to find component of type '${this.componentType}' with property '${this.propertyName}'.`,
};
}
}
private valueMatches(actualValue: any, expectedValue: any): boolean {
if (actualValue === null || actualValue === undefined) {
return false;
}
const compareStrings = (s1: string, s2: string) => {
return this.caseInsensitive
? s1.toLowerCase() === s2.toLowerCase()
: s1 === s2;
};
// Handle new literal/path object structure
if (typeof actualValue === "object" && !Array.isArray(actualValue)) {
if (actualValue.literalString !== undefined) {
return (
typeof expectedValue === "string" &&
compareStrings(actualValue.literalString, expectedValue)
);
}
if (actualValue.literalNumber !== undefined) {
return actualValue.literalNumber === expectedValue;
}
if (actualValue.literalBoolean !== undefined) {
return actualValue.literalBoolean === expectedValue;
}
// Could also have a 'path' key, but for matching we'd expect a literal value in expectedValue
}
// Handle array cases (e.g., for MultipleChoice options)
if (Array.isArray(actualValue)) {
for (const item of actualValue) {
if (typeof item === "object" && item !== null) {
// Check if the item itself is a bound value object
if (
item.literalString !== undefined &&
typeof expectedValue === "string" &&
compareStrings(item.literalString, expectedValue)
)
return true;
if (
item.literalNumber !== undefined &&
item.literalNumber === expectedValue
)
return true;
if (
item.literalBoolean !== undefined &&
item.literalBoolean === expectedValue
)
return true;
// Check for structures like MultipleChoice options {label: {literalString: ...}, value: ...}
if (
item.label &&
typeof item.label === "object" &&
item.label.literalString !== undefined &&
typeof expectedValue === "string" &&
compareStrings(item.label.literalString, expectedValue)
) {
return true;
}
if (item.value === expectedValue) {
return true;
}
} else if (
typeof item === "string" &&
typeof expectedValue === "string" &&
compareStrings(item, expectedValue)
) {
return true;
} else if (item === expectedValue) {
return true;
}
}
}
// Fallback to direct comparison
return JSON.stringify(actualValue) === JSON.stringify(expectedValue);
}
}

View File

@@ -0,0 +1,521 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SurfaceUpdateSchemaMatcher } from "./surface_update_schema_matcher";
import { SchemaMatcher } from "./schema_matcher";
export function validateSchema(
data: any,
schemaName: string,
matchers?: SchemaMatcher[],
): string[] {
const errors: string[] = [];
if (data.surfaceUpdate) {
validateSurfaceUpdate(data.surfaceUpdate, errors);
} else if (data.dataModelUpdate) {
validateDataModelUpdate(data.dataModelUpdate, errors);
} else if (data.beginRendering) {
validateBeginRendering(data.beginRendering, errors);
} else if (data.deleteSurface) {
validateDeleteSurface(data.deleteSurface, errors);
} else {
errors.push(
"A2UI Protocol message must have one of: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface.",
);
}
if (matchers) {
for (const matcher of matchers) {
const result = matcher.validate(data);
if (!result.success) {
errors.push(result.error!);
}
}
}
return errors;
}
function validateDeleteSurface(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("DeleteSurface must have a 'surfaceId' property.");
}
const allowed = ["surfaceId"];
for (const key in data) {
if (!allowed.includes(key)) {
errors.push(`DeleteSurface has unexpected property: ${key}`);
}
}
}
function validateSurfaceUpdate(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("SurfaceUpdate must have a 'surfaceId' property.");
}
if (!data.components || !Array.isArray(data.components)) {
errors.push("SurfaceUpdate must have a 'components' array.");
return;
}
const componentIds = new Set<string>();
for (const c of data.components) {
if (c.id) {
if (componentIds.has(c.id)) {
errors.push(`Duplicate component ID found: ${c.id}`);
}
componentIds.add(c.id);
}
}
for (const component of data.components) {
validateComponent(component, componentIds, errors);
}
}
function validateDataModelUpdate(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("DataModelUpdate must have a 'surfaceId' property.");
}
const allowedTopLevel = ["surfaceId", "path", "contents"];
for (const key in data) {
if (!allowedTopLevel.includes(key)) {
errors.push(`DataModelUpdate has unexpected property: ${key}`);
}
}
if (!Array.isArray(data.contents)) {
errors.push("DataModelUpdate must have a 'contents' array.");
return;
}
const validateValueProperty = (
item: any,
itemErrors: string[],
prefix: string,
) => {
const valueProps = [
"valueString",
"valueNumber",
"valueBoolean",
"valueMap",
];
let valueCount = 0;
let foundValueProp = "";
for (const prop of valueProps) {
if (item[prop] !== undefined) {
valueCount++;
foundValueProp = prop;
}
}
if (valueCount !== 1) {
itemErrors.push(
`${prefix} must have exactly one value property (${valueProps.join(", ")}), found ${valueCount}.`,
);
return;
}
if (foundValueProp === "valueMap") {
if (!Array.isArray(item.valueMap)) {
itemErrors.push(`${prefix} 'valueMap' must be an array.`);
return;
}
item.valueMap.forEach((mapItem: any, index: number) => {
if (!mapItem.key) {
itemErrors.push(
`${prefix} 'valueMap' item at index ${index} is missing a 'key'.`,
);
}
const mapValueProps = ["valueString", "valueNumber", "valueBoolean"];
let mapValueCount = 0;
for (const prop of mapValueProps) {
if (mapItem[prop] !== undefined) {
mapValueCount++;
}
}
if (mapValueCount !== 1) {
itemErrors.push(
`${prefix} 'valueMap' item at index ${index} must have exactly one value property (${mapValueProps.join(", ")}), found ${mapValueCount}.`,
);
}
const allowedMapKeys = ["key", ...mapValueProps];
for (const key in mapItem) {
if (!allowedMapKeys.includes(key)) {
itemErrors.push(
`${prefix} 'valueMap' item at index ${index} has unexpected property: ${key}`,
);
}
}
});
}
};
data.contents.forEach((item: any, index: number) => {
if (!item.key) {
errors.push(
`DataModelUpdate 'contents' item at index ${index} is missing a 'key'.`,
);
}
validateValueProperty(
item,
errors,
`DataModelUpdate 'contents' item at index ${index}`,
);
const allowedKeys = [
"key",
"valueString",
"valueNumber",
"valueBoolean",
"valueMap",
];
for (const key in item) {
if (!allowedKeys.includes(key)) {
errors.push(
`DataModelUpdate 'contents' item at index ${index} has unexpected property: ${key}`,
);
}
}
});
}
function validateBeginRendering(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("BeginRendering message must have a 'surfaceId' property.");
}
if (!data.root) {
errors.push("BeginRendering message must have a 'root' property.");
}
}
function validateBoundValue(
prop: any,
propName: string,
componentId: string,
componentType: string,
errors: string[],
) {
if (typeof prop !== "object" || prop === null || Array.isArray(prop)) {
errors.push(
`Component '${componentId}' of type '${componentType}' property '${propName}' must be an object.`,
);
return;
}
const keys = Object.keys(prop);
const allowedKeys = [
"literalString",
"literalNumber",
"literalBoolean",
"path",
];
let validKeyCount = 0;
for (const key of keys) {
if (allowedKeys.includes(key)) {
validKeyCount++;
}
}
if (validKeyCount !== 1 || keys.length !== 1) {
errors.push(
`Component '${componentId}' of type '${componentType}' property '${propName}' must have exactly one key from [${allowedKeys.join(", ")}]. Found: ${keys.join(", ")}`,
);
}
}
function validateComponent(
component: any,
allIds: Set<string>,
errors: string[],
) {
if (!component.id) {
errors.push(`Component is missing an 'id'.`);
return;
}
if (!component.component) {
errors.push(`Component '${component.id}' is missing 'component'.`);
return;
}
const componentTypes = Object.keys(component.component);
if (componentTypes.length !== 1) {
errors.push(
`Component '${component.id}' must have exactly one property in 'component', but found ${componentTypes.length}.`,
);
return;
}
const componentType = componentTypes[0];
const properties = component.component[componentType];
const checkRequired = (props: string[]) => {
for (const prop of props) {
if (properties[prop] === undefined) {
errors.push(
`Component '${component.id}' of type '${componentType}' is missing required property '${prop}'.`,
);
}
}
};
const checkRefs = (ids: (string | undefined)[]) => {
for (const id of ids) {
if (id && !allIds.has(id)) {
errors.push(
`Component '${component.id}' references non-existent component ID '${id}'.`,
);
}
}
};
switch (componentType) {
case "Heading":
checkRequired(["text"]);
if (properties.text)
validateBoundValue(
properties.text,
"text",
component.id,
componentType,
errors,
);
break;
case "Text":
checkRequired(["text"]);
if (properties.text)
validateBoundValue(
properties.text,
"text",
component.id,
componentType,
errors,
);
break;
case "Image":
checkRequired(["url"]);
if (properties.url)
validateBoundValue(
properties.url,
"url",
component.id,
componentType,
errors,
);
break;
case "Video":
checkRequired(["url"]);
if (properties.url)
validateBoundValue(
properties.url,
"url",
component.id,
componentType,
errors,
);
break;
case "AudioPlayer":
checkRequired(["url"]);
if (properties.url)
validateBoundValue(
properties.url,
"url",
component.id,
componentType,
errors,
);
if (properties.description)
validateBoundValue(
properties.description,
"description",
component.id,
componentType,
errors,
);
break;
case "TextField":
checkRequired(["label"]);
if (properties.label)
validateBoundValue(
properties.label,
"label",
component.id,
componentType,
errors,
);
if (properties.text)
validateBoundValue(
properties.text,
"text",
component.id,
componentType,
errors,
);
break;
case "DateTimeInput":
checkRequired(["value"]);
if (properties.value)
validateBoundValue(
properties.value,
"value",
component.id,
componentType,
errors,
);
break;
case "MultipleChoice":
checkRequired(["selections", "options"]);
if (properties.selections) {
if (
typeof properties.selections !== "object" ||
properties.selections === null ||
(!properties.selections.literalArray && !properties.selections.path)
) {
errors.push(
`Component '${component.id}' of type '${componentType}' property 'selections' must have either 'literalArray' or 'path'.`,
);
}
}
if (Array.isArray(properties.options)) {
properties.options.forEach((option: any, index: number) => {
if (!option.label)
errors.push(
`Component '${component.id}' option at index ${index} missing 'label'.`,
);
if (option.label)
validateBoundValue(
option.label,
"label",
component.id,
componentType,
errors,
);
if (!option.value)
errors.push(
`Component '${component.id}' option at index ${index} missing 'value'.`,
);
});
}
break;
case "Slider":
checkRequired(["value"]);
if (properties.value)
validateBoundValue(
properties.value,
"value",
component.id,
componentType,
errors,
);
break;
case "CheckBox":
checkRequired(["value", "label"]);
if (properties.value)
validateBoundValue(
properties.value,
"value",
component.id,
componentType,
errors,
);
if (properties.label)
validateBoundValue(
properties.label,
"label",
component.id,
componentType,
errors,
);
break;
case "Row":
case "Column":
case "List":
checkRequired(["children"]);
if (properties.children && Array.isArray(properties.children)) {
const hasExplicit = !!properties.children.explicitList;
const hasTemplate = !!properties.children.template;
if ((hasExplicit && hasTemplate) || (!hasExplicit && !hasTemplate)) {
errors.push(
`Component '${component.id}' must have either 'explicitList' or 'template' in children, but not both or neither.`,
);
}
if (hasExplicit) {
checkRefs(properties.children.explicitList);
}
if (hasTemplate) {
checkRefs([properties.children.template?.componentId]);
}
}
break;
case "Card":
checkRequired(["child"]);
checkRefs([properties.child]);
break;
case "Tabs":
checkRequired(["tabItems"]);
if (properties.tabItems && Array.isArray(properties.tabItems)) {
properties.tabItems.forEach((tab: any) => {
if (!tab.title) {
errors.push(
`Tab item in component '${component.id}' is missing a 'title'.`,
);
}
if (!tab.child) {
errors.push(
`Tab item in component '${component.id}' is missing a 'child'.`,
);
}
checkRefs([tab.child]);
if (tab.title)
validateBoundValue(
tab.title,
"title",
component.id,
componentType,
errors,
);
});
}
break;
case "Modal":
checkRequired(["entryPointChild", "contentChild"]);
checkRefs([properties.entryPointChild, properties.contentChild]);
break;
case "Button":
checkRequired(["child", "action"]);
checkRefs([properties.child]);
if (!properties.action || !properties.action.name) {
errors.push(
`Component '${component.id}' Button action is missing a 'name'.`,
);
}
break;
case "Divider":
// No required properties
break;
case "Icon":
checkRequired(["name"]);
if (properties.name)
validateBoundValue(
properties.name,
"name",
component.id,
componentType,
errors,
);
break;
default:
errors.push(
`Unknown component type '${componentType}' in component '${component.id}'.`,
);
}
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "lib",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@@ -0,0 +1,15 @@
# A2UI JSON Schema Files
This directory contains the formal JSON Schema definitions for the A2UI protocol.
## Schema Descriptions
- `server_to_client.json`: This is the core, catalog-agnostic schema for messages sent from the server to the client. It defines the four main message types (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`) and their structure. In this schema, the `component` object within a `surfaceUpdate` message is generic (`"additionalProperties": true`), allowing any component definitions to be passed.
- `client_to_server.json`: This schema defines the structure for event messages sent from the client to the server. This includes user-initiated actions (`userAction`), error reporting (`error`), and the crucial `clientUiCapabilities` message, which allows a client to inform the server about the component catalog it supports.
- `catalog_description_schema.json`: This is a meta-schema that defines the structure of an A2UI component catalog. A catalog consists of a `components` object and a `styles` object, where each key is a component/style name and the value is a JSON schema defining its properties. This allows for the creation of custom component sets.
- `standard_catalog_definition.json`: This file is a concrete implementation of a catalog, conforming to the `catalog_description_schema.json`. It defines the standard set of components (e.g., `Text`, `Image`, `Row`, `Card`) and styles that are part of the baseline A2UI specification.
- `server_to_client_with_standard_catalog.json`: This is a resolved, LLM-friendly version of the server-to-client schema. It is generated by combining `server_to_client.json` with the `standard_catalog_definition.json`. In this version, the generic `component` object is replaced with a strict `oneOf` definition that includes every component from the standard catalog. This provides a complete, strictly-typed schema that is ideal for LLMs to use for generating valid A2UI messages without ambiguity.

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "A2UI Client Capabilities Schema",
"description": "A schema for the a2uiClientCapabilities object, which is sent from the client to the server to describe the client's UI rendering capabilities.",
"type": "object",
"properties": {
"supportedCatalogIds": {
"type": "array",
"description": "The URI of each of the catalogs that is supported by the client. The standard catalog for v0.8 is 'a2ui.org:standard_catalog_0_8_0'.",
"items": {
"type": "string"
}
},
"inlineCatalogs": {
"type": "array",
"description": "An array of inline catalog definitions. This should only be provided if the agent declares 'acceptsInlineCatalogs: true' in its capabilities.",
"items": {
"$ref": "catalog_description_schema.json"
}
}
},
"required": ["supportedCatalogIds"]
}

View File

@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "A2UI Catalog Description Schema",
"description": "A schema for a custom Catalog Description including A2UI components and styles.",
"type": "object",
"properties": {
"catalogId": {
"title": "Catalog ID",
"description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.",
"type": "string"
},
"components": {
"title": "A2UI Components",
"description": "A schema that defines a catalog of A2UI components. Each key is a component name, and each value is the JSON schema for that component's properties.",
"type": "object",
"additionalProperties": {
"$ref": "https://json-schema.org/draft/2020-12/schema"
}
},
"styles": {
"title": "A2UI Styles",
"description": "A schema that defines a catalog of A2UI styles. Each key is a style name, and each value is the JSON schema for that style's properties.",
"type": "object",
"additionalProperties": {
"$ref": "https://json-schema.org/draft/2020-12/schema"
}
}
},
"required": [
"catalogId",
"components",
"styles"
]
}

View File

@@ -0,0 +1,53 @@
{
"title": "A2UI (Agent to UI) Client-to-Server Event Schema",
"description": "Describes a JSON payload for a client-to-server event message.",
"type": "object",
"minProperties": 1,
"maxProperties": 1,
"properties": {
"userAction": {
"type": "object",
"description": "Reports a user-initiated action from a component.",
"properties": {
"name": {
"type": "string",
"description": "The name of the action, taken from the component's action.name property."
},
"surfaceId": {
"type": "string",
"description": "The id of the surface where the event originated."
},
"sourceComponentId": {
"type": "string",
"description": "The id of the component that triggered the event."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "An ISO 8601 timestamp of when the event occurred."
},
"context": {
"type": "object",
"description": "A JSON object containing the key-value pairs from the component's action.context, after resolving all data bindings.",
"additionalProperties": true
}
},
"required": [
"name",
"surfaceId",
"sourceComponentId",
"timestamp",
"context"
]
},
"error": {
"type": "object",
"description": "Reports a client-side error. The content is flexible.",
"additionalProperties": true
}
},
"oneOf": [
{ "required": ["userAction"] },
{ "required": ["error"] }
]
}

View File

@@ -0,0 +1,148 @@
{
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
"type": "object",
"additionalProperties": false,
"properties": {
"beginRendering": {
"type": "object",
"description": "Signals the client to begin rendering a surface with a root component and specific styles.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"catalogId": {
"type": "string",
"description": "The identifier of the component catalog to use for this surface. If omitted, the client MUST default to the standard catalog for this A2UI version (a2ui.org:standard_catalog_0_8_0)."
},
"root": {
"type": "string",
"description": "The ID of the root component to render."
},
"styles": {
"type": "object",
"description": "Styling information for the UI.",
"additionalProperties": true
}
},
"required": ["root", "surfaceId"]
},
"surfaceUpdate": {
"type": "object",
"description": "Updates a surface with a new set of components.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"type": "object",
"description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column."
},
"component": {
"type": "object",
"description": "A wrapper object that MUST contain exactly one key, which is the name of the component type. The value is an object containing the properties for that specific component.",
"additionalProperties": true
}
},
"required": ["id", "component"]
}
}
},
"required": ["surfaceId", "components"]
},
"dataModelUpdate": {
"type": "object",
"description": "Updates the data model for a surface.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced."
},
"contents": {
"type": "array",
"description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.",
"items": {
"type": "object",
"description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "The key for this data entry."
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
},
"valueMap": {
"description": "Represents a map as an adjacency list.",
"type": "array",
"items": {
"type": "object",
"description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
}
},
"required": ["key"]
}
}
},
"required": ["key"]
}
}
},
"required": ["contents", "surfaceId"]
},
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"]
}
}
}

View File

@@ -0,0 +1,827 @@
{
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
"type": "object",
"additionalProperties": false,
"properties": {
"beginRendering": {
"type": "object",
"description": "Signals the client to begin rendering a surface with a root component and specific styles.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"root": {
"type": "string",
"description": "The ID of the root component to render."
},
"styles": {
"type": "object",
"description": "Styling information for the UI.",
"additionalProperties": false,
"properties": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}
},
"required": ["root", "surfaceId"]
},
"surfaceUpdate": {
"type": "object",
"description": "Updates a surface with a new set of components.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"type": "object",
"description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column."
},
"component": {
"type": "object",
"description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.",
"additionalProperties": false,
"properties": {
"Text": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "object",
"description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.",
"enum": [
"h1",
"h2",
"h3",
"h4",
"h5",
"caption",
"body"
]
}
},
"required": ["text"]
},
"Image": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
"enum": [
"contain",
"cover",
"fill",
"none",
"scale-down"
]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.",
"enum": [
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header"
]
}
},
"required": ["url"]
},
"Icon": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "object",
"description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string",
"enum": [
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"payment",
"person",
"phone",
"photo",
"print",
"refresh",
"search",
"send",
"settings",
"share",
"shoppingCart",
"star",
"starHalf",
"starOff",
"upload",
"visibility",
"visibilityOff",
"warning"
]
},
"path": {
"type": "string"
}
}
}
},
"required": ["name"]
},
"Video": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"AudioPlayer": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"description": {
"type": "object",
"description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"Row": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Column": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["children"]
},
"List": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"direction": {
"type": "string",
"description": "The direction in which the list items are laid out.",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Card": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to be rendered inside the card."
}
},
"required": ["child"]
},
"Tabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabItems": {
"type": "array",
"description": "An array of objects, where each object defines a tab with a title and a child component.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "object",
"description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"child": {
"type": "string"
}
},
"required": ["title", "child"]
}
}
},
"required": ["tabItems"]
},
"Divider": {
"type": "object",
"additionalProperties": false,
"properties": {
"axis": {
"type": "string",
"description": "The orientation of the divider.",
"enum": ["horizontal", "vertical"]
}
}
},
"Modal": {
"type": "object",
"additionalProperties": false,
"properties": {
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with (e.g., a button)."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal."
}
},
"required": ["entryPointChild", "contentChild"]
},
"Button": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to display in the button, typically a Text component."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"context": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "object",
"description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"literalString": {
"type": "string"
},
"literalNumber": {
"type": "number"
},
"literalBoolean": {
"type": "boolean"
}
}
}
},
"required": ["key", "value"]
}
}
},
"required": ["name"]
}
},
"required": ["child", "action"]
},
"CheckBox": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "object",
"description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').",
"additionalProperties": false,
"properties": {
"literalBoolean": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
}
},
"required": ["label", "value"]
},
"TextField": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"text": {
"type": "object",
"description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"textFieldType": {
"type": "string",
"description": "The type of input field to display.",
"enum": [
"date",
"longText",
"number",
"shortText",
"obscured"
]
},
"validationRegexp": {
"type": "string",
"description": "A regular expression used for client-side validation of the input."
}
},
"required": ["label"]
},
"DateTimeInput": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"enableDate": {
"type": "boolean",
"description": "If true, allows the user to select a date."
},
"enableTime": {
"type": "boolean",
"description": "If true, allows the user to select a time."
},
"outputFormat": {
"type": "string",
"description": "The desired format for the output string after a date or time is selected."
}
},
"required": ["value"]
},
"MultipleChoice": {
"type": "object",
"additionalProperties": false,
"properties": {
"selections": {
"type": "object",
"description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').",
"additionalProperties": false,
"properties": {
"literalArray": {
"type": "array",
"items": {
"type": "string"
}
},
"path": {
"type": "string"
}
}
},
"options": {
"type": "array",
"description": "An array of available options for the user to choose from.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "string",
"description": "The value to be associated with this option when selected."
}
},
"required": ["label", "value"]
}
},
"maxAllowedSelections": {
"type": "integer",
"description": "The maximum number of options that the user is allowed to select."
}
},
"required": ["selections", "options"]
},
"Slider": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
"additionalProperties": false,
"properties": {
"literalNumber": {
"type": "number"
},
"path": {
"type": "string"
}
}
},
"minValue": {
"type": "number",
"description": "The minimum value of the slider."
},
"maxValue": {
"type": "number",
"description": "The maximum value of the slider."
}
},
"required": ["value"]
}
}
}
},
"required": ["id", "component"]
}
}
},
"required": ["surfaceId", "components"]
},
"dataModelUpdate": {
"type": "object",
"description": "Updates the data model for a surface.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced."
},
"contents": {
"type": "array",
"description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.",
"items": {
"type": "object",
"description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "The key for this data entry."
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
},
"valueMap": {
"description": "Represents a map as an adjacency list.",
"type": "array",
"items": {
"type": "object",
"description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
}
},
"required": ["key"]
}
}
},
"required": ["key"]
}
}
},
"required": ["contents", "surfaceId"]
},
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"]
}
}
}

View File

@@ -0,0 +1,685 @@
{
"components": {
"Text": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "object",
"description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.",
"enum": [
"h1",
"h2",
"h3",
"h4",
"h5",
"caption",
"body"
]
}
},
"required": ["text"]
},
"Image": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
"enum": [
"contain",
"cover",
"fill",
"none",
"scale-down"
]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.",
"enum": [
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header"
]
}
},
"required": ["url"]
},
"Icon": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "object",
"description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string",
"enum": [
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"payment",
"person",
"phone",
"photo",
"print",
"refresh",
"search",
"send",
"settings",
"share",
"shoppingCart",
"star",
"starHalf",
"starOff",
"upload",
"visibility",
"visibilityOff",
"warning"
]
},
"path": {
"type": "string"
}
}
}
},
"required": ["name"]
},
"Video": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"AudioPlayer": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"description": {
"type": "object",
"description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"Row": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Column": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["children"]
},
"List": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"direction": {
"type": "string",
"description": "The direction in which the list items are laid out.",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Card": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to be rendered inside the card."
}
},
"required": ["child"]
},
"Tabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabItems": {
"type": "array",
"description": "An array of objects, where each object defines a tab with a title and a child component.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "object",
"description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"child": {
"type": "string"
}
},
"required": ["title", "child"]
}
}
},
"required": ["tabItems"]
},
"Divider": {
"type": "object",
"additionalProperties": false,
"properties": {
"axis": {
"type": "string",
"description": "The orientation of the divider.",
"enum": ["horizontal", "vertical"]
}
}
},
"Modal": {
"type": "object",
"additionalProperties": false,
"properties": {
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with (e.g., a button)."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal."
}
},
"required": ["entryPointChild", "contentChild"]
},
"Button": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to display in the button, typically a Text component."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"context": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "object",
"description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"literalString": {
"type": "string"
},
"literalNumber": {
"type": "number"
},
"literalBoolean": {
"type": "boolean"
}
}
}
},
"required": ["key", "value"]
}
}
},
"required": ["name"]
}
},
"required": ["child", "action"]
},
"CheckBox": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "object",
"description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').",
"additionalProperties": false,
"properties": {
"literalBoolean": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
}
},
"required": ["label", "value"]
},
"TextField": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"text": {
"type": "object",
"description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"textFieldType": {
"type": "string",
"description": "The type of input field to display.",
"enum": [
"date",
"longText",
"number",
"shortText",
"obscured"
]
},
"validationRegexp": {
"type": "string",
"description": "A regular expression used for client-side validation of the input."
}
},
"required": ["label"]
},
"DateTimeInput": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"enableDate": {
"type": "boolean",
"description": "If true, allows the user to select a date."
},
"enableTime": {
"type": "boolean",
"description": "If true, allows the user to select a time."
},
"outputFormat": {
"type": "string",
"description": "The desired format for the output string after a date or time is selected."
}
},
"required": ["value"]
},
"MultipleChoice": {
"type": "object",
"additionalProperties": false,
"properties": {
"selections": {
"type": "object",
"description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').",
"additionalProperties": false,
"properties": {
"literalArray": {
"type": "array",
"items": {
"type": "string"
}
},
"path": {
"type": "string"
}
}
},
"options": {
"type": "array",
"description": "An array of available options for the user to choose from.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "string",
"description": "The value to be associated with this option when selected."
}
},
"required": ["label", "value"]
}
},
"maxAllowedSelections": {
"type": "integer",
"description": "The maximum number of options that the user is allowed to select."
}
},
"required": ["selections", "options"]
},
"Slider": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
"additionalProperties": false,
"properties": {
"literalNumber": {
"type": "number"
},
"path": {
"type": "string"
}
}
},
"minValue": {
"type": "number",
"description": "The minimum value of the slider."
},
"maxValue": {
"type": "number",
"description": "The maximum value of the slider."
}
},
"required": ["value"]
}
},
"styles": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}

View File

@@ -0,0 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
/results
# production
/build
lib/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.genkit

View File

@@ -0,0 +1,79 @@
# Genkit Eval Framework for UI generation
This is for evaluating A2UI (v0.9) against various LLMs.
This version embeds the JSON schemas directly into the prompt and instructs the LLM to output a JSON object within a markdown code block. The framework then extracts and validates this JSON.
## Setup
To use the models, you need to set the following environment variables with your API keys:
- `GEMINI_API_KEY`
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
You can set these in a `.env` file in the root of the project, or in your shell's configuration file (e.g., `.bashrc`, `.zshrc`).
You also need to install dependencies before running:
```bash
pnpm install
```
## Running all evals (warning: can use _lots_ of model quota)
To run the flow, use the following command:
```bash
pnpm run evalAll
```
## Running a Single Test
You can run the script for a single model and data point by using the `--model` and `--prompt` command-line flags. This is useful for quick tests and debugging.
### Syntax
```bash
pnpm run eval --model=<model_name> --prompt=<prompt_name>
```
### Example
To run the test with the `gemini-2.5-flash-lite` model and the `loginForm` prompt, use the following command:
```bash
pnpm run eval --model=gemini-2.5-flash-lite --prompt=loginForm
```
## Controlling Output
By default, the script prints a progress bar and the final summary table to the console. Detailed logs are written to `output.log` in the results directory.
### Command-Line Options
- `--log-level=<level>`: Sets the console logging level (default: `info`). Options: `error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly`.
- Note: The file log (`output.log` in the results directory) always captures `debug` level logs regardless of this setting.
- `--results=<output_dir>`: (Default: `results/output-<model>` or `results/output-combined` if multiple models are specified) Preserves output files. To specify a custom directory, use `--results=my_results`.
- `--clean-results`: If set, cleans the results directory before running tests.
- `--runs-per-prompt=<number>`: Number of times to run each prompt (default: 1).
- `--model=<model_name>`: (Default: all models) Run only the specified model(s). Can be specified multiple times.
- `--prompt=<prompt_name>`: (Default: all prompts) Run only the specified prompt.
### Examples
Run with debug output in console:
```bash
pnpm run eval -- --log-level=debug
```
Run 5 times per prompt and clean previous results:
```bash
pnpm run eval -- --runs-per-prompt=5 --clean-results
```
## Rate Limiting
The framework includes a two-tiered rate limiting system:
1. **Proactive Limiting**: Locally tracks token and request usage to stay within configured limits (defined in `src/models.ts`).
2. **Reactive Circuit Breaker**: Automatically pauses requests to a model if a `RESOURCE_EXHAUSTED` (429) error is received, resuming only after the requested retry duration.

View File

@@ -0,0 +1,24 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { googleAI } from "@genkit-ai/google-genai";
import { configure } from "genkit";
export default configure({
plugins: [googleAI()],
logLevel: "debug",
enableTracingAndMetrics: true,
});

View File

@@ -0,0 +1,47 @@
{
"name": "a2ui_0_9_eval_llm",
"version": "1.0.0",
"main": "lib/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"eval": "dotenv -- tsx src/index.ts",
"evalAll": "pnpm run eval --clean-results",
"evalGemini": "pnpm run eval --model=gemini-2.5-flash-lite --clean-results",
"evalGpt": "pnpm run eval --model=gpt-5-mini --clean-results",
"evalClaude": "pnpm run eval --model=claude-4-sonnet --clean-results",
"start": "genkit start",
"genkit:dev": "genkit start -- tsx --watch src/dev.ts",
"format": "prettier --write src/**/*.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/node": "^20.19.25",
"@types/yargs": "^17.0.35",
"dotenv-cli": "^10.0.0",
"prettier": "^3.6.2",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"yargs": "^18.0.0"
},
"dependencies": {
"@genkit-ai/ai": "^1.24.0",
"@genkit-ai/compat-oai": "^1.24.0",
"@genkit-ai/core": "^1.24.0",
"@genkit-ai/dotprompt": "^0.9.12",
"@genkit-ai/firebase": "^1.24.0",
"@genkit-ai/google-cloud": "^1.24.0",
"@genkit-ai/google-genai": "^1.24.0",
"@types/js-yaml": "^4.0.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"genkit": "^1.24.0",
"genkitx-anthropic": "^0.25.0",
"js-yaml": "^4.1.1",
"winston": "^3.18.3",
"zod": "^3.25.76"
}
}

4727
vendor/a2ui/specification/0.9/eval/pnpm-lock.yaml generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
onlyBuiltDependencies:
- '@firebase/util'
- esbuild
- protobufjs

View File

@@ -0,0 +1,46 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { googleAI } from "@genkit-ai/google-genai";
import { genkit } from "genkit";
import { openAI } from "@genkit-ai/compat-oai/openai";
import { anthropic } from "genkitx-anthropic";
import { logger } from "./logger";
const plugins = [];
if (process.env.GEMINI_API_KEY) {
logger.info("Initializing Google AI plugin...");
plugins.push(
googleAI({
apiKey: process.env.GEMINI_API_KEY!,
experimental_debugTraces: true,
})
);
}
if (process.env.OPENAI_API_KEY) {
logger.info("Initializing OpenAI plugin...");
plugins.push(openAI());
}
if (process.env.ANTHROPIC_API_KEY) {
logger.info("Initializing Anthropic plugin...");
plugins.push(anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }));
}
export const ai = genkit({
plugins,
});

View File

@@ -0,0 +1,118 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { z } from "genkit";
import { ai } from "./ai";
import { rateLimiter } from "./rateLimiter";
import { logger } from "./logger";
export const analysisFlow = ai.defineFlow(
{
name: "analysisFlow",
inputSchema: z.object({
modelName: z.string(),
failures: z.array(
z.object({
promptName: z.string(),
runNumber: z.number(),
failureType: z.string(),
reason: z.string(),
issues: z.array(z.string()).optional(),
})
),
numRuns: z.number(),
evalModel: z.string(),
}),
outputSchema: z.string(),
},
async ({ modelName, failures, numRuns, evalModel }) => {
const failureDetails = failures
.map((f) => {
let details = `Prompt: ${f.promptName} (Run ${f.runNumber})\nType: ${f.failureType}\nReason: ${f.reason}`;
if (f.issues && f.issues.length > 0) {
details += `\nIssues:\n- ${f.issues.join("\n- ")}`;
}
return details;
})
.join("\n\n---\n\n");
const analysisPrompt = `You are an expert AI analyst.
Your task is to analyze the following failures from an evaluation run of the model "${modelName}".
Out of the ${failures.length} failures, ${failures.filter((f) => f.failureType === "Schema Validation").length} are schema validation failures, ${failures.filter((f) => f.failureType === "Missing Components").length} are missing components failures, and ${failures.filter((f) => f.failureType === "Incorrect Logic").length} are incorrect logic failures.
There were ${numRuns - failures.length} successful runs. Take this into account in the final summary of the analysis.
Failures:
${failureDetails}
Instructions:
1. Identify and list the broad types of errors (e.g., Schema Validation, Missing Components, Incorrect Logic, etc.).
2. Analyze succinctly any patterns you see in the failures (e.g., "The model consistently fails to include the 'id' property", "The model struggles with nested layouts") and list them in a bullet point list. Try to give short examples of the patterns taken from the actual failures.
3. Provide a concise summary of your findings in a single paragraph.
The output is meant to be a short summary, not a full report. It should be easy to read and understand at a glance.
Output Format:
Return a Markdown formatted summary. Use headers and bullet points.
`;
// Calculate estimated tokens for rate limiting
const estimatedInputTokens = Math.ceil(analysisPrompt.length / 2.5);
const { modelsToTest } = await import("./models");
let evalModelConfig = modelsToTest.find((m) => m.name === evalModel);
if (!evalModelConfig) {
evalModelConfig = {
name: evalModel,
model: null,
requestsPerMinute: 60,
tokensPerMinute: 100000,
};
}
await rateLimiter.acquirePermit(evalModelConfig, estimatedInputTokens);
try {
const response = await ai.generate({
prompt: analysisPrompt,
model: evalModelConfig.model || evalModel,
config: evalModelConfig.config,
output: {
format: "text",
},
});
const output = response.output;
if (!output) {
throw new Error("No output from analysis model");
}
if (typeof output !== "string") {
return "Analysis failed: Output was not a string.";
}
return output;
} catch (e: any) {
logger.error(`Error during analysis: ${e}`);
if (evalModelConfig) {
rateLimiter.reportError(evalModelConfig, e);
}
return `Analysis failed: ${e.message}`;
}
}
);

View File

@@ -0,0 +1,18 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "./generation_flow";
import "./evaluation_flow";

View File

@@ -0,0 +1,193 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { z } from "genkit";
import { ai } from "./ai";
import { rateLimiter } from "./rateLimiter";
import { logger } from "./logger";
import * as yaml from "js-yaml";
// Define an evaluation flow
export const evaluationFlow = ai.defineFlow(
{
name: "evaluationFlow",
inputSchema: z.object({
originalPrompt: z.string(),
generatedOutput: z.string(),
evalModel: z.string(),
schemas: z.any(),
}),
outputSchema: z.object({
pass: z.boolean(),
reason: z.string(),
issues: z
.array(
z.object({
issue: z.string(),
severity: z.enum(["minor", "significant", "critical"]),
})
)
.optional(),
evalPrompt: z.string().optional(),
}),
},
async ({ originalPrompt, generatedOutput, evalModel, schemas }) => {
const schemaDefs = Object.values(schemas)
.map((s: any) => JSON.stringify(s, null, 2))
.join("\n\n");
const EvalResultSchema = z.object({
pass: z
.boolean()
.describe("Whether the generated UI meets the requirements"),
reason: z.string().describe("Summary of the reason for a failure."),
issues: z
.array(
z.object({
issue: z.string().describe("Description of the issue"),
severity: z
.enum(["minor", "significant", "critical"])
.describe("Severity of the issue"),
})
)
.describe("List of specific issues found."),
});
const evalPrompt = `You are an expert QA evaluator for a UI generation system.
Your task is to evaluate whether the generated UI JSON matches the user's request and conforms to the expected behavior.
User Request:
${originalPrompt}
Expected Schemas:
${schemaDefs}
Generated Output (JSONL in Markdown):
${generatedOutput}
Instructions:
1. Analyze the Generated Output against the User Request.
2. Check if all requested components are present and match the user's intent.
3. Check if the hierarchy and properties match the description.
4. Verify that the content (text, labels, etc.) is correct and makes sense.
5. Ignore minor formatting differences.
6. If the output is correct and satisfies the request, return "pass": true.
7. If there are missing components, incorrect values, or structural issues that affect the user experience, return "pass": false and provide a detailed "reason".
8. In the "reason", explicitly quote the part of the JSON that is incorrect if possible.
- You can be lenient in your evaluation for URLs, as the generated output may use a placeholder URL for images and icons.
- If label text is similar but not exact, you can still pass the test as long as the meaning is the same. (e.g. "Cancel" vs "Cancel Order")
- If the generated output is missing a component that is specified in the user request, it is required to exist in the output in order to pass the test. If it is not specified, it is not required.
- If the request is vague about the contents of a label or other property, you can still pass the test as long as it can be construed as matching the intent.
- Unless explicitly required to be absent by the user request, extra components or attributes are allowed.
Severity Definitions:
- Minor: Merely cosmetic or a slight deviation from the request.
- Significant: The UI isn't very ergonomic or would be hard to understand.
- Critical: That part of the UI is left off, or the structure isn't valid and can't be rendered.
Return a JSON object with the following schema:
\`\`\`json
{
"type": "object",
"properties": {
"pass": {
"type": "boolean",
"description": "Whether the generated UI meets the requirements"
},
"reason": {
"type": "string",
"description": "Summary of the reason for a failure."
},
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"issue": {
"type": "string",
"description": "Description of the issue"
},
"severity": {
"type": "string",
"enum": ["minor", "significant", "critical"],
"description": "Severity of the issue"
}
},
"required": ["issue", "severity"]
},
"description": "List of specific issues found."
}
},
"required": ["pass", "reason", "issues"]
}
\`\`\`
`;
// Calculate estimated tokens for rate limiting
const estimatedInputTokens = Math.ceil(evalPrompt.length / 2.5);
// Find the model config for the eval model
// We need to look it up from the models list or create a temporary config
// For now, we'll try to find it in the imported models list, or default to a safe config
const { modelsToTest } = await import("./models");
let evalModelConfig = modelsToTest.find((m) => m.name === evalModel);
if (!evalModelConfig) {
// If not found, create a temporary config with default limits
evalModelConfig = {
name: evalModel,
model: null, // We don't need the model object for rate limiting if we just use the name
requestsPerMinute: 60, // Safe default
tokensPerMinute: 100000, // Safe default
};
}
await rateLimiter.acquirePermit(evalModelConfig, estimatedInputTokens);
try {
const response = await ai.generate({
prompt: evalPrompt,
model: evalModelConfig.model || evalModel, // Use the model object if available, otherwise the string
config: evalModelConfig.config,
output: {
schema: EvalResultSchema,
},
});
// Parse the output
const result = response.output;
if (!result) {
throw new Error("No output from evaluation model");
}
return {
pass: result.pass,
reason: result.reason || "No reason provided",
issues: result.issues || [],
evalPrompt: evalPrompt,
};
} catch (e: any) {
logger.error(`Error during evaluation: ${e}`);
if (evalModelConfig) {
rateLimiter.reportError(evalModelConfig, e);
}
throw e; // Re-throw to let the retry logic handle it
}
}
);

View File

@@ -0,0 +1,205 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { evaluationFlow } from "./evaluation_flow";
import { ValidatedResult, EvaluatedResult } from "./types";
import { logger } from "./logger";
import { rateLimiter } from "./rateLimiter";
import * as fs from "fs";
import * as path from "path";
import * as yaml from "js-yaml";
import { IssueSeverity } from "./types";
export class Evaluator {
constructor(
private schemas: any,
private evalModel: string,
private outputDir?: string
) {}
async run(results: ValidatedResult[]): Promise<EvaluatedResult[]> {
const passedResults = results.filter(
(r) => r.validationErrors.length === 0 && r.components
);
const skippedCount = results.length - passedResults.length;
logger.info(
`Starting Phase 3: LLM Evaluation (${passedResults.length} items to evaluate, ${skippedCount} skipped due to validation failure)`
);
const totalJobs = passedResults.length;
let completedCount = 0;
let failedCount = 0;
const evaluatedResults: EvaluatedResult[] = [];
// Initialize results with skipped items
for (const result of results) {
if (result.validationErrors.length > 0) {
evaluatedResults.push({
...result,
evaluationResult: {
pass: false,
reason: "Schema validation failure",
issues: [
{
issue: result.validationErrors.join("\n"),
severity: "criticalSchema",
},
],
overallSeverity: "criticalSchema",
},
});
} else if (!result.components) {
evaluatedResults.push({ ...result });
}
}
if (totalJobs === 0) {
logger.info("Phase 3: Evaluation Complete (No items to evaluate)");
return evaluatedResults;
}
const progressInterval = setInterval(() => {
const queuedCount = rateLimiter.waitingCount;
const inProgressCount =
totalJobs - completedCount - failedCount - queuedCount;
const pct = Math.round(
((completedCount + failedCount) / totalJobs) * 100
);
process.stderr.write(
`\r[Phase 3] Progress: ${pct}% | Completed: ${completedCount} | In Progress: ${inProgressCount} | Queued: ${queuedCount} | Failed: ${failedCount} `
);
}, 1000);
const promises = passedResults.map((result) =>
this.runJob(result).then((evalResult) => {
if (evalResult.evaluationResult) {
completedCount++;
} else {
failedCount++; // Failed to run evaluation flow (e.g. error)
}
evaluatedResults.push(evalResult);
return evalResult;
})
);
await Promise.all(promises);
clearInterval(progressInterval);
process.stderr.write("\n");
logger.info("Phase 3: Evaluation Complete");
return evaluatedResults;
}
private async runJob(result: ValidatedResult): Promise<EvaluatedResult> {
const maxEvalRetries = 3;
let evaluationResult:
| {
pass: boolean;
reason: string;
issues?: { issue: string; severity: IssueSeverity }[];
}
| undefined;
for (let evalRetry = 0; evalRetry < maxEvalRetries; evalRetry++) {
try {
evaluationResult = await evaluationFlow({
originalPrompt: result.prompt.promptText,
generatedOutput: result.rawText || "",
evalModel: this.evalModel,
schemas: this.schemas,
});
break;
} catch (e: any) {
if (evalRetry === maxEvalRetries - 1) {
logger.warn(
`Evaluation failed for ${result.prompt.name} run ${result.runNumber}: ${e.message}`
);
evaluationResult = {
pass: false,
reason: `Evaluation flow failed: ${e.message}`,
};
} else {
await new Promise((resolve) =>
setTimeout(resolve, 1000 * Math.pow(2, evalRetry))
);
}
}
}
let overallSeverity: IssueSeverity | undefined;
if (evaluationResult && !evaluationResult.pass && evaluationResult.issues) {
const severities = evaluationResult.issues.map((i) => i.severity);
if (severities.includes("critical")) {
overallSeverity = "critical";
} else if (severities.includes("significant")) {
overallSeverity = "significant";
} else if (severities.includes("minor")) {
overallSeverity = "minor";
}
}
if (this.outputDir && evaluationResult) {
this.saveEvaluation(result, evaluationResult, overallSeverity);
}
return {
...result,
evaluationResult: evaluationResult
? { ...evaluationResult, overallSeverity }
: undefined,
};
}
private saveEvaluation(
result: ValidatedResult,
evaluationResult: {
pass: boolean;
reason: string;
issues?: { issue: string; severity: IssueSeverity }[];
evalPrompt?: string;
},
overallSeverity?: IssueSeverity
) {
if (!this.outputDir) return;
// Only save if the evaluation failed
if (evaluationResult.pass) return;
const modelDir = path.join(
this.outputDir,
`output-${result.modelName.replace(/[\/:]/g, "_")}`
);
const detailsDir = path.join(modelDir, "details");
fs.writeFileSync(
path.join(
detailsDir,
`${result.prompt.name}.${result.runNumber}.failed.yaml`
),
yaml.dump({ ...evaluationResult, overallSeverity })
);
if (evaluationResult.evalPrompt) {
fs.writeFileSync(
path.join(
detailsDir,
`${result.prompt.name}.${result.runNumber}.eval_prompt.txt`
),
evaluationResult.evalPrompt
);
}
}
}

View File

@@ -0,0 +1,148 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { z } from "genkit";
import { ai } from "./ai";
import { ModelConfiguration } from "./models";
import { rateLimiter } from "./rateLimiter";
import { logger } from "./logger";
// Define a UI component generator flow
export const componentGeneratorFlow = ai.defineFlow(
{
name: "componentGeneratorFlow",
inputSchema: z.object({
prompt: z.string(),
modelConfig: z.any(), // Ideally, we'd have a Zod schema for ModelConfiguration
schemas: z.any(),
catalogRules: z.string().optional(),
}),
outputSchema: z.any(),
},
async ({ prompt, modelConfig, schemas, catalogRules }) => {
const schemaDefs = Object.values(schemas)
.map((s: any) => JSON.stringify(s, null, 2))
.join("\n\n");
const fullPrompt = `You are an AI assistant. Based on the following request, generate a stream of JSON messages that conform to the provided JSON Schemas.
The output MUST be a series of JSON objects, each enclosed in a markdown code block (or a single block with multiple objects).
Standard Instructions:
1. Generate a 'createSurface' message with surfaceId 'main' and catalogId 'https://a2ui.dev/specification/0.9/standard_catalog_definition.json'.
2. Generate a 'updateComponents' message with surfaceId 'main' containing the requested UI.
3. Ensure all component children are referenced by ID (using the 'children' or 'child' property with IDs), NOT nested inline as objects.
4. If the request involves data binding, you may also generate 'updateDataModel' messages.
5. Among the 'updateComponents' messages in the output, there MUST be one root component with id: 'root'.
6. Components need to be nested within a root layout container (Column, Row). No need to add an extra container if the root is already a layout container.
7. There shouldn't be any orphaned components: no components should be generated which don't have a parent, except for the root component.
8. Do NOT output a list of lists (e.g. [[...]]). Output individual JSON objects separated by newlines.
9. STRICTLY follow the JSON Schemas. Do NOT add any properties that are not defined in the schema. Ensure ALL required properties are present.
10. Do NOT invent data bindings or action contexts. Only use them if the prompt explicitly asks for them.
11. Read the 'description' field of each component in the schema carefully. It contains critical usage instructions (e.g. regarding labels, single child limits, and layout behavior) that you MUST follow.
12. Do NOT define components inline inside 'child' or 'children'. Always use a string ID referencing a separate component definition.
13. Do NOT use a 'style' property. Use standard properties like 'alignment', 'distribution', 'usageHint', etc.
14. Do NOT invent properties that are not in the schema. Check the 'properties' list for each component type.
${catalogRules ? `\nInstructions specific to this catalog:\n${catalogRules}` : ""}
Schemas:
${schemaDefs}
Request:
${prompt}
`;
const estimatedInputTokens = Math.ceil(fullPrompt.length / 2.5);
await rateLimiter.acquirePermit(
modelConfig as ModelConfiguration,
estimatedInputTokens
);
// Generate text response
let response;
const startTime = Date.now();
try {
response = await ai.generate({
prompt: fullPrompt,
model: modelConfig.model,
config: modelConfig.config,
});
} catch (e) {
logger.error(`Error during ai.generate: ${e}`);
rateLimiter.reportError(modelConfig as ModelConfiguration, e);
throw e;
}
const latency = Date.now() - startTime;
if (!response) throw new Error("Failed to generate component");
let candidate = (response as any).candidates?.[0];
// Fallback for different response structure (e.g. Genkit 0.9+ or specific model adapters)
if (!candidate && (response as any).message) {
const message = (response as any).message;
candidate = {
index: 0,
content: message.content,
finishReason: "STOP", // Assume STOP if not provided in this format
message: message,
};
}
if (!candidate) {
logger.error(
`No candidates returned in response. Full response: ${JSON.stringify(response, null, 2)}`
);
throw new Error("No candidates returned");
}
if (
candidate.finishReason !== "STOP" &&
candidate.finishReason !== undefined
) {
logger.warn(
`Model finished with reason: ${candidate.finishReason}. Content: ${JSON.stringify(
candidate.content
)}`
);
}
// Record token usage (adjusting for actual usage)
const inputTokens = response.usage?.inputTokens || 0;
const outputTokens = response.usage?.outputTokens || 0;
const totalTokens = inputTokens + outputTokens;
// We already recorded estimatedInputTokens. We need to record the difference.
// If actual > estimated, we record the positive difference.
// If actual < estimated, we technically over-counted, but RateLimiter doesn't support negative adjustments yet.
// For safety, we just record any *additional* tokens if we under-estimated.
// And we definitely record the output tokens.
const additionalInputTokens = Math.max(
0,
inputTokens - estimatedInputTokens
);
const tokensToAdd = additionalInputTokens + outputTokens;
if (tokensToAdd > 0) {
rateLimiter.recordUsage(
modelConfig as ModelConfiguration,
tokensToAdd,
false
);
}
return { text: response.text, latency };
}
);

View File

@@ -0,0 +1,211 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { componentGeneratorFlow } from "./generation_flow";
import { ModelConfiguration } from "./models";
import { TestPrompt } from "./prompts";
import { GeneratedResult } from "./types";
import { extractJsonFromMarkdown } from "./utils";
import { rateLimiter } from "./rateLimiter";
import { logger } from "./logger";
import * as fs from "fs";
import * as path from "path";
export class Generator {
constructor(
private schemas: any,
private outputDir?: string,
private catalogRules?: string
) {}
async run(
prompts: TestPrompt[],
models: ModelConfiguration[],
runsPerPrompt: number
): Promise<GeneratedResult[]> {
const totalJobs = prompts.length * models.length * runsPerPrompt;
let completedCount = 0;
let failedCount = 0;
const results: GeneratedResult[] = [];
const promises: Promise<GeneratedResult>[] = [];
logger.info(`Starting Phase 1: Generation (${totalJobs} jobs)`);
const progressInterval = setInterval(() => {
const queuedCount = rateLimiter.waitingCount;
const inProgressCount =
totalJobs - completedCount - failedCount - queuedCount;
const pct =
totalJobs > 0
? Math.round(((completedCount + failedCount) / totalJobs) * 100)
: 0;
process.stderr.write(
`\r[Phase 1] Progress: ${pct}% | Completed: ${completedCount} | In Progress: ${inProgressCount} | Queued: ${queuedCount} | Failed: ${failedCount} `
);
}, 1000);
for (const model of models) {
for (const prompt of prompts) {
for (let i = 1; i <= runsPerPrompt; i++) {
promises.push(
this.runJob(model, prompt, i).then((result) => {
if (result.error) {
failedCount++;
} else {
completedCount++;
}
results.push(result);
return result;
})
);
}
}
}
await Promise.all(promises);
clearInterval(progressInterval);
process.stderr.write("\n");
logger.info("Phase 1: Generation Complete");
return results;
}
private async runJob(
model: ModelConfiguration,
prompt: TestPrompt,
runIndex: number,
retryCount: number = 0
): Promise<GeneratedResult> {
const startTime = Date.now();
try {
const output: any = await componentGeneratorFlow({
prompt: prompt.promptText,
modelConfig: model,
schemas: this.schemas,
catalogRules: this.catalogRules,
});
const text = output?.text;
const latency = output?.latency || 0;
let components: any[] = [];
let error = null;
if (text) {
try {
components = extractJsonFromMarkdown(text);
if (this.outputDir) {
this.saveArtifacts(model, prompt, runIndex, text, components);
}
} catch (e) {
error = e;
if (this.outputDir) {
this.saveError(model, prompt, runIndex, text, e);
}
}
} else {
error = new Error("No output text returned from model");
}
return {
modelName: model.name,
prompt,
runNumber: runIndex,
rawText: text,
components,
latency,
error,
};
} catch (error: any) {
if (retryCount < 1) {
// Simple retry for tool errors
return this.runJob(model, prompt, runIndex, retryCount + 1);
}
return {
modelName: model.name,
prompt,
runNumber: runIndex,
latency: Date.now() - startTime,
error,
};
}
}
private saveArtifacts(
model: ModelConfiguration,
prompt: TestPrompt,
runIndex: number,
text: string,
components: any[]
) {
if (!this.outputDir) return;
const modelDir = path.join(
this.outputDir,
`output-${model.name.replace(/[\/:]/g, "_")}`
);
const detailsDir = path.join(modelDir, "details");
fs.mkdirSync(detailsDir, { recursive: true });
fs.writeFileSync(
path.join(detailsDir, `${prompt.name}.${runIndex}.json`),
JSON.stringify(components, null, 2)
);
const samplePath = path.join(
detailsDir,
`${prompt.name}.${runIndex}.sample`
);
const yamlHeader = `---
description: ${prompt.description}
name: ${prompt.name}
prompt: |
${prompt.promptText
.split("\n")
.map((line) => " " + line)
.join("\n")}
---
`;
let jsonlBody = "";
for (const comp of components) {
jsonlBody += JSON.stringify(comp) + "\n";
}
fs.writeFileSync(samplePath, yamlHeader + jsonlBody);
}
private saveError(
model: ModelConfiguration,
prompt: TestPrompt,
runIndex: number,
text: string | undefined,
error: any
) {
if (!this.outputDir) return;
const modelDir = path.join(
this.outputDir,
`output-${model.name.replace(/[\/:]/g, "_")}`
);
const detailsDir = path.join(modelDir, "details");
fs.mkdirSync(detailsDir, { recursive: true });
fs.writeFileSync(
path.join(detailsDir, `${prompt.name}.${runIndex}.output.txt`),
text || "No output"
);
fs.writeFileSync(
path.join(detailsDir, `${prompt.name}.${runIndex}.error.json`),
JSON.stringify({ message: error.message, stack: error.stack }, null, 2)
);
}
}

View File

@@ -0,0 +1,493 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as fs from "fs";
import * as path from "path";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { logger, setupLogger } from "./logger";
import { modelsToTest } from "./models";
import { prompts, TestPrompt } from "./prompts";
import { Generator } from "./generator";
import { Validator } from "./validator";
import { Evaluator } from "./evaluator";
import { EvaluatedResult } from "./types";
import { analysisFlow } from "./analysis_flow";
const schemaFiles = [
"../../json/common_types.json",
"../../json/standard_catalog_definition.json",
"../../json/server_to_client.json",
];
function loadSchemas(): Record<string, any> {
const schemas: Record<string, any> = {};
for (const file of schemaFiles) {
const schemaString = fs.readFileSync(path.join(__dirname, file), "utf-8");
const schema = JSON.parse(schemaString);
schemas[path.basename(file)] = schema;
}
return schemas;
}
function generateSummary(
results: EvaluatedResult[],
analysisResults: Record<string, string>
): string {
const promptNameWidth = 40;
const latencyWidth = 20;
const failedRunsWidth = 15;
const severityWidth = 15;
// Group by model
const resultsByModel: Record<string, EvaluatedResult[]> = {};
for (const result of results) {
if (!resultsByModel[result.modelName]) {
resultsByModel[result.modelName] = [];
}
resultsByModel[result.modelName].push(result);
}
let summary = "# Evaluation Summary";
for (const modelName in resultsByModel) {
summary += `\n\n## Model: ${modelName}\n\n`;
const header = `| ${"Prompt Name".padEnd(
promptNameWidth
)} | ${"Avg Latency (ms)".padEnd(latencyWidth)} | ${"Schema Fail".padEnd(
failedRunsWidth
)} | ${"Eval Fail".padEnd(failedRunsWidth)} | ${"Minor".padEnd(
severityWidth
)} | ${"Significant".padEnd(severityWidth)} | ${"Critical".padEnd(
severityWidth
)} |`;
const divider = `|${"-".repeat(promptNameWidth + 2)}|${"-".repeat(
latencyWidth + 2
)}|${"-".repeat(failedRunsWidth + 2)}|${"-".repeat(
failedRunsWidth + 2
)}|${"-".repeat(severityWidth + 2)}|${"-".repeat(
severityWidth + 2
)}|${"-".repeat(severityWidth + 2)}|`;
summary += header;
summary += `\n${divider}`;
const modelResults = resultsByModel[modelName];
const promptsInModel = modelResults.reduce(
(acc, result) => {
if (!acc[result.prompt.name]) {
acc[result.prompt.name] = [];
}
acc[result.prompt.name].push(result);
return acc;
},
{} as Record<string, EvaluatedResult[]>
);
const sortedPromptNames = Object.keys(promptsInModel).sort();
for (const promptName of sortedPromptNames) {
const runs = promptsInModel[promptName];
const totalRuns = runs.length;
const schemaFailedRuns = runs.filter(
(r) => r.error || r.validationErrors.length > 0
).length;
const evalFailedRuns = runs.filter(
(r) => r.evaluationResult && !r.evaluationResult.pass
).length;
const totalLatency = runs.reduce((acc, r) => acc + r.latency, 0);
const avgLatency = (totalLatency / totalRuns).toFixed(0);
const schemaFailedStr =
schemaFailedRuns > 0 ? `${schemaFailedRuns} / ${totalRuns}` : "";
const evalFailedStr =
evalFailedRuns > 0 ? `${evalFailedRuns} / ${totalRuns}` : "";
let minorCount = 0;
let significantCount = 0;
let criticalCount = 0;
for (const r of runs) {
if (r.evaluationResult?.issues) {
for (const issue of r.evaluationResult.issues) {
if (issue.severity === "minor") minorCount++;
else if (issue.severity === "significant") significantCount++;
else if (issue.severity === "critical") criticalCount++;
}
}
}
const minorStr = minorCount > 0 ? `${minorCount}` : "";
const significantStr = significantCount > 0 ? `${significantCount}` : "";
const criticalStr = criticalCount > 0 ? `${criticalCount}` : "";
summary += `\n| ${promptName.padEnd(
promptNameWidth
)} | ${avgLatency.padEnd(latencyWidth)} | ${schemaFailedStr.padEnd(
failedRunsWidth
)} | ${evalFailedStr.padEnd(failedRunsWidth)} | ${minorStr.padEnd(
severityWidth
)} | ${significantStr.padEnd(severityWidth)} | ${criticalStr.padEnd(
severityWidth
)} |`;
}
const totalRunsForModel = modelResults.length;
const successfulRuns = modelResults.filter(
(r) =>
!r.error &&
r.validationErrors.length === 0 &&
(!r.evaluationResult || r.evaluationResult.pass)
).length;
const successPercentage =
totalRunsForModel === 0
? "0.0"
: ((successfulRuns / totalRunsForModel) * 100.0).toFixed(1);
summary += `\n\n**Total successful runs:** ${successfulRuns} / ${totalRunsForModel} (${successPercentage}% success)`;
if (analysisResults[modelName]) {
summary += `\n\n### Failure Analysis\n\n${analysisResults[modelName]}`;
}
}
summary += "\n\n---\n\n## Overall Summary\n";
const totalRuns = results.length;
const totalToolErrorRuns = results.filter((r) => r.error).length;
const totalRunsWithAnyFailure = results.filter(
(r) =>
r.error ||
r.validationErrors.length > 0 ||
(r.evaluationResult && !r.evaluationResult.pass)
).length;
const modelsWithFailures = [
...new Set(
results
.filter(
(r) =>
r.error ||
r.validationErrors.length > 0 ||
(r.evaluationResult && !r.evaluationResult.pass)
)
.map((r) => r.modelName)
),
].join(", ");
let totalMinor = 0;
let totalSignificant = 0;
let totalCritical = 0;
let totalCriticalSchema = 0;
for (const r of results) {
if (r.evaluationResult?.issues) {
for (const issue of r.evaluationResult.issues) {
if (issue.severity === "minor") totalMinor++;
else if (issue.severity === "significant") totalSignificant++;
else if (issue.severity === "critical") totalCritical++;
else if (issue.severity === "criticalSchema") totalCriticalSchema++;
}
}
}
summary += `\n- **Total tool failures:** ${totalToolErrorRuns} / ${totalRuns}`;
const successPercentage =
totalRuns === 0
? "0.0"
: (((totalRuns - totalRunsWithAnyFailure) / totalRuns) * 100.0).toFixed(
1
);
summary += `\n- **Number of runs with any failure (tool error, validation, or eval):** ${totalRunsWithAnyFailure} / ${totalRuns} (${successPercentage}% success)`;
summary += `\n- **Severity Breakdown:**`;
summary += `\n - **Minor:** ${totalMinor}`;
summary += `\n - **Significant:** ${totalSignificant}`;
summary += `\n - **Critical (Eval):** ${totalCritical}`;
summary += `\n - **Critical (Schema):** ${totalCriticalSchema}`;
const latencies = results.map((r) => r.latency).sort((a, b) => a - b);
const totalLatency = latencies.reduce((acc, l) => acc + l, 0);
const meanLatency =
totalRuns > 0 ? (totalLatency / totalRuns).toFixed(0) : "0";
let medianLatency = 0;
if (latencies.length > 0) {
const mid = Math.floor(latencies.length / 2);
if (latencies.length % 2 === 0) {
medianLatency = (latencies[mid - 1] + latencies[mid]) / 2;
} else {
medianLatency = latencies[mid];
}
}
summary += `\n- **Mean Latency:** ${meanLatency} ms`;
summary += `\n- **Median Latency:** ${medianLatency} ms`;
if (modelsWithFailures) {
summary += `\n- **Models with at least one failure:** ${modelsWithFailures}`;
}
return summary;
}
async function main() {
const argv = await yargs(hideBin(process.argv))
.option("log-level", {
type: "string",
description: "Set the logging level",
default: "info",
choices: ["debug", "info", "warn", "error"],
})
.option("results", {
type: "string",
description:
"Directory to keep output files. If not specified, uses results/output-<model>. If specified, uses the provided directory (appending output-<model>).",
coerce: (arg) => (arg === undefined ? true : arg),
default: true,
})
.option("runs-per-prompt", {
type: "number",
description: "Number of times to run each prompt",
default: 1,
})
.option("model", {
type: "string",
array: true,
description: "Filter models by exact name",
default: [],
choices: modelsToTest.map((m) => m.name),
})
.option("prompt", {
type: "string",
array: true,
description: "Filter prompts by name prefix",
})
.option("eval-model", {
type: "string",
description: "Model to use for evaluation",
default: "gemini-2.5-flash",
choices: modelsToTest.map((m) => m.name),
})
.option("clean-results", {
type: "boolean",
description: "Clear the output directory before starting",
default: false,
})
.help()
.alias("h", "help")
.strict().argv;
// Filter Models
let filteredModels = modelsToTest;
if (argv.model && argv.model.length > 0) {
const modelNames = argv.model as string[];
filteredModels = modelsToTest.filter((m) => modelNames.includes(m.name));
if (filteredModels.length === 0) {
logger.error(`No models found matching: ${modelNames.join(", ")}.`);
process.exit(1);
}
}
// Filter Prompts
let filteredPrompts = prompts;
if (argv.prompt && argv.prompt.length > 0) {
const promptPrefixes = argv.prompt as string[];
filteredPrompts = prompts.filter((p) =>
promptPrefixes.some((prefix) => p.name.startsWith(prefix))
);
if (filteredPrompts.length === 0) {
logger.error(
`No prompt found with prefix "${promptPrefixes.join(", ")}".`
);
process.exit(1);
}
}
// Determine Output Directory (Base)
// Note: Generator/Validator/Evaluator handle per-model subdirectories if outputDir is provided.
// But we need a base output dir to pass to them.
let resultsBaseDir: string | undefined;
const resultsArg = argv.results;
if (typeof resultsArg === "string") {
resultsBaseDir = resultsArg;
} else if (resultsArg === true) {
resultsBaseDir = "results";
}
// Clean Results
if (
argv["clean-results"] &&
resultsBaseDir &&
fs.existsSync(resultsBaseDir)
) {
// Only clean if we are using the default structure or explicit path
// We should be careful not to delete root if user passed "/" (unlikely but possible)
// For safety, let's iterate over models and clean their specific dirs if they exist
// Or just clean the base dir if it looks like our results dir.
// The previous logic cleaned `outputDir` which was per-model.
// Here we might want to clean the whole results dir if it's the default "results".
if (resultsBaseDir === "results") {
fs.rmSync(resultsBaseDir, { recursive: true, force: true });
} else {
// If custom dir, maybe just clean it?
// User asked to clean results.
fs.rmSync(resultsBaseDir, { recursive: true, force: true });
}
}
// Setup Logger (Global)
// We need to setup logger to write to file?
// Previous logic setup logger per model output dir.
// Now we have multiple models potentially.
// We can setup logger to write to stdout/stderr primarily, and maybe a global log file?
// Or we can setup logger to NOT write to file, and let phases write their own logs?
// The `setupLogger` function takes an outputDir.
// If we have multiple models, where do we log?
// Maybe just log to the first model's dir or a "latest" dir?
// Or just console for now if multiple models?
// If single model, use that model's dir.
if (resultsBaseDir) {
if (filteredModels.length === 1) {
const modelDirName = `output-${filteredModels[0].name.replace(/[\/:]/g, "_")}`;
setupLogger(path.join(resultsBaseDir, modelDirName), argv["log-level"]);
} else {
// If multiple models, maybe just log to console or a shared log?
// For now, let's just use console logging (default if setupLogger not called with dir?)
// Actually setupLogger needs a dir to create 'eval.log'.
// Let's create a 'combined' log if multiple models?
// Or just skip file logging for multiple models for now.
setupLogger(undefined, argv["log-level"]);
}
} else {
setupLogger(undefined, argv["log-level"]);
}
const schemas = loadSchemas();
const catalogRulesPath = path.join(
__dirname,
"../../json/standard_catalog_rules.txt"
);
let catalogRules: string | undefined;
if (fs.existsSync(catalogRulesPath)) {
catalogRules = fs.readFileSync(catalogRulesPath, "utf-8");
} else {
logger.warn(
`Catalog rules file not found at ${catalogRulesPath}. Proceeding without specific catalog rules.`
);
}
// Phase 1: Generation
const generator = new Generator(schemas, resultsBaseDir, catalogRules);
const generatedResults = await generator.run(
filteredPrompts,
filteredModels,
argv["runs-per-prompt"]
);
// Phase 2: Validation
const validator = new Validator(schemas, resultsBaseDir);
const validatedResults = await validator.run(generatedResults);
// Phase 3: Evaluation
const evaluator = new Evaluator(schemas, argv["eval-model"], resultsBaseDir);
const evaluatedResults = await evaluator.run(validatedResults);
// Phase 4: Failure Analysis
const analysisResults: Record<string, string> = {};
const resultsByModel: Record<string, EvaluatedResult[]> = {};
for (const result of evaluatedResults) {
if (!resultsByModel[result.modelName]) {
resultsByModel[result.modelName] = [];
}
resultsByModel[result.modelName].push(result);
}
for (const modelName in resultsByModel) {
const modelResults = resultsByModel[modelName];
const failures = modelResults
.filter(
(r) =>
r.error ||
r.validationErrors.length > 0 ||
(r.evaluationResult && !r.evaluationResult.pass)
)
.map((r) => {
let failureType = "Unknown";
let reason = "Unknown";
let issues: string[] = [];
if (r.error) {
failureType = "Tool Error";
reason = r.error.message || String(r.error);
} else if (r.validationErrors.length > 0) {
failureType = "Schema Validation";
reason = "Schema validation failed";
issues = r.validationErrors;
} else if (r.evaluationResult && !r.evaluationResult.pass) {
failureType = "Evaluation Failure";
reason = r.evaluationResult.reason;
if (r.evaluationResult.issues) {
issues = r.evaluationResult.issues.map(
(i) => `${i.severity}: ${i.issue}`
);
}
}
return {
promptName: r.prompt.name,
runNumber: r.runNumber,
failureType,
reason,
issues,
};
});
if (failures.length > 0) {
logger.info(`Running failure analysis for model: ${modelName}...`);
try {
const analysis = await analysisFlow({
modelName,
failures,
numRuns: modelResults.length,
evalModel: argv["eval-model"],
});
analysisResults[modelName] = analysis;
} catch (e) {
logger.error(`Failed to run failure analysis for ${modelName}: ${e}`);
analysisResults[modelName] = "Failed to run analysis.";
}
}
}
// Summary
const summary = generateSummary(evaluatedResults, analysisResults);
logger.info(summary);
if (resultsBaseDir) {
// Save summary to each model dir?
// Or just one summary?
// Previous logic saved summary.md in model dir.
for (const model of filteredModels) {
const modelDirName = `output-${model.name.replace(/[\/:]/g, "_")}`;
const modelDir = path.join(resultsBaseDir, modelDirName);
if (fs.existsSync(modelDir)) {
fs.writeFileSync(path.join(modelDir, "summary.md"), summary);
}
}
}
}
if (require.main === module) {
main().catch(console.error);
}

View File

@@ -0,0 +1,70 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as winston from "winston";
import * as path from "path";
let fileTransport: winston.transport | null = null;
const consoleTransport = new winston.transports.Console({
level: "info", // Default to info, can be updated later
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message }) => {
// Clear the current line (where progress bar might be) before logging
// \r clears the line, \x1b[K clears from cursor to end of line
return `\r\x1b[K${timestamp} [${level}]: ${message}`;
})
),
});
// Create a default logger instance that logs to console only initially
export const logger = winston.createLogger({
level: "debug", // Allow all logs to flow through (transports can filter)
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(({ timestamp, level, message }) => {
return `${timestamp} [${level}]: ${message}`;
})
),
transports: [consoleTransport],
});
export function setupLogger(outputDir: string | undefined, logLevel: string) {
// Ensure the global level allows debug logs so they reach the file transport
logger.level = "debug";
// Update Console transport level to match user preference directly
consoleTransport.level = logLevel;
if (fileTransport) {
logger.remove(fileTransport);
fileTransport = null;
}
if (outputDir) {
fileTransport = new winston.transports.File({
filename: path.join(outputDir, "output.log"),
level: "debug", // Always capture everything in the file
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
});
logger.add(fileTransport);
}
}

View File

@@ -0,0 +1,93 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { googleAI } from "@genkit-ai/google-genai";
import { openAI } from "@genkit-ai/compat-oai/openai";
import { claude35Haiku, claude4Sonnet } from "genkitx-anthropic";
export interface ModelConfiguration {
model: any;
name: string;
config?: any;
requestsPerMinute?: number;
tokensPerMinute?: number;
}
export const modelsToTest: ModelConfiguration[] = [
{
model: openAI.model("gpt-5.1"),
name: "gpt-5.1",
config: { reasoning_effort: "minimal" },
requestsPerMinute: 500,
tokensPerMinute: 30000,
},
{
model: openAI.model("gpt-5-mini"),
name: "gpt-5-mini",
config: { reasoning_effort: "minimal" },
requestsPerMinute: 500,
tokensPerMinute: 500000,
},
{
model: openAI.model("gpt-5-nano"),
name: "gpt-5-nano",
config: {},
requestsPerMinute: 500,
tokensPerMinute: 200000,
},
{
model: googleAI.model("gemini-2.5-pro"),
name: "gemini-2.5-pro",
config: { thinkingConfig: { thinkingBudget: 1000 } },
requestsPerMinute: 150,
tokensPerMinute: 2000000,
},
{
model: googleAI.model("gemini-3-pro-preview"),
name: "gemini-3-pro",
config: { thinkingConfig: { thinkingBudget: 1000 } },
requestsPerMinute: 50,
tokensPerMinute: 1000000,
},
{
model: googleAI.model("gemini-2.5-flash"),
name: "gemini-2.5-flash",
config: { thinkingConfig: { thinkingBudget: 0 } },
requestsPerMinute: 1000,
tokensPerMinute: 1000000,
},
{
model: googleAI.model("gemini-2.5-flash-lite"),
name: "gemini-2.5-flash-lite",
config: { thinkingConfig: { thinkingBudget: 0 } },
requestsPerMinute: 4000,
tokensPerMinute: 1200000,
},
{
model: claude4Sonnet,
name: "claude-4-sonnet",
config: {},
requestsPerMinute: 50,
tokensPerMinute: 30000,
},
{
model: claude35Haiku,
name: "claude-35-haiku",
config: {},
requestsPerMinute: 50,
tokensPerMinute: 50000,
},
];

View File

@@ -0,0 +1,373 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface TestPrompt {
name: string;
description: string;
promptText: string;
}
export const prompts: TestPrompt[] = [
{
name: "deleteSurface",
description: "A DeleteSurface message to remove a UI surface.",
promptText: `Generate a JSON message containing a deleteSurface for the surface 'dashboard-surface-1'.`,
},
{
name: "dogBreedGenerator",
description:
"A prompt to generate a UI for a dog breed information and generator tool.",
promptText: `Use a surfaceId of 'main'. Then, generate a 'createSurface' message followed by 'updateComponents' message to describe the following UI:
A vertical list with:
- Dog breed information
- Dog generator
The dog breed information is a card, which contains a title “Famous Dog breeds”, a header image, and a horizontal list of images of different dog breeds. The list information should be in the data model at /breeds.
The dog generator is another card which is a form that generates a fictional dog breed with a description
- Title
- Description text explaining what it is
- Dog breed name (text input)
- Number of legs (number input)
- Button called “Generate” which takes the data above and generates a new dog description
- Skills (ChoicePicker component, usageHint 'multipleSelection')
- A divider
- A section which shows the generated content
`,
},
{
name: "loginForm",
description:
'A simple login form with username, password, a "remember me" checkbox, and a submit button.',
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a login form. It should have a "Login" text (usageHint 'h1'), two text fields for username and password (bound to /login/username and /login/password), a checkbox for "Remember Me" (bound to /login/rememberMe), and a "Sign In" button. The button should trigger a 'login' action, passing the username, password, and rememberMe status in the dynamicContext.`,
},
{
name: "productGallery",
description: "A gallery of products using a list with a template.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a product gallery. It should display a list of products from the data model at '/products'. Use a template for the list items. Each item should be a Card containing a Column. The Column should contain an Image (from '/products/item/imageUrl'), a Text component for the product name (from '/products/item/name'), and a Button labeled "Add to Cart". The button's action should be 'addToCart' and include a context with the product ID, for example, 'productId': 'static-id-123' (use this exact literal string). You should create a template component and then a list that uses it.`,
},
{
name: "productGalleryData",
description:
"An updateDataModel message to populate the product gallery data.",
promptText: `Generate a 'createSurface' message with surfaceId 'main', followed by an updateDataModel message to populate the data model for the product gallery. The update should target the path '/products' and include at least two products. Each product in the map should have keys 'id', 'name', and 'imageUrl'. For example:
{
"product1": {
"id": "product1",
"name": "Awesome Gadget",
"imageUrl": "https://example.com/gadget.jpg"
}
}`,
},
{
name: "settingsPage",
description: "A settings page with tabs and a modal dialog.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a user settings page. Use a Tabs component with two tabs: "Profile" and "Notifications". The "Profile" tab should contain a simple column with a text field for the user's name. The "Notifications" tab should contain a checkbox for "Enable email notifications". Also, include a Modal component. The modal's entry point should be a button labeled "Delete Account", and its content should be a column with a confirmation text and two buttons: "Confirm Deletion" and "Cancel".`,
},
{
name: "updateDataModel",
description: "An updateDataModel message to update user data.",
promptText: `Generate a 'createSurface' message with surfaceId 'main', followed by an updateDataModel message. This is used to update the client's data model. The scenario is that a user has just logged in, and we need to populate their profile information. Create a single data model update message to set '/user/name' to "John Doe" and '/user/email' to "john.doe@example.com".`,
},
{
name: "animalKingdomExplorer",
description: "A simple, explicit UI to display a hierarchy of animals.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a simplified UI explorer for the Animal Kingdom.
The UI must have a main 'Text' (usageHint 'h1') with the text "Simple Animal Explorer".
Below the text heading, create a 'Tabs' component with exactly three tabs: "Mammals", "Birds", and "Reptiles".
Each tab's content should be a 'Column'. The first item in each column must be a 'TextField' with the label "Search...". Below the search field, display the hierarchy for that tab using nested 'Card' components.
The exact hierarchy to create is as follows:
**1. "Mammals" Tab:**
- A 'Card' for the Class "Mammalia".
- Inside the "Mammalia" card, create two 'Card's for the following Orders:
- A 'Card' for the Order "Carnivora". Inside this, create 'Card's for these three species: "Lion", "Tiger", "Wolf".
- A 'Card' for the Order "Artiodactyla". Inside this, create 'Card's for these two species: "Giraffe", "Hippopotamus".
**2. "Birds" Tab:**
- A 'Card' for the Class "Aves".
- Inside the "Aves" card, create three 'Card's for the following Orders:
- A 'Card' for the Order "Accipitriformes". Inside this, create a 'Card' for the species: "Bald Eagle".
- A 'Card' for the Order "Struthioniformes". Inside this, create a 'Card' for the species: "Ostrich".
- A 'Card' for the Order "Sphenisciformes". Inside this, create a 'Card' for the species: "Penguin".
**3. "Reptiles" Tab:**
- A 'Card' for the Class "Reptilia".
- Inside the "Reptilia" card, create two 'Card's for the following Orders:
- A 'Card' for the Order "Crocodilia". Inside this, create a 'Card' for the species: "Nile Crocodile".
- A 'Card' for the Order "Squamata". Inside this, create 'Card's for these two species: "Komodo Dragon", "Ball Python".
Each species card must contain a 'Row' with an 'Image' and a 'Text' component for the species name. Do not add any other components.
Each Class and Order card must contain a 'Column' with a 'Text' component with the name, and then the children cards below.
IMPORTANT: Do not skip any of the classes, orders, or species above. Include every item that is mentioned.
`,
},
{
name: "recipeCard",
description: "A UI to display a recipe with ingredients and instructions.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a recipe card. It should have a 'Text' (usageHint 'h1') for the recipe title, "Classic Lasagna". Below the title, an 'Image' of the lasagna. Then, a 'Row' containing two 'Column's. The first column has a 'Text' (usageHint 'h2') "Ingredients" and a 'List' of ingredients (use 'Text' components for items: "Pasta", "Cheese", "Sauce"). The second column has a 'Text' (usageHint 'h2') "Instructions" and a 'List' of step-by-step instructions (use 'Text' components: "Boil pasta", "Layer ingredients", "Bake"). Finally, a 'Button' at the bottom labeled "Watch Video Tutorial".`,
},
{
name: "musicPlayer",
description: "A simple music player UI.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a music player. It should be a 'Card' containing a 'Column'. Inside the column, there's an 'Image' for the album art, a 'Text' for the song title "Bohemian Rhapsody", another 'Text' for the artist "Queen", a 'Slider' labeled "Progress", and a 'Row' with three 'Button' components. Each Button should have a child 'Text' component. The Text components should have the labels "Previous", "Play", and "Next" respectively.`,
},
{
name: "weatherForecast",
description: "A UI to display the weather forecast.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a weather forecast UI. It should have a 'Text' (usageHint 'h1') with the city name, "New York". Below it, a 'Row' with the current temperature as a 'Text' component ("68°F") and an 'Image' for the weather icon (e.g., a sun). Below that, a 'Divider'. Then, a 'List' component to display the 5-day forecast. Each item in the list should be a 'Row' with the day, an icon, and high/low temperatures.`,
},
{
name: "surveyForm",
description: "A customer feedback survey form.",
promptText: `Create a customer feedback survey form. It should have a 'Text' (usageHint 'h1') "Customer Feedback". Then a 'ChoicePicker' (usageHint 'mutuallyExclusive') with label "How would you rate our service?" and options "Excellent", "Good", "Average", "Poor". Then a 'ChoicePicker' (usageHint 'multipleSelection') with label "What did you like?" and options "Product Quality", "Price", "Customer Support". Finally, a 'TextField' with the label "Any other comments?" and a 'Button' labeled "Submit Feedback".`,
},
{
name: "flightBooker",
description: "A form to search for flights.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a flight booking form. It should have a 'Text' (usageHint 'h1') "Book a Flight". Then a 'Row' with two 'TextField's for "Origin" and "Destination". Below that, a 'Row' with two 'DateTimeInput's for "Departure Date" and "Return Date" (initialize with empty values). Add a 'Slider' labeled "Passengers" (min 1, max 10, value 1). Finally, a 'Button' labeled "Search Flights".`,
},
{
name: "dashboard",
description: "A simple dashboard with statistics.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a simple dashboard. It should have a 'Text' (usageHint 'h1') "Sales Dashboard". Below, a 'Row' containing three 'Card's. The first card has a 'Text' "Revenue" and another 'Text' "$50,000". The second card has "New Customers" and "1,200". The third card has "Conversion Rate" and "4.5%".`,
},
{
name: "contactCard",
description: "A UI to display contact information.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a contact card. It should be a 'Card' with a 'Row'. The row contains an 'Image' (as an avatar) and a 'Column'. The column contains a 'Text' for the name "Jane Doe", a 'Text' for the email "jane.doe@example.com", and a 'Text' for the phone number "(123) 456-7890". Below the main row, add a 'Button' labeled "View on Map".`,
},
{
name: "calendarEventCreator",
description: "A form to create a new calendar event.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a calendar event creation form. It should have a 'Text' (usageHint 'h1') "New Event". Include a 'TextField' for the "Event Title". Use a 'Row' for two 'DateTimeInput's for "Start Time" and "End Time" (initialize both with empty values). Add a 'CheckBox' labeled "All-day event". Finally, a 'Row' with two 'Button's: "Save" and "Cancel".`,
},
{
name: "checkoutPage",
description: "A simplified e-commerce checkout page.",
promptText: `Create a simplified e-commerce checkout page. It should have a 'Text' (usageHint 'h1') "Checkout". A 'Column' for shipping info with 'TextField's for "Name", "Address", "City", "Zip Code". A 'Column' for payment info with 'TextField's for "Card Number", "Expiry Date", "CVV". Finally, a 'Text' "Total: $99.99" and a 'Button' "Place Order".`,
},
{
name: "socialMediaPost",
description: "A component representing a social media post.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a social media post. It should be a 'Card' containing a 'Column'. The first item is a 'Row' with an 'Image' (user avatar) and a 'Text' (username "user123"). Below that, a 'Text' component for the post content: "Enjoying the beautiful weather today!". Then, an 'Image' for the main post picture. Finally, a 'Row' with three 'Button's: "Like", "Comment", and "Share".`,
},
{
name: "eCommerceProductPage",
description: "A detailed product page for an e-commerce website.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a product details page.
The main layout should be a 'Row'.
The left side of the row is a 'Column' containing a large main 'Image' of the product, and below it, a 'Row' of three smaller thumbnail 'Image' components.
The right side of the row is another 'Column' for product information:
- A 'Text' (usageHint 'h1') for the product name, "Premium Leather Jacket".
- A 'Text' component for the price, "$299.99".
- A 'Divider'.
- A 'ChoicePicker' (usageHint 'mutuallyExclusive') labeled "Select Size" with options "S", "M", "L", "XL".
- A 'ChoicePicker' (usageHint 'mutuallyExclusive') labeled "Select Color" with options "Black", "Brown", "Red".
- A 'Button' with a 'Text' child "Add to Cart".
- A 'Text' component for the product description below the button.`,
},
{
name: "interactiveDashboard",
description: "A dashboard with filters and data cards.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for an interactive analytics dashboard.
At the top, a 'Text' (usageHint 'h1') "Company Dashboard".
Below the text heading, a 'Card' containing a 'Row' of filter controls:
- A 'DateTimeInput' with a label for "Start Date" (initialize with empty value).
- A 'DateTimeInput' with a label for "End Date" (initialize with empty value).
- A 'Button' labeled "Apply Filters".
Below the filters card, a 'Row' containing two 'Card's for key metrics:
- The first 'Card' has a 'Text' (usageHint 'h2') "Total Revenue" and a 'Text' component showing "$1,234,567".
- The second 'Card' has a 'Text' (usageHint 'h2') "New Users" and a 'Text' component showing "4,321".
Finally, a large 'Card' at the bottom with a 'Text' (usageHint 'h2') "Revenue Over Time" and a placeholder 'Image' with a valid URL to represent a line chart.`,
},
{
name: "travelItinerary",
description: "A multi-day travel itinerary display.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a travel itinerary for a trip to Paris.
It should have a main 'Text' component with usageHint 'h1' and text "Paris Adventure".
Below, use a 'List' to display three days. Each item in the list should be a 'Card'.
- The first 'Card' (Day 1) should contain a 'Text' (usageHint 'h2') "Day 1: Arrival & Eiffel Tower", and a 'List' of activities for that day: "Check into hotel", "Lunch at a cafe", "Visit the Eiffel Tower".
- The second 'Card' (Day 2) should contain a 'Text' (usageHint 'h2') "Day 2: Museums & Culture", and a 'List' of activities: "Visit the Louvre Museum", "Walk through Tuileries Garden", "See the Arc de Triomphe".
- The third 'Card' (Day 3) should contain a 'Text' (usageHint 'h2') "Day 3: Art & Departure", and a 'List' of activities: "Visit Musée d'Orsay", "Explore Montmartre", "Depart from CDG".
Each activity in the inner lists should be a 'Row' containing a 'CheckBox' (to mark as complete) and a 'Text' component with the activity description.`,
},
{
name: "kanbanBoard",
description: "A Kanban-style task tracking board.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a Kanban board. It should have a 'Text' (usageHint 'h1') "Project Tasks". Below, a 'Row' containing three 'Column's representing "To Do", "In Progress", and "Done". Each column should have a 'Text' (usageHint 'h2') header and a list of 'Card's.
- "To Do" column: Card "Research", Card "Design".
- "In Progress" column: Card "Implementation".
- "Done" column: Card "Planning".
Each card should just contain a 'Text' with the task name.`,
},
{
name: "videoCallInterface",
description: "A video conference UI.",
promptText: `Create a video call interface. It should have a 'Text' (usageHint 'h1') "Video Call". A 'Video' component (placeholder URL). Below that, a 'Row' with three 'Button's labeled "Mute", "Camera", and "End Call".`,
},
{
name: "fileBrowser",
description: "A file explorer list.",
promptText: `Create a file browser. It should have a 'Text' (usageHint 'h1') "My Files". A 'List' of 'Row's. Each row has an 'Icon' (folder or attachFile) and a 'Text' (filename). Examples (create these as static rows, not data bound): "Documents", "Images", "Work.txt".`,
},
{
name: "chatRoom",
description: "A chat application interface.",
promptText: `Create a chat room interface. It should have a 'Column' for the message history. Inside, include several 'Card's representing messages, each with a 'Text' for the sender and a 'Text' for the message body. Specifically include these messages: "Alice: Hi there!", "Bob: Hello!". At the bottom, a 'Row' with a 'TextField' (label "Type a message...") and a 'Button' labeled "Send".`,
},
{
name: "fitnessTracker",
description: "A daily activity summary.",
promptText: `Create a fitness tracker dashboard. It should have a 'Text' (usageHint 'h1') "Daily Activity", and a 'Row' of 'Card's. Each card should contain a 'Column' with a 'Text' label (e.g. "Steps") and a 'Text' value (e.g. "10,000"). Create cards for "Steps" ("10,000"), "Calories" ("500 kcal"), "Distance" ("5 km"). Below that, a 'Slider' labeled "Daily Goal" (initialize value to 50). Finally, a 'List' of recent workouts. Use 'Text' components for the list items, for example: "Morning Run", "Evening Yoga", "Gym Session".`,
},
{
name: "smartHome",
description: "A smart home control panel.",
promptText: `Create a smart home dashboard. It should have a 'Text' (usageHint 'h1') "Living Room". A 'Grid' of 'Card's. To create the grid, use a 'Column' that contains multiple 'Row's. Each 'Row' should contain 'Card's. Create a row with cards for "Lights" (CheckBox, label "Lights", value true) and "Thermostat" (Slider, label "Thermostat", value 72). Create another row with a card for "Music" (CheckBox, label "Music", value false). Ensure the CheckBox labels are exactly "Lights" and "Music".`,
},
{
name: "restaurantMenu",
description: "A restaurant menu with tabs.",
promptText: `Create a restaurant menu with tabs. It should have a 'Text' (usageHint 'h1') "Gourmet Bistro". A 'Tabs' component with "Starters", "Mains", "Desserts".
- "Starters": 'List' containing IDs of separate 'Row' components (Name, Price). Create rows for "Soup - $8", "Salad - $10".
- "Mains": 'List' containing IDs of separate 'Row' components. Create rows for "Steak - $25", "Pasta - $18".
- "Desserts": 'List' containing IDs of separate 'Row' components. Create rows for "Cake - $8", "Pie - $7".`,
},
{
name: "newsAggregator",
description: "A news feed with article cards.",
promptText: `Create a news aggregator. The root component should be a 'Column'. Inside this column, place a 'Text' (usageHint 'h1') "Top Headlines". Below the text, place a 'List' of 'Card's. The 'List' should be a sibling of the 'Text', not a parent. Each card has a 'Column' with an 'Image', a 'Text' (headline), and a 'Text' (summary). Include headlines "Tech Breakthrough" and "Local Sports". Each card should have a 'Button' labeled "Read More". Create these as static components, not data bound.`,
},
{
name: "photoEditor",
description: "A photo editing interface with sliders.",
promptText: `Create a photo editor. It should have a large 'Image' (photo). Below it, a 'Row' of 'Button's (Filters, Crop, Adjust). Below that, a 'Slider' labeled "Intensity" (initialize value to 50).`,
},
{
name: "triviaQuiz",
description: "A trivia question card.",
promptText: `Create a trivia quiz. It should have a 'Text' (usageHint 'h1') "Question 1". A 'Text' "What is the capital of France?". A 'ChoicePicker' (usageHint 'mutuallyExclusive') for answers (options: "Paris", "London", "Berlin", "Madrid"). A 'Button' "Submit Answer".`,
},
{
name: "simpleCalculator",
description: "A basic calculator layout.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a calculator. It should have a 'Card'. Inside the card, there MUST be a single 'Column' that contains two things: a 'Text' (display) showing "0", and a nested 'Column' of 'Row's for the buttons.
- Row 1: "7", "8", "9", "/"
- Row 2: "4", "5", "6", "*"
- Row 3: "1", "2", "3", "-"
- Row 4: "0", ".", "=", "+"
Each button should be a 'Button' component.`,
},
{
name: "jobApplication",
description: "A job application form.",
promptText: `Create a job application form. It should have 'TextField's for "Name", "Email", "Phone", "Resume URL". A 'ChoicePicker' (usageHint 'mutuallyExclusive') labeled "Years of Experience" (options: "0-1", "2-5", "5+"). A 'Button' "Submit Application".`,
},
{
name: "courseSyllabus",
description: "A course syllabus outline.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a course syllabus. 'Text' (h1) "Introduction to Computer Science". 'List' of modules.
- For module 1, a 'Card' with 'Text' "Algorithms" and 'List' ("Sorting", "Searching").
- For module 2, a 'Card' with 'Text' "Data Structures" and 'List' ("Arrays", "Linked Lists").`,
},
{
name: "stockWatchlist",
description: "A stock market watchlist.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a stock watchlist. 'Text' (h1) "Market Watch". 'List' of 'Row's.
- Row 1: 'Text' "AAPL", 'Text' "$150.00", 'Text' "+1.2%".
- Row 2: 'Text' "GOOGL", 'Text' "$2800.00", 'Text' "-0.5%".
- Row 3: 'Text' "AMZN", 'Text' "$3400.00", 'Text' "+0.8%".`,
},
{
name: "podcastEpisode",
description: "A podcast player interface.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a podcast player. 'Card' containing:
- 'Image' (Cover Art).
- 'Text' (h2) "Episode 42: The Future of AI".
- 'Text' "Host: Jane Smith".
- 'Slider' labeled "Progress" (initialize value to 0).
- 'Row' with 'Button' (child 'Text' "1x"), 'Button' (child 'Text' "Play/Pause"), 'Button' (child 'Text' "Share").
Create these as static components, not data bound.`,
},
{
name: "hotelSearchResults",
description: "Hotel search results list.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for hotel search results. 'Text' (h1) "Hotels in Tokyo". 'List' of 'Card's.
- Card 1: 'Row' with 'Image', 'Column' ('Text' "Grand Hotel", 'Text' "5 Stars", 'Text' "$200/night"), 'Button' "Book".
- Card 2: 'Row' with 'Image', 'Column' ('Text' "City Inn", 'Text' "3 Stars", 'Text' "$100/night"), 'Button' "Book".`,
},
{
name: "notificationCenter",
description: "A list of notifications.",
promptText: `Create a notification center. It should have a 'Text' (usageHint 'h1') "Notifications". A 'List' of 'Card's. Include cards for "New message from Sarah" and "Your order has shipped". Each card should have a 'Button' "Dismiss".`,
},
{
name: "nestedDataBinding",
description: "A project dashboard with deeply nested data binding.",
promptText: `Generate a stream of JSON messages for a Project Management Dashboard.
The output must consist of exactly three JSON objects, one after the other.
Generate a createSurface message with surfaceId 'main'.
Generate an updateComponents message with surfaceId 'main'.
It should have a 'Text' (usageHint 'h1') "Project Dashboard".
Then a 'List' of projects bound to '/projects'.
Inside the list template, each item should be a 'Card' containing:
- A 'Text' (usageHint 'h2') bound to the project 'title'.
- A 'List' of tasks bound to the 'tasks' property of the project.
Inside the tasks list template, each item should be a 'Column' containing:
- A 'Text' bound to the task 'description'.
- A 'Row' for the assignee, containing:
- A 'Text' bound to 'assignee/name'.
- A 'Text' bound to 'assignee/role'.
- A 'List' of subtasks bound to 'subtasks'.
Inside the subtasks list template, each item should be a 'Text' bound to 'title'.
Then generate an 'updateDataModel' message.
Populate this dashboard with sample data:
- At least one project.
- The project should have a title, and a list of tasks.
- The task should have a description, an assignee object (with name and role), and a list of subtasks.`,
},
{
name: "profileEditor",
description: "A user profile editing form.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for editing a profile. 'Text' (h1) "Edit Profile". 'Image' (Current Avatar). 'Button' "Change Photo". 'TextField' "Display Name". 'TextField' "Bio" (multiline). 'TextField' "Website". 'Button' "Save Changes".`,
},
{
name: "cinemaSeatSelection",
description: "A seat selection grid.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for cinema seat selection. 'Text' (h1) "Select Seats". 'Text' "Screen" (centered). 'Column' of 'Row's representing rows of seats.
- Row A: 4 'CheckBox'es.
- Row B: 4 'CheckBox'es.
- Row C: 4 'CheckBox'es.
'Button' "Confirm Selection".`,
},
{
name: "flashcardApp",
description: "A language learning flashcard.",
promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a flashcard app. 'Text' (h1) "Spanish Vocabulary". 'Card' (the flashcard). Inside the card, a 'Column' with 'Text' (h2) "Hola" (Front). 'Divider'. 'Text' "Hello" (Back - conceptually hidden, but rendered here). 'Row' of buttons: "Hard", "Good", "Easy".`,
},
];

View File

@@ -0,0 +1,205 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "./logger";
import { ModelConfiguration } from "./models";
interface UsageRecord {
timestamp: number;
tokensUsed: number;
isRequest: boolean;
}
interface ModelRateLimitState {
usageRecords: UsageRecord[];
}
export class RateLimiter {
private modelStates: Map<string, ModelRateLimitState> = new Map();
private _waitingCount = 0;
private modelPauses: Map<string, number> = new Map();
get waitingCount(): number {
return this._waitingCount;
}
private getModelState(modelName: string): ModelRateLimitState {
if (!this.modelStates.has(modelName)) {
this.modelStates.set(modelName, { usageRecords: [] });
}
return this.modelStates.get(modelName)!;
}
private cleanUpRecords(state: ModelRateLimitState): void {
// Use 65 seconds to be safe against clock drift and server bucket alignment
const minuteAgo = Date.now() - 65 * 1000;
state.usageRecords = state.usageRecords.filter(
(record) => record.timestamp > minuteAgo
);
}
reportError(modelConfig: ModelConfiguration, error: any): void {
const isResourceExhausted =
error?.status === "RESOURCE_EXHAUSTED" ||
error?.code === 429 ||
(error?.message && error.message.includes("429"));
if (isResourceExhausted) {
// Try to parse "Please retry in X s" or similar from error message
// Example: "Please retry in 22.648565753s."
const message = error?.originalMessage || error?.message || "";
const match = message.match(/retry in ([0-9.]+)\s*s/i);
let retrySeconds = 60; // Default to 60s if not found
if (match && match[1]) {
retrySeconds = parseFloat(match[1]);
}
// Add a small buffer
const pauseDuration = Math.ceil(retrySeconds * 1000) + 1000;
const pausedUntil = Date.now() + pauseDuration;
this.modelPauses.set(modelConfig.name, pausedUntil);
logger.verbose(
`RateLimiter: Pausing ${modelConfig.name} for ${pauseDuration}ms due to 429 error. Resuming at ${new Date(pausedUntil).toISOString()}`
);
}
}
async acquirePermit(
modelConfig: ModelConfiguration,
tokensCost: number = 0
): Promise<void> {
this._waitingCount++;
try {
const { name, requestsPerMinute, tokensPerMinute } = modelConfig;
if (!requestsPerMinute && !tokensPerMinute) {
return; // No limits
}
const state = this.getModelState(name);
// Loop to re-check after waiting, as multiple limits might be in play
while (true) {
// Check if model is paused globally due to 429
const pausedUntil = this.modelPauses.get(name);
if (pausedUntil && pausedUntil > Date.now()) {
const pauseWait = pausedUntil - Date.now();
logger.verbose(
`Rate limiting ${name}: Paused by circuit breaker for ${pauseWait}ms`
);
await new Promise((resolve) => setTimeout(resolve, pauseWait));
// After waiting, loop again to check normal rate limits
continue;
}
this.cleanUpRecords(state);
const currentNow = Date.now();
let rpmWait = 0;
let tpmWait = 0;
let currentTokens = 0;
let currentRequests = 0;
state.usageRecords.forEach((r) => {
currentTokens += r.tokensUsed;
if (r.isRequest) currentRequests++;
});
const effectiveTokensPerMinute = tokensPerMinute
? Math.floor(tokensPerMinute * 0.9)
: 0;
logger.debug(
`RateLimiter check for ${name}: Cost=${tokensCost}, CurrentTokens=${currentTokens}, Limit=${effectiveTokensPerMinute}, Requests=${currentRequests}, RPM=${requestsPerMinute}`
);
// Check RPM
if (requestsPerMinute && currentRequests + 1 > requestsPerMinute) {
// Find the oldest REQUEST record
const oldestRequest = state.usageRecords.find((r) => r.isRequest);
if (oldestRequest) {
rpmWait = Math.max(
0,
oldestRequest.timestamp + 60 * 1000 - currentNow
);
}
}
// Check TPM
if (tokensPerMinute) {
// Apply a 10% safety buffer to the limit
const effectiveTokensPerMinute = Math.floor(tokensPerMinute * 0.9);
if (currentTokens + tokensCost > effectiveTokensPerMinute) {
// Check if we are ALREADY over limit for the next call
// We need to shed enough tokens so that (current - shed + cost) <= limit
// shed >= current + cost - limit
let tokensToShed =
currentTokens + tokensCost - effectiveTokensPerMinute;
let cumulativeTokens = 0;
for (const record of state.usageRecords) {
cumulativeTokens += record.tokensUsed;
if (cumulativeTokens >= tokensToShed) {
tpmWait = Math.max(
tpmWait,
record.timestamp + 60 * 1000 - currentNow
);
break;
}
}
}
}
const requiredWait = Math.max(rpmWait, tpmWait);
if (requiredWait <= 0) {
// RESERVE THE PERMIT HERE TO PREVENT RACE CONDITIONS
state.usageRecords.push({
timestamp: Date.now(),
tokensUsed: tokensCost,
isRequest: true,
});
break; // Permit acquired
}
logger.verbose(
`Rate limiting ${name}: Waiting ${requiredWait}ms (RPM wait: ${rpmWait}ms, TPM wait: ${tpmWait}ms)`
);
await new Promise((resolve) => setTimeout(resolve, requiredWait));
}
} finally {
this._waitingCount--;
}
}
recordUsage(
modelConfig: ModelConfiguration,
tokensUsed: number,
isRequest: boolean = true
): void {
if (tokensUsed > 0 || isRequest) {
const state = this.getModelState(modelConfig.name);
state.usageRecords.push({
timestamp: Date.now(),
tokensUsed,
isRequest,
});
}
}
}
export const rateLimiter = new RateLimiter();

View File

@@ -0,0 +1,47 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TestPrompt } from "./prompts";
export interface GeneratedResult {
modelName: string;
prompt: TestPrompt;
runNumber: number;
rawText?: string;
components?: any[];
latency: number;
error?: any;
}
export interface ValidatedResult extends GeneratedResult {
validationErrors: string[];
}
export type IssueSeverity =
| "minor"
| "significant"
| "critical"
| "criticalSchema";
export interface EvaluatedResult extends ValidatedResult {
evaluationResult?: {
pass: boolean;
reason: string;
issues?: { issue: string; severity: IssueSeverity }[];
overallSeverity?: IssueSeverity;
evalPrompt?: string;
};
}

View File

@@ -0,0 +1,44 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function extractJsonFromMarkdown(markdown: string): any[] {
const jsonBlockRegex = /```json\s*([\s\S]*?)\s*```/g;
const matches = [...markdown.matchAll(jsonBlockRegex)];
const results: any[] = [];
for (const match of matches) {
if (match[1]) {
const content = match[1].trim();
// Try parsing as a single JSON object first
try {
results.push(JSON.parse(content));
} catch (error) {
// If that fails, try parsing as JSONL (line by line)
const lines = content.split("\n");
for (const line of lines) {
if (line.trim()) {
try {
results.push(JSON.parse(line));
} catch (e2) {
// Ignore invalid lines
}
}
}
}
}
}
return results;
}

View File

@@ -0,0 +1,365 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Ajv from "ajv/dist/2020";
import * as fs from "fs";
import * as path from "path";
import * as yaml from "js-yaml";
import { GeneratedResult, ValidatedResult, IssueSeverity } from "./types";
import { logger } from "./logger";
export class Validator {
private ajv: Ajv;
private validateFn: any;
constructor(
private schemas: Record<string, any>,
private outputDir?: string
) {
this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false to be lenient with unknown keywords if any
for (const [name, schema] of Object.entries(schemas)) {
this.ajv.addSchema(schema, name);
}
this.validateFn = this.ajv.getSchema(
"https://a2ui.dev/specification/0.9/server_to_client.json"
);
}
async run(results: GeneratedResult[]): Promise<ValidatedResult[]> {
logger.info(
`Starting Phase 2: Schema Validation (${results.length} items)`
);
const validatedResults: ValidatedResult[] = [];
let passedCount = 0;
let failedCount = 0;
// Phase 2 is fast (CPU bound), so we can just iterate.
// If we wanted to be fancy we could chunk it, but for < 1000 items it's instant.
for (const result of results) {
if (result.error || !result.components) {
validatedResults.push({ ...result, validationErrors: [] }); // Already failed generation
continue;
}
const errors: string[] = [];
const components = result.components;
// AJV Validation
// AJV Validation
if (this.ajv) {
for (const message of components) {
// Smart validation: check which key is present and validate against that specific definition
// to avoid noisy "oneOf" errors.
let validated = false;
const schemaUri =
"https://a2ui.dev/specification/0.9/server_to_client.json";
if (message.createSurface) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/CreateSurfaceMessage`,
message
);
} else if (message.updateComponents) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/UpdateComponentsMessage`,
message
);
} else if (message.updateDataModel) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/UpdateDataModelMessage`,
message
);
} else if (message.deleteSurface) {
validated = this.ajv.validate(
`${schemaUri}#/$defs/DeleteSurfaceMessage`,
message
);
} else {
// Fallback to top-level validation if no known key matches (or if it's empty/invalid structure)
validated = this.validateFn(message);
}
if (!validated) {
errors.push(
...(this.ajv.errors || []).map(
(err: any) => `${err.instancePath} ${err.message}`
)
);
}
}
}
// Custom Validation (Referential Integrity, etc.)
this.validateCustom(components, errors);
if (errors.length > 0) {
failedCount++;
if (this.outputDir) {
this.saveFailure(result, errors);
}
} else {
passedCount++;
}
validatedResults.push({
...result,
validationErrors: errors,
});
}
logger.info(
`Phase 2: Validation Complete. Passed: ${passedCount}, Failed: ${failedCount}`
);
return validatedResults;
}
private saveFailure(result: GeneratedResult, errors: string[]) {
if (!this.outputDir) return;
const modelDir = path.join(
this.outputDir,
`output-${result.modelName.replace(/[\/:]/g, "_")}`
);
const detailsDir = path.join(modelDir, "details");
const failureData = {
pass: false,
reason: "Schema validation failure",
issues: errors.map((e) => ({
issue: e,
severity: "criticalSchema" as IssueSeverity,
})),
overallSeverity: "criticalSchema" as IssueSeverity,
};
fs.writeFileSync(
path.join(
detailsDir,
`${result.prompt.name}.${result.runNumber}.failed.yaml`
),
yaml.dump(failureData)
);
}
private validateCustom(messages: any[], errors: string[]) {
let hasUpdateComponents = false;
let hasRootComponent = false;
const createdSurfaces = new Set<string>();
for (const message of messages) {
if (message.updateComponents) {
hasUpdateComponents = true;
const surfaceId = message.updateComponents.surfaceId;
if (surfaceId && !createdSurfaces.has(surfaceId)) {
errors.push(
`updateComponents message received for surface '${surfaceId}' before createSurface message.`
);
}
this.validateUpdateComponents(message.updateComponents, errors);
// Check for root component in this message
if (message.updateComponents.components) {
for (const comp of message.updateComponents.components) {
if (comp.id === "root") {
hasRootComponent = true;
}
}
}
} else if (message.createSurface) {
this.validateCreateSurface(message.createSurface, errors);
if (message.createSurface.surfaceId) {
createdSurfaces.add(message.createSurface.surfaceId);
}
} else if (message.updateDataModel) {
this.validateUpdateDataModel(message.updateDataModel, errors);
} else if (message.deleteSurface) {
this.validateDeleteSurface(message.deleteSurface, errors);
} else {
errors.push(
`Unknown message type in output: ${JSON.stringify(message)}`
);
}
}
// Algorithmic check for root component
if (hasUpdateComponents && !hasRootComponent) {
errors.push(
"Missing root component: At least one 'updateComponents' message must contain a component with id: 'root'."
);
}
}
// ... Copied helper functions ...
private validateCreateSurface(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("createSurface must have a 'surfaceId' property.");
}
if (data.catalogId === undefined) {
errors.push("createSurface must have a 'catalogId' property.");
}
const allowed = ["surfaceId", "catalogId"];
for (const key in data) {
if (!allowed.includes(key)) {
errors.push(`createSurface has unexpected property: ${key}`);
}
}
}
private validateDeleteSurface(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("DeleteSurface must have a 'surfaceId' property.");
}
const allowed = ["surfaceId"];
for (const key in data) {
if (!allowed.includes(key)) {
errors.push(`DeleteSurface has unexpected property: ${key}`);
}
}
}
private validateUpdateComponents(data: any, errors: string[]) {
if (data.surfaceId === undefined) {
errors.push("UpdateComponents must have a 'surfaceId' property.");
}
if (!data.components || !Array.isArray(data.components)) {
errors.push("UpdateComponents must have a 'components' array.");
return;
}
const componentIds = new Set<string>();
for (const c of data.components) {
const id = c.id;
if (id) {
if (componentIds.has(id)) {
errors.push(`Duplicate component ID found: ${id}`);
}
componentIds.add(id);
}
// Smart Component Validation
if (this.ajv && c.component) {
const componentType = c.component;
const schemaUri =
"https://a2ui.dev/specification/0.9/standard_catalog_definition.json";
const defRef = `${schemaUri}#/$defs/${componentType}`;
const valid = this.ajv.validate(defRef, c);
if (!valid) {
errors.push(
...(this.ajv.errors || []).map(
(err: any) =>
`${err.instancePath} ${err.message} (in component '${
c.id || "unknown"
}')`
)
);
}
}
}
for (const component of data.components) {
this.validateComponent(component, componentIds, errors);
}
}
private validateUpdateDataModel(data: any, errors: string[]) {
// Schema validation handles types, required fields (surfaceId, op), and extra properties.
// We only need to validate the conditional requirement of 'value' based on 'op'.
if (data.op === "remove") {
if (data.value !== undefined) {
errors.push(
"updateDataModel 'value' property must not be present when op is 'remove'."
);
}
} else {
// op is 'add' or 'replace' (schema validates enum values)
if (data.value === undefined) {
errors.push(
`updateDataModel 'value' property is required when op is '${data.op}'.`
);
}
}
}
private validateComponent(
component: any,
allIds: Set<string>,
errors: string[]
) {
const id = component.id;
if (!id) {
errors.push(`Component is missing an 'id'.`);
return;
}
const componentType = component.component;
if (!componentType || typeof componentType !== "string") {
errors.push(`Component '${id}' is missing 'component' property.`);
return;
}
// Basic required checks that might be missed by AJV if it's lenient or if we want specific messages
// Actually AJV covers most of this, but the custom logic for 'children' and 'refs' is key.
const checkRefs = (ids: (string | undefined)[]) => {
for (const id of ids) {
if (id && !allIds.has(id)) {
errors.push(
`Component ${JSON.stringify(id)} references non-existent component ID.`
);
}
}
};
switch (componentType) {
case "Row":
case "Column":
case "List":
if (component.children) {
if (Array.isArray(component.children)) {
checkRefs(component.children);
} else if (
typeof component.children === "object" &&
component.children !== null
) {
if (component.children.componentId) {
checkRefs([component.children.componentId]);
}
}
}
break;
case "Card":
checkRefs([component.child]);
break;
case "Tabs":
if (component.tabItems && Array.isArray(component.tabItems)) {
component.tabItems.forEach((tab: any) => {
checkRefs([tab.child]);
});
}
break;
case "Modal":
checkRefs([component.entryPointChild, component.contentChild]);
break;
case "Button":
checkRefs([component.child]);
break;
}
}
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "lib",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@@ -0,0 +1,97 @@
{
"title": "A2UI (Agent to UI) Client-to-Server Event Schema",
"description": "Describes a JSON payload for a client-to-server event message.",
"type": "object",
"minProperties": 1,
"maxProperties": 1,
"properties": {
"userAction": {
"type": "object",
"description": "Reports a user-initiated action from a component.",
"properties": {
"name": {
"type": "string",
"description": "The name of the action, taken from the component's action.name property."
},
"surfaceId": {
"type": "string",
"description": "The id of the surface where the event originated."
},
"sourceComponentId": {
"type": "string",
"description": "The id of the component that triggered the event."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "An ISO 8601 timestamp of when the event occurred."
},
"context": {
"type": "object",
"description": "A JSON object containing the key-value pairs from the component's action.context, after resolving all data bindings.",
"additionalProperties": true
}
},
"required": [
"name",
"surfaceId",
"sourceComponentId",
"timestamp",
"context"
]
},
"error": {
"description": "Reports a client-side error.",
"oneOf": [
{
"type": "object",
"title": "Validation Failed Error",
"properties": {
"code": {
"const": "VALIDATION_FAILED"
},
"surfaceId": {
"type": "string",
"description": "The id of the surface where the error occurred."
},
"path": {
"type": "string",
"description": "The JSON pointer to the field that failed validation (e.g. '/components/0/text')."
},
"message": {
"type": "string",
"description": "A short one-sentence description of why validation failed."
}
},
"required": ["code", "path", "message", "surfaceId"]
},
{
"type": "object",
"title": "Generic Error",
"properties": {
"code": {
"not": {
"const": "VALIDATION_FAILED"
}
},
"message": {
"type": "string",
"description": "A short one-sentence description of why the error occurred."
},
"surfaceId": {
"type": "string",
"description": "The id of the surface where the error occurred."
}
},
"required": ["code", "surfaceId", "message"],
"additionalProperties": true
}
]
}
},
"oneOf": [
{ "required": ["userAction"] },
{ "required": ["clientUiCapabilities"] },
{ "required": ["error"] }
]
}

View File

@@ -0,0 +1,120 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://a2ui.dev/specification/0.9/common_types.json",
"title": "A2UI Common Types",
"description": "Common type definitions used across A2UI schemas.",
"$defs": {
"stringOrPath": {
"description": "Represents a value that can be either a literal string or a path to a value in the data model.",
"oneOf": [
{ "type": "string" },
{
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
]
},
"numberOrPath": {
"description": "Represents a value that can be either a literal number or a path to a value in the data model.",
"oneOf": [
{ "type": "number" },
{
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
]
},
"booleanOrPath": {
"description": "Represents a value that can be either a literal boolean or a path to a value in the data model.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
]
},
"stringArrayOrPath": {
"description": "Represents a value that can be either a literal array of strings or a path to a value in the data model.",
"oneOf": [
{
"type": "array",
"items": { "type": "string" }
},
{
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
]
},
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"ComponentCommon": {
"type": "object",
"properties": {
"id": { "$ref": "#/$defs/id" },
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property."
}
},
"required": ["id"]
},
"contextValue": {
"description": "A value that can be a string, number, boolean, or a path to a value.",
"oneOf": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" },
{
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"],
"additionalProperties": false
}
]
},
"childrenProperty": {
"oneOf": [
{
"type": "array",
"items": { "type": "string" },
"description": "A static list of child component IDs."
},
{
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. The `componentId` is the component to use as a template.",
"properties": {
"componentId": {
"$ref": "#/$defs/id"
},
"path": {
"type": "string",
"description": "The path to the list of component property objects in the data model."
}
},
"required": ["componentId", "path"],
"additionalProperties": false
}
]
}
}
}

View File

@@ -0,0 +1,3 @@
{"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/0.9/standard_catalog_definition.json"}}
{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Column","children":["first_name_label","first_name_field","last_name_label","last_name_field","email_label","email_field","phone_label","phone_field","notes_label","notes_field","submit_button"]},{"id":"first_name_label","component":"Text","text":"First Name"},{"id":"first_name_field","component":"TextField","label":"First Name","text":{"path":"/contact/firstName"},"usageHint":"shortText"},{"id":"last_name_label","component":"Text","text":"Last Name"},{"id":"last_name_field","component":"TextField","label":"Last Name","text":{"path":"/contact/lastName"},"usageHint":"shortText"},{"id":"email_label","component":"Text","text":"Email"},{"id":"email_field","component":"TextField","label":"Email","text":{"path":"/contact/email"},"usageHint":"shortText"},{"id":"phone_label","component":"Text","text":"Phone"},{"id":"phone_field","component":"TextField","label":"Phone","text":{"path":"/contact/phone"},"usageHint":"shortText"},{"id":"notes_label","component":"Text","text":"Notes"},{"id":"notes_field","component":"TextField","label":"Notes","text":{"path":"/contact/notes"},"usageHint":"longText"},{"id":"submit_button_label","component":"Text","text":"Submit"},{"id":"submit_button","component":"Button","child":"submit_button_label","action":{"name":"submitContactForm"}}]}}
{"updateDataModel": {"surfaceId": "contact_form_1", "path": "/contact", "op": "replace", "value": {"firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}}}

View File

@@ -0,0 +1,114 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://a2ui.dev/specification/0.9/server_to_client.json",
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.",
"type": "object",
"oneOf": [
{ "$ref": "#/$defs/CreateSurfaceMessage" },
{ "$ref": "#/$defs/UpdateComponentsMessage" },
{ "$ref": "#/$defs/UpdateDataModelMessage" },
{ "$ref": "#/$defs/DeleteSurfaceMessage" }
],
"$defs": {
"CreateSurfaceMessage": {
"properties": {
"createSurface": {
"type": "object",
"description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"catalogId": {
"title": "Catalog ID",
"description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.",
"type": "string"
}
},
"required": ["surfaceId", "catalogId"],
"additionalProperties": false
}
},
"required": ["createSurface"],
"additionalProperties": false
},
"UpdateComponentsMessage": {
"properties": {
"updateComponents": {
"type": "object",
"description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"$ref": "standard_catalog_definition.json#/$defs/anyComponent"
}
}
},
"required": ["surfaceId", "components"],
"additionalProperties": false
}
},
"required": ["updateComponents"],
"additionalProperties": false
},
"UpdateDataModelMessage": {
"properties": {
"updateDataModel": {
"type": "object",
"description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model."
},
"op": {
"type": "string",
"description": "The operation to perform on the data model. Defaults to 'replace' if omitted.",
"enum": ["add", "replace", "remove"]
},
"value": {
"description": "The data to be updated in the data model. Required for 'add' and 'replace' operations. Not allowed for 'remove' operation.",
"additionalProperties": true
}
},
"required": ["surfaceId"],
"additionalProperties": false
}
},
"required": ["updateDataModel"],
"additionalProperties": false
},
"DeleteSurfaceMessage": {
"properties": {
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"],
"additionalProperties": false
}
},
"required": ["deleteSurface"],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,638 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://a2ui.dev/specification/0.9/standard_catalog_definition.json",
"title": "A2UI Component Catalog",
"description": "Definitions for the standard catalog of A2UI components.",
"$defs": {
"Theme": {
"type": "object",
"description": "Theming information for the UI.",
"properties": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
},
"additionalProperties": false
},
"anyComponent": {
"oneOf": [
{ "$ref": "#/$defs/Text" },
{ "$ref": "#/$defs/Image" },
{ "$ref": "#/$defs/Icon" },
{ "$ref": "#/$defs/Video" },
{ "$ref": "#/$defs/AudioPlayer" },
{ "$ref": "#/$defs/Row" },
{ "$ref": "#/$defs/Column" },
{ "$ref": "#/$defs/List" },
{ "$ref": "#/$defs/Card" },
{ "$ref": "#/$defs/Tabs" },
{ "$ref": "#/$defs/Divider" },
{ "$ref": "#/$defs/Modal" },
{ "$ref": "#/$defs/Button" },
{ "$ref": "#/$defs/CheckBox" },
{ "$ref": "#/$defs/TextField" },
{ "$ref": "#/$defs/DateTimeInput" },
{ "$ref": "#/$defs/ChoicePicker" },
{ "$ref": "#/$defs/Slider" }
],
"discriminator": {
"propertyName": "component"
}
},
"Text": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Text" },
"text": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The text content to display. While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation."
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style.",
"enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"]
}
},
"required": ["component", "text"]
}
],
"unevaluatedProperties": false
},
"Image": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Image" },
"url": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The URL of the image to display."
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
"enum": ["contain", "cover", "fill", "none", "scale-down"]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style.",
"enum": [
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header"
]
}
},
"required": ["component", "url"]
}
],
"unevaluatedProperties": false
},
"Icon": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Icon" },
"name": {
"description": "The name of the icon to display.",
"oneOf": [
{
"type": "string",
"enum": [
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"fastForward",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"pause",
"payment",
"person",
"phone",
"photo",
"play",
"print",
"refresh",
"rewind",
"search",
"send",
"settings",
"share",
"shoppingCart",
"skipNext",
"skipPrevious",
"star",
"starHalf",
"starOff",
"stop",
"upload",
"visibility",
"visibilityOff",
"volumeDown",
"volumeMute",
"volumeOff",
"volumeUp",
"warning"
]
},
{
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
]
}
},
"required": ["component", "name"]
}
],
"unevaluatedProperties": false
},
"Video": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Video" },
"url": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The URL of the video to display."
}
},
"required": ["component", "url"]
}
],
"unevaluatedProperties": false
},
"AudioPlayer": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "AudioPlayer" },
"url": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The URL of the audio to be played."
},
"description": {
"description": "A description of the audio, such as a title or summary.",
"$ref": "common_types.json#/$defs/stringOrPath"
}
},
"required": ["component", "url"]
}
],
"unevaluatedProperties": false
},
"Row": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"description": "A layout component that arranges its children horizontally. To create a grid layout, nest Columns within this Row.",
"properties": {
"component": { "const": "Row" },
"children": {
"description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.",
"$ref": "common_types.json#/$defs/childrenProperty"
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (horizontally). Use 'spaceBetween' to push items to the edges, or 'start'/'end'/'center' to pack them together.",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start",
"stretch"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (vertically). This is similar to the CSS 'align-items' property, but uses camelCase values (e.g., 'start').",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["component", "children"]
}
],
"unevaluatedProperties": false
},
"Column": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"description": "A layout component that arranges its children vertically. To create a grid layout, nest Rows within this Column.",
"properties": {
"component": { "const": "Column" },
"children": {
"description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.",
"$ref": "common_types.json#/$defs/childrenProperty"
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (vertically). Use 'spaceBetween' to push items to the edges (e.g. header at top, footer at bottom), or 'start'/'end'/'center' to pack them together.",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly",
"stretch"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (horizontally). This is similar to the CSS 'align-items' property.",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["component", "children"]
}
],
"unevaluatedProperties": false
},
"List": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "List" },
"children": {
"description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list.",
"$ref": "common_types.json#/$defs/childrenProperty"
},
"direction": {
"type": "string",
"description": "The direction in which the list items are laid out.",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["component", "children"]
}
],
"unevaluatedProperties": false
},
"Card": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Card" },
"child": {
"type": "string",
"description": "The ID of the single child component to be rendered inside the card. To display multiple elements, you MUST wrap them in a layout component (like Column or Row) and pass that container's ID here. Do NOT pass multiple IDs or a non-existent ID. Do NOT define the child component inline."
}
},
"required": ["component", "child"]
}
],
"unevaluatedProperties": false
},
"Tabs": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Tabs" },
"tabItems": {
"type": "array",
"description": "An array of objects, where each object defines a tab with a title and a child component.",
"items": {
"type": "object",
"properties": {
"title": {
"description": "The tab title.",
"$ref": "common_types.json#/$defs/stringOrPath"
},
"child": {
"type": "string",
"description": "The ID of the child component. Do NOT define the component inline."
}
},
"required": ["title", "child"],
"additionalProperties": false
}
}
},
"required": ["component", "tabItems"]
}
],
"unevaluatedProperties": false
},
"Divider": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Divider" },
"axis": {
"type": "string",
"description": "The orientation of the divider.",
"enum": ["horizontal", "vertical"]
}
},
"required": ["component"]
}
],
"unevaluatedProperties": false
},
"Modal": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Modal" },
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with (e.g., a button). Do NOT define the component inline."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal. Do NOT define the component inline."
}
},
"required": ["component", "entryPointChild", "contentChild"]
}
],
"unevaluatedProperties": false
},
"Button": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "Button" },
"child": {
"type": "string",
"description": "The ID of the child component. Use a 'Text' component for a labeled button. Only use an 'Icon' if the requirements explicitly ask for an icon-only button. Do NOT define the child component inline."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.",
"properties": {
"name": { "type": "string" },
"context": {
"type": "object",
"description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.",
"additionalProperties": {
"$ref": "common_types.json#/$defs/contextValue"
}
}
},
"required": ["name"],
"additionalProperties": false
}
},
"required": ["component", "child", "action"]
}
],
"unevaluatedProperties": false
},
"CheckBox": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "CheckBox" },
"label": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The text to display next to the checkbox."
},
"value": {
"$ref": "common_types.json#/$defs/booleanOrPath",
"description": "The current state of the checkbox (true for checked, false for unchecked)."
}
},
"required": ["component", "label", "value"]
}
],
"unevaluatedProperties": false
},
"TextField": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "TextField" },
"label": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The text label for the input field."
},
"text": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The value of the text field."
},
"usageHint": {
"type": "string",
"description": "The type of input field to display.",
"enum": ["longText", "number", "shortText", "obscured"]
},
"validationRegexp": {
"type": "string",
"description": "A regular expression used for client-side validation of the input."
}
},
"required": ["component", "label"]
}
],
"unevaluatedProperties": false
},
"DateTimeInput": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": { "const": "DateTimeInput" },
"value": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The selected date and/or time value in ISO 8601 format. If not yet set, initialize with an empty string."
},
"enableDate": {
"type": "boolean",
"description": "If true, allows the user to select a date."
},
"enableTime": {
"type": "boolean",
"description": "If true, allows the user to select a time."
},
"outputFormat": {
"type": "string",
"description": "The desired format for the output string after a date or time is selected."
},
"label": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The text label for the input field."
}
},
"required": ["component", "value"]
}
],
"unevaluatedProperties": false
},
"ChoicePicker": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"description": "A component that allows selecting one or more options from a list.",
"properties": {
"component": {
"const": "ChoicePicker"
},
"label": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The label for the group of options."
},
"usageHint": {
"type": "string",
"description": "A hint for how the choice picker should be displayed and behave.",
"enum": ["multipleSelection", "mutuallyExclusive"]
},
"options": {
"type": "array",
"description": "The list of available options to choose from.",
"items": {
"type": "object",
"properties": {
"label": {
"description": "The text to display for this option.",
"$ref": "common_types.json#/$defs/stringOrPath"
},
"value": {
"type": "string",
"description": "The stable value associated with this option."
}
},
"required": ["label", "value"],
"additionalProperties": false
}
},
"value": {
"$ref": "common_types.json#/$defs/stringArrayOrPath",
"description": "The list of currently selected values. This should be bound to a string array in the data model."
}
},
"required": ["component", "options", "value", "usageHint"]
}
],
"unevaluatedProperties": false
},
"Slider": {
"type": "object",
"allOf": [
{ "$ref": "common_types.json#/$defs/ComponentCommon" },
{
"type": "object",
"properties": {
"component": {
"const": "Slider"
},
"label": {
"$ref": "common_types.json#/$defs/stringOrPath",
"description": "The label for the slider."
},
"min": {
"type": "number",
"description": "The minimum value of the slider."
},
"max": {
"type": "number",
"description": "The maximum value of the slider."
},
"value": {
"$ref": "common_types.json#/$defs/numberOrPath",
"description": "The current value of the slider."
}
},
"required": ["component", "value"]
}
],
"unevaluatedProperties": false
}
}
}

View File

@@ -0,0 +1,5 @@
**REQUIRED PROPERTIES:** You MUST include ALL required properties for every component, even if they are inside a template or will be bound to data.
- For 'Text', you MUST provide 'text'. If dynamic, use { "path": "..." }.
- For 'Image', you MUST provide 'url'. If dynamic, use { "path": "..." }.
- For 'Button', you MUST provide 'action'.
- For 'TextField', 'CheckBox', etc., you MUST provide 'label'.

38
vendor/a2ui/specification/0.9/validate.sh vendored Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
SCHEMA_DIR="/Users/gspencer/code/a2ui/specification/0.9"
SERVER_SCHEMA="${SCHEMA_DIR}/server_to_client.json"
COMMON_TYPES="${SCHEMA_DIR}/common_types.json"
COMPONENT_CATALOG="${SCHEMA_DIR}/component_catalog.json"
EXAMPLE_FILE="${SCHEMA_DIR}/contact_form_example.jsonl"
TEMP_FILE="${SCHEMA_DIR}/temp_message.json"
while read -r line; do
echo "$line" | jq '.' > "${TEMP_FILE}"
if [ $? -ne 0 ]; then
echo "jq failed to parse line: $line"
continue
fi
ajv validate --verbose -s "${SERVER_SCHEMA}" -r "${COMMON_TYPES}" -r "${COMPONENT_CATALOG}" --spec=draft2020 -d "${TEMP_FILE}"
if [ $? -ne 0 ]; then
echo "Validation failed for line: $line"
fi
done < "${EXAMPLE_FILE}"
rm "${TEMP_FILE}"
echo "Validation complete."