<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>Blog E</title>
		<description>Attitude is everything</description>
		<link>https://www.evanlin.com/</link>
		<atom:link href="https://www.evanlin.com/feed.xml" rel="self" type="application/rss+xml" />
		
			<item>
				<title>[Claude Code 實戰] 重新盤點終端機工作流：從 zsh 自動完成到搜尋比對工具鏈</title>
				<description>&lt;h1 id=&quot;痛點每打一個指令都要重新來一次&quot;&gt;痛點：每打一個指令，都要重新來一次&lt;/h1&gt;

&lt;p&gt;平常用 Claude Code 處理事情的時候，一直有兩個小摩擦一直在，只是還沒認真處理過。&lt;/p&gt;

&lt;p&gt;第一個是 shell 本身太陽春：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.zshrc&lt;/code&gt; 裡除了兩行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt; 什麼都沒有，沒有自動完成、沒有指令記憶，翻歷史只能一路按上鍵慢慢找，找到類似指令還得手動改。第二個是跟 Claude Code 協作時，一些明明是唯讀、完全不會有副作用的指令（列檔案、查版本、curl 個 README），每次都要跳出來按一次允許，一來一回打斷節奏。&lt;/p&gt;

&lt;p&gt;這篇記錄的就是一次把這兩件事一次盤點掉的過程：怎麼選工具、踩到什麼坑、以及最後怎麼跟 Claude Code 的權限系統對接起來。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;解法一先讓-zsh-自己記得你打過什麼&quot;&gt;解法一：先讓 zsh 自己記得你打過什麼&lt;/h1&gt;

&lt;p&gt;沒有裝 oh-my-zsh，也不想為了兩個功能扛一整套框架，所以直接挑最小夠用的兩個套件：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;zsh-autosuggestions&lt;/strong&gt;：打字時用灰色文字提示之前打過的類似指令，按 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl+空白鍵&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;→&lt;/code&gt; 接受&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;zsh-completions&lt;/strong&gt;：補強 tab 自動完成的涵蓋範圍&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;brew &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;zsh-autosuggestions zsh-completions
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;再搭配歷史紀錄設定，讓上下鍵可以依照目前已輸入的內容去篩選歷史，而不是整批往前翻：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# --- 指令歷史紀錄設定 ---&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;HISTFILE&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;~/.zsh_history
&lt;span class=&quot;nv&quot;&gt;HISTSIZE&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;10000
&lt;span class=&quot;nv&quot;&gt;SAVEHIST&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;10000
setopt SHARE_HISTORY       &lt;span class=&quot;c&quot;&gt;# 多個終端機視窗共用歷史紀錄&lt;/span&gt;
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_FIND_NO_DUPS
setopt INC_APPEND_HISTORY  &lt;span class=&quot;c&quot;&gt;# 指令一輸入就馬上寫入歷史檔&lt;/span&gt;

