👗 Crie um provador virtual e um estilista de IA com Flutter, ADK Go e Gemini

1. Introdução

O que você criará

Neste codelab, você vai assumir o papel de um desenvolvedor criando o Fashion App, um app de compras do Flutter para uma marca de varejo fictícia. Sua missão: adicionar dois recursos com tecnologia de IA que transformam a experiência de compras on-line.

  1. Provador virtual: o usuário envia uma foto dele, seleciona uma peça de roupa e vê uma imagem gerada por IA dele usando essa peça.
  2. Consultor de estilo de IA: com base na localização, na ocasião e nas preferências de estilo do usuário, um agente de IA seleciona recomendações de roupas completas, que podem ser refinadas por conversa.

A ideia é simples: quando as pessoas provam roupas em um provador, é muito mais provável que elas comprem. Mas on-line? Você está apenas chutando. Este projeto preenche essa lacuna com a IA.

Visão geral da arquitetura

Flutter App  ──── HTTP/REST ────▶  ADK Go Backend
                                       
                            ┌──────────┼──────────┐
                       Fitting Room  Stylist    Catalog
                         Agent       Agent      Agent
                                       
                            Gemini API + Cloud Storage

Principais tecnologias

Componente

Tecnologia

Finalidade

Framework do agente

ADK (Kit de Desenvolvimento de Agente) para Go

Orquestração multiagente, sessões, artefatos

Raciocínio do agente (Pro)

Pré-lançamento do Gemini 3.1 Pro

Alimenta o provador e os agentes de estilista

Raciocínio do agente (Flash)

Pré-lançamento do Gemini 3 Flash

Alimenta os agentes raiz e de catálogo (roteamento/pesquisa leves).

Geração de imagens

Imagem do Gemini 2.5 Flash

Gera imagens de provar e de roupas

Front-end

Flutter (Dart)

App multiplataforma (Web, iOS, Android)

Armazenamento

Google Cloud Storage

Armazena imagens de produtos e artefatos gerados.

Hosting

Cloud Run

Implantação de contêineres sem servidor

2. 📦 Pré-requisitos e configuração do Cloud Shell

1. Abrir editor do Cloud Shell

👉 Abra o editor do Cloud Shell no navegador.

Se o terminal não aparecer na parte de baixo da tela:

  • Clique em VisualizarTerminal.

2. Configurar o SDK do Flutter

O Cloud Shell vem com o Flutter pré-instalado em /google/flutter. Como esse diretório pertence a um usuário do sistema diferente, você vai receber um erro fatal: detected dubious ownership na primeira vez que executar flutter. Adicione-o à lista de diretórios seguros do Git uma vez:

git config --global --add safe.directory /google/flutter

Verifique se o Flutter está no seu PATH e funcionando:

flutter --version

A primeira execução faz o download do SDK do Dart e cria a ferramenta do Flutter. Aguarde um minuto. Você verá algo como Flutter 3.x • channel stable.

3. Clone o repositório

cd ~
git clone https://github.com/gca-americas/fashion-app-demo
cd fashion_app_demo

4. Conheça a estrutura do projeto

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. ☁️ Configuração do projeto do Google Cloud

1. Criar um novo projeto

gcloud projects create fashion-app-demo --name="Fashion App Demo"
gcloud config set project fashion-app-demo

Liste suas contas de faturamento:

gcloud billing accounts list

Confira o

OPEN

. Ele precisa dizer True. Se aparecer False (comum em um teste sem custo financeiro expirado), a conta será encerrada e não vai pagar nada. Vá para o bloco de solução de problemas abaixo antes de continuar.

Copie o ACCOUNT_ID de uma conta do OPEN: True (parece 0X0X0X-0X0X0X-0X0X0X) e vincule ao seu projeto:

gcloud billing projects link fashion-app-demo \
 --billing-account=YOUR_BILLING_ACCOUNT_ID

Verifique o link:

gcloud billing projects describe fashion-app-demo

Você vai ver billingEnabled: true. Se você vir billingEnabled: false mesmo depois de vincular, a conta estará encerrada (OPEN: False). Consulte o bloco de solução de problemas abaixo.

3. Ativar APIs obrigatórias

