使用 ADK 和 A2UI 建構豐富的代理介面

1. 簡介

AI 代理程式可透過 A2UI 生成豐富的互動式使用者介面,並在網頁、行動裝置和電腦上以原生方式呈現,不必執行任意程式碼。A2UI 可讓服務專員傳送聲明式元件說明,供用戶使用自己的原生小工具算繪,而非僅傳送文字回覆或執行有風險的程式碼。就像讓代理程式使用通用 UI 語言。

在本實作實驗室中,您會先使用 Agent Development Kit (ADK) 和 Gemini 3.1 Flash Image (又稱 Nano Banana 2),建立圖像生成代理。接著,您會使用 A2UI 建立自訂介面,突破一般聊天機器人的限制,展示如何動態生成介面,實現更豐富的代理與使用者互動。

課程內容

  • 使用 ADK Python 建立代理
  • 設定代理程式,將 A2UI 元件串流至前端
  • 建立自訂前端,用於算繪 A2UI 元素

必要條件

  • AI 代理的基本知識
  • 對 Python 語法有基本瞭解
  • 對前端概念有基本瞭解

2. 設定

請按照下列操作說明,初始化本程式碼研究室所需的 Google Cloud 專案。初始化專案後,建議您在 Cloud Shell 上運作執行這個程式碼實驗室,因為 Cloud Shell 內建執行實驗室所需的所有工具。

如果您偏好在本機環境中執行本程式碼研究室,請先安裝 Python、uv 和程式碼編輯器,再繼續操作。除非另有說明,否則本程式碼研究室中的所有操作說明,都假設您是在 Cloud Shell 中執行。

自修實驗室環境設定

  1. 登入 Google Cloud 控制台,然後建立新專案或重複使用現有專案。如果沒有 Gmail 或 Google Workspace 帳戶,請先建立帳戶

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • 專案名稱是這個專案參與者的顯示名稱。這是 Google API 未使用的字元字串。你隨時可以更新。
  • 專案 ID 在所有 Google Cloud 專案中都是專屬 ID,而且不可變更 (設定後就無法變更)。Cloud Console 會自動產生專屬字串,通常您不需要在意這個字串。在大多數程式碼研究室中,您需要參照專案 ID (通常會標示為 PROJECT_ID)。如果您不喜歡產生的 ID,可以產生另一個隨機 ID。或者,您也可以嘗試使用自己的 ID,看看是否可用。完成這個步驟後,ID 就無法變更,而且會在專案期間保持不變。
  • 請注意,部分 API 會使用第三個值,也就是「專案編號」。如要進一步瞭解這三個值,請參閱說明文件
  1. 接著,您需要在 Cloud 控制台中啟用帳單,才能使用 Cloud 資源/API。完成這個程式碼研究室的費用不高,甚至可能完全免費。如要關閉資源,避免在本教學課程結束後繼續產生費用,請刪除您建立的資源或專案。Google Cloud 新使用者可參加價值$300 美元的免費試用計畫。

啟動 Cloud Shell

雖然可以透過筆電遠端操作 Google Cloud,但在本程式碼研究室中,您將使用 Google Cloud Shell,這是可在雲端執行的指令列環境。

Google Cloud 控制台中,點選右上角工具列的 Cloud Shell 圖示:

啟用 Cloud Shell

佈建並連線至環境的作業需要一些時間才能完成。完成後,您應該會看到如下的內容:

Google Cloud Shell 終端機的螢幕截圖,顯示環境已連線

這部虛擬機器搭載各種您需要的開發工具,而且主目錄提供 5 GB 的永久儲存空間。此外,這部虛擬機器可在 Google Cloud 運作,大幅提升網路效能並強化驗證功能。您可以在瀏覽器中完成本程式碼研究室的所有工作,不需安裝任何軟體。

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 的開發 UI 中測試代理程式:
uv run adk web --port 8080 --allow_origins "*" --reload_agents

然後按一下「網頁預覽」按鈕,並選取「透過以下通訊埠預覽:8080」。這會在瀏覽器中開啟開發使用者介面。

使用 ADK 的開發 UI 提供幾個提示,測試代理的功能,例如:

  • 動漫女孩在樹下睡覺。粉彩色調。16:9
  • 相片:湖面映照著小屋,時間是傍晚,營造出懷舊感。

代理應會回覆文字和生成的圖片。

b2d0199724e9599.png

4. 建立簡單的前端

現在我們要為代理建立專屬網頁應用程式。我們將使用 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 伺服器,測試網頁應用程式:

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

使用通訊埠 8080 上的「網頁預覽」存取自訂對話,即可直接與服務專員對話。

392fc3e4baa64d1c.png

5. 設定代理程式,發出 A2UI 訊息

現在,請更新代理程式,傳回結構化 UI,而非僅傳回文字。我們會使用官方 a2ui-agent-sdk,為代理程式建構可辨識 A2UI 的系統提示。

使用 A2UI SDK 時,我們不會直接定義代理程式指令,而是使用 A2uiSchemaManager 類別,建構代理程式的系統提示,瞭解 A2UI 的介面生成功能,包括存取元件目錄、完整元件結構定義和使用範例 (如有)。

  1. 首先,請使用 Ctrl+C 停止 FastAPI 伺服器。
  2. 修改 art_creator/agent.py,整合 A2uiSchemaManager 和新的 a2ui_callback Hook:

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 伺服器,測試網頁應用程式:

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

使用通訊埠 8080 上的「網頁預覽」存取自訂對話。你會發現,現在代理程式會發出 JSON 訊息,而不是一般文字。這是 A2UI 元素的內部表示法,我們將在下一節中算繪這些元素。

74f75b59b2dbb6fb.png

6. 為代理程式建立自訂前端

在這個階段,用戶端會取得乾淨的 A2UI 訊息清單 (beginRenderingsurfaceUpdatedataModelUpdate)。現在,我們將以純 JavaScript 建構自訂用戶端轉譯引擎,實際查看這些元件。

以下是完整的 static/app.js,其中包含 A2UI 剖析和算繪邏輯:

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. 恭喜!

您已建構 ADK 代理,可使用 A2UI 動態產生 UI 元素。如要繼續學習,請探索各種架構整合,或參閱下列參考資料中的說明文件。

建構正式版前端

在本研討會中,我們基於教學目的使用了自訂的純 JS 前端,但實際工作環境中,您會使用其中一個官方 A2UI 算繪器建構前端:

平台

轉譯器

安裝

網頁 (React)

@a2ui/react

npm install @a2ui/react

網頁 (Lit)

@a2ui/lit

npm install @a2ui/lit

網頁 (Angular)

@a2ui/angular

npm install @a2ui/angular

行動裝置/電腦

Flutter GenUI SDK

開始使用

參考文件