1. บทนำ
สิ่งที่คุณจะสร้าง
ใน Codelab นี้ คุณจะได้สวมบทบาทเป็นนักพัฒนาแอปที่สร้างแอปแฟชั่น ซึ่งเป็นแอปช็อปปิ้ง Flutter สำหรับแบรนด์ค้าปลีกสมมติ ภารกิจของคุณคือการเพิ่มฟีเจอร์ 2 รายการที่ทำงานด้วยระบบ AI ซึ่งจะเปลี่ยนประสบการณ์การช็อปปิ้งออนไลน์
- ห้องลองเสมือนจริง - ผู้ใช้อัปโหลดรูปภาพของตนเอง เลือกเสื้อผ้า และดูรูปภาพที่ AI สร้างขึ้นซึ่งแสดงให้เห็นว่าผู้ใช้สวมเสื้อผ้าชิ้นนั้น
- สไตลิสต์ 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
2. ลิงก์บัญชีสำหรับการเรียกเก็บเงิน
แสดงรายการบัญชีสำหรับการเรียกเก็บเงิน
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 | วัตถุประสงค์ |
| Vertex AI — |
| Cloud Storage - จัดเก็บรูปภาพแคตตาล็อกผลิตภัณฑ์และผลลัพธ์การลองเสมือนจริงที่สร้างขึ้น |
| Cloud Run - โฮสต์แบ็กเอนด์เป็นคอนเทนเนอร์แบบ Serverless |
| Cloud Build - สร้างอิมเมจ Docker จากแหล่งที่มา |
| 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 |
| ตำรวจจราจร อ่านข้อความของผู้ใช้และส่งต่อให้ตัวแทนผู้เชี่ยวชาญที่เหมาะสม ใช้โมเดลที่รวดเร็วและมีน้ำหนักเบาเนื่องจากต้องตัดสินใจเกี่ยวกับการกำหนดเส้นทางเท่านั้น |
Catalog Agent |
| Product Expert โหลดแคตตาล็อกผลิตภัณฑ์จากไฟล์ YAML และตอบคำค้นหาผลิตภัณฑ์ นอกจากนี้ยังมีขนาดเล็กเนื่องจากเป็นเพียงการค้นหาข้อมูล |
ตัวแทนห้องลองเสมือนจริง |
| ผู้เชี่ยวชาญด้านระบบการลองเสมือนจริง นำรูปภาพผู้ใช้ + รูปภาพสินค้ามาสร้างเป็นรูปภาพคอมโพสิตของบุคคลที่สวมใส่ไอเทมนั้น ใช้โมเดลที่มีความสามารถมากกว่าเนื่องจากต้องใช้เหตุผลเกี่ยวกับรูปภาพ |
Stylist Agent |
| ที่ปรึกษาด้านแฟชั่น โดยระบบจะคัดสรรชุด 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
ลูปนี้สามารถทำซ้ำได้หลายครั้งภายในคำขอเดียว ตัวอย่างเช่น ตัวแทนสไตลิสต์อาจทำสิ่งต่อไปนี้
- รับคำตอบ "ช่วยจัดสไตล์ให้ฉันสำหรับวันหยุดพักผ่อนที่ชายหาดหน่อย"
- เรียกใช้เครื่องมือ
catalog_agentเพื่อรับรายการผลิตภัณฑ์ - เลือกชุด 3 ชุด
- เรียกใช้
fitting_toolสำหรับชุดแต่ละชุดเพื่อสร้างรูปภาพ - แสดงผลการตอบกลับ 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 อย่างดังนี้
listProducts— แสดงแคตตาล็อกผลิตภัณฑ์ทั้งหมดจากไฟล์ YAMLgetProductImage— โหลดรูปภาพสินค้าจาก 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. เปิดตัวอย่างเว็บ
- ใน Cloud Shell ให้คลิกไอคอนตัวอย่างเว็บ (ด้านขวาบน) → แสดงตัวอย่างบนพอร์ต 8080
- แอปช็อปปิ้ง Flutter จะโหลดในแท็บใหม่
- เลือกดูแคตตาล็อกผลิตภัณฑ์และเลือกสินค้า
- แตะไอคอนบุคคล (👤) เพื่อเริ่มขั้นตอนการลองเสมือนจริง
- อัปโหลดรูปภาพแล้วดู AI สร้างรูปภาพการลอง
- แตะ "แต่งตัวให้ฉัน" เพื่อรับคำแนะนำชุด
- พิมพ์ความคิดเห็นเพิ่มเติม เช่น "ทำให้ดูสบายๆ มากขึ้น" ซึ่งเป็นการปรับแต่งในเซสชันเดียวกัน
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 ในเบราว์เซอร์และเรียกใช้โฟลว์ทั้งหมด
- เรียกดู → เลือกผลิตภัณฑ์
- ฟีเจอร์ลองใส่ → อัปโหลดรูปภาพ → ดูรูปภาพที่ AI สร้างขึ้น
- สไตล์ให้ฉัน → กรอกสถานที่/โอกาส → ดูชุดที่คัดสรรมา
- ความคิดเห็น → พิมพ์ "ทำให้ดูสบายๆ ขึ้น" → ดูชุดที่อัปเดตแล้ว
- เพิ่มลงในกระเป๋า → ทำขั้นตอนการช็อปปิ้งให้เสร็จสมบูรณ์
10. 🎉 บทสรุป
สิ่งที่คุณสร้าง
คุณได้สำรวจประสบการณ์การค้าปลีกที่ทำงานด้วยระบบ AI อย่างเต็มรูปแบบด้วยฟีเจอร์ต่อไปนี้
- ✅ แบ็กเอนด์แบบ Multi-Agent ที่มี Agent เฉพาะทาง 4 รายทำงานร่วมกัน
- ✅ ห้องลองเสมือนจริงที่สร้างรูปภาพการลองเสมือนจริงที่ปรับเปลี่ยนในแบบของคุณ
- ✅ สไตลิสต์ AI ที่คัดสรรชุดและปรับแต่งผ่านการสนทนา
- ✅ แอป Flutter แบบข้ามแพลตฟอร์มที่เชื่อมต่อกับแบ็กเอนด์ของเอเจนต์
- ✅ การทำให้ใช้งานได้ใน Cloud Run สำหรับโฮสติ้งแบบ Serverless ที่ปรับขนาดได้
หัวข้อสำคัญ
แนวคิด | ที่ที่คุณเห็น |
การจัดการเป็นกลุ่ม Multi-Agent ของ ADK | การกำหนดเส้นทางของตัวแทนรูทไปยังตัวแทนห้องลองเสื้อผ้า แคตตาล็อก และสไตลิสต์ |
การสร้างรูปภาพแบบมัลติโมดัลของ Gemini |
|
สถานะเซสชันสำหรับ AI แบบสนทนา | สไตลิสต์ใช้เซสชันซ้ำเพื่อรับความคิดเห็นแบบวนซ้ำ |
ที่เก็บอาร์ติแฟกต์สำหรับข้อมูลไบนารี | แยกที่เก็บรูปภาพออกจากคำตอบที่เป็นข้อความ |
การเรียกกลับสำหรับตรรกะของมิดเดิลแวร์ |
|
MVVM + Provider ใน Flutter |
|
การส่งต่อแบบ Agent |
|
ขั้นตอนถัดไป
- 🎨 ปรับแต่งพรอมต์ของเอเจนต์ - แก้ไข
instructions.mdเพื่อเปลี่ยนบุคลิกของสไตลิสต์ - 🛍️ เพิ่มผลิตภัณฑ์อื่นๆ - อัปเดต
catalog.yamlด้วยสินค้าใหม่ - 📱 สร้างให้เหมาะกับอุปกรณ์เคลื่อนที่ - เรียกใช้
flutter build iosหรือflutter build apk - 🔄 เพิ่มเซสชันถาวร - แทนที่
InMemoryServiceด้วยการติดตั้งใช้งานที่ใช้ฐานข้อมูล - 🔒 เพิ่มการตรวจสอบสิทธิ์ - รักษาความปลอดภัยให้กับปลายทาง Cloud Run ด้วย IAM
แหล่งข้อมูล
- เอกสารประกอบ ADK - เอกสารประกอบอย่างเป็นทางการของ Agent Development Kit
- ซอร์สโค้ด ADK Go - ที่เก็บ GitHub
- เอกสารอ้างอิงแพ็กเกจ ADK Go - เอกสารอ้างอิง API
- เอกสารประกอบของ Gemini API - ความสามารถและคำแนะนำของโมเดล
- แพ็กเกจ Flutter Provider - เอกสารประกอบการจัดการสถานะ
- เอกสารประกอบ Cloud Run - คู่มือการติดตั้งใช้งานและการปรับขนาด