👗 สร้างห้องลองเสมือนและสไตลิสต์ AI ด้วย Flutter, ADK Go และ Gemini

1. บทนำ

สิ่งที่คุณจะสร้าง

ใน Codelab นี้ คุณจะได้สวมบทบาทเป็นนักพัฒนาแอปที่สร้างแอปแฟชั่น ซึ่งเป็นแอปช็อปปิ้ง Flutter สำหรับแบรนด์ค้าปลีกสมมติ ภารกิจของคุณคือการเพิ่มฟีเจอร์ 2 รายการที่ทำงานด้วยระบบ AI ซึ่งจะเปลี่ยนประสบการณ์การช็อปปิ้งออนไลน์

  1. ห้องลองเสมือนจริง - ผู้ใช้อัปโหลดรูปภาพของตนเอง เลือกเสื้อผ้า และดูรูปภาพที่ AI สร้างขึ้นซึ่งแสดงให้เห็นว่าผู้ใช้สวมเสื้อผ้าชิ้นนั้น
  2. สไตลิสต์ AI - AI Agent จะดูแลจัดการคำแนะนำชุดที่สมบูรณ์ตามสถานที่ โอกาส และสไตล์ที่ผู้ใช้ต้องการ และผู้ใช้สามารถปรับแต่งคำแนะนำผ่านการสนทนาได้

แนวคิดนี้เรียบง่าย นั่นคือเมื่อผู้คนลองเสื้อผ้าในห้องลอง พวกเขามีแนวโน้มที่จะซื้อเสื้อผ้าเหล่านั้นมากขึ้น แต่บนโลกออนไลน์ คุณแค่เดา โปรเจ็กต์นี้ช่วยลดช่องว่างดังกล่าวด้วย AI

สถาปัตยกรรมข้อมูลโดยย่อ

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

เทคโนโลยีหลัก

ส่วนประกอบ

เทคโนโลยี

วัตถุประสงค์

Agent Framework

ADK (Agent Development Kit) สำหรับ Go

การจัดการหลายเอเจนต์ เซสชัน อาร์ติแฟกต์

การให้เหตุผลของ Agent (Pro)

เวอร์ชันตัวอย่างของ Gemini 3.1 Pro

ขับเคลื่อนห้องลองเสมือนจริงและตัวแทนสไตลิสต์

การให้เหตุผลของเอเจนต์ (Flash)

Gemini 3 Flash (เวอร์ชันตัวอย่าง)

ขับเคลื่อน Agent รูทและแคตตาล็อก (การกำหนดเส้นทาง/การค้นหาแบบเบา)

การสร้างรูปภาพ

รูปภาพ Gemini 2.5 Flash

สร้างรูปภาพการลองเสมือนจริงและชุด

ฟรอนท์เอนด์

Flutter (Dart)

แอปข้ามแพลตฟอร์ม (เว็บ, iOS, Android)

พื้นที่เก็บข้อมูล

Google Cloud Storage

จัดเก็บรูปภาพผลิตภัณฑ์และอาร์ติแฟกต์ที่สร้างขึ้น

โฮสติ้ง

Cloud Run

การติดตั้งใช้งานคอนเทนเนอร์แบบ Serverless

2. 📦 ข้อกำหนดเบื้องต้นและการตั้งค่า Cloud Shell

1. เปิดเครื่องมือแก้ไข Cloud Shell

👉 เปิด Cloud Shell Editor ในเบราว์เซอร์

หากเทอร์มินัลไม่ปรากฏที่ด้านล่างของหน้าจอ ให้ทำดังนี้

  • คลิกดูTerminal

2. ตั้งค่า Flutter SDK

Cloud Shell มาพร้อมกับ Flutter ที่ติดตั้งไว้ล่วงหน้าใน /google/flutter เนื่องจากไดเรกทอรีดังกล่าวเป็นของผู้ใช้ระบบอื่น คุณจะได้รับข้อผิดพลาด fatal: detected dubious ownership ในครั้งแรกที่เรียกใช้ flutter เพิ่มลงในรายการ safe-directory ของ Git เพียงครั้งเดียวโดยทำดังนี้

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

ตรวจสอบว่า Flutter อยู่ใน PATH และใช้งานได้โดยทำดังนี้

flutter --version

การเรียกใช้ครั้งแรกจะดาวน์โหลด Dart SDK และสร้างเครื่องมือ Flutter ซึ่งอาจใช้เวลาสักครู่ คุณควรเห็นข้อความคล้ายกับ Flutter 3.x • channel stable

3. โคลนที่เก็บ

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

4. สำรวจโครงสร้างโปรเจ็กต์

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. ☁️ การตั้งค่าโปรเจ็กต์ Google Cloud

1. สร้างโปรเจ็กต์ใหม่

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

แสดงรายการบัญชีสำหรับการเรียกเก็บเงิน

gcloud billing accounts list

ดู

OPEN

คอลัมน์ โดยต้องระบุว่า True หากระบุว่า False (มักเกิดขึ้นกับช่วงทดลองใช้ฟรีที่หมดอายุ) แสดงว่าบัญชีถูกปิดแล้วและจะไม่มีการชำระเงินใดๆ จริงๆ ให้ข้ามไปยังบล็อกการแก้ปัญหาด้านล่างก่อนดำเนินการต่อ

คัดลอก ACCOUNT_ID ของบัญชี OPEN: True (มีลักษณะคล้าย 0X0X0X-0X0X0X-0X0X0X) แล้วลิงก์กับโปรเจ็กต์ของคุณ

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

วิธียืนยันลิงก์

gcloud billing projects describe fashion-app-demo

คุณควรเห็น billingEnabled: true หากคุณเห็น billingEnabled: false แม้หลังจากลิงก์แล้ว แสดงว่าบัญชีถูกปิด (OPEN: False) โปรดดูบล็อกการแก้ปัญหาด้านล่าง

