[n8n][Gemini] 打造 AI 自動摘要的 RSS 訂閱系統,每日定時推送 LINE 通知

image-20251205112721295

前情提要

身為一個資訊焦慮的工程師,我每天都會追蹤多個技術部落格和 Hacker News。但手動瀏覽實在太花時間,於是我決定用 n8n 打造一個自動化系統:RSS 更新時自動抓取網頁內容、用 Gemini AI 產生摘要、存入 Google Sheets,然後每天早上 6 點推送精選文章到 LINE

這個專案整合了多個服務:

  • 📡 RSS Feed:訂閱多個資訊來源
  • 🕷️ Firecrawl:抓取網頁完整內容
  • 🤖 Gemini 2.5 Flash:AI 自動摘要
  • 📊 Google Sheets:儲存文章資料
  • 📱 LINE Messaging API:Flex Message 推送通知

聽起來很美好,但實作過程中踩了不少坑,這篇文章記錄我遇到的問題和解決方案。

系統架構

整個系統分成兩個獨立的 n8n Workflow:

Workflow 1:RSS 即時處理

Google Chrome 2025-12-05 11.27.59

RSS 觸發 → 格式化資料 → Firecrawl 抓取網頁 → 內容預處理 → Gemini 摘要 → 寫入 Google Sheets

Workflow 2:每日定時發送

image-20251205112906919

每日 6:00 觸發 → 讀取 Google Sheets → 篩選未發送 → 取 10 筆 → 組合 Flex Message → LINE 推送 → 更新狀態

開發過程中遇到的問題

問題 1:n8n Code Node 語法錯誤

我一開始在 Code Node 使用 ES Module 語法:

// ❌ 錯誤的做法
export default async function () {
  const items = this.getInputData();
  // ...
}

結果 n8n 一直報錯,執行失敗。

解決方案: 改用 n8n 標準的寫法,直接使用 $input.all()

// ✅ 正確的做法
const items = $input.all();

const newItems = items.map(item => {
  // 處理邏輯
  return {
    json: {
      ...item.json,
      // 新增欄位
    }
  };
});

return newItems;

問題 2:Gemini API 回傳 MAX_TOKENS 錯誤

送出請求後,Gemini 回傳了這個結果:

{
  "candidates": [
    {
      "content": { "role": "model" },
      "finishReason": "MAX_TOKENS",
      "index": 0
    }
  ],
  "usageMetadata": {
    "promptTokenCount": 568,
    "totalTokenCount": 867,
    "thoughtsTokenCount": 299
  }
}

一開始我以為是輸入太長,但仔細看 promptTokenCount 只有 568,問題出在 輸出 token 限制

原來 Gemini 2.5 Flash 有 Thinking 功能,會消耗一部分 output token 做內部思考。我設定 maxOutputTokens: 300,但 thinking 就用掉了 299,實際輸出只剩 1 個 token!

解決方案: 提高 maxOutputTokens 或關閉 Thinking 功能:

// 方案 1:提高 output token 限制
{
  "generationConfig": {
    "temperature": 0.7,
    "maxOutputTokens": 1024  // 從 300 提高到 1024
  }
}

// 方案 2:關閉 Thinking 功能
{
  "generationConfig": {
    "temperature": 0.7,
    "maxOutputTokens": 512,
    "thinkingConfig": {
      "thinkingBudget": 0  // 關閉 thinking
    }
  }
}

問題 3:Firecrawl 抓取的內容太雜

Firecrawl 會抓取整個網頁,包含導覽列、側欄、留言區等雜訊。直接送給 Gemini 會浪費 token,也影響摘要品質。

解決方案: 在送給 Gemini 之前,先用 Code Node 清理內容:

const items = $input.all();
const maxLen = 1500;  // 限制最大字數

const newItems = items.map(item => {
  const title = item.json.title || '';
  const raw = item.json.content || '';

  // 1. 移除雜訊
  let text = raw
    .replace(/```[\s\S]*?```/g, '')              // 移除程式碼區塊
    .replace(/`[^`]+`/g, '')                     // 移除行內程式碼
    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')        // 移除 markdown 圖片
    .replace(/<[^>]+>/g, '')                     // 移除 HTML 標籤
    .replace(/https?:\/\/\S+/g, '')              // 移除 URL
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')     // 保留連結文字
    .replace(/[#>*`|_~]/g, '')                   // 移除 markdown 符號
    .replace(/\n{3,}/g, '\n\n')                  // 壓縮換行
    .replace(/\s{2,}/g, ' ')                     // 壓縮空白
    .trim();

  // 2. 切掉無關內容
  const cutPatterns = [
    'Leave a Reply', 'Recent Comments', 'Related Posts',
    'Share this', 'Subscribe', 'Newsletter', 'Copyright',
    '關於作者', '延伸閱讀', '相關文章', '留言'
  ];
  
  for (const pattern of cutPatterns) {
    const idx = text.indexOf(pattern);
    if (idx > 200) {
      text = text.slice(0, idx);
    }
  }

  // 3. 限制長度,保留完整句子
  text = text.slice(0, maxLen);
  if (text.length === maxLen) {
    const lastPeriod = Math.max(
      text.lastIndexOf(''),
      text.lastIndexOf(''),
      text.lastIndexOf(''),
      text.lastIndexOf('. ')
    );
    if (lastPeriod > maxLen * 0.5) {
      text = text.slice(0, lastPeriod + 1);
    }
  }

  // 4. 組成精簡的 prompt
  const prompt = `用繁體中文寫100字以內摘要,只輸出摘要正文:

標題:${title}

內容:
${text}`;

  return {
    json: {
      ...item.json,
      prompt: prompt
    }
  };
});

return newItems;

問題 4:LINE Flex Message 報錯 “message is invalid”

LINE Push Message 回傳錯誤:

A message (messages[0]) in the request body is invalid

檢查 Flex Message JSON 後發現,有些文章的 title 欄位是空的,導致 "text": undefined。LINE API 不接受空的 text 欄位。

問題根源: Google Sheets 讀出來的欄位名稱不是 title,而是 col_1(因為標題列設定問題)。

解決方案: 在 Build Flex Message 時加上 fallback:

const items = $input.first().json.data || [];

const bubbles = items.map((item) => {
  // 修正:檢查多個可能的欄位名稱,並提供預設值
  const title = item.title || item.col_1 || item.link || '無標題';
  const summary = item.summary || '無摘要內容';
  const link = item.link || 'https://example.com';
  const source = item.source || 'Unknown';
  
  return {
    "type": "bubble",
    "size": "kilo",
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": title,  // 確保永遠有值
          "weight": "bold",
          "wrap": true
        },
        {
          "type": "text",
          "text": summary,  // 確保永遠有值
          "size": "sm",
          "wrap": true
        }
      ]
    },
    // ...
  };
});

API Credential 設定

Firecrawl API Key

n8n 中選擇 Header Auth

欄位
Name Authorization
Value Bearer fc-your-api-key

Gemini API Key

n8n 中選擇 Header Auth

欄位
Name x-goog-api-key
Value your-gemini-api-key

⚠️ 注意: Gemini 用的是 x-goog-api-key header,不是 Bearer token!

LINE Channel Access Token

n8n 中選擇 Header Auth

欄位
Name Authorization
Value Bearer your-channel-access-token

Google Sheets 欄位設計

title link summary source created_at sent
文章標題 網址 AI 摘要 來源 發布時間 FALSE

⚠️ 重要: 確保第一行的標題列正確設定,否則 n8n 讀出來的 key 會變成 col_1, col_2 這種格式!

LINE Flex Message 效果

最終的 Flex Message 是 Carousel 格式,每篇文章一張卡片:

┌─────────────────────────┐
│ 📝 DK                   │  ← 來源標籤 + emoji
├─────────────────────────┤
│ 文章標題                  │  ← 粗體標題
│                         │
│ 摘要內容摘要內容摘要       │  ← 100 字摘要
│ 內容摘要內容...           │
├─────────────────────────┤
│    [閱讀原文]            │  ← 按鈕連結
└─────────────────────────┘

