使用 ADK 和 A2UI 构建丰富的智能体界面

1. 简介

借助 A2UI,AI 智能体可以生成丰富的交互式用户界面,这些界面可在 Web、移动设备和桌面设备上以原生方式呈现,而无需执行任意代码。A2UI 不会仅提供文本响应或执行有风险的代码,而是让智能体发送声明式组件描述,供客户端使用自己的原生 widget 进行呈现。这就像让智能体说一种通用的界面语言。

在此实践实验室中,您将首先使用智能体开发套件 (ADK) 和 Gemini 3.1 Flash Image(又称 Nano Banana 2)创建一个图片生成智能体。然后,您将使用 A2UI 创建一个超越典型聊天机器人的自定义界面,展示如何动态生成界面以实现更丰富的智能体-用户互动。

学习内容

  • 使用 ADK Python 创建代理
  • 配置代理以将 A2UI 组件流式传输到前端
  • 创建自定义前端以呈现 A2UI 元素

前提条件

  • AI 智能体的基础知识
  • 对 Python 语法有基本的了解
  • 对前端概念有基本的了解

2. 设置

按照以下说明初始化本 Codelab 所需的 Google Cloud 项目。初始化项目后,建议您在 Cloud Shell 中运行本 Codelab,因为 Cloud Shell 随附了运行本 Codelab 所需的所有工具。

如果您希望在本地环境中运行此 Codelab,则需要在继续操作之前安装 Python、uv 和代码编辑器。除非另有说明,否则此 Codelab 中的所有说明都假定您是在 Cloud Shell 中运行此 Codelab。

自定进度的环境设置

  1. 登录 Google Cloud 控制台,然后创建一个新项目或重复使用现有项目。如果您还没有 Gmail 或 Google Workspace 账号,则必须创建一个

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • 项目名称是此项目参与者的显示名称。它是 Google API 尚未使用的字符串。您可以随时对其进行更新。
  • 项目 ID 在所有 Google Cloud 项目中是唯一的,并且是不可变的(一经设置便无法更改)。Cloud 控制台会自动生成一个唯一字符串;通常情况下,您无需关注该字符串。在大多数 Codelab 中,您都需要引用项目 ID(通常用 PROJECT_ID 标识)。如果您不喜欢生成的 ID,可以再随机生成一个 ID。或者,您也可以尝试自己的项目 ID,看看是否可用。完成此步骤后便无法更改该 ID,并且此 ID 在项目期间会一直保留。
  • 此外,还有第三个值,即部分 API 使用的项目编号,供您参考。如需详细了解所有这三个值,请参阅文档
  1. 接下来,您需要在 Cloud 控制台中启用结算功能,以便使用 Cloud 资源/API。运行此 Codelab 应该不会产生太多的费用(如果有的话)。若要关闭资源以避免产生超出本教程范围的结算费用,您可以删除自己创建的资源或删除项目。Google Cloud 新用户符合参与 300 美元免费试用计划的条件。

启动 Cloud Shell

虽然可以通过笔记本电脑对 Google Cloud 进行远程操作,但在此 Codelab 中,您将使用 Google Cloud Shell,这是一个在云端运行的命令行环境。

Google Cloud 控制台 中,点击右上角工具栏中的 Cloud Shell 图标:

激活 Cloud Shell

预配和连接到环境应该只需要片刻时间。完成后,您应该会看到如下内容:

Google Cloud Shell 终端的屏幕截图,显示环境已连接

这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 5 GB 主目录,并且在 Google Cloud 中运行,大大增强了网络性能和身份验证功能。您在此 Codelab 中的所有工作都可以在浏览器中完成。您无需安装任何程序。

3. 创建新的 ADK 代理

  1. 为本次研讨会创建一个名为 a2ui_lab 的文件夹:
mkdir -p ~/a2ui_lab && cd ~/a2ui_lab
  1. 在此文件夹中配置 uv 软件包管理系统并安装依赖项:
uv init && uv add google-adk fastapi uvicorn a2ui-agent-sdk
  1. 启用 AI Platform API(用于进行 Gemini 模型调用)
gcloud services enable aiplatform.googleapis.com
  1. 在此文件夹中初始化 ADK 代理:
export GOOGLE_CLOUD_PROJECT=`gcloud config get project`
uv run adk create --model gemini-3.5-flash --project $GOOGLE_CLOUD_PROJECT --region global art_creator

