Создавайте многофункциональные агентские интерфейсы с помощью ADK и A2UI.

1. Введение

A2UI позволяет агентам ИИ создавать насыщенные интерактивные пользовательские интерфейсы, которые отображаются нативно на веб-сайтах, мобильных устройствах и настольных компьютерах — без выполнения произвольного кода. Вместо текстовых ответов или рискованного выполнения кода, A2UI позволяет агентам отправлять декларативные описания компонентов, которые клиенты отображают с помощью своих собственных нативных виджетов. Это как если бы агенты говорили на универсальном языке пользовательского интерфейса.

В этой практической работе вы сначала создадите агента для генерации изображений, используя Agent Development Kit (ADK) и Gemini 3.1 Flash Image (также известный как Nano Banana 2). Затем вы используете A2UI для создания пользовательского интерфейса, выходящего за рамки типичного чат-бота, демонстрируя, как можно динамически генерировать интерфейсы для обеспечения более насыщенного взаимодействия между агентом и пользователем.

Что вы узнаете

  • Создайте агента с помощью ADK Python.
  • Настройте агент для потоковой передачи компонентов A2UI на фронтенд.
  • Создайте собственный интерфейс для отображения элементов A2UI.

Предварительные требования

  • Базовые знания об агентах искусственного интеллекта.
  • Базовое понимание синтаксиса Python.
  • Базовое понимание концепций фронтенда.

2. Настройка

Следуйте приведенным ниже инструкциям, чтобы инициализировать проект Google Cloud, необходимый для этого практического занятия. После инициализации проекта рекомендуется запускать это практическое занятие в Cloud Shell, поскольку в ней уже есть все необходимые инструменты для его запуска.

Если вы предпочитаете выполнять это практическое задание в локальной среде, вам потребуется установить Python, UV-развертку и редактор кода, прежде чем продолжить. Все инструкции в этом практическом задании предполагают выполнение в Cloud Shell, если не указано иное.

Настройка среды для самостоятельного обучения

  1. Войдите в консоль Google Cloud и создайте новый проект или используйте существующий. Если у вас еще нет учетной записи Gmail или Google Workspace, вам необходимо ее создать .

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • Название проекта — это отображаемое имя участников данного проекта. Это строка символов, не используемая API Google. Вы всегда можете его изменить.
  • Идентификатор проекта уникален для всех проектов Google Cloud и является неизменяемым (его нельзя изменить после установки). Консоль Cloud автоматически генерирует уникальную строку; обычно вам неважно, какая она. В большинстве практических заданий вам потребуется указать идентификатор вашего проекта (обычно обозначается как PROJECT_ID ). Если сгенерированный идентификатор вас не устраивает, вы можете сгенерировать другой случайный идентификатор. В качестве альтернативы вы можете попробовать свой собственный и посмотреть, доступен ли он. После этого шага его нельзя изменить, и он сохраняется на протяжении всего проекта.
  • К вашему сведению, существует третье значение — номер проекта , которое используется некоторыми API. Подробнее обо всех трех значениях можно узнать в документации .
  1. Далее вам потребуется включить оплату в консоли Cloud для использования ресурсов/API Cloud. Выполнение этого практического задания не потребует больших затрат, если вообще потребует. Чтобы отключить ресурсы и избежать дополнительных расходов после завершения этого урока, вы можете удалить созданные ресурсы или удалить проект. Новые пользователи Google Cloud имеют право на бесплатную пробную версию стоимостью 300 долларов США .

Запустить Cloud Shell

Хотя Google Cloud можно управлять удаленно с ноутбука, в этом практическом занятии вы будете использовать Google Cloud Shell — среду командной строки, работающую в облаке.

В консоли Google Cloud нажмите на значок Cloud Shell на панели инструментов в правом верхнем углу:

Активировать Cloud Shell

Подготовка и подключение к среде займут всего несколько минут. После завершения вы должны увидеть что-то подобное:

Скриншот терминала Google Cloud Shell, показывающий, что среда подключена.

