diff --git a/a2a_samples/a2ui_contact_lookup/a2ui_examples.py b/a2a_samples/a2ui_contact_lookup/a2ui_examples.py index 5e512bbf..36ac3e73 100644 --- a/a2a_samples/a2ui_contact_lookup/a2ui_examples.py +++ b/a2a_samples/a2ui_contact_lookup/a2ui_examples.py @@ -38,7 +38,24 @@ "surfaceId": "contact-list", "path": "/", "contents": [ - { "key": "contacts", "valueList": [] } + {{ "key": "contacts", "valueMap": [ + {{ "key": "contact1", "valueMap": [ + {{ "key": "name", "valueString": "Alice Wonderland" }}, + {{ "key": "phone", "valueString": "+1-555-123-4567" }}, + {{ "key": "email", "valueString": "alice@example.com" }}, + {{ "key": "imageUrl", "valueString": "https://example.com/alice.jpg" }}, + {{ "key": "title", "valueString": "Mad Hatter" }}, + {{ "key": "department", "valueString": "Wonderland" }} + ] }}, + {{ "key": "contact2", "valueMap": [ + {{ "key": "name", "valueString": "Bob The Builder" }}, + {{ "key": "phone", "valueString": "+1-555-765-4321" }}, + {{ "key": "email", "valueString": "bob@example.com" }}, + {{ "key": "imageUrl", "valueString": "https://example.com/bob.jpg" }}, + {{ "key": "title", "valueString": "Construction" }}, + {{ "key": "department", "valueString": "Building" }} + ] }} + ] }} ] } } ] diff --git a/a2a_samples/a2ui_contact_lookup/a2ui_schema.py b/a2a_samples/a2ui_contact_lookup/a2ui_schema.py index f8e6a614..722e8433 100644 --- a/a2a_samples/a2ui_contact_lookup/a2ui_schema.py +++ b/a2a_samples/a2ui_contact_lookup/a2ui_schema.py @@ -15,10 +15,10 @@ # a2ui_schema.py -A2UI_SCHEMA = """ +A2UI_SCHEMA = r''' { "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI 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'.", + "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", "properties": { "beginRendering": { @@ -84,7 +84,7 @@ "properties": { "text": { "type": "object", - "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. 'doc.title').", + "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. '/doc/title').", "properties": { "literalString": { "type": "string" @@ -107,7 +107,7 @@ "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. 'hotel.description').", + "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. '/hotel/description').", "properties": { "literalString": { "type": "string" @@ -125,7 +125,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -154,7 +154,7 @@ "properties": { "name": { "type": "object", - "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. 'icon.name').", + "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/icon/name').", "properties": { "literalString": { "type": "string" @@ -172,7 +172,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -190,7 +190,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -202,7 +202,7 @@ }, "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -230,7 +230,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -278,7 +278,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -326,7 +326,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -373,7 +373,7 @@ "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').", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", "properties": { "literalString": { "type": "string" @@ -441,7 +441,7 @@ }, "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').", + "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').", "properties": { "path": { "type": "string" @@ -472,7 +472,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -484,7 +484,7 @@ }, "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').", + "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').", "properties": { "literalBoolean": { "type": "boolean" @@ -502,7 +502,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -514,7 +514,7 @@ }, "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -547,7 +547,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -577,7 +577,7 @@ "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').", + "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').", "properties": { "literalArray": { "type": "array", @@ -598,7 +598,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -628,7 +628,7 @@ "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').", + "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').", "properties": { "literalNumber": { "type": "number" @@ -668,7 +668,7 @@ }, "path": { "type": "string", - "description": "An optional path to a location within the data model (e.g., 'user.name'). If omitted, the entire data model will be replaced." + "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", @@ -690,11 +690,16 @@ "valueBoolean": { "type": "boolean" }, - "valueList": { + "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.", "properties": { + "key": { + "type": "string" + }, "valueString": { "type": "string" }, @@ -704,7 +709,8 @@ "valueBoolean": { "type": "boolean" } - } + }, + "required": ["key"] } } }, @@ -727,4 +733,4 @@ } } } -""" +''' diff --git a/a2a_samples/a2ui_restaurant_finder/prompt_builder.py b/a2a_samples/a2ui_restaurant_finder/prompt_builder.py index 48a0cbdc..77a2e95f 100644 --- a/a2a_samples/a2ui_restaurant_finder/prompt_builder.py +++ b/a2a_samples/a2ui_restaurant_finder/prompt_builder.py @@ -13,10 +13,10 @@ # limitations under the License. # The A2UI schema remains constant for all A2UI responses. -A2UI_SCHEMA = """ +A2UI_SCHEMA = r''' { "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI 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'.", + "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", "properties": { "beginRendering": { @@ -228,7 +228,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -276,7 +276,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -324,7 +324,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -666,7 +666,7 @@ }, "path": { "type": "string", - "description": "An optional path to a location within the data model (e.g., 'user.name'). If omitted, the entire data model will be replaced." + "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", @@ -688,11 +688,16 @@ "valueBoolean": { "type": "boolean" }, - "valueList": { + "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.", "properties": { + "key": { + "type": "string" + }, "valueString": { "type": "string" }, @@ -702,7 +707,8 @@ "valueBoolean": { "type": "boolean" } - } + }, + "required": ["key"] } } }, @@ -725,7 +731,7 @@ } } } -""" +''' RESTAURANT_UI_EXAMPLES = """ ---BEGIN SINGLE_COLUMN_LIST_EXAMPLE--- @@ -753,7 +759,24 @@ "surfaceId": "default", "path": "/", "contents": [ - {{ "key": "items", "valueList": [] }} // Populate this with restaurant data + {{ "key": "items", "valueMap": [ + {{ "key": "item1", "valueMap": [ + {{ "key": "name", "valueString": "The Fancy Place" }}, + {{ "key": "rating", "valueNumber": 4.8 }}, + {{ "key": "detail", "valueString": "Fine dining experience" }}, + {{ "key": "infoLink", "valueString": "https://example.com/fancy" }}, + {{ "key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }}, + {{ "key": "address", "valueString": "123 Main St" }} + ] }}, + {{ "key": "item2", "valueMap": [ + {{ "key": "name", "valueString": "Quick Bites" }}, + {{ "key": "rating", "valueNumber": 4.2 }}, + {{ "key": "detail", "valueString": "Casual and fast" }}, + {{ "key": "infoLink", "valueString": "https://example.com/quick" }}, + {{ "key": "imageUrl", "valueString": "https://example.com/quick.jpg" }}, + {{ "key": "address", "valueString": "456 Oak Ave" }} + ] }} + ] }} // Populate this with restaurant data ] }} }} ] @@ -794,7 +817,24 @@ "surfaceId": "default", "path": "/", "contents": [ - {{ "key": "items", "valueList": [] }} // Populate this with restaurant data + {{ "key": "items", "valueMap": [ + {{ "key": "item1", "valueMap": [ + {{ "key": "name", "valueString": "The Fancy Place" }}, + {{ "key": "rating", "valueNumber": 4.8 }}, + {{ "key": "detail", "valueString": "Fine dining experience" }}, + {{ "key": "infoLink", "valueString": "https://example.com/fancy" }}, + {{ "key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }}, + {{ "key": "address", "valueString": "123 Main St" }} + ] }}, + {{ "key": "item2", "valueMap": [ + {{ "key": "name", "valueString": "Quick Bites" }}, + {{ "key": "rating", "valueNumber": 4.2 }}, + {{ "key": "detail", "valueString": "Casual and fast" }}, + {{ "key": "infoLink", "valueString": "https://example.com/quick" }}, + {{ "key": "imageUrl", "valueString": "https://example.com/quick.jpg" }}, + {{ "key": "address", "valueString": "456 Oak Ave" }} + ] }} + ] }} // Populate this with restaurant data ] }} }} ] @@ -890,7 +930,7 @@ def get_ui_prompt(base_url: str, examples: str) -> str: 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. --- UI TEMPLATE RULES --- - - If the query is for a list of restaurants, use the restaurant data you have already received from the `get_restaurants` tool to populate the `dataModelUpdate.contents` array (e.g., as a `valueList` for the "items" key). + - If the query is for a list of restaurants, use the restaurant data you have already received from the `get_restaurants` tool to populate the `dataModelUpdate.contents` array (e.g., as a `valueMap` for the "items" key). - If the number of restaurants is 5 or fewer, you MUST use the `SINGLE_COLUMN_LIST_EXAMPLE` template. - If the number of restaurants is more than 5, you MUST use the `TWO_COLUMN_LIST_EXAMPLE` template. - If the query is to book a restaurant (e.g., "USER_WANTS_TO_BOOK..."), you MUST use the `BOOKING_FORM_EXAMPLE` template. diff --git a/docs/a2ui_protocol.md b/docs/a2ui_protocol.md index 601b1f9b..86d38f6a 100644 --- a/docs/a2ui_protocol.md +++ b/docs/a2ui_protocol.md @@ -72,7 +72,7 @@ This document specifies the architecture and data formats for the A2UI protocol. The central philosophy of A2UI is the decoupling of three key elements: -1. **The Component Tree (The Structure):** A server-provided tree of abstract components that describes the UI's structure. This is defined by `componentUpdate` messages. +1. **The Component Tree (The Structure):** A server-provided tree of abstract components that describes the UI's structure. This is defined by `surfaceUpdate` messages. 2. **The Data Model (The State):** A server-provided JSON object containing the dynamic values that populate the UI, such as text, booleans, or lists. This is managed via `dataModelUpdate` messages. 3. **The Widget Registry (The "Catalog"):** A client-defined mapping of component types (e.g., "Row", "Text") to concrete, native widget implementations. This registry is **part of the client application**, not the protocol stream. The server must generate components that the target client's registry understands. @@ -292,7 +292,7 @@ Container components (`Row`, `Column`, `List`) define their children using a `ch To render dynamic lists, a container uses the `template` property. -1. `dataBinding`: A path to a list in the data model (e.g., `user.posts`). +1. `dataBinding`: A path to a list in the data model (e.g., `/user/posts`). 2. `componentId`: The `id` of another component in the buffer to use as a template for each item in the list. The client will iterate over the list at `dataBinding` and, for each item, render the component specified by `componentId`. The item's data is made available to the template component for relative data binding. @@ -306,8 +306,9 @@ A2UI enforces a clean separation between the UI's structure (components) and its This message is the only way to modify the client's data model. - `surfaceId`: The unique identifier for the UI surface this data model update applies to. -- `path`: An optional path to a location within the data model (e.g., 'user.name'). If omitted, the update applies to the root of the data model. -- `contents`: An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value\*' property (e.g. `valueString`, `valueNumber`, `valueBoolean`, `valueList`). +- `path`: An optional path to a location within the data model (e.g., '/user/name'). If omitted, the update applies to the root of the data model. +- `contents`: An array of data entries arranged as an adjacency list. Each entry must contain a 'key' and exactly one corresponding typed 'value\*' property (e.g. `valueString`, `valueNumber`, `valueBoolean`, `valueMap`). + - `valueMap`: A JSON object representing a map as an adjacency list. #### Example: Updating the data model @@ -318,7 +319,14 @@ This message is the only way to modify the client's data model. "path": "user", "contents": [ { "key": "name", "valueString": "Bob" }, - { "key": "isVerified", "valueBoolean": true } + { "key": "isVerified", "valueBoolean": true }, + { + "key": "address", + "valueMap": [ + { "key": "street", "valueString": "123 Main St" }, + { "key": "city", "valueString": "Anytown" } + ] + } ] } } @@ -350,7 +358,7 @@ A component can also bind to numbers (`literalNumber`), booleans (`literalBoolea - **Path Only**: If only `path` is provided, the value is dynamic. It's resolved from the data model at render time. ```json - "text": { "path": "user.name" } + "text": { "path": "/user/name" } ``` - **Path and Literal Value (Initialization Shorthand)**: If **both** `path` and a `literal*` value are provided, it serves as a shorthand for data model initialization. The client MUST: @@ -361,8 +369,8 @@ A component can also bind to numbers (`literalNumber`), booleans (`literalBoolea This allows the server to set a default value and bind to it in a single step. ```json - // This initializes data model at 'user.name' to "Guest" and binds to it. - "text": { "path": "user.name", "literalString": "Guest" } + // This initializes data model at '/user/name' to "Guest" and binds to it. + "text": { "path": "/user/name", "literalString": "Guest" } ``` The client's interpreter is responsible for resolving these paths against the data model before rendering. The A2UI protocol supports direct 1:1 binding; it does not include transformers (e.g., formatters, conditionals). Any data transformation must be performed by the server before sending it in a `dataModelUpdate`. @@ -454,7 +462,7 @@ This message provides a feedback mechanism for the server. It is sent when the c "context": [ { "key": "userInput", - "value": { "path": "form.textField" } + "value": { "path": "/form/textField" } }, { "key": "formId", "value": { "literalString": "f-123" } } ] @@ -473,9 +481,8 @@ This message provides a feedback mechanism for the server. It is sent when the c { "dataModelUpdate": { "surfaceId": "main_content_area", - "form": { - "textField": "User input text" - } + "path": "form", + "contents": [{ "key": "textField", "valueString": "User input text" }] } } ``` @@ -506,12 +513,12 @@ This message provides a feedback mechanism for the server. It is sent when the c A robust client-side interpreter for A2UI should be composed of several key components: - **JSONL Parser:** A parser capable of reading the stream line by line and decoding each line as a separate JSON object. -- **Message Dispatcher:** A mechanism (e.g., a `switch` statement) to identify the message type (`streamHeader`, `componentUpdate`, etc.) and route it to the correct handler. +- **Message Dispatcher:** A mechanism (e.g., a `switch` statement) to identify the message type (`beginRendering`, `surfaceUpdate`, etc.) and route it to the correct handler. - **Component Buffer:** A `Map` that stores all component instances by their `id`. This is populated by `componentUpdate` messages. - **Data Model Store:** A `Map` (or similar) that holds the application state. This is built and modified by `dataModelUpdate` messages. - **Interpreter State:** A state machine to track if the client is ready to render (e.g., a `_isReadyToRender` boolean that is set to `true` by `beginRendering`). - **Widget Registry**: A developer-provided map (e.g., `Map`) that associates component type strings ("Row", "Text") with functions that build native widgets. -- **Binding Resolver:** A utility that can take a `BoundValue` (e.g., `{ "path": "user.name" }`) and resolve it against the Data Model Store. +- **Binding Resolver:** A utility that can take a `BoundValue` (e.g., `{ "path": "/user/name" }`) and resolve it against the Data Model Store. - **Surface Manager:** Logic to create, update, and delete UI surfaces based on `surfaceId`. - **Event Handler:** A function, exposed to the `WidgetRegistry`, that constructs and sends the client event message (e.g., `userAction`) to the configured REST API endpoint. @@ -575,6 +582,10 @@ This section provides the formal JSON Schema for a single server-to-client messa "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.", @@ -649,6 +660,24 @@ This section provides the formal JSON Schema for a single server-to-client messa }, "required": ["url"] }, + "Icon": { + "type": "object", + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. 'icon.name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["name"] + }, "Video": { "type": "object", "properties": { @@ -712,7 +741,7 @@ This section provides the formal JSON Schema for a single server-to-client messa }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -760,7 +789,7 @@ This section provides the formal JSON Schema for a single server-to-client messa }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -808,7 +837,7 @@ This section provides the formal JSON Schema for a single server-to-client messa }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -1150,7 +1179,7 @@ This section provides the formal JSON Schema for a single server-to-client messa }, "path": { "type": "string", - "description": "An optional path to a location within the data model (e.g., 'user.name'). If omitted, the entire data model will be replaced." + "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", @@ -1172,11 +1201,16 @@ This section provides the formal JSON Schema for a single server-to-client messa "valueBoolean": { "type": "boolean" }, - "valueList": { + "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.", "properties": { + "key": { + "type": "string" + }, "valueString": { "type": "string" }, @@ -1186,10 +1220,12 @@ This section provides the formal JSON Schema for a single server-to-client messa "valueBoolean": { "type": "boolean" } - } + }, + "required": ["key"] } } - } + }, + "required": ["key"] } } }, diff --git a/eval/src/basic_schema_matcher.ts b/eval/src/basic_schema_matcher.ts index 4f840796..fd530e55 100644 --- a/eval/src/basic_schema_matcher.ts +++ b/eval/src/basic_schema_matcher.ts @@ -33,10 +33,10 @@ export class BasicSchemaMatcher extends SchemaMatcher { return result; } - const pathParts = this.propertyPath.split('.'); + const pathParts = this.propertyPath.split("."); let actualValue = schema; for (const part of pathParts) { - if (actualValue && typeof actualValue === 'object') { + if (actualValue && typeof actualValue === "object") { actualValue = actualValue[part]; } else { actualValue = undefined; diff --git a/eval/src/dev.ts b/eval/src/dev.ts index 725b6b4c..f1b747bc 100644 --- a/eval/src/dev.ts +++ b/eval/src/dev.ts @@ -14,4 +14,4 @@ limitations under the License. */ -import './flows'; +import "./flows"; diff --git a/eval/src/flows.ts b/eval/src/flows.ts index 0567873d..95b538cd 100644 --- a/eval/src/flows.ts +++ b/eval/src/flows.ts @@ -27,7 +27,7 @@ if (process.env.GEMINI_API_KEY) { googleAI({ apiKey: process.env.GEMINI_API_KEY!, experimental_debugTraces: true, - }) + }), ); } if (process.env.OPENAI_API_KEY) { @@ -67,5 +67,5 @@ export const componentGeneratorFlow = ai.defineFlow( if (!output) throw new Error("Failed to generate component"); return output; - } + }, ); diff --git a/eval/src/index.ts b/eval/src/index.ts index 0601c0be..7d479c40 100644 --- a/eval/src/index.ts +++ b/eval/src/index.ts @@ -33,7 +33,7 @@ interface InferenceResult { function generateSummary( resultsByModel: Record, - results: InferenceResult[] + results: InferenceResult[], ): string { const promptNameWidth = 40; const latencyWidth = 20; @@ -44,14 +44,14 @@ function generateSummary( for (const modelName in resultsByModel) { summary += `\n\n## Model: ${modelName}\n\n`; const header = `| ${"Prompt Name".padEnd( - promptNameWidth + promptNameWidth, )} | ${"Avg Latency (ms)".padEnd(latencyWidth)} | ${"Failed Runs".padEnd( - failedRunsWidth + failedRunsWidth, )} | ${"Tool Error Runs".padEnd(toolErrorRunsWidth)} |`; const divider = `|${"-".repeat(promptNameWidth + 2)}|${"-".repeat( - latencyWidth + 2 + latencyWidth + 2, )}|${"-".repeat(failedRunsWidth + 2)}|${"-".repeat( - toolErrorRunsWidth + 2 + toolErrorRunsWidth + 2, )}|`; summary += header; summary += `\n${divider}`; @@ -64,7 +64,7 @@ function generateSummary( acc[result.prompt.name].push(result); return acc; }, - {} as Record + {} as Record, ); let totalModelFailedRuns = 0; @@ -74,7 +74,7 @@ function generateSummary( const totalRuns = runs.length; const errorRuns = runs.filter((r) => r.error).length; const failedRuns = runs.filter( - (r) => r.error || r.validationResults.length > 0 + (r) => r.error || r.validationResults.length > 0, ).length; const totalLatency = runs.reduce((acc, r) => acc + r.latency, 0); const avgLatency = (totalLatency / totalRuns).toFixed(0); @@ -86,9 +86,9 @@ function generateSummary( const errorRunsStr = errorRuns > 0 ? `${errorRuns} / ${totalRuns}` : ""; summary += `\n| ${promptName.padEnd( - promptNameWidth + promptNameWidth, )} | ${avgLatency.padEnd(latencyWidth)} | ${failedRunsStr.padEnd( - failedRunsWidth + failedRunsWidth, )} | ${errorRunsStr.padEnd(toolErrorRunsWidth)} |`; } @@ -100,13 +100,13 @@ function generateSummary( const totalRuns = results.length; const totalToolErrorRuns = results.filter((r) => r.error).length; const totalRunsWithAnyFailure = results.filter( - (r) => r.error || r.validationResults.length > 0 + (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) + .map((r) => r.modelName), ), ].join(", "); @@ -132,7 +132,7 @@ async function main() { } return acc; }, - {} as Record + {} as Record, ); const verbose = !!args.verbose; @@ -156,7 +156,7 @@ async function main() { let filteredModels = modelsToTest; if (typeof args.model === "string") { filteredModels = modelsToTest.filter((m) => - m.name.startsWith(args.model as string) + m.name.startsWith(args.model as string), ); if (filteredModels.length === 0) { console.error(`No model found with prefix "${args.model}".`); @@ -167,7 +167,7 @@ async function main() { let filteredPrompts = prompts; if (typeof args.prompt === "string") { filteredPrompts = prompts.filter((p) => - p.name.startsWith(args.prompt as string) + p.name.startsWith(args.prompt as string), ); if (filteredPrompts.length === 0) { console.error(`No prompt found with prefix "${args.prompt}".`); @@ -180,7 +180,7 @@ async function main() { for (const prompt of filteredPrompts) { const schemaString = fs.readFileSync( path.join(__dirname, prompt.schemaPath), - "utf-8" + "utf-8", ); const schema = JSON.parse(schemaString); for (const modelConfig of filteredModels) { @@ -193,7 +193,7 @@ async function main() { } for (let i = 1; i <= runsPerPrompt; i++) { console.log( - `Queueing generation for model: ${modelConfig.name}, prompt: ${prompt.name} (run ${i})` + `Queueing generation for model: ${modelConfig.name}, prompt: ${prompt.name} (run ${i})`, ); const startTime = Date.now(); generationPromises.push( @@ -207,23 +207,23 @@ async function main() { if (modelOutputDir) { const inputPath = path.join( modelOutputDir, - `${prompt.name}.input.txt` + `${prompt.name}.input.txt`, ); fs.writeFileSync(inputPath, prompt.promptText); const outputPath = path.join( modelOutputDir, - `${prompt.name}.output.json` + `${prompt.name}.output.json`, ); fs.writeFileSync( outputPath, - JSON.stringify(component, null, 2) + JSON.stringify(component, null, 2), ); } const validationResults = validateSchema( component, prompt.schemaPath, - prompt.matchers + prompt.matchers, ); return { modelName: modelConfig.name, @@ -239,13 +239,13 @@ async function main() { if (modelOutputDir) { const inputPath = path.join( modelOutputDir, - `${prompt.name}.input.txt` + `${prompt.name}.input.txt`, ); fs.writeFileSync(inputPath, prompt.promptText); const errorPath = path.join( modelOutputDir, - `${prompt.name}.error.json` + `${prompt.name}.error.json`, ); const errorOutput = { message: error.message, @@ -254,7 +254,7 @@ async function main() { }; fs.writeFileSync( errorPath, - JSON.stringify(errorOutput, null, 2) + JSON.stringify(errorOutput, null, 2), ); } return { @@ -266,7 +266,7 @@ async function main() { validationResults: [], runNumber: i, }; - }) + }), ); } } @@ -302,7 +302,7 @@ async function main() { if (hasValidationFailures) { console.log("Validation Failures:"); result.validationResults.forEach((failure) => - console.log(`- ${failure}`) + console.log(`- ${failure}`), ); } if (verbose) { diff --git a/eval/src/prompts.ts b/eval/src/prompts.ts index da1e4357..29eb5e9b 100644 --- a/eval/src/prompts.ts +++ b/eval/src/prompts.ts @@ -15,23 +15,23 @@ */ import { BasicSchemaMatcher } from "./basic_schema_matcher"; -import { SurfaceUpdateSchemaMatcher } from "./surface_update_schema_matcher"; import { MessageTypeMatcher } from "./message_type_matcher"; import { SchemaMatcher } from "./schema_matcher"; +import { SurfaceUpdateSchemaMatcher } from "./surface_update_schema_matcher"; export interface TestPrompt { - promptText: string; - description: string; name: string; + description: string; schemaPath: string; - matchers?: SchemaMatcher[]; + promptText: string; + matchers: SchemaMatcher[]; } export const prompts: TestPrompt[] = [ { name: "deleteSurface", description: "A DeleteSurface message to remove a UI surface.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", promptText: `Generate a JSON message containing a deleteSurface for the surface 'dashboard-surface-1'.`, matchers: [ new MessageTypeMatcher("deleteSurface"), @@ -43,7 +43,7 @@ export const prompts: TestPrompt[] = [ name: "dogBreedGenerator", description: "A prompt to generate a UI for a dog breed information and generator tool.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", promptText: `Generate a JSON message containing a surfaceUpdate to describe the following UI: A root node has already been created with ID "root". @@ -68,8 +68,18 @@ The dog generator is another card which is a form that generates a fictional dog new MessageTypeMatcher("surfaceUpdate"), new SurfaceUpdateSchemaMatcher("Column"), new SurfaceUpdateSchemaMatcher("Image"), - new SurfaceUpdateSchemaMatcher("TextField", "label", "Dog breed name"), - new SurfaceUpdateSchemaMatcher("TextField", "label", "Number of legs"), + new SurfaceUpdateSchemaMatcher( + "TextField", + "label", + "Dog breed name", + true + ), + new SurfaceUpdateSchemaMatcher( + "TextField", + "label", + "Number of legs", + true + ), new SurfaceUpdateSchemaMatcher("Button", "label", "Generate"), ], }, @@ -77,13 +87,13 @@ The dog generator is another card which is a form that generates a fictional dog name: "loginForm", description: 'A simple login form with username, password, a "remember me" checkbox, and a submit button.', - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), - new SurfaceUpdateSchemaMatcher("TextField", "label", "password"), + new SurfaceUpdateSchemaMatcher("TextField", "label", "username", true), + new SurfaceUpdateSchemaMatcher("TextField", "label", "password", true), new SurfaceUpdateSchemaMatcher("CheckBox", "label", "Remember Me"), new SurfaceUpdateSchemaMatcher("Button", "label", "Sign In"), ], @@ -91,7 +101,7 @@ The dog generator is another card which is a form that generates a fictional dog { name: "productGallery", description: "A gallery of products using a list with a template.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -102,14 +112,35 @@ The dog generator is another card which is a form that generates a fictional dog new SurfaceUpdateSchemaMatcher("Button", "label", "Add to Cart"), ], }, + { + name: "productGalleryData", + description: + "A DataModelUpdate message to populate the product gallery data.", + schemaPath: "../../specification/json/server_to_client.json", + 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: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), + new SurfaceUpdateSchemaMatcher("TextField", "label", "name", true), new SurfaceUpdateSchemaMatcher( "CheckBox", "label", @@ -123,21 +154,21 @@ The dog generator is another card which is a form that generates a fictional dog { name: "dataModelUpdate", description: "A DataModelUpdate message to update user data.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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". @@ -181,25 +212,25 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve "Simple Animal Explorer" ), new SurfaceUpdateSchemaMatcher("TextField", "label", "Search..."), - new SurfaceUpdateSchemaMatcher("Text", "text", "Mammalia"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Carnivora"), + 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", "Artiodactyla"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Artiodactyla"), new SurfaceUpdateSchemaMatcher("Text", "text", "Giraffe"), new SurfaceUpdateSchemaMatcher("Text", "text", "Hippopotamus"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Aves"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Accipitriformes"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Class: Aves"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Accipitriformes"), new SurfaceUpdateSchemaMatcher("Text", "text", "Bald Eagle"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Struthioniformes"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Struthioniformes"), new SurfaceUpdateSchemaMatcher("Text", "text", "Ostrich"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Sphenisciformes"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Sphenisciformes"), new SurfaceUpdateSchemaMatcher("Text", "text", "Penguin"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Reptilia"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Crocodilia"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Class: Reptilia"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Crocodilia"), new SurfaceUpdateSchemaMatcher("Text", "text", "Nile Crocodile"), - new SurfaceUpdateSchemaMatcher("Text", "text", "Squamata"), + new SurfaceUpdateSchemaMatcher("Text", "text", "Order: Squamata"), new SurfaceUpdateSchemaMatcher("Text", "text", "Komodo Dragon"), new SurfaceUpdateSchemaMatcher("Text", "text", "Ball Python"), ], @@ -207,7 +238,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "recipeCard", description: "A UI to display a recipe with ingredients and instructions.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -222,7 +253,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "musicPlayer", description: "A simple music player UI.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -239,7 +270,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "weatherForecast", description: "A UI to display the weather forecast.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -252,7 +283,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "surveyForm", description: "A customer feedback survey form.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -270,7 +301,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "flightBooker", description: "A form to search for flights.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -285,7 +316,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "dashboard", description: "A simple dashboard with statistics.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -302,7 +333,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "contactCard", description: "A UI to display contact information.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -317,7 +348,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "calendarEventCreator", description: "A form to create a new calendar event.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -332,7 +363,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "checkoutPage", description: "A simplified e-commerce checkout page.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -348,7 +379,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "socialMediaPost", description: "A component representing a social media post.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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"), @@ -368,7 +399,7 @@ IMPORTANT: Do not skip any of the classes, orders, or species above. Include eve { name: "eCommerceProductPage", description: "A detailed product page for an e-commerce website.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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. @@ -397,7 +428,7 @@ The right side of the row is another 'Column' for product information: { name: "interactiveDashboard", description: "A dashboard with filters and data cards.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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: @@ -424,7 +455,7 @@ Finally, a large 'Card' at the bottom with a 'Heading' "Revenue Over Time" and a { name: "travelItinerary", description: "A multi-day travel itinerary display.", - schemaPath: "../../specification/json/protocol_schema_llm.json", + schemaPath: "../../specification/json/server_to_client.json", 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'. diff --git a/eval/src/surface_update_schema_matcher.ts b/eval/src/surface_update_schema_matcher.ts index 72d68fc1..52310a60 100644 --- a/eval/src/surface_update_schema_matcher.ts +++ b/eval/src/surface_update_schema_matcher.ts @@ -26,23 +26,49 @@ export class SurfaceUpdateSchemaMatcher extends SchemaMatcher { 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.` }; + 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.` }; + return { + success: false, + error: `'surfaceUpdate' message does not contain a 'components' array.`, + }; } const components = schema.surfaceUpdate.components; - const matchingComponents = components.filter(c => c.component && c.component[this.componentType]); + + 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}'.` }; + return { + success: false, + error: `Failed to find component of type '${this.componentType}'.`, + }; } if (!this.propertyName) { @@ -51,61 +77,131 @@ export class SurfaceUpdateSchemaMatcher extends SchemaMatcher { for (const component of matchingComponents) { const properties = component.component[this.componentType]; - if (properties && properties[this.propertyName] !== undefined) { - if (this.propertyValue === undefined) { - return { success: true }; + 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 }; + } } - 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)}'.` }; + 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}'.` }; + return { + success: false, + error: `Failed to find component of type '${this.componentType}' with property '${this.propertyName}'.`, + }; } } - private valueMatches(propertyValue: any, expectedValue: any): boolean { - if (propertyValue === null || propertyValue === undefined) { + private valueMatches(actualValue: any, expectedValue: any): boolean { + if (actualValue === null || actualValue === undefined) { return false; } - if (typeof propertyValue === 'object' && !Array.isArray(propertyValue)) { - if (propertyValue.literalString !== undefined && propertyValue.literalString === expectedValue) { - return true; + 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 (propertyValue.literalNumber !== undefined && propertyValue.literalNumber === expectedValue) { - return true; + if (actualValue.literalNumber !== undefined) { + return actualValue.literalNumber === expectedValue; } - if (propertyValue.literalBoolean !== undefined && propertyValue.literalBoolean === expectedValue) { - return true; + 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 } - if (Array.isArray(propertyValue)) { - for (const item of propertyValue) { - if (typeof item === 'object' && item !== null) { - if (item.value === 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.label && typeof item.label === 'object' && (item.label.literalString === expectedValue)) { + 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; } } } - if (JSON.stringify(propertyValue) === JSON.stringify(expectedValue)) { - return true; - } - - return false; + // Fallback to direct comparison + return JSON.stringify(actualValue) === JSON.stringify(expectedValue); } } diff --git a/eval/src/validator.ts b/eval/src/validator.ts index 832a18c3..e00e876c 100644 --- a/eval/src/validator.ts +++ b/eval/src/validator.ts @@ -20,7 +20,7 @@ import { SchemaMatcher } from "./schema_matcher"; export function validateSchema( data: any, schemaName: string, - matchers?: SchemaMatcher[] + matchers?: SchemaMatcher[], ): string[] { const errors: string[] = []; if (data.surfaceUpdate) { @@ -33,7 +33,7 @@ export function validateSchema( validateDeleteSurface(data.deleteSurface, errors); } else { errors.push( - "A2UI Protocol message must have one of: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface." + "A2UI Protocol message must have one of: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface.", ); } @@ -89,28 +89,106 @@ function validateDataModelUpdate(data: any, errors: string[]) { if (data.surfaceId === undefined) { errors.push("DataModelUpdate must have a 'surfaceId' property."); } - if (data.dataModelUpdate === undefined) { - errors.push( - "DataModelUpdate must have a nested 'dataModelUpdate' property." - ); - return; - } - const nested = data.dataModelUpdate; - if (nested.contents === undefined) { - errors.push("Nested DataModelUpdate must have a 'contents' property."); - } - const allowed = ["surfaceId", "dataModelUpdate"]; + + const allowedTopLevel = ["surfaceId", "path", "contents"]; for (const key in data) { - if (!allowed.includes(key)) { - errors.push(`Top-level DataModelUpdate has unexpected property: ${key}`); + if (!allowedTopLevel.includes(key)) { + errors.push(`DataModelUpdate has unexpected property: ${key}`); } } - const nestedAllowed = ["path", "contents"]; - for (const key in nested) { - if (!nestedAllowed.includes(key)) { - errors.push(`Nested 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[]) { @@ -122,10 +200,43 @@ function validateBeginRendering(data: any, errors: string[]) { } } +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, - errors: string[] + errors: string[], ) { if (!component.id) { errors.push(`Component is missing an 'id'.`); @@ -139,7 +250,7 @@ function validateComponent( 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}.` + `Component '${component.id}' must have exactly one property in 'component', but found ${componentTypes.length}.`, ); return; } @@ -151,7 +262,7 @@ function validateComponent( for (const prop of props) { if (properties[prop] === undefined) { errors.push( - `Component '${component.id}' of type '${componentType}' is missing required property '${prop}'.` + `Component '${component.id}' of type '${componentType}' is missing required property '${prop}'.`, ); } } @@ -161,7 +272,7 @@ function validateComponent( for (const id of ids) { if (id && !allIds.has(id)) { errors.push( - `Component '${component.id}' references non-existent component ID '${id}'.` + `Component '${component.id}' references non-existent component ID '${id}'.`, ); } } @@ -170,33 +281,160 @@ function validateComponent( 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": @@ -207,7 +445,7 @@ function validateComponent( 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.` + `Component '${component.id}' must have either 'explicitList' or 'template' in children, but not both or neither.`, ); } if (hasExplicit) { @@ -228,15 +466,23 @@ function validateComponent( properties.tabItems.forEach((tab: any) => { if (!tab.title) { errors.push( - `Tab item in component '${component.id}' is missing a 'title'.` + `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'.` + `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; @@ -245,14 +491,31 @@ function validateComponent( checkRefs([properties.entryPointChild, properties.contentChild]); break; case "Button": - checkRequired(["label", "action"]); + 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}'.` + `Unknown component type '${componentType}' in component '${component.id}'.`, ); } } diff --git a/specification/json/server_to_client.json b/specification/json/server_to_client.json index 8e446667..863f9243 100644 --- a/specification/json/server_to_client.json +++ b/specification/json/server_to_client.json @@ -66,7 +66,7 @@ "properties": { "text": { "type": "object", - "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. 'doc.title').", + "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. '/doc/title').", "properties": { "literalString": { "type": "string" @@ -89,7 +89,7 @@ "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. 'hotel.description').", + "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. '/hotel/description').", "properties": { "literalString": { "type": "string" @@ -107,7 +107,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -136,7 +136,7 @@ "properties": { "name": { "type": "object", - "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. 'icon.name').", + "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/icon/name').", "properties": { "literalString": { "type": "string" @@ -154,7 +154,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -172,7 +172,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -184,7 +184,7 @@ }, "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -212,7 +212,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -260,7 +260,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -308,7 +308,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -355,7 +355,7 @@ "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').", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", "properties": { "literalString": { "type": "string" @@ -423,7 +423,7 @@ }, "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').", + "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').", "properties": { "path": { "type": "string" @@ -454,7 +454,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -466,7 +466,7 @@ }, "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').", + "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').", "properties": { "literalBoolean": { "type": "boolean" @@ -484,7 +484,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -496,7 +496,7 @@ }, "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -529,7 +529,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -559,7 +559,7 @@ "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').", + "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').", "properties": { "literalArray": { "type": "array", @@ -580,7 +580,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -610,7 +610,7 @@ "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').", + "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').", "properties": { "literalNumber": { "type": "number" @@ -650,7 +650,7 @@ }, "path": { "type": "string", - "description": "An optional path to a location within the data model (e.g., 'user.name'). If omitted, the entire data model will be replaced." + "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", @@ -672,11 +672,16 @@ "valueBoolean": { "type": "boolean" }, - "valueList": { + "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.", "properties": { + "key": { + "type": "string" + }, "valueString": { "type": "string" }, @@ -686,10 +691,12 @@ "valueBoolean": { "type": "boolean" } - } + }, + "required": ["key"] } } - } + }, + "required": ["key"] } } }, diff --git a/web/contact/contact.ts b/web/contact/contact.ts index 5df2ba55..354e6980 100644 --- a/web/contact/contact.ts +++ b/web/contact/contact.ts @@ -275,7 +275,8 @@ export class A2UIContactFinder extends SignalWatcher(LitElement) { const message: v0_8.Types.A2UIClientEventMessage = { userAction: { - actionName: evt.detail.action.action, + surfaceId: surfaceId, + name: evt.detail.action.name, sourceComponentId: target.id, timestamp: new Date().toISOString(), context, diff --git a/web/contact/package.json b/web/contact/package.json index 32d79e66..7d49f279 100644 --- a/web/contact/package.json +++ b/web/contact/package.json @@ -1,10 +1,10 @@ { - "name": "@a2ui/restaurant", + "name": "@a2ui/contact", "private": true, "version": "0.1.0", - "description": "A2UI Restaurant Demo", - "main": "./dist/restaurant.js", - "types": "./dist/restaurant.d.ts", + "description": "A2UI Contact Demo", + "main": "./dist/contact.js", + "types": "./dist/contact.d.ts", "type": "module", "scripts": { "prepack": "npm run build", @@ -39,7 +39,7 @@ "FORCE_COLOR": "1" }, "dependencies": [ - "#build:tsc" + "../lib#build:tsc" ], "files": [ "**/*.ts", diff --git a/web/lib/src/0.8/data/model-processor.ts b/web/lib/src/0.8/data/model-processor.ts index 519dfb9c..142c0170 100644 --- a/web/lib/src/0.8/data/model-processor.ts +++ b/web/lib/src/0.8/data/model-processor.ts @@ -239,8 +239,8 @@ export class A2UIModelProcessor { if (!valueKey) continue; let value: DataValue = item[valueKey]; - // It's a valueList. We must unwrap its contents. - if (valueKey === "valueList" && Array.isArray(value)) { + // It's a valueMap. We must unwrap its contents. + if (valueKey === "valueMap" && Array.isArray(value)) { value = value.map((wrappedItem: unknown) => { if (!isObject(wrappedItem)) return null; const innerValueKey = this.#findValueKey(wrappedItem); diff --git a/web/lib/src/0.8/schemas/server_to_client.json b/web/lib/src/0.8/schemas/server_to_client.json index 8e446667..863f9243 100644 --- a/web/lib/src/0.8/schemas/server_to_client.json +++ b/web/lib/src/0.8/schemas/server_to_client.json @@ -66,7 +66,7 @@ "properties": { "text": { "type": "object", - "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. 'doc.title').", + "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. '/doc/title').", "properties": { "literalString": { "type": "string" @@ -89,7 +89,7 @@ "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. 'hotel.description').", + "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. '/hotel/description').", "properties": { "literalString": { "type": "string" @@ -107,7 +107,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -136,7 +136,7 @@ "properties": { "name": { "type": "object", - "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. 'icon.name').", + "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/icon/name').", "properties": { "literalString": { "type": "string" @@ -154,7 +154,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -172,7 +172,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -184,7 +184,7 @@ }, "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -212,7 +212,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -260,7 +260,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -308,7 +308,7 @@ }, "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 list in the data model.", + "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.", "properties": { "componentId": { "type": "string" @@ -355,7 +355,7 @@ "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').", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", "properties": { "literalString": { "type": "string" @@ -423,7 +423,7 @@ }, "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').", + "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').", "properties": { "path": { "type": "string" @@ -454,7 +454,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -466,7 +466,7 @@ }, "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').", + "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').", "properties": { "literalBoolean": { "type": "boolean" @@ -484,7 +484,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -496,7 +496,7 @@ }, "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -529,7 +529,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -559,7 +559,7 @@ "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').", + "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').", "properties": { "literalArray": { "type": "array", @@ -580,7 +580,7 @@ "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').", + "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').", "properties": { "literalString": { "type": "string" @@ -610,7 +610,7 @@ "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').", + "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').", "properties": { "literalNumber": { "type": "number" @@ -650,7 +650,7 @@ }, "path": { "type": "string", - "description": "An optional path to a location within the data model (e.g., 'user.name'). If omitted, the entire data model will be replaced." + "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", @@ -672,11 +672,16 @@ "valueBoolean": { "type": "boolean" }, - "valueList": { + "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.", "properties": { + "key": { + "type": "string" + }, "valueString": { "type": "string" }, @@ -686,10 +691,12 @@ "valueBoolean": { "type": "boolean" } - } + }, + "required": ["key"] } } - } + }, + "required": ["key"] } } }, diff --git a/web/lib/src/0.8/types/types.ts b/web/lib/src/0.8/types/types.ts index 85470dc4..df13be03 100644 --- a/web/lib/src/0.8/types/types.ts +++ b/web/lib/src/0.8/types/types.ts @@ -220,7 +220,8 @@ export interface DataModelUpdate { valueNumber?: number; valueBoolean?: boolean; - valueList?: { + valueMap?: { + key: string; valueString?: string /** May be JSON */; valueNumber?: number; valueBoolean?: boolean;