您应该会看到如下所示的输出:

$ uv run adk create --model gemini-3.5-flash --project $GOOGLE_CLOUD_PROJECT --region global art_creator
Agent created in ~/a2ui_lab/art_creator:
- .env
- __init__.py
- agent.py
⚠️  WARNING: Secrets (like GOOGLE_API_KEY) are stored in .env.
Please ensure .env is added to your .gitignore to avoid committing secrets to version control.

请注意,uv run 是在当前 uv 代码库(我们在运行 uv init 时创建)的上下文中执行命令的命令。添加 google-adk 软件包依赖项时,命令 adk 已安装到此代码库中。

在 ADK 文档中,您经常会看到不带 uv run 前缀的 adk 命令,但在本研讨会中运行命令时,请务必在 adk 前面加上 uv run 前缀,以便运行正确的命令行实用程序。

现在,基本智能体结构已创建完毕,我们可以在 agent.py 中定义图片生成智能体。

  1. 使用以下命令打开 Cloud Shell 编辑器:
cloudshell workspace ~/a2ui_lab
  1. art_creator/agent.py 的内容替换为以下代码:

art_creator/agent.py

import os
import time
from google.adk.agents.llm_agent import Agent
from google.adk.tools.tool_context import ToolContext
from google.genai import types

# Load env variables
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))

async def generate_image(prompt: str, tool_context: ToolContext) -> dict:
    """Generates a high-quality image based on the user's detailed description prompt.

    Args:
        prompt: A descriptive text prompt describing the image to generate.
        tool_context: Context for the tool execution.
    """
    from google.genai import Client
    client = Client(
        vertexai=True, 
        project=os.environ.get("GOOGLE_CLOUD_PROJECT"), 
        location=os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
    )
    
    try:
        response = client.models.generate_content(
            model="gemini-3.1-flash-image",
            contents=prompt,
            config=types.GenerateContentConfig(
                response_modalities=['TEXT', 'IMAGE'],
            )
        )
        
        image_bytes = None
        for part in response.parts or []:
            if part.inline_data is not None:
                image_bytes = part.inline_data.data
                break
                
        if not image_bytes:
            return {"status": "failed", "detail": "No image data returned"}
            
        filename = f"image_{int(time.time())}.png"
        await tool_context.save_artifact(
            filename,
            types.Part.from_bytes(data=image_bytes, mime_type='image/png'),
        )
        
        return {
            "status": "success",
            "filename": filename,
            "url": f"/api/artifacts/{tool_context.session.id}/{filename}"
        }
    except Exception as e:
        return {"status": "failed", "detail": str(e)}

root_agent = Agent(
    name="art_agent",
    model="gemini-3.5-flash",
    description="A basic art generation agent.",
    instruction=(
        "You are an art assistant. When the user describes an image they want to generate, "
        "use the generate_image tool to create it, then return a text message containing the image's URL."
    ),
    tools=[generate_image],
)
  1. 您现在可以使用命令 uv run adk web 在 ADK 的开发界面中测试代理:
uv run adk web --port 8080 --allow_origins "*" --reload_agents

然后,点击“网页预览”按钮,并选择“在端口 8080 上预览”。这将在浏览器中打开开发界面。

使用 ADK 的开发界面,通过向智能体提供一些提示来测试其功能,例如:

  • 动漫女孩在树下睡觉。粉彩色。16:9
  • 照片:湖面映照着一间小屋。傍晚时分。怀旧感。

您应该会看到智能体以文本和生成的图片进行回答。

b2d0199724e9599.png

4. 创建简单的前端

现在,我们将为智能体构建一个专用 Web 应用。我们将使用 FastAPI 运行 ADK 运行程序,并提供一个简单的单页聊天界面。

首先,在终端中输入 Ctrl+C,停止 ADK 开发服务器。然后,在工作区根目录 (~/a2ui_lab/main.py) 中创建一个名为 main.py 的文件,其中包含以下内容:

main.py

import os
import logging
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from google.adk.runners import InMemoryRunner
from google.adk.agents.run_config import RunConfig
from google.genai import types

from art_creator.agent import root_agent

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(title="Art Agent - Simple Chat")

class ChatRequest(BaseModel):
    prompt: str
    session_id: str = "default_session"