不同來源有不同的顏色和 emoji:

  • 📝 DK (藍色 #4A90A4)
  • 🔥 HN (橘色 #FF6600)
  • 🎮 Steam (深藍 #1B2838)
  • 🇯🇵 LY Blog (綠色 #00C300)

踩坑總結

問題 原因 解決方案
Code Node 執行失敗 ES Module 語法不相容 使用 $input.all() 標準寫法
Gemini MAX_TOKENS Thinking 功能消耗 output token 提高 maxOutputTokens 到 1024
摘要品質差 網頁雜訊太多 預處理移除無關內容
LINE message invalid Flex Message 有空值 加上 fallback 預設值
Google Sheets 欄位名稱錯誤 標題列未正確設定 確保第一行有正確的欄位名稱

開發心得

這次專案讓我學到幾個重要的經驗:

  1. Gemini 2.5 的 Thinking 功能會消耗 output token:如果你的輸出被截斷,先檢查 thoughtsTokenCount,可能需要提高 maxOutputTokens 或關閉 thinking。

  2. n8n Code Node 要用標準寫法:避免使用 export defaultthis.getInputData(),直接用 $input.all() 最穩定。

  3. 永遠要處理空值:API 回傳的資料可能缺少欄位,在組合輸出時一定要加上 fallback。

  4. 預處理很重要:送給 AI 的內容越乾淨,摘要品質越好,也越省 token。

  5. Google Sheets 的欄位名稱取決於標題列:如果讀出來的 key 是 col_1,代表標題列有問題。

這個系統現在每天早上 6 點會自動推送 10 篇精選文章到我的 LINE,終於可以在通勤時快速掌握技術動態了!🎉

參考資料

[Gemini][Google Maps] 使用 Google Maps Grounding API 打造位置感知的 AI 應用

image-20251202231128366

前情提要

在開發 LINE Bot 時,我想加入一個功能:讓使用者分享位置後,AI 可以智慧推薦附近的餐廳、加油站或停車場。傳統做法需要串接 Google Places API,處理複雜的搜尋邏輯和結果排序。但 Google 在 2024 年推出了 Grounding with Google Maps 功能,可以讓 Gemini 模型直接存取 Google Maps 的 2.5 億個地點資訊,讓 AI 回應自動帶有地理位置脈絡!

這項功能透過 Vertex AI 提供,可以讓 Gemini 模型「接地氣」(grounded)地回答位置相關問題,不再只是憑空想像。

開發過程中遇到的問題

在實作 maps_grounding.py 時,我最初使用 Gemini Developer API 搭配 API Key 的方式:

# ❌ 錯誤的做法
client = genai.Client(
    api_key=api_key,
    http_options=HttpOptions(api_version="v1")
)

response = client.models.generate_content(
    model="gemini-2.0-flash-lite",  # 不支援 Maps Grounding
    contents=query,
    config=GenerateContentConfig(
        tools=[Tool(google_maps=GoogleMaps())],
        tool_config=ToolConfig(...)
    ),
)

結果出現了這個錯誤:

google.genai.errors.ClientError: 400 INVALID_ARGUMENT.
{'error': {'code': 400, 'message': 'Invalid JSON payload received.
Unknown name "tools": Cannot find field.
Invalid JSON payload received. Unknown name "toolConfig": Cannot find field.'}}

經過查閱文件後才發現,Google Maps Grounding 只支援 Vertex AI,無法使用 Gemini Developer API

正確的解決方案

1. 理解 API 差異

Google 提供兩種不同的 Gemini API 存取方式:

特性 Gemini Developer API Vertex AI API
認證方式 API Key ADC / Service Account
Maps Grounding ❌ 不支援 ✅ 支援
企業級功能 有限 完整
適用場景 快速原型開發 生產環境

2. 修正程式碼

以下是正確的實作方式:

from google import genai
from google.genai import types

# ✅ 正確的做法:使用 Vertex AI
client = genai.Client(
    vertexai=True,  # 啟用 Vertex AI 模式
    project=project_id,  # GCP 專案 ID
    location=location,  # 建議使用 'global'
    http_options=types.HttpOptions(api_version="v1")
)

# 使用支援 Maps Grounding 的模型
response = client.models.generate_content(
    model="gemini-2.0-flash",  # ✅ 支援的模型
    contents=query,
    config=types.GenerateContentConfig(
        tools=[
            types.Tool(google_maps=types.GoogleMaps(
                enable_widget=False
            ))
        ],
        tool_config=types.ToolConfig(
            retrieval_config=types.RetrievalConfig(
                lat_lng=types.LatLng(
                    latitude=latitude,
                    longitude=longitude
                ),
                language_code="zh-TW",  # 支援繁體中文
            ),
        ),
    ),
)

3. 環境設定

要使用 Maps Grounding,需要設定以下環境變數:

# 必要的環境變數
export GOOGLE_CLOUD_PROJECT="your-project-id"
export GOOGLE_CLOUD_LOCATION="global"
export GOOGLE_GENAI_USE_VERTEXAI="True"

# 認證方式(擇一)
# 方式 1: 使用 ADC (開發環境)
gcloud auth application-default login

# 方式 2: 使用 Service Account (生產環境)
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"

# 啟用 Vertex AI API
gcloud services enable aiplatform.googleapis.com

實際應用範例

image-20251202231340480

實作後的功能非常強大,可以用自然語言查詢附近地點:

async def search_nearby_places(
    latitude: float,
    longitude: float,
    place_type: str = "restaurant",
    custom_query: Optional[str] = None,
    language_code: str = "zh-TW"
) -> str:
    """
    使用 Google Maps Grounding API 搜尋附近地點

    範例查詢:
    - "請幫我找出附近的加油站,並列出名稱、距離和地址。"
    - "請幫我找出附近評價不錯的餐廳,並列出名稱、類型和地址。"
    """

使用情境

  1. 對話式助理:「幫我找附近好喝的義式濃縮咖啡店」
  2. 個人化推薦:「有哪些適合親子、步行可達的餐廳?」
  3. 地區總結:「這個飯店附近有什麼特色?」

這些應用場景特別適合:

  • 🏠 房地產平台
  • ✈️ 旅遊規劃
  • 🚗 移動出行
  • 📱 社交媒體

支援的模型清單

目前支援 Google Maps Grounding 的 Gemini 模型:

  • ✅ Gemini 2.5 Pro
  • ✅ Gemini 2.5 Flash
  • ✅ Gemini 2.0 Flash
  • ✅ Gemini 2.5 Flash with Live API
  • ❌ Gemini 2.0 Flash-Lite(不支援)

Google Maps Platform Code Assist (MCP)

Code Assist Toolkit header

在開發過程中,我也發現 Google 推出了 Google Maps Platform Code Assist toolkit,這是一個基於 Model Context Protocol (MCP) 的工具,可以:

  • 🔍 即時文件檢索:透過 RAG 技術搜尋最新的官方文件和程式碼範例
  • 🤖 AI 助手整合:支援 Gemini CLI、Claude Code、Cursor 等多種開發環境
  • 📚 豐富的資源:涵蓋官方文件、教學、GitHub 範例和安全資源

如何使用 MCP

# 使用 Node.js 安裝
npm install -g @googlemaps/code-assist-mcp

# 在 Claude Code 或 Cursor 中設定 MCP 伺服器
# 之後就能直接在 AI 助手中查詢最新的 Google Maps 文件
gemini extensions install https://github.com/googlemaps/platform-ai.git

#or

claude mcp add google-maps-platform-code-assist -- npx -y @googlemaps/code-assist-mcp@latest

這個工具特別適合在開發時快速查詢 API 用法,不用在瀏覽器和編輯器之間切換!

使用後的成果

iTerm2 2025-12-02 22.38.11

可以看到透過使用 Google Maps Platform Code Assist 之後,他們能找到完整的範例程式碼,並且知道要設定哪些相關參數。可以一次就將所有的功能都修復完成。

我原本有使用 Context7 但是對於 Google Map 相關的設定還是有錯誤,並且也使用錯的 API 。這部分還是需要找到相關的 MCP 來使用才會正確。

以下就是一段範例程式碼來使用 Google Map Grounding API

prompt = "What are the best Italian restaurants within a 15-minute walk from here?"

response = client.models.generate_content(
    model='gemini-2.5-flash',
    contents=prompt,
    config=types.GenerateContentConfig(
        # Turn on grounding with Google Maps
        tools=[types.Tool(google_maps=types.GoogleMaps())],
        # Optionally provide the relevant location context (this is in Los Angeles)
        tool_config=types.ToolConfig(retrieval_config=types.RetrievalConfig(
            lat_lng=types.LatLng(
                latitude=34.050481, longitude=-118.248526))),
    ),
)

目前需要注意的地方

1. 必須使用 Vertex AI

Maps Grounding 功能不支援一般的 Gemini Developer API,必須透過 Vertex AI 存取。

2. 認證設定

  • 開發環境:使用 gcloud auth application-default login
  • 生產環境:使用 Service Account 並設定 GOOGLE_APPLICATION_CREDENTIALS

3. 支援的模型

確保使用支援的模型(如 gemini-2.0-flash),避免使用 -lite 版本。

4. 區域選擇

建議將 GOOGLE_CLOUD_LOCATION 設為 global 以獲得最佳可用性。

5. 成本考量

Vertex AI 的計費方式與 Developer API 不同,建議先在定價頁面了解費用結構。

開發心得

這次從錯誤中學到的最大收穫是:並非所有 Gemini 功能都能透過 Developer API 存取。企業級功能如 Maps Grounding、進階安全過濾器等,都需要透過 Vertex AI。

雖然設定 Vertex AI 比單純使用 API Key 複雜一些,但換來的是:

  • ✅ 更強大的功能(Maps Grounding、Search Grounding)
  • ✅ 更完整的企業級支援
  • ✅ 更靈活的部署選項
  • ✅ 更細緻的存取控制

如果你正在開發需要位置感知的 AI 應用,Google Maps Grounding 絕對值得一試!

參考資料

[VS Code][Colab] Google 正式釋出 Colab VS Code Plugin

Connecting to a new Colab server and executing a code cell

前情提要

Google Colab 是一個我很喜歡的服務,你可以在線上透過 JupyterNotebook 的介面,快速使用到 GPU (甚至是 TPU)。有許多需要大量運算資源的東西,都可以很快速的在遠端的機器上面執行。

我自己很常在上面去嘗試一些模型,雖然常常排隊排不到機器。

image-20251114155345861

使用 Colab 可能有的痛點

雖然使用Google Colab 機器非常的方便,但是由於在線上編輯有一些比較麻煩的地方:

  • 無法使用 Copilot 這類型的 Code Assist Tool 來幫我 Auto-Complete 一些程式碼
  • 無法跑 Gemini CLI Code Assist 來幫我寫出一些更多的測試或是幫忙想應用。

Colab for VS Code Plugin

但是現在 Colab VS Code Extension 終於可以在 VS Code Plugin 上面使用了。你可以透過 “Colab” 直接找到官方釋出的 Plugin 。

Code 2025-11-14 15.55.03

安裝過程相當的簡單又快速。

連線到 Colab

Code 2025-11-14 15.23.10

如果要連線,在選擇 Kernel 的時候,就可以選擇 Colab 來遠端連線。

Code 2025-11-14 15.23.16

這裡還可以快速連線,或是找你上次連線過的伺服器。

Code 2025-11-14 15.23.22

這裡就是讓人興奮的地方,可以找找 TPU (不保證排得到隊伍)來用用看。

這樣就可以了。

實際應用:

Code 2025-11-14 15.54.52

這樣比較對味啦!! Vibe Coding 出現之後,我們越來越習慣 Vibe Coding 了。但是如果需要 Step by Step 的去偵錯,或是想要跑一些大型機器才能運行的運算。真的還是需要透過 Colab 來幫忙,但是如果又希望可以有 Gemini CLI 的輔助的話,或許 Colab VS Code Extension 就是你不可或缺的好夥伴。

目前一些需要注意的地方

由於 Colab VS Code Plugin 還在持續開發中,有一些原本在 Colab Web UI 上可以使用的 google.colab 功能目前還無法在 VS Code 中使用。以下是一些主要的限制:

  • auth.authenticate_user(): 認證 URL 會出現在選單中,無法直接點擊。建議改用 Python Cloud Client Library。
  • drive.mount(): 目前無法掛載 Google Drive,可以改用 Drive Python API 來存取檔案。
  • files.download() / files.upload(): 原生的檔案上傳下載功能無法使用,但可以透過 IPyWidget 來達成相同效果。
  • userdata.get(): 目前會回傳錯誤,暫時需要從 Colab Web UI 複製 secret 值到 notebook 中。

雖然有這些限制,但整體來說 Colab VS Code Plugin 還是大幅提升了開發體驗,特別是對於習慣使用 VS Code 和各種 AI Coding Assistant 的開發者來說,絕對是值得一試的好工具!

參考資料

[Python] LINE Bot 名片管家進化:一鍵生成 vCard QR Code,讓名片直接加入手機通訊錄

image-20251114103158657

前情提要

在先前的 LINE Bot 智慧名片管家 專案中,我們已經實作了使用 Gemini Pro Vision API 自動辨識名片的功能。使用者只要拍照上傳名片,AI 就能自動解析姓名、職稱、公司、電話、Email 等資訊,並儲存到 Firebase Realtime Database 中。

但在實際使用時,我發現了一個痛點:

📱 我已經有數位化的名片資料了,但要加入手機通訊錄還是得手動一個一個欄位輸入…

想像這些情境:

  • 📇 參加研討會:收集了 20 張名片,辨識完成後還要手動加入通訊錄
  • 💼 業務拜訪:拿到客戶名片,想快速加入手機聯絡人
  • 🤝 社交場合:認識新朋友,希望立即儲存聯絡方式

於是我想:既然資料已經數位化了,為什麼不能一鍵加入通訊錄呢?

最理想的方式就是:生成 vCard QR Code,讓使用者掃描後直接加入通訊錄

專案程式碼

https://github.com/kkdai/linebot-namecard-python

(透過這個程式碼,可以快速部署到 GCP Cloud Run,享受無伺服器的便利)

📚 關於 vCard 與 QR Code

vCard 格式介紹

vCard(Virtual Contact File)是一種電子名片的標準格式,副檔名為 .vcf。幾乎所有智慧型手機和郵件客戶端都原生支援 vCard,包括:

  • 📱 iPhone:自動識別並提示「加入聯絡人」
  • 🤖 Android:透過聯絡人 App 匯入
  • 💻 電腦:Outlook、Apple Mail、Gmail 等都支援

vCard 3.0 格式範例

BEGIN:VCARD
VERSION:3.0
FN:Kevin Dai
N:Dai;Kevin;;;
ORG:LINE Taiwan
TITLE:Software Engineer
TEL;TYPE=WORK,VOICE:+886-123-456-789
EMAIL;TYPE=WORK:[email protected]
ADR;TYPE=WORK:;;Taipei, Taiwan;;;;
NOTE:Met at DevFest 2025
END:VCARD

QR Code + vCard 的優勢

將 vCard 編碼成 QR Code 有幾個好處:

  1. 一掃即加:相機 App 掃描後自動識別
  2. 跨平台:iPhone/Android 都支援
  3. 無需下載:不用儲存檔案再匯入
  4. 資料完整:包含所有聯絡資訊和備註

✨ 專案功能介紹

核心功能流程

使用者上傳名片圖片
    ↓
Gemini Vision API 辨識
    ↓
儲存到 Firebase Realtime Database
    ↓
顯示名片 Flex Message
    ↓
【新功能】點擊「📥 加入通訊錄」按鈕
    ↓
生成 vCard QR Code
    ↓
上傳到 Firebase Storage
    ↓
回傳 QR Code 圖片給使用者
    ↓
使用者掃描 → 加入通訊錄 ✅

新增功能亮點

  1. 📥 一鍵生成 QR Code
    • 點擊名片上的「加入通訊錄」按鈕
    • 自動生成包含完整資訊的 vCard QR Code
    • 包含姓名、職稱、公司、電話、Email、地址、備註
  2. ☁️ Firebase Storage 整合
    • QR Code 圖片上傳到 Firebase Storage
    • 自動設為公開可讀取
    • 透過 LINE ImageMessage 發送給使用者
  3. 🤖 Gemini Vision 協作
    • 原有的名片辨識功能(Gemini Vision API)
    • 辨識結果 → Firebase Database → QR Code
    • AI 辨識 + 雲端儲存 + 行動應用的完整整合
  4. 📱 使用者友善
    • 自動產生使用說明
    • 支援 iPhone/Android
    • 掃描即可加入通訊錄

💻 核心功能實作

1. vCard 格式生成

首先實作 vCard 格式字串的生成,這是整個功能的基礎。

檔案位置: app/qrcode_utils.py

def generate_vcard_string(namecard_data: Dict[str, str]) -> str:
    """
    Generate vCard 3.0 format string from namecard data.

    Args:
        namecard_data: Dictionary containing namecard fields

    Returns:
        vCard formatted string
    """
    name = namecard_data.get('name', '')
    title = namecard_data.get('title', '')
    company = namecard_data.get('company', '')
    phone = namecard_data.get('phone', '')
    email = namecard_data.get('email', '')
    address = namecard_data.get('address', '')
    memo = namecard_data.get('memo', '')

    # Build vCard 3.0 format
    vcard_lines = [
        'BEGIN:VCARD',
        'VERSION:3.0',
        f'FN:{name}',
        f'N:{name};;;',  # Family Name; Given Name; Additional Names; Honorific Prefixes; Honorific Suffixes
    ]

    if company:
        vcard_lines.append(f'ORG:{company}')

    if title:
        vcard_lines.append(f'TITLE:{title}')

    if phone:
        # Clean phone number format for vCard
        clean_phone = phone.replace('-', '').replace(' ', '')
        vcard_lines.append(f'TEL;TYPE=WORK,VOICE:{clean_phone}')

    if email:
        vcard_lines.append(f'EMAIL;TYPE=WORK:{email}')

    if address:
        # vCard address format: PO Box;Extended Address;Street;City;Region;Postal Code;Country
        vcard_lines.append(f'ADR;TYPE=WORK:;;{address};;;;')

    if memo:
        # Escape special characters in memo
        escaped_memo = memo.replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;')
        vcard_lines.append(f'NOTE:{escaped_memo}')

    vcard_lines.append('END:VCARD')

    return '\n'.join(vcard_lines)

設計要點

  • ✅ 使用 vCard 3.0 格式(相容性最好)
  • 處理空欄位:只在有資料時才加入對應欄位
  • 電話號碼清理:移除 - 和空格,確保格式正確
  • 特殊字元轉義:備註中的換行、逗號、分號需要轉義
  • 完整資訊:包含備註欄位,保留 AI 辨識時的額外資訊

2. QR Code 圖片生成

使用 qrcode 套件將 vCard 字串編碼成 QR Code 圖片。

def generate_vcard_qrcode(namecard_data: Dict[str, str],
                          box_size: int = 10,
                          border: int = 2) -> BytesIO:
    """
    Generate QR Code image containing vCard data.

    Args:
        namecard_data: Dictionary containing namecard fields
        box_size: Size of each box in pixels (default: 10)
        border: Border size in boxes (default: 2)

    Returns:
        BytesIO object containing PNG image data
    """
    # Generate vCard string
    vcard_string = generate_vcard_string(namecard_data)

    # Create QR Code instance
    qr = qrcode.QRCode(
        version=None,  # Auto-determine version based on data size
        error_correction=qrcode.constants.ERROR_CORRECT_L,
        box_size=box_size,
        border=border,
    )

    # Add vCard data
    qr.add_data(vcard_string)
    qr.make(fit=True)

    # Generate image
    img = qr.make_image(fill_color="black", back_color="white")

    # Save to BytesIO
    img_bytes = BytesIO()
    img.save(img_bytes, format='PNG')
    img_bytes.seek(0)  # Reset pointer to beginning

    return img_bytes

關鍵參數說明

參數 說明 選擇理由
version=None 自動決定 QR Code 大小 根據資料量自動調整,確保可掃描
error_correction=L 錯誤修正等級(Low) vCard 資料不會頻繁損壞,選擇最小等級以減少 QR Code 大小
box_size=10 每個方塊 10 像素 在手機螢幕上有良好的掃描性
border=2 邊框 2 個方塊寬 符合 QR Code 標準的最小邊框

為什麼使用 BytesIO?

  • ✅ 不需要寫入實體檔案系統
  • ✅ 直接在記憶體中處理圖片
  • ✅ 方便後續上傳到 Firebase Storage
  • ✅ 減少 I/O 操作,提升效能

3. Firebase Storage 整合

這是整個功能的核心:將 QR Code 圖片上傳到 Firebase Storage 並取得公開 URL。

檔案位置: app/firebase_utils.py

from firebase_admin import storage
from io import BytesIO

def upload_qrcode_to_storage(
        image_bytes: BytesIO, user_id: str, card_id: str) -> str:
    """
    上傳 QR Code 圖片到 Firebase Storage 並回傳公開 URL

    Args:
        image_bytes: QR Code 圖片的 BytesIO 物件
        user_id: 使用者 ID
        card_id: 名片 ID

    Returns:
        圖片的公開 URL,若失敗則回傳 None
    """
    try:
        bucket = storage.bucket()
        blob_name = f"qrcodes/{user_id}/{card_id}.png"
        blob = bucket.blob(blob_name)

        # 上傳圖片
        image_bytes.seek(0)  # 重置指標到開頭
        blob.upload_from_file(image_bytes, content_type='image/png')

        # 設定為公開可讀取
        blob.make_public()

        # 回傳公開 URL
        return blob.public_url
    except Exception as e:
        print(f"Error uploading QR code to storage: {e}")
        return None

設計考量

  1. 檔案路徑結構qrcodes/{user_id}/{card_id}.png
    • 按使用者分類,方便管理
    • 使用 card_id 確保檔名唯一
    • 同一張名片重複生成會覆蓋舊檔案
  2. 公開權限blob.make_public()
    • QR Code 需要被 LINE Bot 透過 URL 存取
    • Firebase Storage Rules 設為 allow read: if true
    • 寫入權限只給 Admin SDK(Cloud Run)
  3. Content-Type 設定content_type='image/png'
    • 確保瀏覽器正確顯示圖片
    • LINE ImageMessage 需要正確的 MIME type

4. Firebase 初始化配置

app/main.py 中正確設定 Firebase Storage Bucket:

import firebase_admin
from firebase_admin import credentials

# Firebase 初始化
firebase_config = {
    "databaseURL": config.FIREBASE_URL,
}
# 如果設定了 Storage Bucket,則加入配置
if config.FIREBASE_STORAGE_BUCKET:
    firebase_config["storageBucket"] = config.FIREBASE_STORAGE_BUCKET

try:
    cred = credentials.ApplicationDefault()
    firebase_admin.initialize_app(cred, firebase_config)
    print("Firebase Admin SDK initialized successfully.")
except Exception as e:
    # 從環境變數解析 JSON
    gac_str = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS_JSON")
    if gac_str:
        cred_json = json.loads(gac_str)
        cred = credentials.Certificate(cred_json)
        firebase_admin.initialize_app(cred, firebase_config)
        print("Firebase Admin SDK initialized successfully from ENV VAR.")

環境變數設定

# 部署到 Cloud Run 時需要設定
FIREBASE_STORAGE_BUCKET=your-project-id.firebasestorage.app

# 或舊格式
FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com

為什麼需要明確設定 Storage Bucket?

  • Firebase Admin SDK 預設只初始化 Database
  • 如果不指定 storageBucket,呼叫 storage.bucket() 會失敗
  • 明確設定可避免執行時錯誤

5. LINE Bot Postback 處理

當使用者點擊「加入通訊錄」按鈕時,處理完整流程。

檔案位置: app/line_handlers.py

from linebot.models import ImageSendMessage, TextSendMessage

async def handle_download_contact(
        event: PostbackEvent, user_id: str, card_id: str, card_name: str):
    """處理下載聯絡人 QR Code 的請求"""
    try:
        # 1. 從 Firebase 取得完整的名片資料
        card_data = firebase_utils.get_card_by_id(user_id, card_id)
        if not card_data:
            await line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='找不到該名片資料。'))
            return

        # 2. 生成 vCard QR Code
        qrcode_image = qrcode_utils.generate_vcard_qrcode(card_data)

        # 3. 上傳到 Firebase Storage 並取得 URL
        image_url = firebase_utils.upload_qrcode_to_storage(
            qrcode_image, user_id, card_id)

        if not image_url:
            await line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='生成 QR Code 時發生錯誤,請稍後再試。'))
            return

        # 4. 生成使用說明
        instruction_text = qrcode_utils.get_qrcode_usage_instruction(card_name)

        # 5. 回傳 QR Code 圖片和使用說明
        image_message = ImageSendMessage(
            original_content_url=image_url,
            preview_image_url=image_url
        )
        text_message = TextSendMessage(text=instruction_text)

        await line_bot_api.reply_message(
            event.reply_token,
            [image_message, text_message])

    except Exception as e:
        print(f"Error in handle_download_contact: {e}")
        await line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text='處理您的請求時發生錯誤,請稍後再試。'))

