緣起:聊天打到一半,那張梗圖到底在哪?
每個重度聊天的人手機與電腦裡都存了一堆迷因圖,但真正要用的時候——對話進行到一半、想丟一張「謝謝再聯絡」或「我就爛」——卻怎麼也翻不到。檔名是 IMG_4821.jpg,相簿沒有分類,搜尋更是無從下手。
我先看到一個很棒的開源專案 ShiQu1218/MemeTalk,它用 Python + Streamlit + SQLite 打造了一套本地迷因語意搜尋系統,會掃描你本機的迷因資料夾、用 OCR 與向量嵌入建立索引,再做多路召回。功能完整,但偏研究取向、要開瀏覽器跑 Streamlit。
我想要的是更貼近「日常順手工具」的東西:
一個原生 Mac App,一個搜尋框,打我想找的內容,就跳出相關的梗圖,點一下直接複製到剪貼簿。
於是有了 MemeFinder。這篇文章紀錄它從零到「選單列常駐 + 全域快捷鍵」的開發過程,以及途中幾個很有代表性的坑。
系統設計與架構
核心概念很單純:指定一個本機迷因資料夾 → 用 Gemini 幫每張圖建立索引 → 打字做語意搜尋 → 點圖複製。
技術選型上我做了三個關鍵決定:
- 原生 SwiftUI App,而不是 Electron。剪貼簿複製圖片、全域快捷鍵、選單列常駐,這些用 AppKit 都是一級公民。
- Gemini 負責兩件事:用視覺模型
gemini-3-flash-preview讀出圖中文字、生成繁中描述與情緒標籤;用gemini-embedding-2把這些語意轉成 768 維向量。 - 語意向量 + 關鍵字混合搜尋。純關鍵字對中文召回太差;語意向量才能做到「打相關敘述就找到圖」。
系統架構流向
graph TD
A[使用者指定迷因資料夾] -->|掃描 jpg/png/webp| B[Indexer 索引器]
B -->|每張圖| C[Gemini 視覺模型 gemini-3-flash-preview]
C -->|OCR文字 + 描述 + 標籤 + 情緒| D[Gemini 嵌入 gemini-embedding-2]
D -->|768 維向量| E[本機索引檔 index.json]
F[使用者打字查詢] -->|⌃⌘M 選單列浮窗| G[Gemini 嵌入查詢字串]
G -->|cosine 相似度 + 關鍵字加權| E
E -->|排序結果| H[縮圖牆]
H -->|點圖| I[NSPasteboard 複製到剪貼簿]
整個專案刻意拆成兩個 Swift Package target:
| Target | 類型 | 內容 |
|---|---|---|
MemeFinder |
library | 邏輯、模型、服務、ViewModel(全部有單元測試) |
MemeFinderApp |
executable | SwiftUI 畫面 + 選單列殼(薄殼,依賴上面的函式庫) |
這個拆分不是裝飾——它直接決定了測試能不能順利跑,後面「踩坑二」會講到為什麼。
核心實作
1. 用 Gemini 視覺模型自動標註迷因圖
索引時,每張圖會送進視覺模型,要求它只輸出 JSON:圖中文字、繁中描述、標籤、情緒。responseMimeType 設成 application/json 來穩定輸出格式:
public static func annotateRequest(apiKey: String, imageData: Data, mimeType: String) -> URLRequest {
let prompt = """
你是迷因圖標註助手。請閱讀這張圖,輸出 JSON,欄位:
ocr_text(圖中所有文字), description(用繁體中文描述畫面與梗),
tags(3-8 個繁體中文關鍵字陣列), emotion(單一情緒詞)。只輸出 JSON。
"""
let body: [String: Any] = [
"contents": [[
"parts": [
["text": prompt],
["inline_data": ["mime_type": mimeType, "data": imageData.base64EncodedString()]]
]
]],
"generationConfig": ["responseMimeType": "application/json"]
]
// ... 設定 URL、x-goog-api-key header、POST body
}
2. 語意 + 關鍵字混合排序
查詢字串嵌入成向量後,對每張圖算 cosine 相似度,再對 OCR 文字與標籤命中的關鍵字加權,合併排序:
public func search(queryEmbedding: [Float], queryText: String,
in images: [IndexedImage], limit: Int) -> [SearchResult] {
let tokens = queryText.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
let results: [SearchResult] = images.compactMap { image in
let cos = cosineSimilarity(queryEmbedding, image.embedding)
let haystack = (image.ocrText + " " + image.tags.joined(separator: " ")).lowercased()
let matches = tokens.filter { !$0.isEmpty && haystack.contains($0) }.count
let boost = 0.1 * Float(min(matches, 3)) // 關鍵字加權上限 0.3
let score = cos + boost
return score > 0 ? SearchResult(image: image, score: score) : nil
}
return Array(results.sorted { $0.score > $1.score }.prefix(limit))
}
整個搜尋引擎是純函式,把 Gemini 藏在 protocol 後面,所以這段邏輯完全能離線單元測試,不用打真實 API。
重大踩坑與解決方案
這個專案真正花時間的地方,從來不是「快樂路徑」,而是下面這幾個坑。
踩坑一:神秘的 GeminiError error 0——索引與搜尋全部失敗
App 打包完、設定好金鑰、選好資料夾,一搜尋——下面什麼圖都沒有,只跳出 GeminiError error 0。
我沒有亂猜,而是直接用真實金鑰打了一次 embedding 端點,把回應印出來:
curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:embedContent" \
-H "x-goog-api-key: $KEY" \
-d '{"content":{"parts":[{"text":"貓"}]},"output_dimensionality":768}'
證據一翻兩瞪眼:
{ "embedding": { "values": [ -0.0063, -0.0200, ... ] } }
問題在於,我的解析器讀的是 複數 embeddings[0].values(那是 batchEmbedContents 批次端點的格式),但單筆 embedContent 回的是 單數 embedding.values。於是每一次 embed 都失敗——索引每張圖失敗、把查詢字串轉向量也失敗,全都丟出 badResponse(在 UI 上顯示成 GeminiError error 0)。
【解決方案】
修正解析器讀單數 embedding.values,並保留複數格式作為後備;順手也加固了標註解析器(思考型模型有時會多回一個沒有文字的 “thought” part,要跳過取第一個有文字的 part):
public static func embedding(fromEmbedContent data: Data) throws -> [Float] {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw GeminiError.badResponse("cannot parse embedContent payload")
}
// 單筆 embedContent 回傳 {"embedding":{"values":[...]}}
if let embedding = root["embedding"] as? [String: Any],
let values = embedding["values"] as? [Double] {
return values.map(Float.init)
}
// batchEmbedContents 才是 {"embeddings":[{"values":[...]}]} — 一併容忍
if let embeddings = root["embeddings"] as? [[String: Any]],
let values = embeddings.first?["values"] as? [Double] {
return values.map(Float.init)
}
throw GeminiError.badResponse("cannot parse embedContent payload")
}
教訓:API 回應格式請以真實回應為準,不要相信記憶或二手文件。一行 curl 省下無數猜測。
踩坑二:SwiftPM 的 main 入口衝突與 SwiftUICore 連結錯誤
我一開始把整個專案做成單一 executableTarget,讓測試直接依賴它。結果測試怎麼跑都連結失敗:executable target 需要一個 main 進入點,但這個進入點要到 UI 那一步的 @main App 才會存在;而隨手補一個 main.swift 佔位檔,又會和 @main 衝突(Swift 不允許一個 target 同時有兩個進入點)。更別說 SwiftUI 在 executable target 還會冒出 SwiftUICore.tbd ... not an allowed client 的連結警告。
【原因分析與解決方案】 這其實是個架構問題,不是編譯問題。正確做法是把專案拆成兩層:
MemeFinder(library target):所有邏輯、模型、服務、ViewModel——測試只依賴這層,沒有進入點,乾乾淨淨地當函式庫連結。ViewModel 要import Combine(而不是 SwiftUI)就能拿到ObservableObject。MemeFinderApp(executable target):只放 SwiftUI 畫面與@main,import MemeFinder取用上面的公開型別。
拆完之後,library 與測試完全不碰 SwiftUI,連結警告消失,@main 衝突也不復存在。「測試要依賴什麼」往往會反過來逼出乾淨的模組邊界。
踩坑三:平行索引的速率限制與「索引到一半想喊停」
第一版索引是一張一張序列呼叫 Gemini(先 annotate 再 embed),上百張圖慢到讓人懷疑人生。於是改成用 withTaskGroup 做有界平行(同時最多 4 條),但這帶出三個新問題:
- Gemini 免費額有速率限制,併發太多會吃 429。
- 大資料夾索引到一半,使用者想取消。
- 平行完成的順序是亂的,但結果要穩定排序。
【解決方案】
三個問題分別處理,全部收斂在同一個 buildIndex 裡:
- 429 退避重試:只對
GeminiError.rateLimited做指數退避重試(最多 3 次),其他錯誤直接記錄不重試。 - 協作式取消:尊重
Task.isCancelled,取消時停止派新工作、保留已完成的部分。連退避時的Task.sleep都讓CancellationError正常傳遞,而不是吞掉它再多打一次 API。 - 穩定排序:結果收進
[路徑: 圖]字典,最後依「事先排好序的檔案清單」重組輸出,跟完成順序脫鉤。
// 先塞滿 maxConcurrent 個任務,之後每完成一個就補一個——嚴格維持併發上限
for _ in 0..<maxConcurrent { if !scheduleNext() { break } }
while let res = await group.next() {
if let img = res.image { resultsByPath[res.path] = img }
if let err = res.error { errors.append(err) }
done += 1
progress(done, total)
_ = scheduleNext()
}
順帶一提,HTTP 狀態碼也被抽成一個純函式 mapResponse(data:statusCode:):429 → rateLimited、其他非 2xx → httpError(碼)、2xx → 回傳資料。重試邏輯才有依據,這段也好測。
踩坑四:從「有視窗的 App」進化成「選單列常駐 + 全域快捷鍵」
工具好不好用,差別在於「叫出它要幾步」。我希望聊天到一半按 ⌃⌘M 就能呼叫搜尋浮窗,App 平常縮在選單列、不佔 Dock。這一步踩了兩個 macOS 老坑:
(a) 全域快捷鍵要不要輔助使用權限? 不用。用 Carbon 的 RegisterEventHotKey 註冊固定快捷鍵,不需要 Accessibility 權限(不像監聽全鍵盤)。但在 Swift 6 嚴格並行下,C 事件回呼要透過一個 id → 實例 的靜態註冊表來分派,得用 nonisolated(unsafe) 並靠「Carbon 事件只在主執行緒派發」這個不變量來保證安全。若 ⌃⌘M 已被佔用,RegisterEventHotKey 會回傳失敗——這時靜默降級、記一筆 log,點選單列 icon 仍可用。
(b) 選單列右鍵選單的時序競態。 最初的寫法是「設定 statusItem.menu → performClick → 馬上清空 menu」,但同步清空會和 AppKit 的選單追蹤迴圈打架,選單會閃一下就消失。
【解決方案】
改用直接彈出選單,完全繞過 statusItem.menu 的賦值與清空:
@objc private func statusButtonClicked() {
guard let event = NSApp.currentEvent else { togglePopover(); return }
if event.type == .rightMouseUp {
// 直接彈出,不要賦值再同步清空 statusItem.menu(會和 AppKit 選單追蹤迴圈競態)
if let button = statusItem?.button {
NSMenu.popUpContextMenu(makeMenu(), with: event, for: button)
}
} else {
togglePopover()
}
}
最後在 build-app.sh 打包的 Info.plist 加上 LSUIElement = true,Dock 圖示消失,MemeFinder 正式成為純選單列工具。
踩坑五:設定表單一片空白——一個症狀,三層原因
改成選單列版後,使用者回報「設定視窗整個是空白的」。這個看似單純的 bug,剝開來其實有三層,每一層都很有代表性。
第一層:Form 在手刻 NSWindow 裡塌成零高度。
原本設定畫面住在 SwiftUI 原生的 Settings { } scene 裡,SwiftUI 會自動給它合理尺寸。改版後改用手刻的 NSWindow(contentViewController: NSHostingController(rootView: SettingsView())) 承載,而 SettingsView 的結尾只寫了 .frame(width: 460)——只有寬度、沒有高度。NSWindow(contentViewController:) 會用內容的自然尺寸決定視窗大小,但 SwiftUI Form 在垂直方向是貪婪的、沒有約束時自然高度會被算成接近 0,於是視窗開成一條 460 寬、高度幾乎為零的空白條。修正只要補上高度:
.padding(20)
// 在手刻 NSWindow(非 SwiftUI Settings scene)裡承載時,沒有高度約束的
// Form 會塌成 ~0,視窗就變成一條空白。
.frame(width: 460, height: 320)
第二層:⌘, 和選單列「設定…」走的是兩條不同的路。
補了高度後,使用者說「還是空白」。追問之下才發現——他是按 ⌘, 叫出設定的,而選單列右鍵的「設定…」走的卻是另一條路。原因是:⌘, 在 SwiftUI App 會觸發 Settings { } scene,而我當初為了迴避狀態共享問題,把那裡放成了 Settings { EmptyView() }:
// 改版時為了避開狀態共享,Settings scene 被放空——於是 ⌘, 開出來就是一片空白
var body: some Scene {
Settings { EmptyView() }
}
換句話說,設定有兩個入口,而它們指向不同的東西:⌘, 指向空 scene,選單列「設定…」指向真正的視窗。修正是把兩條路統一——讓 Settings scene 裝真正的 SettingsView(⌘, 直接可用),選單列「設定…」也改成開同一個原生設定視窗:
Settings {
SettingsView(vm: appDelegate.settings, indexing: appDelegate.indexing,
onReindex: { appDelegate.reindexNow() },
onCancel: { appDelegate.cancelReindex() })
}
// 選單列「設定…」改成開同一個 Settings scene
@objc private func openSettings() {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
這裡也順帶把 SwiftUI App body 是 @MainActor 隔離這件事用上了——所以從 body 直接讀取 @MainActor 的 appDelegate.settings 是合法的,不需要額外的橋接。
第三層(最陰險的):選單列 App 用 open 根本不會載入新版。
過程中最浪費時間的,是我重新編譯後請使用者 open MemeFinder.app,他卻一直看到舊行為。因為 MemeFinder 是 LSUIElement 選單列常駐 App——當已經有一個實例在跑時,open 只會喚醒既有的舊行程,而不會用新的二進位重啟。於是我們其實一直在測同一個舊版。正確的開發循環是先確實關掉,再從原始碼跑:
killall MemeFinderApp 2>/dev/null; swift run MemeFinderApp
這層提醒我:debug 時要先確認「你測的真的是你改的那一版」,否則所有推理都建立在錯誤的觀察上。
關於「開發過程」本身
這個專案幾乎全程是用規格 → 計畫 → 子代理逐項實作 → 兩段式審查的 AI agent 工作流推進的:每個功能先寫設計規格、再拆成可獨立測試的小任務、每個任務都先寫失敗測試(TDD)再實作,完成後由獨立的審查代理檢查規格符合度與程式品質,最後再做一次整支分支的總審。
幾個踩坑——GeminiError error 0、library/executable 拆分、退避時吞掉 CancellationError、選單時序競態——其實有一半是審查階段揪出來的,而不是第一版就寫對。這也呼應了那條老原則:有測試護體、有人(或代理)認真讀 diff,比寫得快重要得多。 最終整支專案維持 47 個單元測試、release build 零警告。
成果與效益
- 打字即得、點圖即貼:在選單列浮窗打中文敘述,語意搜尋立刻列出相關梗圖,點一下複製到剪貼簿,直接貼進 LINE / Slack / 訊息。
- 隱私友善、離線可搜:圖片與索引都在本機(
~/Library/Application Support/MemeFinder/index.json),只有「建立索引」那一步會呼叫 Gemini。 - 真正的順手工具:⌃⌘M 隨叫隨到、選單列常駐、不佔 Dock;增量索引只處理新增/變動的圖,索引可顯示進度、可取消。
- 乾淨可維護的架構:library/executable 雙層、Gemini 藏在 protocol 後、純邏輯全有測試覆蓋。
本專案所有開發程式碼均已開源於 GitHub:kkdai/meme-finder-app。歡迎大家 clone 下來、放進自己的迷因收藏資料夾,親自體驗一下「打字就找到梗圖」的快感!
- macOS (3) ,
- SwiftUI (1) ,
- Gemini API (4) ,
- Embeddings (1) ,
- Swift Concurrency (1) ,
- Swift Package Manager (1) ,
- Menu Bar App (1)