之前我其實做過收藏功能。在 line-cafe-action-agent 版本中,我曾用 Gemini Function Calling 實作:
收藏第二間
查看我的收藏
刪除收藏第一間
但這次實際在目前版本輸入「收藏第二間」時,Bot 卻沒有把這句話和上方的推薦連起來。
問題不是 Gemini 突然忘記,而是原本的 Function Calling 收藏留在早期的獨立版本;後來 Bot 的主線改用新的 search session,文字訊息裡又沒有帶著 session ID,目前的 handler 也不會主動找回「最近一批推薦」。
對 Bot 來說,「第二間」因此只是一個失去上下文的編號。它知道使用者想收藏,卻不知道指的是哪一家店。
舊版 Gemini 會判斷使用者想執行哪個動作,再建立一筆等待確認的操作。使用者按下確認後,Bot 才把店家寫進 Firestore。當時也有暫存推薦內容,只是這套上下文銜接沒有跟著後續版本一起保留下來。
那一版已經證明自然語言可以控制收藏,不是假的功能,也不是只有畫面沒有資料。
所以這次更準確的說法,不是「替 Bot 第一次加入收藏」,而是針對這個斷掉的上下文重新優化:
把 Function Calling 階段的實驗版收藏,重新整理成正式的想去清單。
完整程式碼:
https://github.com/zonawang/line-cafe-wishlist
舊版 Function Calling 實驗:
https://github.com/zonawang/line-cafe-action-agent
Action Agent 當時最重要的問題是:Gemini 能不能理解「收藏第二間」,並安全地把它轉成程式操作?
因此它的重點放在:
這些設計很適合驗證「自然語言操作」是否可行。
但當 Bot 後來陸續加入偏好、足跡和主動回訪,舊版收藏沒有一起進入新的主線。它留在 line-cafe-action-agent,而不是目前正式服務的功能之一。
這次要解決的問題也不同。我關心的不再只是 Gemini 有沒有選對 function,而是收藏能不能被找到、會不會重複,以及幾天後能不能自然地接著使用。
一開始,我曾想過直接把喜歡的店放進既有的咖啡足跡。
但兩者代表的事情完全不同:
想去清單:我對這間店有興趣,但還沒去
咖啡足跡:我真的去過,而且留下了體驗
如果混在一起,之後查看紀錄時,就分不清哪些是實際經驗,哪些只是暫時收藏。
所以新版把收藏重新定義成一條清楚的想去流程:
看到推薦
↓
加入想去清單
↓
之後查看收藏
↓
開啟地圖/安排時間/移除
它不會假裝使用者已經去過,也不會要求使用者收藏時就決定日期。
舊版雖然也有「收藏這間」按鈕,但按下後會先送出「收藏第幾間」,再交給 Gemini Function Calling 判斷。
新版的收藏入口同樣放在每一張咖啡廳推薦卡片上,但按鈕會直接送出結構化 Postback。收藏哪一批推薦、其中第幾間,都已經是明確資料,不需要再請模型理解一次。
原本的卡片可以開啟 Google Maps、安排時間或直接記錄造訪;現在多了一個「加入想去清單」。使用者不需要先記住店名,再另外輸入收藏指令。
點下按鈕後,Bot 會回覆:
⭐ 已把「某間咖啡廳」加入你的想去清單。
[查看想去清單]
如果同一間店已經收藏過,Bot 會直接告知,而不是建立第二筆一模一樣的資料。這是舊版沒有處理的部分:舊版每次確認收藏,都可能產生一個新的隨機文件 ID。
這個小差異很重要。收藏按鈕很容易被重複點擊,網路也可能讓使用者不確定第一次是否成功。如果每按一次就多一張卡片,清單很快就會失去整理的意義。