流程設計亮點

  1. 完整錯誤處理:每個步驟都有錯誤檢查
  2. 友善提示:失敗時給予明確的錯誤訊息
  3. 一次回傳兩則訊息:圖片 + 說明文字
  4. 非同步處理:使用 async/await 避免阻塞

6. Flex Message 按鈕配置

在名片的 Flex Message 中新增「加入通訊錄」按鈕。

檔案位置: app/flex_messages.py

"footer": {
    "type": "box",
    "layout": "vertical",
    "spacing": "sm",
    "contents": [
        {
            "type": "box",
            "layout": "horizontal",
            "spacing": "sm",
            "contents": [
                {
                    "type": "button",
                    "style": "link",
                    "height": "sm",
                    "action": {
                        "type": "postback",
                        "label": "新增/修改記事",
                        "data": f"action=add_memo&card_id={card_id}",
                        "displayText": f"我想為 {name} 新增記事"
                    },
                    "flex": 1
                },
                {
                    "type": "button",
                    "style": "link",
                    "height": "sm",
                    "action": {
                        "type": "postback",
                        "label": "編輯資料",
                        "data": f"action=edit_card&card_id={card_id}",
                        "displayText": f"我想編輯 {name} 的名片"
                    },
                    "flex": 1
                }
            ]
        },
        {
            "type": "button",
            "style": "primary",
            "height": "sm",
            "action": {
                "type": "postback",
                "label": "📥 加入通訊錄",
                "data": f"action=download_contact&card_id={card_id}",
                "displayText": f"下載 {name} 的聯絡人資訊"
            },
            "margin": "sm"
        }
    ]
}