gcloud services enable \
 aiplatform.googleapis.com \
 storage.googleapis.com \
 run.googleapis.com \
 cloudbuild.googleapis.com \
 artifactregistry.googleapis.com

API

Finalidade

aiplatform.googleapis.com

Vertex AI: as fitting_tool chamam a geração de imagens do Gemini pela Vertex AI.

storage.googleapis.com

Cloud Storage: armazena imagens do catálogo de produtos e resultados gerados de simulação.

run.googleapis.com

Cloud Run: hospeda o back-end como um contêiner sem servidor.

cloudbuild.googleapis.com

Cloud Build: cria imagens Docker da origem

artifactregistry.googleapis.com

Artifact Registry: armazena imagens Docker criadas.

4. Criar um bucket do 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. Fazer upload de imagens do catálogo de produtos

A ferramenta getProductImage do back-end lê de gs://$GCS_BUCKET/catalog-assets/images/. Faça upload das imagens do catálogo para esse caminho exato:

cd ~/fashion_app_demo
gcloud storage cp flutter_frontend/assets/images/*.png \
 gs://fashion-app-$PROJECT_ID/catalog-assets/images/

Verifique o upload. Uma lista de arquivos .png vai aparecer.

gcloud storage ls gs://fashion-app-$PROJECT_ID/catalog-assets/images/

6. Configurar o arquivo .env

cd ~/fashion_app_demo/adk_backend
cat > .env << EOF
GOOGLE_CLOUD_PROJECT=$PROJECT_ID
GCS_BUCKET=fashion-app-$PROJECT_ID
EOF

7. Autenticar com o Application Default Credentials

Execute este comando antes de iniciar o back-end localmente. O back-end Go usa o ADC para autenticar todas as chamadas para a Vertex AI (Gemini) e o Cloud Storage. Sem o ADC, o back-end será iniciado, mas todas as solicitações de simulação vão falhar com um 401 CREDENTIALS_MISSING.

Uma credencial abrange os dois serviços. Execute estes dois comandos em ordem:

# 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)

Verifique se o ADC está íntegro:

gcloud auth application-default print-access-token | head -c 20 && echo "..."

Você vai ver cerca de 20 caracteres de um token seguidos por .... Se houver um erro, o login não foi concluído. Repita a etapa 1.

4. 🏗️ Visão geral da arquitetura

Agora que o ambiente está pronto, vamos entender como o sistema funciona antes de analisar o código.

O sistema de quatro agentes

O back-end é criado como um sistema multiagente usando o ADK (Kit de Desenvolvimento de Agente) para Go. Quatro agentes trabalham juntos, cada um com uma responsabilidade 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

Papel

Agente raiz

gemini-3-flash-preview

Agente de trânsito. Lê a mensagem do usuário e delega ao agente especialista certo. Usa um modelo rápido e leve porque só precisa tomar decisões de roteamento.

Agente do catálogo

gemini-3-flash-preview

Expert em produtos. Carrega o catálogo de produtos de um arquivo YAML e responde a consultas de produtos. Também é leve, já que apenas pesquisa dados.

Agente de provador

gemini-3.1-pro-preview

Especialista em simulador virtual. Pega uma foto do usuário e uma imagem do produto e gera uma imagem composta da pessoa usando o item. Usa um modelo mais eficiente porque precisa raciocinar sobre imagens.

Agente de estilista

gemini-3.1-pro-preview

Consultor de moda. Com base no local, na ocasião e nas preferências, ele seleciona três combinações de roupas do catálogo. Pode gerar imagens de simulação para cada roupa. Também usa o modelo avançado para raciocínio criativo.

O ponto de entrada: main.go

Tudo começa em main.go, que conecta os agentes e inicia o 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()
}

Algumas observações importantes:

  • Os agentes são criados de baixo para cima: o agente de catálogo é criado primeiro porque os agentes de provador e estilista dependem dele (eles delegam pesquisas de produtos a ele).
  • agent.NewMultiLoader registra todos os quatro agentes para que a API REST possa encaminhar para qualquer um deles por nome.
  • O adkrest.NewServer fornece a API REST automaticamente. Você não precisa escrever os manipuladores de endpoint. O ADK oferece gerenciamento de sessão, armazenamento de artefatos e execução de agentes prontos para uso.
  • O session.InMemoryService() armazena sessões na memória. Isso significa que as sessões serão perdidas se o servidor for reiniciado, o que é bom para uma demonstração. Em produção, você usaria um armazenamento persistente.
  • O gcsartifact.NewService armazena artefatos (imagens geradas) no Google Cloud Storage. Assim, eles persistem em todas as solicitações e podem ser compartilhados por URIs do GCS.

5. 🤖 Análise detalhada do ADK (Kit de Desenvolvimento de Agente)

O que é o ADK?

O Kit de Desenvolvimento de Agente (ADK) é um framework de código aberto do Google para criar agentes de IA em Go (e Python/Java). É a camada entre seu aplicativo e a API Gemini.

Você pode chamar a API Gemini diretamente. Mas quando o app precisar:

  • Pesquisar produtos em um catálogo
  • Gerar imagens com base em fotos do usuário
  • Lembrar das roupas sugeridas anteriormente
  • Coordenar vários agentes de IA

Você precisa de estrutura. O ADK fornece essa estrutura.

O ciclo do agente

Todos os agentes do ADK seguem um loop:

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

Esse loop pode ser repetido várias vezes em uma única solicitação. Por exemplo, o agente de estilista pode:

  1. Receber "Me dê sugestões de looks para uma viagem à praia"
  2. Chamar a ferramenta catalog_agent para receber a lista de produtos
  3. Selecione três combinações de roupas
  4. Chame fitting_tool para cada roupa e gere imagens.
  5. Retornar a resposta JSON estruturada

Conceitos básicos (com código deste repositório)

Agentes de LLM

O elemento básico principal. Criado com 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
})

O campo Instruction é a persona do agente. Ele informa ao LLM quem ele é e como se comportar. Neste repositório, as instruções são escritas como arquivos Markdown e incorporadas no tempo de compilação usando a diretiva //go:embed do Go:

//go:embed instructions.md
var instructions string

Isso mantém os comandos como documentos separados e com controle de versão, em vez de strings inline.

Ferramentas

As ferramentas são funções Go que o LLM pode chamar. O ADK processa a tradução entre o formato de chamada de ferramenta do LLM e sua função Go digitada:

// 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)

O ADK gera automaticamente um esquema JSON das suas structs Go e o envia para o LLM. Quando o LLM decide chamar listProducts, o ADK desserializa os argumentos, chama sua função e envia o resultado de volta.

O parâmetro tool.Context dá às ferramentas acesso aos serviços de tempo de execução do ADK, principalmente artefatos:

// Save an image as an artifact
ctx.Artifacts().Save(ctx, "my_image", imagePart)


// Load an artifact
resp, _ := ctx.Artifacts().Load(ctx, "my_image")

Delegação de subagente

Um agente pode usar outro agente como ferramenta via 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
},

Quando o agente do provador precisa de informações do produto, ele pode chamar o agente do catálogo como se fosse uma ferramenta comum. O LLM vê na lista de ferramentas e pode decidir invocar.

Sessões

As sessões rastreiam o histórico de conversas. A API REST do ADK gerencia esses itens automaticamente:

POST /api/apps/{appName}/users/{userId}/sessions    Creates a new session
POST /api/run  (with sessionId)                     Runs agent within that session

Uma decisão de design importante neste app: o provador cria uma nova sessão por solicitação (cada teste é independente), enquanto o estilista reutiliza a mesma sessão (para lembrar sugestões anteriores e refinar com base no feedback).

Estado

O estado é um armazenamento de chave-valor anexado a uma sessão. Os agentes leem e gravam o estado para coordenar:

// Write to state
ctx.State().Set("previously_used_products", "[\"id_bomber\",\"id_hat\"]")


// Read from state
val, err := ctx.State().Get("previously_used_products")

O agente de estilista usa o estado para lembrar quais produtos já foram sugeridos e, assim, escolher outros na próxima vez.

Artefatos

Os artefatos são objetos binários nomeados (geralmente imagens) armazenados por sessão. Ao contrário das respostas de texto, elas são armazenadas separadamente e buscadas por nome:

// 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}

Isso mantém as respostas leves: o agente retorna apenas o nome do artefato, e o front-end busca os dados da imagem binária separadamente.

Callbacks

Callbacks são hooks que são executados em pontos específicos no ciclo do agente. Eles podem inspecionar, modificar ou interromper a execução:

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},
}

Se um callback retornar uma resposta não nula, o comportamento padrão será ignorado. Por exemplo, um BeforeModelCallback que retorna uma resposta armazenada em cache pula completamente a chamada real do LLM.

Aplicação de esquema JSON

Os agentes do provador e do estilista forçam o LLM a responder em JSON estruturado:

GenerateContentConfig: &genai.GenerateContentConfig{
   ResponseMIMEType:   "application/json",
   ResponseJsonSchema: fittingSchemaMap(),  // Defines the expected structure
}

Isso garante que o front-end do Flutter sempre receba dados analisáveis, não texto livre.

O agente de catálogo: exemplo mais simples

O agente de catálogo (catalog/agent.go) é o mais simples do sistema, um bom ponto de partida para entender os padrões do ADK.

Ele tem duas ferramentas:

  1. listProducts: retorna o catálogo completo de produtos de um arquivo YAML.
  2. getProductImage: carrega uma imagem do produto do GCS (ou fallback local) e a salva como um artefato.

A ferramenta getProductImage mostra um padrão importante: carregamento de várias origens com cache de artefatos:

// 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
}

A ferramenta tenta primeiro os artefatos, depois o GCS e, por fim, os arquivos locais. Depois de carregada, a imagem é armazenada em cache como um artefato para que as chamadas subsequentes sejam instantâneas.

6. 🧪 O pipeline de IA: agentes em ação

Agora vamos analisar os dois agentes mais sofisticados, que geram imagens e selecionam roupas.

6.1 O agente do provador

Arquivo:

adk_backend/fittingroom/agent.go

O agente do provador é o mecanismo por trás do "Simulador virtual". Quando um usuário envia uma foto e escolhe um produto, esse agente gera uma imagem composta da pessoa usando o item.

O fitting_tool: instruções detalhadas

A lógica principal está na função doFitting. Veja o que acontece quando o agente faz a chamada:

Etapa 1: resolver a imagem do usuário

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
   }

A imagem do usuário pode vir de duas fontes:

  • Um nome de artefato (como upload_abc123_1): é o upload inicial, salvo pelo callback SaveIncomingBlobs.
  • Um URI gs://: é um resultado de ajuste gerado anteriormente e armazenado no GCS para reutilização em várias sessões.

Esse design de caminho duplo é intencional: quando o agente de estilista gera simulações de roupas, ele reutiliza o URL do GCS do resultado inicial do provador para que a identidade do usuário permaneça consistente em todas as roupas.

Etapa 2: criar o comando 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
   }

O toolInstructions (incorporado de tool_instructions.md) é crucial. Ele diz ao Gemini para preservar a identidade do usuário (rosto, tipo de corpo, tom de pele, cabelo) ao aplicar apenas a peça de roupa. Sem essa engenharia de comandos, o modelo pode mudar a aparência da pessoa.

Etapa 3: chamar o Gemini para gerar imagens

   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
       })

Todos os quatro agentes e a ferramenta de geração de imagens compartilham um único caminho de autenticação: Backend: genai.BackendVertexAI com o ID do projeto, autenticado usando Application Default Credentials. Os modelos de orquestração (gemini-3.1-pro-preview, gemini-3-flash-preview) e o modelo de imagem (gemini-2.5-flash-image) estão todos no mesmo endpoint da Vertex AI, e o mesmo ADC também autoriza o acesso ao Cloud Storage: uma credencial, todas as chamadas.

Etapa 4: salvar o 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

A gravação dupla (artefato + GCS) é fundamental para a transferência do agente entre o provador e o estilista. O artefato fornece acesso imediato na sessão atual, enquanto o URI do GCS permite que o estilista (que é executado em uma sessão diferente) faça referência à mesma imagem mais tarde.

O callback SaveIncomingBlobs

Antes mesmo de começar a raciocinar, esse BeforeAgentCallback é executado para salvar as imagens enviadas pelo usuário:

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
}

Ao retornar (nil, nil), o callback sinaliza "Terminei o pré-processamento. Agora execute o agente normalmente". Se ele retornasse conteúdo não nulo, o agente seria interrompido completamente.

6.2 O agente estilista

Arquivo:

adk_backend/stylist/agent.go

O agente de estilista é o mais sofisticado do sistema. Ele seleciona recomendações de roupas personalizadas e oferece suporte ao refinamento iterativo por conversa.

Três callbacks: a memória da estilista

O estilista usa três callbacks para manter o contexto em conversas multiturnos:

Callback 1:

InjectPreviousProducts (BeforeModel)

O problema: se o usuário disser "mostre opções diferentes", o LLM poderá sugerir os mesmos produtos novamente porque não rastreia o que já recomendou.

A solução: depois de cada resposta, os IDs dos produtos são salvos no estado da sessão. Antes da próxima chamada de LLM, esse callback lê os dados e injeta uma dica:

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
}

Callback 2:

ExtractAndInjectUserImage (BeforeModel)

O problema: quando o usuário envia feedback ("deixe mais informal"), a mensagem de acompanhamento não inclui a foto do usuário novamente. mas a ferramenta de ajuste precisa.

A solução: na primeira solicitação, esse callback extrai a referência da imagem do usuário e a salva no estado. Em solicitações subsequentes, ele é reinserido:

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
}

Callback 3:

SaveSelectedProducts (AfterModel)

Depois que o LLM responde com sugestões de roupas, essa callback analisa o JSON para extrair IDs de produtos e os salva para que a callback InjectPreviousProducts use na 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
}

Juntos, esses três callbacks criam um feedback contínuo:

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 O agente raiz

Arquivo:

adk_backend/rootagent/agent.go

O agente mais simples, com apenas 31 linhas:

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},
   })
}

Ele usa o gemini-3-flash-preview (o modelo mais rápido) porque as decisões de encaminhamento são simples. O LLM só precisa ler a intenção do usuário e escolher o subagente certo. Não são necessárias ferramentas. O SubAgents processa a delegação automaticamente.

7. 📱 Arquitetura de front-end do Flutter

O front-end do Flutter é um app de compras de varejo totalmente funcional. Os recursos de IA ficam em flutter_frontend/lib/workshop_tasks/, separados da experiência de compra pré-criada em core_app/.

O padrão MVVM

O app segue a arquitetura Model-View-ViewModel com o pacote 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 camada tem uma função clara:

  • Modelo: classes de dados como Product, Outfit, StyleRequest e enums como TryOnState
  • ViewModel (ChangeNotifier): contém o estado atual e transmite mudanças para a interface usando notifyListeners().
  • View (widget): se inscreve no ViewModel com context.watch() e é reconstruída quando o estado muda.
  • Serviço: faz chamadas HTTP para o back-end do ADK e retorna dados tipados.

A camada de serviço

Os serviços são definidos como interfaces abstratas, com implementações específicas do 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 { ... }

Essa separação significa que você pode trocar o back-end do ADK pelo Firebase AI, um serviço simulado ou qualquer outra implementação sem mudar o restante do app.

O padrão de API de três etapas

AdkFittingRoomService e AdkStylingService seguem o mesmo padrão para se comunicar com o back-end do ADK:

Etapa 1: criar uma sessão

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
}

Etapa 2: executar o 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...
}

Etapa 3: buscar o artefato

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
}

Uma diferença de design importante: o serviço de provador cria uma nova sessão para cada solicitação (_createSession() é chamado a cada vez), enquanto o serviço de estilo reutiliza a mesma sessão (_sessionId ??= await _createSession()) para permitir uma conversa em várias etapas.

Gerenciamento de estado: o TryItOnProvider

Arquivo:

workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart

O TryItOnProvider gerencia todo o fluxo de simulação. Ele usa uma enumeração TryOnState como uma máquina de estado:

enum TryOnState { initial, imagePicked, generating, success, error }


class TryItOnProvider with ChangeNotifier {
 TryOnState _state = TryOnState.initial;
 Uint8List? _userImageBytes;
 Uint8List? _generatedImage;
 String? _errorMessage;

As transições de estado particulares garantem a consistência. Você nunca atualiza o estado sem limpar dados desatualizados e notificar a interface:

 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();
 }

O principal método de geração une tudo:

 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;
 }

A interface: telas como um roteador de estado

Arquivo:

workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart

A tela de simulação usa a correspondência de padrões do Dart 3 com AnimatedSwitcher para fazer o roteamento entre sub-telas com base no estado do provedor:

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 inscreve no provedor. Sempre que notifyListeners() é chamado, esse widget é recriado, e AnimatedSwitcher faz a transição entre as telas de maneira suave. Não há Navigator.push. O conteúdo da tela muda no local com base no tipo enumerado de estado.

Transferência de agente: provador de roupas → estilista

O padrão de UX mais interessante é como o app passa o contexto do agente do provador para o agente de estilista.

Em 5_fitting_room.dart, depois que a imagem do Provador Virtual é gerada, o botão "Estilize-me" abre um formulário. Quando o usuário envia:

// 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,
));

O StyleRequest agrupa tudo o que o estilista precisa:

  • Local e ocasião: contexto de texto para estilização
  • URL da imagem do usuário do GCS: para que o estilista possa reutilizar a mesma representação do usuário.
  • Produto selecionado: para que o estilista inclua o item em todas as roupas

Essa é a transferência agêntica: a transferência perfeita de contexto multimodal de um agente de IA para outro, com o usuário vendo apenas um formulário simples.

O fluxo de estilização: StylingProvider

Arquivo:

workshop_tasks/step_2_style_me/providers/styling_provider.dart

O StylingProvider é mais simples do que o TryItOnProvider porque delega a maior parte da complexidade ao back-end:

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;
 }
}

O método refineWithFeedback envia uma mensagem de texto simples para a mesma sessão. Os callbacks InjectPreviousProducts e ExtractAndInjectUserImage do back-end processam todo o gerenciamento de contexto automaticamente.

8. 🚀 Executar o app localmente

Para uma experiência tranquila no Cloud Shell, o back-end do Go veicula o app da Web do Flutter compilado da mesma porta (8080). Um processo, um URL de prévia, sem problemas de origem cruzada e sem edição de arquivos de configuração.

Antes de começar: verificação de integridade do ADC

O back-end precisa de Application Default Credentials para chamar a Vertex AI. Se você concluiu a etapa 7 da configuração do projeto nesta sessão do Cloud Shell e nesta Conta do Google, tudo certo. Se você estiver voltando depois de um tempo, tiver trocado de conta ou não tiver certeza, verifique em cinco segundos:

gcloud auth application-default print-access-token | head -c 20 && echo "..."

Se isso imprimir cerca de 20 caracteres de um token, está tudo certo. Se houver um erro, execute novamente a etapa 7 da configuração do projeto:

gcloud auth application-default login
gcloud auth application-default set-quota-project $(gcloud config get-value project)

Você vai usar dois terminais do Cloud Shell:

  • Terminal A: executa o back-end continuamente (./run.sh). Deixe-o aberto.
  • Terminal B: executa o build da Web do Flutter uma vez (flutter build web) e sai quando termina.

A ordem não importa. Você pode começar por qualquer um dos dois. Mas, para uma experiência de primeira execução mais limpa, crie o Flutter primeiro para que o back-end tenha uma interface do usuário para atender desde o início.

1. Terminal B: criar o pacote do Flutter Web (única vez)

Abra uma nova guia do Cloud Shell (o + na parte de cima do painel do terminal) e faça o seguinte:

cd ~/fashion_app_demo/flutter_frontend
flutter pub get
flutter build web

Isso produz flutter_frontend/build/web/, um diretório de arquivos estáticos (HTML, JS, recursos), e é encerrado quando termina. O back-end vai veicular esses dados assim que perceber que o diretório existe.

2. Terminal A: iniciar o back-end (longa duração)

No terminal original do Cloud Shell:

cd ~/fashion_app_demo/adk_backend
./run.sh

Você verá um código como este:

Serving Flutter web build from ../flutter_frontend/build/web

Deixe esse terminal em execução: o back-end fica ativo enquanto run.sh estiver ativo. Para interromper, pressione Ctrl+C.

O servidor expõe tudo na porta 8080:

  • /: app da Web do Flutter (a interface de compras)
  • /api/: endpoints REST do ADK (chamados pelo app Flutter)
  • Interface de desenvolvimento do ADK: também em / quando não há um build do Flutter. Útil para depuração direta de agentes.

3. Abrir a Visualização da Web

  1. No Cloud Shell, clique no ícone Visualização da Web (canto superior direito) → Visualizar na porta 8080.
  2. O app de compras do Flutter é carregado em uma nova guia.
  3. Navegue pelo catálogo de produtos e selecione um item
  4. Toque no ícone de pessoa (👤) para iniciar o fluxo de simulação.
  5. Envie uma foto e veja a IA gerar uma imagem do Provador Virtual
  6. Toque em "Estilize-me" para receber recomendações de roupas
  7. Digite um feedback complementar, como "deixe mais informal": refinamento na mesma sessão

9. ☁️ Implantar no Cloud Run

Agrupar o build do Flutter no back-end

O contêiner do Cloud Run envia a API e a interface de uma imagem. Copie o build da Web do Flutter para adk_backend/flutter_web/. Esse é o primeiro caminho que o servidor Go verifica ao escolher qual interface veicular:

cd ~/fashion_app_demo/flutter_frontend
flutter build web
rm -rf ../adk_backend/flutter_web
cp -r build/web ../adk_backend/flutter_web

Se você estiver fazendo iterações localmente, talvez já tenha build/web da etapa "Executar localmente". Executar flutter build web novamente não é um problema.)

Implantar o back-end (fornece API e interface)

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

Quando a implantação terminar, você vai receber um URL do serviço como https://fashion-app-backend-xyz-uc.a.run.app. Abra em um navegador. O app de compras do Flutter será carregado de /, e as chamadas de API vão para /api/ no mesmo host. Nenhuma edição de configuração de front-end é necessária, nenhuma chave de API foi transmitida.

Verificar a implantação

Abra o URL do Cloud Run no navegador e execute o fluxo completo:

  1. Procurar → Selecione um produto
  2. Provador Virtual → Faça upload da sua foto → Confira a imagem gerada com IA
  3. Me dê um estilo → Informe o local/a ocasião → Confira as opções selecionadas
  4. Feedback → Digite "deixe mais casual" → Confira as roupas atualizadas
  5. Adicionar à bolsa → Concluir o fluxo de compras

10. 🎉 Conclusão

O que você criou

Você conheceu uma experiência completa de varejo com tecnologia de IA, incluindo:

  • ✅ Um backend multiagente com quatro agentes especializados trabalhando juntos
  • ✅ Um provador virtual que gera imagens personalizadas para simulação
  • ✅ Um consultor de estilo de IA que seleciona e refina looks por conversa
  • ✅ Um app Flutter multiplataforma que se conecta ao back-end do agente
  • Implantação do Cloud Run para hospedagem escalonável e sem servidor

Principais conceitos

Conceito

Onde você viu

Orquestração multiagente do ADK

Roteamento do agente raiz para agentes de provador, catálogo e estilista

Geração de imagens multimodais com o Gemini

fitting_tool combinar fotos de usuários com imagens de produtos

Estado da sessão para IA de conversação

O estilista reutiliza sessões para feedback iterativo

Armazenamento de artefatos para dados binários

Separar o armazenamento de imagens das respostas de texto

Callbacks para lógica de middleware

SaveIncomingBlobs, InjectPreviousProducts, SaveSelectedProducts

MVVM + Provider no Flutter

TryItOnProvider e StylingProvider com ChangeNotifier

Transição agêntica

StyleRequest transmissão de contexto multimodal entre agentes

Próximas etapas

  • 🎨 Personalizar comandos do agente: edite instructions.md para mudar a personalidade do estilista.
  • 🛍️ Adicionar mais produtos: atualize catalog.yaml com novos itens.
  • 📱 Crie para dispositivos móveis: execute flutter build ios ou flutter build apk
  • 🔄 Adicionar sessões persistentes: substitua InMemoryService por uma implementação com suporte de banco de dados.
  • 🔒 Adicionar autenticação: proteja o endpoint do Cloud Run com o IAM

Recursos