static_dir = os.path.join(os.path.dirname(__file__), "static")
os.makedirs(static_dir, exist_ok=True)

runner = InMemoryRunner(agent=root_agent)
runner.auto_create_session = True

@app.get("/api/artifacts/{session_id}/{filename}")
async def get_artifact(session_id: str, filename: str):
    user_id = "default_user"
    part = await runner.artifact_service.load_artifact(
        app_name=runner.app_name,
        user_id=user_id,
        filename=filename,
        session_id=session_id
    )
    if not part:
        raise HTTPException(status_code=404, detail="Artifact not found")
    if part.inline_data:
        from fastapi.responses import Response
        return Response(content=part.inline_data.data, media_type=part.inline_data.mime_type)
    raise HTTPException(status_code=400, detail="Unsupported artifact format")

@app.post("/api/chat")
async def chat_endpoint(request: ChatRequest):
    if not request.prompt.strip():
        raise HTTPException(status_code=400, detail="Prompt cannot be empty")
        
    user_id = "default_user"
    content = types.Content(
        role="user", 
        parts=[types.Part.from_text(text=request.prompt)]
    )
    
    full_response_text = ""
    try:
        async for event in runner.run_async(
            user_id=user_id,
            session_id=request.session_id,
            new_message=content,
            run_config=RunConfig(save_input_blobs_as_artifacts=True),
        ):
            if event.content and event.content.parts:
                if event.author != "user":
                    for part in event.content.parts:
                        if part.text:
                            full_response_text += part.text
                        elif part.inline_data:
                            try:
                                # Process raw binary/custom text parts (A2UI callback packages)
                                text_data = part.inline_data.data.decode("utf-8")
                                full_response_text += text_data
                            except Exception:
                                pass
    except Exception as e:
        logger.exception("Error running ADK agent:")
        raise HTTPException(status_code=500, detail=str(e))
        
    image_url = None
    try:
        artifact_keys = await runner.artifact_service.list_artifact_keys(
            app_name=runner.app_name,
            user_id=user_id,
            session_id=request.session_id
        )
        image_keys = [k for k in artifact_keys if k.startswith("image_") and k.endswith(".png")]
        if image_keys:
            sorted_keys = sorted(image_keys, reverse=True)
            image_url = f"/api/artifacts/{request.session_id}/{sorted_keys[0]}"
    except Exception:
        pass
        
    return {
        "text": full_response_text.strip(),
        "image_url": image_url
    }

app.mount("/static", StaticFiles(directory=static_dir), name="static")

@app.get("/")
async def read_index():
    from fastapi.responses import FileResponse
    return FileResponse(os.path.join(static_dir, "index.html"))

接下来,创建 static 目录以存储前端文件:

mkdir -p static

现在,添加索引 HTML (static/index.html):

static/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Art Agent - Simple Chat</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        <div id="chat-messages" class="messages">
            <div class="message system">
                <strong>System:</strong> Welcome to the Art Agent! Describe the image you want to generate.
            </div>
        </div>
        <form id="chat-form" class="input-form">
            <input type="text" id="user-input" placeholder="Type image description..." autocomplete="off" required>
            <button type="submit">Generate</button>
        </form>
    </div>
    <script src="/static/app.js"></script>
</body>
</html>

样式 CSS (static/style.css):

static/style.css

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    background-color: #f7f9fa;
    margin: 0;
    padding: 20px;
    display: flex;
    justify-content: center;
}

.container {
    width: 100%;
    max-width: 600px;
    background: #ffffff;
    border: 1px solid #e1e8ed;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.05);
    display: flex;
    flex-direction: column;
    height: 80vh;
}