3. เปิดใช้ API ที่จำเป็น

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

API

วัตถุประสงค์

aiplatform.googleapis.com

Vertex AIfitting_tool เรียกใช้การสร้างรูปภาพของ Gemini ผ่าน Vertex AI

storage.googleapis.com

Cloud Storage - จัดเก็บรูปภาพแคตตาล็อกผลิตภัณฑ์และผลลัพธ์การลองเสมือนจริงที่สร้างขึ้น

run.googleapis.com

Cloud Run - โฮสต์แบ็กเอนด์เป็นคอนเทนเนอร์แบบ Serverless

cloudbuild.googleapis.com

Cloud Build - สร้างอิมเมจ Docker จากแหล่งที่มา

artifactregistry.googleapis.com

Artifact Registry - จัดเก็บอิมเมจ Docker ที่สร้างขึ้น

4. สร้างที่เก็บข้อมูล 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. อัปโหลดรูปภาพแคตตาล็อกผลิตภัณฑ์

เครื่องมือ getProductImage ของแบ็กเอนด์จะอ่านจาก gs://$GCS_BUCKET/catalog-assets/images/ อัปโหลดรูปภาพแคตตาล็อกไปยังเส้นทางที่แน่นอนนั้น

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

ยืนยันการอัปโหลด (คุณควรเห็นรายการไฟล์ .png)

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

6. กำหนดค่าไฟล์ .env

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

7. ตรวจสอบสิทธิ์ด้วยข้อมูลรับรองเริ่มต้นของแอปพลิเคชัน

คุณต้องเรียกใช้คำสั่งนี้ก่อนเริ่มแบ็กเอนด์ในเครื่อง แบ็กเอนด์ของ Go ใช้ ADC เพื่อตรวจสอบสิทธิ์การเรียกใช้ Vertex AI (Gemini) และ Cloud Storage ทุกครั้ง หากไม่มี ADC แบ็กเอนด์จะเริ่มทำงาน แต่คำขอฟีเจอร์ลองเสมือนจริงทั้งหมดจะล้มเหลวโดยมีข้อผิดพลาด 401 CREDENTIALS_MISSING

โดยข้อมูลเข้าสู่ระบบชุดเดียวจะใช้ได้กับทั้ง 2 บริการ เรียกใช้ 2 คำสั่งนี้ตามลำดับ

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

ตรวจสอบว่า ADC ทำงานได้ดี

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

คุณควรเห็นโทเค็นประมาณ 20 อักขระตามด้วย ... หากเกิดข้อผิดพลาด แสดงว่าระบบไม่ได้บันทึกการเข้าสู่ระบบ ให้ทำขั้นตอนที่ 1 อีกครั้ง

4. 🏗️ ภาพรวมสถาปัตยกรรม

ตอนนี้สภาพแวดล้อมพร้อมแล้ว เรามาทำความเข้าใจวิธีการทำงานของระบบก่อนที่จะดูโค้ดกัน

ระบบตัวแทน 4 ราย

แบ็กเอนด์สร้างขึ้นเป็นระบบ Multi-Agent โดยใช้ ADK (Agent Development Kit) สำหรับ Go ซึ่งมี Agent 4 ตัวที่ทำงานร่วมกัน โดยแต่ละตัวมีหน้าที่รับผิดชอบที่เฉพาะเจาะจง ดังนี้

                   ┌──────────────┐
                    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 
                                  └────────────────┘

Agent

รุ่น

บทบาท

Root Agent

gemini-3-flash-preview

ตำรวจจราจร อ่านข้อความของผู้ใช้และส่งต่อให้ตัวแทนผู้เชี่ยวชาญที่เหมาะสม ใช้โมเดลที่รวดเร็วและมีน้ำหนักเบาเนื่องจากต้องตัดสินใจเกี่ยวกับการกำหนดเส้นทางเท่านั้น

Catalog Agent

gemini-3-flash-preview

Product Expert โหลดแคตตาล็อกผลิตภัณฑ์จากไฟล์ YAML และตอบคำค้นหาผลิตภัณฑ์ นอกจากนี้ยังมีขนาดเล็กเนื่องจากเป็นเพียงการค้นหาข้อมูล

ตัวแทนห้องลองเสมือนจริง

gemini-3.1-pro-preview

ผู้เชี่ยวชาญด้านระบบการลองเสมือนจริง นำรูปภาพผู้ใช้ + รูปภาพสินค้ามาสร้างเป็นรูปภาพคอมโพสิตของบุคคลที่สวมใส่ไอเทมนั้น ใช้โมเดลที่มีความสามารถมากกว่าเนื่องจากต้องใช้เหตุผลเกี่ยวกับรูปภาพ

Stylist Agent

gemini-3.1-pro-preview

ที่ปรึกษาด้านแฟชั่น โดยระบบจะคัดสรรชุด 3 ชุดจากแคตตาล็อกตามสถานที่ โอกาส และความชอบ สร้างรูปภาพการลองเสมือนจริงสำหรับชุดแต่ละชุดได้ นอกจากนี้ ยังใช้โมเดลที่มีความสามารถในการให้เหตุผลเชิงสร้างสรรค์

จุดแรกเข้า: main.go

ทุกอย่างเริ่มต้นใน main.go ซึ่งเชื่อมต่อเอเจนต์เข้าด้วยกันและเริ่มเซิร์ฟเวอร์ 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()
}

