File size: 10,024 Bytes
6c8ae75
 
6578989
6c8ae75
6578989
 
 
 
 
6c8ae75
6578989
1239bc0
6578989
6c8ae75
 
6578989
217ceea
f91846a
1239bc0
 
 
 
 
 
 
6c8ae75
 
1239bc0
 
 
 
 
8e6ac56
6c8ae75
6578989
6c8ae75
d390efa
6578989
 
 
 
 
 
1239bc0
6578989
 
98dc21e
6578989
 
 
 
 
 
 
 
 
 
 
 
 
4123c19
1239bc0
55b1be9
6578989
 
 
1239bc0
98dc21e
6c8ae75
55b1be9
 
 
df5bf2a
55b1be9
 
 
 
6578989
4ea90a7
1239bc0
4ea90a7
 
1239bc0
6578989
6c8ae75
 
 
 
 
 
1239bc0
6578989
4ea90a7
905e639
6c8ae75
 
 
 
 
 
 
 
 
6bf9daa
1239bc0
6c8ae75
6bf9daa
1239bc0
6578989
f89617e
6c8ae75
6578989
 
 
6c8ae75
6578989
6c8ae75
 
 
 
6578989
6c8ae75
 
 
 
 
 
6578989
 
 
6c8ae75
6578989
 
 
 
 
 
6c8ae75
6578989
66bc22e
6c8ae75
 
 
 
 
 
 
 
 
 
 
 
f89617e
6c8ae75
 
 
 
6578989
 
 
 
 
66bc22e
6578989
6c8ae75
6578989
6c8ae75
 
 
 
 
be612ee
f0c8815
6c8ae75
 
f0c8815
6578989
6c8ae75
f0c8815
6578989
 
 
 
 
 
f0c8815
 
 
6c8ae75
6578989
f0c8815
6578989
f0c8815
 
6c8ae75
 
 
 
1239bc0
 
6578989
74c58ba
1239bc0
 
 
 
6578989
1239bc0
 
 
6578989
1239bc0
 
be612ee
1239bc0
 
 
be612ee
1239bc0
 
 
6578989
 
 
 
6c8ae75
6578989
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1239bc0
 
6578989
1239bc0
6578989
 
 
6c8ae75
6578989
 
1239bc0
 
 
6578989
 
 
 
 
 
 
 
6c8ae75
 
 
6578989
6c8ae75
 
 
 
6578989
 
1239bc0
 
6578989
 
e5dd0bd
3bf6983
6578989
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
import os
import io
import re
from collections import defaultdict
import PIL.Image
import uvicorn
import requests
from pydantic_settings import BaseSettings

from fastapi import FastAPI, Request, Header, BackgroundTasks, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from google import genai
from google.genai import types

from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (
    MessageEvent,
    TextMessage,
    TextSendMessage,
    ImageSendMessage,
    ImageMessage,
)

# LangChain 相關匯入
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.agents import AgentExecutor, create_tool_calling_agent


# ==========================
#  環境變數與設定管理
# ==========================

class Settings(BaseSettings):
    """使用 Pydantic 管理環境變數"""
    google_api_key: str
    channel_access_token: str
    channel_secret: str
    base_url: str  # 應用程式的公開網址,例如 ngrok 或 Hugging Face Space 的 URL

    class Config:
        env_file = ".env"

# 載入設定
settings = Settings()

# ==========================
#  API 客戶端與工具函式初始化
# ==========================

# 設置 Google AI API 金鑰
genai.configure(api_key=settings.google_api_key)

# 設置 Line Bot API
line_bot_api = LineBotApi(settings.channel_access_token)
line_handler = WebhookHandler(settings.channel_secret)

# 建立 FastAPI 應用程式
app = FastAPI()

# 確保靜態檔案目錄存在
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")

# 設定 CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

def get_image_from_line(message_id: str) -> str | None:
    """
    從 Line 訊息 ID 獲取圖片內容並儲存到暫存檔案。
    """
    try:
        message_content = line_bot_api.get_message_content(message_id)
        # 使用 /tmp 目錄儲存暫存檔案,適合多數雲端環境
        file_path = f"/tmp/{message_id}.png"
        with open(file_path, "wb") as f:
            for chunk in message_content.iter_content():
                f.write(chunk)
        print(f"✅ 圖片成功儲存到:{file_path}")
        return file_path
    except Exception as e:
        print(f"❌ 從 Line 取得圖片失敗:{e}")
        return None


# ==========================
#  LangChain 工具定義
# ==========================

@tool
def generate_and_upload_image(prompt: str) -> str:
    """
    這個工具可以根據文字提示生成圖片,並將其上傳到伺服器。
    
    Args:
        prompt: 用於生成圖片的文字提示。
        
    Returns:
        回傳生成圖片的公開 URL。
    """
    try:
        # 使用 gemini-2.5-flash-image-preview 模型進行圖片生成
        model = genai.GenerativeModel('gemini-2.5-flash-image-preview')
        response = model.generate_content(
            contents=prompt,
            generation_config=types.GenerationConfig(response_modalities=['IMAGE'])
        )
        
        image_binary = None
        for part in response.candidates[0].content.parts:
            if part.inline_data and part.inline_data.data:
                image_binary = part.inline_data.data
                break
        
        if image_binary:
            image = PIL.Image.open(io.BytesIO(image_binary))
            # 隨機生成一個檔案名以避免衝突
            file_name = f"{os.urandom(16).hex()}.png"
            file_path = os.path.join("static", file_name)
            image.save(file_path, format="PNG")
            
            # 使用環境變數中的 BASE_URL 來建立完整的公開網址
            image_url = f"{settings.base_url}/{file_path}"
            print(f"✅ 圖片生成成功,URL: {image_url}")
            return f"圖片生成成功,請查看此 URL: {image_url}"
        
        return "圖片生成失敗,未收到有效的圖片資料。"
    except Exception as e:
        return f"圖片生成與上傳過程中發生錯誤: {e}"