autoload &lt;span class=&quot;nt&quot;&gt;-Uz&lt;/span&gt; up-line-or-beginning-search down-line-or-beginning-search
zle &lt;span class=&quot;nt&quot;&gt;-N&lt;/span&gt; up-line-or-beginning-search
zle &lt;span class=&quot;nt&quot;&gt;-N&lt;/span&gt; down-line-or-beginning-search
bindkey &lt;span class=&quot;s2&quot;&gt;&quot;^[[A&quot;&lt;/span&gt; up-line-or-beginning-search
bindkey &lt;span class=&quot;s2&quot;&gt;&quot;^[[B&quot;&lt;/span&gt; down-line-or-beginning-search
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;裝完套件、加完設定，理論上重開終端機就能用了——實際上沒有這麼順利。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;解法二補顏色讓終端機看得更快&quot;&gt;解法二：補顏色，讓終端機看得更快&lt;/h1&gt;

&lt;p&gt;自動完成裝好之後，順手把顏色也一起補了：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;zsh-syntax-highlighting&lt;/strong&gt;：打指令時即時上色，有效指令綠色、無效指令紅色&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ls -G&lt;/code&gt;：資料夾、執行檔、連結各自不同顏色&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep --color=auto&lt;/code&gt;：比對到的關鍵字直接標紅&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;CLICOLOR&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;1
&lt;span class=&quot;nb&quot;&gt;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;LSCOLORS&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;GxFxCxDxBxegedabagaced
&lt;span class=&quot;nb&quot;&gt;alias grep&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;grep --color=auto&apos;&lt;/span&gt;

&lt;span class=&quot;nb&quot;&gt;source&lt;/span&gt; /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;字體也一起處理：Ghostty 的設定檔（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/Library/Application Support/com.mitchellh.ghostty/config.ghostty&lt;/code&gt;）原本沒指定字體大小，吃系統預設的 13，直接加一行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;font-size = 16&lt;/code&gt; 解決。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;解法三把-lscatcd-也換成更聰明的版本&quot;&gt;解法三：把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ls&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cat&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cd&lt;/code&gt; 也換成更聰明的版本&lt;/h1&gt;

&lt;p&gt;顏色跟自動完成算是基礎建設，接著補了三個常用指令的現代化替代品，選擇標準只有一個：&lt;strong&gt;單一執行檔、沒有背景常駐程序，不會拖慢啟動速度&lt;/strong&gt;。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;指令&lt;/th&gt;
      &lt;th&gt;替代品&lt;/th&gt;
      &lt;th&gt;換來什麼&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ls&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eza --icons&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;彩色、圖示、樹狀結構（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lt&lt;/code&gt;）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cat&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bat --paging=never&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;語法高亮、行號&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cd&lt;/code&gt;（輔助）&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zoxide&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;記住常去的資料夾，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;z proj&lt;/code&gt; 直接跳過去&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fzf&lt;/code&gt; 這次評估後沒有裝——目前的使用習慣還沒有到需要模糊搜尋歷史/檔案的程度，等真的有感覺卡再補。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;解法四讓-claude-code-搜尋比對檔案更快&quot;&gt;解法四：讓 Claude Code 搜尋、比對檔案更快&lt;/h1&gt;

&lt;p&gt;前面幾項是「人用得爽」，這一項是「Claude Code 用得快」。Claude Code 內建的搜尋工具本身就是用 ripgrep（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rg&lt;/code&gt;）在跑，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jq&lt;/code&gt; 也已經裝了，所以缺的是這三個：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;fd&lt;/strong&gt;：取代 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;find&lt;/code&gt;，語法簡單、速度快，列檔案清單特別有感&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ast-grep&lt;/strong&gt;：&lt;strong&gt;結構化&lt;/strong&gt;程式碼搜尋，不是純文字比對，而是看語法樹（AST）。可以搜「所有呼叫某函式且第一個參數是字串的地方」，對大範圍重構或精準搜尋特定寫法比 regex 準確很多&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;difftastic&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;difft&lt;/code&gt;）：語法感知的 diff，函式搬位置也看得出來只是移動，不是整段被砍掉重寫&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;brew &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;fd ast-grep difftastic git-delta
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git-delta&lt;/code&gt; 主要是給人看 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git diff&lt;/code&gt; 用的，跟 Claude Code 本身無關，但反正裝了就順手一起弄。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;解法五把常用唯讀指令收進-claude-code-的權限白名單&quot;&gt;解法五：把常用唯讀指令收進 Claude Code 的權限白名單&lt;/h1&gt;

&lt;p&gt;工具裝好之後浮出一個新問題：這些新指令第一次被 Claude Code 呼叫時，一樣得跳出來問一次要不要允許。用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fewer-permission-prompts&lt;/code&gt; 這個 skill 掃了最近幾個 session 的 transcript，統計出實際常跑、而且真的是唯讀的指令，整理出一份白名單：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;permissions&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;allow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(curl -s https://raw.githubusercontent.com/*)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(curl -s &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://api.github.com/*)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(brew list*)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(xcodes list*)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(xcodebuild -version)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(curl -sI *)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(difft *)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Bash(delta *)&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fd&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rg&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jq&lt;/code&gt; 沒有出現在清單裡，不是漏掉，是因為 Claude Code 本身已經把這幾個列為內建自動允許的唯讀指令，不需要再加規則。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;容易忽略的三個坑&quot;&gt;容易忽略的三個坑&lt;/h1&gt;

&lt;h3 id=&quot;坑一compinit-抱怨insecure-directories&quot;&gt;坑一：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compinit&lt;/code&gt; 抱怨「insecure directories」&lt;/h3&gt;

&lt;p&gt;裝完套件重開終端機，第一次啟動就跳出：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;zsh compinit: insecure directories, run compaudit for list.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compaudit&lt;/code&gt; 查出問題出在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/opt/homebrew/share&lt;/code&gt; 這層目錄權限太開放（group 有寫入權限）。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compinit&lt;/code&gt; 在載入補全腳本前會檢查權限，只要有一層目錄是「別人也能寫」，就直接拒絕載入，避免有心人把惡意腳本塞進補全路徑裡執行。修法是 Homebrew 官方就建議的做法：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;chmod &lt;/span&gt;go-w /opt/homebrew/share
&lt;span class=&quot;nb&quot;&gt;chmod&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-R&lt;/span&gt; go-w /opt/homebrew/share/zsh
&lt;span class=&quot;nb&quot;&gt;rm&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; ~/.zcompdump&lt;span class=&quot;k&quot;&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;清掉快取讓它重建一次，問題就沒再出現過。&lt;/p&gt;

&lt;h3 id=&quot;坑二zsh-syntax-highlighting-一定要放在檔案最後一行&quot;&gt;坑二：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zsh-syntax-highlighting&lt;/code&gt; 一定要放在檔案最後一行&lt;/h3&gt;

&lt;p&gt;這個套件的官方文件寫得很明白：這一行必須是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.zshrc&lt;/code&gt; 裡&lt;strong&gt;最後被執行&lt;/strong&gt;的東西，放在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zsh-autosuggestions&lt;/code&gt; 或其他 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bindkey&lt;/code&gt; 設定前面的話，語法上色跟自動建議會互相干擾、按鍵綁定也可能失效。整理設定檔的時候特別把它挪到檔案最尾端，而不是照裝的順序隨手加在中間。&lt;/p&gt;

&lt;h3 id=&quot;坑三ast-grep-沒有被放進白名單是刻意的&quot;&gt;坑三：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ast-grep&lt;/code&gt; 沒有被放進白名單，是刻意的&lt;/h3&gt;

&lt;p&gt;白名單清單裡少了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ast-grep&lt;/code&gt;，不是漏掉。它預設跑起來是唯讀搜尋沒錯，但只要加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-U&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--update-all&lt;/code&gt; 參數就會直接改寫檔案。權限規則是前綴比對（prefix match），沒辦法只允許「不帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-U&lt;/code&gt;」的用法——只要開了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bash(ast-grep *)&lt;/code&gt; 這種大範圍規則，理論上就等於連改寫檔案的用法也一起放行了。&lt;/p&gt;

&lt;p&gt;這跟專案原本對 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sed&lt;/code&gt; 的處理邏輯是一致的：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sed&lt;/code&gt; 也只有「唯讀運算式」的用法會被自動允許，帶有 in-place 編輯的用法一律還是要跳出來問一次。與其自己重新評估一次風險，不如直接沿用同一套判斷標準。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;總結與效益&quot;&gt;總結與效益&lt;/h1&gt;

&lt;p&gt;這次的終端機環境重整，說到底是把「人打字」跟「Claude Code 執行」兩件事分開優化：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;打字更少&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zsh-autosuggestions&lt;/code&gt; + 歷史篩選，重複指令幾乎不用重打&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;看得更快&lt;/strong&gt;：語法上色、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eza&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bat&lt;/code&gt; 讓輸出一眼就能分辨重點&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;找路更快&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zoxide&lt;/code&gt; 取代死記路徑，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fd&lt;/code&gt; 取代龜速的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;find&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Claude Code 搜得更準&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ast-grep&lt;/code&gt; 補上純文字比對做不到的結構化搜尋，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;difftastic&lt;/code&gt; 讓 diff 結果更貼近實際改動&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;少按幾次允許&lt;/strong&gt;：把真正唯讀、且風險可控的指令收進白名單，剩下真正該問的（像 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ast-grep -U&lt;/code&gt;）還是照樣會問&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;工具選擇的主軸自始至終沒變過：單一執行檔、沒有背景常駐、能不裝框架就不裝框架。真正花時間的反而是判斷「這個要不要放進白名單」——速度是其次，會不會不小心把一個能改檔案的指令包成看似安全的萬用規則，才是要謹慎的地方。&lt;/p&gt;
</description>
				<pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/claude-code-tool-enhance/</link>
				<guid isPermaLink="true">https://www.evanlin.com/claude-code-tool-enhance/</guid>
			</item>
		
			<item>
				<title>[開發紀錄][Node.js] Feedly Classic 回不去了，我自己動手做了一個 FeedFlow（一）</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260726142208046.png&quot; alt=&quot;image-20260726142208046&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;前言&quot;&gt;前言:&lt;/h2&gt;

&lt;p&gt;Feedly 改版之後，介面我一直不習慣，尤其是懷念舊版 Feedly Classic 那種一欄式、資訊密度高、不拖泥帶水的閱讀節奏。訂閱的來源裡有不少英文、日文、韓文的技術部落格，每次要嘛開分頁丟去翻譯、要嘛乾脆跳過，久而久之這些來源就變成已讀不回。&lt;/p&gt;

&lt;p&gt;與其繼續將就，我花了一個週末，自己刻了一個 RSS 閱讀器：&lt;a href=&quot;https://github.com/kkdai/rss-feed-class-webapp&quot;&gt;FeedFlow&lt;/a&gt;。手機瀏覽器優先、深色主題、多欄位檢視模式，這部分是在向 Feedly Classic 致敬；資料存在 Google Firestore，帳號綁 LINE Login；訂閱到的非中文文章，背景會自動用 Gemini 2.5 Flash 翻成繁體中文。目前已經部署在 &lt;a href=&quot;https://feedflow-660825558664.asia-east1.run.app&quot;&gt;Cloud Run 上面&lt;/a&gt;，自己每天在用。&lt;/p&gt;

&lt;p&gt;這個 repo 會持續開發下去，這篇是系列文章的第一篇，先把整個專案的骨架、幾個關鍵決策的來龍去脈記錄下來。&lt;/p&gt;

&lt;h1 id=&quot;tldr&quot;&gt;TL;DR&lt;/h1&gt;

&lt;p&gt;本篇文章會依序介紹：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#why&quot;&gt;為什麼要自己刻一個 RSS 閱讀器&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#line-login&quot;&gt;LINE Login 在這個專案裡代表的意義&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#rss-dev&quot;&gt;RSS 閱讀器的開發過程：從單機 MVP 到多使用者雲端同步&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#gemini&quot;&gt;為什麼選 Gemini 當翻譯引擎，中間踩了哪些坑&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#frontend&quot;&gt;前端介面：四種檢視模式與行動裝置優先設計&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#secret-cleanup&quot;&gt;意外的插曲：把洩漏的密鑰從 git 歷史裡挖乾淨&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#whats-next&quot;&gt;目前進度與下一篇的方向&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#summary&quot;&gt;結論&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#refer&quot;&gt;參考連結&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1 id=&quot;為什麼要自己刻一個-rss-閱讀器&quot;&gt;為什麼要自己刻一個 RSS 閱讀器&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;why&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;市面上不缺 RSS 閱讀器，但我要的東西很具體：手機上開起來要快、介面資訊密度要高（不要每篇文章都用一張大圖佔掉整個螢幕）、然後外文來源要能就地翻譯，不用切換到別的分頁。這三個條件湊在一起，現成的服務沒有一個完全符合，尤其是「非中文文章自動翻譯」這件事，幾乎沒有閱讀器把它當一等公民做。&lt;/p&gt;

&lt;p&gt;專案取名 FeedFlow，核心就三塊：後端一支 Express（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;server.js&lt;/code&gt;），負責抓 RSS、解析內容、呼叫 Gemini、讀寫 Firestore；前端是純 Vanilla JS 的 ES Modules（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app.js&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;store.js&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api.js&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;i18n.js&lt;/code&gt;），沒有套框架；資料庫用 Firestore，帳號則是 LINE Login。整個 MVP 第一版就把訂閱管理、資料夾分類、四種檢視模式、深色主題全部生出來了，後面的每一版都是在這個骨架上加東西。&lt;/p&gt;

&lt;h1 id=&quot;line-login-在這個專案裡代表的意義&quot;&gt;LINE Login 在這個專案裡代表的意義&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;line-login&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260726142225462.png&quot; alt=&quot;image-20260726142225462&quot; /&gt;&lt;/p&gt;

&lt;p&gt;我之前寫過 &lt;a href=&quot;https://www.evanlin.com/go-oauth-pkce/&quot;&gt;如何透過 Golang 開發 OAuth2 的 PKCE&lt;/a&gt;，講的是 LINE Login 導入 PKCE 的實作細節。那時候是把 LINE Login 當研究對象在拆解協定；這次在 FeedFlow 裡，LINE Login 換了一個角色——它是整個多使用者架構能不能成立的關鍵。&lt;/p&gt;

&lt;p&gt;FeedFlow 最早其實沒有帳號系統，資料就存在瀏覽器的 localStorage，換一台裝置訂閱就不見了。要做雲端同步，第一件事是要有一個穩定的使用者身份，而且這個身份要能在 Firestore 裡當 document 路徑的 key（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;users/{userId}/...&lt;/code&gt;）。比起自己刻一套帳號密碼系統，直接用 LINE Login 換來的 LINE User ID（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sub&lt;/code&gt; claim）當這把 key，省掉了整套密碼、驗證信、忘記密碼流程，對一個個人專案來說划算很多。&lt;/p&gt;

&lt;p&gt;這個決定也不是一次到位的。最早的版本其實是偷懶版：讓使用者自己貼一段 LINE UID 字串進去，後端只是拿去當 Firestore key，沒有做任何身份驗證——這樣的「登入」等於任何人都能冒充任何一個 UID。下一版才換成正規的 LINE OpenID Connect：標準的 OAuth 2.1 授權碼流程，拿到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id_token&lt;/code&gt; 之後用 LINE 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/oauth2/v2.1/verify&lt;/code&gt; 驗證簽章，session 存在 HTTP-only cookie，而不是塞在網址參數裡到處跑。再之後又補上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nonce&lt;/code&gt; 檢查，擋掉 CSRF。這個「先求有、再求對」的順序，某種程度上也反映了個人專案常見的節奏：先把功能兜起來確認方向對不對，安全性的坑等看得到需求了再回頭補。&lt;/p&gt;

&lt;p&gt;因為主要使用情境是在 LINE 裡分享連結、在 LINE 內建瀏覽器打開，所以除了標準的 OAuth 重導向，也整合了 LIFF SDK，讓使用者如果是在 LINE App 裡開啟，可以直接用 LIFF 的 SSO 登入，不用再跳出去瀏覽器繞一圈。&lt;/p&gt;

&lt;h1 id=&quot;rss-閱讀器的開發過程從單機-mvp-到多使用者雲端同步&quot;&gt;RSS 閱讀器的開發過程：從單機 MVP 到多使用者雲端同步&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;rss-dev&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MVP 階段的功能其實跟一般 RSS 閱讀器沒什麼差異：貼網址進去，後端用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rss-parser&lt;/code&gt; 解析 feed，找不到 feed 就用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cheerio&lt;/code&gt; 去掃網頁 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link&amp;gt;&lt;/code&gt; 標籤做 auto-discovery；訂閱進來的來源可以分類到資料夾；文章可以標記已讀、全部標記已讀；提供重新整理拉取最新文章。這一版資料還全部存在 localStorage。&lt;/p&gt;

&lt;p&gt;真正讓專案變得「像一個產品」的是接上 Firestore 之後的那幾版。每個使用者的訂閱清單、資料夾、閱讀進度、偏好設定，都寫進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;users/{LINE_UID}/...&lt;/code&gt; 底下獨立的路徑，多租戶資料互不干擾。閱讀進度記到很細：不只是「這篇讀過了」的 ID 清單（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;readArticleIds&lt;/code&gt;），還記了每個 feed 目前讀到哪一篇（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lastReadArticleId&lt;/code&gt;），換裝置或重新整理都接得回去。&lt;/p&gt;

&lt;p&gt;比較有意思的是「auto-hydration」這個小機制：Cloud Run 重新部署、或是換一台裝置登入時，記憶體裡是沒有文章資料的，只有 Firestore 裡的訂閱清單。這時後端會在背景把訂閱清單裡每個 feed 重新抓一次、解析出文章，再把畫面填滿，使用者不會看到一個空的「請先訂閱」畫面，銜接得算自然。&lt;/p&gt;

&lt;p&gt;後來又加了一版「豐富預覽」：貼 RSS 網址進去的當下，除了抓 feed 的標題跟簡介，還會順便抓最新三篇文章當樣本，非中文的內容一併丟給 Gemini 翻譯，訂閱前就能看懂這個來源在寫什麼，不用真的訂下去才發現是自己看不懂的語言。&lt;/p&gt;

&lt;h1 id=&quot;為什麼選-gemini-當翻譯引擎中間踩了哪些坑&quot;&gt;為什麼選 Gemini 當翻譯引擎，中間踩了哪些坑&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;gemini&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;會需要翻譯，是因為訂閱清單裡有不少非中文來源。判斷邏輯很單純：後端偵測文章語言，不是繁體中文的，就丟給 Gemini 2.5 Flash 翻，回傳 JSON 結構的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;translatedTitle&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;translatedContent&lt;/code&gt;，前端在文章卡片跟閱讀器都加一個「✨ 繁中」的翻譯徽章，閱讀器裡還有一顆按鈕可以在原文跟翻譯之間切換。選 Gemini 沒有太多懸念，2.5 Flash 的延遲跟成本都適合這種「每次進畫面就要順手翻好幾篇」的用量，用其他家 API 也做得到，但當時手上這個 GCP 專案本來就在用，順手接上去而已。&lt;/p&gt;

&lt;p&gt;第一版是最直接的做法：拿 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GEMINI_API_KEY&lt;/code&gt; 直接打 Generative Language API。能動，但部署到 Cloud Run 之後，這代表要多管理一組 API Key 環境變數，金鑰外洩的風險也跟著多一份。後來把翻譯這段改成走 Vertex AI，用 Cloud Run 服務本身的身份（ADC，Application Default Credentials）驗證，不用另外持有一把 API Key——Cloud Run 的 service account 本身就有權限呼叫 Vertex AI，設定 IAM 就好，不用在程式碼或環境變數裡塞任何密鑰。本地開發沒有 ADC 環境的時候，才退回用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GEMINI_API_KEY&lt;/code&gt; 當備援。&lt;/p&gt;

&lt;p&gt;這一版上線後在 Cloud Run 的 log 裡整批翻譯請求都在失敗，訊息是「Neither Vertex AI ADC self-identity nor GEMINI_API_KEY is available」，明明兩條路都設定了。查下去才發現是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@google/genai&lt;/code&gt; SDK 的用法搞錯：它要的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vertexai: true&lt;/code&gt; 這個布林值，加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;location&lt;/code&gt; 兩個平行參數；我一開始寫成巢狀的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vertexai: { project, location }&lt;/code&gt;，SDK 判斷「有沒有開 Vertex 模式」跟「有沒有讀到 project/location」是兩段分開的邏輯，結果變成 SDK 以為 Vertex 模式開著，卻讀不到專案跟區域，每次呼叫都在起手式就死掉。&lt;/p&gt;

&lt;p&gt;與其去啃 SDK 的參數規則，最後乾脆把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@google/genai&lt;/code&gt; 整包拿掉，改用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-auth-library&lt;/code&gt; 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GoogleAuth&lt;/code&gt; 直接要一張 ADC token，自己組 HTTP request 打 Vertex AI 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generateContent&lt;/code&gt; REST endpoint。少一層 SDK 包裝，行為反而更好預測——這種時候繞過 SDK、直接打 REST API，比在文件裡找 SDK 到底哪個參數該巢狀哪個該平行，省事很多。&lt;/p&gt;

&lt;h1 id=&quot;前端介面四種檢視模式與行動裝置優先設計&quot;&gt;前端介面：四種檢視模式與行動裝置優先設計&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;frontend&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;介面設計的參考座標就是 Feedly Classic：一個右上角的檢視模式切換鈕，四種模式都在同一份文章資料上套不同版型，不用重新抓資料。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;模式&lt;/th&gt;
      &lt;th&gt;特色&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Magazine（雜誌）&lt;/td&gt;
      &lt;td&gt;預設模式，圖文並陳的摘要卡片，適合快速掃過標題跟一小段內容&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;List（列表）&lt;/td&gt;
      &lt;td&gt;高密度純文字列表，一眼能看到的文章數量最多&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Title Only（純標題）&lt;/td&gt;
      &lt;td&gt;只留標題，捲動速度最快&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cards（卡片）&lt;/td&gt;
      &lt;td&gt;大圖為主的視覺卡片，適合圖片內容豐富的來源&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;側邊欄是資料夾結構，每個資料夾跟未分類的訂閱各自列出，旁邊帶未讀數字徽章；文章清單跟閱讀器是兩塊面板，點文章從清單滑進閱讀器、上滑手勢滑回清單，操作邏輯照著手機原生 App 的習慣走，不是網頁常見的那種點連結整頁跳轉。整體是深色主題，topbar 放了選單、檢視模式、全部已讀、重新整理、設定幾顆圖示按鈕，登入後 LINE 顯示名稱直接顯示在原本「LINE 登入」按鈕的位置。&lt;/p&gt;

&lt;p&gt;設定頁裡把「介面語言」跟「翻譯目標語言」拆成兩個獨立選項：介面語言目前有 zh-TW / en / ja 三種，翻譯目標語言則是決定 Gemini 要把外文翻成哪種語言，這兩件事沒有理由綁在一起，一個使用者的介面可能開英文，但還是想把日文文章翻成繁體中文看。&lt;/p&gt;

&lt;h1 id=&quot;目前進度與下一篇的方向&quot;&gt;目前進度與下一篇的方向&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;whats-next&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;寫這篇的當下，repo 裡剛好新增了兩份文件（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/superpowers/specs/&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/superpowers/plans/&lt;/code&gt;），在規劃文章列表的分頁瀏覽，每五篇一頁、支援滑動手勢、滾輪、按鈕三種方式翻頁。這部分還在開發中，等做完會是這個系列的下一篇。&lt;/p&gt;

&lt;h1 id=&quot;結論&quot;&gt;結論&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;summary&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;FeedFlow 目前解決的問題很單純：手機上快速掃過一堆外文技術文章，不用切分頁翻譯、不用忍受臃腫的介面。三個決策撐起了現在這個架構——LINE Login 提供免刻帳密系統的使用者身份、Firestore 撐多使用者雲端同步、Gemini 2.5 Flash（走 Vertex AI ADC，不落地存 API Key）處理翻譯。這幾塊都不是一次做對，LINE 登入從貼字串進化到正規 OAuth，翻譯從裸 API Key 換成服務身份驗證，都是先跑通、再補強。&lt;/p&gt;

&lt;h1 id=&quot;參考連結&quot;&gt;參考連結：&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;refer&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.evanlin.com/go-oauth-pkce/&quot;&gt;如何透過 Golang 開發 OAuth2 的 PKCE - 以 LINE Login 為例&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/kkdai/rss-feed-class-webapp&quot;&gt;kkdai/rss-feed-class-webapp&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://feedflow-660825558664.asia-east1.run.app&quot;&gt;FeedFlow 線上 Demo&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.line.biz/en/docs/line-login/&quot;&gt;LINE Login 官方文件&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://ai.google.dev/&quot;&gt;Gemini API 文件&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://cloud.google.com/docs/authentication/application-default-credentials&quot;&gt;Vertex AI - Application Default Credentials&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Sat, 25 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/feed-class-webapp-1/</link>
				<guid isPermaLink="true">https://www.evanlin.com/feed-class-webapp-1/</guid>
			</item>
		
			<item>
				<title>[學習心得][Golang] AI Agent 時代的授權難題：ID-JAG 是什麼？為什麼我用 Go 重新實作了一次</title>
				<description>&lt;h2 id=&quot;前言&quot;&gt;前言:&lt;/h2&gt;

&lt;p&gt;這半年多讓 AI Agent 直接接上內部系統、幫忙做事，已經不是什麼新鮮事了。但只要仔細想一步：Agent 要用「誰的身份」去呼叫那些 API？如果它拿到的權限跟人一樣大，一旦被騙去執行了不該做的操作，後果可能比人自己手滑還嚴重。&lt;/p&gt;

&lt;p&gt;這正是 ID-JAG（Identity Assertion JWT Authorization Grant）想解的問題。我最近把這套機制的原理整理了一遍，也照著 &lt;a href=&quot;https://github.com/athenz-community/id-jag-the-hard-way&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;athenz-community/id-jag-the-hard-way&lt;/code&gt;&lt;/a&gt; 這個教學 repo，把裡面的 MCP Server 用 Go 重新實作了一遍：&lt;a href=&quot;https://github.com/kkdai/id-jag-mcp&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/id-jag-mcp&lt;/code&gt;&lt;/a&gt;。這篇文章想把 ID-JAG 的技術原理講清楚：它建立在哪些 RFC 標準上、跟我之前寫過的 OAuth2 / PKCE 有什麼不一樣、實際的 token 交換流程長什麼樣，最後也會示範怎麼把這個 Go 專案跑起來、怎麼測試。&lt;/p&gt;

&lt;h1 id=&quot;tldr&quot;&gt;TL;DR&lt;/h1&gt;

&lt;p&gt;本篇文章會依序介紹：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#what-is-idjag&quot;&gt;什麼是 ID-JAG？為什麼需要它？&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#oauth-review&quot;&gt;從 OAuth2、PKCE，到 Agent 時代的新問題&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#rfc-foundation&quot;&gt;兩塊 RFC 基石：Token Exchange 與 JWT Bearer&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#idjag-flow&quot;&gt;ID-JAG 的完整 token 交換流程&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#least-privilege&quot;&gt;每一跳都窄化權限：最小權限原則怎麼落地&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#why-go&quot;&gt;為什麼要用 Go 重新實作這個 MCP Server？&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#try-it&quot;&gt;動手玩：安裝、執行與測試&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#summary&quot;&gt;結論&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#refer&quot;&gt;參考文章&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1 id=&quot;什麼是-id-jag為什麼需要它&quot;&gt;什麼是 ID-JAG？為什麼需要它？&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;what-is-idjag&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ID-JAG 是一種讓 AI Agent 代替使用者去存取受保護資源的授權機制，重點是「代替」這兩個字。目前它還是 IETF 的 Internet-Draft，還沒成為正式 RFC，但已經有 LY Corporation（在 Athenz 授權系統上）跟 Okta 等單位開始實作，MCP（Model Context Protocol）規範也已經引用了這份草案。&lt;/p&gt;

&lt;p&gt;傳統的服務對服務授權，通常是兩種極端：要嘛整個服務共用一把萬用鑰匙（API Key、Service Account），要嘛乾脆把使用者的 session 或長效 token 直接借給程式用。前者權限太大，後者不僅沒有審計軌跡，一旦洩漏，攻擊者幾乎可以完全冒充使用者，而且很難被偵測出來。等到 AI Agent 開始自己決定要呼叫哪些工具、串接哪些內部 API 的時候，這兩種做法的風險都被放大了：Agent 可能因為 prompt injection 或幻覺，做出使用者根本沒打算做的操作，而如果 Agent 手上握的是一把萬用鑰匙，後果就是全公司資料任其存取。真實場景裡也常常是「Orchestrator Agent 呼叫 Sub-Agent」的多層架構，風險是一路往下傳遞的。&lt;/p&gt;

&lt;p&gt;ID-JAG 想做到的是：Agent 每一次行動，都必須能證明「這是某個特定使用者，在某個特定當下，授權我做這一件特定的事」，而且這個授權範圍要盡可能窄、有效期要盡可能短。它建立在 OAuth2 的 token exchange（&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8693&quot;&gt;RFC 8693&lt;/a&gt;）之上，多蓋了一層「這個 token 是從人的身份主張衍生出來的」證明機制。這也是為什麼它同時被列在 &lt;a href=&quot;https://oauth.net/cross-app-access/&quot;&gt;OAuth.net 的 Cross-App Access（XAA）頁面&lt;/a&gt; 上——這正是 Agent 時代才會冒出來的新問題。&lt;/p&gt;

&lt;p&gt;順帶一提，ID-JAG 也解決了一個很實際的使用體驗問題：如果每次 Agent 要存取一個新服務，都得跳出瀏覽器視窗讓使用者手動按「同意」，這種體驗很快就會讓人疲乏、乾脆什麼都按同意。ID-JAG 把授權動作收斂到使用者透過 SSO 登入的那一刻，之後 Agent 需要新的存取權限時，是拿著已經核發的身份主張去跟授權伺服器換 token，不需要使用者再跳出來按一次。&lt;/p&gt;

&lt;h1 id=&quot;從-oauth2pkce到-agent-時代的新問題&quot;&gt;從 OAuth2、PKCE，到 Agent 時代的新問題&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;oauth-review&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;我之前寫過 &lt;a href=&quot;https://www.evanlin.com/go-oauth-pkce/&quot;&gt;如何透過 Golang 開發 OAuth2 的 PKCE&lt;/a&gt;，內容是 LINE Login 導入 PKCE 的實作經驗。那篇文章解的問題，跟 ID-JAG 解的問題其實完全是兩個層次，拿出來對比一下會更清楚 ID-JAG 到底新在哪裡。&lt;/p&gt;

&lt;p&gt;PKCE 解的是「client 身份是否可信」的問題：手機 App 這種沒辦法安全保管 client secret 的公開客戶端，authorization code 有可能在傳遞過程中被同一支手機上的惡意 App 攔截。PKCE 用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;code_verifier&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;code_challenge&lt;/code&gt; 這組一次性配對，確保就算 code 被偷了，沒有正確的 verifier 也換不到 token。整個問題發生在「使用者本人跟他手上那支 App」之間的單一跳（single hop）。&lt;/p&gt;

&lt;p&gt;ID-JAG 解的則是「這個非人類的服務身份，有沒有資格代表這個人做這件事」的問題，而且往往橫跨好幾跳：使用者登入 IdP → AI Client Gateway → MCP Server → 最終的 Resource Server。每一跳的呼叫者都不是使用者本人，卻都得證明自己是「奉旨行事」。傳統 OAuth 2.0 一開始的設計，是為「人類使用者 ↔ 應用程式」這種場景設計的，並不直接支援這種多層 Agent 鏈的委派情境。PKCE 保護的是一次授權交換的完整性；ID-JAG 保護的是一整條授權鏈路上，每一個環節的最小必要權限。兩者不衝突，是同一個大架構下，解決不同階段問題的機制。&lt;/p&gt;

&lt;h1 id=&quot;兩塊-rfc-基石token-exchange-與-jwt-bearer&quot;&gt;兩塊 RFC 基石：Token Exchange 與 JWT Bearer&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;rfc-foundation&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ID-JAG 沒有憑空發明一套新協定，而是把兩個既有的 IETF 標準組合起來用。搞懂這兩塊基石，才看得懂後面完整的交換流程在做什麼。&lt;/p&gt;

&lt;p&gt;第一塊是 RFC 8693 — OAuth 2.0 Token Exchange，定義了一個通用的「用一種 token 換另一種 token」協定，概念上有點像去外幣兌換所把日圓換成台幣，只是這裡換的是安全令牌。一個 token exchange 請求大致長這樣：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=&amp;lt;身份主張 JWT&amp;gt;
subject_token_type=urn:ietf:params:oauth:token-type:jwt
requested_token_type=urn:ietf:params:oauth:token-type:access_token
scope=read:orders
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subject_token&lt;/code&gt; 放的是代表被委派身份的 token，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requested_token_type&lt;/code&gt; 說明想換成什麼型別的 token，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scope&lt;/code&gt; 則可以在交換的當下就把權限範圍縮小。&lt;/p&gt;

&lt;p&gt;第二塊是 RFC 7523 — JWT Bearer Grant，讓一個 JWT 本身就可以直接拿來當 OAuth 2.0 的授權憑證，不用先繞去換一次 authorization code：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;POST /token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=&amp;lt;已簽名的身份主張 JWT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;授權伺服器收到之後，用簽發者（IdP）的公鑰驗證這個 JWT 的簽章，確認 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;audience&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scope&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exp&lt;/code&gt; 這些欄位都合理，就可以直接核發 Access Token，不需要使用者二次同意——因為 IdP 已經替這個身份主張背書過了。&lt;/p&gt;

&lt;p&gt;這兩者要交換的「身份主張 JWT」，一般會包含以下欄位：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;iss&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://enterprise.idp.example.com/v1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;sub&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;alice@company.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;aud&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://api.service.example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;exp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1773839486&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;iat&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1773825086&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;jti&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;abc123-7dc6-42ab-b326-uniqueid&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;scope&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;read:orders write:tickets&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sub&lt;/code&gt; 是被代理的使用者、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aud&lt;/code&gt; 是這份主張要交給哪個服務用、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scope&lt;/code&gt; 是允許的權限範圍、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jti&lt;/code&gt; 則是唯一 ID，用來防止同一份主張被重放使用。ID-JAG 就是把這份短效的身份主張，透過上面兩個 RFC 定義的交換機制，逐步換成 Agent 真正能拿去呼叫 API 的 Access Token。&lt;/p&gt;

&lt;h1 id=&quot;id-jag-的完整-token-交換流程&quot;&gt;ID-JAG 的完整 token 交換流程&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;idjag-flow&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id-jag-the-hard-way&lt;/code&gt; 這個教學 repo 的架構為例（用 Athenz 當授權伺服器），完整的鏈路是把前面兩個 RFC 串接起來使用：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;使用者登入 IdP（Keycloak）
   │  拿到 OIDC ID Token
   ▼
AI Client Gateway
   │  用 RFC 8693 Token Exchange，把 ID Token 換成 ID-JAG
   │  （grant_type=token-exchange, subject_token_type=id_token,
   │   requested_token_type=id-jag）
   ▼
再用 RFC 7523 JWT Bearer，拿 ID-JAG 去換一個真正能用的 Athenz Access Token
   │  （grant_type=jwt-bearer, assertion=&amp;lt;ID-JAG&amp;gt;）
   ▼
AI Client 帶著這個 Access Token 呼叫 MCP Server
   │
   ▼
MCP Server 收到請求後，「自己」再做一次 RFC 8693 Token Exchange
   │  把收到的 Access Token 換成「這個工具需要的最小 scope」的新 Access Token
   │  （這一步用的是 MCP Server 自己的 mTLS service identity，不是使用者的憑證）
   ▼
用換到的窄化 Access Token 呼叫最終的 Resource Server
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;整條鏈路裡，token 被交換了不只一次，而是每經過一個信任邊界就交換一次，範圍也越換越窄。這個設計刻意讓每一段路徑的「憑證」都不一樣——AI Client Gateway 手上的 token 沒辦法直接拿去騙過最終的 Resource Server，因為 MCP Server 這一關會強制重新驗證、重新換發。核發出來的 Access Token 裡，通常也會同時記錄 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sub&lt;/code&gt;（被代理的使用者）跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;act&lt;/code&gt;（實際執行操作的 Agent 身份）兩個欄位，下游服務因此可以清楚看到「Alice 透過某個 Agent 執行了這個操作」，稽核記錄不會斷在半路。&lt;/p&gt;

&lt;h1 id=&quot;每一跳都窄化權限最小權限原則怎麼落地&quot;&gt;每一跳都窄化權限：最小權限原則怎麼落地&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;least-privilege&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;這是我覺得整個架構設計得最漂亮的地方：最小權限不是寫在文件裡的原則，而是被 token exchange 這個機制物理性地強制執行。&lt;/p&gt;

&lt;p&gt;以我重新實作的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id-jag-mcp&lt;/code&gt; 為例，它對外提供三個工具：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;工具&lt;/th&gt;
      &lt;th&gt;對應的 Athenz Scope&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;get_k8s_docs&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api:role.docs-getter&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delete_k8s_doc&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api:role.docs-deleter&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post_k8s_doc&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api:role.docs-poster&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;MCP Server 收到請求時，不會直接把 AI Client 傳來的 Access Token 轉發給上游 API——它一定會先用自己的 mTLS 憑證，向 Athenz ZTS 拿著「這個工具實際需要的 scope」重新交換一次 token，換到的新 token 才會拿去呼叫上游。就算 AI Client Gateway 那邊的 token 範圍比較寬（例如同時擁有讀跟刪的權限），MCP Server 在轉發之前，也只會替每一個工具要求它真正需要的那一小塊權限。&lt;/p&gt;

&lt;p&gt;換句話說，整個系統裡沒有任何一個環節「順手」擁有比它當下任務更大的權限——這不是靠 code review 或是內規檢查出來的，而是架構上根本沒辦法繞過。&lt;/p&gt;

&lt;h1 id=&quot;為什麼要用-go-重新實作這個-mcp-server&quot;&gt;為什麼要用 Go 重新實作這個 MCP Server？&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;why-go&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id-jag-the-hard-way&lt;/code&gt; 原本的 MCP Server（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api_server/mcp/&lt;/code&gt;）是用 TypeScript + Express 寫的，而且是手刻 JSON-RPC 2.0 協定（沒有用官方 SDK）。我想確認兩件事：第一，這套 token 交換架構如果換一個語言、換一個 MCP SDK 實作，邏輯是不是真的完全可以複製；第二，官方的 &lt;a href=&quot;https://github.com/modelcontextprotocol/go-sdk&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modelcontextprotocol/go-sdk&lt;/code&gt;&lt;/a&gt; 用起來到底如何。&lt;/p&gt;

&lt;p&gt;最後的實作維持了原本的核心邏輯（同樣的 scope 對應、同樣的 mTLS token exchange 流程），但把協定層整個換成官方 Go SDK，mTLS client 則是自己刻的（沒有依賴 Athenz 官方的 Go client library）。&lt;/p&gt;

&lt;h1 id=&quot;動手玩安裝執行與測試&quot;&gt;動手玩：安裝、執行與測試&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;try-it&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/id-jag-mcp&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/id-jag-mcp&lt;/code&gt;&lt;/a&gt;（Apache 2.0 授權）。專案結構跟職責切分大致是：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;cmd/id-jag-mcp/       進入點：讀取設定、組裝所有元件、啟動 HTTP server
internal/config/      環境變數設定讀取
internal/athenz/      mTLS client + Athenz ZTS RFC 8693 token exchange
internal/tools/       工具輸入型別 + 共用的上游轉發邏輯
internal/server/      MCP 工具註冊（官方 SDK）+ REST 快捷路由 + logging
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;先把專案抓下來、建置成 binary：&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;git clone https://github.com/kkdai/id-jag-mcp.git
&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;id-jag-mcp
go build &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; id-jag-mcp ./cmd/id-jag-mcp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;要實際跑起來，需要準備 mTLS 憑證、以及一個可連線的 Athenz ZTS 和上游 API server，設定完全透過環境變數：&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; certs
&lt;span class=&quot;nb&quot;&gt;cp&lt;/span&gt; /path/to/api-mcp.crt /path/to/api-mcp.key /path/to/ca.crt certs/

&lt;span class=&quot;nb&quot;&gt;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;UPSTREAM_BASE_URL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;http://localhost:14443
&lt;span class=&quot;nb&quot;&gt;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;AUTHORIZATION_SERVER_URL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;https://athenz-zts-server.athenz:4443/zts/v1

go run ./cmd/id-jag-mcp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;啟動後除了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt; 這個給 MCP client 連線的 endpoint，也提供了跟三個工具對應的 REST 快捷路由，方便用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 直接測試，不需要透過 MCP client：&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$AT&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; http://localhost:8101/api/docs

curl &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; DELETE &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$AT&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; http://localhost:8101/api/docs/5

curl &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$AT&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Content-Type: application/json&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{&quot;name&quot;:&quot;doc1&quot;,&quot;content&quot;:&quot;hello&quot;}&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  http://localhost:8101/api/docs
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;如果只是想確認邏輯本身有沒有寫對，不需要真的架一套 Athenz/Keycloak 環境——測試全程用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;httptest&lt;/code&gt; 模擬 ZTS 跟上游 API：&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;go build ./...
go vet ./...
go &lt;span class=&quot;nb&quot;&gt;test&lt;/span&gt; ./...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/athenz&lt;/code&gt; 的測試會起一個假的 ZTS server，驗證送出去的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grant_type&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subject_token&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scope&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;audience&lt;/code&gt; 這些參數是否正確；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/tools&lt;/code&gt; 的測試則會驗證每個工具轉發給上游時，帶的是「交換後窄化的 token」而不是原始收到的那個。這樣不需要真的連上 Athenz，也能確認整條 token 交換邏輯是對的。&lt;/p&gt;

&lt;p&gt;README 裡（中英文都有）有完整的環境變數清單跟更多細節。&lt;/p&gt;

&lt;h1 id=&quot;結論&quot;&gt;結論&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;summary&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ID-JAG 解的不是「這個 client 是不是它自己說的那個 client」（這是 PKCE 的問題），而是「這個非人類的服務身份，有沒有資格代表某個特定的人，在當下做這一件特定的事」。撐起這套架構的不是文件上的約束，是把 RFC 8693 跟 RFC 7523 這兩個標準串成一條鏈，讓每一跳都被強制重新驗證、重新窄化權限。&lt;/p&gt;

&lt;p&gt;如果你的 AI Agent 已經開始接觸內部系統，這是很值得花時間搞懂的一套架構——而且不一定要照抄 Athenz 這一套，重點是理解「每一跳都要重新換發、範圍要越換越窄」這個核心原則，套用到自己的授權伺服器上。&lt;/p&gt;

&lt;h1 id=&quot;相關文章&quot;&gt;相關文章：&lt;/h1&gt;

&lt;p&gt;&lt;a id=&quot;refer&quot;&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.evanlin.com/go-oauth-pkce/&quot;&gt;如何透過 Golang 開發 OAuth2 的 PKCE - 以 LINE Login 為例&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/athenz-community/id-jag-the-hard-way&quot;&gt;athenz-community/id-jag-the-hard-way&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/kkdai/id-jag-mcp&quot;&gt;kkdai/id-jag-mcp&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://oauth.net/cross-app-access/&quot;&gt;OAuth.net - Cross-App Access (XAA)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8693&quot;&gt;RFC 8693 - OAuth 2.0 Token Exchange&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc7523&quot;&gt;RFC 7523 - JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://tools.ietf.org/html/rfc7636&quot;&gt;RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/modelcontextprotocol/go-sdk&quot;&gt;modelcontextprotocol/go-sdk&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://athenz.github.io/athenz/&quot;&gt;Athenz 官方文件&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/&quot;&gt;ID-JAG IETF Internet-Draft&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/id-jag-mcp-go/</link>
				<guid isPermaLink="true">https://www.evanlin.com/id-jag-mcp-go/</guid>
			</item>
		
			<item>
				<title>[GCP 實戰] LINE 名片 Bot 再進化：用 Gemini 一次搞定名片正反面辨識合併</title>
				<description>&lt;h1 id=&quot;痛點一張中文名片其實是兩張名片&quot;&gt;痛點：一張中文名片，其實是兩張名片&lt;/h1&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260723152322389.png&quot; alt=&quot;image-20260723152322389&quot; /&gt;&lt;/p&gt;

&lt;p&gt;台灣的名片有個很常見的設計：正面印中文，背面印英文（或反過來）。我們的 LINE 名片 Bot 原本的邏輯很單純：收到一張圖片，OCR 一次，存一筆資料。&lt;/p&gt;

&lt;p&gt;問題就出在這裡。使用者傳正面，Bot 存了一筆只有中文姓名的資料；如果使用者接著把背面也傳過去，Bot 會把它當成「另一張新名片」，同一個人在資料庫裡就會出現兩筆各缺一半資訊的紀錄。使用者得自己手動比對、刪除重複資料，體驗非常糟。&lt;/p&gt;

&lt;p&gt;這篇文章記錄我們如何讓 Bot 學會「這是同一張名片的兩面」，並且把正反面資訊合併成一筆完整資料。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;解法先問一句還有背面嗎&quot;&gt;解法：先問一句「還有背面嗎？」&lt;/h1&gt;

&lt;p&gt;比起自己寫規則去猜兩張圖片是不是同一張名片，我們選了一個更直接的做法：&lt;strong&gt;問使用者&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;流程設計如下：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;使用者傳送名片正面，Bot 照常 OCR。&lt;/li&gt;
  &lt;li&gt;OCR 完成後，Bot &lt;strong&gt;不馬上存檔&lt;/strong&gt;，而是回覆一句「📇 已辨識正面資料，這張名片還有背面嗎？」，並附上兩顆 Quick Reply 按鈕。&lt;/li&gt;
  &lt;li&gt;使用者按「沒有，直接儲存」→ 照原本流程存檔，結束。&lt;/li&gt;
  &lt;li&gt;使用者按「有背面」→ Bot 記住剛剛那張正面圖片，等待下一張圖片進來。&lt;/li&gt;
  &lt;li&gt;背面圖片一到，兩張圖片一起送進 Gemini，合併成一筆資料再存檔。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;這個等待狀態我們用專案原本就有的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;user_states&lt;/code&gt; 記憶體字典來管理，並且加上 5 分鐘的逾時：使用者按了「有背面」卻不理它、跑去做別的事，5 分鐘一過就當作放棄，不會卡住整個流程。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;user_states&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;action&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;pending_backside_confirm&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;card_obj&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;card_obj&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;front_image_bytes&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;expires_at&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PENDING_BACKSIDE_TIMEOUT_SECONDS&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;核心讓-gemini-一次看兩張圖自己做合併&quot;&gt;核心：讓 Gemini 一次看兩張圖，自己做合併&lt;/h1&gt;

&lt;p&gt;最關鍵的技術決定是：要不要分兩次辨識正面、背面，再自己寫程式合併？我們選擇了另一條路，把正反面圖片包在同一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content&lt;/code&gt; 請求裡，直接交給 Gemini 判斷。&lt;/p&gt;

&lt;p&gt;原因很簡單：中英文姓名要合併成「王大明 David Wang」這種格式，靠字串規則去兜很容易兜得又醜又不準；但這種語意層級的整合，靠規則去湊反而更容易出錯，交給 Gemini 直接判斷比較省事。&lt;/p&gt;

&lt;p&gt;在 &lt;a href=&quot;file:///Users/al03034132/Documents/linebot-namecard-python/app/gemini_utils.py&quot;&gt;app/gemini_utils.py&lt;/a&gt; 中新增了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_json_from_two_images&lt;/code&gt;，沿用既有的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NAMECARD_SCHEMA&lt;/code&gt; 結構化輸出，只是這次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contents&lt;/code&gt; 塞了兩個圖片 Part：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;generate_json_from_two_images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;front_img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PIL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;back_img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PIL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GenerativeModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;gemini-3-flash-preview&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;generation_config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;response_mime_type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;response_schema&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NAMECARD_SCHEMA&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;front_part&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pil_to_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;front_img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;image/jpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;back_part&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pil_to_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;back_img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;image/jpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;front_part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;back_part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;labels&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;client_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;namecard&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Prompt 也只是在原本的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IMGAGE_PROMPT&lt;/code&gt; 後面多加一段合併指示：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;DOUBLE_SIDED_IMAGE_PROMPT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;IMGAGE_PROMPT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
這兩張圖片是同一張名片的正面與背面，請整合成一筆完整資料。
若同一欄位中英文都有出現（如姓名、公司），請合併呈現
（例如「王大明 David Wang」）；
若某欄位只有一面出現，直接採用該面的值；忽略明顯重複的資訊。
&quot;&quot;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;一次 API 呼叫，換來的是完全不用自己寫合併規則、也不用維護那套規則。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;容易忽略的兩個小坑&quot;&gt;容易忽略的兩個小坑&lt;/h1&gt;

&lt;p&gt;功能上線前的整體 code review，抓到兩個很容易被忽略、但真的會咬人的細節。&lt;/p&gt;

&lt;h3 id=&quot;坑一重複檢查的時機點&quot;&gt;坑一：重複檢查的時機點&lt;/h3&gt;

&lt;p&gt;原本的重複檢查（比對 email 是否已存在）是 OCR 完馬上做。但雙面辨識上線後，如果正面剛好跟舊資料的 email 重複，這時候就提早判定「已存在」並結束流程，那背面圖片裡如果帶有新的 email，就再也沒有機會被看到了。&lt;/p&gt;

&lt;p&gt;修法是把重複檢查往後挪，統一放到「單面選擇不合併」或「雙面合併完成」之後才做，確保永遠是拿最終版本的資料去比對：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_finalize_and_save_card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card_obj&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;existing_card_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;firebase_utils&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;check_if_card_exists&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card_obj&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;existing_card_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# ... 回覆已存在
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;card_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;firebase_utils&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;add_namecard&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card_obj&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# ... 回覆儲存成功
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;單面流程、雙面合併流程最後都收斂呼叫這個共用函式，重複檢查只會在資料底定的那一刻執行一次。&lt;/p&gt;

&lt;h3 id=&quot;坑二清狀態不能一路清到底&quot;&gt;坑二：清狀態不能一路清到底&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;user_states&lt;/code&gt; 這個字典其實同時被好幾個功能共用：編輯備忘錄、修改欄位、這次的背面辨識都會往裡面寫東西。一開始的實作圖方便，只要偵測到殘留狀態就整個刪掉再處理新事件。&lt;/p&gt;

&lt;p&gt;問題是：如果使用者正在「編輯電話欄位」等你輸入新號碼，這時候手滑傳了一張圖片，這段邏輯會把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;editing_field&lt;/code&gt; 的狀態也一起清掉，使用者原本的編輯操作就這樣被默默取消了。&lt;/p&gt;

&lt;p&gt;修法是只清跟背面辨識流程相關的兩個狀態，其他狀態一律不碰：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;action&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;pending_backside_confirm&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;awaiting_backside_image&apos;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;del&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_states&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;同樣的邏輯後來也發現漏掉了一個分支，處理「操作已過期」回覆的地方一開始也是整個清掉，統一改成一樣的選擇性判斷後才算真正補齊。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;順手做的資源清理別讓背面圖片一直留在記憶體裡&quot;&gt;順手做的資源清理：別讓背面圖片一直留在記憶體裡&lt;/h1&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;awaiting_backside_image&lt;/code&gt; 狀態裡存的不只是文字，還有正面圖片的原始位元組資料。如果使用者問完「還有背面嗎」就人間蒸發，這包資料理論上會一直留在 process 記憶體裡，因為原本的設計只有「使用者下次互動」才會順便檢查並清掉逾時狀態。&lt;/p&gt;

&lt;p&gt;我們加了一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sweep_expired_states()&lt;/code&gt;，掛在 Webhook 進來的第一時間執行一次，把所有使用者裡已經過期的暫存狀態清掉，不用等到當事人自己回來才被動清理：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sweep_expired_states&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;expired_user_ids&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_states&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;items&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;expires_at&apos;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;expires_at&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;expired_user_ids&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;del&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_states&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;只要任何一個使用者傳訊息進來，就會順便幫全體使用者做一次垃圾清理，讓放棄流程的人不會留下永久佔用記憶體的殘骸。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;總結與效益&quot;&gt;總結與效益&lt;/h1&gt;

&lt;p&gt;這次的雙面辨識合併功能，讓 LINE 名片 Bot 更貼近台灣使用者真實的名片使用習慣：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;一次辨識，資料完整&lt;/strong&gt;：正反兩面一次送進 Gemini，中英文欄位自動合併，不用再手動比對重複資料。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;不打斷使用者&lt;/strong&gt;：忽略提示、逾時、臨時去做別的事，都能自然退回單面儲存，不會卡住流程。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;重複檢查對準最終資料&lt;/strong&gt;：確保比對的永遠是合併後的完整版本，不會漏接背面才出現的新資訊。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;狀態機互不干擾&lt;/strong&gt;：背面辨識的暫存狀態只影響自己，不會誤傷使用者正在進行的其他操作。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;記憶體用得乾淨&lt;/strong&gt;：主動清掃逾時狀態，放棄流程的使用者不會留下看不見的記憶體負擔。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;完整程式碼已同步推播至 &lt;a href=&quot;https://github.com/kkdai/linebot-namecard-python&quot;&gt;GitHub&lt;/a&gt;，歡迎參考！&lt;/p&gt;
</description>
				<pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-image-namecard-enhance/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-image-namecard-enhance/</guid>
			</item>
		
			<item>
				<title>[好書分享]台灣AI大未來 解析最新的AI趨勢、台灣情勢、企業布局與個人發展</title>
				<description>&lt;div&gt;&lt;a href=&quot;https://moo.im/a/02oszP&quot; title=&quot;台灣AI大未來&quot;&gt;&lt;img src=&quot;https://cdn.readmoo.com/cover/d6/ad7hjbg_210x315.jpg?v=0&quot; /&gt;&lt;/a&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;台灣AI大未來
解析最新的AI趨勢、台灣情勢、企業布局與個人發展
作者： 簡立峰（Chien Lee-feng）  蕭玉品  
出版社：商業周刊 
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;買書推薦網址&quot;&gt;買書推薦網址：&lt;/h4&gt;

&lt;ul&gt;
  &lt;li&gt;Readmoo: &lt;a href=&quot;https://moo.im/a/02oszP&quot;&gt;由此去購買&lt;/a&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;h1 id=&quot;前言&quot;&gt;前言:&lt;/h1&gt;

&lt;p&gt;這是 2026 年第 2 本讀完的書。這大概也是蠻新的一本書，就是 2025 年底才出的一本書，那時候會買這本書因為就是公司在 2024 年有邀請簡立峰來公司演講，後來偶然在電子書櫃上看到他出的書，就想說來看一下。&lt;/p&gt;

&lt;h2 id=&quot;大綱&quot;&gt;大綱&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;當AI改寫世界，台灣下一步怎麼走？
Google台灣區前董事總經理、電腦科學暨人工智慧學者──簡立峰
為台灣撰寫第一本AI時代的使用說明書，
讓台灣人看懂AI時代的機會與挑戰！

世界每十年就歷經一次數位革命：
●1990年，個人電腦開啟電腦世代；
●2000年，網際網路造就網路世代；
●2010年，行動裝置與社群媒體引領行動世代；
●2020年，生成式AI如ChatGPT震撼登場……
如今正是AI世代，遊戲規則全面改寫，差距正在1：99間急遽擴大。
你會落後被淘汰，還是把握機遇、成為1%的贏家？
本書帶你洞悉AI格局，掌握關鍵轉型之鑰！

【地緣政治下的AI發展】
當全球正經歷一場由AI驅動的典範轉移，美國將AI視為其重返霸權的關鍵，這不僅預示著AI產品化將徹底顛覆世界的運行規則，更開啟了未來AI演變的無限可能。從「曼哈頓計畫」到「星際之門」的深遠布局，本書將深入剖析美中關稅戰下的全球局勢，洞察AI如何重塑國際秩序。
●1：99的挑戰，抓住機會的國家、企業、個人，都有可能會成為獨一無二、遠超他人的「1」，其他人則成了遠遠落後的「99」。
●DeepSeek的出現，顛覆了美國壟斷的現象，帶來AI世界的「再平衡」，等於發明了窮人的原子彈。
●如果不加快晶片國產化的腳步，不具生產力的美國就沒有明天，直接在AI戰役中喪失競爭力，台積電因而成了美中對抗的X因子。

【放眼世界的台灣】
AI浪潮席捲全球，這不只是技術革新，更是國家發展的關鍵轉捩點。身處這股巨浪中，台灣不僅擁有得天獨厚的「護國神山」台積電，更在AI挑戰與機會並存之際，看到成為「世界的台灣」的黃金十年。本書帶你一窺台灣製造業的未來潛能，以及新舊企業如何重新定調「台灣製造」。
●面對地緣政治變局，以製造業為主的台灣企業要順勢而為，透過海外生產，在台研發打造「台灣＋N（外國）」模式，協助台灣去除紅色供應鏈、加入美系供應鏈的一環。
●海島的市場永遠在外面，你飛去日本、飛去美國旅遊、出差幾天不等於國際化，國際化是每天的生活受不同文化衝擊。

【百工百業的AI實踐】
AI時代是企業轉型、人才再造的關鍵時刻，敢於轉向的企業才有競爭機會。本書例舉許多不同業種企業如何因應AI的案例，並提供實用的對策方向，引導台灣企業轉型搶占AI市場，邁向成長與創新。
●AI的影響可比喻成「大風吹」，從科技巨擘到中小企業，不論是騰籠換鳥，還是為員工賦能，風往哪裡吹，新的機會就在那裡。
●「老創＋新創」從軟體整合轉向軟硬整合，結合二者優勢，AI應用才有可能。
●發展主權AI並非外包出去就結束了，不論是自建模型、請科技巨頭幫忙，要把策略規畫得清清楚楚，否則恐怕只是白花錢。

【掌握個人學習、職涯的黃金鑰匙】
身為AI世代的一分子，如何利用AI提升學習效率，同時清晰辨識AI的極限，是AI時代的重要課題。本書建議如何善用AI工具的同時，也點出人類的差異化經驗將成為無可取代的稀世珍寶，因此聰明地累積個人獨特價值，才能讓自己在AI時代立於不敗之地。
●AI喜歡用某些特定句型，究其原因在於AI是機率概念，自然有些規律在裡頭，但反過來說，正因為它資料量夠大，才能試出各種人類沒見過的組合。
●AI將許多工作的「及格線」從60分一舉提高到80分，迫使各行各業必須重新定義人力的核心職能和價值。
● AI時代，具備專業基礎的資深人才學AI最快，因為他們長期累積的知識能判斷AI生成內容的正確性，這次AI的典範轉移，反過來將老一世代的優勢給放大了。

這是一本專為台灣量身打造的AI生存指南，幫助你全面掌握AI變革的脈絡，找到國家、企業與個人在變局中的成長之道。
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這本書可以說是 Google 台灣前董事總經理簡立峰博士與資深媒體人蕭玉品，聯手為台灣人與企業量身打造的「AI時代生存說明書」。簡博士用非常務實、精準的在地視角，剖析了在這波瘋狂的 AI 浪潮下，台灣該如何重新定位、企業如何打出軟硬整合的國際盃，以及每個人該如何避免陷入「大腦外包」的危機。&lt;/p&gt;

&lt;p&gt;我為你將全書的四大核心架構整理出來，先透過這個概覽掌握整體脈絡：&lt;/p&gt;

&lt;h3 id=&quot;全書四大核心架構概覽&quot;&gt;全書四大核心架構概覽&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;strong&gt;面向分類&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;核心痛點與趨勢&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;台灣與個人的突圍戰略&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;1. 最新AI趨勢&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;AI 帶來高度的集權與統一，可能演變成 1:99 的能力與資源懸殊；不過 DeepSeek 等新興勢力的崛起，也正為全球帶來「再平衡」的機會。&lt;/td&gt;
      &lt;td&gt;理解 AI 的「機率與語言架構」本質，從中尋找非美系壟斷的突破點，拉高基本能力下限。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2. 台灣情勢定位&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;台灣雖是地緣政治與 AI 晶片的關鍵 X 因子，但也面臨島嶼內捲、少子化與五缺的結構性限制。&lt;/td&gt;
      &lt;td&gt;將「農民心態」徹底轉向「航海家心態」，以出海為唯一生存王道，跨出台灣邊界擴大數位國土。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3. 企業轉型布局&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;台灣「硬體極強、軟體極弱」，缺乏算力與商業情境的軟體新創很難獨立在國際存活。&lt;/td&gt;
      &lt;td&gt;推動「老創（硬體大廠）＋新創（軟體應用）」攜手，利用 Edge AI（邊緣AI）為強大的硬體裝置裝上大腦。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;4. 個人發展鑰匙&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;面臨「大腦外包」的無形危機，只會照書教、缺乏實戰經驗的平庸專業新鮮人將首當其衝。&lt;/td&gt;
      &lt;td&gt;從「解題式」慣性轉為「出題式」思維，透過與 AI 進行高頻次的「反覆互動與修正」來創造獨特價值。&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;一-最新ai趨勢199-的超級人類大挑戰&quot;&gt;一、 最新AI趨勢：1:99 的「超級人類」大挑戰&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;極端的權力集中&lt;/strong&gt;：AI 時代帶來了高度的中央集權，全球科技巨頭佔據極大優勢。全球數千種語言中，僅有約百種能在主流 AI 中使用，且英文與簡體中文被深度優化，這代表語言與文化架構是掌握 AI 的第一關鍵。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;1:99 的分水嶺&lt;/strong&gt;：這波海嘯最殘酷的不是消滅底層普通人（AI 反而能拉高普通人的下限），而是消滅「平庸的專業人士」。抓住機會的 1% 人會因為 AI 變身超級人類，拿走 99% 人的能力與機會，其他人則成了落後的 99%。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AI 世界的再平衡&lt;/strong&gt;：近年非美系低成本高效模型的出現，打破了美國科技巨頭的絕對壟斷，這被形容為發明了「窮人的原子彈」，為資源較少的國家與企業帶來重新洗牌的契機。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;二-台灣情勢從海島內捲轉向大航海時代&quot;&gt;二、 台灣情勢：從「海島內捲」轉向「大航海時代」&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;地緣政治的 X 因子&lt;/strong&gt;：台積電與台灣硬體供應鏈在美中科技對抗中高居關鍵地位，因為台灣具備「最早知道需求」的特性（例如能率先掌握伺服器電壓變化等系統需求），在全球基礎設施的調整中擁有重要身分。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;打破農民心態&lt;/strong&gt;：多數台灣企業習慣了「海島思維」，日常生活中「看不見海」，容易在舒適的同溫層中陷入內捲。面對未來 20 年少子化、新生兒暴跌的結構性危機，簡博士疾呼必須轉向「航海家心態」，因為「出海」已是台灣各行各業唯一的生存之道。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;數位國土的延伸&lt;/strong&gt;：以台積電為例，AI 能讓台灣在海外複製工廠後，由台灣工程師進行遠距操作；台灣也應將高齡化、勞動力短缺的危機轉為機會，積極發展機器人與屬於自己的主權 AI，避免國家級的數位落差。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;三-企業布局軟硬整合讓老創新創攜手共舞&quot;&gt;三、 企業布局：軟硬整合，讓「老創＋新創」攜手共舞&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;用 Edge AI 幫硬體裝上大腦&lt;/strong&gt;：Edge AI（邊緣AI，指讓終端裝置具備在地運算能力，不全依賴雲端）是台灣的天下。台灣純做軟體新創很難拼過國際巨頭，但我們可以把 AI 服務直接內建、綑綁在全世界都在用的強大硬體裝置中（如捷安特的自行車或各種終端設備），大幅提升附加價值。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;老創加新創打國際盃&lt;/strong&gt;：現在的 AI 新創如果沒有富爸爸提供的數據、算力和真實的「商業情境」，幾乎不可能成功。因此，硬體大廠（老創）應該攜手軟體新創，結合老創的國際通路與新創的靈活應用，一起組隊出海打國際盃。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;主權 AI 的務實規劃&lt;/strong&gt;：發展主權 AI 不能只是盲目把業務外包給科技巨頭。企業不論是自建模型還是與大廠合作，都必須把自身的策略、場域應用規劃得清清楚楚，否則只是平白燒錢。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;四-個人發展拒絕大腦外包做高段位的出題者&quot;&gt;四、 個人發展：拒絕「大腦外包」，做高段位的「出題者」&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;思維從「解題」變「出題」&lt;/strong&gt;：AI 的能耐都是被「問」出來的，問題越專業，得到的回應就越準確。未來職場不再看重死記硬背，核心能力將轉向問題定義、思辨與方向掌控，能展現主動影響力的「出題者」才能勝出。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;利用「反覆迭代」深化學習&lt;/strong&gt;：如果只是把問題丟給 AI、一次取得答案就直接複製使用，這種行為等同於抄襲；但如果能跟 AI 來回修改 10 次，那是「學習」的過程；若是持續反覆修正與調整到 100 次，才是真正接近「創造」的層次。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;累積不可取代的「差異化經驗」&lt;/strong&gt;：記憶與計算這些功能都可以外包給 AI，但你個人的獨特經驗、跨領域協作能力（π型人才）以及人類的思辨力，才是 AI 時代無可取代的稀世珍寶。聰明地利用 AI 工具放大自己的產出，才能避免成為被時代淘汰的「失落一代」。&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;本書最核心的靈魂金句：&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;「改變是人類永遠的不安，但若從宏觀角度看，AI 帶來的，是讓人類更有能力的契機。」當計算與記憶被大腦外包時，請務必保留住自己思考與創造的力量。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;這支 &lt;a href=&quot;https://www.youtube.com/watch?v=XHTfeCk0GwQ&quot;&gt;簡立峰博士專訪：誰是AI時代失落的一代&lt;/a&gt; 影片深入探討了書中提及的「1:99 超級人類挑戰」與職場思維轉型，能幫你更直觀地理解如何在這個大腦外包的時代保留個人競爭力。&lt;/p&gt;

&lt;h2 id=&quot;心得&quot;&gt;心得&lt;/h2&gt;

&lt;p&gt;就我自己的心得來說，這一本書整理得蠻多近期國內外的 AI 發展過程。並且以簡立峰自己曾經身為 Google 前任總經理的相關經歷給予許多未來的看法。並且許多時候分享了 AI 時代下，各行各業應該要如何面對給予的一個建議。 這個部分之前在他的演講中也經常會提到，並且也有相當清楚的分享與說明。&lt;/p&gt;

&lt;p&gt;就自己的感受來說，這部分的書籍可能翻一下就好。比較起來還是比較喜歡簡立峰博士帶來的演講部分，會讓人有更多的震撼與觸發。&lt;/p&gt;
</description>
				<pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/reading-tw-ai-no1/</link>
				<guid isPermaLink="true">https://www.evanlin.com/reading-tw-ai-no1/</guid>
			</item>
		
			<item>
				<title>[好書分享]蔡桑說怪 - 日本神話與靈界怪談，有時還有臺灣</title>
				<description>&lt;div&gt;&lt;a href=&quot;https://moo.im/a/egjpDI&quot; title=&quot;蔡桑說怪&quot;&gt;&lt;img src=&quot;https://cdn.readmoo.com/cover/fk/kk8bmcd_210x315.jpg?v=0&quot; /&gt;&lt;/a&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;蔡桑說怪
日本神話與靈界怪談，有時還有臺灣
 共 73 人評分
作者： 蔡亦竹  出版社：圓神出版 
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;買書推薦網址&quot;&gt;買書推薦網址：&lt;/h4&gt;

&lt;ul&gt;
  &lt;li&gt;Readmoo: &lt;a href=&quot;https://moo.im/a/egjpDI&quot;&gt;由此去購買&lt;/a&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;h1 id=&quot;前言&quot;&gt;前言:&lt;/h1&gt;

&lt;p&gt;這是 2026 年第 1 本讀完的書。今年上半年都沒有寫讀書心得，因為許多書籍都只有看一點點。這一本書也讀了蠻久的，是在找書的時候偶爾看到這一本書，結果整本書到了後半段卻是相當的好看，我一口氣最後就看完。&lt;/p&gt;

&lt;h2 id=&quot;大綱&quot;&gt;大綱&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;沒有最ㄎㄧㄤ，只有更ㄎㄧㄤ！蔡亦竹a.k.a.民俗學中二教授的鬼話連篇大解放！

◆日本初代神明家庭是如何天天上演八點檔狗血劇？
◆日本神話是怎麼把各種「性隱喻」藏在故事裡？
◆妖怪不是統統都是害人精，哪些妖怪可以讓你發大財、上天堂？
◆妖怪界裡也有霸凌現象？只是變老變醜就可以是一種妖怪？
◆玉皇大帝其實不是CEO？觀世音菩薩其實是外籍人士？
◆臺灣也可以有「師公手錶」「妖怪寶可夢」？

臺灣人怕鬼，日本人怕鬼，全世界的人都怕鬼……
沒有看過《鬼話連篇》沒關係，來這裡聽蔡桑練肖話、說鬼神，讓你心裡不再「毛毛der」！

大多數人對日本的印象是──參拜不完的寺廟、超萌超宅的coser、AV女優……威！一定還有靈異故事、貞子、妖怪，以及各種都市傳說！

聽蔡桑如何把毛骨悚然的撞鬼經驗結合流傳千古的歷史故事，看蔡桑如何用超接地氣語法，揭露日本神話背後的文化意涵！

日本民俗學博士蔡亦竹集結多年對民俗學的研究，以神話鬼話做媒介，用輕鬆易懂的鄉民語言，帶讀者進入日本的「神鬼傳奇」。其中包括日本神明的家譜、妖怪與文化的關聯以及其中所蘊藏的寓意等，同時也讓臺灣的眾神明可以臺日友好大串聯地活躍於文字中，讓你懂鬼話、迷鬼神！看完保證媽媽還會問你，為什麼要跪著看這本書？

因為《蔡桑說怪》會讓你跪地大喊：「日本神話到底是嗑了什麼？我也想要來一點！」
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這本書可不是那種硬梆梆的學術論文，而是筑波大學民俗學博士蔡亦竹（蔡桑），用超接地氣的「鄉民用語」與爆笑風格，把日本神話和靈界怪談扒開給你看的文化解析書！最精妙的是，他不僅講日本，還會時不時拉回臺灣的民俗視角做對比。&lt;/p&gt;

&lt;p&gt;以下為你精煉出這本書的三大核心板塊與重點整理：&lt;/p&gt;

&lt;h2 id=&quot;核心三大板塊重點整理&quot;&gt;核心三大板塊重點整理&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;strong&gt;板塊分類&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;核心研究焦點&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;蔡桑的「台味白話解讀」與亮點&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;1. 日本的神話原型&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;日本初代神明家庭（伊邪那岐、伊邪那美、天照、須佐之男）的誕生，以及歷史上的怨靈信仰。&lt;/td&gt;
      &lt;td&gt;用「色情與暴力、獵奇與SOD大集合」來吐槽日本神話極度放飛自我的荒謬劇情。介紹日本古代史的「怨靈同好會」及天狗等特有種。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2. 妖怪與都市怪談&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;鄉野鬼怪如何隨著時代演變成現代的都市傳說（如裂口女、超高速阿婆）。&lt;/td&gt;
      &lt;td&gt;妖怪是「都市化的新寵物」，背後折射出的是現代人的集體焦慮、孤單，以及媒體對靈異風潮的推波助瀾。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3. 有時還有臺灣&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;臺灣與日本之間信仰的跨海交涉與文化對照（如長崎媽祖與台南飛虎將軍）。&lt;/td&gt;
      &lt;td&gt;展現「臺日友好大串聯」。透過觀看日本的怪談，反思臺灣人自己的文化根源與主體性。&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;一-日本神話比本土劇還超展開的第一家庭&quot;&gt;一、 日本神話：比本土劇還超展開的第一家庭&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;初代家庭的愛恨情仇&lt;/strong&gt;：日本的創世神明（伊邪那岐與伊邪那美）決裂過程荒謬又驚悚（老婆在黃泉變腐屍、老公嚇到逃跑離婚），後代的太陽神天照大神和弟弟須佐之男也是相愛相殺。蔡桑笑稱這些情節放到現代來看，簡直是種種驚悚與獵奇情節的大集合。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;怨靈同好會&lt;/strong&gt;：日本歷史上許多被膜拜的高階神明（如學問之神菅原道真、崇德天皇），其實生前都是「死得很慘的政治鬥爭受害者」。因為後人害怕他們變成怨靈報復，才趕緊蓋神社把他們當神拜，形成了日本獨特的怨靈信仰文化。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;二-妖怪與都市傳說現代人的集體焦慮&quot;&gt;二、 妖怪與都市傳說：現代人的集體焦慮&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;妖怪是都市的新寵物&lt;/strong&gt;：以前的妖怪（如河童、山姥）住在深山樹林，代表人類對大自然的敬畏；都市化之後，妖怪也「搬進城裡」，演變成裂口女、超高速阿婆等都市怪談。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;反映現實社會的孤獨&lt;/strong&gt;：這些現代怪談的誕生，表面上是恐怖故事，骨子裡其實折射出都市人的疏離感與集體焦慮。同時書中也回顧了八〇、九〇年代日本大眾媒體（電視靈異節目）為了收視率推波助瀾的「靈異熱潮興衰史」。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;三-有時還有臺灣臺日神鬼的奇妙連結&quot;&gt;三、 有時還有臺灣：臺日神鬼的奇妙連結&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;講日文的媽祖與日本神明&lt;/strong&gt;：書中特別提及臺日信仰的交織。例如日本長崎有多間媽祖廟，那裡的媽祖因為在地化而會「說日文」；而臺灣台南則有「飛虎將軍廟」，供奉的是二戰時為了保護臺灣村民而犧牲的日本飛行員杉浦茂峰。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;民俗學的本質是「人的研究」&lt;/strong&gt;：蔡桑強調，無論是研究日本神話還是臺灣的靈異現象，恐怖或荒謬的終究不是鬼怪，而是背後的人類社會。信仰能撫慰人心，是因為它反映了當代人的思考邏輯。&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;本書核心精神金句：&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;「了解人就會理解鬼，妖怪、幽靈都是依據現實的想像。」&lt;/p&gt;

  &lt;p&gt;我們必須去發現每一個現象背後為何形成的主因。當我們透過日本怪談這面鏡子，深刻理解了民俗傳說的運作，才能帶著更清晰的眼光，回頭發現並認同屬於「臺灣自己」的文化形貌。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;心得&quot;&gt;心得&lt;/h2&gt;

&lt;p&gt;這一本書是充滿了相當多的鄉間傳奇，並且最後跟著一篇他的研究報告與實際發生過的事情會讓你覺得驚訝萬分。首先整本書會開始分享著日本鬼怪的一些故事，並且去思考著許多日本神話背後鬼怪故事由來。 並且也會根據著日本色情與暴力跟他們許多鬼怪故事的牽連。&lt;/p&gt;

&lt;p&gt;第二話就一些台灣相關故事的分享，並且也會講到「膽大黨」裡面的超高速婆婆的由來，裂嘴女的相關故事。這些都會讓你很想要一口氣閱讀完。隨著最近好像鬼月又要到了，似乎這一系列的書又會變得開始熱門。大家也也可以看看。&lt;/p&gt;
</description>
				<pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/reading-tsai-san-ghost/</link>
				<guid isPermaLink="true">https://www.evanlin.com/reading-tsai-san-ghost/</guid>
			</item>
		
			<item>
				<title>[數位憑證皮夾] 進階版 - 打造「訪客背書發證」：一個同時當驗證方與發行方的 DID 全鏈應用（附開發踩坑紀錄）</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20251009102618401.png&quot; alt=&quot;image-20251009102618401&quot; style=&quot;zoom: 50%;&quot; /&gt;&lt;/p&gt;

&lt;p&gt;(圖片來源： &lt;a href=&quot;https://www.wallet.gov.tw/zh-tw&quot;&gt;數位憑證皮夾官方網站&lt;/a&gt;)&lt;/p&gt;

&lt;h2 id=&quot;前提&quot;&gt;前提：&lt;/h2&gt;

&lt;p&gt;上一篇&lt;a href=&quot;https://github.com/kkdai/did-usecase-HR&quot;&gt;入門版&lt;/a&gt;做了一個 HR 員工卡系統：同仁自己申請一張員工卡，然後拿它去申請「運動補助」跟「育兒補助」。那一篇的重點是「&lt;strong&gt;發卡（Issuer）&lt;/strong&gt;」跟「&lt;strong&gt;驗證（Verifier）&lt;/strong&gt;」兩個角色分開來看。&lt;/p&gt;

&lt;p&gt;這一篇想再往前走一步：如果一個場景要&lt;strong&gt;同時扮演驗證方跟發行方&lt;/strong&gt;，串成一條完整的 DID 生態鏈，會長什麼樣子？我挑的場景是「&lt;strong&gt;訪客背書發證&lt;/strong&gt;」——這也是我在腦力激盪五個檢驗方應用時，覺得最能展示「全鏈」的一個。&lt;/p&gt;

&lt;p&gt;順便，這篇會很誠實地把開發過程中&lt;strong&gt;踩到的三個坑&lt;/strong&gt;寫下來，因為那些才是 TIL 真正有價值的部分。&lt;/p&gt;

&lt;p&gt;程式碼在這裡：&lt;a href=&quot;https://github.com/kkdai/did-usecase-visitor&quot;&gt;https://github.com/kkdai/did-usecase-visitor&lt;/a&gt;
線上體驗：&lt;a href=&quot;https://did-usecase-visitor-660825558664.asia-east1.run.app&quot;&gt;https://did-usecase-visitor-660825558664.asia-east1.run.app&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;場景員工門禁--訪客背書發證&quot;&gt;場景：員工門禁 + 訪客背書發證&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260709172435871.png&quot; alt=&quot;image-20260709172435871&quot; /&gt;&lt;/p&gt;

&lt;p&gt;這個大廳證件台有兩個模式：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;員工門禁 / 活動報名&lt;/strong&gt;：員工用數位皮夾出示員工卡，系統只驗證「&lt;strong&gt;是不是有效員工&lt;/strong&gt;」，驗過就開門 / 報名成功。姓名、生日、子女數這些欄位一律不揭露，留在皮夾裡——這就是選擇性揭露。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;訪客背書發證&lt;/strong&gt;（這篇的主角）：由一位在職員工出示員工卡「背書」，驗證通過之後，系統&lt;strong&gt;當場核發一張帶到期時間的臨時訪客通行證&lt;/strong&gt;到訪客的皮夾。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;第二個模式的價值在於，它把兩個角色接起來了：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;先當 &lt;strong&gt;Verifier&lt;/strong&gt;（驗員工卡）→ 驗過才當 &lt;strong&gt;Issuer&lt;/strong&gt;（發訪客卡）&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;比起傳統的紙本訪客簿（抄身分證、押證件影本、一堆個資堆在櫃台還要人工回收），數位背書只留下「哪位員工背書」這一筆可追責的資訊，訪客資料留在訪客自己的皮夾，通行證還可以設到期時間。&lt;/p&gt;

&lt;h2 id=&quot;架構決策為什麼不直接改上一個專案&quot;&gt;架構決策：為什麼不直接改上一個專案&lt;/h2&gt;

&lt;p&gt;這次我開了一個全新的專案、部署到獨立的 Cloud Run 服務，而不是在原本的 HR 專案上加頁面。幾個考量：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;靜態前端 + JSON API&lt;/strong&gt;：原專案用 jade 樣板 server-side render，這次改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public/&lt;/code&gt; 靜態頁 + 幾支 JSON API（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/access/qrcode&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/access/status&lt;/code&gt;），前後端分得比較乾淨。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;把皮夾 API 呼叫抽成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lib/wallet.js&lt;/code&gt;&lt;/strong&gt;：原專案的 issuer / verifier 呼叫是內嵌在路由裡、而且重複。這次抽成三個函式：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requestPresentationQRCode()&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getPresentationResult()&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;issueCredential()&lt;/code&gt;，好維護也好測。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;狀態改用記憶體&lt;/strong&gt;：原專案把資料寫進單一 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;record.js&lt;/code&gt; 檔案，在 Cloud Run 這種無狀態環境上寫檔會有問題。這次用簡單的記憶體物件（重啟歸零，展示用途足夠）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;權杖沿用同一個沙盒帳號&lt;/strong&gt;：issuer / verifier 的 access token 跟上一篇是同一組，直接重用。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;驗證「持有員工卡」的部分，我先沿用既有的運動補助 verifier ref 當 fallback（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VERIFIER_ACCESS_REF&lt;/code&gt; 沒設就用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VERIFIER_SPORT_REF&lt;/code&gt;），這樣不用等後台設定就能先跑起來。&lt;/p&gt;

&lt;h2 id=&quot;踩坑紀錄一出示成功了畫面卻一直卡住&quot;&gt;踩坑紀錄一：出示成功了，畫面卻一直卡住&lt;/h2&gt;

&lt;p&gt;這是最經典的一個。手機掃碼、皮夾也完成出示了，但桌面的頁面就是不往下走，一直在輪詢。&lt;/p&gt;

&lt;p&gt;第一步先看 Cloud Run 的日誌，發現 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/access/status&lt;/code&gt; 每 3 秒回一次、每次都回「未驗證」。我在後端加了一行把驗證方&lt;strong&gt;原始回應&lt;/strong&gt;印出來的 log，重新部署後再測一次，就抓到真相了：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;credentialType&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0028680530_line_school&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;claims&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;ename&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;english_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;cname&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;英文名字&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Lub&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;ename&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;join_company&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;cname&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;入職時間&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;2018-10-05&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;verifyResult&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;resultDescription&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;success&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;transactionId&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;8cd7f37b-...&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;看到問題了嗎？回應裡的欄位是 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verifyResult&lt;/code&gt;（camelCase）&lt;/strong&gt;，而且&lt;strong&gt;根本沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;code&lt;/code&gt; 這個欄位&lt;/strong&gt;。但我沿用上一篇的舊寫法，判斷式是：&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 舊的（對不上現在的回應）&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;verified&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;verify_result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data.code&lt;/code&gt; 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;undefined&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data.verify_result&lt;/code&gt; 也是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;undefined&lt;/code&gt;（人家叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verifyResult&lt;/code&gt;），所以永遠 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;false&lt;/code&gt;，永遠 pending。&lt;strong&gt;其實驗證早就成功了&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verifyResult: true&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resultDescription: &quot;success&quot;&lt;/code&gt;），只是我判斷的欄位名對不上——看起來沙盒 API 的回應格式已經從 snake_case 換成 camelCase 了。&lt;/p&gt;

&lt;p&gt;修法就是把判斷式改成相容兩種格式：&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;verified&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;verifyResult&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;        &lt;span class=&quot;c1&quot;&gt;// 新格式 camelCase&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;verify_result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;       &lt;span class=&quot;c1&quot;&gt;// 舊格式相容&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;verify_result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;TIL&lt;/strong&gt;：接第三方 API，不要相信「上一版能動的判斷式這一版也能動」。沙盒會改。加一行印出原始回應的 log，比對著改，比盯著程式碼猜半天快多了。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;踩坑紀錄二訪客卡一直待發&quot;&gt;踩坑紀錄二：訪客卡一直「待發」&lt;/h2&gt;

&lt;p&gt;門禁那關通了之後，換訪客背書那關卡住——畫面顯示「訪客卡待發（issuer 樣板未設定）」，沒有真的發出一張卡。&lt;/p&gt;

&lt;p&gt;我直接拿 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 打發卡 API &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/vc-item-data&lt;/code&gt; 來看它到底回什麼。分兩種情況測：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;情況 A：用員工樣板 + 正確的員工欄位&lt;/strong&gt; → HTTP 200，而且完整回應裡有這些 key：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;KEYS: [ &apos;id&apos;, &apos;content&apos;, &apos;pureContent&apos;, ..., &apos;qrCode&apos;, &apos;deepLink&apos;, &apos;expired&apos;, ... ]
qrCode   = data:image/png;base64,iVBOR...      ← 真的能掃進皮夾的領卡 QR
deepLink = https://frontend-uat.wallet.gov.tw/api/moda/vcqrcode?...
expired  = 2027-01-09T...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;情況 B：用員工樣板 + 訪客欄位&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitor_type&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;endorsed_by&lt;/code&gt;…） → HTTP 500 / 400 BAD_REQUEST。&lt;/p&gt;

&lt;p&gt;原因很清楚了：員工樣板的欄位是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isRequired: true&lt;/code&gt;（姓名、英文名字…），我卻送了一堆它沒有的訪客欄位，就被打槍。而發卡&lt;strong&gt;成功時的回應其實就帶了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;qrCode&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deepLink&lt;/code&gt;&lt;/strong&gt;，可以直接讓訪客掃碼領卡——我原本的解析是對的，卡關的純粹是「欄位對不上樣板」。&lt;/p&gt;

&lt;p&gt;於是我設計了兩種發卡模式，用環境變數自動切換（程式裡的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAS_VISITOR_TEMPLATE&lt;/code&gt;）：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;模式&lt;/th&gt;
      &lt;th&gt;條件&lt;/th&gt;
      &lt;th&gt;行為&lt;/th&gt;
      &lt;th&gt;卡面&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Option 1（fallback）&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;沒設 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VISITOR_VC_*&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;借用員工樣板，把訪客資訊塞進它的必填欄位（姓名=「臨時訪客」等）發卡&lt;/td&gt;
      &lt;td&gt;顯示為員工卡卡面&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Option 2（正規）&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;有設 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VISITOR_VC_*&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;送 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitor_type / endorsed_by / valid_until&lt;/code&gt; 到專屬訪客樣板&lt;/td&gt;
      &lt;td&gt;正規訪客通行證卡面&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Option 1 的好處是&lt;strong&gt;不用等後台設定就能發出一張真的能領的卡&lt;/strong&gt;（雖然卡面是借來的），先把整條鏈跑通；要正規卡面再走 Option 2 建專屬樣板即可，程式碼一行都不用改。&lt;/p&gt;

&lt;h2 id=&quot;踩坑紀錄三領卡-qr-太小--手機版面&quot;&gt;踩坑紀錄三：領卡 QR 太小 + 手機版面&lt;/h2&gt;

&lt;p&gt;第一版我把訪客通行證做成一張漂亮的小識別證，領卡 QR 只有 48px——結果就是&lt;strong&gt;根本掃不到&lt;/strong&gt;。這個 QR 是要給「另一支手機」掃來領卡的，太小就失去意義。&lt;/p&gt;

&lt;p&gt;後來把訪客證改成直式卡片，領卡 QR 放大成卡片主體（最大 240px、白底留白），下面才放「背書員工 / 有效至」的資訊。兩個 QR（出示用、領卡用）也都改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clamp()&lt;/code&gt; 響應式尺寸，手機上不爆版、桌機上夠清楚。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;TIL&lt;/strong&gt;：只要是「給別人掃」的 QR，就要當成主角來排版，不能當裝飾。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;關於限時自動失效的真相&quot;&gt;關於「限時自動失效」的真相&lt;/h2&gt;

&lt;p&gt;我原本以為可以逐張指定「這張訪客證 4 小時後過期」，但實測發現：透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/vc-item-data&lt;/code&gt; 發卡，卡片的實際有效期是&lt;strong&gt;跟著樣板設定走的&lt;/strong&gt;（例如員工樣板是發卡日 +約半年），沒辦法一張一張指定短效期。&lt;/p&gt;

&lt;p&gt;所以現在卡面上的「有效至 HH:MM」是&lt;strong&gt;應用層自己算的顯示值&lt;/strong&gt;，不是皮夾強制的到期。如果要真正的短效訪客證，有兩條路：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;建立訪客樣板時，把樣板的&lt;strong&gt;有效期直接設短&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;或改用平台的&lt;strong&gt;排程撤銷（revoke）&lt;/strong&gt;——發卡回應裡有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clearScheduleId&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scheduleRevokeMessage&lt;/code&gt; 這些欄位，暗示平台支援排程撤銷，但要另外串接對應 API。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;部署從原始碼直接上-cloud-run&quot;&gt;部署：從原始碼直接上 Cloud Run&lt;/h2&gt;

&lt;p&gt;這次用 buildpacks 從原始碼直接部署，不用自己寫 Dockerfile：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run deploy did-usecase-visitor &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--source&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1 &lt;span class=&quot;nt&quot;&gt;--platform&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;managed &lt;span class=&quot;nt&quot;&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--set-env-vars&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;VC_SERNUM=607861,VC_UID=0028680530_line_school,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
ISSUER_ACCESS_TOKEN=...,VERIFIER_SPORT_REF=...,VERIFIER_ACCESS_TOKEN=...,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
VISITOR_TTL_HOURS=4&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;之後要切換到 Option 2 的正規訪客卡，只要在這串 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--set-env-vars&lt;/code&gt; 補上
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VISITOR_VC_SERNUM=&amp;lt;新樣板 vcId&amp;gt;,VISITOR_VC_UID=&amp;lt;新樣板 vcCid&amp;gt;&lt;/code&gt; 重新部署即可，
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAS_VISITOR_TEMPLATE&lt;/code&gt; 會自動變 true。&lt;/p&gt;

&lt;h2 id=&quot;總結與未來展望&quot;&gt;總結與未來展望&lt;/h2&gt;

&lt;p&gt;這次的重點不是「又做了一個 demo」，而是三件事：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;DID 全鏈是可行的&lt;/strong&gt;：同一個場景同時當 Verifier 跟 Issuer，驗過一張卡再發一張卡，把生態鏈接起來，體驗上很順。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;踩坑都在細節&lt;/strong&gt;：欄位命名（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verifyResult&lt;/code&gt; vs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verify_result&lt;/code&gt;）、樣板必填欄位、QR 尺寸——這些不看原始回應、不實際用手機掃，是不會發現的。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;fallback 設計讓 demo 先能動&lt;/strong&gt;：不用等後台把每個樣板 / ref 都建好，先用既有資源跑通，再逐步換成正規設定，開發節奏會好很多。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;數位憑證皮夾能做的應用場景真的很多，「訪客背書」只是其中一個。上一篇的員工卡，其實還可以延伸出福利社優惠核銷、年資里程碑禮、親子設施門禁、健身房積點……每一個都是一個「檢驗方」的新應用。很期待看到更多有創意的場景被做出來。&lt;/p&gt;
</description>
				<pubDate>Wed, 08 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/did-visitor/</link>
				<guid isPermaLink="true">https://www.evanlin.com/did-visitor/</guid>
			</item>
		
			<item>
				<title>[GCP 帳單與 Vertex AI] 破解單一專案 Gemini 費用拆分難題：Vertex AI 動態計費標籤 (Labels) 實戰記</title>
				<description>&lt;h1 id=&quot;痛點同一個專案內的-gemini-api-費用如何精準分攤&quot;&gt;痛點：同一個專案內的 Gemini API 費用如何精準分攤？&lt;/h1&gt;

&lt;p&gt;在開發企業級 LLM 服務或是經營多租戶 (Multi-tenant) 平台時，最常被財務與維運團隊問到的問題就是：&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;「我們同一個 GCP 專案內接了許多不同的業務與 LINE Bot，每天的 Gemini Key 費用都會統統出現在 Gemini API 的範圍，我們有辦法根據不同的 Gemini Key 或不同的使用者來拆分費用嗎？」&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;直接回答你的問題：&lt;/strong&gt;
在 Google Cloud 帳單（Cloud Billing）報告中，&lt;strong&gt;無法直接「根據不同的 API Key 金鑰名稱」來分開顯示費用&lt;/strong&gt;。
Google Cloud 的帳單報表最小的歸屬維度是到「專案 (Project)」、「服務 (Service)」和「SKU (產品細項)」，系統並不會把個別的 API Key 字串當作獨立的計費項目。對帳單系統來說，同一個專案內不論你建了 10 把還是 100 把 API Key，通通都會被揉在一起算成一筆 Gemini API 的總帳。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;山不轉路轉vertex-ai-的請求標籤-labels救星&quot;&gt;山不轉路轉：Vertex AI 的「請求標籤 (Labels)」救星&lt;/h1&gt;

&lt;p&gt;如果因為架構限制非得塞在同一個專案，最推薦的做法就是：&lt;strong&gt;切換至 Vertex AI 呼叫，並使用「請求標籤 (Labels)」&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;如果你目前使用的是 Google AI Studio 的 API Key，它在單一專案內是無法傳遞計費標籤的。但如果你將程式碼改為呼叫 &lt;strong&gt;Vertex AI 的 Gemini API&lt;/strong&gt;（一樣在同一個專案內），Vertex AI 支援在每次發送請求時，動態帶入自訂的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;labels&lt;/code&gt;（標籤）。&lt;/p&gt;

&lt;h3 id=&quot;原理與流程&quot;&gt;原理與流程&lt;/h3&gt;
&lt;p&gt;在每次發送請求（例如呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generateContent&lt;/code&gt;）時，於 API Request 中帶入特定的 Metadata：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;contents&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;labels&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;client_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;info_helper&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;api_key_group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;marketing_team&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這些自訂標籤會直接被傳遞到 GCP 的帳單系統。之後當你到 GCP 帳單報告中，在「分組依據 (Group by)」選擇你設定的標籤鍵（例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;client_id&lt;/code&gt;），就能在同一個專案內，把不同標籤（代表不同服務、客戶或使用者）的費用算得一清二楚！&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;專案實戰改造全面導入-labels-機制&quot;&gt;專案實戰改造：全面導入 Labels 機制&lt;/h1&gt;

&lt;p&gt;為了完成這個需求，我們盤點了目前 LINE Bot 專案的 API 呼叫架構，並進行了以下重構。&lt;/p&gt;

&lt;h3 id=&quot;1-專案-api-呼叫盤點&quot;&gt;1. 專案 API 呼叫盤點&lt;/h3&gt;
&lt;p&gt;經由掃描，我們發現專案中絕大部分都是使用 Vertex AI 進行呼叫（17 個 Client 中有 14 個使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vertexai=True&lt;/code&gt;），只有少數例外：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Vertex AI 呼叫&lt;/strong&gt;：包括 GitHub 摘要、多個 Google Maps Grounding 工具、文字摘要、圖片分析、語音轉文字等（共 11 個檔案、19 處呼叫點）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Gemini API Key 呼叫&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; 的 Live API、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;batch_service.py&lt;/code&gt; 的 Batch 服務，以及 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tts_tool.py&lt;/code&gt; 的 TTS 語音合成。&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;[!IMPORTANT]
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;labels&lt;/code&gt; 參數僅 Vertex AI 支援，若在 API Key (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vertexai=False&lt;/code&gt;) 下帶入此參數會導致 SDK 拋出 Error，因此我們只針對 11 個使用 Vertex AI 的檔案進行修改。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;2-實作修改方式&quot;&gt;2. 實作修改方式&lt;/h3&gt;

&lt;p&gt;對於 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-genai&lt;/code&gt; Python SDK，我們有兩種主要的修改場景：&lt;/p&gt;

&lt;h4 id=&quot;場景-a已包含-generatecontentconfig&quot;&gt;場景 A：已包含 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenerateContentConfig&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;若原本的呼叫就帶有 Config，我們只需在 config 中額外傳入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;labels={&quot;client_id&quot;: &quot;info_helper&quot;}&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 修改前 (例如 loader/gh_tools.py)
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-2.5-flash&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;max_output_tokens&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2048&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 修改後
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-2.5-flash&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;max_output_tokens&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2048&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;labels&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;client_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;info_helper&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 帶入計費標籤
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;場景-b無-config-參數&quot;&gt;場景 B：無 Config 參數&lt;/h4&gt;
&lt;p&gt;若原本的呼叫非常簡單（例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;searchtool.py&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;youtube_gcp.py&lt;/code&gt;），我們需要主動帶入一個包含 labels 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenerateContentConfig&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 修改前 (例如 loader/searchtool.py)
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-3.1-flash-lite-preview&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 修改後
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-3.1-flash-lite-preview&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;labels&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;client_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;info_helper&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 新增 config 帶入標籤
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;3-被修改的檔案清單&quot;&gt;3. 被修改的檔案清單&lt;/h3&gt;

&lt;p&gt;我們總共對以下 11 個檔案中的 19 個呼叫點進行了精準修改，並在提交前使用 Python 的 AST 模組（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ast.parse&lt;/code&gt;）以及 Flake8 進行語法與排版檢驗：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/agents/chat_agent.py&quot;&gt;agents/chat_agent.py&lt;/a&gt;&lt;/strong&gt;：修改 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_create_chat_config()&lt;/code&gt;，為一般問答及 Grounding 對話都加上 labels。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/chat_session.py&quot;&gt;loader/chat_session.py&lt;/a&gt;&lt;/strong&gt;：為 Chat session config 帶入 labels。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/gh_tools.py&quot;&gt;loader/gh_tools.py&lt;/a&gt;&lt;/strong&gt;：GitHub 摘要 API。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/langtools.py&quot;&gt;loader/langtools.py&lt;/a&gt;&lt;/strong&gt;：文字摘要、圖片 JSON 生成、社群貼文生成。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/maps_grounding.py&quot;&gt;loader/maps_grounding.py&lt;/a&gt;&lt;/strong&gt;：地圖搜尋 API。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/searchtool.py&quot;&gt;loader/searchtool.py&lt;/a&gt;&lt;/strong&gt;：關鍵字提取工具。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/youtube_gcp.py&quot;&gt;loader/youtube_gcp.py&lt;/a&gt;&lt;/strong&gt;：YouTube 影片理解 API。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/tools/audio_tool.py&quot;&gt;tools/audio_tool.py&lt;/a&gt;&lt;/strong&gt;：異步語音轉文字。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/tools/maps_tool.py&quot;&gt;tools/maps_tool.py&lt;/a&gt;&lt;/strong&gt;：地圖附近搜尋、餐廳名稱擷取、批次與評價搜尋等 5 處呼叫。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/tools/summarizer.py&quot;&gt;tools/summarizer.py&lt;/a&gt;&lt;/strong&gt;：文字摘要與 Agentic Vision 圖像理解。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/tools/youtube_tool.py&quot;&gt;tools/youtube_tool.py&lt;/a&gt;&lt;/strong&gt;：YouTube 摘要工具。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;避坑指南小心-sdk-模組導入問題&quot;&gt;避坑指南：小心 SDK 模組導入問題&lt;/h1&gt;

&lt;p&gt;在為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;youtube_gcp.py&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;youtube_tool.py&lt;/code&gt; 重構無 Config 的呼叫時，由於這兩個檔案原本只使用了 named import 導入特定的型別：&lt;/p&gt;
&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai.types&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;當我們在程式碼中寫下 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;types.GenerateContentConfig(...)&lt;/code&gt; 時，系統會拋出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NameError: name &apos;types&apos; is not defined&lt;/code&gt; 的錯誤。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解決辦法：&lt;/strong&gt;
我們需要修正該 import 敘述，直接引入 &lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/loader/youtube_gcp.py#L8&quot;&gt;GenerateContentConfig&lt;/a&gt;：&lt;/p&gt;
&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 修改前
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai.types&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 修改後
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai.types&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;並在呼叫時直接使用，而不加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;types.&lt;/code&gt; 前綴：&lt;/p&gt;
&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;labels&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;client_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;info_helper&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;總結與後續步驟&quot;&gt;總結與後續步驟&lt;/h1&gt;

&lt;p&gt;本次修改成功將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;client_id=info_helper&lt;/code&gt; 標籤注入至 LINE Bot 專案內所有 Vertex AI API 的呼叫中。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;帳單生效延遲&lt;/strong&gt;：請注意，當我們開始帶入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;labels&lt;/code&gt; 之後，GCP 的帳單數據通常會有 24 到 48 小時的生效延遲。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;在 GCP Billing 設定&lt;/strong&gt;：過兩天後，可以前往 GCP Console -&amp;gt; &lt;strong&gt;Billing (計費)&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Reports (報表)&lt;/strong&gt;。在右側的 Group by (分組依據) 中選擇 &lt;strong&gt;Labels&lt;/strong&gt; 並輸入我們的 key &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;client_id&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;大功告成&lt;/strong&gt;：此時報表就會將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;info_helper&lt;/code&gt; 當作獨立的計費列獨立繪出，完美解決了專案費用分開核銷與統計的難題！&lt;/li&gt;
&lt;/ol&gt;
</description>
				<pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gcp-gemini-label/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gcp-gemini-label/</guid>
			</item>
		
			<item>
				<title>[AI 實戰][Gemini Live Translate] 打磨 macOS 會議翻譯 App：自動重連、懸浮字幕、會議記錄匯出全面進化</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260702134921415.png&quot; alt=&quot;image-20260702134921415&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;寫在前面第二回合換一把利器&quot;&gt;寫在前面：第二回合，換一把利器&lt;/h1&gt;

&lt;p&gt;在&lt;a href=&quot;2026-06-10-agy-macos-app.md&quot;&gt;上一篇文章&lt;/a&gt;中，我們用 &lt;strong&gt;AGY CLI (Antigravity)&lt;/strong&gt; 從零打造了一個 macOS 即時會議翻譯 App：透過 ScreenCaptureKit 擷取 Zoom / Google Meet 的音訊，送入 Gemini Live API 進行即時翻譯，並在 App 視窗中顯示繁體中文雙語字幕。&lt;/p&gt;

&lt;p&gt;App 上線後，開發者在實際開會中發現了幾個讓人皺眉的問題，同時也有了更多功能想法。這一次，我們換上了 Anthropic 的 &lt;strong&gt;Claude Code&lt;/strong&gt;，在終端機中展開了第二回合的深度打磨。&lt;/p&gt;

&lt;p&gt;以下記錄這場協作的完整對話流程，還原每一個關鍵決策點。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段一揭露隱藏危機--10-分鐘後自動停住的-websocket-謎團&quot;&gt;階段一：揭露隱藏危機 — 10 分鐘後自動停住的 WebSocket 謎團&lt;/h1&gt;

&lt;p&gt;App 看似完美運作，但開發者在一場真實的長會議後帶著疑問回來了：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 查一下這個程式碼，為什麼大概即時翻譯大概十多分鐘就會停住，幫我查看可能會有的原因。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;閱讀了全部五個 Swift 原始檔，並結合內建的 Gemini Live API 技能文件，精準指出問題根源：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini Live API 的 WebSocket 連線有約 10 分鐘的 Session 上限&lt;/strong&gt;。時間一到，伺服器會主動關閉連線並送出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GoAway&lt;/code&gt; 信號。然而原始程式碼對這個情境毫無防備，造成三層失效：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;斷線後不重連&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;didCloseWith&lt;/code&gt; 回呼只更新了 UI 上的狀態文字，沒有任何重連邏輯。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;音訊靜默丟棄&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isConnected = false&lt;/code&gt; 之後，每一個來自麥克風的音訊 chunk 都被 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;guard isConnected else { return }&lt;/code&gt; 無聲地吃掉，使用者毫無察覺。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isRunning&lt;/code&gt; 狀態錯誤&lt;/strong&gt;：主視窗的「翻譯中」圖示仍在閃爍，但實際上什麼都沒有發生。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;隨即重構了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiLiveConnection.swift&lt;/code&gt;，在不改變任何外部介面的前提下，引入三道防線：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;GoAway 信號提前偵測&lt;/strong&gt;：在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parseServerResponse()&lt;/code&gt; 中解析伺服器發出的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;goAway&lt;/code&gt; JSON 訊息，不等連線真正斷掉，就提前主動重連，實現無縫接續翻譯。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;指數退避自動重連&lt;/strong&gt;：斷線後以 2s → 4s → 6s 的間隔遞增重試，最多 10 次；重連成功後計數歸零，確保下一次斷線同樣有完整的重試機會。全程靜默，使用者無感。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;30 秒 Ping 保活&lt;/strong&gt;：連線成功後啟動 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Timer&lt;/code&gt;，每 30 秒向伺服器發送 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sendPing&lt;/code&gt;，防止中間網路設備因閒置而切斷連線。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;修改完成後，開發者執行了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bash build_app.sh&lt;/code&gt; 並推送至 GitHub：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Build 成功，commit 完成，push 上去了。之後即時翻譯應該可以長時間持續運作，Session 到期時自動靜默重連，使用者不會察覺中斷。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段二功能訪談--五個問題鎖定兩大優先開發方向&quot;&gt;階段二：功能訪談 — 五個問題鎖定兩大優先開發方向&lt;/h1&gt;

&lt;p&gt;穩定性解決之後，開發者開始思考 App 能走得更遠：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 幫我仔細研究後給我三個新功能推薦&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;仔細閱讀程式碼後，先提出一個定向問題，而非直接給出清單：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;這三個新功能的方向你比較傾向哪一類？&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;A. 實用性 / UX&lt;/strong&gt; — 讓日常使用更方便（字幕匯出、快捷鍵、會議記錄）&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;B. 翻譯品質&lt;/strong&gt; — 更準確、更自然（多語言偵測、說話人辨識）&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;C. 系統整合&lt;/strong&gt; — 與其他工具串接（Notion、Zoom overlay）&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: A&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;三個 UX 功能隨即出爐：&lt;strong&gt;懸浮字幕視窗&lt;/strong&gt;、&lt;strong&gt;會議記錄自動匯出&lt;/strong&gt;、&lt;strong&gt;全域快捷鍵&lt;/strong&gt;。開發者的回應很直接：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 1 跟 2 都要&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;接下來是一場簡短的需求訪談，一次只問一個最關鍵的問題：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;懸浮視窗要顯示幾行？→ &lt;strong&gt;雙行（原文小字 + 翻譯大字）&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;背景風格？→ &lt;strong&gt;毛玻璃效果（vibrancy）&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;匯出方式？→ &lt;strong&gt;自動存到桌面，不跳對話框&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;五個問題之後，設計方向完全清晰。提出完整的設計方案並撰寫了規格文件，存入版本庫後，開發者確認「沒問題」，進入實作階段。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段三計畫驅動開發--subagent-閉環交付review-抓出關鍵-bug&quot;&gt;階段三：計畫驅動開發 — Subagent 閉環交付，Review 抓出關鍵 Bug&lt;/h1&gt;

&lt;p&gt;有了明確規格，進入了它最擅長的工作模式：&lt;strong&gt;先寫計畫，再用多個獨立 Subagent 分工執行，每個 Task 完成後立即由 Reviewer Subagent 審查&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;整個流程分為三個 Task，以下記錄最關鍵的兩個：&lt;/p&gt;

&lt;h3 id=&quot;task-1會議記錄自動匯出&quot;&gt;Task 1：會議記錄自動匯出&lt;/h3&gt;

&lt;p&gt;Implementer Subagent 快速完成了三件事：移除原本 25 行的歷史記錄上限、新增 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exportTranscript()&lt;/code&gt; 方法、在停止翻譯時自動將完整的雙語對照記錄以 Markdown 格式存入 Desktop。&lt;/p&gt;

&lt;p&gt;然而 &lt;strong&gt;Reviewer Subagent（審查子代理人）&lt;/strong&gt; 立刻舉旗：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;發現 Critical Issue：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stop()&lt;/code&gt; 裡的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status = &quot;已停止&quot;&lt;/code&gt; 緊接在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exportTranscript()&lt;/code&gt; 後面執行，立即覆蓋了存檔路徑訊息。使用者永遠只會看到「已停止」，永遠不知道檔案存到哪裡。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;這是一個一行之差的邏輯 Bug，在沒有 Reviewer 的情況下非常容易被忽略。&lt;strong&gt;Fix Subagent&lt;/strong&gt; 隨即介入，將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exportTranscript()&lt;/code&gt; 改為回傳 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bool&lt;/code&gt;：有匯出成功時 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stop()&lt;/code&gt; 不再覆蓋 status；沒有記錄可匯出時才顯示「已停止」。修改後 Reviewer 再次確認，全數通過。&lt;/p&gt;

&lt;h3 id=&quot;task-2懸浮字幕視窗&quot;&gt;Task 2：懸浮字幕視窗&lt;/h3&gt;

&lt;p&gt;新增 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FloatingSubtitleWindow.swift&lt;/code&gt;，核心結構為三層疊加：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSPanel&lt;/code&gt;&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;level = .floating&lt;/code&gt;）：永遠置頂，不搶奪焦點（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.nonactivatingPanel&lt;/code&gt;），能跨全螢幕 App 顯示&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSVisualEffectView&lt;/code&gt;&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;material = .hudWindow&lt;/code&gt;）：macOS 原生毛玻璃效果&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSHostingView&lt;/code&gt;&lt;/strong&gt; 內嵌 SwiftUI 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FloatingSubtitleView&lt;/code&gt;：直接綁定 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TranslatorViewModel.currentLine&lt;/code&gt;，實時更新&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;同時，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TranslatorViewModel&lt;/code&gt; 的所有權從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ContentView&lt;/code&gt; 上移至 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TranslatorApp&lt;/code&gt;，讓主視窗與懸浮視窗共用同一份資料來源，避免資料複製或同步問題。視窗位置在拖拉後存入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UserDefaults&lt;/code&gt;，重啟後自動恢復。&lt;/p&gt;

&lt;p&gt;Task Reviewer 逐一核查 11 項規格，全數通過，無任何修正需求。&lt;/p&gt;

&lt;p&gt;整個「實作 → 審查 → 修正 → 再審查」的閉環全程由子代理人自動完成，開發者只需確認最終 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bash build_app.sh&lt;/code&gt; 乾淨通過即可：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Build 成功、commit 完成、push 上去了。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段四app-品牌升級--用-python-即時生成專業-icon&quot;&gt;階段四：App 品牌升級 — 用 Python 即時生成專業 Icon&lt;/h1&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260702135008634.png&quot; alt=&quot;image-20260702135008634&quot; /&gt;&lt;/p&gt;

&lt;p&gt;功能齊備之後，開發者把注意力放到了外觀：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: app icon 不好看，幫我產生一個專業的&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;先確認環境中有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pillow&lt;/code&gt;（Python 圖像函式庫），接著直接動手寫了一個完整的 Icon 生成腳本，設計說明如下：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;背景&lt;/strong&gt;：深海藍漸層（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#0D1B4E&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#1565C0&lt;/code&gt;），macOS 標準 22% 圓角，呼應 macOS Design Language。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;核心圖案&lt;/strong&gt;：兩個相互疊加的對話泡泡，上方泡泡（半透明白）內含「&lt;strong&gt;A&lt;/strong&gt;」代表英文原音，下方泡泡（純白）內含「&lt;strong&gt;中&lt;/strong&gt;」代表翻譯輸出，中央以雙向箭頭連接，一眼即懂「即時翻譯」的產品定位。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;字型&lt;/strong&gt;：英文採 Avenir Next，中文採 Apple SD Gothic Neo，均為 macOS 內建字型，無需任何外部資源。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;腳本一次輸出 10 種尺寸（16px → 1024px），透過系統的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iconutil&lt;/code&gt; 命令轉成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.icns&lt;/code&gt; 檔，並自動更新 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_app.sh&lt;/code&gt; 將 icon 複製進 App Bundle，Info.plist 加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CFBundleIconFile&lt;/code&gt; 宣告。全程不需要打開 Xcode，也不需要任何圖像設計工具。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段五程式碼品質精修--清零所有編譯-warning&quot;&gt;階段五：程式碼品質精修 — 清零所有編譯 Warning&lt;/h1&gt;

&lt;p&gt;在開發者執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bash build_app.sh&lt;/code&gt; 驗收時，注意到輸出中夾帶了幾行黃色警告：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 執行 build_app.sh 有一些 warning 幫我確認一下&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;仔細執行 Build 並分類了三種警告，依序對症下藥：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Warning 類型&lt;/th&gt;
      &lt;th&gt;根本原因&lt;/th&gt;
      &lt;th&gt;修法&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onChange(of:perform:)&lt;/code&gt; deprecated × 2&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swiftc&lt;/code&gt; 未指定部署目標，預設以最新 SDK 規則檢查&lt;/td&gt;
      &lt;td&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_app.sh&lt;/code&gt; 加入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-target arm64-apple-macos13.0&lt;/code&gt;，讓編譯器知道我們針對 macOS 13，舊 API 是正確選擇&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCRunningApplication&lt;/code&gt; non-Sendable × 2&lt;/td&gt;
      &lt;td&gt;ScreenCaptureKit 框架的類型未標記 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Sendable&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import ScreenCaptureKit&lt;/code&gt; 改為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@preconcurrency import ScreenCaptureKit&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TranslatorViewModel&lt;/code&gt; 非 Sendable 被捕獲&lt;/td&gt;
      &lt;td&gt;ViewModel 在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Sendable&lt;/code&gt; 閉包中被捕獲&lt;/td&gt;
      &lt;td&gt;為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TranslatorViewModel&lt;/code&gt; 加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt;（SwiftUI ViewModel 的現代標準做法），delegate conformance 加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@preconcurrency&lt;/code&gt; 壓制衍生警告&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;最終 Build 輸出乾淨如新，沒有任何 Warning：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;🛠 開始編譯 Swift 檔案 (target: arm64-apple-macos13.0)...
🎨 複製 App Icon...
📝 產生 Info.plist...
✅ 打包完成！
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;全部修改一併 commit 並 push 至 GitHub。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段六真實場景踩坑--screencapturekit-權限迷宮&quot;&gt;階段六：真實場景踩坑 — ScreenCaptureKit 權限迷宮&lt;/h1&gt;

&lt;p&gt;App 功能看似完整，直到開發者實際開機要開始使用時：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 是因為權限問題嗎？我打開 app 一直無法掃描到「目標 App」列表，幫我檢查一下相關程式碼&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;App 清單永遠是空的。系統設定裡的「螢幕錄製」也確實有打勾。這是一個典型的「明明有給權限，但就是不動」的鬼打牆問題。&lt;/p&gt;

&lt;h3 id=&quot;第一刀靜默失敗的-error-處理&quot;&gt;第一刀：靜默失敗的 error 處理&lt;/h3&gt;

&lt;p&gt;閱讀 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioCaptureManager.swift&lt;/code&gt; 後，第一眼就發現問題：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetchShareableApps()&lt;/code&gt; 呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCShareableContent.current&lt;/code&gt; 失敗時，只會 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; 到 console，UI 顯示空列表但毫無任何提示。開發者完全不知道發生了什麼事。&lt;/p&gt;

&lt;p&gt;第一波修改做了三件事：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Info.plist&lt;/code&gt; 補上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSScreenCaptureUsageDescription&lt;/code&gt;&lt;/strong&gt;：沒有這個 key，macOS 的授權對話框永遠不會跳出來。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;加入 ad-hoc 簽名步驟&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codesign --sign - --force --deep&lt;/code&gt; — ScreenCaptureKit 需要 App 具備 code identity，才能出現在「系統設定 &amp;gt; 螢幕錄製」清單中。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;錯誤往 UI 浮出&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetchShareableApps()&lt;/code&gt; 改為回傳 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(apps, errorMessage?)&lt;/code&gt;，任何失敗都會顯示在 App 的狀態列，讓開發者能立刻看到發生了什麼。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build 完成，再次測試——還是同樣的錯誤訊息。&lt;/p&gt;

&lt;h3 id=&quot;第二刀錯誤分類邏輯太激進&quot;&gt;第二刀：錯誤分類邏輯太激進&lt;/h3&gt;

&lt;p&gt;仔細看 error 判斷的程式碼：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isPermissionDenied&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;nsError&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;domain&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;...&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;nsError&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;localizedDescription&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;lowercased&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;permission&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;localizedDescription&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;lowercased&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;denied&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contains(&quot;permission&quot;)&lt;/code&gt; 這一行太過激進，只要 error 描述裡有任何含有 “permission” 的字，就會被錯誤地判定為「權限被拒」，顯示「請至系統設定開啟授權」。實際上可能是完全不同的錯誤。&lt;/p&gt;

&lt;p&gt;修正了判斷邏輯——只有 ScreenCaptureKit 確切的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;userDeclined&lt;/code&gt; 錯誤碼（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-3801&lt;/code&gt;）才視為權限問題，其他錯誤一律顯示真實的 domain、code 與描述，方便診斷：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isPermissionDenied&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;nsError&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3801&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;message&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isPermissionDenied&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;需要螢幕錄製權限：請至系統設定開啟授權&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;無法取得 App 清單（code &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nsError&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;code&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;）：&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;localizedDescription&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;第三刀找到根本原因--tcc-身分不匹配&quot;&gt;第三刀：找到根本原因 — TCC 身分不匹配&lt;/h3&gt;

&lt;p&gt;修正 error 分類後， 執行 App 並擷取 log，發現狀態列顯示的是帶有 code 編號的新訊息，不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-3801&lt;/code&gt;。這確認了：&lt;strong&gt;問題根本不是用戶沒給權限，而是 macOS 根本認不出這個 App&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;根本原因：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;每次執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_app.sh&lt;/code&gt; 重新 ad-hoc 簽名後，binary 的 hash 改變，macOS TCC 資料庫把它視為一個全新的 App。&lt;/strong&gt; 舊的螢幕錄製授權是給上一個 binary 的，新 binary 沒有繼承。系統設定裡顯示勾選，但那是對舊身分的授權，對新 binary 無效。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;解法是重置 TCC 讓 macOS 重新觸發授權對話框：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;tccutil reset ScreenCapture com.poc.MeetingTranslator
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;執行後，重新開啟 App、點「↻」，macOS 立刻跳出「MeetingTranslator 想要錄製這個螢幕的內容」對話框。點「允許」，App 清單瞬間列出所有正在執行的應用程式。&lt;/p&gt;

&lt;h3 id=&quot;永久對策把重置寫進-build-流程&quot;&gt;永久對策：把重置寫進 build 流程&lt;/h3&gt;

&lt;p&gt;Ad-hoc 簽名的問題在開發期間會持續存在——每次 rebuild 都需要重新授權。把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tccutil reset&lt;/code&gt; 直接加進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_app.sh&lt;/code&gt; 的最後一步：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;tccutil reset ScreenCapture com.poc.MeetingTranslator 2&amp;gt;/dev/null &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;✅ 已重置，開啟 App 後系統會重新詢問授權&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;從此每次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bash build_app.sh&lt;/code&gt; 之後，直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open MeetingTranslator.app&lt;/code&gt;，系統就會重新詢問一次授權，整個開發循環再也不會卡在「明明有給權限卻不動」的怪圈裡。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;結語計畫--subagent-實作--ai-review閉環的真正價值&quot;&gt;結語：「計畫 → Subagent 實作 → AI Review」閉環的真正價值&lt;/h1&gt;

&lt;p&gt;這次的協作，讓我感受到與第一次 AGY CLI 開發截然不同的工作方式：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;主動問問題，而非直接動手&lt;/strong&gt;：面對「給我三個新功能推薦」，Claude Code 的第一步是問方向；面對「懸浮視窗」，它逐一確認風格與細節。這種「先對齊再實作」的節奏，比直接猜測需求要可靠得多。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;計畫是品質的護城河&lt;/strong&gt;：在實作前先撰寫規格文件與實作計畫，讓每個 Subagent 都有清晰的邊界與驗收條件。這個看似「多餘」的步驟，在 Task 1 的審查中直接發現了人類開發者很容易忽略的狀態覆蓋 Bug。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;AI Review AI 是不同的保障層&lt;/strong&gt;：Reviewer Subagent 和 Implementer Subagent 是完全獨立啟動的，它們沒有共享上下文。正因如此，Reviewer 能以全新視角發現 Implementer 的盲點——這是「AI 雙檢」帶來的額外保障，不是人力 Code Review 的替代品，而是一個全新的品質層次。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;工具邊界即是功能邊界&lt;/strong&gt;：App Icon 生成、Warning 修復、Git commit/push，Claude Code 在整個開發環境中自由穿梭，開發者不需要切換任何工具，所有動作都在對話中完成。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;真實使用才是最好的測試&lt;/strong&gt;：階段六的 ScreenCaptureKit 問題在所有 build 測試中從未出現，直到開發者真正開機要用才踩到。這種「靜默失敗 + 系統層面的身分不匹配」問題，只有在真實場景下才會浮現。Claude Code 的診斷方式——從修正 error 分類、到讓 UI 顯示真實錯誤碼、再到找到 TCC 根本原因——是一個典型的「縮小假設範圍，讓問題說話」的除錯思路。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果說第一篇是「從零到有」，這篇記錄的是「從可用到好用，再到在真實場景下站得住腳」。兩種 AI Agent、兩種協作風格，共同完成了一個涵蓋底層音訊、WebSocket 連線、SwiftUI UI、Python 圖像生成、系統權限診斷的完整 Native macOS App。我們下期見！&lt;/p&gt;
</description>
				<pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/agy-macos-app-enhance/</link>
				<guid isPermaLink="true">https://www.evanlin.com/agy-macos-app-enhance/</guid>
			</item>
		
			<item>
				<title>[Gemini API 實戰] 打造 MemeFinder：用 Gemini 視覺與語意嵌入，做一個「打字就找到梗圖」的原生 Mac 選單列小工具</title>
				<description>&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/b46cc843-d88c-40bf-8ec0-a8d36ce062f4&quot; alt=&quot;image-memefinder-hero&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;緣起聊天打到一半那張梗圖到底在哪&quot;&gt;緣起：聊天打到一半，那張梗圖到底在哪？&lt;/h1&gt;

&lt;p&gt;每個重度聊天的人手機與電腦裡都存了一堆迷因圖，但真正要用的時候——對話進行到一半、想丟一張「謝謝再聯絡」或「我就爛」——卻怎麼也翻不到。檔名是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IMG_4821.jpg&lt;/code&gt;，相簿沒有分類，搜尋更是無從下手。&lt;/p&gt;

&lt;p&gt;我先看到一個很棒的開源專案 &lt;a href=&quot;https://github.com/ShiQu1218/MemeTalk&quot;&gt;ShiQu1218/MemeTalk&lt;/a&gt;，它用 Python + Streamlit + SQLite 打造了一套本地迷因語意搜尋系統，會掃描你本機的迷因資料夾、用 OCR 與向量嵌入建立索引，再做多路召回。功能完整，但偏研究取向、要開瀏覽器跑 Streamlit。&lt;/p&gt;

&lt;p&gt;我想要的是更貼近「日常順手工具」的東西：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;一個原生 Mac App，一個搜尋框，打我想找的內容，就跳出相關的梗圖，點一下直接複製到剪貼簿。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;於是有了 &lt;strong&gt;MemeFinder&lt;/strong&gt;。這篇文章紀錄它從零到「選單列常駐 + 全域快捷鍵」的開發過程，以及途中幾個很有代表性的坑。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;系統設計與架構&quot;&gt;系統設計與架構&lt;/h1&gt;

&lt;p&gt;核心概念很單純：&lt;strong&gt;指定一個本機迷因資料夾 → 用 Gemini 幫每張圖建立索引 → 打字做語意搜尋 → 點圖複製&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;技術選型上我做了三個關鍵決定：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;原生 SwiftUI App&lt;/strong&gt;，而不是 Electron。剪貼簿複製圖片、全域快捷鍵、選單列常駐，這些用 AppKit 都是一級公民。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Gemini&lt;/strong&gt; 負責兩件事：用視覺模型 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt; 讀出圖中文字、生成繁中描述與情緒標籤；用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-embedding-2&lt;/code&gt; 把這些語意轉成 768 維向量。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;語意向量 + 關鍵字混合搜尋&lt;/strong&gt;。純關鍵字對中文召回太差；語意向量才能做到「打相關敘述就找到圖」。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;系統架構流向&quot;&gt;系統架構流向&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    A[使用者指定迷因資料夾] --&amp;gt;|掃描 jpg/png/webp| B[Indexer 索引器]
    B --&amp;gt;|每張圖| C[Gemini 視覺模型 gemini-3-flash-preview]
    C --&amp;gt;|OCR文字 + 描述 + 標籤 + 情緒| D[Gemini 嵌入 gemini-embedding-2]
    D --&amp;gt;|768 維向量| E[本機索引檔 index.json]
    F[使用者打字查詢] --&amp;gt;|⌃⌘M 選單列浮窗| G[Gemini 嵌入查詢字串]
    G --&amp;gt;|cosine 相似度 + 關鍵字加權| E
    E --&amp;gt;|排序結果| H[縮圖牆]
    H --&amp;gt;|點圖| I[NSPasteboard 複製到剪貼簿]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;整個專案刻意拆成兩個 Swift Package target：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Target&lt;/th&gt;
      &lt;th&gt;類型&lt;/th&gt;
      &lt;th&gt;內容&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemeFinder&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;library&lt;/td&gt;
      &lt;td&gt;邏輯、模型、服務、ViewModel（全部有單元測試）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemeFinderApp&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;executable&lt;/td&gt;
      &lt;td&gt;SwiftUI 畫面 + 選單列殼（薄殼，依賴上面的函式庫）&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;這個拆分不是裝飾——它直接決定了測試能不能順利跑，後面「踩坑二」會講到為什麼。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;核心實作&quot;&gt;核心實作&lt;/h1&gt;

&lt;h3 id=&quot;1-用-gemini-視覺模型自動標註迷因圖&quot;&gt;1. 用 Gemini 視覺模型自動標註迷因圖&lt;/h3&gt;

&lt;p&gt;索引時，每張圖會送進視覺模型，要求它&lt;strong&gt;只輸出 JSON&lt;/strong&gt;：圖中文字、繁中描述、標籤、情緒。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;responseMimeType&lt;/code&gt; 設成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;application/json&lt;/code&gt; 來穩定輸出格式：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;annotateRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;apiKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;imageData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;mimeType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;URLRequest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
    你是迷因圖標註助手。請閱讀這張圖，輸出 JSON，欄位：
    ocr_text(圖中所有文字), description(用繁體中文描述畫面與梗),
    tags(3-8 個繁體中文關鍵字陣列), emotion(單一情緒詞)。只輸出 JSON。
    &quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;contents&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[[&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;parts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;inline_data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;mime_type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mimeType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;imageData&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;base64EncodedString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()]]&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]],&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;generationConfig&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;responseMimeType&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ... 設定 URL、x-goog-api-key header、POST body&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;2-語意--關鍵字混合排序&quot;&gt;2. 語意 + 關鍵字混合排序&lt;/h3&gt;

&lt;p&gt;查詢字串嵌入成向量後，對每張圖算 cosine 相似度，再對 OCR 文字與標籤命中的關鍵字加權，合併排序：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;queryEmbedding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;queryText&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                   &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;IndexedImage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;SearchResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;tokens&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;queryText&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;lowercased&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;whereSeparator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isWhitespace&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;SearchResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compactMap&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cos&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;cosineSimilarity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;queryEmbedding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;embedding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;haystack&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ocrText&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot; &quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;joined&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;separator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot; &quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;lowercased&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;matches&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tokens&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;filter&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isEmpty&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;haystack&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;boost&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;matches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;// 關鍵字加權上限 0.3&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cos&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;boost&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SearchResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;score&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;score&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Array&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;results&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sorted&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$1&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;prefix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;整個搜尋引擎是純函式，把 Gemini 藏在 protocol 後面，所以這段邏輯完全能離線單元測試，不用打真實 API。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;重大踩坑與解決方案&quot;&gt;重大踩坑與解決方案&lt;/h1&gt;

&lt;p&gt;這個專案真正花時間的地方，從來不是「快樂路徑」，而是下面這幾個坑。&lt;/p&gt;

&lt;h3 id=&quot;踩坑一神秘的-geminierror-error-0索引與搜尋全部失敗&quot;&gt;踩坑一：神秘的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiError error 0&lt;/code&gt;——索引與搜尋全部失敗&lt;/h3&gt;

&lt;p&gt;App 打包完、設定好金鑰、選好資料夾，一搜尋——下面什麼圖都沒有，只跳出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiError error 0&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;我沒有亂猜，而是直接用真實金鑰打了一次 embedding 端點，把回應印出來：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;s2&quot;&gt;&quot;https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:embedContent&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;x-goog-api-key: &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$KEY&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{&quot;content&quot;:{&quot;parts&quot;:[{&quot;text&quot;:&quot;貓&quot;}]},&quot;output_dimensionality&quot;:768}&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;證據一翻兩瞪眼：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;embedding&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;values&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;-0.0063&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;-0.0200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;問題在於，我的解析器讀的是 &lt;strong&gt;複數&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;embeddings[0].values&lt;/code&gt;（那是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;batchEmbedContents&lt;/code&gt; 批次端點的格式），但單筆 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;embedContent&lt;/code&gt; 回的是 &lt;strong&gt;單數&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;embedding.values&lt;/code&gt;。於是&lt;strong&gt;每一次 embed 都失敗&lt;/strong&gt;——索引每張圖失敗、把查詢字串轉向量也失敗，全都丟出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;badResponse&lt;/code&gt;（在 UI 上顯示成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiError error 0&lt;/code&gt;）。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;【解決方案】&lt;/strong&gt;
修正解析器讀單數 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;embedding.values&lt;/code&gt;，並保留複數格式作為後備；順手也加固了標註解析器（思考型模型有時會多回一個沒有文字的 “thought” part，要跳過取第一個有文字的 part）：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;embedding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fromEmbedContent&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;root&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;JSONSerialization&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;jsonObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;GeminiError&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;badResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;cannot parse embedContent payload&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// 單筆 embedContent 回傳 {&quot;embedding&quot;:{&quot;values&quot;:[...]}}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;embedding&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;embedding&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
       &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;embedding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;values&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;values&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// batchEmbedContents 才是 {&quot;embeddings&quot;:[{&quot;values&quot;:[...]}]} — 一併容忍&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;embeddings&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;embeddings&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]],&lt;/span&gt;
       &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;embeddings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;values&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;values&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;GeminiError&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;badResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;cannot parse embedContent payload&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;教訓：&lt;strong&gt;API 回應格式請以真實回應為準，不要相信記憶或二手文件&lt;/strong&gt;。一行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 省下無數猜測。&lt;/p&gt;

&lt;h3 id=&quot;踩坑二swiftpm-的-main-入口衝突與-swiftuicore-連結錯誤&quot;&gt;踩坑二：SwiftPM 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; 入口衝突與 SwiftUICore 連結錯誤&lt;/h3&gt;

&lt;p&gt;我一開始把整個專案做成單一 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;executableTarget&lt;/code&gt;，讓測試直接依賴它。結果測試怎麼跑都連結失敗：executable target 需要一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; 進入點，但這個進入點要到 UI 那一步的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main App&lt;/code&gt; 才會存在；而隨手補一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.swift&lt;/code&gt; 佔位檔，又會和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main&lt;/code&gt; 衝突（Swift 不允許一個 target 同時有兩個進入點）。更別說 SwiftUI 在 executable target 還會冒出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SwiftUICore.tbd ... not an allowed client&lt;/code&gt; 的連結警告。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;【原因分析與解決方案】&lt;/strong&gt;
這其實是個架構問題，不是編譯問題。正確做法是把專案拆成兩層：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemeFinder&lt;/code&gt;（library target）&lt;/strong&gt;：所有邏輯、模型、服務、ViewModel——測試只依賴這層，沒有進入點，乾乾淨淨地當函式庫連結。ViewModel 要 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import Combine&lt;/code&gt;（而不是 SwiftUI）就能拿到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ObservableObject&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemeFinderApp&lt;/code&gt;（executable target）&lt;/strong&gt;：只放 SwiftUI 畫面與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import MemeFinder&lt;/code&gt; 取用上面的公開型別。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;拆完之後，library 與測試完全不碰 SwiftUI，連結警告消失，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main&lt;/code&gt; 衝突也不復存在。&lt;strong&gt;「測試要依賴什麼」往往會反過來逼出乾淨的模組邊界。&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;踩坑三平行索引的速率限制與索引到一半想喊停&quot;&gt;踩坑三：平行索引的速率限制與「索引到一半想喊停」&lt;/h3&gt;

&lt;p&gt;第一版索引是一張一張序列呼叫 Gemini（先 annotate 再 embed），上百張圖慢到讓人懷疑人生。於是改成用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;withTaskGroup&lt;/code&gt; 做&lt;strong&gt;有界平行&lt;/strong&gt;（同時最多 4 條），但這帶出三個新問題：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Gemini 免費額有&lt;strong&gt;速率限制&lt;/strong&gt;，併發太多會吃 429。&lt;/li&gt;
  &lt;li&gt;大資料夾索引到一半，使用者想&lt;strong&gt;取消&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;平行完成的順序是亂的，但結果要&lt;strong&gt;穩定排序&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;【解決方案】&lt;/strong&gt;
三個問題分別處理，全部收斂在同一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;buildIndex&lt;/code&gt; 裡：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;429 退避重試&lt;/strong&gt;：只對 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiError.rateLimited&lt;/code&gt; 做指數退避重試（最多 3 次），其他錯誤直接記錄不重試。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;協作式取消&lt;/strong&gt;：尊重 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task.isCancelled&lt;/code&gt;，取消時停止派新工作、保留已完成的部分。連退避時的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task.sleep&lt;/code&gt; 都讓 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CancellationError&lt;/code&gt; 正常傳遞，而不是吞掉它再多打一次 API。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;穩定排序&lt;/strong&gt;：結果收進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[路徑: 圖]&lt;/code&gt; 字典，最後依「事先排好序的檔案清單」重組輸出，跟完成順序脫鉤。&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 先塞滿 maxConcurrent 個任務，之後每完成一個就補一個——嚴格維持併發上限&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;..&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;maxConcurrent&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;scheduleNext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;res&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;img&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resultsByPath&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;img&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;done&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;progress&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;done&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;scheduleNext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;順帶一提，HTTP 狀態碼也被抽成一個純函式 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mapResponse(data:statusCode:)&lt;/code&gt;：429 → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rateLimited&lt;/code&gt;、其他非 2xx → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;httpError(碼)&lt;/code&gt;、2xx → 回傳資料。重試邏輯才有依據，這段也好測。&lt;/p&gt;

&lt;h3 id=&quot;踩坑四從有視窗的-app進化成選單列常駐--全域快捷鍵&quot;&gt;踩坑四：從「有視窗的 App」進化成「選單列常駐 + 全域快捷鍵」&lt;/h3&gt;

&lt;p&gt;工具好不好用，差別在於「叫出它要幾步」。我希望聊天到一半按 &lt;strong&gt;⌃⌘M&lt;/strong&gt; 就能呼叫搜尋浮窗，App 平常縮在選單列、不佔 Dock。這一步踩了兩個 macOS 老坑：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) 全域快捷鍵要不要輔助使用權限？&lt;/strong&gt; 不用。用 Carbon 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RegisterEventHotKey&lt;/code&gt; 註冊固定快捷鍵，不需要 Accessibility 權限（不像監聽全鍵盤）。但在 Swift 6 嚴格並行下，C 事件回呼要透過一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id → 實例&lt;/code&gt; 的靜態註冊表來分派，得用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nonisolated(unsafe)&lt;/code&gt; 並靠「Carbon 事件只在主執行緒派發」這個不變量來保證安全。若 ⌃⌘M 已被佔用，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RegisterEventHotKey&lt;/code&gt; 會回傳失敗——這時靜默降級、記一筆 log，點選單列 icon 仍可用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) 選單列右鍵選單的時序競態。&lt;/strong&gt; 最初的寫法是「設定 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;statusItem.menu&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;performClick&lt;/code&gt; → 馬上清空 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;menu&lt;/code&gt;」，但同步清空會和 AppKit 的選單追蹤迴圈打架，選單會閃一下就消失。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;【解決方案】&lt;/strong&gt;
改用直接彈出選單，完全繞過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;statusItem.menu&lt;/code&gt; 的賦值與清空：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;@objc&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;statusButtonClicked&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;event&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;NSApp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;currentEvent&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;togglePopover&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rightMouseUp&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// 直接彈出，不要賦值再同步清空 statusItem.menu（會和 AppKit 選單追蹤迴圈競態）&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;statusItem&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;NSMenu&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;popUpContextMenu&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;makeMenu&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;togglePopover&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;最後在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build-app.sh&lt;/code&gt; 打包的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Info.plist&lt;/code&gt; 加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LSUIElement = true&lt;/code&gt;，Dock 圖示消失，MemeFinder 正式成為純選單列工具。&lt;/p&gt;

&lt;h3 id=&quot;踩坑五設定表單一片空白一個症狀三層原因&quot;&gt;踩坑五：設定表單一片空白——一個症狀，三層原因&lt;/h3&gt;

&lt;p&gt;改成選單列版後，使用者回報「設定視窗整個是空白的」。這個看似單純的 bug，剝開來其實有三層，每一層都很有代表性。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;第一層：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Form&lt;/code&gt; 在手刻 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSWindow&lt;/code&gt; 裡塌成零高度。&lt;/strong&gt;
原本設定畫面住在 SwiftUI 原生的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Settings { }&lt;/code&gt; scene 裡，SwiftUI 會自動給它合理尺寸。改版後改用手刻的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSWindow(contentViewController: NSHostingController(rootView: SettingsView()))&lt;/code&gt; 承載，而 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SettingsView&lt;/code&gt; 的結尾只寫了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.frame(width: 460)&lt;/code&gt;——&lt;strong&gt;只有寬度、沒有高度&lt;/strong&gt;。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSWindow(contentViewController:)&lt;/code&gt; 會用內容的自然尺寸決定視窗大小，但 SwiftUI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Form&lt;/code&gt; 在垂直方向是貪婪的、沒有約束時自然高度會被算成接近 0，於是視窗開成一條 460 寬、高度幾乎為零的空白條。修正只要補上高度：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// 在手刻 NSWindow（非 SwiftUI Settings scene）裡承載時，沒有高度約束的&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// Form 會塌成 ~0，視窗就變成一條空白。&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;460&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;320&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;第二層：⌘, 和選單列「設定…」走的是兩條不同的路。&lt;/strong&gt;
補了高度後，使用者說「還是空白」。追問之下才發現——他是按 &lt;strong&gt;⌘,&lt;/strong&gt; 叫出設定的，而選單列右鍵的「設定…」走的卻是另一條路。原因是：⌘, 在 SwiftUI App 會觸發 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Settings { }&lt;/code&gt; scene，而我當初為了迴避狀態共享問題，把那裡放成了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Settings { EmptyView() }&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 改版時為了避開狀態共享，Settings scene 被放空——於是 ⌘, 開出來就是一片空白&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Scene&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Settings&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;EmptyView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;換句話說，&lt;strong&gt;設定有兩個入口，而它們指向不同的東西&lt;/strong&gt;：⌘, 指向空 scene，選單列「設定…」指向真正的視窗。修正是把兩條路統一——讓 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Settings&lt;/code&gt; scene 裝真正的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SettingsView&lt;/code&gt;（⌘, 直接可用），選單列「設定…」也改成開同一個原生設定視窗：&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Settings&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;SettingsView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;vm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;appDelegate&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;indexing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;appDelegate&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;indexing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                 &lt;span class=&quot;nv&quot;&gt;onReindex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;appDelegate&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;reindexNow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
                 &lt;span class=&quot;nv&quot;&gt;onCancel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;appDelegate&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;cancelReindex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 選單列「設定…」改成開同一個 Settings scene&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;@objc&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;openSettings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;NSApp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;activate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;ignoringOtherApps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;NSApp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sendAction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Selector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;showSettingsWindow:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)),&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;這裡也順帶把 SwiftUI App body 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; 隔離這件事用上了——所以從 body 直接讀取 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@MainActor&lt;/code&gt; 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;appDelegate.settings&lt;/code&gt; 是合法的，不需要額外的橋接。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;第三層（最陰險的）：選單列 App 用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open&lt;/code&gt; 根本不會載入新版。&lt;/strong&gt;