สิ่งสำคัญบางอย่างที่ควรทราบมีดังนี้

  • สร้างเอเจนต์จากล่างขึ้นบน: สร้างเอเจนต์แคตตาล็อกก่อนเนื่องจากทั้งเอเจนต์ห้องลองเสมือนจริงและเอเจนต์สไตลิสต์ขึ้นอยู่กับเอเจนต์แคตตาล็อก (เอเจนต์ทั้ง 2 รายนี้จะมอบหมายให้เอเจนต์แคตตาล็อกค้นหาสินค้า)
  • agent.NewMultiLoader จะลงทะเบียนทั้ง 4 เอเจนต์เพื่อให้ REST API กำหนดเส้นทางไปยังเอเจนต์ใดก็ได้ตามชื่อ
  • adkrest.NewServer จะให้ REST API โดยอัตโนมัติ คุณจึงไม่ต้องเขียนตัวแฮนเดิลปลายทางด้วยตนเอง ADK ช่วยให้คุณจัดการเซสชัน จัดเก็บอาร์ติแฟกต์ และเรียกใช้เอเจนต์ได้ทันที
  • session.InMemoryService() จัดเก็บเซสชันไว้ในหน่วยความจำ ซึ่งหมายความว่าเซสชันจะหายไปหากเซิร์ฟเวอร์รีสตาร์ท ซึ่งเหมาะสำหรับการสาธิต ในเวอร์ชันที่ใช้งานจริง คุณควรใช้ที่เก็บข้อมูลแบบถาวร
  • gcsartifact.NewService จัดเก็บอาร์ติแฟกต์ (รูปภาพที่สร้างขึ้น) ใน Google Cloud Storage เพื่อให้คงอยู่ได้ในคำขอต่างๆ และแชร์ผ่าน URI ของ GCS ได้

5. 🤖 ข้อมูลเจาะลึกเกี่ยวกับ ADK (Agent Development Kit)

ADK คืออะไร

Agent Development Kit (ADK) เป็นเฟรมเวิร์กโอเพนซอร์สจาก Google สำหรับสร้าง AI Agent ใน Go (และ Python/Java) ซึ่งเป็นเลเยอร์ระหว่างแอปพลิเคชันของคุณกับ Gemini API

คุณอาจเรียกใช้ Gemini API โดยตรง แต่เมื่อแอปของคุณต้องดำเนินการต่อไปนี้

  • ค้นหาผลิตภัณฑ์จากแคตตาล็อก
  • สร้างรูปภาพตามรูปภาพของผู้ใช้
  • จดจำชุดที่ระบบแนะนำก่อนหน้านี้
  • ประสานงาน AI Agent หลายตัว

คุณต้องมีโครงสร้าง ADK มีโครงสร้างดังกล่าว

วงจรของเอเจนต์

ตัวแทน ADK ทุกรายจะทำตามลูปต่อไปนี้

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

ลูปนี้สามารถทำซ้ำได้หลายครั้งภายในคำขอเดียว ตัวอย่างเช่น ตัวแทนสไตลิสต์อาจทำสิ่งต่อไปนี้

  1. รับคำตอบ "ช่วยจัดสไตล์ให้ฉันสำหรับวันหยุดพักผ่อนที่ชายหาดหน่อย"
  2. เรียกใช้เครื่องมือ catalog_agent เพื่อรับรายการผลิตภัณฑ์
  3. เลือกชุด 3 ชุด
  4. เรียกใช้ fitting_tool สำหรับชุดแต่ละชุดเพื่อสร้างรูปภาพ
  5. แสดงผลการตอบกลับ JSON ที่มีโครงสร้าง

แนวคิดหลัก (พร้อมโค้ดจากที่เก็บนี้)

เอเจนต์ LLM

องค์ประกอบที่ใช้สร้างสรรค์หลัก สร้างด้วย 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
})

ฟิลด์ Instruction คือลักษณะตัวตนของ Agent ซึ่งจะบอก LLM ว่า Agent คือใครและควรมีพฤติกรรมอย่างไร ในที่เก็บนี้ คำสั่งจะเขียนเป็นไฟล์ Markdown และฝังไว้ในเวลาคอมไพล์โดยใช้คำสั่ง //go:embed ของ Go

//go:embed instructions.md
var instructions string

ซึ่งจะช่วยให้พรอมต์เป็นเอกสารที่แยกกันและมีการควบคุมเวอร์ชันได้ แทนที่จะเป็นสตริงในบรรทัด

เครื่องมือ

เครื่องมือคือฟังก์ชัน Go ที่ LLM เรียกใช้ได้ ADK จะจัดการการแปลระหว่างรูปแบบการเรียกใช้เครื่องมือของ LLM กับฟังก์ชัน Go ที่คุณพิมพ์ ดังนี้

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

ADK จะสร้างสคีมา JSON จากโครงสร้าง Go โดยอัตโนมัติและส่งไปยัง LLM เมื่อ LLM ตัดสินใจเรียกใช้ listProducts, ADK จะยกเลิกการซีเรียลไลซ์อาร์กิวเมนต์ เรียกใช้ฟังก์ชันของคุณ และส่งผลลัพธ์กลับ

พารามิเตอร์ tool.Context ช่วยให้เครื่องมือเข้าถึงบริการรันไทม์ของ ADK ซึ่งที่สำคัญที่สุดคืออาร์ติแฟกต์

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


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

การมอบสิทธิ์ Agent ย่อย

Agent สามารถใช้ Agent อื่นเป็นเครื่องมือผ่าน 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
},

เมื่อตัวแทนห้องลองเสมือนจริงต้องการข้อมูลสินค้า ก็จะเรียกตัวแทนแคตตาล็อกได้ราวกับเป็นเครื่องมือทั่วไป LLM จะเห็นฟังก์ชันนี้ในรายการเครื่องมือและสามารถตัดสินใจที่จะเรียกใช้ได้

เซสชัน

เซสชันจะติดตามประวัติการสนทนา REST API ของ ADK จะจัดการโดยอัตโนมัติ

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