@tool
def analyze_image_with_text(image_path: str, user_text: str) -> str:
    """
    這個工具可以根據圖片和文字提示來回答問題。
    
    Args:
        image_path: 圖片在本地端儲存的路徑。
        user_text: 針對圖片提出的文字問題。
        
    Returns:
        模型針對圖片和文字提示給出的回應。
    """
    try:
        if not os.path.exists(image_path):
            return "圖片路徑無效,無法進行分析。"
            
        img_user = PIL.Image.open(image_path)
        model = genai.GenerativeModel("gemini-1.5-flash") # 使用 gemini 1.5 flash 模型
        response = model.generate_content([img_user, user_text])

        if response.text:
            return response.text
        else:
            return "Gemini 沒有給出答案,請嘗試換個方式提問!"
    except Exception as e:
        return f"圖片分析過程中發生錯誤: {e}"


# ==========================
#  LangChain 代理人設定
# ==========================

# 結合所有工具
tools = [generate_and_upload_image, analyze_image_with_text]

# 建立 LLM 模型實例
llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash", temperature=0.3)

# 建立提示模板
prompt = ChatPromptTemplate.from_messages([
    ("system", """你是一個強大的圖像生成與問答助理。
- 當用戶的指令明顯是要生成圖片時 (例如:'畫一張...'、'生成...'、'幫我做一張圖...'),請使用 `generate_and_upload_image` 工具。
- 當用戶的指令包含圖片路徑 (image_path) 和問題時,請使用 `analyze_image_with_text` 工具。
- 成功執行 `generate_and_upload_image` 工具後,你會獲得一個 URL,你的最終回答必須包含這個 URL。
- 如果工具執行過程中產生任何錯誤訊息,請以友善的方式解讀並回應給用戶。"""),
    ("user", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])


# 建立代理人
agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

# ==========================
#  FastAPI 路由
# ==========================

@app.get("/")
def root():
    return {"message": "Line Bot is running!"}

@app.post("/webhook")
async def webhook(
    request: Request,
    background_tasks: BackgroundTasks,
    x_line_signature: str = Header(None),
):
    body = await request.body()
    try:
        # 使用背景任務處理 Webhook,避免 Line Server 超時
        background_tasks.add_task(
            line_handler.handle, body.decode("utf-8"), x_line_signature
        )
    except InvalidSignatureError:
        raise HTTPException(status_code=400, detail="Invalid signature")
    return "ok"

@line_handler.add(MessageEvent, message=(ImageMessage, TextMessage))
def handle_message(event):
    user_id = event.source.user_id

    # 處理圖片上傳:使用者上傳圖片後直接進行分析
    if isinstance(event.message, ImageMessage):
        image_path = get_image_from_line(event.message.id)
        if image_path:
            try:
                # 組合給代理人的輸入,使用一個通用的問題來分析圖片
                agent_input = {
                    "input": f"這是一張使用者上傳的圖片,請詳細描述你看到了什麼。圖片的路徑是 '{image_path}'。"
                }
                # 運行代理人
                response = agent_executor.invoke(agent_input)
                output_text = response["output"]
                
                # 回覆分析結果
                line_bot_api.reply_message(event.reply_token, TextSendMessage(text=output_text))

            except Exception as e:
                print(f"代理人執行出錯: {e}")
                error_message = f"圖片分析時發生錯誤,請稍後再試。\n錯誤訊息:{e}"
                line_bot_api.reply_message(event.reply_token, TextSendMessage(text=error_message))
        else:
            line_bot_api.reply_message(
                event.reply_token, TextSendMessage(text="❌ 圖片接收失敗,請再試一次。")
            )

    # 處理文字訊息:主要用於生成圖片或一般問答
    elif isinstance(event.message, TextMessage):
        user_text = event.message.text
        agent_input = {"input": user_text}
        
        try:
            # 運行代理人
            response = agent_executor.invoke(agent_input)
            output_text = response["output"]

            # 使用正規表示法尋找 URL,更穩定
            image_url_match = re.search(r'https?://\S+\.(?:png|jpg|jpeg|gif)', output_text, re.IGNORECASE)
            
            if image_url_match:
                image_url = image_url_match.group(0)
                # 推送訊息,包含生成的圖片
                line_bot_api.push_message(
                    event.source.user_id,
                    [
                        TextSendMessage(text="✨ 這是我為您生成的圖片喔~"),
                        ImageSendMessage(original_content_url=image_url, preview_image_url=image_url)
                    ]
                )
            else:
                # 若無圖片 URL,則直接回覆文字
                line_bot_api.reply_message(event.reply_token, TextSendMessage(text=output_text))
        except Exception as e:
            print(f"代理人執行出錯: {e}")
            error_message = f"代理人執行時發生錯誤,請稍後再試。\n錯誤訊息:{e}"
            line_bot_api.reply_message(event.reply_token, TextSendMessage(text=error_message))

if __name__ == "__main__":
    # 使用 settings 物件中的設定
    uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)