hazelhh commited on
Commit
1239bc0
·
verified ·
1 Parent(s): 4ea90a7

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +200 -494
main.py CHANGED
@@ -1,52 +1,50 @@
1
- from fastapi import FastAPI, Request, Header, BackgroundTasks, HTTPException, status
 
 
2
  from fastapi.middleware.cors import CORSMiddleware
 
 
 
 
3
  from linebot import LineBotApi, WebhookHandler
4
  from linebot.exceptions import InvalidSignatureError
5
- from linebot.models import MessageEvent, TextMessage, TextSendMessage, ImageMessage, ImageSendMessage
6
- import json
7
- import os
8
- import requests
9
- import base64
10
- from collections import defaultdict
 
 
11
  import uvicorn
12
- import logging
13
- from PIL import Image
14
- import io
15
- import requests.exceptions
16
 
17
- # 設置日誌記錄
18
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
 
 
 
19
 
20
- # 檢查環境變數
21
- if not all(k in os.environ for k in ["CHANNEL_ACCESS_TOKEN", "CHANNEL_SECRET", "GOOGLE_API_KEY", "IMGBB_API_KEY", "HF_SPACE"]):
22
- logging.error("Missing environment variables. Please set CHANNEL_ACCESS_TOKEN, CHANNEL_SECRET, GOOGLE_API_KEY, IMGBB_API_KEY, and HF_SPACE.")
23
- raise ValueError("Missing environment variables. Please set CHANNEL_ACCESS_TOKEN, CHANNEL_SECRET, GOOGLE_API_KEY, IMGBB_API_KEY, and HF_SPACE.")
24
 
25
- # Line Bot API 設定
 
 
 
 
26
  line_bot_api = LineBotApi(os.environ["CHANNEL_ACCESS_TOKEN"])
27
  line_handler = WebhookHandler(os.environ["CHANNEL_SECRET"])
28
 
29
- # 使用者狀態追蹤
30
- user_states = defaultdict(lambda: {
31
- "upper_body_images": [],
32
- "lower_body_images": [],
33
- "current_mode": None, # "upper" or "lower"
34
- "is_ready_for_outfit": False, # 標記衣物是否收集完畢
35
- "is_ready_for_photo": False, # 標記個人照片是否上傳
36
- "user_info": {}, # 儲存身高、三圍、場合
37
- "personal_photo": None, # 儲存個人照片
38
- "personal_photo_base64": None # 新增:儲存個人照片的 Base64 字串
39
- })
40
-
41
- MAX_IMAGES_PER_TYPE = 3
42
- GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent"
43
- IMAGIN_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent"
44
- GOOGLE_API_KEY = os.environ["GOOGLE_API_KEY"]
45
- IMGBB_API_KEY = os.environ["IMGBB_API_KEY"]
46
- HF_SPACE_URL = os.environ["HF_SPACE"]
47
 
 
48
  app = FastAPI()
 