การตัดสินใจด้านการออกแบบที่สำคัญในแอปนี้คือ ฟิตติ้งรูมจะสร้างเซสชันใหม่ต่อคำขอ (การลองแต่ละครั้งจะแยกกัน) ส่วนสไตลิสต์จะใช้เซสชันเดิมซ้ำ (เพื่อให้จดจำคำแนะนำก่อนหน้าและปรับแต่งตามความคิดเห็นได้)

รัฐ

สถานะคือที่เก็บคีย์-ค่าที่แนบมากับเซสชัน เอเจนต์อ่านและเขียนสถานะเพื่อประสานงาน

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


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

ตัวแทนสไตลิสต์ใช้สถานะเพื่อจดจำผลิตภัณฑ์ที่แนะนำไปแล้ว จึงเลือกผลิตภัณฑ์อื่นในครั้งถัดไป

อาร์ติแฟกต์

อาร์ติแฟกต์คือออบเจ็กต์ไบนารีที่มีชื่อ (โดยปกติคือรูปภาพ) ซึ่งจัดเก็บไว้ต่อเซสชัน ระบบจะจัดเก็บคำตอบที่เป็นรูปภาพแยกต่างหากและดึงข้อมูลตามชื่อ ซึ่งแตกต่างจากคำตอบที่เป็นข้อความ

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

ซึ่งจะช่วยให้การตอบกลับมีขนาดเล็ก โดยเอเจนต์จะแสดงเฉพาะชื่ออาร์ติแฟกต์ และส่วนหน้าจะดึงข้อมูลรูปภาพไบนารีแยกต่างหาก

การติดต่อกลับ

Callback คือ Hook ที่ทำงานในจุดที่เฉพาะเจาะจงในลูปของ Agent โดยสามารถตรวจสอบ แก้ไข หรือขัดขวางการดำเนินการได้ดังนี้

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

หากการเรียกกลับแสดงผลการตอบกลับที่ไม่ใช่ค่าว่าง ระบบจะข้ามลักษณะการทำงานเริ่มต้น เช่น BeforeModelCallback ที่แสดงผลการตอบกลับที่แคชไว้จะข้ามการเรียกใช้ LLM จริงไปเลย

การบังคับใช้สคีมา JSON

ทั้งเอเจนต์ห้องลองเสมือนจริงและสไตลิสต์จะบังคับให้ LLM ตอบกลับใน JSON ที่มีโครงสร้าง

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

วิธีนี้ช่วยให้มั่นใจได้ว่าส่วนหน้าของ Flutter จะได้รับข้อมูลที่แยกวิเคราะห์ได้เสมอ ไม่ใช่ข้อความอิสระ

เอเจนต์แคตตาล็อก: ตัวอย่างที่ง่ายที่สุด

เอเจนต์แคตตาล็อก (catalog/agent.go) เป็นเอเจนต์ที่ง่ายที่สุดในระบบ ซึ่งเป็นจุดเริ่มต้นที่ดีในการทำความเข้าใจรูปแบบ ADK

โดยมีเครื่องมือ 2 อย่างดังนี้

  1. listProducts — แสดงแคตตาล็อกผลิตภัณฑ์ทั้งหมดจากไฟล์ YAML
  2. getProductImage — โหลดรูปภาพสินค้าจาก GCS (หรือการสำรองข้อมูลในเครื่อง) และบันทึกเป็นอาร์ติแฟกต์

getProductImage เครื่องมือนี้แสดงรูปแบบที่สำคัญ นั่นคือ การโหลดแบบหลายแหล่งที่มาพร้อมการแคชอาร์ติแฟกต์

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

เครื่องมือจะลองใช้สิ่งประดิษฐ์ก่อน จากนั้นจึงใช้ GCS แล้วจึงใช้ไฟล์ในเครื่อง เมื่อโหลดแล้ว ระบบจะแคชรูปภาพเป็นอาร์ติแฟกต์เพื่อให้การเรียกใช้ครั้งต่อๆ ไปเป็นไปอย่างรวดเร็ว

6. 🧪 ไปป์ไลน์ AI: ตัวแทนในการดำเนินการ

มาดูเอเจนต์ที่ซับซ้อนที่สุด 2 รายการกัน ซึ่งเป็นเอเจนต์ที่สร้างรูปภาพและคัดสรรชุดจริงๆ

6.1 ตัวแทนห้องลอง

ไฟล์:

adk_backend/fittingroom/agent.go

เอเจนต์ห้องลองคือเครื่องมือที่อยู่เบื้องหลัง "ระบบการลองเสมือนจริง" เมื่อผู้ใช้อัปโหลดรูปภาพและเลือกผลิตภัณฑ์ เอเจนต์นี้จะสร้างรูปภาพคอมโพสิตของบุคคลที่สวมใส่ไอเทมนั้น

fitting_tool - ทีละขั้นตอน

ตรรกะหลักอยู่ในฟังก์ชัน doFitting สิ่งที่จะเกิดขึ้นเมื่อตัวแทนเรียกใช้ฟังก์ชันนี้มีดังนี้

ขั้นตอนที่ 1: แก้ไขรูปภาพผู้ใช้

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
   }

รูปภาพผู้ใช้มาจาก 2 แหล่งที่มา ได้แก่

  • ชื่ออาร์ติแฟกต์ (เช่น upload_abc123_1) - นี่คือการอัปโหลดครั้งแรกที่บันทึกโดยการเรียกกลับ SaveIncomingBlobs
  • URI gs:// — นี่คือผลลัพธ์การลองเสมือนที่สร้างไว้ก่อนหน้านี้ ซึ่งจัดเก็บไว้ใน GCS เพื่อนำไปใช้ซ้ำในเซสชันต่างๆ