UI 設計考量

┌────────────────────────────────────┐
│  [新增/修改記事]  [編輯資料]       │  ← 第一排並排(link style)
│  [📥 加入通訊錄]                   │  ← 第二排獨立(primary style)
└────────────────────────────────────┘
  • 第一排並排:常用的編輯功能,使用 link 樣式
  • 第二排獨立:下載功能,使用 primary 樣式突出顯示
  • Emoji 視覺化:📥 圖示讓使用者一眼識別下載功能

7. 使用說明生成

提供清楚的使用指引,讓使用者知道如何使用 QR Code。

def get_qrcode_usage_instruction(name: str) -> str:
    """
    Get user instruction message for using the QR Code.

    Args:
        name: Name of the person on the namecard

    Returns:
        Instruction message string
    """
    return f"""已為「{name}」生成聯絡人 QR Code!

📱 使用方式:
1. 用手機相機 App 掃描上方的 QR Code
2. 系統會自動識別聯絡人資訊
3. 點擊「加入聯絡人」即可匯入

✅ 支援 iPhone 和 Android 所有智慧型手機"""

設計理念

  • 個人化訊息:包含名片主人的姓名
  • 步驟清楚:1-2-3 簡單明瞭
  • 跨平台說明:強調 iPhone/Android 都支援
  • Emoji 視覺化:📱 和 ✅ 讓訊息更友善

🤖 Gemini Vision API 在整體架構中的角色

雖然這次的 QR Code 功能本身沒有用到 Gemini,但整個名片管家系統是以 Gemini Vision API 為核心的完整應用。

Gemini + Firebase Storage 的協作流程

┌─────────────────────────────────────────────┐
│  使用者上傳名片照片                          │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  LINE Bot 接收圖片                          │
│  (app/line_handlers.py)                    │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  Gemini Pro Vision API 辨識                │
│  - 姓名、職稱、公司                         │
│  - 電話、Email、地址                        │
│  (app/gemini_utils.py)                     │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  儲存到 Firebase Realtime Database         │
│  /namecard/{user_id}/{card_id}/            │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  使用者點擊「📥 加入通訊錄」                 │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  生成 vCard QR Code                        │
│  (app/qrcode_utils.py)                     │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  上傳到 Firebase Storage                   │
│  qrcodes/{user_id}/{card_id}.png           │
│  (app/firebase_utils.py)                   │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  回傳 QR Code 給使用者                      │
│  使用者掃描 → 加入通訊錄 ✅                  │
└─────────────────────────────────────────────┘

Gemini Vision API 的關鍵作用

app/gemini_utils.py 中,我們使用 Gemini Pro Vision 解析名片圖片:

def generate_json_from_image(img: PIL.Image, prompt: str):
    """
    Use Gemini Pro Vision to extract structured data from image.
    """
    model = genai.GenerativeModel('gemini-1.5-pro')
    response = model.generate_content([prompt, img])
    return response

Prompt 設計 (app/config.py):

IMGAGE_PROMPT = """
這是一張名片,你是一個名片秘書。請將以下資訊整理成 json 給我。
如果看不出來的,幫我填寫 N/A
只好 json 就好:
name, title, address, email, phone, company.
其中 phone 的內容格式為 #886-0123-456-789,1234. 沒有分機就忽略 ,1234
"""

為什麼選擇 Gemini Vision?

  1. 中文辨識能力強:台灣名片常有中文,Gemini 處理效果好
  2. 結構化輸出:直接生成 JSON 格式,方便解析
  3. 容錯能力:無法辨識時自動填 “N/A”
  4. 彈性格式:支援各種名片版型

Firebase 雙服務整合

這個專案同時使用了 Firebase 的兩大服務:

服務 用途 資料類型 存取方式
Realtime Database 儲存名片結構化資料 JSON firebase_admin.db
Storage 儲存 QR Code 圖片 Binary (PNG) firebase_admin.storage

為什麼需要兩個服務?

  • Database:適合結構化資料,支援即時查詢和更新
  • Storage:適合大型二進位檔案,提供 CDN 加速

資料流向

Gemini Vision → Database (結構化資料)
                    ↓
                QR Code 生成
                    ↓
                Storage (圖片檔案)
                    ↓
                LINE Bot (圖片 URL)

🔧 遇到的挑戰與解決方案

1. Firebase Storage Bucket 配置問題

問題:初始化 Firebase Admin SDK 時,沒有設定 Storage Bucket 導致錯誤。

錯誤訊息

ValueError: Invalid None value for Firebase Storage bucket.

原因分析

  • Firebase Admin SDK 預設只初始化 Realtime Database
  • 必須在 initialize_app() 時明確指定 storageBucket
  • 環境變數未正確設定

解決方案

  1. config.py 新增配置
    FIREBASE_STORAGE_BUCKET = os.environ.get("FIREBASE_STORAGE_BUCKET")
    
  2. main.py 初始化時加入: ```python firebase_config = { “databaseURL”: config.FIREBASE_URL, } if config.FIREBASE_STORAGE_BUCKET: firebase_config[“storageBucket”] = config.FIREBASE_STORAGE_BUCKET

firebase_admin.initialize_app(cred, firebase_config)


3. **部署時設定環境變數**:
```bash
--set-env-vars "...,FIREBASE_STORAGE_BUCKET=line-vertex.firebasestorage.app,..."

學到的經驗

  • Firebase 不同服務需要不同的配置參數
  • 環境變數要完整檢查,避免 runtime 錯誤
  • 新格式 .firebasestorage.app 和舊格式 .appspot.com 都支援

2. Storage Rules 的權限設定

問題:如何設定 Firebase Storage Rules,讓 Cloud Run 能寫入,但 QR Code 圖片可以公開讀取?

初始想法

// ❌ 這樣會讓任何人都能寫入
allow read, write: if true;

正確方案

利用 Firebase Admin SDK 會繞過 Rules 的特性:

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read: if true;   // 任何人都可以讀取
      allow write: if false; // 禁止客戶端寫入
    }
  }
}

為什麼這樣可行?

  1. ✅ Cloud Run 使用 Admin SDK,有完整權限(繞過 Rules)
  2. allow read: if true 讓 LINE Bot 能透過 URL 存取圖片
  3. allow write: if false 阻止惡意客戶端上傳檔案
  4. blob.make_public() 設定的公開權限仍然有效

關鍵學習

  • Admin SDK vs 客戶端 SDK 的權限差異
  • Storage Rules 只影響客戶端存取
  • 雲端服務使用 Admin SDK 是最佳實踐

3. QR Code 大小與掃描性優化

問題:生成的 QR Code 太小或太大都不好掃描。

實驗過程

參數組合 結果 問題
box_size=5, border=1 圖片太小 手機掃描困難
box_size=15, border=4 圖片太大 LINE 壓縮後失真
box_size=10, border=2 ✅ 適中 掃描順暢

最終方案

qr = qrcode.QRCode(
    version=None,         # 自動調整大小
    error_correction=qrcode.constants.ERROR_CORRECT_L,  # 低錯誤修正
    box_size=10,          # 每個方塊 10px
    border=2,             # 邊框 2 個方塊
)

為什麼選擇 ERROR_CORRECT_L(低錯誤修正)?

  • vCard 資料相對穩定,不會損壞
  • 低錯誤修正 = QR Code 更簡單 = 掃描更快
  • 如果用高錯誤修正(H),QR Code 會變得很複雜

實測結果

  • ✅ iPhone 相機:秒掃
  • ✅ Android 相機:秒掃
  • ✅ LINE 內建掃描器:正常

4. vCard 特殊字元處理

問題:備註中如果有換行、逗號、分號等特殊字元,會導致 vCard 格式錯誤。

錯誤範例

NOTE:這個人很重要,記得要聯絡;下次見面時間: 2025/11/15

vCard 解析器會把逗號和分號當作分隔符,導致資料錯亂。

解決方案

if memo:
    # Escape special characters in memo
    escaped_memo = memo.replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;')
    vcard_lines.append(f'NOTE:{escaped_memo}')

vCard 轉義規則

字元 轉義後 說明
換行 \n \\n 文字中的換行
逗號 , \\, 避免當作分隔符
分號 ; \\; 避免當作分隔符

學到的經驗

  • vCard 有自己的轉義規則,不能直接照搬 JSON
  • 使用者輸入的備註可能包含任何字元
  • 完整測試各種特殊字元情況

5. BytesIO 指標重置問題

問題:上傳圖片到 Firebase Storage 時,有時會上傳空檔案。

錯誤原因

img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
# ❌ 此時指標在檔案末端

blob.upload_from_file(img_bytes, content_type='image/png')
# ❌ 從末端開始讀取 = 讀到空內容

解決方案

img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)  # ✅ 重置指標到開頭

blob.upload_from_file(img_bytes, content_type='image/png')

為什麼需要 seek(0)?

  1. img.save() 會移動指標到檔案末端
  2. upload_from_file() 從當前位置開始讀取
  3. 如果不重置,會讀取 0 bytes

學到的經驗

  • 使用 BytesIO 要注意指標位置
  • 寫入後要記得 seek(0) 再讀取
  • 這是常見的新手陷阱

6. LINE ImageMessage 的 URL 要求