Эта виртуальная машина содержит все необходимые инструменты разработки. Она предоставляет постоянный домашний каталог объемом 5 ГБ и работает в облаке Google, что значительно повышает производительность сети и аутентификацию. Вся работа в этом практическом задании может выполняться в браузере. Вам не нужно ничего устанавливать.

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. Включите 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 . Команда ` adk была установлена ​​в этот репозиторий при добавлении зависимости пакета google-adk .

В документации ADK вы часто будете встречать команды adk без префикса uv run , но при выполнении команд в этом мастер-классе всегда добавляйте префикс uv run к adk , чтобы запускалась правильная утилита командной строки.

Теперь, когда создана базовая структура агента, мы можем определить агента генерации изображений в файле 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. Теперь вы можете протестировать агента в пользовательском интерфейсе разработчика ADK, используя команду uv run adk web :`.
uv run adk web --port 8080 --allow_origins "*" --reload_agents

Затем нажмите кнопку «Предварительный просмотр в интернете» и выберите «Предварительный просмотр на порту 8080». Это откроет пользовательский интерфейс для разработчиков в вашем браузере.

Используйте пользовательский интерфейс разработчика ADK для проверки возможностей агента, задав ему несколько вопросов, например:

  • Аниме-девушка спит под деревом. Пастельные тона. Соотношение сторон 16:9.
  • Фотография домика, отражающегося в озере. Поздний вечер. Ностальгическое чувство.

Вы должны увидеть ответ агента в виде текста и сгенерированного изображения.

b2d0199724e9599.png

4. Создайте простой интерфейс пользователя.

Теперь мы создадим специальное веб-приложение для нашего агента. Мы будем использовать FastAPI для запуска нашего ADK-раннера и предоставления простого одностраничного интерфейса чата.

Сначала остановите сервер разработки ADK, нажав Ctrl+C в терминале. Затем создайте файл с именем main.py в корневой директории рабочей области ( ~/a2ui_lab/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.

Теперь давайте обновим агента, чтобы он возвращал структурированный пользовательский интерфейс вместо простого текста. Мы будем использовать официальный a2ui-agent-sdk для создания системной подсказки для агента, поддерживающей A2UI.

При использовании SDK A2UI вместо непосредственного определения инструкций агента мы используем класс A2uiSchemaManager, который структурирует системную подсказку агента таким образом, чтобы она соответствовала возможностям генерации интерфейса A2UI, включая предоставление доступа к каталогу компонентов, полной схеме компонентов и примерам использования (если таковые имеются).

  1. Сначала остановите сервер FastAPI, нажав Ctrl+C .
  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:

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

Используйте веб-предварительный просмотр на порту 8080, чтобы получить доступ к своему настроенному чату. Вы заметите, что теперь агент отправляет сообщения в формате JSON вместо обычного текста. Это внутреннее представление элементов A2UI, которые мы будем отображать в следующем разделе.

74f75b59b2dbb6fb.png

6. Создайте пользовательский интерфейс для агента.

На этом этапе наш клиент получает список чистых сообщений A2UI ( beginRendering , surfaceUpdate и dataModelUpdate ). Теперь мы создадим собственный механизм рендеринга на стороне клиента на чистом 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. Вы можете продолжить обучение, изучив различные интеграции с фреймворками или ознакомившись с документацией в приведенных ниже ссылках.

Создайте производственный интерфейс пользователя.

В этом мастер-классе мы использовали специально разработанный фронтенд на чистом JavaScript в дидактических целях, но для производственной среды вам потребуется создать фронтенд, используя один из официальных рендереров A2UI:

Платформа

Рендерер

Установить

Веб (React)

@a2ui/react

npm install @a2ui/react

Веб (литература)

@a2ui/lit

npm install @a2ui/lit

Веб (Angular)

@a2ui/angular

npm install @a2ui/angular

Мобильные/настольные устройства

Flutter GenUI SDK

Начиная

Справочная документация