1. 소개
A2UI를 사용하면 AI 에이전트가 임의의 코드를 실행하지 않고도 웹, 모바일, 데스크톱에서 네이티브로 렌더링되는 풍부한 대화형 사용자 인터페이스를 생성할 수 있습니다. A2UI를 사용하면 상담사는 텍스트 전용 응답이나 위험한 코드 실행 대신 클라이언트가 자체 네이티브 위젯을 사용하여 렌더링하는 선언적 구성요소 설명을 보낼 수 있습니다. 에이전트가 범용 UI 언어를 사용하는 것과 같습니다.
이 실습에서는 먼저 에이전트 개발 키트 (ADK)와 Gemini 3.1 Flash Image (일명 Nano Banana 2)를 사용하여 이미지 생성 에이전트를 만듭니다. 그런 다음 A2UI를 사용하여 일반적인 챗봇을 넘어선 맞춤 인터페이스를 만들어 에이전트-사용자 간 상호작용을 풍부하게 지원하는 인터페이스를 동적으로 생성하는 방법을 보여줍니다.
학습할 내용
- ADK Python을 사용하여 에이전트 만들기
- A2UI 구성요소를 프런트엔드로 스트리밍하도록 에이전트 구성
- A2UI 요소를 렌더링하는 맞춤 프런트엔드 만들기
기본 요건
- AI 에이전트에 관한 기본 지식
- Python 문법에 대한 기본 이해
- 프런트엔드 개념에 대한 기본적인 이해
2. 설정
아래 안내에 따라 이 Codelab에 필요한 Google Cloud 프로젝트를 초기화합니다. 프로젝트를 초기화한 후에는 바로 실행하는 데 필요한 모든 도구가 포함된 Cloud Shell에서 이 Codelab을 실행하는 것이 좋습니다.
로컬 환경에서 이 Codelab을 실행하려면 진행하기 전에 Python, uv, 코드 편집기를 설치해야 합니다. 이 Codelab의 모든 안내는 달리 언급하지 않는 한 Cloud Shell에서 실행하는 것으로 가정합니다.
자습형 환경 설정
- Google Cloud Console에 로그인하여 새 프로젝트를 만들거나 기존 프로젝트를 재사용합니다. 아직 Gmail이나 Google Workspace 계정이 없는 경우 계정을 만들어야 합니다.



- 프로젝트 이름은 이 프로젝트 참가자의 표시 이름입니다. 이는 Google API에서 사용하지 않는 문자열이며 언제든지 업데이트할 수 있습니다.
- 프로젝트 ID는 모든 Google Cloud 프로젝트에서 고유하며, 변경할 수 없습니다(설정된 후에는 변경할 수 없음). Cloud 콘솔은 고유한 문자열을 자동으로 생성합니다. 일반적으로는 신경 쓰지 않아도 됩니다. 대부분의 Codelab에서는 프로젝트 ID (일반적으로
PROJECT_ID로 식별됨)를 참조해야 합니다. 생성된 ID가 마음에 들지 않으면 다른 임의 ID를 생성할 수 있습니다. 또는 직접 시도해 보고 사용 가능한지 확인할 수도 있습니다. 이 단계 이후에는 변경할 수 없으며 프로젝트 기간 동안 유지됩니다. - 참고로 세 번째 값은 일부 API에서 사용하는 프로젝트 번호입니다. 이 세 가지 값에 대한 자세한 내용은 문서를 참고하세요.
- 다음으로 Cloud 리소스/API를 사용하려면 Cloud 콘솔에서 결제를 사용 설정해야 합니다. 이 Codelab 실행에는 많은 비용이 들지 않습니다. 이 튜토리얼이 끝난 후에 요금이 청구되지 않도록 리소스를 종료하려면 만든 리소스 또는 프로젝트를 삭제하면 됩니다. Google Cloud 신규 사용자는 300달러(USD) 상당의 무료 체험판 프로그램에 참여할 수 있습니다.
Cloud Shell 시작
Google Cloud를 노트북에서 원격으로 실행할 수 있지만, 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.
Google Cloud Console의 오른쪽 상단 툴바에 있는 Cloud Shell 아이콘을 클릭합니다.

환경을 프로비저닝하고 연결하는 데 몇 분 정도 소요됩니다. 완료되면 다음과 같이 표시됩니다.