問題:有時候 QR Code 無法在 LINE 中顯示。

原因分析

LINE Bot 的 ImageSendMessage 對 URL 有嚴格要求:

  1. ✅ 必須是 HTTPS
  2. ✅ 圖片必須是 JPEG 或 PNG
  3. ✅ URL 必須公開可存取
  4. original_content_urlpreview_image_url 可以相同

正確用法

image_message = ImageSendMessage(
    original_content_url=image_url,  # Firebase Storage 的 public URL
    preview_image_url=image_url      # 可以用同一個 URL
)

Firebase Storage 的優勢

  • ✅ 自動提供 HTTPS URL
  • blob.make_public() 確保公開存取
  • ✅ CDN 加速,載入快速
  • blob.public_url 直接取得完整 URL

🎯 總結與未來改進

專案亮點

  1. 🤖 AI 驅動的名片辨識
    • Gemini Pro Vision API 自動解析名片
    • 支援中文名片,辨識準確率高
    • 結構化資料儲存,方便後續處理
  2. 📥 一鍵加入通訊錄
    • vCard QR Code 標準格式
    • iPhone/Android 原生支援
    • 掃描即加,無需手動輸入
  3. ☁️ Firebase 雙服務整合
    • Realtime Database 儲存結構化資料
    • Storage 儲存 QR Code 圖片
    • Admin SDK 確保安全性
  4. 🚀 無伺服器架構
    • 部署到 Google Cloud Run
    • 自動擴展,按需付費
    • 冷啟動優化,回應快速
  5. 🎨 使用者體驗優化
    • LINE Flex Message 精美介面
    • Postback 按鈕互動流暢
    • 清楚的使用說明

架構優勢

┌────────────────────────────────────────┐
│        Google Cloud Platform           │
│  ┌──────────────────────────────────┐  │
│  │      Cloud Run (無伺服器)        │  │
│  │  - FastAPI                       │  │
│  │  - LINE Bot SDK                  │  │
│  │  - Firebase Admin SDK            │  │
│  └──────────────────────────────────┘  │
│                                        │
│  ┌──────────────────────────────────┐  │
│  │   Gemini Pro Vision API         │  │
│  │  - 名片圖片辨識                  │  │
│  │  - 結構化資料提取                │  │
│  └──────────────────────────────────┘  │
│                                        │
│  ┌──────────────────────────────────┐  │
│  │      Firebase Services           │  │
│  │  - Realtime Database (名片資料)  │  │
│  │  - Storage (QR Code 圖片)        │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

實戰經驗分享

1. Firebase 服務的選擇

何時用 Realtime Database?

  • ✅ 結構化資料(JSON)
  • ✅ 需要即時查詢和更新
  • ✅ 資料量不大(名片資訊)
  • ✅ 需要簡單的查詢邏輯

何時用 Firebase Storage?

  • ✅ 二進位檔案(圖片、影片、PDF)
  • ✅ 需要公開存取 URL
  • ✅ 需要 CDN 加速
  • ✅ 檔案大小較大

這個專案的最佳組合

名片文字資料 → Realtime Database
QR Code 圖片 → Storage

2. vCard 標準的實用性

vCard 是個被低估的標準:

  • 跨平台:所有裝置都支援
  • 無需 APP:不用安裝額外軟體
  • 標準化:30 年歷史的成熟標準
  • 可擴展:支援照片、社群媒體等

使用情境遠超名片

  • 電子郵件簽名檔
  • 網站「聯絡我們」頁面
  • 會議報到系統
  • 社群媒體個人檔案

3. QR Code 的設計哲學

好的 QR Code 設計

  • ✅ 大小適中(10-15 px per module)
  • ✅ 最小邊框(2 modules)
  • ✅ 低錯誤修正(如果內容穩定)
  • ✅ 高對比度(黑白最佳)

避免過度設計

  • ❌ 加入 Logo(增加掃描難度)
  • ❌ 使用彩色(容易失真)
  • ❌ 過度藝術化(降低可讀性)

4. Firebase Admin SDK vs 客戶端 SDK

特性 Admin SDK 客戶端 SDK
執行環境 伺服器端 瀏覽器/手機
權限 完整權限(繞過 Rules) 受 Rules 限制
認證 Service Account 使用者認證
適用場景 Cloud Run, Cloud Functions Web App, Mobile App
安全性 高(不暴露憑證) 需要 Rules 保護

這個專案的選擇

  • ✅ 使用 Admin SDK(Cloud Run 環境)
  • ✅ Storage Rules 設為 write: false
  • ✅ Admin SDK 仍可寫入(繞過 Rules)

5. Gemini API 的最佳實踐

Prompt 設計技巧

# ✅ 好的 Prompt
"""
這是一張名片,你是一個名片秘書。請將以下資訊整理成 json 給我。
如果看不出來的,幫我填寫 N/A
只好 json 就好:
name, title, address, email, phone, company.
"""

# ❌ 不好的 Prompt
"Extract name, title, company from this business card"

為什麼第一個更好?

  1. 角色設定:「你是名片秘書」讓 AI 理解任務
  2. 明確格式:要求 JSON,不要其他說明
  3. 容錯處理:無法辨識時填 N/A
  4. 中文指令:處理中文名片時更準確

未來改進方向

1. 功能擴展

短期(1-2 週)

  • QR Code 加入公司 Logo(提升品牌識別)
  • 支援多種 QR Code 樣式選擇
  • QR Code 下載為檔案(不只圖片連結)
  • 批次生成多張名片的 QR Code

中期(1-2 個月)

  • 整合 NFC 虛擬名片(iPhone Wallet)
  • 支援 vCard 4.0 格式(更多欄位)
  • 名片分享統計(誰掃描了 QR Code)
  • 自訂 QR Code 設計(顏色、形狀)

長期(3-6 個月)

  • AI 名片管理助手(自動分類、提醒聯絡)
  • 與 Google Contacts / iCloud 同步
  • 名片交換記錄(何時何地交換)
  • 社群媒體整合(LinkedIn, Facebook)

2. 效能優化

QR Code 快取機制

# 目前:每次都重新生成並上傳
# 改進:檢查名片資料是否變更
if card_data_hash == cached_hash:
    return cached_qrcode_url  # 直接回傳快取的 URL

Storage 成本優化

  • 設定 QR Code 過期時間(7 天後自動刪除)
  • 使用 Cloud Storage Lifecycle Management
  • 壓縮圖片大小(目前約 5KB,可降至 2KB)

Cloud Run 冷啟動優化

  • 使用最小化的 Docker Image
  • Pre-import 常用套件
  • 設定最小實例數(避免冷啟動)

3. 安全性強化

當前挑戰

  • QR Code 圖片是公開的(任何人有 URL 都能存取)
  • 沒有使用者配額限制(惡意使用者可大量生成)
  • 沒有 Rate Limiting(防止 DoS)

改進方案

  1. Signed URL(簽名 URL)
    # 使用時效性 URL,1 小時後失效
    blob.generate_signed_url(expiration=timedelta(hours=1))
    
  2. 使用者配額管理
    # Firebase Database 記錄每個使用者的 QR Code 生成次數
    qrcode_count = db.reference(f"qrcode_quota/{user_id}").get()
    if qrcode_count > 100:  # 每日上限 100 次
     return "您已達到今日生成上限"
    
  3. Rate Limiting: ```python from slowapi import Limiter limiter = Limiter(key_func=get_remote_address)

@app.post(“/webhook”) @limiter.limit(“100/minute”) # 每分鐘最多 100 次請求 async def webhook(request: Request): …


#### 4. 使用體驗提升

**Rich Menu 設計**:

┌────────────┬────────────┬────────────┐ │ 📸 拍攝名片 │ 📇 我的名片 │ ⚙️ 設定 │ ├────────────┼────────────┼────────────┤ │ 📥 匯入名片 │ 🔍 搜尋名片 │ 💡 使用教學 │ └────────────┴────────────┴────────────┘


**名片分享功能**:
- 使用者可以分享自己的名片 QR Code
- 類似 LINE 的「我的 QR Code」
- 對方掃描後自動加入聯絡人

**智能提醒**:
```python
# 使用 Gemini 分析備註,自動設定提醒
if "下週要聯絡" in memo:
    # 設定 7 天後的提醒
    send_reminder(user_id, card_id, days=7)

關鍵學習

透過這個專案,我深入學習了:

  1. Firebase 生態系統
    • Realtime Database vs Firestore vs Storage 的選擇
    • Admin SDK 的權限模型與 Rules 的關係
    • 多服務整合的最佳實踐
  2. vCard 與 QR Code 標準
    • vCard 3.0 的格式規範與轉義規則
    • QR Code 參數優化(大小、錯誤修正)
    • 跨平台相容性測試
  3. Gemini Vision API
    • Prompt Engineering 技巧
    • 結構化資料提取
    • 中文處理的最佳實踐
  4. LINE Bot 開發
    • Flex Message 進階排版
    • Postback 互動設計
    • ImageMessage 的 URL 要求
  5. 雲端原生架構
    • 無伺服器設計模式
    • 環境變數管理
    • Storage 與 Database 的分工

最重要的體悟:

AI + 雲端 + 即時通訊 = 無限可能

這個專案展示了如何將三大技術結合:

  • 🤖 AI:Gemini Vision 自動辨識名片
  • ☁️ 雲端:Firebase 資料儲存 + Cloud Run 部署
  • 💬 即時通訊:LINE Bot 作為使用者介面

關鍵成功因素

  1. ✅ 選對工具(Gemini for OCR, Firebase for Storage)
  2. ✅ 標準化格式(vCard 確保相容性)
  3. ✅ 使用者體驗(一鍵加入通訊錄,無需學習)
  4. ✅ 安全設計(Admin SDK + Storage Rules)

希望這個經驗分享能幫助到正在探索 AI 應用開發的朋友們!

相關資源


如果你覺得這個專案有幫助,歡迎給個 Star ⭐,或是分享給需要的朋友!

LINE Messaging API 新功能介紹: Mark as Read API 讓你的聊天機器人標記訊息已讀

image-20251112102510088

在 2025 年 11 月 5 日,LINE Messaging API 推出了新的功能,讓聊天機器人可以將用戶發送的訊息標記為已讀。這項功能的推出,讓開發者能夠為用戶提供更好的互動體驗,用戶可以清楚知道機器人是否已經「看過」他們的訊息。

前言

image-20251112103925983

以往在回應設定中,如果開啟了聊天,希望可以用真人來回覆客戶的話。這個時候,由於系統是允許「真人聊天」跟「聊天機器人」是共存的。但是如果這個聊天選項打開的話,直到真人打開聊天視窗之前,即便客戶的訊息已經被聊天機器人處理了,他也不會標示成「已讀」。

這一篇文章就要跟大家分享,這一個新功能開放後。該如何應用這個新的 API 。

新 API 功能介紹

已讀標記功能