49
 
 
50
  app.add_middleware(
51
  CORSMiddleware,
52
  allow_origins=["*"],
@@ -55,502 +53,210 @@ app.add_middleware(
55
  allow_headers=["*"],
56
  )
57
 
58
- @app.get("/")
59
- def root():
60
- return {"title": "Line Bot 穿搭建議"}
61
-
62
- @app.post("/webhook")
63
- async def webhook(
64
- request: Request,
65
- background_tasks: BackgroundTasks,
66
- x_line_signature=Header(None),
67
- ):
68
- body = await request.body()
69
- try:
70
- background_tasks.add_task(line_handler.handle, body.decode("utf-8"), x_line_signature)
71
- except InvalidSignatureError:
72
- raise HTTPException(status_code=400, detail="Invalid signature")
73
- return "ok"
74
-
75
- def get_base64_image(message_id: str):
76
- logging.info(f"Fetching image content for message ID: {message_id}")
77
- message_content = line_bot_api.get_message_content(message_id)
78
- image_bytes = message_content.content
79
- return base64.b64encode(image_bytes).decode('utf-8')
80
-
81
- def upload_to_imgbb(image_base64: str):
82
- """
83
- 將 Base64 編碼的圖片上傳到 Imgbb。
84
- """
85
- logging.info("Attempting to upload image to Imgbb.")
86
- url = "https://api.imgbb.com/1/upload"
87
- payload = {
88
- "key": IMGBB_API_KEY,
89
- "image": image_base64
90
- }
91
- try:
92
- response = requests.post(url, data=payload)
93
- response.raise_for_status()
94
- imgbb_link = response.json()["data"]["url"]
95
- logging.info(f"Image successfully uploaded to Imgbb: {imgbb_link}")
96
- return imgbb_link
97
- except requests.exceptions.RequestException as e:
98
- logging.error(f"Error uploading to Imgbb: {e}")
99
- if 'response' in locals() and response is not None:
100
- logging.error(f"Imgbb API Response: {response.status_code} - {response.text}")
101
- return None
102
-
103
- def get_base64_from_url(image_url: str):
104
  """
105
- URL 下載圖片並轉換為 Base64 編碼。
106
  """
107
  try:
108
- response = requests.get(image_url)
109
- response.raise_for_status()
110
- return base64.b64encode(response.content).decode('utf-8')
111
- except requests.exceptions.RequestException as e:
112
- logging.error(f"Error fetching image from URL: {image_url}, error: {e}")
 
 
 
 
113
  return None
114
 
115
- def get_gemini_response(prompt: str, images: list, api_url: str):
116
- logging.info("Sending request to Gemini API.")
117
- payload = {
118
- "contents": [
119
- {
120
- "parts": [
121
- {"text": prompt}
122
- ] + [
123
- {"inlineData": {"mimeType": "image/jpeg", "data": img}} for img in images
124
- ]
125
- }
126
- ]
127
- }
128
-
129
- try:
130
- response = requests.post(f"{api_url}?key={GOOGLE_API_KEY}", json=payload, timeout=120) # Increased timeout for image generation
131
- response.raise_for_status()
132
- response_data = response.json()
133
- if 'candidates' in response_data and response_data['candidates']:
134
- gemini_text = response_data['candidates'][0]['content']['parts'][0]['text']
135
- logging.info("Successfully received response from Gemini.")
136
- return gemini_text
137
- else:
138
- logging.error(f"Gemini API response has no candidates: {response.text}")
139
- return "Gemini API 暫時無法提供服務,請稍後再試。"
140
- except requests.exceptions.RequestException as e:
141
- logging.error(f"Gemini API request failed: {e}")
142
- return f"Gemini API 請求失敗:{e}"
143
-
144
- def get_virtual_tryon_image(user_photo_base64: str, upper_body_image_base64: str, lower_body_image_base64: str):
145
  """
146
- 使用 gemini-2.5-flash-image-preview (nanobanana) 模擬虛擬試穿。
147
  """
148
- logging.info("Attempting virtual try-on with gemini-2.5-flash-image-preview.")
149
-
150
- prompt = "請將提供的上衣和褲子,虛擬試穿到第一張人像照片上。結果必須看起來真實且自然,衣服的紋理和細節必須保留。"
151
-
152
- all_images_base64 = [user_photo_base64, upper_body_image_base64, lower_body_image_base64]
153
-
154
- payload = {
155
- "contents": [
156
- {
157
- "parts": [
158
- {"text": prompt}
159
- ] + [
160
- {"inlineData": {"mimeType": "image/jpeg", "data": img_base64}} for img_base64 in all_images_base64
161
- ]
162
- }
163
- ],
164
- "generationConfig": {
165
- "responseModalities": ['IMAGE']
166
- }
167
- }
168
-
169
- try:
170
- response = requests.post(f"{IMAGIN_API_URL}?key={GOOGLE_API_KEY}", json=payload, timeout=120)
171
- response.raise_for_status()
172
- response_data = response.json()
173
- if 'candidates' in response_data and response_data['candidates']:
174
- # 從回應中提取 Base64 編碼的圖片
175
- image_part = response_data['candidates'][0]['content']['parts'][0]
176
- if image_part and 'inlineData' in image_part:
177
- generated_image_base64 = image_part['inlineData']['data']
178
- # 將生成的 Base64 圖片上傳到 Imgbb
179
- virtual_tryon_url = upload_to_imgbb(generated_image_base64)
180
- if virtual_tryon_url:
181
- logging.info(f"Successfully generated and uploaded virtual try-on image: {virtual_tryon_url}")
182
- return virtual_tryon_url
183
- else:
184
- logging.error("Failed to upload generated image to Imgbb.")
185
- return None
186
- else:
187
- logging.error("Gemini image response format is invalid.")
188
- return None
189
- else:
190
- logging.error(f"Gemini image generation response has no candidates: {response.text}")
191
- return None
192
- except requests.exceptions.RequestException as e:
193
- logging.error(f"Gemini image generation API request failed: {e}")
194
- return None
195
 
196
- def generate_outfit_image(upper_body_images: list, lower_body_images: list):
197
  """
198
- 使用 gemini-2.5-flash-image-preview 生成一套全新的穿搭圖片。
199
  """
200
- logging.info("Attempting to generate a new outfit image.")
201
-
202
- prompt = "使用提供的上���和褲子/裙子圖片,生成一套完整且時尚的穿搭圖片。請將衣服呈現在一個有模特兒穿著或是在平面上呈現的完整畫面中。風格應與提供的衣物相符。"
203
-
204
- all_images_base64 = (
205
- [get_base64_from_url(url) for url in upper_body_images] +
206
- [get_base64_from_url(url) for url in lower_body_images]
207
- )
208
 
209
- payload = {
210
- "contents": [
211
- {
212
- "parts": [
213
- {"text": prompt}
214
- ] + [
215
- {"inlineData": {"mimeType": "image/jpeg", "data": img_base64}} for img_base64 in all_images_base64
216
- ]
217
- }
218
- ],
219
- "generationConfig": {
220
- "responseModalities": ['IMAGE']
221
- }
222
- }
223
 
224
- try:
225
- response = requests.post(f"{IMAGIN_API_URL}?key={GOOGLE_API_KEY}", json=payload, timeout=120)
226
- response.raise_for_status()
227
- response_data = response.json()
228
- if 'candidates' in response_data and response_data['candidates']:
229
- image_part = response_data['candidates'][0]['content']['parts'][0]
230
- if image_part and 'inlineData' in image_part:
231
- generated_image_base64 = image_part['inlineData']['data']
232
- generated_image_url = upload_to_imgbb(generated_image_base64)
233
- if generated_image_url:
234
- logging.info(f"Successfully generated and uploaded new outfit image: {generated_image_url}")
235
- return generated_image_url
236
- else:
237
- logging.error("Failed to upload generated outfit image to Imgbb.")
238
- return None
239
- else:
240
- logging.error("Gemini image response format is invalid for outfit generation.")
241
- return None
242
- else:
243
- logging.error(f"Gemini outfit generation response has no candidates: {response.text}")
244
- return None
245
- except requests.exceptions.RequestException as e:
246
- logging.error(f"Gemini outfit generation API request failed: {e}")
247
- return None
248
 
249
- def save_image_locally(image_binary: bytes):
 
250
  """
251
- 將二進位圖片資料儲存到本地,並返回一個可供外部存取的 URL。
 
 
 
 
 
 
252
  """
253
  try:
254
- # 確保 'static' 資料夾存在
255
- if not os.path.exists("static"):
256
- os.makedirs("static")
 
 
257
 
258
- image = Image.open(io.BytesIO(image_binary))
259
- # 隨機生成一個檔案名以避免衝突
260
- file_name = f"static/{os.urandom(16).hex()}.png"
261
- image.save(file_name, format="PNG")
 
262
 
263
- image_url = os.path.join(HF_SPACE_URL, file_name)
264
- logging.info(f"Image successfully saved locally: {image_url}")
265
- return image_url
 
 
 
 
 
 
 
266
  except Exception as e:
267
- logging.error(f"Error saving image locally: {e}")
268
- return None
269
-
270
-
271
- @line_handler.add(MessageEvent, message=TextMessage)
272
- def handle_text_message(event):
273
- user_id = event.source.user_id
274
- text = event.message.text.lower()
275
- reply_token = event.reply_token
276
-
277
- logging.info(f"Received text message from user {user_id}: '{text}'")
278
 
279
- # 新增 重置 功能
280
- if text in ["重置", "重來", "重新開始", "再一次"]:
281
- user_states[user_id] = defaultdict(lambda: {"upper_body_images": [], "lower_body_images": [], "current_mode": None, "is_ready_for_outfit": False, "is_ready_for_photo": False, "user_info": {}, "personal_photo": None, "personal_photo_base64": None})[user_id]
282
- line_bot_api.reply_message(
283
- reply_token,
284
- TextSendMessage(text="狀態已重置。請重新輸入個人資訊,格式為:身高,胸圍,腰圍,臀圍,場合。例如:165,85,65,90,約會")
285
- )
286
- return
287
 
288
- # 新增 圖片生成穿搭功能
289
- if text in ["生成穿搭", "圖片推薦"] and user_states[user_id]["is_ready_for_outfit"]:
290
- if len(user_states[user_id]["upper_body_images"]) == MAX_IMAGES_PER_TYPE and len(user_states[user_id]["lower_body_images"]) == MAX_IMAGES_PER_TYPE:
291
- line_bot_api.reply_message(
292
- reply_token,
293
- TextSendMessage(text="好的,正在為您生成一套新的穿搭圖片,請稍候...")
294
- )
295
-
296
- generated_outfit_url = generate_outfit_image(
297
- user_states[user_id]["upper_body_images"],
298
- user_states[user_id]["lower_body_images"]
299
- )
300
-
301
- if generated_outfit_url:
302
- line_bot_api.push_message(
303
- user_id,
304
- ImageSendMessage(original_content_url=generated_outfit_url, preview_image_url=generated_outfit_url)
305
- )
306
- line_bot_api.push_message(
307
- user_id,
308
- TextSendMessage(text="這是根據您的衣物生成的圖片推薦。如果想再次使用,請輸入「重置」。")
309
- )
310
- else:
311
- line_bot_api.push_message(
312
- user_id,
313
- TextSendMessage(text="圖片生成失敗,請稍後再試。")
314
- )
315
 
316
- # 生成後重置狀態
317
- user_states[user_id] = defaultdict(lambda: {"upper_body_images": [], "lower_body_images": [], "current_mode": None, "is_ready_for_outfit": False, "is_ready_for_photo": False, "user_info": {}, "personal_photo": None, "personal_photo_base64": None})[user_id]
318
- return
 
 
 
 
319
  else:
320
- line_bot_api.reply_message(
321
- reply_token,
322
- TextSendMessage(text="請先上傳三件上衣和三件褲子/裙子圖片。")
323
- )
324
- return
325
-
326
- # 處理個人資訊輸入
327
- if not user_states[user_id]["is_ready_for_outfit"] and not user_states[user_id]["is_ready_for_photo"]:
328
- try:
329
- parts = text.split(',')
330
- if len(parts) == 5:
331
- height = float(parts[0])
332
- bust = float(parts[1])
333
- waist = float(parts[2])
334
- hip = float(parts[3])
335
- occasion = parts[4].strip()
336
-
337
- user_states[user_id]["user_info"] = {
338
- "height": height,
339
- "bust": bust,
340
- "waist": waist,
341
- "hip": hip,
342
- "occasion": occasion
343
- }
344
- user_states[user_id]["is_ready_for_outfit"] = True
345
-
346
- line_bot_api.reply_message(
347
- reply_token,
348
- TextSendMessage(text=f"已收到您的資訊!接下來請上傳三件上衣和三件褲子的圖片。請先輸入「上衣」或「褲子」來開始。")
349
- )
350
- return
351
- except ValueError:
352
- logging.error(f"Invalid user info format: '{text}'")
353
- line_bot_api.reply_message(
354
- reply_token,
355
- TextSendMessage(text="請依照格式輸入:身高,胸圍,腰圍,臀圍,場合。例如:165,85,65,90,約會")
356
- )
357
- return
358
-
359
- # 處理衣物上傳模式
360
- if user_states[user_id]["is_ready_for_outfit"]:
361
- if text == "上衣":
362
- user_states[user_id]["current_mode"] = "upper"
363
- line_bot_api.reply_message(
364
- reply_token,
365
- TextSendMessage(text=f"請上傳三件上衣圖片,您已上傳 {len(user_states[user_id]['upper_body_images'])}/{MAX_IMAGES_PER_TYPE} 張。")
366
- )
367
- return
368
- elif text in ["褲子", "裙子"]:
369
- user_states[user_id]["current_mode"] = "lower"
370
- line_bot_api.reply_message(
371
- reply_token,
372
- TextSendMessage(text=f"請上傳三件褲子/裙子圖片,您已上傳 {len(user_states[user_id]['lower_body_images'])}/{MAX_IMAGES_PER_TYPE} 張。")
373
- )
374
- return
375
 
376
- # 如果沒有進入任何模式,給予提示
377
- line_bot_api.reply_message(
378
- reply_token,
379
- TextSendMessage(text="請先依照格式輸入個人資訊,格式為:身高,胸圍,腰圍,臀圍,場合。例如:165,85,65,90,約會")
380
- )
381
 
382
 
383
- @line_handler.add(MessageEvent, message=ImageMessage)
384
- def handle_image_message(event):
385
- user_id = event.source.user_id
386
- reply_token = event.reply_token
387
-
388
- logging.info(f"Received image message from user {user_id}")
389
 
390
- # 檢查是否已準備好處理圖片
391
- if not user_states[user_id]["is_ready_for_outfit"]:
392
- logging.warning("User is not in outfit collection mode.")
393
- line_bot_api.reply_message(
394
- reply_token,
395
- TextSendMessage(text="請先輸入個人資訊,格式為:身高,胸圍,腰圍,臀圍,場合。例如:165,85,65,90,約會")
396
- )
397
- return
398
 
399
- # 檢查是否所有衣物都已上傳
400
- is_outfit_complete = (
401
- len(user_states[user_id]["upper_body_images"]) == MAX_IMAGES_PER_TYPE and
402
- len(user_states[user_id]["lower_body_images"]) == MAX_IMAGES_PER_TYPE
403
- )
404
 
405
- # 處理個人照片上傳
406
- if is_outfit_complete and not user_states[user_id]["personal_photo"]:
407
- try:
408
- image_id = event.message.id
409
- # 取得圖片的二進位資料
410
- image_content = line_bot_api.get_message_content(image_id).content
411
-
412
- # 使用新函式來儲存圖片並取得 URL
413
- photo_url = save_image_locally(image_content)
414
- if not photo_url:
415
- raise Exception("Failed to save personal photo locally.")
416
 
417
- user_states[user_id]["personal_photo"] = photo_url
418
- user_states[user_id]["is_ready_for_photo"] = True
419
-
420
- line_bot_api.reply_message(
421
- reply_token,
422
- TextSendMessage(text="已收到您的個人照片,正在為您準備穿搭... 請稍候。")
423
- )
424
-
425
- # 準備 Gemini 提示
426
- user_info = user_states[user_id]["user_info"]
427
- occasion = user_info["occasion"]
428
-
429
- prompt = (
430
- f"我提供了三件上衣圖片和三件下半身圖片,以及使用者的一張個人照片。使用者資訊:身高 {user_info['height']}cm,三圍 {user_info['bust']}-{user_info['waist']}-{user_info['hip']},場合是「{occasion}」。"
431
- "請根據這些衣物,為我推薦一套最適合的穿搭,並詳細說明為何這套搭配適合這個場合。請以繁體中文回答。"
432
- "請在描述中以圖片連結的形式(例如:'https://example.com/outfit1.jpg')顯示你搭配好的衣物組合。"
433
- )
434
-
435
- # 取得圖片的 Base64 編碼,用於 Gemini API 呼叫
436
- all_images_base64 = (
437
- [get_base64_from_url(url) for url in user_states[user_id]["upper_body_images"]] +
438
- [get_base64_from_url(url) for url in user_states[user_id]["lower_body_images"]] +
439
- [get_base64_from_url(photo_url)]
440
- )
441
-
442
- response_text = get_gemini_response(prompt, all_images_base64, GEMINI_API_URL)
443
-
444
- # 從 Gemini 建議中找出最佳搭配的衣物圖片(這裡需要更複雜的邏輯,我們使用佔位符)
445
- # 在實際應用中,您可以設計一個提示,讓 Gemini 返回最佳搭配的圖片索引
446
- best_upper_index = 0
447
- best_lower_index = 0
448
-
449
- best_upper_body_image = user_states[user_id]["upper_body_images"][best_upper_index]
450
- best_lower_body_image = user_states[user_id]["lower_body_images"][best_lower_index]
451
 
452
- # 呼叫虛擬試穿 API,並取得結果圖片 URL
453
- virtual_tryon_url = get_virtual_tryon_image(get_base64_from_url(photo_url), get_base64_from_url(best_upper_body_image), get_base64_from_url(best_lower_body_image))
454
-
455
- # 發送 Gemini 的文字建議
456
- line_bot_api.push_message(
457
- user_id,
458
- TextSendMessage(text=f"這是為您推薦的搭配:\n\n{response_text}")
459
- )
460
-
461
- # 發送虛擬試穿照片
462
- if virtual_tryon_url:
463
- line_bot_api.push_message(
464
- user_id,
465
- ImageSendMessage(original_content_url=virtual_tryon_url, preview_image_url=virtual_tryon_url)
466
- )
467
 
468
- # 重置狀態以便下一次使用
469
- user_states[user_id] = defaultdict(lambda: {"upper_body_images": [], "lower_body_images": [], "current_mode": None, "is_ready_for_outfit": False, "is_ready_for_photo": False, "user_info": {}, "personal_photo": None, "personal_photo_base64": None})[user_id]
470
- return
471
-
472
- except Exception as e:
473
- logging.error(f"Error processing personal photo: {e}")
474
- line_bot_api.reply_message(
475
- reply_token,
476
- TextSendMessage(text=f"圖片處理失敗,請稍後再試。錯誤:{e}")
477
- )
478
- return
479
 
480
- # 處理衣物圖片上傳
481
- mode = user_states[user_id]["current_mode"]
482
- if not mode:
483
- logging.warning("User is not in a defined upload mode.")
484
- line_bot_api.reply_message(
485
- reply_token,
486
- TextSendMessage(text="請先傳送「上衣」或「褲子」來選擇要上傳的衣服類型。")
 
 
 
487
  )
488
- return
 
 
489
 
490
- try:
491
- image_id = event.message.id
492
- image_content = line_bot_api.get_message_content(image_id).content
493
-
494
- # 使用新函式來儲存圖片並取得 URL
495
- image_url = save_image_locally(image_content)
496
- if not image_url:
497
- logging.error("Failed to save image locally.")
 
498
  line_bot_api.reply_message(
499
- reply_token,
500
- TextSendMessage(text="圖片上傳失敗,請稍後再試。")
501
  )
502
- return
503
-
504
- if mode == "upper":
505
- if len(user_states[user_id]["upper_body_images"]) < MAX_IMAGES_PER_TYPE:
506
- user_states[user_id]["upper_body_images"].append(image_url)
507
- reply_text = f"已接收上衣。上衣: {len(user_states[user_id]['upper_body_images'])}/{MAX_IMAGES_PER_TYPE},褲子/裙子: {len(user_states[user_id]['lower_body_images'])}/{MAX_IMAGES_PER_TYPE}。"
508
- if len(user_states[user_id]["upper_body_images"]) == MAX_IMAGES_PER_TYPE:
509
- reply_text += "上衣已收集完畢。"
510
- line_bot_api.reply_message(
511
- reply_token,
512
- TextSendMessage(text=reply_text)
513
- )
514
- return
515
- else:
516
- logging.info("Upper body image count is full.")
517
- line_bot_api.reply_message(
518
- reply_token,
519
- TextSendMessage(text="上衣數量已滿,請傳送「褲子」來上傳褲子圖片。")
520
- )
521
- return
522
- else: # mode == "lower"
523
- if len(user_states[user_id]["lower_body_images"]) < MAX_IMAGES_PER_TYPE:
524
- user_states[user_id]["lower_body_images"].append(image_url)
525
- reply_text = f"已接收褲子/裙子。上衣: {len(user_states[user_id]['upper_body_images'])}/{MAX_IMAGES_PER_TYPE},褲子/裙子: {len(user_states[user_id]['lower_body_images'])}/{MAX_IMAGES_PER_TYPE}。"
526
- if len(user_states[user_id]["lower_body_images"]) == MAX_IMAGES_PER_TYPE:
527
- reply_text += "褲子/裙子已收集完畢。"
528
- line_bot_api.reply_message(
529
- reply_token,
530
- TextSendMessage(text=reply_text)
 
 
 
 
 
531
  )
532
- # 檢查是否所有圖片都已上傳完畢
533
- if (len(user_states[user_id]["upper_body_images"]) == MAX_IMAGES_PER_TYPE and
534
- len(user_states[user_id]["lower_body_images"]) == MAX_IMAGES_PER_TYPE):
535
- line_bot_api.push_message(
536
- user_id,
537
- TextSendMessage(text="所有衣物圖片已收集完畢!\n\n現在您可以上傳**個人照片**進行虛擬試穿,或輸入「**生成穿搭**」來獲得圖片推薦。")
538
- )
539
- return
540
  else:
541
- logging.info("Lower body image count is full.")
542
- line_bot_api.reply_message(
543
- reply_token,
544
- TextSendMessage(text="褲子數量已滿,請傳送「上衣」來上傳上衣圖片。")
545
- )
546
- return
547
-
548
- except Exception as e:
549
- logging.error(f"Error processing outfit image: {e}")
550
- line_bot_api.reply_message(
551
- reply_token,
552
- TextSendMessage(text=f"圖片處理失敗,請稍後再試。錯誤:{e}")
553
- )
554
 
555
  if __name__ == "__main__":
556
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
 
1
+ import os
2
+ import io
3
+ from collections import defaultdict
4
  from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi import FastAPI, Request, Header, BackgroundTasks, HTTPException
6
+ from fastapi.staticfiles import StaticFiles
7
+ from google import genai
8
+ from google.genai import types
9
  from linebot import LineBotApi, WebhookHandler
10
  from linebot.exceptions import InvalidSignatureError
11
+ from linebot.models import (
12
+ MessageEvent,
13
+ TextMessage,
14
+ TextSendMessage,
15
+ ImageSendMessage,
16
+ ImageMessage,
17
+ )
18
+ import PIL.Image
19
  import uvicorn
 
 
 
 
20
 
21
+ # LangChain 相關匯入
22
+ from langchain_core.prompts import ChatPromptTemplate
23
+ from langchain_core.tools import tool
24
+ from langchain_google_genai import ChatGoogleGenerativeAI
25
+ from langchain.agents import AgentExecutor, create_tool_calling_agent
26
+
27
 
28
+ # ==========================
29
+ # 環境設定與工具函式
30
+ # ==========================
 
31
 
32
+ # 設置 Google AI API 金鑰
33
+ google_api = os.environ["GOOGLE_API_KEY"]
34
+ genai_client = genai.Client(api_key=google_api)
35
+
36
+ # 設置 Line Bot 的 API 金鑰和秘密金鑰
37
  line_bot_api = LineBotApi(os.environ["CHANNEL_ACCESS_TOKEN"])
38
  line_handler = WebhookHandler(os.environ["CHANNEL_SECRET"])
39
 
40
+ # 使用字典模擬用戶訊息歷史存儲
41
+ user_message_history = defaultdict(list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
+ # 建立 FastAPI 應用程式
44
  app = FastAPI()
45
+ app.mount("/static", StaticFiles(directory="static"), name="static")
46
 
47
+ # 設定 CORS
48
  app.add_middleware(
49
  CORSMiddleware,
50
  allow_origins=["*"],
 
53
  allow_headers=["*"],
54
  )
55
 
56
+ def get_image_url_from_line(message_id):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  """
58
+ Line 訊息 ID 獲取圖片內容並儲存到暫存檔案。
59
  """
60
  try:
61
+ message_content = line_bot_api.get_message_content(message_id)
62
+ file_path = f"/tmp/{message_id}.png"
63
+ with open(file_path, "wb") as f:
64
+ for chunk in message_content.iter_content():
65
+ f.write(chunk)
66
+ print(f"✅ 圖片成功儲存到:{file_path}")
67
+ return file_path
68
+ except Exception as e:
69
+ print(f"❌ 圖片取得失敗:{e}")
70
  return None
71
 
72
+ def store_user_message(user_id, message_type, message_content):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  """
74
+ 儲存用戶的訊息。
75
  """
76
+ user_message_history[user_id].append(
77
+ {"type": message_type, "content": message_content}
78
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
+ def get_previous_message(user_id):
81
  """
82
+ 獲取用戶的上一則訊息。
83
  """
84
+ if user_id in user_message_history and len(user_message_history[user_id]) > 0:
85
+ return user_message_history[user_id][-1]
86
+ return {"type": "text", "content": "No message!"}
 
 
 
 
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
+ # ==========================
90
+ # LangChain 工具定義
91
+ # ==========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
+ @tool
94
+ def generate_and_upload_image(prompt: str) -> str:
95
  """
96
+ 這個工具可以根據文字提示生成圖片,並將其上傳到伺服器。
97
+
98
+ Args:
99
+ prompt: 用於生成圖片的文字提示。
100
+
101
+ Returns:
102
+ 回傳生成圖片的 URL。
103
  """
104
  try:
105
+ response = genai_client.models.generate_content(
106
+ model="gemini-2.0-flash-preview-image-generation",
107
+ contents=prompt,
108
+ config=types.GenerateContentConfig(response_modalities=['Text', 'Image'])
109
+ )
110
 
111
+ image_binary = None
112
+ for part in response.candidates[0].content.parts:
113
+ if part.inline_data is not None:
114
+ image_binary = part.inline_data.data
115
+ break
116
 
117
+ if image_binary:
118
+ image = PIL.Image.open(io.BytesIO(image_binary))
119
+ # 隨機生成一個檔案名以避免衝突
120
+ file_name = f"static/{os.urandom(16).hex()}.png"
121
+ image.save(file_name, format="PNG")
122
+
123
+ image_url = os.path.join(os.getenv("HF_SPACE"), file_name)
124
+ return image_url
125
+
126
+ return "圖片生成失敗。"
127
  except Exception as e:
128
+ return f"圖片生成與上傳失敗: {e}"
 
 
 
 
 
 
 
 
 
 
129
 
130
+ @tool
131
+ def analyze_image_with_text(image_path: str, user_text: str) -> str:
132
+ """
133
+ 這個工具可以根據圖片和文字提示來回答問題。
 
 
 
 
134
 
135
+ Args:
136
+ image_path: 圖片在本地端儲存的路徑。
137
+ user_text: 針對圖片提出的文字問題。
138
+
139
+ Returns:
140
+ 模型針對圖片和文字提示給出的回應。
141
+ """
142
+ try:
143
+ if not os.path.exists(image_path):
144
+ return "圖片路徑無效,無法進行分析。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
+ img_user = PIL.Image.open(image_path)
147
+ response = genai_client.models.generate_content(
148
+ model="gemini-2.5-flash",
149
+ contents=[img_user, user_text]
150
+ )
151
+ if (response.text != None):
152
+ out = response.text
153
  else:
154
+ out = "Gemini沒答案!請換個說法!"
155
+ except Exception as e:
156
+ # 處理錯誤
157
+ out = f"Gemini執行出錯: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
+ return out
 
 
 
 
160
 
161
 
162
+ # ==========================
163
+ # LangChain 代理人設定
164
+ # ==========================
 
 
 
165
 
166
+ # 結合所有工具
167
+ tools = [generate_and_upload_image, analyze_image_with_text]
 
 
 
 
 
 
168
 
169
+ # 建立 LLM 模型實例
170
+ llm = ChatGoogleGenerativeAI(google_api_key=google_api, model="gemini-2.5-flash", temperature=0.2)
 
 
 
171
 
172
+ # 建立提示模板
173
+ prompt_template = ChatPromptTemplate([
174
+ ("system", "你是一個強大的圖像生成與問答助理,可以根據用戶的請求使用提供的工具。當你執行 generate_and_upload_image 工具\
175
+ 成功後會獲得一個 URL,然後你回答的 output 要包含有這個 URL 的完整資訊。如果工具有產生錯誤訊息請解讀並回應。"),
176
+ ("user", "{input}"),
177
+ ("placeholder", "{agent_scratchpad}"),
178
+ ])
 
 
 
 
179
 
180
+ # 建立代理人
181
+ agent = create_tool_calling_agent(llm, tools, prompt_template)
182
+ agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
+ # ==========================
185
+ # FastAPI 路由
186
+ # ==========================
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
+ @app.get("/")
189
+ def root():
190
+ return {"title": "Line Bot"}
 
 
 
 
 
 
 
 
191
 
192
+ @app.post("/webhook")
193
+ async def webhook(
194
+ request: Request,
195
+ background_tasks: BackgroundTasks,
196
+ x_line_signature=Header(None),
197
+ ):
198
+ body = await request.body()
199
+ try:
200
+ background_tasks.add_task(
201
+ line_handler.handle, body.decode("utf-8"), x_line_signature
202
  )
203
+ except InvalidSignatureError:
204
+ raise HTTPException(status_code=400, detail="Invalid signature")
205
+ return "ok"
206
 
207
+ @line_handler.add(MessageEvent, message=(ImageMessage, TextMessage))
208
+ def handle_message(event):
209
+ user_id = event.source.user_id
210
+
211
+ # 處理圖片上傳
212
+ if event.message.type == "image":
213
+ image_path = get_image_url_from_line(event.message.id)
214
+ if image_path:
215
+ store_user_message(user_id, "image", image_path)
216
  line_bot_api.reply_message(
217
+ event.reply_token, TextSendMessage(text="圖片已接收成功囉,幫我輸入你想詢問的問題喔~")
 
218
  )
219
+ else:
220
+ line_bot_api.reply_message(
221
+ event.reply_token, TextSendMessage(text="沒有接收到圖片~")
222
+ )
223
+
224
+ # 處理文字訊息
225
+ elif event.message.type == "text":
226
+ user_text = event.message.text
227
+ previous_message = get_previous_message(user_id)
228
+ print(previous_message)
229
+
230
+ # 根據上一則訊息類型,動態傳遞給代理人
231
+ if previous_message["type"] == "image":
232
+ image_path = previous_message["content"]
233
+ agent_input = {
234
+ "input": f"請根據這張圖片回答問題。圖片的路徑是 {image_path},我的問題是:{user_text}"
235
+ }
236
+ # 清除上一則圖片訊息,避免重複觸發
237
+ user_message_history[user_id].pop()
238
+ else:
239
+ agent_input = {"input": user_text}
240
+ try:
241
+ # 運行代理人
242
+ response = agent_executor.invoke(agent_input)
243
+ out = response["output"]
244
+ if 'https' in out:
245
+ img_tmp = 'https'+out.split('https')[1]
246
+ image_url = img_tmp.split('png')[0]+'png'
247
+ line_bot_api.push_message(
248
+ event.source.user_id,
249
+ [
250
+ TextSendMessage(text="✨ 這是我為你生成的圖片喔~"),
251
+ ImageSendMessage(original_content_url=image_url, preview_image_url=image_url)
252
+ ]
253
  )
 
 
 
 
 
 
 
 
254
  else:
255
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=out))
256
+ except Exception as e:
257
+ print(f"代理人執行出錯: {e}")
258
+ out = f"代理人執行出錯!錯誤訊息:{e}"
259
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=out))
 
 
 
 
 
 
 
 
260
 
261
  if __name__ == "__main__":
262
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=True)