การออกแบบแบบ 2 เส้นทางนี้มีจุดประสงค์เพื่อเมื่อเอเจนต์สไตลิสต์สร้างการลองชุดในภายหลัง ระบบจะนำ URL ของ GCS จากผลลัพธ์ของห้องลองเสมือนจริงครั้งแรกมาใช้ซ้ำเพื่อให้ข้อมูลประจำตัวของผู้ใช้สอดคล้องกันในชุดทั้งหมด

ขั้นตอนที่ 2: สร้างพรอมต์มัลติโมดัล

   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
   }

toolInstructions (ฝังจาก tool_instructions.md) มีความสำคัญอย่างยิ่ง เนื่องจากจะบอกให้ Gemini คงตัวตนของผู้ใช้ (ใบหน้า รูปร่าง สีผิว ผม) ไว้ในขณะที่ใช้เฉพาะไอเทมเสื้อผ้า หากไม่มีวิศวกรรมพรอมต์ (Prompt Engineering) นี้ โมเดลอาจเปลี่ยนรูปลักษณ์ของบุคคล

ขั้นตอนที่ 3: เรียกใช้ Gemini เพื่อสร้างรูปภาพ

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

เอเจนต์ทั้ง 4 รายและเครื่องมือสร้างรูปภาพใช้เส้นทางการตรวจสอบสิทธิ์เดียวกัน: Backend: genai.BackendVertexAI โดยใช้รหัสโปรเจ็กต์ที่ตรวจสอบสิทธิ์ผ่านข้อมูลรับรองเริ่มต้นของแอปพลิเคชัน โมเดลการจัดระเบียบ (gemini-3.1-pro-preview, gemini-3-flash-preview) และโมเดลรูปภาพ (gemini-2.5-flash-image) ทั้งหมดอยู่เบื้องหลังปลายทาง Vertex AI เดียวกัน และ ADC เดียวกันยังให้สิทธิ์เข้าถึง Cloud Storage ด้วย โดยใช้ข้อมูลเข้าสู่ระบบเดียวในทุกการเรียก

ขั้นตอนที่ 4: บันทึกผลลัพธ์

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

การบันทึกคู่ (อาร์ติแฟกต์ + GCS) เป็นกุญแจสำคัญในการส่งต่อตัวแทนระหว่างห้องลองเสมือนจริงกับสไตลิสต์ อาร์ติแฟกต์จะให้สิทธิ์เข้าถึงทันทีภายในเซสชันปัจจุบัน ขณะที่ URI ของ GCS จะช่วยให้สไตลิสต์ (ซึ่งทำงานในเซสชันอื่น) อ้างอิงรูปภาพเดียวกันได้ในภายหลัง

SaveIncomingBlobs การติดต่อกลับ

ก่อนที่เอเจนต์จะเริ่มให้เหตุผล BeforeAgentCallback นี้จะทำงานเพื่อบันทึกรูปภาพที่ผู้ใช้อัปโหลด

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
}

การส่งคืน (nil, nil) จะเป็นการส่งสัญญาณการเรียกกลับว่า "ฉันประมวลผลล่วงหน้าเสร็จแล้ว ตอนนี้ให้เรียกใช้เอเจนต์ตามปกติ" หากมีการแสดงเนื้อหาที่ไม่ใช่ค่าว่าง ระบบจะข้ามการทำงานของเอเจนต์ทั้งหมด

6.2 ตัวแทนสไตลิสต์

ไฟล์:

adk_backend/stylist/agent.go

โดยเอเจนต์สไตลิสต์เป็นเอเจนต์ที่ซับซ้อนที่สุดในระบบ โดยจะคัดสรรคำแนะนำชุดที่ปรับเปลี่ยนในแบบของคุณ และรองรับการปรับแต่งซ้ำๆ ผ่านการสนทนา

การโทรกลับ 3 ครั้ง - ความทรงจำของสไตลิสต์

สไตลิสต์ใช้การเรียกกลับ 3 รายการเพื่อรักษาบริบทในการสนทนาแบบหลายรอบ

Callback 1:

InjectPreviousProducts (BeforeModel)

ปัญหา: หากผู้ใช้พูดว่า "แสดงตัวเลือกอื่นให้ฉันหน่อย" LLM อาจแนะนำผลิตภัณฑ์เดิมอีกครั้งเนื่องจากไม่ได้ติดตามสิ่งที่แนะนำไปแล้วโดยธรรมชาติ

วิธีแก้ปัญหา: หลังจากได้รับคำตอบแต่ละครั้ง ระบบจะบันทึกรหัสผลิตภัณฑ์ลงในสถานะเซสชัน ก่อนการเรียก LLM ครั้งถัดไป การเรียกกลับนี้จะอ่านและแทรกคำใบ้

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)

ปัญหา: เมื่อผู้ใช้แสดงความคิดเห็น ("ทำให้ดูสบายๆ ขึ้นหน่อย") ข้อความติดตามผลจะไม่รวมรูปภาพของผู้ใช้อีก แต่เครื่องมือการปรับแต่งต้องใช้

วิธีแก้ปัญหา: ในคำขอแรก คอลแบ็กนี้จะดึงข้อมูลอ้างอิงรูปภาพของผู้ใช้และบันทึกลงในสถานะ ในคำขอต่อๆ ไป ระบบจะแทรกพารามิเตอร์นี้อีกครั้ง

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)

หลังจากที่ LLM ตอบกลับพร้อมคำแนะนำชุด ระบบจะเรียกใช้ฟังก์ชันนี้เพื่อแยกวิเคราะห์ JSON เพื่อดึงรหัสผลิตภัณฑ์และบันทึกไว้ให้ฟังก์ชัน InjectPreviousProducts ใช้ในครั้งถัดไป

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
}

โดยการเรียกกลับทั้ง 3 รายการนี้จะสร้างวงจรความคิดเห็นร่วมกัน ดังนี้

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 Agent รูท

ไฟล์:

adk_backend/rootagent/agent.go

เอเจนต์ที่เรียบง่ายที่สุดมีเพียง 31 บรรทัด

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

โดยใช้ gemini-3-flash-preview (โมเดลที่เร็วที่สุด) เนื่องจากระบบจะกำหนดเส้นทางได้ง่ายๆ เพียงแค่ LLM ต้องอ่านเจตนาของผู้ใช้และเลือก Agent ย่อยที่เหมาะสม ไม่จำเป็นต้องใช้เครื่องมือใดๆ เนื่องจาก SubAgents จะจัดการการมอบสิทธิ์โดยอัตโนมัติ

7. 📱 สถาปัตยกรรมฟรอนท์เอนด์ของ Flutter

ส่วนหน้าของ Flutter เป็นแอปช็อปปิ้งค้าปลีกที่ใช้งานได้อย่างเต็มรูปแบบ ฟีเจอร์ AI จะอยู่ใน flutter_frontend/lib/workshop_tasks/ แยกจากประสบการณ์การช็อปปิ้งที่สร้างไว้ล่วงหน้าใน core_app/

รูปแบบ MVVM

แอปใช้สถาปัตยกรรม Model-View-ViewModel กับแพ็กเกจ 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                                                 
└──────────────────┘    └────────────────────┘    └──────────────────┘

แต่ละเลเยอร์มีบทบาทที่ชัดเจนดังนี้

  • โมเดล: คลาสข้อมูล เช่น Product, Outfit, StyleRequest และ Enum เช่น TryOnState
  • ViewModel (ChangeNotifier): จัดเก็บสถานะปัจจุบันและออกอากาศการเปลี่ยนแปลงไปยัง UI ผ่าน notifyListeners()
  • View (วิดเจ็ต): ติดตาม ViewModel ด้วย context.watch() และสร้างใหม่เมื่อสถานะเปลี่ยนแปลง
  • บริการ: ทำการเรียก HTTP ไปยังแบ็กเอนด์ของ ADK และแสดงผลข้อมูลที่พิมพ์

เลเยอร์บริการ

บริการจะกำหนดเป็นอินเทอร์เฟซเชิงนามธรรมที่มีการติดตั้งใช้งานเฉพาะ 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 { ... }

การแยกนี้หมายความว่าคุณสามารถเปลี่ยนแบ็กเอนด์ ADK เป็น Firebase AI, บริการจำลอง หรือการติดตั้งใช้งานอื่นๆ ได้โดยไม่ต้องเปลี่ยนส่วนอื่นๆ ของแอป

รูปแบบ API 3 ขั้นตอน

ทั้ง AdkFittingRoomService และ AdkStylingService ใช้รูปแบบเดียวกันในการสื่อสารกับแบ็กเอนด์ของ ADK ดังนี้

ขั้นตอนที่ 1: สร้างเซสชัน

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
}

ขั้นตอนที่ 2: เรียกใช้เอเจนต์

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

ขั้นตอนที่ 3: ดึงข้อมูลอาร์ติแฟกต์

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
}

ความแตกต่างที่สำคัญในการออกแบบคือ บริการห้องลองเสื้อจะสร้างเซสชันใหม่สำหรับทุกคำขอ (_createSession() จะเรียกใช้ทุกครั้ง) ในขณะที่บริการจัดแต่งสไตล์จะใช้เซสชันเดิมซ้ำ (_sessionId ??= await _createSession()) เพื่อเปิดใช้การสนทนาแบบหลายรอบ

การจัดการสถานะ: TryItOnProvider

ไฟล์:

workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart

TryItOnProvider จะจัดการโฟลว์การลองเสมือนจริงทั้งหมด โดยใช้ TryOnState enum เป็นเครื่องสถานะ ดังนี้

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


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

การเปลี่ยนสถานะส่วนตัวช่วยให้มั่นใจได้ถึงความสอดคล้องกัน คุณจะไม่สามารถอัปเดตสถานะโดยไม่ล้างข้อมูลที่ไม่มีอัปเดตและแจ้ง UI ได้

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

วิธีการสร้างหลักจะเชื่อมโยงทุกอย่างเข้าด้วยกัน ดังนี้

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

UI: หน้าจอเป็นเราเตอร์สถานะ

ไฟล์:

workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart

หน้าจอการลองเสมือนใช้การจับคู่รูปแบบของ Dart 3 กับ AnimatedSwitcher เพื่อกำหนดเส้นทางระหว่างหน้าจอย่อยตามสถานะของผู้ให้บริการ

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() สมัครใช้บริการผู้ให้บริการ เมื่อใดก็ตามที่มีการเรียกใช้ notifyListeners() วิดเจ็ตนี้จะสร้างใหม่ และ AnimatedSwitcher จะเปลี่ยนผ่านระหว่างหน้าจอได้อย่างราบรื่น ไม่มี Navigator.push เนื่องจากเนื้อหาบนหน้าจอจะเปลี่ยนตามการแจงนับสถานะ

การส่งต่อแบบเป็น Agent: ห้องลองเสื้อผ้า → สไตลิสต์

รูปแบบ UX ที่น่าสนใจที่สุดคือวิธีที่แอปส่งบริบทจากตัวแทนห้องลองเสื้อไปยังตัวแทนสไตลิสต์

ใน 5_fitting_room.dart หลังจากสร้างรูปภาพลองเสร็จแล้ว ปุ่ม "แต่งสไตล์ให้ฉัน" จะเปิดแบบฟอร์ม เมื่อผู้ใช้ส่ง

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

StyleRequest ชุดอุปกรณ์มีทุกอย่างที่สไตลิสต์ต้องการ ดังนี้

  • สถานที่และโอกาส - บริบทข้อความสำหรับการจัดรูปแบบ
  • URL รูปภาพผู้ใช้ GCS เพื่อให้สไตลิสต์ใช้การแสดงผู้ใช้แบบเดียวกันซ้ำได้
  • ผลิตภัณฑ์ที่เลือก - สไตลิสต์จึงใส่ผลิตภัณฑ์นี้ในทุกชุด