過程中最浪費時間的，是我重新編譯後請使用者 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open MemeFinder.app&lt;/code&gt;，他卻一直看到舊行為。因為 MemeFinder 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LSUIElement&lt;/code&gt; 選單列常駐 App——當已經有一個實例在跑時，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open&lt;/code&gt; 只會&lt;strong&gt;喚醒既有的舊行程&lt;/strong&gt;，而不會用新的二進位重啟。於是我們其實一直在測同一個舊版。正確的開發循環是先確實關掉，再從原始碼跑：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;killall MemeFinderApp 2&amp;gt;/dev/null&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; swift run MemeFinderApp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這層提醒我：&lt;strong&gt;debug 時要先確認「你測的真的是你改的那一版」&lt;/strong&gt;，否則所有推理都建立在錯誤的觀察上。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;關於開發過程本身&quot;&gt;關於「開發過程」本身&lt;/h1&gt;

&lt;p&gt;這個專案幾乎全程是用&lt;strong&gt;規格 → 計畫 → 子代理逐項實作 → 兩段式審查&lt;/strong&gt;的 AI agent 工作流推進的：每個功能先寫設計規格、再拆成可獨立測試的小任務、每個任務都先寫失敗測試（TDD）再實作，完成後由獨立的審查代理檢查規格符合度與程式品質，最後再做一次整支分支的總審。&lt;/p&gt;