.messages {
    flex: 1;
    overflow-y: auto;
    padding: 20px;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.message {
    padding: 10px 14px;
    border-radius: 6px;
    max-width: 85%;
    line-height: 1.4;
    word-wrap: break-word;
}

.message.user {
    background-color: #e8f5fe;
    align-self: flex-end;
}

.message.agent {
    background-color: #f1f3f4;
    align-self: flex-start;
}

.message.system {
    background-color: #fff;
    border: 1px solid #e1e8ed;
    color: #657786;
    align-self: center;
    font-size: 13px;
    text-align: center;
}

.input-form {
    display: flex;
    border-top: 1px solid #e1e8ed;
    padding: 12px;
}

.input-form input {
    flex: 1;
    border: 1px solid #ccc;
    border-radius: 4px;
    padding: 10px;
    font-size: 14px;
    outline: none;
}

.input-form button {
    background-color: #1da1f2;
    color: white;
    border: none;
    border-radius: 4px;
    padding: 10px 16px;
    margin-left: 8px;
    font-size: 14px;
    font-weight: bold;
    cursor: pointer;
}

.input-form button:hover {
    background-color: #1a91da;
}

.generated-img {
    max-width: 100%;
    border-radius: 4px;
    margin-top: 8px;
    display: block;
}

.image-card {
    border: 1px solid #ddd;
    border-radius: 6px;
    padding: 12px;
    background-color: #fff;
    margin-top: 8px;
}

.btn-download {
    background-color: #1da1f2;
    color: white;
    border: none;
    border-radius: 4px;
    padding: 8px 12px;
    font-size: 13px;
    font-weight: bold;
    cursor: pointer;
    margin-top: 8px;
    width: 100%;
}

最后,添加 JavaScript 控制器 (static/app.js):

static/app.js

document.addEventListener('DOMContentLoaded', () => {
    const chatForm = document.getElementById('chat-form');
    const userInput = document.getElementById('user-input');
    const chatMessages = document.getElementById('chat-messages');
    const sessionId = "session_" + Math.random().toString(36).substring(2, 9);

    chatForm.addEventListener('submit', async (e) => {
        e.preventDefault();
        const prompt = userInput.value.trim();
        if (!prompt) return;

        userInput.value = '';
        appendMessage('user', prompt);

        const tempBubble = appendMessage('agent', '...');

        try {
            const response = await fetch('/api/chat', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ prompt, session_id: sessionId })
            });
            const data = await response.json();
            tempBubble.remove();
            
            const textValue = (data && typeof data === 'object' && data.text) ? data.text : "";
            const imageUrl = (data && typeof data === 'object' && data.image_url) ? data.image_url : null;
            appendMessage('agent', textValue, imageUrl);
        } catch (error) {
            tempBubble.remove();
            appendMessage('agent', `Error: ${error.message}`);
        }
    });

    function appendMessage(sender, text, imageUrl = null) {
        const bubble = document.createElement('div');
        bubble.className = `message ${sender}`;

        const content = document.createElement('span');
        content.innerHTML = `<strong>${sender === 'user' ? 'You' : 'Agent'}:</strong> `;
        
        const textNode = document.createTextNode(text);
        content.appendChild(textNode);
        bubble.appendChild(content);

        // Always format multi-line JSON blocks nicely if the message is from the agent and looks like JSON
        if (sender === 'agent' && text && (text.startsWith('{') || text.startsWith('['))) {
            bubble.style.fontFamily = 'monospace';
            bubble.style.whiteSpace = 'pre-wrap';
            bubble.style.fontSize = '12px';
        }

        if (imageUrl) {
            const card = document.createElement('div');
            card.className = 'image-card';
            const img = document.createElement('img');
            img.src = imageUrl;
            img.className = 'generated-img';
            card.appendChild(img);

            const dlBtn = document.createElement('button');
            dlBtn.className = 'btn-download';
            dlBtn.textContent = 'Download PNG';
            dlBtn.onclick = () => {
                const link = document.createElement('a');
                link.href = imageUrl;
                link.download = `generation-${Date.now()}.png`;
                link.click();
            };
            card.appendChild(dlBtn);
            bubble.appendChild(card);
        }

        chatMessages.appendChild(bubble);
        chatMessages.scrollTop = chatMessages.scrollHeight;
        return bubble;
    }
});

通过启动 FastAPI 服务器来测试您的 Web 应用:

uv run python -m uvicorn main:app --port 8080 --host 0.0.0.0

使用端口 8080 上的网页预览功能访问自定义聊天。现在,您可以直接与代理对话了。

392fc3e4baa64d1c.png

5. 配置代理以发出 A2UI 消息

现在,我们来更新智能体,使其返回结构化界面,而不仅仅是文本。我们将使用官方 a2ui-agent-sdk 为智能体构建一个支持 A2UI 的系统提示。