要避免重複,首先得回答一個問題:Bot 怎麼知道兩筆推薦是不是同一家店?
只比較店名並不可靠。
不同地區可能有同名咖啡廳,同一家店的名稱也可能因為分店資訊、空格或語言而稍微改變。因此我採用推薦結果中的 Google Maps URI 作為識別來源。
程式會先把 URI 標準化,再計算 SHA-256,取其中一段作為 Firestore 文件 ID:
Google Maps URI
↓
標準化並計算雜湊
↓
固定的 wishlist item ID
同一位使用者再次收藏相同 URI 時,會落在同一份文件上;店名日後即使更新,也只會更新原本的收藏,不會多出重複項目。
新版資料放在:
cafe-user-wishlists/{ownerId}/entries/{wishlistItemId}
每位使用者都有自己的 entries,內容只保存這個功能真正需要的資料:
ownerId
cafe.title
cafe.uri
createdAt
updatedAt
沒有先替未來想像十幾個欄位,也沒有把完整搜尋結果全部複製進來。
這裡也有一個必須說清楚的現況:舊版使用 cafe-favorites,新版使用 cafe-user-wishlists,兩個 collection 目前不共用資料。
舊版:cafe-favorites/{ownerId}/items
新版:cafe-user-wishlists/{ownerId}/entries
因此以前 Action Agent 裡的收藏,不會自動出現在新版想去清單。如果舊 collection 裡已有需要保留的正式資料,下一步應該做一次 migration:讀取舊項目、用 Maps URI 產生新版 ID,再寫入新的 entries。
這不是兩套收藏可以永遠並存的優點,而是這次回頭比對後發現、需要明確記錄的資料銜接問題。
推薦卡片上的按鈕帶有兩項資訊:搜尋 session ID,以及咖啡廳在這批結果中的編號。
Bot 收到操作後,不會直接相信按鈕傳回來的店名,而是重新讀取原本的搜尋 session,確認:
通過檢查後,才把咖啡廳寫入使用者自己的清單。
這樣做也讓按鈕資料保持很短。LINE Postback 不需要夾帶一整段店家 JSON,更不需要相信可以被重新組合的外部輸入。
收藏完成後,使用者可以輸入:
我的想去清單
想去清單
我的收藏
收藏的咖啡廳
咖啡收藏
Bot 會把最近收藏的店做成 Flex Message 輪播。每張卡片提供三個下一步:
如果清單是空的,Bot 不只回答「沒有資料」,還會附上「傳送目前位置」按鈕,讓使用者能立刻回到找店流程。
我把單次顯示限制在最近 10 間。想去清單的目的,是幫助使用者做下一個決定,不是一次塞進無限長的歷史資料。
這些固定指令會先由 wishlist text handler 判斷,不必每次都呼叫 Gemini。和舊版相比,結果更固定、回覆更快,也不會為「查看清單」這類明確操作產生模型成本。

這次最重要的銜接,是收藏卡片上的「安排喝咖啡時間」。
使用者可能在幾天前收藏一間店。等到週末有空時,只要開啟想去清單、選擇日期時間,就能延續原本的行程流程,不必重新分享位置,也不必期待同一家店再次被推薦。
新的地方只有「時間是從 wishlist item 選出來的」。後續的 Calendar 與行程處理仍沿用既有能力,沒有為收藏另外複製一套排程系統。
這也是我這次刻意遵守的原則:
新功能負責補上缺口,不重新發明已經能工作的部分。
新版在去重、卡片呈現、固定入口和後續行程上比較完整,但舊版仍有一個值得保留的設計:刪除前會二次確認。
目前新版點下「移出想去清單」後會直接刪除,操作比較快,卻也比較容易因為誤觸而失去收藏。
如果繼續改善,我會在兩種方向中選一個:
產品化不代表新版本的每個決定都一定勝過舊版。把兩次實作放在一起比較,反而更容易看見被自己不小心拿掉的保護措施。
這次新增的測試主要集中在收藏自己的規則:
加上既有測試,目前共有 40 項測試全數通過,TypeScript typecheck 與 GitHub Actions 也保持成功。
測試數量不是重點。真正要確認的是:重複點擊不會產生垃圾資料、過期按鈕不能亂寫入,以及舊功能沒有因為推薦卡多一個按鈕而壞掉。
這次重做的功能不大,甚至沒有新的 AI 模型。
但它讓 Bot 的使用方式自然了很多。使用者不需要在看到推薦時立刻做決定,也不會因為今天沒空去,就失去一間感興趣的店。
現在流程多了一個很符合生活的停靠點:
現在看到 → 先收藏 → 有空再決定
對我來說,這也是產品逐漸完整的一個訊號。不是每次更新都要讓 Bot 更聰明,也不是曾經做過的功能就不能重做。實驗版回答「做不做得到」,產品版則要回答「找不找得到、用不用得久」。
👉 GitHub:https://github.com/zonawang/line-cafe-wishlist
👉 更多 LINE Bot 與 AI 實作紀錄: https://github.com/zonawang/zona-ai-learning-lab