นี่คือการส่งต่อโดย AI Agent ซึ่งเป็นการโอนบริบทแบบหลายรูปแบบจาก AI Agent หนึ่งไปยังอีก AI Agent หนึ่งอย่างราบรื่น โดยผู้ใช้จะเห็นเพียงแบบฟอร์มที่เรียบง่าย

ขั้นตอนการจัดรูปแบบ: StylingProvider

ไฟล์:

workshop_tasks/step_2_style_me/providers/styling_provider.dart

StylingProvider ง่ายกว่า TryItOnProvider เนื่องจากมอบความซับซ้อนส่วนใหญ่ให้กับแบ็กเอนด์

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

เมธอด refineWithFeedback จะส่งข้อความธรรมดาไปยังเซสชันเดียวกัน ซึ่งแฮนเดิล InjectPreviousProducts และแฮนเดิล ExtractAndInjectUserImage ของแบ็กเอนด์จะจัดการบริบททั้งหมดโดยอัตโนมัติ

8. 🚀 เรียกใช้แอปในเครื่อง

แบ็กเอนด์ Go จะแสดงเว็บแอป Flutter ที่คอมไพล์แล้วจากพอร์ตเดียวกัน (8080) เพื่อให้ Cloud Shell ทำงานได้อย่างราบรื่น 1 กระบวนการ, 1 URL แสดงตัวอย่าง, ไม่ต้องกังวลเรื่องการข้ามต้นทาง, ไม่ต้องแก้ไขไฟล์การกำหนดค่า

ก่อนเริ่มต้น - ตรวจสอบความสมเหตุสมผลของ ADC

แบ็กเอนด์ต้องมีข้อมูลรับรองเริ่มต้นของแอปพลิเคชันเพื่อเรียกใช้ Vertex AI หากทำขั้นตอนที่ 7 ของการตั้งค่าโปรเจ็กต์ในเซสชัน Cloud Shell นี้และบัญชี Google นี้เสร็จแล้ว ก็ถือว่าเรียบร้อย หากคุณกลับมาหลังจากหยุดพัก เปลี่ยนบัญชี หรือไม่แน่ใจ ให้ใช้เวลา 5 วินาทีเพื่อยืนยันโดยทำดังนี้

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

หากพิมพ์โทเค็นออกมาได้ประมาณ 20 อักขระ ก็ถือว่าเรียบร้อย หากเกิดข้อผิดพลาด ให้เรียกใช้ขั้นตอนที่ 7 ของการตั้งค่าโปรเจ็กต์อีกครั้ง

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

คุณจะใช้เทอร์มินัล Cloud Shell 2 รายการ

  • เทอร์มินัล A — เรียกใช้แบ็กเอนด์อย่างต่อเนื่อง (./run.sh) เปิดไว้
  • เทอร์มินัล B - เรียกใช้การสร้างเว็บ Flutter 1 ครั้ง (flutter build web) ออกเมื่อเสร็จสิ้น

คุณจะเริ่มจากข้อใดก่อนก็ได้ แต่เพื่อประสบการณ์การใช้งานครั้งแรกที่ราบรื่นที่สุด ให้สร้าง Flutter ก่อนเพื่อให้แบ็กเอนด์มี UI ที่จะแสดงตั้งแต่เริ่ม

1. เทอร์มินัล B - สร้าง Flutter Web Bundle (ครั้งเดียว)

เปิดแท็บ Cloud Shell ใหม่ (+ ที่ด้านบนของแผงเทอร์มินัล) จากนั้นทำดังนี้

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

ซึ่งจะสร้าง flutter_frontend/build/web/ ซึ่งเป็นไดเรกทอรีของไฟล์แบบคงที่ (HTML, JS, ชิ้นงาน) และจะออกเมื่อเสร็จสิ้น แบ็กเอนด์จะแสดงข้อมูลเหล่านี้ทันทีที่เห็นว่ามีไดเรกทอรีอยู่

2. เทอร์มินัล A - เริ่มแบ็กเอนด์ (ทำงานเป็นเวลานาน)

ในเทอร์มินัล Cloud Shell เดิม ให้ทำดังนี้

cd ~/fashion_app_demo/adk_backend
./run.sh

คุณควรเห็นข้อความต่อไปนี้

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

เปิดเทอร์มินัลนี้ไว้ - แบ็กเอนด์จะทำงานตราบใดที่ run.sh ยังทำงานอยู่ หากต้องการหยุด ให้กด Ctrl+C

เซิร์ฟเวอร์จะแสดงทุกอย่างในพอร์ต 8080 ดังนี้

  • / — เว็บแอป Flutter (UI การช็อปปิ้ง)
  • /api/ — อุปกรณ์ปลายทาง REST ของ ADK (เรียกใช้โดยแอป Flutter)
  • UI สำหรับนักพัฒนา ADK — อยู่ที่ / ด้วยเมื่อไม่มีบิลด์ Flutter ซึ่งมีประโยชน์สำหรับการแก้ไขข้อบกพร่องของเอเจนต์โดยตรง

3. เปิดตัวอย่างเว็บ

  1. ใน Cloud Shell ให้คลิกไอคอนตัวอย่างเว็บ (ด้านขวาบน) → แสดงตัวอย่างบนพอร์ต 8080
  2. แอปช็อปปิ้ง Flutter จะโหลดในแท็บใหม่
  3. เลือกดูแคตตาล็อกผลิตภัณฑ์และเลือกสินค้า
  4. แตะไอคอนบุคคล (👤) เพื่อเริ่มขั้นตอนการลองเสมือนจริง
  5. อัปโหลดรูปภาพแล้วดู AI สร้างรูปภาพการลอง
  6. แตะ "แต่งตัวให้ฉัน" เพื่อรับคำแนะนำชุด
  7. พิมพ์ความคิดเห็นเพิ่มเติม เช่น "ทำให้ดูสบายๆ มากขึ้น" ซึ่งเป็นการปรับแต่งในเซสชันเดียวกัน