當用戶發送訊息給 LINE 官方帳號時,機器人現在可以主動將訊息標記為已讀狀態。這讓用戶在聊天介面上可以看到「已讀」的指示,就像一般的 1 對 1 聊天一樣。這項功能特別適合用於:

  • 客服機器人:讓用戶知道他們的問題已經被機器人接收並處理
  • 訂單通知機器人:確認用戶的訂單查詢已被讀取
  • 互動式問答機器人:提供更自然的對話體驗

SDK 版本需求

  • line-bot-sdk-go/v8: v8.18.0 或更新版本
  • Go: 1.24 或更新版本

新增 API 規格

LINE Messaging API 新增了兩個標記已讀的 API:

  1. MarkMessagesAsRead

    使用 userId 來標記特定用戶的所有未讀訊息為已讀。

    • 端點: POST https://api.line.me/v2/bot/message/markAsRead
    • 請求參數:
      {
        "chat": {
          "userId": "U1234567890abcdef1234567890abcdef"
        }
      }
      
  2. MarkMessagesAsReadByToken (本文重點)

    使用訊息專屬的 markAsReadToken 來標記特定訊息為已讀,更精確也更安全。

    • 端點: POST https://api.line.me/v2/bot/message/markAsRead/token
    • 請求參數:
      {
        "markAsReadToken": "abc123def456..."
      }
      

新 API 欄位介紹

markAsReadToken 欄位

LINE Messaging API v8.18.0 在各種訊息內容中新增了 markAsReadToken 欄位:

  • TextMessageContent.markAsReadToken: 文字訊息的已讀標記 token
  • StickerMessageContent.markAsReadToken: 貼圖訊息的已讀標記 token
  • ImageMessageContent.markAsReadToken: 圖片訊息的已讀標記 token
  • VideoMessageContent.markAsReadToken: 影片訊息的已讀標記 token
  • AudioMessageContent.markAsReadToken: 音訊訊息的已讀標記 token
  • FileMessageContent.markAsReadToken: 檔案訊息的已讀標記 token
  • LocationMessageContent.markAsReadToken: 位置訊息的已讀標記 token

每個訊息都會有一個唯一的 markAsReadToken,機器人可以使用這個 token 來標記該訊息為已讀。

如何使用 Golang 來開發相關部分

以下是使用 Golang 實作 Mark as Read 功能的完整範例程式碼: (請注意 github.com/line/line-bot-sdk-go/v8 需要更新到 8.18.0 之後)

範例程式碼在:https://github.com/kkdai/linebot-mark-as-read

實作方式:使用 Quick Reply + Postback

本範例採用使用者友善的互動方式:在每則回覆訊息上加上「Mark as Read」快速回覆按鈕,讓使用者可以主動選擇要將哪些訊息標記為已讀。

步驟 1: 接收訊息並提取 markAsReadToken

case webhook.TextMessageContent:
    // 從訊息內容中取得 markAsReadToken
    markAsReadToken := message.MarkAsReadToken
    log.Printf("Received text message with markAsReadToken: %s\n", markAsReadToken)

    // 建立 Quick Reply,將 token 儲存在 postback data 中
    quickReply := &messaging_api.QuickReply{
        Items: []messaging_api.QuickReplyItem{
            {
                Type: "action",
                Action: &messaging_api.PostbackAction{
                    Label:       "Mark as Read",
                    Data:        fmt.Sprintf("action=markasread&token=%s", markAsReadToken),
                    DisplayText: "Marked as read",
                },
            },
        },
    }

    // 回覆訊息,附帶 Quick Reply 按鈕
    if _, err = bot.ReplyMessage(
        &messaging_api.ReplyMessageRequest{
            ReplyToken: e.ReplyToken,
            Messages: []messaging_api.MessageInterface{
                messaging_api.TextMessage{
                    Text:       message.Text,
                    QuickReply: quickReply,
                },
            },
        },
    ); err != nil {
        log.Print(err)
    } else {
        log.Println("Sent text reply with Quick Reply button.")
    }

步驟 2: 處理 Postback 事件並呼叫 Mark as Read API

case webhook.PostbackEvent:
    // 當使用者點擊 "Mark as Read" 按鈕時觸發
    log.Printf("Postback event: data=%s\n", e.Postback.Data)

    // 解析 postback data 取得 action 和 token
    // 格式: "action=markasread&token=xxxxx"
    values, err := url.ParseQuery(e.Postback.Data)
    if err != nil {
        log.Printf("Failed to parse postback data: %v\n", err)
    } else {
        action := values.Get("action")
        markAsReadToken := values.Get("token")

        if action == "markasread" && markAsReadToken != "" {
            log.Printf("Marking messages as read with token: %s\n", markAsReadToken)

            // 呼叫 Mark as Read By Token API
            _, err := bot.MarkMessagesAsReadByToken(
                &messaging_api.MarkMessagesAsReadByTokenRequest{
                    MarkAsReadToken: markAsReadToken,
                },
            )
            if err != nil {
                log.Printf("Failed to mark messages as read: %v\n", err)
            } else {
                log.Println("Successfully marked messages as read using token")
            }
        }
    }

說明

