1. Introducción
Qué compilará
En este codelab, te pondrás en el lugar de un desarrollador que crea Fashion App, una app de compras de Flutter para una marca minorista ficticia. Tu misión es agregar dos funciones potenciadas por IA que transformen la experiencia de compra en línea.
- Probador virtual: El usuario sube una foto de sí mismo, selecciona una prenda y ve una imagen generada por IA de sí mismo con esa prenda.
- Asistente de moda con IA: Según la ubicación, la ocasión y las preferencias de estilo del usuario, un agente de IA selecciona recomendaciones de atuendos completos, y el usuario puede definirlos mejor a través de una conversación.
La idea es simple: cuando las personas se prueban ropa en un probador, es mucho más probable que la compren. ¿Pero en línea? Estás adivinando. Este proyecto cierra esa brecha con la IA.
Arquitectura de un vistazo
Flutter App ──── HTTP/REST ────▶ ADK Go Backend
│
┌──────────┼──────────┐
Fitting Room Stylist Catalog
Agent Agent Agent
│
Gemini API + Cloud Storage
Tecnologías principales
Componente | Tecnología | Objetivo |
Framework del agente | ADK (Kit de desarrollo de agentes) para Go | Organización de varios agentes, sesiones y artefactos |
Agent Reasoning (Pro) | Versión preliminar de Gemini 3.1 Pro | Potencia los agentes de probador y de estilista |
Agent Reasoning (Flash) | Versión preliminar de Gemini 3 Flash | Potencia los agentes raíz y de catálogo (enrutamiento y búsqueda ligeros) |
Generación de imágenes | Gemini 2.5 Flash Image | Genera imágenes de prueba virtual y de atuendos |
Frontend | Flutter (Dart) | App multiplataforma (Web, iOS y Android) |
Almacenamiento | Google Cloud Storage | Almacena imágenes de productos y artefactos generados |
Hosting | Cloud Run | Implementación de contenedores sin servidores |
2. 📦 Requisitos previos y configuración de Cloud Shell
1. Abre el editor de Cloud Shell
👉 Abre el Editor de Cloud Shell en tu navegador.
Si la terminal no aparece en la parte inferior de la pantalla, haz lo siguiente:
- Haz clic en Ver → Terminal.
2. Configura el SDK de Flutter
Cloud Shell se entrega con Flutter preinstalado en /google/flutter. Como ese directorio pertenece a otro usuario del sistema, recibirás un error de fatal: detected dubious ownership la primera vez que ejecutes flutter. Agrega el directorio a la lista de directorios seguros de Git una vez:
git config --global --add safe.directory /google/flutter
Verifica que Flutter esté en tu PATH y que funcione:
flutter --version
La primera ejecución descarga el SDK de Dart y compila la herramienta de Flutter. Esto puede tardar un minuto. Deberías ver algo como Flutter 3.x • channel stable.
3. Clona el repositorio
cd ~
git clone https://github.com/gca-americas/fashion-app-demo
cd fashion_app_demo
4. Explora la estructura del proyecto
fashion_app_demo/
├── adk_backend/ # Go backend with ADK agents
│ ├── main.go # Entry point — wires all agents + REST server
│ ├── catalog/ # Catalog Agent — product lookup
│ │ ├── agent.go
│ │ ├── catalog.yaml # Product database (YAML)
│ │ └── instructions.md # Agent persona prompt
│ ├── fittingroom/ # Fitting Room Agent — virtual try-on
│ │ ├── agent.go
│ │ ├── instructions.md # Agent persona prompt
│ │ └── tool_instructions.md # Image generation prompt
│ ├── stylist/ # Stylist Agent — outfit curation
│ │ ├── agent.go
│ │ └── instructions.md # Agent persona + output format
│ ├── rootagent/ # Root Agent — routes to the right agent
│ │ └── agent.go
│ └── tools/ # Shared tools
│ ├── imagetool.go # getProductImage — loads product images
│ ├── outfit_gen_tool.go # generate_outfit_image — creates outfit images
│ └── cors_helper.go # CORS middleware + request logging
│
├── flutter_frontend/ # Flutter cross-platform app
│ ├── lib/
│ │ ├── main.dart # App entry point + Provider setup
│ │ ├── app_config.dart # Backend URL configuration
│ │ ├── core_app/ # Pre-built shopping app (browse, cart, etc.)
│ │ └── workshop_tasks/ # AI feature code
│ │ ├── step_1_try_it_on/ # Virtual Try-On flow
│ │ │ ├── providers/ # TryItOnProvider (state management)
│ │ │ ├── services/ # AdkFittingRoomService (HTTP calls)
│ │ │ └── ui/ # Screens (product detail → try on → fitting room)
│ │ └── step_2_style_me/ # Style Me flow
│ │ ├── models/ # Outfit, StyleRequest data classes
│ │ ├── providers/ # StylingProvider (state management)
│ │ ├── services/ # AdkStylingService (HTTP calls)
│ │ └── ui/ # Screens (form sheet → outfit carousel)
│ └── assets/images/ # Product catalog images
3. ☁️ Configuración del proyecto de Google Cloud
1. Crear un proyecto nuevo
gcloud projects create fashion-app-demo --name="Fashion App Demo"
gcloud config set project fashion-app-demo
2. Vincular una cuenta de facturación
Haz una lista de tus cuentas de facturación:
gcloud billing accounts list
Mira la
OPEN
columna. Debe decir True. Si dice False (común con una prueba gratuita vencida), la cuenta está cerrada y no pagará nada. Antes de continuar, ve al bloque de solución de problemas que se encuentra a continuación.
Copia el ACCOUNT_ID de una cuenta de OPEN: True (se ve como 0X0X0X-0X0X0X-0X0X0X) y vincúlalo a tu proyecto:
gcloud billing projects link fashion-app-demo \
--billing-account=YOUR_BILLING_ACCOUNT_ID
Verifica el vínculo:
gcloud billing projects describe fashion-app-demo
Deberías ver billingEnabled: true. Si ves billingEnabled: false incluso después de la vinculación, significa que la cuenta está cerrada (OPEN: False). Consulta el bloque de solución de problemas a continuación.
3. Habilita las API obligatorias
gcloud services enable \
aiplatform.googleapis.com \
storage.googleapis.com \
run.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com
API | Objetivo |
| Vertex AI: |
| Cloud Storage: Almacena imágenes del catálogo de productos y los resultados de la prueba generados. |
| Cloud Run: Aloja el backend como un contenedor sin servidores |
| Cloud Build: Compila imágenes de Docker a partir del código fuente. |
| Artifact Registry: Almacena imágenes de Docker compiladas |
4. Crea un bucket de GCS
export PROJECT_ID=$(gcloud config get-value project)
gcloud storage buckets create gs://fashion-app-$PROJECT_ID \
--location=us-central1 \
--uniform-bucket-level-access
5. Sube imágenes del catálogo de productos
La herramienta getProductImage del backend lee desde gs://$GCS_BUCKET/catalog-assets/images/. Sube las imágenes del catálogo a esa ruta de acceso exacta:
cd ~/fashion_app_demo
gcloud storage cp flutter_frontend/assets/images/*.png \
gs://fashion-app-$PROJECT_ID/catalog-assets/images/
Verifica la carga (deberías ver una lista de archivos .png):
gcloud storage ls gs://fashion-app-$PROJECT_ID/catalog-assets/images/
6. Configura el archivo .env
cd ~/fashion_app_demo/adk_backend
cat > .env << EOF
GOOGLE_CLOUD_PROJECT=$PROJECT_ID
GCS_BUCKET=fashion-app-$PROJECT_ID
EOF
7. Autentica con credenciales predeterminadas de la aplicación
Debes ejecutar este comando antes de iniciar el backend de forma local. El backend de Go usa la ADC para autenticar cada llamada a Vertex AI (Gemini) y Cloud Storage. Sin ADC, el backend se iniciará, pero todas las solicitudes de prueba fallarán con un 401 CREDENTIALS_MISSING.
Una credencial abarca ambos servicios. Ejecuta estos dos comandos en el siguiente orden:
# 1. Log in (opens a browser; in Cloud Shell, paste the verification code back)
gcloud auth application-default login
# 2. Attach your project as the quota / billing project for ADC
gcloud auth application-default set-quota-project $(gcloud config get-value project)
Verifica que el ADC esté en buen estado:
gcloud auth application-default print-access-token | head -c 20 && echo "..."
Deberías ver alrededor de 20 caracteres de un token seguidos de .... Si se produce un error, significa que no se realizó el acceso. Vuelve a ejecutar el paso 1.
4. 🏗️ Descripción general de la arquitectura
Ahora que el entorno está listo, veamos cómo funciona el sistema antes de analizar el código.
El sistema de cuatro agentes
El backend se compila como un sistema multiagente con el ADK (Kit de desarrollo de agentes) para Go. Cuatro agentes trabajan juntos, cada uno con una responsabilidad específica:
┌──────────────┐
│ Root Agent │ ← Routes requests to the right agent
│ (gemini-3- │
│ flash- │
│ preview) │
└──────┬───────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────────┐ ┌──────────┐ ┌────────────────┐
│ Fitting Room │ │ Catalog │ │ Stylist │
│ Agent │ │ Agent │ │ Agent │
│ (gemini-3.1- │ │ (gemini- │ │ (gemini-3.1- │
│ pro-preview) │ │ 3-flash-│ │ pro-preview) │
│ │ │ preview)│ │ │
│ Tools: │ │ │ │ Tools: │
│ • fitting_tool │ │ Tools: │ │ • fitting_tool │
│ • getProduct │ │ • list │ │ • getProduct │
│ Image │ │ Products │ │ Image │
│ • catalog_agent│ │ • get │ │ • catalog_agent│
│ (delegation) │ │ Product │ │ (delegation) │
│ │ │ Image │ │ • generate_ │
└────────────────┘ └──────────┘ │ outfit_image │
└────────────────┘
Agente | Modelo | Rol |
Agente raíz |
| Policía de tráfico. Lee el mensaje del usuario y lo delega al agente especialista adecuado. Usa un modelo rápido y ligero porque solo necesita tomar decisiones de rutas. |
Agente de catálogo |
| Ser experto en productos Carga el catálogo de productos desde un archivo YAML y responde preguntas sobre los productos. También es liviano, ya que solo busca datos. |
Agente de probador virtual |
| Especialista en pruebas virtuales Toma una foto del usuario y una imagen del producto, y genera una imagen compuesta de la persona usando ese artículo. Utiliza un modelo más potente porque necesita razonar sobre las imágenes. |
Agente de estilista |
| Asesor de moda. Dadas la ubicación, la ocasión y las preferencias, selecciona 3 combinaciones de atuendos del catálogo. Puede generar imágenes de prueba para cada atuendo. También usa el modelo capaz para el razonamiento creativo. |
Punto de entrada: main.go
Todo comienza en main.go, que conecta los agentes y, luego, inicia el servidor HTTP:
// main.go — simplified for clarity
func main() {
godotenv.Load() // Load .env file
// 1. Create the artifact storage (GCS-backed)
artifacts, _ := gcsartifact.NewService(ctx, bucket)
// 2. Build agents bottom-up (dependencies first)
catagent, _ := catalog.NewCatalogAgent(apikey, "catalog/catalog.yaml")
fitagent, _ := fittingroom.NewFittingRoomAgent(apikey, catagent)
stylistAgent, _ := stylist.NewStylistAgent(apikey, catagent)
ragent, _ := rootagent.NewRootAgent(apikey, fitagent, catagent, stylistAgent)
// 3. Register all agents with a multi-loader
loader, _ := agent.NewMultiLoader(ragent, fitagent, catagent, stylistAgent)
// 4. Create the ADK REST server
restHandler, _ := adkrest.NewServer(adkrest.ServerConfig{
SessionService: session.InMemoryService(),
MemoryService: memory.InMemoryService(),
AgentLoader: loader,
ArtifactService: artifacts,
})
// 5. Mount behind /api/ with CORS support
r := mux.NewRouter()
r.Use(tools.LocalhostCORS)
r.PathPrefix("/api/").Handler(
http.StripPrefix("/api", tools.LogHandler(restHandler)))
http.Server{Addr: ":8080", Handler: r}.ListenAndServe()
}
Debes tener en cuenta lo siguiente:
- Los agentes se compilan de forma ascendente: Primero se crea el agente de catálogo, ya que tanto el agente de probador como el de estilista dependen de él (le delegan las búsquedas de productos).
agent.NewMultiLoaderregistra los cuatro agentes para que la API de REST pueda enrutar a cualquiera de ellos por su nombre.adkrest.NewServerproporciona la API de REST automáticamente, por lo que no es necesario que escribas controladores de extremos. El ADK te proporciona administración de sesiones, almacenamiento de artefactos y ejecución de agentes listos para usar.session.InMemoryService()almacena sesiones en la memoria. Esto significa que las sesiones se pierden si se reinicia el servidor, lo que está bien para una demostración. En la producción, usarías un almacenamiento persistente.gcsartifact.NewServicealmacena artefactos (imágenes generadas) en Google Cloud Storage, por lo que persisten en las solicitudes y se pueden compartir a través de URIs de GCS.
5. 🤖 Análisis detallado del ADK (Kit de desarrollo de agentes)
¿Qué es el ADK?
El Kit de desarrollo de agentes (ADK) es un framework de código abierto de Google para crear agentes de IA en Go (y Python/Java). Es la capa entre tu aplicación y la API de Gemini.
Podrías llamar a la API de Gemini directamente. Sin embargo, una vez que tu app necesite hacer lo siguiente:
- Cómo buscar productos en un catálogo
- Generar imágenes basadas en las fotos del usuario
- Recordar qué atuendos se sugirieron anteriormente
- Coordina varios agentes de IA
Necesitas estructura. El ADK proporciona esa estructura.
El bucle del agente
Todos los agentes del ADK siguen un bucle:
1. Receive a message (from user or another agent)
2. Think — the LLM reasons about what to do
3. Act — call a tool, delegate to a sub-agent, or respond
4. Return — send the result back
Este bucle se puede repetir varias veces dentro de una misma solicitud. Por ejemplo, el agente de estilista podría hacer lo siguiente:
- Recibir "Ayúdame a elegir mi atuendo para unas vacaciones en la playa"
- Llama a la herramienta
catalog_agentpara obtener la lista de productos - Selecciona 3 combinaciones de atuendos
- Llama a
fitting_toolpara cada atuendo y generar imágenes - Devuelve la respuesta JSON estructurada
Conceptos básicos (con código de este repo)
Agentes LLM
Es el componente básico principal. Creado con llmagent.New():
// From catalog/agent.go
agent, err := llmagent.New(llmagent.Config{
Name: "catalog_agent", // Unique identifier
Model: m, // Which LLM to use
Description: "An agent that can search and list products from the catalog",
Instruction: instructions, // Persona prompt (embedded from .md file)
Tools: []tool.Tool{listTool, imageTool}, // What the agent can do
})
El campo Instruction es el arquetipo del agente, ya que le indica al LLM quién es y cómo debe comportarse. En este repo, las instrucciones se escriben como archivos Markdown y se incorporan en el tiempo de compilación con la directiva //go:embed de Go:
//go:embed instructions.md
var instructions string
Esto mantiene las instrucciones como documentos separados y con versiones, en lugar de cadenas intercaladas.
Herramientas
Las herramientas son funciones de Go a las que puede llamar el LLM. El ADK controla la traducción entre el formato de llamada a herramientas del LLM y tu función de Go escrita:
// From catalog/agent.go
type ListProductsArgs struct{} // Input (can be empty)
type ListProductsResult struct {
Products []Product `json:"products"` // Output
}
func ListProducts(ctx tool.Context, args ListProductsArgs) (ListProductsResult, error) {
return ListProductsResult{Products: catalogProducts}, nil
}
// Register it:
listTool, _ := functiontool.New(functiontool.Config{
Name: "listProducts",
Description: "list all products in the catalog",
}, ListProducts)
El ADK genera automáticamente un esquema JSON a partir de tus structs de Go y lo envía al LLM. Cuando el LLM decide llamar a listProducts, el ADK deserializa los argumentos, llama a tu función y envía el resultado.
El parámetro tool.Context otorga a las herramientas acceso a los servicios de tiempo de ejecución del ADK, en especial a los artefactos:
// Save an image as an artifact
ctx.Artifacts().Save(ctx, "my_image", imagePart)
// Load an artifact
resp, _ := ctx.Artifacts().Load(ctx, "my_image")
Delegación de subagente
Un agente puede usar otro agente como herramienta a través de agenttool.New():
// From fittingroom/agent.go
Tools: []tool.Tool{
loadartifactstool.New(), // List available artifacts
imgtool, // Get product images
agenttool.New(catalogAgent, nil), // Delegate to catalog agent
fittingTool, // Generate try-on image
},
Cuando el agente de probador necesita información del producto, puede llamar al agente de catálogo como si fuera una herramienta normal. El LLM la ve en la lista de herramientas y puede decidir invocarla.
Sesiones
Las sesiones hacen un seguimiento del historial de conversaciones. La API de REST del ADK los administra automáticamente:
POST /api/apps/{appName}/users/{userId}/sessions → Creates a new session
POST /api/run (with sessionId) → Runs agent within that session
Una decisión de diseño fundamental en esta app es que el probador crea una sesión nueva por solicitud (cada prueba es independiente), mientras que el estilista reutiliza la misma sesión (por lo que recuerda las sugerencias anteriores y puede mejorarlas según los comentarios).
Estado
El estado es un almacén de pares clave-valor adjunto a una sesión. Los agentes leen y escriben el estado para coordinar:
// Write to state
ctx.State().Set("previously_used_products", "[\"id_bomber\",\"id_hat\"]")
// Read from state
val, err := ctx.State().Get("previously_used_products")
El agente de estilista usa el estado para recordar qué productos ya sugirió, de modo que elige otros la próxima vez.
Artefactos
Los artefactos son objetos binarios con nombre (por lo general, imágenes) que se almacenan por sesión. A diferencia de las respuestas de texto, se almacenan por separado y se recuperan por nombre:
// Save a generated image as an artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, imagePart)
// The frontend fetches it via:
// GET /api/apps/{app}/users/{user}/sessions/{session}/artifacts/{artName}
Esto mantiene las respuestas ligeras: el agente solo devuelve el nombre del artefacto, y el frontend recupera los datos de la imagen binaria por separado.
Devoluciones de llamada
Las devoluciones de llamada son hooks que se ejecutan en puntos específicos del bucle del agente. Pueden inspeccionar, modificar o interrumpir la ejecución:
llmagent.Config{
// Runs before the agent starts — used to save uploaded images
BeforeAgentCallbacks: []agent.BeforeAgentCallback{SaveIncomingBlobs},
// Runs before each LLM call — used to inject context
BeforeModelCallbacks: []llmagent.BeforeModelCallback{
ExtractAndInjectUserImage,
InjectPreviousProducts,
},
// Runs after each LLM response — used to extract data
AfterModelCallbacks: []llmagent.AfterModelCallback{SaveSelectedProducts},
}
Si una devolución de llamada muestra una respuesta que no es nula, se omite el comportamiento predeterminado. Por ejemplo, un BeforeModelCallback que devuelve una respuesta almacenada en caché omitiría por completo la llamada real al LLM.
Aplicación del esquema JSON
Tanto el agente de probadores como el de estilista obligan al LLM a responder en JSON estructurado:
GenerateContentConfig: &genai.GenerateContentConfig{
ResponseMIMEType: "application/json",
ResponseJsonSchema: fittingSchemaMap(), // Defines the expected structure
}
Esto garantiza que el frontend de Flutter siempre reciba datos analizables, no texto de formato libre.
El agente de Catalog: ejemplo más simple
El agente de catálogo (catalog/agent.go) es el agente más simple del sistema y un buen punto de partida para comprender los patrones del ADK.
Tiene dos herramientas:
listProducts: Muestra el catálogo de productos completo de un archivo YAML.getProductImage: Carga una imagen del producto desde GCS (o una alternativa local) y la guarda como un artefacto.
La herramienta getProductImage muestra un patrón importante: carga de varias fuentes con almacenamiento en caché de artefactos:
// From tools/imagetool.go — simplified
func GetProductImage(ctx tool.Context, args GetProductImageArgs) (GetProductImageResult, error) {
// First: check if it's already an artifact
_, err := ctx.Artifacts().Load(ctx, args.ImageName)
if err == nil {
return GetProductImageResult{Image: args.ImageName}, nil // Cache hit!
}
// Second: try loading from GCS bucket
rc, err := client.Bucket(bucket).Object("catalog-assets/images/" + args.ImageName).NewReader(ctx)
if err != nil {
// Third: fall back to local filesystem
localData, _ := os.ReadFile("../flutter_frontend/assets/images/" + args.ImageName)
ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
return GetProductImageResult{Image: args.ImageName}, nil
}
// Save to artifact cache and return
ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
return GetProductImageResult{Image: args.ImageName}, nil
}
La herramienta primero prueba los artefactos, luego GCS y, por último, los archivos locales. Una vez cargada, la imagen se almacena en caché como un artefacto, por lo que las llamadas posteriores son instantáneas.
6. 🧪 La canalización de IA: Agentes en acción
Ahora, veamos los dos agentes más sofisticados, los que realmente generan imágenes y seleccionan atuendos.
6.1 El agente de Fitting Room
Archivo:
adk_backend/fittingroom/agent.go
El agente del probador es el motor detrás de la función "Probar virtualmente". Cuando un usuario sube su foto y elige un producto, este agente genera una imagen compuesta de la persona usando ese artículo.
El fitting_tool: paso a paso
La lógica principal se encuentra en la función doFitting. Esto es lo que sucede cuando el agente la llama:
Paso 1: Resuelve la imagen del usuario
func doFitting(ctx tool.Context, args FittingToolArgs) (FittingToolResult, error) {
if len(args.Accessories) > 2 {
args.Accessories = args.Accessories[:2] // Safety limit: max 2 items
}
var userPart *genai.Part
if strings.HasPrefix(args.UserImage, "gs://") {
// If we have a GCS URI from a previous fitting, use it directly
userPart = &genai.Part{FileData: &genai.FileData{
FileURI: args.UserImage,
MIMEType: gcsURIMimeType(args.UserImage),
}}
} else {
// Otherwise, load the image from artifact storage
userImgResp, err := ctx.Artifacts().Load(ctx, args.UserImage)
userPart = userImgResp.Part
}
La imagen del usuario puede provenir de dos fuentes:
- Un nombre de artefacto (como
upload_abc123_1): Es la carga inicial, guardada por la devolución de llamadaSaveIncomingBlobs. - Un URI de
gs://: Es un resultado de ajuste generado previamente y almacenado en GCS para su reutilización en diferentes sesiones.
Este diseño de doble ruta es intencional: cuando el agente de estilista genera más adelante pruebas de atuendos, reutiliza la URL de GCS del resultado inicial del probador para que la identidad del usuario se mantenga coherente en todos los atuendos.
Paso 2: Crea la instrucción multimodal
parts := []*genai.Part{
genai.NewPartFromText(toolInstructions), // Identity preservation prompt
genai.NewPartFromText("Reference Person Photo:"),
userPart, // The user's photo
}
for _, acc := range args.Accessories {
accResp, _ := ctx.Artifacts().Load(ctx, acc) // Load product image artifact
parts = append(parts, genai.NewPartFromText("Product Image to Apply:"))
parts = append(parts, accResp.Part) // The product photo
}
El toolInstructions (incorporado desde tool_instructions.md) es fundamental, ya que le indica a Gemini que conserve la identidad del usuario (rostro, tipo de cuerpo, tono de piel, cabello) y que solo aplique la prenda. Sin esta ingeniería de instrucciones, el modelo podría cambiar la apariencia de la persona.
Paso 3: Llama a Gemini para generar imágenes
client, _ := genai.NewClient(ctx, &genai.ClientConfig{
Backend: genai.BackendVertexAI, // Vertex AI endpoint
Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), // From your .env
Location: "global", // Multi-region endpoint
})
resp, _ := client.Models.GenerateContent(ctx, "gemini-2.5-flash-image",
[]*genai.Content{genai.NewContentFromParts(parts, "user")},
&genai.GenerateContentConfig{
ResponseModalities: []string{"TEXT", "IMAGE"}, // Request both text and image output
Temperature: genai.Ptr(float32(0.2)), // Low temperature for consistency
})
Los cuatro agentes y la herramienta de generación de imágenes comparten una sola ruta de autenticación: Backend: genai.BackendVertexAI con el ID del proyecto, autenticado a través de las credenciales predeterminadas de la aplicación. Los modelos de orquestación (gemini-3.1-pro-preview, gemini-3-flash-preview) y el modelo de imagen (gemini-2.5-flash-image) se encuentran detrás del mismo extremo de Vertex AI, y las mismas ADC también autorizan el acceso a Cloud Storage: una credencial para cada llamada.
Paso 4: Guarda el resultado
// Find the image in the response (may contain both text + image parts)
var genPart *genai.Part
for _, p := range resp.Candidates[0].Content.Parts {
if p.InlineData != nil {
genPart = p
break
}
}
// Save as an ADK artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, genPart)
// Also upload to GCS for cross-session reuse
objectName := fmt.Sprintf("generated-fittings/%s.jpg", ctx.InvocationID())
w := storageClient.Bucket(bucket).Object(objectName).NewWriter(ctx)
w.Write(genPart.InlineData.Data)
gcsURI := fmt.Sprintf("gs://%s/%s", bucket, objectName)
return FittingToolResult{ArtifactName: artName, GCSUrl: gcsURI}, nil
El guardado doble (artefacto + GCS) es la clave para la transferencia del agente entre el probador y el estilista. El artefacto proporciona acceso inmediato dentro de la sesión actual, mientras que el URI de GCS permite que el estilista (que se ejecuta en una sesión diferente) haga referencia a la misma imagen más adelante.
La devolución de llamada de SaveIncomingBlobs
Antes de que el agente comience a razonar, se ejecuta este BeforeAgentCallback para guardar las imágenes que subió el usuario:
func SaveIncomingBlobs(ctx agent.CallbackContext) (*genai.Content, error) {
for pindex, p := range ctx.UserContent().Parts {
if p.InlineData != nil {
aname := fmt.Sprintf("upload_%s_%d", ctx.InvocationID(), pindex)
ctx.Artifacts().Save(ctx, aname, p)
}
}
return nil, nil // Return nil to proceed with normal agent execution
}
Cuando se devuelve (nil, nil), la devolución de llamada indica "Terminé el procesamiento previo. Ahora, ejecuta el agente con normalidad". Si devolviera contenido no nulo, se cortocircuitaría todo el agente.
6.2 El agente de Stylist
Archivo:
adk_backend/stylist/agent.go
El agente de estilista es el más sofisticado del sistema. Selecciona recomendaciones de atuendos personalizadas y admite el perfeccionamiento iterativo a través de la conversación.
Tres devoluciones de llamada: La memoria de la estilista
El asistente usa tres devoluciones de llamada para mantener el contexto en las conversaciones de varios turnos:
Devolución de llamada 1:
InjectPreviousProducts (BeforeModel)
El problema: Si el usuario dice "muéstrame diferentes opciones", es posible que el LLM vuelva a sugerir los mismos productos porque no realiza un seguimiento inherente de lo que ya recomendó.
Solución: Después de cada respuesta, los IDs de los productos se guardan en el estado de la sesión. Antes de la siguiente llamada al LLM, esta devolución de llamada los lee y, luego, inserta una sugerencia:
func InjectPreviousProducts(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
prev, err := ctx.State().Get(stateKeyPreviousProducts) // Read from session state
if err != nil {
return nil, nil // No previous state — first run
}
// Append hint to the user's message
for i := len(req.Contents) - 1; i >= 0; i-- {
if req.Contents[i].Role == "user" {
req.Contents[i].Parts = append(req.Contents[i].Parts,
genai.NewPartFromText(fmt.Sprintf(
"IMPORTANT: You previously suggested these products: %s. "+
"You MUST pick DIFFERENT complementary products this time.", prev)))
break
}
}
return nil, nil // Continue to LLM call
}
Devolución de llamada 2:
ExtractAndInjectUserImage (BeforeModel)
El problema: Cuando el usuario proporciona comentarios ("hazlo más informal"), el mensaje de seguimiento no incluye la foto del usuario nuevamente. Pero la herramienta de ajuste la necesita.
La solución: En la primera solicitud, esta devolución de llamada extrae la referencia de la imagen del usuario y la guarda en el estado. En las solicitudes posteriores, se vuelve a insertar:
func ExtractAndInjectUserImage(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
var foundImgStr string
// Search for user image in the latest message
for i := len(req.Contents) - 1; i >= 0; i-- {
if req.Contents[i].Role == "user" {
for _, part := range req.Contents[i].Parts {
if strings.Contains(part.Text, "User try-on base image") {
foundImgStr = part.Text // Found the GCS URI reference
}
}
break
}
}
if foundImgStr != "" {
ctx.State().Set(stateKeyUserImageStr, foundImgStr) // Save for later
} else {
// Not in current message — retrieve from state and inject
val, _ := ctx.State().Get(stateKeyUserImageStr)
if savedImgStr, ok := val.(string); ok {
// Inject into the latest user message
req.Contents[last].Parts = append(req.Contents[last].Parts,
genai.NewPartFromText("REMINDER: Use this image: " + savedImgStr))
}
}
return nil, nil
}
Devolución de llamada 3:
SaveSelectedProducts (AfterModel)
Después de que el LLM responde con sugerencias de atuendos, esta devolución de llamada analiza el JSON para extraer los IDs de los productos y los guarda para que la devolución de llamada InjectPreviousProducts los use la próxima vez:
func SaveSelectedProducts(ctx agent.CallbackContext, resp *model.LLMResponse, respErr error) (*model.LLMResponse, error) {
for _, part := range resp.Content.Parts {
ids := extractProductIDs(part.Text) // Parse JSON → extract product IDs
if len(ids) > 0 {
data, _ := json.Marshal(ids)
ctx.State().Set(stateKeyPreviousProducts, string(data)) // Save to state
}
}
return nil, nil // Don't modify the response
}
En conjunto, estas tres devoluciones de llamada crean un ciclo de retroalimentación:
Request 1: User sends styling request + user image
→ ExtractAndInjectUserImage SAVES image to state
→ LLM generates 3 outfits
→ SaveSelectedProducts SAVES product IDs to state
Request 2: User says "make it more casual"
→ ExtractAndInjectUserImage INJECTS saved image into prompt
→ InjectPreviousProducts INJECTS "don't reuse these IDs"
→ LLM generates 3 NEW outfits
→ SaveSelectedProducts UPDATES product IDs in state
6.3 El agente raíz
Archivo:
adk_backend/rootagent/agent.go
El agente más simple, con solo 31 líneas:
func NewRootAgent(project string, fittingAgent, catalogAgent, stylistAgent agent.Agent) (agent.Agent, error) {
m, _ := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
Backend: genai.BackendVertexAI,
Project: project,
Location: "global",
})
return llmagent.New(llmagent.Config{
Name: "root_agent",
Model: m,
Description: "A root agent that delegates to other agents",
Instruction: "You are a helpful shopping assistant. If the user asks about fitting " +
"items or generating images, delegate to the fitting room agent. If the user " +
"asks about products, delegate to the catalog agent. If the user asks for " +
"styling advice, delegate to the stylist agent.",
SubAgents: []agent.Agent{fittingAgent, catalogAgent, stylistAgent},
})
}
Utiliza gemini-3-flash-preview (el modelo más rápido) porque las decisiones de enrutamiento son simples: el LLM solo necesita leer la intención del usuario y elegir el agente secundario correcto. No se necesitan herramientas, ya que SubAgents controla la delegación automáticamente.
7. 📱 Arquitectura de frontend de Flutter
El frontend de Flutter es una app de compras minoristas completamente funcional. Las funciones basadas en IA se encuentran en flutter_frontend/lib/workshop_tasks/, separadas de la experiencia de compra prediseñada en core_app/.
El patrón MVVM
La app sigue la arquitectura Model-View-ViewModel con el paquete Provider:
┌──────────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ View (Widget) │◀───│ ViewModel (Provider)│◀───│ Service (HTTP) │
│ │ │ │ │ │
│ • Renders UI │ │ • Holds state │ │ • Makes API calls│
│ • User gestures │───▶│ • Business logic │───▶│ • Parses response│
│ • Listens for │ │ • notifyListeners() │ │ • Returns data │
│ state changes │ │ │ │ │
└──────────────────┘ └────────────────────┘ └──────────────────┘
Cada capa tiene un rol claro:
- Modelo: Clases de datos como
Product,Outfit,StyleRequesty enumeraciones comoTryOnState - ViewModel (
ChangeNotifier): Contiene el estado actual y transmite los cambios a la IU a través denotifyListeners() - View (widget): Se suscribe al ViewModel con
context.watchy se vuelve a compilar cuando cambia el estado.() - Servicio: Realiza llamadas HTTP al backend del ADK y devuelve datos escritos.
La capa de servicio
Los servicios se definen como interfaces abstractas, con implementaciones específicas del ADK:
// Abstract interface — defines WHAT the service does
abstract class TryItOnService {
Future<(Uint8List?, String?)> generateTryOnImage(
Uint8List userImageBytes,
Uint8List productImageBytes,
);
}
// Concrete implementation — defines HOW (via ADK REST API)
class AdkFittingRoomService implements TryItOnService { ... }
Esta separación significa que podrías intercambiar el backend del ADK por Firebase AI, un servicio simulado o cualquier otra implementación sin cambiar el resto de la app.
El patrón de API de 3 pasos
Tanto AdkFittingRoomService como AdkStylingService siguen el mismo patrón para comunicarse con el backend del ADK:
Paso 1: Crea una sesión
Future<String> _createSession() async {
final url = Uri.parse(
'$_baseUrl/apps/${Uri.encodeComponent(_appName)}/users/$_userId/sessions');
final response = await _client.post(url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({}));
final body = jsonDecode(response.body) as Map<String, dynamic>;
return body['id'] as String; // Returns the session ID
}
Paso 2: Ejecuta el agente
Future<(String?, String?)> _runAgent({required String sessionId, ...}) async {
final requestBody = jsonEncode({
'appName': _appName,
'userId': _userId,
'sessionId': sessionId,
'newMessage': {
'role': 'user',
'parts': [
{'text': 'Generate a virtual try-on...'},
{'inlineData': {'mimeType': 'image/jpeg', 'data': base64Encode(userImageBytes)}},
{'inlineData': {'mimeType': 'image/png', 'data': base64Encode(productImageBytes)}},
],
},
});
final response = await _client.post(Uri.parse('$_baseUrl/run'), body: requestBody);
// Parse response events for artifact name and GCS URL...
}
Paso 3: Recupera el artefacto
Future<Uint8List?> _loadArtifact({required String sessionId, required String artifactName}) async {
final url = Uri.parse(
'$_baseUrl/apps/$_appName/users/$_userId/sessions/$sessionId/artifacts/$artifactName');
final response = await _client.get(url);
final part = jsonDecode(response.body) as Map<String, dynamic>;
final data = part['inlineData']['data'] as String;
return base64Decode(data); // Returns raw image bytes
}
Una diferencia de diseño fundamental es que el servicio de probador crea una sesión nueva para cada solicitud (se llama a _createSession() cada vez), mientras que el servicio de estilismo reutiliza la misma sesión (_sessionId ??= await _createSession()) para habilitar la conversación de varios turnos.
Administración de estado: TryItOnProvider
Archivo:
workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart
El TryItOnProvider administra todo el flujo de prueba. Usa un enum TryOnState como máquina de estados:
enum TryOnState { initial, imagePicked, generating, success, error }
class TryItOnProvider with ChangeNotifier {
TryOnState _state = TryOnState.initial;
Uint8List? _userImageBytes;
Uint8List? _generatedImage;
String? _errorMessage;
Las transiciones de estado privadas garantizan la coherencia: nunca actualizas el estado sin borrar también los datos obsoletos y notificar a la IU:
void _setGenerating() {
_state = TryOnState.generating;
_errorMessage = null; // Clear any previous error
_wasLastGenerationCached = false;
notifyListeners(); // Tell the UI to rebuild
}
void _setSuccess(Uint8List image, {bool isCached = false}) {
_generatedImage = image;
_errorMessage = null;
_wasLastGenerationCached = isCached;
_state = TryOnState.success;
notifyListeners();
}
El método de generación principal une todo:
Future<String?> generateTryOnImage(String productImagePath) async {
final userImageBytes = _userImageBytes;
if (userImageBytes == null) {
_setError('No image selected.');
return _errorMessage;
}
// Check local cache first
final cachedImage = ImageUtils.getCachedImage(_sessionCache, userImageBytes, productUint8List);
if (cachedImage != null) {
_setSuccess(cachedImage, isCached: true);
return null;
}
_setGenerating(); // Triggers loading state in UI
try {
final (generatedBytes, gcsUrl) = await _aiService.generateTryOnImage(
userImageBytes, productUint8List);
if (generatedBytes != null) {
_fittingGcsUrl = gcsUrl; // Save for the stylist agent later
_setSuccess(generatedBytes);
}
} catch (e) {
_setError(e.toString());
}
return _state == TryOnState.success ? null : _errorMessage;
}
La IU: Pantallas como un router de estado
Archivo:
workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart
La pantalla de prueba usa la coincidencia de patrones de Dart 3 con AnimatedSwitcher para enrutar entre subpantallas según el estado del proveedor:
class TryItOnScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tryOnProvider = context.watch<TryItOnProvider>();
return ScaffoldWithBackgroundNoise(
appBar: const TryOnAppBar(),
body: AnimatedSwitcher(
duration: AppDurations.medium,
child: switch (tryOnProvider.state) {
TryOnState.initial || TryOnState.error => const ChooseImageScreen(),
TryOnState.imagePicked || TryOnState.generating => LoadingScreen(
userImage: tryOnProvider.userImageBytes),
TryOnState.success => const FittingRoomScreen(),
},
),
);
}
}
context.watch se suscribe al proveedor. Cada vez que se llama a notifyListeners(), este widget se vuelve a compilar y AnimatedSwitcher realiza una transición fluida entre las pantallas. No hay Navigator.push: El contenido de la pantalla cambia en el mismo lugar según el enum del estado.
Transferencia de agente: Probador → Estilista
El patrón de UX más interesante es cómo la app pasa el contexto del agente del probador al agente del estilista.
En 5_fitting_room.dart, después de que se genera la imagen de prueba, el botón "Sugerir atuendo" abre un formulario. Cuando el usuario envía la solicitud, sucede lo siguiente:
// From 1_style_me_form_sheet.dart
Navigator.pop(context, StyleRequest(
location: _locationController.text.trim(),
occasion: _occasionController.text.trim(),
notes: _notesController.text.trim(),
gcsUserImageUrl: provider.fittingGcsUrl, // GCS URI from fitting result
userImageData: provider.fittingGcsUrl == null
? provider.userImageBytes : null, // Fallback to raw bytes
selectedProductId: provider.selectedProduct?.id, // Product they already tried on
selectedProductTitle: provider.selectedProduct?.title,
));
El StyleRequest incluye todo lo que necesita el estilista:
- Ubicación y ocasión: Contexto de texto para el diseño
- URL de la imagen del usuario de GCS: Para que el estilista pueda reutilizar la misma representación del usuario
- Producto seleccionado: Para que el estilista lo incluya en todos los conjuntos
Esta es la transferencia de agentes, que transfiere sin problemas el contexto multimodal de un agente de IA a otro, y el usuario solo ve un formulario simple.
El flujo de diseño: StylingProvider
Archivo:
workshop_tasks/step_2_style_me/providers/styling_provider.dart
El StylingProvider es más simple que TryItOnProvider porque delega la mayor parte de la complejidad al backend:
class StylingProvider with ChangeNotifier {
StylingState _state = StylingState.initial;
List<Outfit> _outfits = [];
Future<bool> getStyleSuggestions(StyleRequest request) async {
if (_state == StylingState.loading) return false; // Prevent spam
_currentRequest = request;
_setLoading();
try {
final suggestions = await _stylingService.getStyleSuggestions(request);
_setSuccess(suggestions);
return true;
} catch (e) {
_setError('Something went wrong.');
}
return false;
}
// Multi-turn refinement — uses the same session
Future<bool> refineWithFeedback(String feedback) async {
if (_state == StylingState.loading) return false;
_setLoading();
try {
final suggestions = await _stylingService.refineWithFeedback(feedback);
_setSuccess(suggestions);
return true;
} catch (e) {
_setError('Something went wrong.');
}
return false;
}
}
El método refineWithFeedback envía un mensaje de texto sin formato a la misma sesión. Las devoluciones de llamada InjectPreviousProducts y ExtractAndInjectUserImage del backend controlan automáticamente toda la administración del contexto.
8. 🚀 Ejecuta la app de forma local
Para una experiencia fluida de Cloud Shell, el backend de Go entrega la app web de Flutter compilada desde el mismo puerto (8080). Un proceso, una URL de vista previa, sin problemas de origen cruzado ni edición de archivos de configuración.
Antes de comenzar: Comprueba el ADC
El backend necesita credenciales predeterminadas de la aplicación para llamar a Vertex AI. Si completaste el paso 7 de la configuración del proyecto en esta sesión de Cloud Shell y esta Cuenta de Google, todo está listo. Si regresas después de un descanso, cambiaste de cuenta o no tienes certeza, dedica 5 segundos a verificar lo siguiente:
gcloud auth application-default print-access-token | head -c 20 && echo "..."
Si se imprimen alrededor de 20 caracteres de un token, todo está listo. Si se produce un error, vuelve a ejecutar el paso 7 de la configuración del proyecto:
gcloud auth application-default login
gcloud auth application-default set-quota-project $(gcloud config get-value project)
Usarás dos terminales de Cloud Shell:
- Terminal A: Ejecuta el backend de forma continua (
./run.sh). Déjala abierta. - Terminal B: Ejecuta la compilación web de Flutter una vez (
flutter build web) y se cierra cuando termina.
El orden no importa, puedes comenzar con cualquiera de los dos. Sin embargo, para obtener la experiencia inicial más limpia, primero compila Flutter para que el backend tenga una IU que mostrar desde el momento en que se inicia.
1. Terminal B: Compila el paquete web de Flutter (una sola vez)
Abre una nueva pestaña de Cloud Shell (el signo + en la parte superior del panel de la terminal) y, luego, haz lo siguiente:
cd ~/fashion_app_demo/flutter_frontend
flutter pub get
flutter build web
Esto genera flutter_frontend/build/web/, un directorio de archivos estáticos (HTML, JS, recursos), y sale cuando finaliza. El backend publicará estos archivos en cuanto vea que existe el directorio.
2. Terminal A: Inicia el backend (de larga duración)
En la terminal original de Cloud Shell, haz lo siguiente:
cd ~/fashion_app_demo/adk_backend
./run.sh
Deberías ver algo como lo siguiente:
Serving Flutter web build from ../flutter_frontend/build/web
Deja esta terminal en ejecución: El backend permanece activo mientras run.sh esté activo. Para detenerla, presiona Ctrl+C.
El servidor expone todo en el puerto 8080:
/: App web de Flutter (la IU de compras)/api/: Extremos de REST del ADK (a los que llama la app de Flutter)- IU de desarrollo del ADK: También en
/cuando no hay una compilación de Flutter; útil para la depuración directa del agente
3. Abrir la vista previa en la Web
- En Cloud Shell, haz clic en el ícono de Vista previa en la Web (en la esquina superior derecha) → Vista previa en el puerto 8080.
- La app de compras de Flutter se carga en una pestaña nueva
- Explorar el catálogo de productos y seleccionar un artículo
- Presiona el ícono de persona (👤) para iniciar el flujo de prueba.
- Sube una foto y mira cómo la IA genera una imagen de prueba virtual
- Presiona "Sugerir atuendo" para obtener recomendaciones de atuendos.
- Escribe comentarios de seguimiento, como "haz que sea más informal" (refinamiento en la misma sesión).
9. ☁️ Implementa en Cloud Run
Agrupa la compilación de Flutter en el backend
El contenedor de Cloud Run incluye la API y la IU en una sola imagen. Copia la compilación web de Flutter en adk_backend/flutter_web/, que es la primera ruta que verifica el servidor de Go cuando elige qué IU publicar:
cd ~/fashion_app_demo/flutter_frontend
flutter build web
rm -rf ../adk_backend/flutter_web
cp -r build/web ../adk_backend/flutter_web
(Si has estado iterando de forma local, es posible que ya tengas build/web del paso Ejecutar de forma local. Volver a ejecutar flutter build web sigue siendo una buena opción.
Implementa el backend (publica la API y la IU)
cd ~/fashion_app_demo/adk_backend
gcloud run deploy fashion-app-backend \
--source . \
--region us-central1 \
--allow-unauthenticated \
--set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GCS_BUCKET=fashion-app-$PROJECT_ID" \
--memory 1Gi \
--cpu 2 \
--timeout 300s \
--min-instances 0 \
--max-instances 3
Cuando finalice la implementación, obtendrás una URL de servicio como https://fashion-app-backend-xyz-uc.a.run.app. Ábrela en un navegador. La app de compras de Flutter se carga desde / y sus llamadas a la API se dirigen a /api/ en el mismo host. No se necesitan ediciones en la configuración del frontend ni se pasó una clave de API.
Verifica la implementación
Abre la URL de Cloud Run en tu navegador y ejecuta el flujo completo:
- Explorar → Selecciona un producto
- Probador → Sube tu foto → Mira la imagen generada por IA
- Sugerencias de estilo → Completa la ubicación o la ocasión → Ve atuendos seleccionados
- Comentarios → Escribe "Hazlo más informal" → Mira los atuendos actualizados
- Agregar a la bolsa → Completa el flujo de compra
10. 🎉 Conclusión
Qué compilaste
Exploraste una experiencia de venta minorista completa potenciada por IA con lo siguiente:
- ✅ Un backend multiagente con 4 agentes especializados que trabajan juntos
- ✅ Un probador virtual que genera imágenes de prueba personalizadas
- ✅ Un estilista de IA que selecciona atuendos y los perfecciona a través de conversaciones
- ✅ Una app de Flutter multiplataforma que se conecta al backend del agente
- ✅ Implementación de Cloud Run para un hosting escalable y sin servidores
Conceptos clave
Concepto | Dónde lo viste |
Organización de varios agentes con el ADK | Enrutamiento del agente raíz a los agentes de probador, catálogo y estilista |
Generación de imágenes multimodales con Gemini |
|
Estado de la sesión para la IA conversacional | Sesiones de reutilización de estilistas para obtener comentarios iterativos |
Almacenamiento de artefactos para datos binarios | Separación del almacenamiento de imágenes de las respuestas de texto |
Devoluciones de llamada para la lógica de middleware |
|
MVVM + Provider en Flutter |
|
Transferencia de agente |
|
Próximos pasos
- 🎨 Personaliza las instrucciones del agente: Edita
instructions.mdpara cambiar la personalidad del estilista. - 🛍️ Agrega más productos: Actualiza
catalog.yamlcon elementos nuevos. - 📱 Crea pensando en dispositivos móviles: Ejecuta
flutter build iosoflutter build apk - 🔄 Agrega sesiones persistentes: Reemplaza
InMemoryServicepor una implementación respaldada por una base de datos - 🔒 Agrega autenticación: Protege el extremo de Cloud Run con IAM
Recursos
- Documentación del ADK: Documentos oficiales del Kit de desarrollo de agentes
- Código fuente de Go del ADK: Repositorio de GitHub
- Referencia del paquete Go del ADK: Referencia de la API
- Documentación de la API de Gemini: Capacidades y guías del modelo
- Paquete de Provider de Flutter: Documentos de administración de estados
- Documentación de Cloud Run: Guías de implementación y escalamiento