9. ☁️ ทำให้ใช้งานได้กับ Cloud Run

รวม Flutter Build ไว้ในแบ็กเอนด์

คอนเทนเนอร์ Cloud Run จะจัดส่งทั้ง API และ UI จากอิมเมจเดียว คัดลอกบิลด์เว็บ Flutter ไปยัง adk_backend/flutter_web/ ซึ่งเป็นเส้นทางแรกที่เซิร์ฟเวอร์ Go ตรวจสอบเมื่อเลือก UI ที่จะแสดง

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

(หากคุณทำซ้ำในเครื่อง คุณอาจมี build/web จากขั้นตอนการเรียกใช้ในเครื่องอยู่แล้ว การเรียกใช้ flutter build web อีกครั้งก็ไม่เป็นไร)

ติดตั้งใช้งานแบ็กเอนด์ (ให้บริการ API + UI)

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

เมื่อการติดตั้งใช้งานเสร็จสมบูรณ์ คุณจะได้รับURL ของบริการ เช่น https://fashion-app-backend-xyz-uc.a.run.app เปิดในเบราว์เซอร์ - แอปช็อปปิ้ง Flutter จะโหลดจาก / และการเรียก API จะไปที่ /api/ ในโฮสต์เดียวกัน ไม่ต้องแก้ไขการกำหนดค่าส่วนหน้า ไม่มีการส่งคีย์ API

ยืนยันการติดตั้งใช้งาน

เปิด URL ของ Cloud Run ในเบราว์เซอร์และเรียกใช้โฟลว์ทั้งหมด

  1. เรียกดู → เลือกผลิตภัณฑ์
  2. ฟีเจอร์ลองใส่ → อัปโหลดรูปภาพ → ดูรูปภาพที่ AI สร้างขึ้น
  3. สไตล์ให้ฉัน → กรอกสถานที่/โอกาส → ดูชุดที่คัดสรรมา
  4. ความคิดเห็น → พิมพ์ "ทำให้ดูสบายๆ ขึ้น" → ดูชุดที่อัปเดตแล้ว
  5. เพิ่มลงในกระเป๋า → ทำขั้นตอนการช็อปปิ้งให้เสร็จสมบูรณ์

10. 🎉 บทสรุป

สิ่งที่คุณสร้าง

คุณได้สำรวจประสบการณ์การค้าปลีกที่ทำงานด้วยระบบ AI อย่างเต็มรูปแบบด้วยฟีเจอร์ต่อไปนี้

  • แบ็กเอนด์แบบ Multi-Agent ที่มี Agent เฉพาะทาง 4 รายทำงานร่วมกัน
  • ห้องลองเสมือนจริงที่สร้างรูปภาพการลองเสมือนจริงที่ปรับเปลี่ยนในแบบของคุณ
  • สไตลิสต์ AI ที่คัดสรรชุดและปรับแต่งผ่านการสนทนา
  • แอป Flutter แบบข้ามแพลตฟอร์มที่เชื่อมต่อกับแบ็กเอนด์ของเอเจนต์
  • การทำให้ใช้งานได้ใน Cloud Run สำหรับโฮสติ้งแบบ Serverless ที่ปรับขนาดได้

หัวข้อสำคัญ

แนวคิด

ที่ที่คุณเห็น

การจัดการเป็นกลุ่ม Multi-Agent ของ ADK

การกำหนดเส้นทางของตัวแทนรูทไปยังตัวแทนห้องลองเสื้อผ้า แคตตาล็อก และสไตลิสต์

การสร้างรูปภาพแบบมัลติโมดัลของ Gemini

fitting_tool การรวมรูปภาพของผู้ใช้กับรูปภาพผลิตภัณฑ์

สถานะเซสชันสำหรับ AI แบบสนทนา

สไตลิสต์ใช้เซสชันซ้ำเพื่อรับความคิดเห็นแบบวนซ้ำ

ที่เก็บอาร์ติแฟกต์สำหรับข้อมูลไบนารี

แยกที่เก็บรูปภาพออกจากคำตอบที่เป็นข้อความ

การเรียกกลับสำหรับตรรกะของมิดเดิลแวร์

SaveIncomingBlobs, InjectPreviousProducts, SaveSelectedProducts

MVVM + Provider ใน Flutter

TryItOnProvider และ StylingProvider ด้วย ChangeNotifier

การส่งต่อแบบ Agent

StyleRequest การส่งบริบทแบบมัลติโมดอลระหว่างเอเจนต์

ขั้นตอนถัดไป

  • 🎨 ปรับแต่งพรอมต์ของเอเจนต์ - แก้ไข instructions.md เพื่อเปลี่ยนบุคลิกของสไตลิสต์
  • 🛍️ เพิ่มผลิตภัณฑ์อื่นๆ - อัปเดต catalog.yaml ด้วยสินค้าใหม่
  • 📱 สร้างให้เหมาะกับอุปกรณ์เคลื่อนที่ - เรียกใช้ flutter build ios หรือ flutter build apk
  • 🔄 เพิ่มเซสชันถาวร - แทนที่ InMemoryService ด้วยการติดตั้งใช้งานที่ใช้ฐานข้อมูล
  • 🔒 เพิ่มการตรวจสอบสิทธิ์ - รักษาความปลอดภัยให้กับปลายทาง Cloud Run ด้วย IAM

แหล่งข้อมูล