使用 A2UI SDK 时,我们不直接定义代理指令,而是使用 A2uiSchemaManager 类,该类将构建代理的系统提示,以了解 A2UI 的界面生成功能,包括提供对组件目录、完整组件架构和使用示例(如有)的访问权限。

  1. 首先,按 Ctrl+C 停止 FastAPI 服务器。
  2. 修改 art_creator/agent.py 以集成 A2uiSchemaManager 和新的 a2ui_callback 钩子:

art_creator/agent.py

import os
import time
from google.adk.agents.llm_agent import Agent
from google.adk.tools.tool_context import ToolContext
from google.genai import types
from a2ui.schema.manager import A2uiSchemaManager
from a2ui.basic_catalog.provider import BasicCatalog

# Load env variables
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))


async def generate_image(prompt: str, tool_context: ToolContext) -> dict:
    """Generates a high-quality image based on the user's detailed description prompt."""
    from google.genai import Client
    client = Client(
        vertexai=True, 
        project=os.environ.get("GOOGLE_CLOUD_PROJECT"), 
        location=os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
    )
    
    try:
        response = client.models.generate_content(
            model="gemini-3.1-flash-image",
            contents=prompt,
            config=types.GenerateContentConfig(
                response_modalities=['TEXT', 'IMAGE'],
            )
        )
        
        image_bytes = None
        for part in response.parts or []:
            if part.inline_data is not None:
                image_bytes = part.inline_data.data
                break
                
        if not image_bytes:
            return {"status": "failed", "detail": "No image data returned"}
            
        filename = f"image_{int(time.time())}.png"
        await tool_context.save_artifact(
            filename,
            types.Part.from_bytes(data=image_bytes, mime_type='image/png'),
        )
        
        return {
            "status": "success",
            "filename": filename,
            "url": f"/api/artifacts/{tool_context.session.id}/{filename}"
        }
    except Exception as e:
        return {"status": "failed", "detail": str(e)}

schema_manager = A2uiSchemaManager(
    version="0.8",
    catalogs=[BasicCatalog.get_config("0.8")],
)

instruction = schema_manager.generate_system_prompt(
    role_description=(
        "You are a specialized Image Creator agent. "
        "When given an image description, analyze the prompt and ask the user for any missing details. "
        "The image generation prompt should include: "
        "Subject, environment, style, lighting, color and mood. "
    ),
    workflow_description=(
        "1. if the user greets you, greet the user back explaining your purpose. "
        "2. if the user describes an image, DO NOT GENERATE IT IMMEDIATELY: compare with "
        "   the ideal generation prompt and ask the user for any missing details using "
        "   rich A2UI UI elements only. NOTE: only run this step once per image, if the user "
        "   decides to not detail one or more elements it is ok. "
        "3. combine the original prompt with the responses in the UI elements and call `generate_image` "
        "   with the generated prompt. "
        "   DO NOT INCLUDE EXAMPLES IN THE GENERATED PROMPT, ONLY THE ELEMENTS THE USER ASKED FOR. "
        "4. display the resulting image to the user in a card including the image, the prompt and a "
        "   download button"
    ),
    ui_description=(
        "Use Card, Text, Image, Multichoice and Button components to present the options. "
        "Always include a single choice selection box for image resolution (1K, 2K or 4K) and one for "
        "aspect ratio (1:1, 16:9 or 9:16). "
        "When rendering the final output (generated image) always render the generated image using an "
        "Image component with the url bound to the image's URL/path returned by the tool. "
        "Add a Text component with the prompt that generated the image. "
        "Include a Button component labeled 'Download PNG' to allow downloading the image. "
        "Do NOT use markdown formatting in text values. Use the usageHint property for heading levels instead. "
        "Respond ONLY with the A2UI JSON array. Do NOT include any text "
        "outside the JSON. Put all explanations into Text components."
    ),
    include_schema=True,
)

root_agent = Agent(
    model="gemini-3.5-flash",
    name="art_agent_a2ui",
    instruction=instruction,
    tools=[generate_image],
)

请注意,现在智能体指令是通过 schema_manager.generate_system_prompt 调用生成的,而不是硬编码在智能体定义中。

通过启动 FastAPI 服务器来测试您的 Web 应用:

uv run python -m uvicorn main:app --port 8080 --host 0.0.0.0

使用端口 8080 上的网页预览功能访问自定义聊天。您会注意到,现在智能体输出的是 JSON 消息,而不是普通文本。这是 A2UI 元素的内部表示形式,我们将在下一部分中渲染这些元素。