&lt;p&gt;幾個踩坑——&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiError error 0&lt;/code&gt;、library/executable 拆分、退避時吞掉 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CancellationError&lt;/code&gt;、選單時序競態——其實有一半是&lt;strong&gt;審查階段&lt;/strong&gt;揪出來的，而不是第一版就寫對。這也呼應了那條老原則：&lt;strong&gt;有測試護體、有人（或代理）認真讀 diff，比寫得快重要得多。&lt;/strong&gt; 最終整支專案維持 47 個單元測試、release build 零警告。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成果與效益&quot;&gt;成果與效益&lt;/h1&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;打字即得、點圖即貼&lt;/strong&gt;：在選單列浮窗打中文敘述，語意搜尋立刻列出相關梗圖，點一下複製到剪貼簿，直接貼進 LINE / Slack / 訊息。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;隱私友善、離線可搜&lt;/strong&gt;：圖片與索引都在本機（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/Library/Application Support/MemeFinder/index.json&lt;/code&gt;），只有「建立索引」那一步會呼叫 Gemini。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;真正的順手工具&lt;/strong&gt;：⌃⌘M 隨叫隨到、選單列常駐、不佔 Dock；增量索引只處理新增/變動的圖，索引可顯示進度、可取消。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;乾淨可維護的架構&lt;/strong&gt;：library/executable 雙層、Gemini 藏在 protocol 後、純邏輯全有測試覆蓋。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;本專案所有開發程式碼均已開源於 GitHub：&lt;a href=&quot;https://github.com/kkdai/meme-finder-app&quot;&gt;kkdai/meme-finder-app&lt;/a&gt;。歡迎大家 clone 下來、放進自己的迷因收藏資料夾，親自體驗一下「打字就找到梗圖」的快感！&lt;/p&gt;
</description>
				<pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/meme-finder/</link>
				<guid isPermaLink="true">https://www.evanlin.com/meme-finder/</guid>
			</item>
		
	</channel>
</rss>
