diff --git a/renderers/lit/src/0.8/ui/checkbox.ts b/renderers/lit/src/0.8/ui/checkbox.ts index 00d6c4e12..d5663aadc 100644 --- a/renderers/lit/src/0.8/ui/checkbox.ts +++ b/renderers/lit/src/0.8/ui/checkbox.ts @@ -22,6 +22,7 @@ import { A2uiMessageProcessor } from "@a2ui/web_core/data/model-processor"; import { classMap } from "lit/directives/class-map.js"; import { styleMap } from "lit/directives/style-map.js"; import { structuralStyles } from "./styles.js"; +import { extractStringValue } from "./utils/utils.js"; @customElement("a2ui-checkbox") export class Checkbox extends Root { @@ -70,6 +71,8 @@ export class Checkbox extends Root { return; } + + this.processor.setData( this.component, this.value.path, @@ -100,7 +103,12 @@ export class Checkbox extends Root { .checked=${value} /> ${extractStringValue( + this.label, + this.component, + this.processor, + this.surfaceId + )} `; } @@ -113,6 +121,7 @@ export class Checkbox extends Root { return this.#renderField(this.value.literal); } else if (this.value && "path" in this.value && this.value.path) { if (!this.processor || !this.component) { + return html`(no model)`; } @@ -122,6 +131,8 @@ export class Checkbox extends Root { this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID ); + + if (textValue === null) { return html`Invalid label`; } diff --git a/renderers/lit/src/0.8/ui/icon.ts b/renderers/lit/src/0.8/ui/icon.ts index 92cab4fd3..1e6138350 100644 --- a/renderers/lit/src/0.8/ui/icon.ts +++ b/renderers/lit/src/0.8/ui/icon.ts @@ -41,6 +41,24 @@ export class Icon extends Root { min-height: 0; overflow: auto; } + + .g-icon { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'liga'; + } `, ]; diff --git a/renderers/lit/src/0.8/ui/multiple-choice.ts b/renderers/lit/src/0.8/ui/multiple-choice.ts index 990aa239d..29f636aa2 100644 --- a/renderers/lit/src/0.8/ui/multiple-choice.ts +++ b/renderers/lit/src/0.8/ui/multiple-choice.ts @@ -191,8 +191,11 @@ export class MultipleChoice extends Root { } getCurrentSelections(): string[] { - if (!this.processor || !this.component || Array.isArray(this.selections)) { - return []; + if (!this.processor || !this.component) { + return Array.isArray(this.selections) ? this.selections : []; + } + if (Array.isArray(this.selections)) { + return this.selections; } const selectionValue = this.processor.getData( diff --git a/samples/agent/adk/component_gallery/__main__.py b/samples/agent/adk/component_gallery/__main__.py new file mode 100644 index 000000000..6fdb3affe --- /dev/null +++ b/samples/agent/adk/component_gallery/__main__.py @@ -0,0 +1,94 @@ + +"""Main entry point for the Component Gallery agent.""" +import logging +import os +import sys + +import click +import uvicorn +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from a2ui.extension.a2ui_extension import get_a2ui_agent_extension +from starlette.middleware.cors import CORSMiddleware +from dotenv import load_dotenv + +from agent import ComponentGalleryAgent +from agent_executor import ComponentGalleryExecutor + +load_dotenv() + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + force=True, +) + +logger = logging.getLogger(__name__) + +@click.command() +@click.option("--host", default="localhost") +@click.option("--port", default=10005) +def main(host, port): + try: + capabilities = AgentCapabilities( + streaming=True, + extensions=[get_a2ui_agent_extension()], + ) + + # Skill definition + skill = AgentSkill( + id="component_gallery", + name="Component Gallery", + description="Demonstrates A2UI components.", + tags=["gallery", "demo"], + examples=["Show me the gallery"], + ) + + base_url = f"http://{host}:{port}" + + agent_card = AgentCard( + name="Component Gallery Agent", + description="A2UI Component Gallery", + url=base_url, + version="0.0.1", + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=capabilities, + skills=[skill], + ) + + agent_executor = ComponentGalleryExecutor(base_url=base_url) + + request_handler = DefaultRequestHandler( + agent_executor=agent_executor, + task_store=InMemoryTaskStore(), + ) + + server = A2AStarletteApplication( + agent_card=agent_card, http_handler=request_handler + ) + + app = server.build() + + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Check if images dir exists before mounting? Skipping for now. + + print(f"Starting Component Gallery Agent on port {port}...") + uvicorn.run(app, host=host, port=port) + + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) + +if __name__ == "__main__": + main() diff --git a/samples/agent/adk/component_gallery/a2ui_schema.py b/samples/agent/adk/component_gallery/a2ui_schema.py new file mode 100644 index 000000000..f4c776d80 --- /dev/null +++ b/samples/agent/adk/component_gallery/a2ui_schema.py @@ -0,0 +1,792 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# a2ui_schema.py + +A2UI_SCHEMA = r''' +{ + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", + "type": "object", + "properties": { + "beginRendering": { + "type": "object", + "description": "Signals the client to begin rendering a surface with a root component and specific styles.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "root": { + "type": "string", + "description": "The ID of the root component to render." + }, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "properties": { + "font": { + "type": "string", + "description": "The primary font for the UI." + }, + "primaryColor": { + "type": "string", + "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + } + } + } + }, + "required": ["root", "surfaceId"] + }, + "surfaceUpdate": { + "type": "object", + "description": "Updates a surface with a new set of components.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." + }, + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "type": "object", + "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this component." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, + "component": { + "type": "object", + "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", + "properties": { + "Text": { + "type": "object", + "properties": { + "text": { + "type": "object", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "usageHint": { + "type": "string", + "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": ["text"] + }, + "Image": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": [ + "contain", + "cover", + "fill", + "none", + "scale-down" + ] + }, + "usageHint": { + "type": "string", + "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", + "enum": [ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header" + ] + } + }, + "required": ["url"] + }, + "Icon": { + "type": "object", + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", + "properties": { + "literalString": { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "payment", + "person", + "phone", + "photo", + "print", + "refresh", + "search", + "send", + "settings", + "share", + "shoppingCart", + "star", + "starHalf", + "starOff", + "upload", + "visibility", + "visibilityOff", + "warning" + ] + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["name"] + }, + "Video": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "AudioPlayer": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "description": { + "type": "object", + "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "Row": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Column": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"] + } + }, + "required": ["children"] + }, + "List": { + "type": "object", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Card": { + "type": "object", + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to be rendered inside the card." + } + }, + "required": ["child"] + }, + "Tabs": { + "type": "object", + "properties": { + "tabItems": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "items": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "child": { + "type": "string" + } + }, + "required": ["title", "child"] + } + } + }, + "required": ["tabItems"] + }, + "Divider": { + "type": "object", + "properties": { + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"] + } + } + }, + "Modal": { + "type": "object", + "properties": { + "entryPointChild": { + "type": "string", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." + }, + "contentChild": { + "type": "string", + "description": "The ID of the component to be displayed inside the modal." + } + }, + "required": ["entryPointChild", "contentChild"] + }, + "Button": { + "type": "object", + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to display in the button, typically a Text component." + }, + "primary": { + "type": "boolean", + "description": "Indicates if this button should be styled as the primary action." + }, + "action": { + "type": "object", + "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "object", + "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", + "properties": { + "path": { + "type": "string" + }, + "literalString": { + "type": "string" + }, + "literalNumber": { + "type": "number" + }, + "literalBoolean": { + "type": "boolean" + } + } + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["name"] + } + }, + "required": ["child", "action"] + }, + "CheckBox": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "object", + "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", + "properties": { + "literalBoolean": { + "type": "boolean" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["label", "value"] + }, + "TextField": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "text": { + "type": "object", + "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "textFieldType": { + "type": "string", + "description": "The type of input field to display.", + "enum": [ + "date", + "longText", + "number", + "shortText", + "obscured" + ] + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["label"] + }, + "DateTimeInput": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date." + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time." + }, + "outputFormat": { + "type": "string", + "description": "The desired format for the output string after a date or time is selected." + } + }, + "required": ["value"] + }, + "MultipleChoice": { + "type": "object", + "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').", + "properties": { + "literalArray": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + } + } + }, + "options": { + "type": "array", + "description": "An array of available options for the user to choose from.", + "items": { + "type": "object", + "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').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "string", + "description": "The value to be associated with this option when selected." + } + }, + "required": ["label", "value"] + } + }, + "maxAllowedSelections": { + "type": "integer", + "description": "The maximum number of options that the user is allowed to select." + } + }, + "required": ["selections", "options"] + }, + "Slider": { + "type": "object", + "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').", + "properties": { + "literalNumber": { + "type": "number" + }, + "path": { + "type": "string" + } + } + }, + "minValue": { + "type": "number", + "description": "The minimum value of the slider." + }, + "maxValue": { + "type": "number", + "description": "The maximum value of the slider." + } + }, + "required": ["value"] + } + } + } + }, + "required": ["id", "component"] + } + } + }, + "required": ["surfaceId", "components"] + }, + "dataModelUpdate": { + "type": "object", + "description": "Updates the data model for a surface.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." + }, + "contents": { + "type": "array", + "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", + "items": { + "type": "object", + "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", + "properties": { + "key": { + "type": "string", + "description": "The key for this data entry." + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + }, + "valueMap": { + "description": "Represents a map as an adjacency list.", + "type": "array", + "items": { + "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", + "properties": { + "key": { + "type": "string" + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + } + }, + "required": ["key"] + } + } + }, + "required": ["key"] + } + } + }, + "required": ["contents", "surfaceId"] + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"] + } + } +} +''' diff --git a/samples/agent/adk/component_gallery/agent.py b/samples/agent/adk/component_gallery/agent.py new file mode 100644 index 000000000..6c6624c2e --- /dev/null +++ b/samples/agent/adk/component_gallery/agent.py @@ -0,0 +1,78 @@ + +"""Agent logic for the Component Gallery.""" +import logging +import json +from collections.abc import AsyncIterable +from typing import Any + +from google.adk.agents.llm_agent import LlmAgent +from google.adk.artifacts import InMemoryArtifactService +from google.adk.memory.in_memory_memory_service import InMemoryMemoryService +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from gallery_examples import get_gallery_json + +logger = logging.getLogger(__name__) + +class ComponentGalleryAgent: + """An agent that displays a component gallery.""" + + def __init__(self, base_url: str): + self.base_url = base_url + + async def stream(self, query: str, session_id: str) -> AsyncIterable[dict[str, Any]]: + """Streams the gallery or responses to actions.""" + + logger.info(f"Stream called with query: {query}") + + # Initial Load or Reset + if "WHO_ARE_YOU" in query or "START" in query: # Simple trigger for initial load + gallery_json = get_gallery_json() + response = f"Here is the component gallery.\n---a2ui_JSON---\n{gallery_json}" + yield { + "is_task_complete": True, + "content": response + } + return + + # Handle Actions + if query.startswith("ACTION:"): + action_name = query + # Create a response update for the second surface + import datetime + import asyncio + + # Simulate network/processing delay + await asyncio.sleep(0.5) + + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + + response_update = [ + { + "surfaceUpdate": { + "surfaceId": "response-surface", + "components": [ + { + "id": "response-text", + "component": { + "Text": { "text": { "literalString": f"Agent Processed Action: {action_name} at {timestamp}" } } + } + } + ] + } + } + ] + + json_str = json.dumps(response_update) + response = f"Action processed.\n---a2ui_JSON---\n{json_str}" + yield { + "is_task_complete": True, + "content": response + } + return + + # Fallback for text + yield { + "is_task_complete": True, + "content": "I am the Component Gallery Agent." + } diff --git a/samples/agent/adk/component_gallery/agent_executor.py b/samples/agent/adk/component_gallery/agent_executor.py new file mode 100644 index 000000000..2fadfc1cd --- /dev/null +++ b/samples/agent/adk/component_gallery/agent_executor.py @@ -0,0 +1,83 @@ + +"""Agent executor for Component Gallery.""" +import logging +import json +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import ( + DataPart, + Part, + TaskState, + TextPart +) +from a2a.utils import new_agent_parts_message, new_task +from agent import ComponentGalleryAgent +from a2ui.extension.a2ui_extension import create_a2ui_part, try_activate_a2ui_extension + +logger = logging.getLogger(__name__) + +class ComponentGalleryExecutor(AgentExecutor): + def __init__(self, base_url: str): + self.agent = ComponentGalleryAgent(base_url=base_url) + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + query = "START" # Default start + ui_event_part = None + + try_activate_a2ui_extension(context) + + if context.message and context.message.parts: + for part in context.message.parts: + if isinstance(part.root, DataPart): + if "userAction" in part.root.data: + ui_event_part = part.root.data["userAction"] + elif "request" in part.root.data: + query = part.root.data["request"] + elif isinstance(part.root, TextPart): + # If user says something, might want to handle it, but for now defaults to START usually for initial connection + if part.root.text: + query = part.root.text + + if ui_event_part: + action = ui_event_part.get("name") + ctx = ui_event_part.get("context", {}) + query = f"ACTION: {action} with {ctx}" + + task = context.current_task + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + + updater = TaskUpdater(event_queue, task.id, task.context_id) + + async for item in self.agent.stream(query, task.context_id): + content = item["content"] + final_parts = [] + + if "---a2ui_JSON---" in content: + text_content, json_string = content.split("---a2ui_JSON---", 1) + if text_content.strip(): + final_parts.append(Part(root=TextPart(text=text_content.strip()))) + + if json_string.strip(): + try: + json_data = json.loads(json_string.strip()) + if isinstance(json_data, list): + for msg in json_data: + final_parts.append(create_a2ui_part(msg)) + else: + final_parts.append(create_a2ui_part(json_data)) + except Exception as e: + logger.error(f"Failed to parse JSON: {e}") + else: + final_parts.append(Part(root=TextPart(text=content))) + + await updater.update_status( + TaskState.completed, + new_agent_parts_message(final_parts, task.context_id, task.id), + final=True + ) + + async def cancel(self, request, event_queue): + pass diff --git a/samples/agent/adk/component_gallery/gallery_examples.py b/samples/agent/adk/component_gallery/gallery_examples.py new file mode 100644 index 000000000..f3d28fe19 --- /dev/null +++ b/samples/agent/adk/component_gallery/gallery_examples.py @@ -0,0 +1,329 @@ + +"""Defines the Component Gallery 'Kitchen Sink' example.""" +import json + +def get_gallery_json() -> str: + """Returns the JSON structure for the Component Gallery surfaces.""" + + messages = [] + + # Common Data Model + # We use a single global data model for simplicity across all demo surfaces. + # Common Data Model Content + # We define the content here and inject it into EACH surface so they all share the same initial state. + gallery_data_content = { + "key": "galleryData", + "valueMap": [ + { "key": "textField", "valueString": "Hello World" }, + { "key": "checkbox", "valueBoolean": False }, + { "key": "checkboxChecked", "valueBoolean": True }, + { "key": "slider", "valueNumber": 30 }, + { "key": "date", "valueString": "2025-10-26" }, + { "key": "favorites", "valueMap": [ + { "key": "0", "valueString": "A" } + ]} + ] + } + + # Helper to create a surface for a single component + def add_demo_surface(surface_id, component_def): + root_id = f"{surface_id}-root" + + components = [] + components.append({ + "id": root_id, + "component": component_def + }) + + messages.append({ "beginRendering": { "surfaceId": surface_id, "root": root_id } }) + messages.append({ "surfaceUpdate": { "surfaceId": surface_id, "components": components } }) + + # Inject data model for this surface + messages.append({ + "dataModelUpdate": { + "surfaceId": surface_id, + "contents": [gallery_data_content] + } + }) + + # 1. TextField + add_demo_surface("demo-text", { + "TextField": { + "label": { "literalString": "Enter some text" }, + "text": { "path": "galleryData/textField" } + } + }) + + # 2. CheckBox + add_demo_surface("demo-checkbox", { + "CheckBox": { + "label": { "literalString": "Toggle me" }, + "value": { "path": "galleryData/checkbox" } + } + }) + + # 3. Slider + add_demo_surface("demo-slider", { + "Slider": { + "value": { "path": "galleryData/slider" }, + "minValue": 0, + "maxValue": 100 + } + }) + + # 4. DateTimeInput + add_demo_surface("demo-date", { + "DateTimeInput": { + "value": { "path": "galleryData/date" }, + "enableDate": True + } + }) + + # 5. MultipleChoice + add_demo_surface("demo-multichoice", { + "MultipleChoice": { + "selections": { "path": "galleryData/favorites" }, + "options": [ + { "label": { "literalString": "Apple" }, "value": "A" }, + { "label": { "literalString": "Banana" }, "value": "B" }, + { "label": { "literalString": "Cherry" }, "value": "C" } + ] + } + }) + + # 6. Image + add_demo_surface("demo-image", { + "Image": { + "url": { "literalString": "https://picsum.photos/400/200" }, + "usageHint": "mediumFeature" + } + }) + + # 7. Button + # Button needs a child Text component. + button_surface_id = "demo-button" + btn_root_id = "demo-button-root" + btn_text_id = "demo-button-text" + + messages.append({ "beginRendering": { "surfaceId": button_surface_id, "root": btn_root_id } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": button_surface_id, + "components": [ + { + "id": btn_text_id, + "component": { "Text": { "text": { "literalString": "Trigger Action" } } } + }, + { + "id": btn_root_id, + "component": { + "Button": { + "child": btn_text_id, + "primary": True, + "action": { + "name": "custom_action", + "context": [ + { "key": "info", "value": { "literalString": "Custom Button Clicked" } } + ] + } + } + } + } + ] + } + }) + + # 8. Tabs + tabs_surface_id = "demo-tabs" + tabs_root_id = "demo-tabs-root" + tab1_id = "tab-1-content" + tab2_id = "tab-2-content" + + messages.append({ "beginRendering": { "surfaceId": tabs_surface_id, "root": tabs_root_id } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": tabs_surface_id, + "components": [ + { + "id": tab1_id, + "component": { "Text": { "text": { "literalString": "First Tab Content" } } } + }, + { + "id": tab2_id, + "component": { "Text": { "text": { "literalString": "Second Tab Content" } } } + }, + { + "id": tabs_root_id, + "component": { + "Tabs": { + "tabItems": [ + { "title": { "literalString": "View One" }, "child": tab1_id }, + { "title": { "literalString": "View Two" }, "child": tab2_id } + ] + } + } + } + ] + } + }) + + # 9. Icon + icon_surface_id = "demo-icon" + messages.append({ "beginRendering": { "surfaceId": icon_surface_id, "root": "icon-root" } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": icon_surface_id, + "components": [ + { + "id": "icon-root", + "component": { + "Row": { + "children": { "explicitList": ["icon-1", "icon-2", "icon-3"] }, + "distribution": "spaceEvenly", + "alignment": "center" + } + } + }, + { "id": "icon-1", "component": { "Icon": { "name": { "literalString": "star" } } } }, + { "id": "icon-2", "component": { "Icon": { "name": { "literalString": "home" } } } }, + { "id": "icon-3", "component": { "Icon": { "name": { "literalString": "settings" } } } } + ] + } + }) + + # 10. Divider + div_surface_id = "demo-divider" + messages.append({ "beginRendering": { "surfaceId": div_surface_id, "root": "div-root" } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": div_surface_id, + "components": [ + { + "id": "div-root", + "component": { + "Column": { + "children": { "explicitList": ["div-text-1", "div-horiz", "div-text-2"] }, + "distribution": "start", + "alignment": "stretch" + } + } + }, + { "id": "div-text-1", "component": { "Text": { "text": { "literalString": "Above Divider" } } } }, + { "id": "div-horiz", "component": { "Divider": { "axis": "horizontal" } } }, + { "id": "div-text-2", "component": { "Text": { "text": { "literalString": "Below Divider" } } } } + ] + } + }) + + # 11. Card + card_surface_id = "demo-card" + messages.append({ "beginRendering": { "surfaceId": card_surface_id, "root": "card-root" } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": card_surface_id, + "components": [ + { + "id": "card-root", + "component": { + "Card": { + "child": "card-text" + } + } + }, + { "id": "card-text", "component": { "Text": { "text": { "literalString": "I am inside a Card" } } } } + ] + } + }) + + # 12. Video + add_demo_surface("demo-video", { + "Video": { + "url": { "literalString": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" } + } + }) + + # 13. Modal + # Modal needs an entry point (Button) and content. + modal_surface_id = "demo-modal" + messages.append({ "beginRendering": { "surfaceId": modal_surface_id, "root": "modal-root" } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": modal_surface_id, + "components": [ + { + "id": "modal-root", + "component": { + "Modal": { + "entryPointChild": "modal-btn", + "contentChild": "modal-content" + } + } + }, + { + "id": "modal-btn", + "component": { + "Button": { + "child": "modal-btn-text", + "primary": False, + "action": { "name": "noop" } + } + } + }, + { "id": "modal-btn-text", "component": { "Text": { "text": { "literalString": "Open Modal" } } } }, + { + "id": "modal-content", + "component": { "Text": { "text": { "literalString": "This is the modal content!" } } } + } + ] + } + }) + + # 14. List + list_surface_id = "demo-list" + messages.append({ "beginRendering": { "surfaceId": list_surface_id, "root": "list-root" } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": list_surface_id, + "components": [ + { + "id": "list-root", + "component": { + "List": { + "children": { "explicitList": ["list-item-1", "list-item-2", "list-item-3"] }, + "direction": "vertical", + "alignment": "stretch" + } + } + }, + { "id": "list-item-1", "component": { "Text": { "text": { "literalString": "Item 1" } } } }, + { "id": "list-item-2", "component": { "Text": { "text": { "literalString": "Item 2" } } } }, + { "id": "list-item-3", "component": { "Text": { "text": { "literalString": "Item 3" } } } } + ] + } + }) + + # 15. AudioPlayer + add_demo_surface("demo-audio", { + "AudioPlayer": { + "url": { "literalString": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3" }, + "description": { "literalString": "Sample Audio" } + } + }) + + # Response Surface + messages.append({ "beginRendering": { "surfaceId": "response-surface", "root": "response-text" } }) + messages.append({ + "surfaceUpdate": { + "surfaceId": "response-surface", + "components": [ + { + "id": "response-text", + "component": { + "Text": { "text": { "literalString": "Interact with the gallery to see responses." } } + } + } + ] + } + }) + + return json.dumps(messages, indent=2) diff --git a/samples/agent/adk/component_gallery/tools.py b/samples/agent/adk/component_gallery/tools.py new file mode 100644 index 000000000..0889380ce --- /dev/null +++ b/samples/agent/adk/component_gallery/tools.py @@ -0,0 +1,2 @@ + +# Minimal tools.py diff --git a/samples/client/lit/component_gallery/README.md b/samples/client/lit/component_gallery/README.md new file mode 100644 index 000000000..3c4a73d9f --- /dev/null +++ b/samples/client/lit/component_gallery/README.md @@ -0,0 +1,30 @@ +# A2UI Generator + +This is a UI to generate and visualize A2UI responses. + +## Prerequisites + +1. [nodejs](https://nodejs.org/en) + +## Running + +This sample depends on the Lit renderer. Before running this sample, you need to build the renderer. + +1. **Build the renderer:** + ```bash + cd ../../../renderers/lit + npm install + npm run build + ``` + +2. **Run this sample:** + ```bash + cd - # back to the sample directory + npm install + ``` + +3. **Run the servers:** + - Run the [A2A server](../../../agent/adk/component_gallery/) + - Run the dev server: `npm run dev` + +After starting the dev server, you can open http://localhost:5173/ to view the sample. \ No newline at end of file diff --git a/samples/client/lit/component_gallery/README_CUSTOM_COMPONENTS.md b/samples/client/lit/component_gallery/README_CUSTOM_COMPONENTS.md new file mode 100644 index 000000000..f94b77c76 --- /dev/null +++ b/samples/client/lit/component_gallery/README_CUSTOM_COMPONENTS.md @@ -0,0 +1,63 @@ +# A2UI Custom Components & Client Architecture Guide + +This guide explains how the **Contact Client** works in tandem with the **Contact Multiple Surfaces Agent** to define and render rich, custom user interfaces. + +## Client-First Extension Model + +This sample demonstrates a powerful pattern where the **Client** controls the capabilities of the agent: + +1. **Component Definition**: This client defines custom components (`OrgChart`, `WebFrame`) in `ui/custom-components/`. +2. **Schema Generation**: Each custom component has an associated JSON schema. +3. **Handshake**: When connecting to the agent, the client sends these schemas in the `metadata.inlineCatalog` field of the initial request. +4. **Dynamic Support**: This allows *any* A2UI agent (that supports inline catalogs) to immediately start using these components without prior knowledge. + +## Custom Components Implemented + +### 1. `OrgChart` +*Located in: `ui/custom-components/org-chart.ts`* +A visual tree illustrating the organizational hierarchy. +- **Implementation**: A standard LitElement component. +- **Interaction**: Emits `chart_node_click` events when nodes are clicked, which are sent back to the agent as A2UI Actions. + +### 2. `WebFrame` (Interactive Iframe) +*Located in: `ui/custom-components/web-frame.ts`* +A tailored iframe wrapper for embedding external content or static HTML tools. +- **Use Case**: Used here to render the "Office Floor Plan" map. +- **Security**: Uses `sandbox` attributes to restrict script execution while allowing necessary interactions. +- **Bridge**: Includes a `postMessage` bridge to allow the embedded content (the map) to trigger A2UI actions in the main application. + +## Multiple Surfaces + +The client is designed to render multiple A2UI "Surfaces" simultaneously. Instead of a single chat stream, the `contact.ts` shell manages: + +- **Main Profile (`contact-card`)**: The primary view. +- **Side Panel (`org-chart-view`)**: A persistent side view for context. +- **Overlay (`location-surface`)**: A temporary surface for specific tasks like map viewing. + +## How to Run in Tandem + +To see this full experience, you must run this client with the specific `contact_multiple_surfaces` agent. + +### 1. Start the Agent +The agent serves the backend logic and the static assets (like the floor plan HTML). +```bash +cd ../../../agent/adk/contact_multiple_surfaces +uv run . +``` +*Runs on port 10004.* + +### 2. Start this Client +The client connects to the agent and renders the UI. +```bash +# In this directory (samples/client/lit/contact) +npm install +npm run dev +``` +*The client acts as a shell, connecting to localhost:10004 by default.* + +## Configuration + +The connection to the agent is configured in `middleware/a2a.ts`. If you need to change the agent port, update the URL in that file: +```typescript +const agentUrl = "http://localhost:10004"; +``` diff --git a/samples/client/lit/component_gallery/client.ts b/samples/client/lit/component_gallery/client.ts new file mode 100644 index 000000000..0603dc992 --- /dev/null +++ b/samples/client/lit/component_gallery/client.ts @@ -0,0 +1,75 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { v0_8 } from "@a2ui/lit"; +import { registerContactComponents } from "./ui/custom-components/register-components.js"; +type A2TextPayload = { + kind: "text"; + text: string; +}; + +type A2DataPayload = { + kind: "data"; + data: v0_8.Types.ServerToClientMessage; +}; + +type A2AServerPayload = + | Array + | { error: string }; + +import { componentRegistry } from "@a2ui/lit/ui"; + +export class A2UIClient { + #ready: Promise = Promise.resolve(); + get ready() { + return this.#ready; + } + + async send( + message: v0_8.Types.A2UIClientEventMessage + ): Promise { + const catalog = componentRegistry.getInlineCatalog(); + const finalMessage = { + ...message, + metadata: { + inlineCatalogs: [catalog], + }, + }; + + const response = await fetch("/a2a", { + body: JSON.stringify(finalMessage), + method: "POST", + }); + + if (response.ok) { + const data = (await response.json()) as A2AServerPayload; + const messages: v0_8.Types.ServerToClientMessage[] = []; + if ("error" in data) { + throw new Error(data.error); + } else { + for (const item of data) { + if (item.kind === "text") continue; + messages.push(item.data); + } + } + return messages; + } + + const error = (await response.json()) as { error: string }; + throw new Error(error.error); + } +} +registerContactComponents(); diff --git a/samples/client/lit/component_gallery/component-gallery.ts b/samples/client/lit/component_gallery/component-gallery.ts new file mode 100644 index 000000000..061e5fbc2 --- /dev/null +++ b/samples/client/lit/component_gallery/component-gallery.ts @@ -0,0 +1,365 @@ + +import { SignalWatcher } from "@lit-labs/signals"; +import { provide } from "@lit/context"; +import { LitElement, html, css, nothing, unsafeCSS } from "lit"; +import { customElement, state, query } from "lit/decorators.js"; +import { theme as uiTheme } from "./theme/theme.js"; +import { A2UIClient } from "./client.js"; +import { v0_8 } from "@a2ui/lit"; +import * as UI from "@a2ui/lit/ui"; +import "./ui/ui.js"; +import "./ui/debug-panel.js"; +import { DebugPanel } from "./ui/debug-panel.js"; + +interface DemoItem { + id: string; + title: string; + description: string; + actionButton?: boolean; // Whether to show a manual "Log Value" button shell-side +} + +const DEMO_ITEMS: DemoItem[] = [ + { id: "demo-text", title: "TextField", description: "Allows user to enter text. Supports binding to data model.", actionButton: true }, + { id: "demo-checkbox", title: "CheckBox", description: "A binary toggle.", actionButton: true }, + { id: "demo-slider", title: "Slider", description: "Select a value from a range.", actionButton: true }, + { id: "demo-date", title: "DateTimeInput", description: "Pick a date or time.", actionButton: true }, + { id: "demo-multichoice", title: "MultipleChoice", description: "Select valid options from a list.", actionButton: true }, + { id: "demo-image", title: "Image", description: "Displays an image from a URL." }, + { id: "demo-button", title: "Button", description: "Triggers a client-side action." }, + { id: "demo-tabs", title: "Tabs", description: "Switch between different views." }, + { id: "demo-icon", title: "Icon", description: "Standard icons." }, + { id: "demo-divider", title: "Divider", description: "Visual separation." }, + { id: "demo-card", title: "Card", description: "A container for other components." }, + { id: "demo-video", title: "Video", description: "Video player." }, + { id: "demo-modal", title: "Modal", description: "Overlay dialog." }, + { id: "demo-list", title: "List", description: "Vertical or horizontal list." }, + { id: "demo-audio", title: "AudioPlayer", description: "Play audio content." }, +]; + +@customElement("a2ui-component-gallery") +export class A2UIComponentGallery extends SignalWatcher(LitElement) { + + @provide({ context: UI.Context.themeContext }) + accessor theme: v0_8.Types.Theme = uiTheme; + + @state() accessor #requesting = false; + @state() accessor #error: string | null = null; + + @query('debug-panel') accessor debugPanel!: DebugPanel; + + #processor = v0_8.Data.createSignalA2uiMessageProcessor(); + #a2uiClient = new A2UIClient(); + + static styles = [ + unsafeCSS(v0_8.Styles.structuralStyles), + css` + :host { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + overflow: hidden; + background: linear-gradient(to bottom right, #0f172a, #1e293b); + color: #f1f5f9; + font-family: 'Roboto', sans-serif; + } + + header { + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(8px); + padding: 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.1); + flex-shrink: 0; + } + + h1 { margin: 0; font-size: 1.2rem; font-weight: 500; letter-spacing: 0.5px; } + + main { + flex: 1; + display: flex; + overflow: hidden; + } + + .gallery-pane { + flex: 1; + overflow-y: auto; + padding: 24px; + border-right: 1px solid rgba(148, 163, 184, 0.1); + display: flex; + flex-direction: column; + gap: 24px; + max-width: 800px; /* Reasonable reading width */ + margin: 0 auto; + width: 100%; + } + + .demo-card { + background: rgba(30, 41, 59, 0.4); + backdrop-filter: blur(12px); + border: 1px solid rgba(148, 163, 184, 0.1); + border-radius: 16px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + } + + .demo-header { + display: flex; + flex-direction: column; + gap: 4px; + border-bottom: 1px solid var(--md-sys-color-outline-variant); + padding-bottom: 12px; + } + + .demo-title { + margin: 0; + font-size: 1.1rem; + font-weight: 500; + color: #f8fafc; + } + + .demo-desc { + margin: 0; + font-size: 0.9rem; + color: #94a3b8; + line-height: 1.5; + } + + .demo-content { + flex: 1; + min-height: 80px; /* Ensure space for component */ + padding: 8px 0; + } + + .action-row { + display: flex; + justify-content: flex-end; + border-top: 1px solid rgba(148, 163, 184, 0.1); + padding-top: 16px; + } + + button.log-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 9999px; + padding: 8px 20px; + color: #38bdf8; + font-weight: 500; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + button.log-btn:hover { + background: var(--md-sys-color-surface-container-high); + } + + .response-pane { + flex: 0 0 320px; + overflow-y: auto; + padding: 24px; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(12px); + border-left: 1px solid rgba(148, 163, 184, 0.1); + } + + .footer { + flex-shrink: 0; + } + + .placeholder { + color: var(--md-sys-color-outline); + font-style: italic; + text-align: center; + margin-top: 40px; + grid-column: 1 / -1; + } + ` + ]; + + async connectedCallback() { + super.connectedCallback(); + await this.#initiateSession(); + } + + async #initiateSession() { + const message: v0_8.Types.A2UIClientEventMessage = { + request: "START_GALLERY" + }; + await this.#sendAndProcessMessage(message); + } + + render() { + return html` +
+

A2UI Component Gallery

+
+
+ +
+ ${this.#renderSurface('response-surface')} +
+
+ + `; + } + + #renderGalleryItems() { + // Check if we have at least one surface loaded to verify connection + if (this.#processor.getSurfaces().size === 0) { + return html`
Loading Gallery...
`; + } + + return DEMO_ITEMS.map(item => html` +
+
+

${item.title}

+

${item.description}

+
+
+ ${this.#renderSurface(item.id)} +
+ ${item.actionButton ? html` +
+ +
+ ` : nothing} +
+ `); + } + + #renderSurface(surfaceId: string) { + const surface = this.#processor.getSurfaces().get(surfaceId); + if (!surface) return html``; + + // Need to spread surface to ensure reactivity? + return html` + this.#handleAction(evt, surfaceId)} + > + `; + } + + // Manual Log Action from Client shell + #logValue(item: DemoItem) { + // Map item IDs to data paths based on knowledge of gallery_examples.py + const pathMap: Record = { + "demo-text": "galleryData/textField", + "demo-checkbox": "galleryData/checkbox", + "demo-slider": "galleryData/slider", + "demo-date": "galleryData/date", + "demo-multichoice": "galleryData/favorites" + }; + + const path = pathMap[item.id]; + if (!path) return; + + // We must pass a mock node because getData expects a component to resolve paths relative to. + const mockNode: any = { dataContextPath: "/" }; + + // Resolve path. Try surface-specific first, then default. + let value = this.#processor.getData(mockNode, path, item.id); + if (value === null) { + value = this.#processor.getData(mockNode, path, v0_8.Data.A2uiMessageProcessor.DEFAULT_SURFACE_ID); + } + + // Construct context for the action + const context = { + path, + value: String(value), + component: item.title + }; + + const message: v0_8.Types.A2UIClientEventMessage = { + userAction: { + surfaceId: item.id, + name: "shell_log_value", + sourceComponentId: "shell-log-btn", + timestamp: new Date().toISOString(), + context + } + }; + + this.#sendAndProcessMessage(message); + } + + async #handleAction(evt: any, surfaceId: string) { + const { action, dataContextPath, sourceComponent } = evt.detail; + const target = evt.composedPath()[0] as HTMLElement; + + const context: any = {}; + if (action.context) { + for (const item of action.context) { + if (item.value.literalBoolean !== undefined) context[item.key] = item.value.literalBoolean; + else if (item.value.literalNumber !== undefined) context[item.key] = item.value.literalNumber; + else if (item.value.literalString !== undefined) context[item.key] = item.value.literalString; + else if (item.value.path) { + const path = this.#processor.resolvePath(item.value.path, dataContextPath); + const value = this.#processor.getData(sourceComponent, path, surfaceId); + context[item.key] = value; + } + } + } + + // Log locally too + this.#log('info', `Action Triggered: ${action.name}`, context); + + const message: v0_8.Types.A2UIClientEventMessage = { + userAction: { + surfaceId, + name: action.name, + sourceComponentId: target.id || 'unknown', + timestamp: new Date().toISOString(), + context + } + }; + + await this.#sendAndProcessMessage(message); + } + + async #sendAndProcessMessage(message: v0_8.Types.A2UIClientEventMessage) { + this.#requesting = true; + + // Log Outgoing + this.#log('outgoing', message.userAction ? `Action: ${message.userAction.name}` : `Request: ${message.request}`, message); + + try { + const response = await this.#a2uiClient.send(message); + + // Log Incoming + if (response.length > 0) { + this.#log('incoming', `Received ${response.length} messages`, response); + } else { + this.#log('info', 'Received empty response (ACK)', {}); + } + + this.#processor.processMessages(response); + this.requestUpdate(); + + } catch (err) { + this.#error = String(err); + this.#log('info', `Error: ${err}`, { error: err }); + } finally { + this.#requesting = false; + } + } + + #log(type: 'incoming' | 'outgoing' | 'info', summary: string, detail: any) { + if (this.debugPanel) { + this.debugPanel.addLog(type, summary, detail); + } + } +} diff --git a/samples/client/lit/component_gallery/events/events.ts b/samples/client/lit/component_gallery/events/events.ts new file mode 100644 index 000000000..0ab66c3b7 --- /dev/null +++ b/samples/client/lit/component_gallery/events/events.ts @@ -0,0 +1,35 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { HTMLTemplateResult } from "lit"; + +const eventInit = { + bubbles: true, + cancelable: true, + composed: true, +}; + +export class SnackbarActionEvent extends Event { + static eventName = "snackbaraction"; + + constructor( + public readonly action: string, + public readonly value?: HTMLTemplateResult | string, + public readonly callback?: () => void + ) { + super(SnackbarActionEvent.eventName, { ...eventInit }); + } +} diff --git a/samples/client/lit/component_gallery/index.html b/samples/client/lit/component_gallery/index.html new file mode 100644 index 000000000..7a6237609 --- /dev/null +++ b/samples/client/lit/component_gallery/index.html @@ -0,0 +1,29 @@ + + + + + + + A2UI Component Gallery + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/client/lit/component_gallery/middleware/a2a.ts b/samples/client/lit/component_gallery/middleware/a2a.ts new file mode 100644 index 000000000..0847eba63 --- /dev/null +++ b/samples/client/lit/component_gallery/middleware/a2a.ts @@ -0,0 +1,153 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { IncomingMessage, ServerResponse } from "http"; +import { Plugin, ViteDevServer } from "vite"; +import { A2AClient } from "@a2a-js/sdk/client"; +import { + MessageSendParams, + Part, + SendMessageSuccessResponse, + Task, +} from "@a2a-js/sdk"; +import { v4 as uuidv4 } from "uuid"; + +const A2UI_MIME_TYPE = "application/json+a2ui"; + +const fetchWithCustomHeader: typeof fetch = async (url, init) => { + const headers = new Headers(init?.headers); + headers.set("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.8"); + + const newInit = { ...init, headers }; + return fetch(url, newInit); +}; + +const isJson = (str: string) => { + try { + const parsed = JSON.parse(str); + return ( + typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ); + } catch (err) { + console.warn(err); + return false; + } +}; + +let client: A2AClient | null = null; +const createOrGetClient = async () => { + if (!client) { + // Create a client pointing to the agent's Agent Card URL. + client = await A2AClient.fromCardUrl( + "http://localhost:10005/.well-known/agent-card.json", + { fetchImpl: fetchWithCustomHeader } + ); + } + + return client; +}; + +export const plugin = (): Plugin => { + return { + name: "a2a-handler", + configureServer(server: ViteDevServer) { + server.middlewares.use( + "/a2a", + async (req: IncomingMessage, res: ServerResponse, next: () => void) => { + if (req.method === "POST") { + let originalBody = ""; + + req.on("data", (chunk) => { + originalBody += chunk.toString(); + }); + + req.on("end", async () => { + let sendParams: MessageSendParams; + + if (isJson(originalBody)) { + console.log( + "[a2a-middleware] Received JSON UI event:", + originalBody + ); + + const clientEvent = JSON.parse(originalBody); + sendParams = { + message: { + messageId: uuidv4(), + role: "user", + parts: [ + { + kind: "data", + data: clientEvent, + metadata: { 'mimeType': A2UI_MIME_TYPE }, + } as Part, + ], + kind: "message", + }, + }; + } else { + console.log( + "[a2a-middleware] Received text query:", + originalBody + ); + sendParams = { + message: { + messageId: uuidv4(), + role: "user", + parts: [ + { + kind: "text", + text: originalBody, + }, + ], + kind: "message", + }, + }; + } + + const client = await createOrGetClient(); + const response = await client.sendMessage(sendParams); + if ("error" in response) { + console.error("Error:", response.error.message); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: response.error.message })); + return; + } else { + const result = (response as SendMessageSuccessResponse) + .result as Task; + if (result.kind === "task") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(result.status.message?.parts)); + return; + } + } + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify([])); + }); + + return; + } else { + next(); + } + } + ); + }, + }; +}; diff --git a/samples/client/lit/component_gallery/middleware/index.ts b/samples/client/lit/component_gallery/middleware/index.ts new file mode 100644 index 000000000..4ecec532c --- /dev/null +++ b/samples/client/lit/component_gallery/middleware/index.ts @@ -0,0 +1,17 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +export * as A2AMiddleware from "./a2a.js"; diff --git a/samples/client/lit/component_gallery/package.json b/samples/client/lit/component_gallery/package.json new file mode 100644 index 000000000..db7b663c7 --- /dev/null +++ b/samples/client/lit/component_gallery/package.json @@ -0,0 +1,69 @@ +{ + "name": "@a2ui/component-gallery", + "private": true, + "version": "0.1.0", + "description": "A2UI Component Gallery Layout", + "main": "./dist/component-gallery.js", + "types": "./dist/component-gallery.d.ts", + "type": "module", + "scripts": { + "prepack": "npm run build", + "build": "wireit", + "build:tsc": "wireit", + "dev": "npm run serve --watch", + "test": "wireit", + "serve": "wireit" + }, + "wireit": { + "serve": { + "command": "vite dev", + "dependencies": [ + "build" + ], + "service": true + }, + "test": { + "command": "node --test --enable-source-maps --test-reporter spec dist/src/0.8/tests/**/*.test.js", + "dependencies": [ + "build" + ] + }, + "build": { + "dependencies": [ + "build:tsc" + ] + }, + "build:tsc": { + "command": "tsc -b --pretty", + "env": { + "FORCE_COLOR": "1" + }, + "dependencies": [ + "../../../../renderers/lit:build:tsc" + ], + "files": [ + "**/*.ts", + "tsconfig.json" + ], + "output": [ + "dist/", + "!dist/**/*.min.js{,.map}" + ], + "clean": "if-file-deleted" + } + }, + "dependencies": { + "@a2a-js/sdk": "^0.3.4", + "@a2ui/lit": "file:../../../../renderers/lit", + "@lit-labs/signals": "^0.1.3", + "@lit/context": "^1.1.4", + "lit": "^3.3.1" + }, + "devDependencies": { + "dotenv": "^17.2.3", + "typescript": "^5.8.3", + "uuid": "^13.0.0", + "vite": "^7.1.11", + "wireit": "^0.15.0-pre.2" + } +} \ No newline at end of file diff --git a/samples/client/lit/component_gallery/theme/theme.ts b/samples/client/lit/component_gallery/theme/theme.ts new file mode 100644 index 000000000..82cce9bfd --- /dev/null +++ b/samples/client/lit/component_gallery/theme/theme.ts @@ -0,0 +1,453 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { v0_8 } from "@a2ui/lit"; + +/** Elements */ + +const a = { + "typography-f-sf": true, + "typography-fs-n": true, + "typography-w-500": true, + "layout-as-n": true, + "layout-dis-iflx": true, + "layout-al-c": true, + "typography-td-none": true, + "color-c-p40": true, +}; + +const audio = { + "layout-w-100": true, +}; + +const body = { + "typography-f-s": true, + "typography-fs-n": true, + "typography-w-400": true, + "layout-mt-0": true, + "layout-mb-2": true, + "typography-sz-bm": true, + "color-c-n10": true, +}; + +const button = { + "typography-f-sf": true, + "typography-fs-n": true, + "typography-w-500": true, + "layout-pt-3": true, + "layout-pb-3": true, + "layout-pl-5": true, + "layout-pr-5": true, + "layout-mb-1": true, + "border-br-16": true, + "border-bw-0": true, + "border-c-n70": true, + "border-bs-s": true, + "color-bgc-s30": true, + "behavior-ho-80": true, +}; + +const heading = { + "typography-f-sf": true, + "typography-fs-n": true, + "typography-w-500": true, + "layout-mt-0": true, + "layout-mb-2": true, +}; + +const iframe = { + "behavior-sw-n": true, +}; + +const input = { + "typography-f-sf": true, + "typography-fs-n": true, + "typography-w-400": true, + "layout-pl-4": true, + "layout-pr-4": true, + "layout-pt-2": true, + "layout-pb-2": true, + "border-br-6": true, + "border-bw-1": true, + "color-bc-s70": true, + "border-bs-s": true, + "layout-as-n": true, + "color-c-n10": true, +}; + +const p = { + "typography-f-s": true, + "typography-fs-n": true, + "typography-w-400": true, + "layout-m-0": true, + "typography-sz-bm": true, + "layout-as-n": true, + "color-c-n10": true, +}; + +const orderedList = { + "typography-f-s": true, + "typography-fs-n": true, + "typography-w-400": true, + "layout-m-0": true, + "typography-sz-bm": true, + "layout-as-n": true, + "color-c-n10": true, +}; + +const unorderedList = { + "typography-f-s": true, + "typography-fs-n": true, + "typography-w-400": true, + "layout-m-0": true, + "typography-sz-bm": true, + "layout-as-n": true, + "color-c-n10": true, +}; + +const listItem = { + "typography-f-s": true, + "typography-fs-n": true, + "typography-w-400": true, + "layout-m-0": true, + "typography-sz-bm": true, + "layout-as-n": true, + "color-c-n10": true, +}; + +const pre = { + "typography-f-c": true, + "typography-fs-n": true, + "typography-w-400": true, + "typography-sz-bm": true, + "typography-ws-p": true, + "layout-as-n": true, +}; + +const textarea = { + ...input, + "layout-r-none": true, + "layout-fs-c": true, +}; + +const video = { + "layout-el-cv": true, +}; + +const aLight = v0_8.Styles.merge(a, {}); +const inputLight = v0_8.Styles.merge(input, {}); +const textareaLight = v0_8.Styles.merge(textarea, {}); +const buttonLight = v0_8.Styles.merge(button, {}); +const bodyLight = v0_8.Styles.merge(body, {}); +const pLight = v0_8.Styles.merge(p, {}); +const preLight = v0_8.Styles.merge(pre, {}); +const orderedListLight = v0_8.Styles.merge(orderedList, {}); +const unorderedListLight = v0_8.Styles.merge(unorderedList, {}); +const listItemLight = v0_8.Styles.merge(listItem, {}); + +export const theme: v0_8.Types.Theme = { + additionalStyles: { + Button: { + "--n-35": "var(--n-100)", + "--n-10": "var(--n-0)", + background: + "linear-gradient(135deg, light-dark(#818cf8, #06b6d4) 0%, light-dark(#a78bfa, #3b82f6) 100%)", + boxShadow: "0 4px 15px rgba(102, 126, 234, 0.4)", + padding: "12px 28px", + textTransform: "uppercase", + }, + Text: { + h1: { + color: "transparent", + background: + "linear-gradient(135deg, light-dark(#818cf8, #06b6d4) 0%, light-dark(#a78bfa, #3b82f6) 100%)", + "-webkit-background-clip": "text", + "background-clip": "text", + "-webkit-text-fill-color": "transparent", + }, + h2: { + color: "transparent", + background: + "linear-gradient(135deg, light-dark(#818cf8, #06b6d4) 0%, light-dark(#a78bfa, #3b82f6) 100%)", + "-webkit-background-clip": "text", + "background-clip": "text", + "-webkit-text-fill-color": "transparent", + }, + h3: { + color: "transparent", + background: + "linear-gradient(135deg, light-dark(#818cf8, #06b6d4) 0%, light-dark(#a78bfa, #3b82f6) 100%)", + "-webkit-background-clip": "text", + "background-clip": "text", + "-webkit-text-fill-color": "transparent", + }, + h4: {}, + h5: {}, + body: {}, + caption: {}, + }, + Card: { + background: + "radial-gradient(circle at top left, light-dark(transparent, rgba(6, 182, 212, 0.15)), transparent 40%), radial-gradient(circle at bottom right, light-dark(transparent, rgba(139, 92, 246, 0.15)), transparent 40%), linear-gradient(135deg, light-dark(rgba(255, 255, 255, 0.7), rgba(30, 41, 59, 0.7)), light-dark(rgba(255, 255, 255, 0.7), rgba(15, 23, 42, 0.8)))", + }, + TextField: { + "--p-0": "light-dark(var(--n-0), #1e293b)", + }, + Modal: { + background: + "linear-gradient(135deg, light-dark(rgba(255, 255, 255, 0.9), rgba(30, 41, 59, 1)), light-dark(rgba(255, 255, 255, 0.95), rgba(15, 23, 42, 1)))", + boxShadow: "0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2)", + borderRadius: "8px", + padding: "16px", + minWidth: "300px", + maxWidth: "80vw", + display: "flex", + flexDirection: "column", + }, + }, + components: { + AudioPlayer: {}, + Button: { + "layout-pt-2": true, + "layout-pb-2": true, + "layout-pl-3": true, + "layout-pr-3": true, + "border-br-12": true, + "border-bw-0": true, + "border-bs-s": true, + "color-bgc-p30": true, + "behavior-ho-70": true, + "typography-w-400": true, + }, + Card: { "border-br-9": true, "layout-p-4": true, "color-bgc-n100": true }, + CheckBox: { + element: { + "layout-m-0": true, + "layout-mr-2": true, + "layout-p-2": true, + "border-br-12": true, + "border-bw-1": true, + "border-bs-s": true, + "color-bgc-p100": true, + "color-bc-p60": true, + "color-c-n30": true, + "color-c-p30": true, + }, + label: { + "color-c-p30": true, + "typography-f-sf": true, + "typography-v-r": true, + "typography-w-400": true, + "layout-flx-1": true, + "typography-sz-ll": true, + }, + container: { + "layout-dsp-iflex": true, + "layout-al-c": true, + }, + }, + Column: { + "layout-g-2": true, + }, + DateTimeInput: { + container: { + "typography-sz-bm": true, + "layout-w-100": true, + "layout-g-2": true, + "layout-dsp-flexhor": true, + "layout-al-c": true, + "typography-ws-nw": true, + }, + label: { + "color-c-p30": true, + "typography-sz-bm": true, + }, + element: { + "layout-pt-2": true, + "layout-pb-2": true, + "layout-pl-3": true, + "layout-pr-3": true, + "border-br-2": true, + "border-bw-1": true, + "border-bs-s": true, + "color-bgc-p100": true, + "color-bc-p60": true, + "color-c-n30": true, + "color-c-p30": true, + }, + }, + Divider: {}, + Image: { + all: { + "border-br-5": true, + "layout-el-cv": true, + "layout-w-100": true, + "layout-h-100": true, + }, + avatar: { "is-avatar": true }, + header: {}, + icon: {}, + largeFeature: {}, + mediumFeature: {}, + smallFeature: {}, + }, + Icon: {}, + List: { + "layout-g-4": true, + "layout-p-2": true, + }, + Modal: { + backdrop: { "color-bbgc-p60_20": true }, + element: { + "border-br-2": true, + "color-bgc-p100": true, + "layout-p-4": true, + "border-bw-1": true, + "border-bs-s": true, + "color-bc-p80": true, + }, + }, + MultipleChoice: { + container: {}, + label: {}, + element: {}, + }, + Row: { + "layout-g-4": true, + }, + Slider: { + container: {}, + label: {}, + element: {}, + }, + Tabs: { + container: {}, + controls: { all: {}, selected: {} }, + element: {}, + }, + Text: { + all: { + "layout-w-100": true, + "layout-g-2": true, + }, + h1: { + "typography-f-sf": true, + "typography-v-r": true, + "typography-w-400": true, + "layout-m-0": true, + "layout-p-0": true, + "typography-sz-hs": true, + }, + h2: { + "typography-f-sf": true, + "typography-v-r": true, + "typography-w-400": true, + "layout-m-0": true, + "layout-p-0": true, + "typography-sz-tl": true, + }, + h3: { + "typography-f-sf": true, + "typography-v-r": true, + "typography-w-400": true, + "layout-m-0": true, + "layout-p-0": true, + "typography-sz-tl": true, + }, + h4: { + "typography-f-sf": true, + "typography-v-r": true, + "typography-w-400": true, + "layout-m-0": true, + "layout-p-0": true, + "typography-sz-bl": true, + }, + h5: { + "typography-f-sf": true, + "typography-v-r": true, + "typography-w-400": true, + "layout-m-0": true, + "layout-p-0": true, + "typography-sz-bm": true, + }, + body: {}, + caption: {}, + }, + TextField: { + container: { + "typography-sz-bm": true, + "layout-w-100": true, + "layout-g-2": true, + "layout-dsp-flexhor": true, + "layout-al-c": true, + "typography-ws-nw": true, + }, + label: { + "layout-flx-0": true, + "color-c-p30": true, + }, + element: { + "typography-sz-bm": true, + "layout-pt-2": true, + "layout-pb-2": true, + "layout-pl-3": true, + "layout-pr-3": true, + "border-br-2": true, + "border-bw-1": true, + "border-bs-s": true, + "color-bgc-p100": true, + "color-bc-p60": true, + "color-c-n30": true, + "color-c-p30": true, + }, + }, + Video: { + "border-br-5": true, + "layout-el-cv": true, + }, + }, + elements: { + a: aLight, + audio, + body: bodyLight, + button: buttonLight, + h1: heading, + h2: heading, + h3: heading, + h4: heading, + h5: heading, + iframe, + input: inputLight, + p: pLight, + pre: preLight, + textarea: textareaLight, + video, + }, + markdown: { + p: [...Object.keys(pLight)], + h1: [...Object.keys(heading)], + h2: [...Object.keys(heading)], + h3: [...Object.keys(heading)], + h4: [...Object.keys(heading)], + h5: [...Object.keys(heading)], + ul: [...Object.keys(unorderedListLight)], + ol: [...Object.keys(orderedListLight)], + li: [...Object.keys(listItemLight)], + a: [...Object.keys(aLight)], + strong: [], + em: [], + }, +}; diff --git a/samples/client/lit/component_gallery/tsconfig.json b/samples/client/lit/component_gallery/tsconfig.json new file mode 100644 index 000000000..977166deb --- /dev/null +++ b/samples/client/lit/component_gallery/tsconfig.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "incremental": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + // "allowJs": true, + "preserveWatchOutput": true, + "sourceMap": true, + "target": "es2022", + "module": "es2022", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "useDefineForClassFields": false, + "rootDir": ".", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo", + + /* Bundler mode */ + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + + /* Linting */ + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "lit": [ + "../../../../renderers/lit/node_modules/lit" + ], + "lit-html": [ + "../../../../renderers/lit/node_modules/lit-html" + ], + "lit-element": [ + "../../../../renderers/lit/node_modules/lit-element" + ], + "@lit/reactive-element": [ + "../../../../renderers/lit/node_modules/@lit/reactive-element" + ], + "@lit/context": [ + "../../../../renderers/lit/node_modules/@lit/context" + ] + } + }, + "references": [{ "path": "../../../../renderers/lit" }] +} diff --git a/samples/client/lit/component_gallery/types/types.ts b/samples/client/lit/component_gallery/types/types.ts new file mode 100644 index 000000000..90644ce08 --- /dev/null +++ b/samples/client/lit/component_gallery/types/types.ts @@ -0,0 +1,42 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { HTMLTemplateResult } from "lit"; + +export enum SnackType { + NONE = "none", + INFORMATION = "information", + WARNING = "warning", + ERROR = "error", + PENDING = "pending", +} + +export type SnackbarUUID = ReturnType; + +export type SnackbarAction = { + title: string; + action: string; + value?: HTMLTemplateResult | string; + callback?: () => void; +}; + +export type SnackbarMessage = { + id: SnackbarUUID; + type: SnackType; + persistent: boolean; + message: string | HTMLTemplateResult; + actions?: SnackbarAction[]; +}; diff --git a/samples/client/lit/component_gallery/ui/custom-components/README.md b/samples/client/lit/component_gallery/ui/custom-components/README.md new file mode 100644 index 000000000..18f611132 --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/README.md @@ -0,0 +1,167 @@ +# A2UI custom component integration guide + +This guide details how to create, register, and use a custom component in the A2UI client. + +## Create the component + +Create a new Lit component file in `lib/src/0.8/ui/custom-components/`. +Example: `my-component.ts` + +```typescript +import { html, css } from "lit"; +import { property } from "lit/decorators.js"; + +import { Root } from "../root.js"; + +export class MyComponent extends Root { + @property() accessor myProp: string = "Default"; + + static styles = [ + ...Root.styles, // Inherit base styles + css` + :host { + display: block; + padding: 16px; + border: 1px solid #ccc; + } + `, + ]; + + render() { + return html` +
+

My Custom Component

+

Prop value: ${this.myProp}

+
+ `; + } +} +``` + +## Register the component + +Update `lib/src/0.8/ui/custom-components/index.ts` to register your new component. +You must pass the desired tag name as the third argument. + +```typescript +import { componentRegistry } from "../component-registry.js"; +import { MyComponent } from "./my-component.js"; // Import your component + +export function registerCustomComponents() { + // Register with explicit tag name + componentRegistry.register("MyComponent", MyComponent, "my-component"); +} + +export { MyComponent }; // Export for type usage if needed +``` + +## Define the schema (server-side) + +Create a JSON schema for your component properties. This will be used by the server to validate messages. +Example: `lib/my_component_schema.json` + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "const": "object" }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "myProp": { + "type": "string", + "description": "A sample property." + } + }, + "required": ["myProp"] + } + }, + "required": ["type", "properties"] +} +``` + +## Use in client application + +In your client application (e.g., `contact` sample), ensure you import and call the registration function. + +```typescript +import { registerCustomComponents } from "@a2ui/lit/ui"; + +// Call this once at startup +registerCustomComponents(); +``` + +## Overriding standard components + +You can replace standard A2UI components (like `TextField`, `Video`, `Button`) with your own custom implementations. + +### Steps to override + +1. **Create your component** extending `Root` (just like a custom component). + +2. **Ensure it accepts the standard properties** for that component type (e.g., `label` and `text` for `TextField`). + +3. **Register it** using the **standard type name** (e.g., `"TextField"`). + + ```typescript + // 1. Define your override + class MyPremiumTextField extends Root { + @property() accessor label = ""; + @property() accessor text = ""; + + static styles = [ + ...Root.styles, + css` + /* your premium styles */ + `, + ]; + + render() { + return html` +
+ + +
+ `; + } + } + + // 2. Register with the STANDARD type name + import { componentRegistry } from "@a2ui/lit/ui"; + componentRegistry.register( + "TextField", + MyPremiumTextField, + "my-premium-textfield" + ); + ``` + +**Result:** +When the server sends a `TextField` component, the client will now render `` instead of the default ``. + +## Verify + +You can verify the component by creating a simple HTML test file or by sending a server message with the new component type. + +**Server message example:** + +```json +{ + "surfaceId": "main", + "component": { + "type": "MyComponent", + "id": "comp-1", + "properties": { + "myProp": "Hello World" + } + } +} +``` + +## Troubleshooting + +- **`NotSupportedError`**: If you see "constructor has already been used", ensure you **removed** the `@customElement` decorator from your component class. +- **Component not rendering**: Check if `registerCustomComponents()` is actually called. Verify the tag name in the DOM matches what you registered (e.g., `` vs ``). +- **Styles missing**: Ensure `static styles` includes `...Root.styles`. diff --git a/samples/client/lit/component_gallery/ui/custom-components/org-chart.ts b/samples/client/lit/component_gallery/ui/custom-components/org-chart.ts new file mode 100644 index 000000000..c2feab1ea --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/org-chart.ts @@ -0,0 +1,200 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { Root } from '@a2ui/lit/ui'; +import { v0_8 } from '@a2ui/lit'; +import { html, css, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { map } from 'lit/directives/map.js'; + +// Use aliases for convenience +const StateEvent = v0_8.Events.StateEvent; +type Action = v0_8.Types.Action; + +export interface OrgChartNode { + title: string; + name: string; +} + +@customElement('org-chart') +export class OrgChart extends Root { + @property({ type: Array }) accessor chain: OrgChartNode[] = []; + @property({ type: Object }) accessor action: Action | null = null; + + static styles = [ + ...Root.styles, + css` + :host { + display: block; + padding: 16px; + font-family: 'Roboto', sans-serif; + } + + .container { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + } + + .node { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 24px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + min-width: 200px; + position: relative; + transition: transform 0.2s, box-shadow 0.2s; + cursor: pointer; + } + + .node:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + + .node:focus { + outline: 2px solid #1a73e8; + outline-offset: 2px; + } + + .node.current { + background: #e8f0fe; + border-color: #1a73e8; + border-width: 2px; + } + + .title { + font-size: 0.85rem; + color: #5f6368; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + + .name { + font-size: 1.1rem; + font-weight: 500; + color: #202124; + } + + .arrow { + color: #9aa0a6; + font-size: 24px; + line-height: 1; + } + `]; + + render() { + let chainData: OrgChartNode[] | null = null; + let unresolvedChain: any = this.chain; + + // Resolve "chain" if it is a path object + const chainAsAny = this.chain as any; + if (chainAsAny && typeof chainAsAny === 'object' && 'path' in chainAsAny && chainAsAny.path) { + if (this.processor) { + const resolved = this.processor.getData(this.component, chainAsAny.path, this.surfaceId ?? 'default'); + if (resolved) { + unresolvedChain = resolved; + } + } + } + + if (Array.isArray(unresolvedChain)) { + chainData = unresolvedChain as OrgChartNode[]; + } else if (unresolvedChain instanceof Map) { + // Handle Map (values are the nodes) + const entries = Array.from((unresolvedChain as Map).entries()); + entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)); + chainData = entries.map(entry => entry[1]); + } else if (typeof unresolvedChain === 'object' && unresolvedChain !== null) { + chainData = Object.values(unresolvedChain); + } + + // Normalize items: model processor converts nested objects to Maps, so we must convert them back + chainData = (chainData || []).map(node => { + // Helper to safely get property regardless of type + const getVal = (k: string) => { + if (node instanceof Map) return node.get(k); + return (node as any)?.[k]; + }; + + return { + title: getVal('title') ?? '', + name: getVal('name') ?? '', + }; + }); + + if (!chainData || chainData.length === 0) { + return html`
No hierarchy data
`; + } + + return html` +
+ ${map(chainData, (node, index) => { + // Use chainData.length, not this.chain.length + const isLast = index === (chainData?.length ?? 0) - 1; + return html` + + ${!isLast ? html`
` : ''} + `; + })} +
+ `; + } + + private handleNodeClick(node: OrgChartNode) { + if (!this.action) return; + + // Create a new action with the node's context merged in + const newContext = [ + ...(this.action.context || []), + { + key: 'clickedNodeTitle', + value: { literalString: node.title } + }, + { + key: 'clickedNodeName', + value: { literalString: node.name } + } + ]; + + const actionWithContext: Action = { + ...this.action, + context: newContext as Action['context'] + }; + + const evt = new StateEvent<"a2ui.action">({ + eventType: "a2ui.action", + action: actionWithContext, + dataContextPath: this.dataContextPath, + sourceComponentId: this.id, + sourceComponent: this.component, + }); + this.dispatchEvent(evt); + } +} diff --git a/samples/client/lit/component_gallery/ui/custom-components/premium-text-field.ts b/samples/client/lit/component_gallery/ui/custom-components/premium-text-field.ts new file mode 100644 index 000000000..968b0fbfd --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/premium-text-field.ts @@ -0,0 +1,100 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { Root } from '@a2ui/lit/ui'; +import { html, css } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class PremiumTextField extends Root { + @property() accessor label = ''; + @property() accessor text = ''; + + static styles = [ + ...Root.styles, + css` + :host { + display: block; + padding: 16px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + border: 1px solid #e0e0e0; + transition: all 0.2s ease; + font-family: 'Inter', sans-serif; + } + :host(:hover) { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0,0,0,0.12); + } + .input-container { + position: relative; + margin-top: 8px; + } + input { + width: 100%; + padding: 12px 16px; + font-size: 16px; + border: 2px solid #e0e0e0; + border-radius: 8px; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; + background: #fafafa; + } + input:focus { + border-color: #6200ee; + background: #fff; + } + label { + display: block; + font-size: 14px; + font-weight: 600; + color: #333; + margin-bottom: 4px; + } + .hint { + margin-top: 8px; + font-size: 12px; + color: #666; + display: flex; + align-items: center; + gap: 4px; + } + .badge { + background: #6200ee; + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: bold; + text-transform: uppercase; + } + ` + ]; + + render() { + return html` + +
+ +
+
+ Custom + This is a premium override of the standard TextField. +
+ `; + } +} diff --git a/samples/client/lit/component_gallery/ui/custom-components/register-components.ts b/samples/client/lit/component_gallery/ui/custom-components/register-components.ts new file mode 100644 index 000000000..047a0438c --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/register-components.ts @@ -0,0 +1,69 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { componentRegistry } from "@a2ui/lit/ui"; +import { OrgChart } from "./org-chart.js"; +import { WebFrame } from "./web-frame.js"; +import { PremiumTextField } from "./premium-text-field.js"; + +export function registerContactComponents() { + // Register OrgChart + componentRegistry.register("OrgChart", OrgChart, "org-chart", { + type: "object", + properties: { + chain: { + type: "array", + items: { + type: "object", + properties: { + title: { type: "string" }, + name: { type: "string" }, + }, + required: ["title", "name"], + }, + }, + action: { $ref: "#/definitions/Action" }, + }, + required: ["chain"], + }); + + // Register PremiumTextField as an override for TextField + componentRegistry.register( + "TextField", + PremiumTextField, + "premium-text-field" + ); + + // Register WebFrame + componentRegistry.register("WebFrame", WebFrame, "a2ui-web-frame", { + type: "object", + properties: { + url: { type: "string" }, + html: { type: "string" }, + height: { type: "number" }, + interactionMode: { + type: "string", + enum: ["readOnly", "interactive"] + }, + allowedEvents: { + type: "array", + items: { type: "string" } + } + }, + }); + + console.log("Registered Contact App Custom Components"); +} diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/README.md b/samples/client/lit/component_gallery/ui/custom-components/test/README.md new file mode 100644 index 000000000..f4277bfeb --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/test/README.md @@ -0,0 +1,30 @@ +# Contact sample verification tests + +This directory contains tests to verify custom component integration specifically within the `contact` sample application environment. + +## How to run + +These tests run via the Vite development server used by the contact sample. + +### 1. Start the dev server +From the `web/lit/samples/contact` directory, run: + +```bash +npm run dev +``` + +### 2. Access the tests +Open your browser and navigate to the local server (usually port 5173): + +- **Component override test**: + [http://localhost:5173/ui/custom-components/test/override-test.html](http://localhost:5173/ui/custom-components/test/override-test.html) + *Verifies that a standard component (TextField) can be overridden by a custom implementation.* + +- **Hierarchy graph integration test**: + [http://localhost:5173/ui/custom-components/test/hierarchy-test.html](http://localhost:5173/ui/custom-components/test/hierarchy-test.html) + *Verifies that the HierarchyGraph component renders correctly within the contact app's build setup.* + +## Files + +- `override-test.html` & `override-test.ts`: Implements and tests a custom `TextField` override. +- `hierarchy-test.html`: Tests the `HierarchyGraph` component. diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/org-chart-test.html b/samples/client/lit/component_gallery/ui/custom-components/test/org-chart-test.html new file mode 100644 index 000000000..b54b5710c --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/test/org-chart-test.html @@ -0,0 +1,86 @@ + + + + + + + + + A2UI Org Chart Test (Contact Sample) + + + + + +

A2UI Org Chart Test (Contact Sample)

+ +
+ +
+ + + + + \ No newline at end of file diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/override-test.html b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.html new file mode 100644 index 000000000..7edf0291c --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.html @@ -0,0 +1,43 @@ + + + + + + + + A2UI Component Override Test + + + + + +

Component Override Test

+
+ + + \ No newline at end of file diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/override-test.ts b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.ts new file mode 100644 index 000000000..7ded768bd --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.ts @@ -0,0 +1,46 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { componentRegistry, Root } from "@a2ui/lit/ui"; +import { html, css } from "lit"; +import { property } from "lit/decorators.js"; +// 1. Define the override +import { PremiumTextField } from "../premium-text-field.js"; + +// 2. Register it as "TextField" +componentRegistry.register("TextField", PremiumTextField, "premium-text-field"); +console.log("Registered PremiumTextField override"); + +// 3. Render a standard TextField component node +const container = document.getElementById("app"); +if (container) { + const root = document.createElement("a2ui-root") as Root; + + const textFieldComponent = { + type: "TextField", + id: "tf-1", + properties: { + label: "Enter your name", + text: "John Doe", + }, + }; + + // Root renders its *children*, so we must pass the component as a child. + root.childComponents = [textFieldComponent]; + + root.enableCustomElements = true; // Enable the feature + container.appendChild(root); +} diff --git a/samples/client/lit/component_gallery/ui/custom-components/web-frame.ts b/samples/client/lit/component_gallery/ui/custom-components/web-frame.ts new file mode 100644 index 000000000..6eee51cf7 --- /dev/null +++ b/samples/client/lit/component_gallery/ui/custom-components/web-frame.ts @@ -0,0 +1,234 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { html, css, PropertyValues, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { Root } from "@a2ui/lit/ui"; +import { v0_8 } from "@a2ui/lit"; + + +interface WebFrameConfig { + [key: string]: unknown; +} + +@customElement("a2ui-web-frame") +export class WebFrame extends Root { + static override styles = [ + ...Root.styles, + css` + :host { + display: block; + width: 100%; + border: 1px solid #eee; + position: relative; + overflow: hidden; /* For Aspect Ratio / Container */ + } + iframe { + width: 100%; + height: 100%; + border: none; + background: #f5f5f5; + } + .controls { + position: absolute; + top: 20px; + right: 20px; + display: flex; + gap: 10px; + z-index: 10; + } + .controls button { + width: 32px; + height: 32px; + font-size: 20px; + cursor: pointer; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + .controls button:hover { + background: #f0f0f0; + } + `, + ]; + + /* --- Properties (Server Contract) --- */ + + @property({ type: String }) + accessor url: string = ""; + + @property({ type: String }) + accessor html: string = ""; + + @property({ type: Number }) + accessor height: number | undefined = undefined; + + @property({ type: String }) + accessor interactionMode: "readOnly" | "interactive" = "readOnly"; + + @property({ type: Array }) + accessor allowedEvents: string[] = []; + + // --- Internal State --- + + @query("iframe") + accessor iframe!: HTMLIFrameElement; + + // --- Security Constants --- + static readonly TRUSTED_DOMAINS = [ + "localhost", + "127.0.0.1", + "openstreetmap.org", + "youtube.com", + "maps.google.com" + ]; + + override render() { + const sandboxAttr = this.#calculateSandbox(); + // Default to aspect ratio if no height. Use 16:9 or 4:3. + const style = this.height ? `height: ${this.height}px;` : 'aspect-ratio: 4/3;'; + + // Determine content: srcdoc (html) vs src (url) + const srcRaw = this.url; + // VERY IMPORTANT: If html is empty, do NOT pass it to srcdoc, otherwise it overrides src with blank page. + const srcDocRaw = this.html || undefined; + + return html` +
+
+ + +
+ +
+ `; + } + + #calculateSandbox(): string { + // 1. If HTML is provided, it's treated as Trusted (but isolated) + if (this.html) { + if (this.interactionMode === 'interactive') { + return "allow-scripts allow-forms allow-popups allow-modals"; + } + return "allow-scripts"; // ReadOnly but scripts allowed for rendering + } + + // 2. Parse Domain from URL + try { + const urlObj = new URL(this.url, window.location.href); // Handle relative URLs too + const hostname = urlObj.hostname; + + const isTrusted = WebFrame.TRUSTED_DOMAINS.some(d => hostname === d || hostname.endsWith(`.${d}`)); + + if (!isTrusted) { + // Untrusted: Strict Lockdown + return ""; + } + + // Trusted + // Always allow same-origin for trusted domains to avoid issues with local assets or CORS checks + if (this.interactionMode === 'interactive') { + return "allow-scripts allow-forms allow-popups allow-modals allow-same-origin"; + } else { + return "allow-scripts allow-same-origin"; + } + + } catch (e) { + // Invalid URL -> Lockdown + return ""; + } + } + + // --- Event Bridge --- + + firstUpdated() { + window.addEventListener("message", this.#onMessage); + } + + disconnectedCallback() { + window.removeEventListener("message", this.#onMessage); + super.disconnectedCallback(); + } + + #onMessage = (event: MessageEvent) => { + // In production, verify event.origin matches this.src origin (if not opaque). + const data = event.data; + + // Spec Protocol: { type: 'a2ui_action', action: '...', data: ... } + if (data && data.type === 'a2ui_action') { + const { action, data: actionData } = data; // 'data' property in message payload + + // 1. Validate Action + if (this.allowedEvents.includes(action)) { + // 2. Dispatch + this.#dispatchAgentAction(action, actionData); + } else { + console.warn(`[WebFrame] Action '${action}' blocked. Not in allowedEvents:`, this.allowedEvents); + } + } + // Legacy support for 'emit' temporarily if we want to be safe, but spec implies replacement. + // I will remove legacy to be strict. + }; + + #dispatchAgentAction(actionName: string, params: any) { + const context: v0_8.Types.Action["context"] = []; + if (params && typeof params === 'object') { + for (const [key, value] of Object.entries(params)) { + if (typeof value === "string") { + context.push({ key, value: { literalString: value } }); + } else if (typeof value === "number") { + context.push({ key, value: { literalNumber: value } }); + } else if (typeof value === "boolean") { + context.push({ key, value: { literalBoolean: value } }); + } + } + } + + const action: v0_8.Types.Action = { + name: actionName, + context, + }; + + const eventPayload: v0_8.Events.StateEventDetailMap["a2ui.action"] = { + eventType: "a2ui.action", + action, + sourceComponentId: this.id, + dataContextPath: this.dataContextPath, + sourceComponent: this.component as v0_8.Types.AnyComponentNode, + }; + + this.dispatchEvent(new v0_8.Events.StateEvent(eventPayload)); + } + + // --- Zoom Controls (External) --- + // Keeps working by sending 'zoom' to iframe. + // We assume the iframe content knows how to handle 'zoom' message if it supports it. + #zoom(factor: number) { + if (this.iframe && this.iframe.contentWindow) { + this.iframe.contentWindow.postMessage({ type: 'zoom', payload: { factor } }, '*'); + } + } +} diff --git a/samples/client/lit/component_gallery/ui/debug-panel.ts b/samples/client/lit/component_gallery/ui/debug-panel.ts new file mode 100644 index 000000000..6bc74814a --- /dev/null +++ b/samples/client/lit/component_gallery/ui/debug-panel.ts @@ -0,0 +1,226 @@ + +import { LitElement, html, css, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +type LogEntry = { + type: 'incoming' | 'outgoing' | 'info'; + timestamp: string; + summary: string; + detail: any; + id: number; +}; + +@customElement("debug-panel") +export class DebugPanel extends LitElement { + @property({ type: Boolean }) accessor isOpen = true; + @state() accessor logs: LogEntry[] = []; + @state() accessor selectedLogId: number | null = null; + + private nextId = 0; + + @state() accessor panelHeight = 400; + @state() accessor isResizing = false; + + private startY = 0; + private startHeight = 0; + + static styles = css` + :host { + display: flex; + flex-direction: column; + background: #1e1e1e; + color: #d4d4d4; + font-family: monospace; + border-top: 1px solid #333; + overflow: hidden; + position: relative; + } + + .resize-handle { + height: 4px; + background: #333; + cursor: ns-resize; + width: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 10; + } + + .resize-handle:hover, :host(.resizing) .resize-handle { + background: #007acc; + } + + :host([isopen="false"]) { + height: 32px !important; + } + + .header { + display: flex; + align-items: center; + padding: 4px 8px; + background: #252526; + border-bottom: 1px solid #333; + user-select: none; + margin-top: 4px; /* Space for handle */ + } + + .title { + flex: 1; + font-weight: bold; + font-size: 12px; + } + + .controls { + display: flex; + gap: 8px; + } + + button { + background: #333; + border: 1px solid #444; + color: #ccc; + cursor: pointer; + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + } + + button:hover { + background: #444; + } + + .content { + display: flex; + flex: 1; + overflow: hidden; + } + + .log-list { + flex: 1; + overflow-y: auto; + border-right: 1px solid #333; + padding: 4px 0; + } + + .log-item { + padding: 4px 8px; + cursor: pointer; + font-size: 11px; + display: flex; + gap: 8px; + border-bottom: 1px solid #2a2a2a; + } + + .log-item:hover { + background: #2a2a2d; + } + + .log-item.selected { + background: #37373d; + } + + .log-type { + font-weight: bold; + width: 60px; + flex-shrink: 0; + } + + .type-incoming { color: #4ec9b0; } + .type-outgoing { color: #ce9178; } + .type-info { color: #569cd6; } + + .log-time { + color: #888; + flex-shrink: 0; + } + + .log-summary { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .detail-view { + flex: 1.5; + overflow-y: auto; + padding: 8px; + white-space: pre-wrap; + font-size: 11px; + background: #1e1e1e; + } + `; + + addLog(type: 'incoming' | 'outgoing' | 'info', summary: string, detail: any) { + this.logs = [...this.logs, { + type, + timestamp: new Date().toLocaleTimeString(), + summary, + detail, + id: this.nextId++ + }]; + } + + private startResize(e: MouseEvent) { + this.isResizing = true; + this.startY = e.clientY; + this.startHeight = this.getBoundingClientRect().height; + + window.addEventListener('mousemove', this.doResize); + window.addEventListener('mouseup', this.stopResize); + this.classList.add('resizing'); + } + + private doResize = (e: MouseEvent) => { + if (!this.isResizing) return; + const delta = this.startY - e.clientY; + const newHeight = this.startHeight + delta; + this.panelHeight = Math.max(100, Math.min(window.innerHeight - 50, newHeight)); + } + + private stopResize = () => { + this.isResizing = false; + window.removeEventListener('mousemove', this.doResize); + window.removeEventListener('mouseup', this.stopResize); + this.classList.remove('resizing'); + } + + render() { + const selectedLog = this.logs.find(l => l.id === this.selectedLogId); + + return html` +
+
+ Debug Panel (${this.logs.length} events) +
+ + +
+
+ ${this.isOpen ? html` + +
+
+ ${this.logs.slice().reverse().map(log => html` +
this.selectedLogId = log.id}> + ${log.timestamp} + ${log.type.toUpperCase()} + ${log.summary} +
+ `)} +
+
+ ${selectedLog + ? JSON.stringify(selectedLog.detail, null, 2) + : 'Select an event to view details.'} +
+
+ ` : nothing} + `; + } +} diff --git a/samples/client/lit/component_gallery/ui/snackbar.ts b/samples/client/lit/component_gallery/ui/snackbar.ts new file mode 100644 index 000000000..91f2f036d --- /dev/null +++ b/samples/client/lit/component_gallery/ui/snackbar.ts @@ -0,0 +1,299 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { LitElement, html, css, nothing, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { SnackbarMessage, SnackbarUUID, SnackType } from "../types/types"; +import { repeat } from "lit/directives/repeat.js"; +import { SnackbarActionEvent } from "../events/events"; +import { classMap } from "lit/directives/class-map.js"; +import { v0_8 } from "@a2ui/lit"; + +const DEFAULT_TIMEOUT = 8000; + +@customElement("ui-snackbar") +export class Snackbar extends LitElement { + @property({ reflect: true, type: Boolean }) + accessor active = false; + + @property({ reflect: true, type: Boolean }) + accessor error = false; + + @property() + accessor timeout = DEFAULT_TIMEOUT; + + #messages: SnackbarMessage[] = []; + #timeout = 0; + + static styles = [ + unsafeCSS(v0_8.Styles.structuralStyles), + css` + :host { + --text-color: var(--n-0); + --bb-body-medium: 16px; + --bb-body-line-height-medium: 24px; + + display: flex; + align-items: center; + position: fixed; + bottom: var(--bb-grid-size-7); + left: 50%; + translate: -50% 0; + opacity: 0; + pointer-events: none; + border-radius: var(--bb-grid-size-2); + background: var(--n-90); + padding: var(--bb-grid-size-3) var(--bb-grid-size-6); + width: 60svw; + max-width: 720px; + z-index: 1800; + scrollbar-width: none; + overflow-x: scroll; + font: 400 var(--bb-body-medium) / var(--bb-body-line-height-medium) + var(--bb-font-family); + } + + :host([active]) { + transition: opacity 0.3s cubic-bezier(0, 0, 0.3, 1) 0.2s; + opacity: 1; + pointer-events: auto; + } + + :host([error]) { + background: var(--e-90); + --text-color: var(--e-40); + } + + .g-icon { + flex: 0 0 auto; + color: var(--text-color); + margin-right: var(--bb-grid-size-4); + + &.rotate { + animation: 1s linear 0s infinite normal forwards running rotate; + } + } + + #messages { + color: var(--text-color); + flex: 1 1 auto; + margin-right: var(--bb-grid-size-11); + + a, + a:visited { + color: var(--bb-ui-600); + text-decoration: none; + + &:hover { + color: var(--bb-ui-500); + text-decoration: underline; + } + } + } + + #actions { + flex: 0 1 auto; + width: fit-content; + margin-right: var(--bb-grid-size-3); + + & button { + font: 500 var(--bb-body-medium) / var(--bb-body-line-height-medium) + var(--bb-font-family); + padding: 0; + background: transparent; + border: none; + margin: 0 var(--bb-grid-size-4); + color: var(--text-color); + opacity: 0.7; + transition: opacity 0.2s cubic-bezier(0, 0, 0.3, 1); + + &:not([disabled]) { + cursor: pointer; + + &:hover, + &:focus { + opacity: 1; + } + } + } + } + + #close { + display: flex; + align-items: center; + padding: 0; + color: var(--text-color); + background: transparent; + border: none; + margin: 0 0 0 var(--bb-grid-size-2); + opacity: 0.7; + transition: opacity 0.2s cubic-bezier(0, 0, 0.3, 1); + + .g-icon { + margin-right: 0; + } + + &:not([disabled]) { + cursor: pointer; + + &:hover, + &:focus { + opacity: 1; + } + } + } + + @keyframes rotate { + from { + rotate: 0deg; + } + + to { + rotate: 360deg; + } + } + `, + ]; + + show(message: SnackbarMessage, replaceAll = false) { + const existingMessage = this.#messages.findIndex( + (msg) => msg.id === message.id + ); + if (existingMessage === -1) { + if (replaceAll) { + this.#messages.length = 0; + } + + this.#messages.push(message); + } else { + this.#messages[existingMessage] = message; + } + + window.clearTimeout(this.#timeout); + if (!this.#messages.every((msg) => msg.persistent)) { + this.#timeout = window.setTimeout(() => { + this.hide(); + }, this.timeout); + } + + this.error = this.#messages.some((msg) => msg.type === SnackType.ERROR); + this.active = true; + this.requestUpdate(); + + return message.id; + } + + hide(id?: SnackbarUUID) { + if (id) { + const idx = this.#messages.findIndex((msg) => msg.id === id); + if (idx !== -1) { + this.#messages.splice(idx, 1); + } + } else { + this.#messages.length = 0; + } + + this.active = this.#messages.length !== 0; + this.updateComplete.then((avoidedUpdate) => { + if (!avoidedUpdate) { + return; + } + + this.requestUpdate(); + }); + } + + render() { + let rotate = false; + let icon = ""; + for (let i = this.#messages.length - 1; i >= 0; i--) { + if ( + !this.#messages[i].type || + this.#messages[i].type === SnackType.NONE + ) { + continue; + } + + icon = this.#messages[i].type; + if (this.#messages[i].type === SnackType.PENDING) { + icon = "progress_activity"; + rotate = true; + } + break; + } + + return html` ${icon + ? html`${icon}` + : nothing} +
+ ${repeat( + this.#messages, + (message) => message.id, + (message) => { + return html`
${message.message}
`; + } + )} +
+
+ ${repeat( + this.#messages, + (message) => message.id, + (message) => { + if (!message.actions) { + return nothing; + } + + return html`${repeat( + message.actions, + (action) => action.value, + (action) => { + return html``; + } + )}`; + } + )} +
+ `; + } +} diff --git a/samples/client/lit/component_gallery/ui/ui.ts b/samples/client/lit/component_gallery/ui/ui.ts new file mode 100644 index 000000000..7726f29d7 --- /dev/null +++ b/samples/client/lit/component_gallery/ui/ui.ts @@ -0,0 +1,17 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +export { Snackbar } from "./snackbar"; diff --git a/samples/client/lit/component_gallery/vite.config.ts b/samples/client/lit/component_gallery/vite.config.ts new file mode 100644 index 000000000..16c0e4785 --- /dev/null +++ b/samples/client/lit/component_gallery/vite.config.ts @@ -0,0 +1,46 @@ + +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { config } from "dotenv"; +import { UserConfig } from "vite"; +import * as Middleware from "./middleware"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async () => { + config(); + + const entry: Record = { + component_gallery: resolve(__dirname, "index.html"), + }; + + return { + plugins: [Middleware.A2AMiddleware.plugin()], + build: { + rollupOptions: { + input: entry, + }, + target: "esnext", + }, + define: {}, + resolve: { + dedupe: ["lit"], + }, + } satisfies UserConfig; +};