74f75b59b2dbb6fb.png

6. 为代理创建自定义前端

在此阶段,我们的客户端会收到一份干净的 A2UI 消息列表(beginRenderingsurfaceUpdatedataModelUpdate)。现在,我们将使用纯 JavaScript 构建自定义客户端渲染引擎,以查看这些组件的实际效果。

以下是包含 A2UI 解析和渲染逻辑的完整静态/app.js:

static/app.js

document.addEventListener('DOMContentLoaded', () => {
    const chatForm = document.getElementById('chat-form');
    const userInput = document.getElementById('user-input');
    const chatMessages = document.getElementById('chat-messages');
    const sessionId = "session_" + Math.random().toString(36).substring(2, 9);

    async function sendChat(prompt, showInUi = true) {
        if (!prompt) return;

        if (showInUi) {
            appendMessage('user', prompt);
        }

        const tempBubble = appendMessage('agent', '...');

        try {
            const response = await fetch('/api/chat', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ prompt, session_id: sessionId })
            });
            const data = await response.json();
            tempBubble.remove();

            let text = data.text || "";
            let a2uiMessages = [];

            // 1. Extract <a2ui-json> blocks
            const a2uiRegex = /<a2ui-json>(.*?)<\/a2ui-json>/gs;
            let match;
            while ((match = a2uiRegex.exec(text)) !== null) {
                try {
                    const jsonStr = match[1].trim();
                    const parsed = JSON.parse(jsonStr);
                    const parsedList = Array.isArray(parsed) ? parsed : [parsed];
                    for (const msg of parsedList) {
                        if (msg && typeof msg === 'object') {
                            a2uiMessages.push(msg);
                        }
                    }
                } catch (e) {
                    console.error("Error parsing <a2ui-json> block:", e);
                }
            }

            // 2. Extract <a2a_datapart_json> blocks (for robust history/callback parsing)
            const a2aRegex = /<a2a_datapart_json>(.*?)<\/a2a_datapart_json>/gs;
            while ((match = a2aRegex.exec(text)) !== null) {
                try {
                    const jsonStr = match[1].trim();
                    const parsed = JSON.parse(jsonStr);
                    const dataMsg = (parsed && parsed.kind === 'data') ? parsed.data : parsed;
                    if (dataMsg && typeof dataMsg === 'object') {
                        a2uiMessages.push(dataMsg);
                    }
                } catch (e) {
                    console.error("Error parsing <a2a_datapart_json> block:", e);
                }
            }

            // 3. Clean XML and A2UI JSON tags from displayed conversational text
            const cleanText = text.replace(/<(a2ui-json|a2a_datapart_json)>.*?<\/\1>/gs, '').trim();

            const imageUrl = (data && typeof data === 'object' && data.image_url) ? data.image_url : null;
            appendMessage('agent', cleanText, imageUrl, a2uiMessages);
        } catch (error) {
            tempBubble.remove();
            appendMessage('agent', `Error: ${error.message}`);
        }
    }

    chatForm.addEventListener('submit', async (e) => {
        e.preventDefault();
        const prompt = userInput.value.trim();
        if (!prompt) return;

        userInput.value = '';
        await sendChat(prompt, true);
    });

    function renderA2UI(a2uiMessages) {
        try {
            let rootId = null;
            const components = {};
            const dataModel = {};

            for (const msg of a2uiMessages) {
                if (msg.beginRendering) {
                    rootId = msg.beginRendering.root;
                } else if (msg.surfaceUpdate) {
                    for (const item of msg.surfaceUpdate.components) {
                        components[item.id] = item.component;
                    }
                } else if (msg.dataModelUpdate) {
                    for (const item of msg.dataModelUpdate.contents) {
                        const val = item.valueString !== undefined ? item.valueString :
                                    item.valueBool !== undefined ? item.valueBool :
                                    item.valueInt !== undefined ? item.valueInt :
                                    item.valueDouble !== undefined ? item.valueDouble : item.valueString;
                        dataModel[item.key] = val;
                    }
                }
            }

            if (!rootId || Object.keys(components).length === 0) {
                return null;
            }

            function resolveValue(valObj) {
                if (!valObj) return '';
                let val = '';
                if (typeof valObj === 'string') val = valObj;
                else if (valObj.literalString !== undefined) val = valObj.literalString;
                else if (valObj.path !== undefined) val = dataModel[valObj.path] || '';
                else val = JSON.stringify(valObj);

                // Dynamically replace any wrong session IDs in artifact URLs with the active sessionId
                if (typeof val === 'string' && val.includes('/api/artifacts/')) {
                    val = val.replace(/\/api\/artifacts\/session_[a-z0-9]+/g, `/api/artifacts/${sessionId}`);
                }
                return val;
            }

            function buildElement(id) {
                try {
                    const compDesc = components[id];
                    if (!compDesc) return null;

                    const type = Object.keys(compDesc)[0];
                    const props = compDesc[type];

                    const el = document.createElement('div');
                    el.className = `a2ui-component a2ui-${type.toLowerCase()}`;
                    el.style.margin = '4px 0';

                    if (type === 'Column') {
                        el.style.display = 'flex';
                        el.style.flexDirection = 'column';
                        el.style.gap = '8px';
                        const children = props.children?.explicitList || [];
                        for (const childId of children) {
                            const childEl = buildElement(childId);
                            if (childEl) el.appendChild(childEl);
                        }
                    } else if (type === 'Row') {
                        el.style.display = 'flex';
                        el.style.flexDirection = 'row';
                        el.style.gap = '8px';
                        el.style.alignItems = 'center';
                        const children = props.children?.explicitList || [];
                        for (const childId of children) {
                            const childEl = buildElement(childId);
                            if (childEl) el.appendChild(childEl);
                        }
                    } else if (type === 'Card') {
                        el.style.border = '1px solid #ddd';
                        el.style.borderRadius = '6px';
                        el.style.padding = '12px';
                        el.style.backgroundColor = '#fdfdfd';
                        el.style.marginTop = '8px';
                        if (props.child) {
                            const childEl = buildElement(props.child);
                            if (childEl) el.appendChild(childEl);
                        }
                    } else if (type === 'Text') {
                        const textVal = resolveValue(props.text);
                        const tag = props.usageHint === 'h1' ? 'h3' :
                                    props.usageHint === 'h2' ? 'h4' : 'p';
                        const textEl = document.createElement(tag);
                        textEl.textContent = textVal;
                        textEl.style.margin = '0 0 4px 0';
                        el.appendChild(textEl);
                    } else if (type === 'Image') {
                        const srcVal = resolveValue(props.url) || resolveValue(props.src);
                        const imgEl = document.createElement('img');
                        imgEl.src = srcVal;
                        imgEl.style.maxWidth = '100%';
                        imgEl.style.borderRadius = '4px';
                        imgEl.style.display = 'block';
                        imgEl.style.marginTop = '6px';
                        imgEl.className = 'generated-img';
                        el.appendChild(imgEl);
                    } else if (type === 'Divider') {
                        const hrEl = document.createElement('hr');
                        hrEl.style.border = '0';
                        hrEl.style.borderTop = '1px solid #eee';
                        hrEl.style.margin = '12px 0';
                        el.appendChild(hrEl);
                    } else if (type === 'MultipleChoice') {
                        const labelVal = resolveValue(props.label);
                        const options = props.options?.explicitList || (Array.isArray(props.options) ? props.options : []);

                        const container = document.createElement('div');
                        container.style.display = 'flex';
                        container.style.flexDirection = 'column';
                        container.style.gap = '4px';
                        container.style.margin = '8px 0';

                        if (labelVal) {
                            const labelEl = document.createElement('label');
                            labelEl.textContent = labelVal;
                            labelEl.style.fontSize = '12px';
                            labelEl.style.fontWeight = 'bold';
                            container.appendChild(labelEl);
                        }

                        const selectEl = document.createElement('select');
                        selectEl.className = 'a2ui-select';
                        selectEl.name = id;

                        for (const option of options) {
                            const optEl = document.createElement('option');
                            optEl.value = option.value !== undefined ? option.value : (option.id !== undefined ? option.id : '');
                            optEl.textContent = resolveValue(option.label);
                            selectEl.appendChild(optEl);
                        }
                        container.appendChild(selectEl);
                        el.appendChild(container);
                    } else if (type === 'Button') {
                        let labelVal = props.label ? resolveValue(props.label) : '';
                        if (!labelVal && props.child) {
                            const childComp = components[props.child];
                            if (childComp && childComp.Text) {
                                labelVal = resolveValue(childComp.Text.text);
                            }
                        }

                        const btnEl = document.createElement('button');
                        btnEl.className = 'btn-download';
                        btnEl.textContent = labelVal || 'Submit';

                        btnEl.addEventListener('click', (e) => {
                            e.preventDefault();
                            
                            const action = props.action;
                            let downloadUrl = null;
                            if (action && (action.name === 'download_file' || action.name === 'download') && action.context) {
                                const urlContext = action.context.find(ctx => ctx.key === 'url');
                                if (urlContext) {
                                    downloadUrl = resolveValue(urlContext.value);
                                }
                            }

                            const isDownload = downloadUrl || (labelVal && labelVal.toLowerCase().includes('download'));
                            if (isDownload) {
                                const finalUrl = downloadUrl || (el.closest('.message')?.querySelector('img')?.src);
                                if (finalUrl) {
                                    const link = document.createElement('a');
                                    link.href = finalUrl;
                                    link.download = `generation-${Date.now()}.png`;
                                    link.click();
                                }
                            } else {
                                const bubbleEl = el.closest('.message');
                                const selects = bubbleEl.querySelectorAll('.a2ui-select');
                                let answers = [];
                                selects.forEach(sel => {
                                    let labelText = sel.previousSibling ? sel.previousSibling.textContent : sel.name;
                                    const selectedText = sel.options[sel.selectedIndex]?.textContent || sel.value;
                                    answers.push(`- ${labelText}: ${selectedText}`);
                                });

                                if (answers.length > 0) {
                                    const responseText = `Selected options:\n` + answers.join('\n');
                                    sendChat(responseText, false);
                                } else {
                                    sendChat(labelVal || 'Submit', false);
                                }
                            }
                        });
                        el.appendChild(btnEl);
                    }

                    return el;
                } catch (err) {
                    console.error('Error building component:', id, err);
                    return null;
                }
            }

            return buildElement(rootId);
        } catch (err) {
            console.error('Error in renderA2UI:', err);
            return null;
        }
    }

    function appendMessage(sender, text, imageUrl = null, a2ui = null) {
        const bubble = document.createElement('div');
        bubble.className = `message ${sender}`;

        const textSpan = document.createElement('span');
        textSpan.innerHTML = `<strong>${sender === 'user' ? 'You' : 'Agent'}:</strong> `;
        bubble.appendChild(textSpan);

        if (text) {
            const textContent = document.createTextNode(text);
            textSpan.appendChild(textContent);
        }

        if (sender === 'agent' && a2ui && a2ui.length > 0) {
            const a2uiEl = renderA2UI(a2ui);
            if (a2uiEl) {
                bubble.appendChild(a2uiEl);
            }
        }

        if (imageUrl) {
            const imgContainer = document.createElement('div');
            imgContainer.style.marginTop = '8px';
            const img = document.createElement('img');
            img.src = imageUrl;
            img.style.maxWidth = '100%';
            img.style.borderRadius = '4px';
            img.className = 'generated-img';
            imgContainer.appendChild(img);
            bubble.appendChild(imgContainer);
        }

        chatMessages.appendChild(bubble);
        chatMessages.scrollTop = chatMessages.scrollHeight;
        return bubble;
    }
});

再次启动 FastAPI 应用服务器:

uv run python -m uvicorn main:app --port 8080 --host 0.0.0.0

并与完全动态的 A2UI Art Creator 代理对话!

e655de35ca809f8b.png

7. 恭喜!

您构建了一个使用 A2UI 动态生成界面元素的 ADK 智能体。您可以探索各种框架集成,也可以探索下方参考资料中的文档,继续您的学习之旅。

构建生产环境前端

在此研讨会中,我们出于教学目的使用了自定义的纯 JS 前端,但在实际应用中,您可以使用以下官方 A2UI 渲染器之一来构建前端:

平台

渲染程序

安装

Web(React)

@a2ui/react

npm install @a2ui/react

Web (Lit)

@a2ui/lit

npm install @a2ui/lit

Web (Angular)

@a2ui/angular

npm install @a2ui/angular

移动设备/桌面设备

Flutter GenUI SDK

开始使用

参考文档