本實作的流程如下:

  1. 接收訊息:當使用者發送訊息時,從 webhook event 的訊息內容中提取 markAsReadToken
  2. 儲存 token:將 token 編碼在 Quick Reply 按鈕的 postback data 中(格式:action=markasread&token={token}
  3. 回覆訊息:機器人回覆訊息,並附帶 “Mark as Read” 快速回覆按鈕
  4. 使用者互動:使用者看到按鈕並可以選擇點擊
  5. 觸發 Postback:點擊按鈕後觸發 PostbackEvent
  6. 解析 token:使用 url.ParseQuery() 解析 postback data 取得 token
  7. 呼叫 API:使用 bot.MarkMessagesAsReadByToken() 標記訊息為已讀
  8. 顯示已讀:使用者在 LINE 聊天介面看到訊息被標記為已讀

關鍵技術點

  • Quick Reply: 提供使用者友善的互動介面
  • PostbackAction: 可以攜帶資料(data)的按鈕動作
  • url.ParseQuery: 安全地解析查詢字串格式的 postback data
  • MarkMessagesAsReadByToken: 使用 token 精確標記特定訊息

這樣的設計讓使用者可以自主選擇要標記哪些訊息為已讀,提供更好的使用體驗。

未來展望

隨著 Mark as Read API 的推出,開發者可以探索更多創新的應用場景:

  1. 智能客服系統:當客服機器人處理完用戶問題後,自動將訊息標記為已讀,讓用戶清楚知道問題已被處理。搭配自動回覆和人工介入,提供更完整的客服體驗。

  2. 訂單追蹤機器人:用戶查詢訂單狀態時,機器人可以在查詢完成後標記訊息為已讀,給予用戶即時的回饋。這對電商平台的客戶體驗提升特別有幫助。

  3. 互動式教學機器人:在線上教學場景中,當學生提交作業或問題時,機器人可以在檢查或回答後標記已讀,讓學生知道老師(或 AI)已經看過他們的訊息。

  4. 任務管理機器人:企業內部使用的任務管理機器人,可以在接收到任務指派或狀態更新時,標記訊息為已讀,確保團隊成員知道訊息已被系統記錄。

  5. 條件式已讀標記:開發者可以設計更複雜的邏輯,例如:
    • 只在成功處理後才標記已讀
    • 根據訊息類型決定是否標記
    • 延遲一段時間後才標記(模擬真人閱讀)
    • 搭配其他 API(如 Typing indicator)提供更自然的互動
  6. 數據分析與優化:追蹤哪些訊息被標記為已讀,分析用戶行為模式,了解用戶與機器人的互動習慣,進一步優化回應策略和使用者體驗。

技術延伸

開發者還可以考慮:

  • 結合 AI 決策:使用 Gemini 或其他 AI 來決定何時應該標記訊息為已讀
  • 批次處理:在處理大量訊息時,批次標記已讀狀態
  • 狀態管理:在資料庫中記錄已讀狀態,避免重複標記
  • 錯誤處理:當 API 呼叫失敗時,實作重試機制

這些應用場景不僅能提升用戶體驗,也能為企業帶來更多的商業價值。Mark as Read API 雖然是一個簡單的功能,但配合創意的使用方式,可以大幅提升聊天機器人的互動品質,讓機器人的行為更接近真人,提供更好的使用者體驗。

[Python] 用 Python + Gemini File Search 打造智能文件助手 LINE Bot:讓 AI 幫你讀文件

image-20251108080734448

前情提要

在工作和生活中,我們經常需要處理大量的文件:會議記錄、技術文件、合約、研究報告等等。每次要找特定資訊時,都得翻開文件一頁一頁找,既費時又容易遺漏重點。

最近 Google 推出了 Gemini File Search API,讓 AI 可以直接分析上傳的文件並回答問題。我想到,如果能結合 LINE Bot,讓大家透過最常用的通訊軟體就能「問」文件問題,那不是很方便嗎?

想像一下這些場景:

  • 📄 會議記錄:「這次會議的主要決議是什麼?」
  • 📊 技術文件:「這個 API 的參數有哪些?」
  • 🖼️ 圖片內容:「這張圖片裡有什麼?」
  • 📑 研究報告:「這份報告的結論是什麼?」

於是我決定動手打造這個「智能文件助手 LINE Bot」,讓 AI 成為你的私人文件分析師!

專案程式碼

(透過這個程式碼,可以快速部署到 GCP Cloud Run,享受無伺服器的便利)

LINE 2025-11-08 08.07.11

📚 關於 Gemini File Search 基本介紹

Gemini File SearchGoogle DeepMind 於 2025 年 11 月 6 日推出的全新工具,直接內建在 Gemini API 之中。這個工具是一套全託管的 RAG(檢索增強生成,Retrieval-Augmented Generation)系統,目標是讓開發者能更簡單、有效率地將自己的資料與 Gemini 模型結合,產生更精確、相關且可驗證的 AI 回應。


主要特色

  • 簡化開發流程 File Search 免去自行搭建 RAG 管線的麻煩,開發者只需專注於應用程式本身。檔案儲存、分段(chunking)、嵌入(embedding)及檢索等繁瑣細節都自動處理。
  • 強大的向量搜尋 採用最新的 Gemini Embedding 模型,可理解使用者查詢的語意與上下文,找出最相關的資訊,即使關鍵字不同也能命中答案。
  • 自動引用來源 AI 回應會自動附上出處,明確標示答案引用自哪一份文件、哪一段內容,方便核對與驗證。
  • 廣泛格式支援 支援 PDF、DOCX、TXT、JSON 及多種程式語言檔案等主流格式,方便建立多元知識庫。
  • 輕鬆整合 可直接在 generateContent API 中使用,且有完善的 Python SDK,開發者能快速上手。

📚 專案功能介紹

核心功能

  1. 📤 多格式檔案上傳
    • 支援文件檔案:PDF、Word (DOCX)、純文字 (TXT) 等
    • 支援圖片檔案:JPG、PNG 等(利用 Gemini Image Understanding 圖片內容)
    • 自動處理中文檔名,避免編碼問題
    • 即時回饋上傳狀態
  2. 🤖 AI 智能問答
    • 基於 Google Gemini 2.5 Flash 模型
    • 從上傳的文件中搜尋相關內容並回答
    • 支援繁體中文、英文等多語言
    • 理解上下文,提供精準回答
  3. 👥 多對話隔離
    • 1對1聊天:每個人有獨立的文件庫(完全隔離)
    • 群組聊天:群組成員共享文件庫(協作查詢)
    • 自動識別對話類型,無需手動設定
    • File Search Store 自動建立和管理
  4. 📁 檔案管理功能
    • Quick Reply 快速操作:上傳成功後提供快捷按鈕
    • 明確檔案指定:Quick Reply 自動帶入檔案名稱
  5. 🔄 智能錯誤處理
    • 檔案上傳失敗自動重試
    • 沒有文件時引導使用者上傳
    • 詳細的錯誤日誌記錄

💻 核心功能實作

1. File Search Store 的自動管理

這是整個系統的核心,負責管理每個使用者或群組的文件庫。

Store 命名策略

根據對話類型自動生成唯一的 store 名稱:

def get_store_name(event: MessageEvent) -> str:
    """
    Get the file search store name based on the message source.
    Returns user_id for 1-on-1 chat, group_id for group chat.
    """
    if event.source.type == "user":
        return f"user_{event.source.user_id}"
    elif event.source.type == "group":
        return f"group_{event.source.group_id}"
    elif event.source.type == "room":
        return f"room_{event.source.room_id}"
    else:
        return f"unknown_{event.source.user_id}"

Store 存在性檢查與建立

關鍵的設計是:File Search Store 的 name 是由 API 自動生成的(例如 fileSearchStores/abc123),我們只能設定 display_name。因此需要透過 list()display_name 來查找:

async def ensure_file_search_store_exists(store_name: str) -> tuple[bool, str]:
    """
    Ensure file search store exists, create if not.
    Returns (success, actual_store_name).
    """
    try:
        # List all stores and check if one with our display_name exists
        stores = client.file_search_stores.list()
        for store in stores:
            if hasattr(store, 'display_name') and store.display_name == store_name:
                print(f"File search store '{store_name}' already exists: {store.name}")
                return True, store.name

        # Store doesn't exist, create it
        print(f"Creating file search store with display_name '{store_name}'...")
        store = client.file_search_stores.create(
            config={'display_name': store_name}
        )
        print(f"File search store created: {store.name} (display_name: {store_name})")
        return True, store.name

    except Exception as e:
        print(f"Error ensuring file search store exists: {e}")
        return False, ""

Cache 機制優化

為了避免每次都要 list 所有 stores,我們加入了快取機制:

# Cache to store display_name -> actual_name mapping
store_name_cache = {}

# 在上傳時使用 cache
if store_name in store_name_cache:
    actual_store_name = store_name_cache[store_name]
else:
    success, actual_store_name = await ensure_file_search_store_exists(store_name)
    store_name_cache[store_name] = actual_store_name

2. 檔案上傳與狀態管理

LINE 2025-11-10 21.49.26

完整的檔案上傳流程,包含等待 API 處理完成:

async def upload_to_file_search_store(file_path: Path, store_name: str, display_name: Optional[str] = None) -> bool:
    """
    Upload a file to Gemini file search store.
    Returns True if successful, False otherwise.
    """
    try:
        # Check cache first
        if store_name in store_name_cache:
            actual_store_name = store_name_cache[store_name]
        else:
            success, actual_store_name = await ensure_file_search_store_exists(store_name)
            if not success:
                return False
            store_name_cache[store_name] = actual_store_name

        # Upload to file search store
        config_dict = {}
        if display_name:
            config_dict['display_name'] = display_name

        operation = client.file_search_stores.upload_to_file_search_store(
            file_search_store_name=actual_store_name,
            file=str(file_path),
            config=config_dict if config_dict else None
        )

        # Wait for operation to complete (with timeout)
        max_wait = 60  # seconds
        elapsed = 0
        while not operation.done and elapsed < max_wait:
            await asyncio.sleep(2)
            operation = client.operations.get(operation)
            elapsed += 2

        if operation.done:
            print(f"File uploaded successfully")
            return True
        else:
            print(f"Upload operation timeout")
            return False

    except Exception as e:
        print(f"Error uploading to file search store: {e}")
        return False

3. 智能查詢與 File Search 整合

當使用者提問時,系統會先檢查是否有上傳文件,然後使用 File Search 查詢:

async def query_file_search(query: str, store_name: str) -> str:
    """
    Query the file search store using generate_content.
    Returns the AI response text.
    """
    try:
        # Get actual store name from cache or by searching
        actual_store_name = None

        if store_name in store_name_cache:
            actual_store_name = store_name_cache[store_name]
        else:
            # Try to find the store by display_name
            stores = client.file_search_stores.list()
            for store in stores:
                if hasattr(store, 'display_name') and store.display_name == store_name:
                    actual_store_name = store.name
                    store_name_cache[store_name] = actual_store_name
                    break

        if not actual_store_name:
            # Store doesn't exist - guide user to upload files
            return "📁 您還沒有上傳任何檔案。\n\n請先傳送文件檔案(PDF、DOCX、TXT 等)或圖片給我,上傳完成後就可以開始提問了!"

        # Create FileSearch tool with actual store name
        tool = types.Tool(
            file_search=types.FileSearch(
                file_search_store_names=[actual_store_name]
            )
        )

        # Generate content with file search
        response = client.models.generate_content(
            model=MODEL_NAME,
            contents=query,
            config=types.GenerateContentConfig(
                tools=[tool],
                temperature=0.7,
            )
        )

        if response.text:
            return response.text
        else:
            return "抱歉,我無法從文件中找到相關資訊。"

    except Exception as e:
        print(f"Error querying file search: {e}")
        return f"查詢時發生錯誤:{str(e)}"

4. 引用來源(Citations)功能

Gemini File Search API 的一大特色就是會自動提供引用來源,讓使用者可以驗證 AI 回答的準確性。我們實作了完整的引用功能:

提取 Grounding Metadata

當 AI 回答問題時,會在 grounding_metadata 中包含引用資訊:

async def query_file_search(query: str, store_name: str) -> tuple[str, list]:
    """
    Query the file search store using generate_content.
    Returns (AI response text, list of citations).
    """
    # ... (前面的查詢代碼)

    # Extract grounding metadata (citations)
    citations = []
    try:
        if hasattr(response, 'candidates') and response.candidates:
            candidate = response.candidates[0]
            if hasattr(candidate, 'grounding_metadata') and candidate.grounding_metadata:
                grounding_chunks = candidate.grounding_metadata.grounding_chunks
                for chunk in grounding_chunks:
                    if hasattr(chunk, 'web') and chunk.web:
                        # Web source (網頁來源)
                        citations.append({
                            'type': 'web',
                            'title': getattr(chunk.web, 'title', 'Unknown'),
                            'uri': getattr(chunk.web, 'uri', ''),
                        })
                    elif hasattr(chunk, 'retrieved_context') and chunk.retrieved_context:
                        # File search source (文件來源)
                        citations.append({
                            'type': 'file',
                            'title': getattr(chunk.retrieved_context, 'title', 'Unknown'),
                            'text': getattr(chunk.retrieved_context, 'text', '')[:500],
                        })
        print(f"Found {len(citations)} citations")
    except Exception as citation_error:
        print(f"Error extracting citations: {citation_error}")

    return (response.text, citations)

引用快取機制

為了讓使用者可以查看引用詳情,我們實作了引用快取:

# Cache to store citations/grounding metadata for each user/group
# Key: store_name, Value: list of grounding chunks
citations_cache = {}

# 查詢完成後,儲存引用資訊
response_text, citations = await query_file_search(query, store_name)

# Store citations in cache (limit to 3 for Quick Reply)
if citations:
    citations_cache[store_name] = citations[:3]
    print(f"Stored {len(citations_cache[store_name])} citations for {store_name}")

Quick Reply 引用按鈕

在回答中加入 Quick Reply 按鈕,讓使用者一鍵查看引用詳情:

# Create Quick Reply buttons for citations
quick_reply = None
if citations:
    quick_reply_items = []
    for i, citation in enumerate(citations[:3], 1):  # Limit to 3 citations
        quick_reply_items.append(
            QuickReplyButton(action=MessageAction(
                label=f"📖 引用{i}",
                text=f"📖 引用{i}"
            ))
        )
    quick_reply = QuickReply(items=quick_reply_items)

# Reply to user with citations
reply_msg = TextSendMessage(text=response_text, quick_reply=quick_reply)

查看引用詳情

當使用者點擊「📖 引用」按鈕時,顯示完整的引用內容:

# Check if user wants to view a citation
if query.startswith("📖 引用"):
    # Extract citation number
    citation_num = int(query.replace("📖 引用", "").strip())
    if store_name in citations_cache and 0 < citation_num <= len(citations_cache[store_name]):
        citation = citations_cache[store_name][citation_num - 1]

        # Format citation text
        if citation['type'] == 'file':
            citation_text = f"📖 引用 {citation_num}\n\n"
            citation_text += f"📄 文件:{citation['title']}\n\n"
            citation_text += f"📝 內容:\n{citation['text']}"
            if len(citation.get('text', '')) >= 500:
                citation_text += "\n\n... (內容過長,已截斷)"
        elif citation['type'] == 'web':
            citation_text = f"📖 引用 {citation_num}\n\n"
            citation_text += f"🌐 來源:{citation['title']}\n"
            citation_text += f"🔗 連結:{citation['uri']}"

        reply_msg = TextSendMessage(text=citation_text)

設計要點

  • 兩種引用來源:支援文件引用(file)和網頁引用(web)
  • 限制數量:Quick Reply 最多顯示 3 個引用(LINE Bot 限制)
  • 內容截斷:文件內容超過 500 字元會自動截斷,避免訊息過長
  • 快取機制:每個 store 的引用獨立儲存,避免混淆
  • 使用者體驗:一鍵查看引用詳情,無需複製貼上

實際效果

image-20251110211857624

5. Quick Reply 快速操作

當使用者上傳檔案成功後,系統會提供 Quick Reply 按鈕,讓使用者快速執行常見操作:

LINE 2025-11-10 21.57.41

設計要點

  • 明確檔案名稱:Quick Reply 的文字自動帶入 {file_name},避免多檔案時的混淆

  • 一鍵操作:使用者點擊按鈕即可發送完整問題,無需手動輸入

  • 常見需求:提供「生成摘要」、「重點整理」等高頻功能

5. 檔案刪除功能

列出文件

使用者可以輸入「列出檔案」等關鍵字來查看已上傳的文件:

def is_list_files_intent(text: str) -> bool:
    """
    Check if user wants to list files.
    """
    list_keywords = [
        '列出檔案', '列出文件', '顯示檔案', '顯示文件',
        '查看檔案', '查看文件', '檔案列表', '文件列表',
        '有哪些檔案', '有哪些文件', '我的檔案', '我的文件',
        'list files', 'show files', 'my files'
    ]
    text_lower = text.lower().strip()
    return any(keyword in text_lower for keyword in list_keywords)

刪除文件功能

當使用者點擊刪除按鈕時,透過 Postback 事件處理刪除:

async def delete_document(document_name: str) -> bool:
    """
    Delete a document from file search store.
    Note: force=True is required to permanently delete documents from File Search Store.
    """
    try:
        # Try to use SDK method first with force=True
        if hasattr(client.file_search_stores, 'documents'):
            # Force delete is required for File Search Store documents
            client.file_search_stores.documents.delete(
                name=document_name,
                config={'force': True}  # ⚠️ 必須加上 force=True
            )
            return True
    except Exception as sdk_error:
        # Fallback to REST API with force parameter
        import requests
        url = f"https://generativelanguage.googleapis.com/v1beta/{document_name}"
        params = {
            'key': GOOGLE_API_KEY,
            'force': 'true'  # ⚠️ 必須加上 force parameter
        }
        response = requests.delete(url, params=params, timeout=10)
        response.raise_for_status()
        return True

關鍵重點

  • File Search Store 中的文件是 immutable(不可變)
  • 刪除時必須加上 force: True 參數,否則會失敗
  • 雙重後備機制確保相容性(SDK → REST API)

Postback 事件處理

async def handle_postback(event: PostbackEvent):
    """
    Handle postback events (e.g., delete file button clicks).
    """
    try:
        # Parse postback data
        data = event.postback.data
        params = dict(param.split('=') for param in data.split('&'))

        action = params.get('action')
        doc_name = params.get('doc_name')

        if action == 'delete_file' and doc_name:
            success = await delete_document(doc_name)

            if success:
                reply_msg = TextSendMessage(
                    text=f"✅ 檔案已刪除成功!\n\n如需查看剩餘檔案,請輸入「列出檔案」。"
                )
            else:
                reply_msg = TextSendMessage(text="❌ 刪除檔案失敗,請稍後再試。")

            await line_bot_api.reply_message(event.reply_token, reply_msg)
    except Exception as e:
        print(f"Error handling postback: {e}")

🔧 遇到的挑戰與解決方案

1. File Search Store API 的名稱設計

問題:一開始以為可以直接指定 store 的 name,但實際上 create() 不接受 name 參數。

錯誤訊息

FileSearchStores.create() got an unexpected keyword argument 'name'

原因分析

  • File Search Store 的 name 是由 API 自動生成的(格式:fileSearchStores/xxxxx
  • 我們只能設定 display_name 作為識別
  • 需要透過 list() 遍歷來查找對應的 store

解決方案

  1. 使用 display_name 儲存我們定義的名稱(如 user_U123456
  2. 透過 list() 找到對應的 store 並取得實際的 name
  3. 建立 cache 避免重複查詢

2. 中文檔名的編碼問題

問題:當檔案名稱包含中文時,API 呼叫會失敗。

錯誤訊息

'ascii' codec can't encode characters in position 19-21: ordinal not in range(128)

問題分析

# 問題代碼:檔案路徑包含中文
file_path = "uploads/123456_會議記錄.pdf"  # ❌ 編碼錯誤

解決方案

# 解決方案:使用 ASCII 檔名,保留原始名稱供顯示
_, ext = os.path.splitext("會議記錄.pdf")
safe_file_name = f"{message_id}{ext}"  # "123456.pdf" ✅
file_path = UPLOAD_DIR / safe_file_name

# 在 config 中保留原始檔名
config = {'display_name': '會議記錄.pdf'}  # 用於 AI 回答時的引用

好處

  • 檔案系統操作使用 ASCII 路徑(不會出錯)
  • AI 回答時仍然顯示原始中文檔名(使用者友善)

3. Store 不存在時的 404 錯誤

問題:首次上傳檔案時,store 還不存在就嘗試上傳。

錯誤訊息

404 Not Found. {'message': '', 'status': 'Not Found'}

解決方案: 在上傳前先檢查並建立 store:

# 1. 檢查 cache
if store_name in store_name_cache:
    actual_store_name = store_name_cache[store_name]
else:
    # 2. 檢查是否存在,不存在則建立
    success, actual_store_name = await ensure_file_search_store_exists(store_name)
    # 3. 加入 cache
    store_name_cache[store_name] = actual_store_name

# 4. 使用實際的 store name 上傳
operation = client.file_search_stores.upload_to_file_search_store(
    file_search_store_name=actual_store_name,
    file=str(file_path),
    config=config_dict
)

4. 非同步檔案處理

問題:上傳檔案是耗時操作,需要等待處理完成。

解決方案

  1. 使用 aiofiles 進行異步檔案讀寫
  2. 使用 asyncio.sleep() 而非 time.sleep()
  3. 實作輪詢機制等待操作完成
# 等待上傳完成
max_wait = 60  # seconds
elapsed = 0
while not operation.done and elapsed < max_wait:
    await asyncio.sleep(2)  # 異步等待
    operation = client.operations.get(operation)
    elapsed += 2

5. VertexAI 不支援 File Search API

問題:原本想支援 VertexAI,但發現 File Search API 只支援 Gemini API。

官方說明: 根據 Google AI 文件,File Search 功能目前只支援透過 Gemini API 使用。

解決方案

  • 移除所有 VertexAI 相關程式碼和設定
  • 簡化環境變數配置
  • 只需要 GOOGLE_API_KEY 即可

6. 刪除文件需要 force 參數

問題:實作刪除文件功能時,直接呼叫 delete() API 會失敗。

錯誤訊息

刪除失敗,或是沒有回應

原因分析: 根據 Google Gemini File Search API 文件(2025年11月6日發布):

  • File Search Store 中的文件是 immutable(不可變的)
  • 刪除操作必須使用 force: True 參數才能永久刪除
  • 如果不加 force 參數,API 會拒絕刪除請求

解決方案

  1. SDK 方式:在 config 中加上 force: True
    client.file_search_stores.documents.delete(
     name=document_name,
     config={'force': True}  # ⚠️ 必須加上
    )
    
  2. REST API 方式:在 query parameters 中加上 force=true
    params = {
     'key': GOOGLE_API_KEY,
     'force': 'true'  # ⚠️ 必須加上
    }
    response = requests.delete(url, params=params)
    

關鍵學習

  • File Search 的文件一旦建立就是不可變的
  • 如果要「更新」文件,必須先刪除(force delete)再重新上傳
  • 這與一般的 Files API 行為不同(Files API 的檔案 48 小時後自動刪除)

🎯 總結與未來改進

專案亮點

  1. 開箱即用的文件助手:無需安裝 APP,透過 LINE 就能使用
  2. 智能文件分析:結合 Gemini 2.5 Flash 的強大能力
  3. 中文友善:完整支援中文檔名和查詢
  4. 隔離機制:每個對話有獨立的文件庫,安全可靠
  5. 自動化管理:File Search Store 自動建立,使用者無感知
  6. 引用來源追蹤:自動提取並顯示 AI 回答的引用來源,可驗證答案準確性
  7. Quick Reply 便利性:上傳後立即提供快捷操作,查詢後可一鍵查看引用
  8. 多媒體支援:文件查詢 + 圖片分析,一個 Bot 搞定

實戰經驗分享

在開發過程中,我深刻體會到:

1. API 設計的差異

不同雲端服務的 API 設計理念差異很大:

  • Google Gemini:name 由系統生成,開發者設定 display_name
  • 需要適應:透過 list + 遍歷來查找資源

這提醒我們:閱讀官方文件比猜測 API 行為更重要

2. 編碼問題無所不在

即使在 2024 年,編碼問題仍然存在:

  • 檔案系統可能不支援 Unicode
  • API 可能對特殊字元有限制
  • 解決方案:分離「儲存用檔名」和「顯示用檔名」

3. 非同步程式設計的重要性

在處理外部 API 時:

  • 使用 async/await 避免阻塞
  • 使用 asyncio.sleep() 而非 time.sleep()
  • 適當的 timeout 設定避免無限等待

4. Immutable 資料的處理

File Search Store 的設計哲學:

  • 文件一旦上傳就是不可變的(immutable)
  • 刪除需要明確的 force: True 參數
  • 要「更新」文件必須先刪除再上傳
  • 這與其他服務(如 Files API)完全不同

這讓我學到:不同服務有不同的資料模型,不能假設行為一致。

未來改進方向

  1. 效能優化
    • 實作更完整的 cache 機制
    • 批次處理多檔案上傳
    • 壓縮大型檔案
    • 減少 API 呼叫次數
  2. 功能擴展
    • 支援檔案刪除功能(已完成)
    • 支援列出已上傳檔案(已完成)
    • 整合圖片理解功能(已完成)
    • Quick Reply 快速操作(已完成)
    • 引用來源追蹤(已完成,支援查看引用詳情)
    • 支援多檔案批次上傳
    • 檔案分類和標籤管理
    • 檔案內容全文搜尋
    • 引用來源跳轉(如果是文件,顯示頁碼或段落位置)
  3. 使用體驗優化
    • Rich Menu 設計
    • 更友善的錯誤提示
    • 上傳進度顯示(長時間處理時)
    • 查詢歷史記錄
    • 檔案搜尋功能(按檔名或時間)
  4. 安全性強化
    • 檔案大小限制
    • 檔案類型驗證
    • 使用者配額管理
    • 敏感資料過濾
    • Store 定期清理機制

關鍵學習

透過這個專案,我學到了:

  1. Google Gemini File Search 的正確使用方式與 immutable 資料模型
  2. Grounding Metadata 的提取與引用來源追蹤機制
  3. FastAPI 在處理 LINE Bot webhook 的高效性
  4. Python async/await 在 I/O 密集型應用的重要性
  5. 編碼問題的處理策略(分離儲存名稱與顯示名稱)
  6. 雲端原生應用的設計模式
  7. LINE Quick Reply 的情境化應用與使用者體驗提升(檔案摘要 + 引用查看)
  8. AI 對話 vs 傳統 UI:選擇合適的互動方式
  9. API 設計差異:不同服務有不同的資料模型和限制
  10. 雙重後備機制:SDK + REST API 確保穩定性
  11. 依賴版本管理:使用版本範圍避免衝突,追蹤官方文檔更新

最重要的是:

AI 不只是聊天機器人,更是強大的內容分析工具。File Search API 讓我們能輕鬆打造專業級的文件問答系統。

Quick Reply 是 LINE Bot 的靈魂**。在正確的時機提供正確的快捷操作,可以大幅提升使用者體驗和操作效率。無論是上傳後的「檔案摘要」還是查詢後的「查看引用」,都讓使用者能快速完成任務。

引用來源讓 AI 回答更可信。透過 Grounding Metadata 提取引用資訊,使用者可以驗證 AI 的回答來源,這對於專業文件分析特別重要。結合 Quick Reply 的一鍵查看,讓引用功能真正實用。

依賴管理是持續挑戰。新興框架(如 Google ADK)的 API 變化快,需要持續追蹤官方文檔和 GitHub。使用版本範圍而非固定版本,可以提高相容性,但也要注意 breaking changes。

希望這個經驗分享能幫助到正在探索 AI 應用開發的朋友們!

相關資源


如果你覺得這個專案有幫助,歡迎給個 Star ⭐,或是分享給需要的朋友!