가상 머신에는 필요한 개발 도구가 모두 들어있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 이 Codelab의 모든 작업은 브라우저 내에서 수행할 수 있습니다. 아무것도 설치할 필요가 없습니다.
3. 새 ADK 에이전트 만들기
- 이 워크숍을 위한
a2ui_lab라는 폴더를 만듭니다.
mkdir -p ~/a2ui_lab && cd ~/a2ui_lab
- 이 폴더에서 uv 패키지 관리자를 구성하고 종속 항목을 설치합니다.
uv init && uv add google-adk fastapi uvicorn a2ui-agent-sdk
- AI Platform API 사용 설정 (Gemini 모델 호출)
gcloud services enable aiplatform.googleapis.com
- 이 폴더에서 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 init를 실행할 때 생성한 현재 uv 저장소의 컨텍스트 내에서 명령어를 실행하는 명령어입니다. google-adk 패키지 종속 항목을 추가할 때 adk 명령어가 이 저장소에 설치되었습니다.
ADK 문서에서는 uv run 접두사 없이 adk 명령어를 자주 볼 수 있지만 이 워크숍에서 명령어를 실행할 때는 항상 adk에 uv run을 접두사로 붙여 올바른 명령줄 유틸리티가 실행되도록 하세요.
이제 기본 에이전트 구조가 생성되었으므로 agent.py에서 이미지 생성 에이전트를 정의할 수 있습니다.
- 다음 명령어를 사용하여 Cloud Shell 편집기를 엽니다.
cloudshell workspace ~/a2ui_lab
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],
)
- 이제
uv run adk web명령어를 사용하여 ADK의 개발 UI에서 에이전트를 테스트할 수 있습니다.
uv run adk web --port 8080 --allow_origins "*" --reload_agents
그런 다음 웹 미리보기 버튼을 클릭하고 포트 8080에서 미리보기를 선택합니다. 그러면 브라우저에서 개발 UI가 열립니다.
ADK의 개발 UI를 사용하여 다음과 같은 몇 가지 프롬프트를 제공하여 에이전트 기능을 테스트합니다.
- 나무 아래에서 잠을 자는 애니메이션 소녀 파스텔 색상 16:9
- 호수에 비친 통나무집 사진 늦은 오후 향수
에이전트가 텍스트와 생성된 이미지로 응답합니다.

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에서 웹 미리보기를 사용하여 맞춤설정된 채팅에 액세스합니다. 이제 상담사에게 직접 말할 수 있습니다.

5. A2UI 메시지를 내보내도록 에이전트 구성
이제 텍스트 대신 구조화된 UI를 반환하도록 에이전트를 업데이트해 보겠습니다. 공식 a2ui-agent-sdk를 사용하여 에이전트의 A2UI 인식 시스템 프롬프트를 빌드합니다.
A2UI SDK를 사용할 때는 에이전트 명령어를 직접 정의하는 대신 A2uiSchemaManager 클래스를 사용합니다. 이 클래스는 구성요소 카탈로그, 전체 구성요소 스키마, 사용 예시 (있는 경우)에 대한 액세스 권한을 부여하는 등 A2UI의 인터페이스 생성 기능을 이해하도록 에이전트의 시스템 프롬프트를 구조화합니다.
- 먼저 Ctrl+C를 사용하여 FastAPI 서버를 중지합니다.
A2uiSchemaManager및 새a2ui_callback후크를 통합하도록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
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 요소의 내부 표현입니다.

6. 에이전트의 맞춤 프런트엔드 만들기
이 단계에서 클라이언트는 정리된 A2UI 메시지 목록 (beginRendering, surfaceUpdate, dataModelUpdate)을 가져옵니다. 이제 일반 JavaScript로 맞춤 클라이언트 측 렌더링 엔진을 빌드하여 이러한 구성요소가 작동하는 것을 확인합니다.
다음은 A2UI 파싱 및 렌더링 로직이 포함된 전체 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);
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 에이전트와 대화하세요.

7. 축하합니다.
A2UI를 사용하여 UI 요소를 동적으로 생성하는 ADK 에이전트를 빌드했습니다. 다양한 프레임워크 통합을 살펴보거나 아래 참고 자료의 문서를 살펴 학습 여정을 계속할 수 있습니다.
프로덕션 프런트엔드 빌드
이 워크숍에서는 교육 목적으로 맞춤 제작된 순수 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 |