<?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>[好書分享]台灣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>
		
			<item>
				<title>[Gemini API 實戰] Gemini Batch API 與 Webhook 實戰記：打造 LINE Bot 附近餐廳大數據一鍵深度分析</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260614175257527.png&quot; alt=&quot;image-20260614175257527&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;異步處理的利器gemini-batch-api--webhooks&quot;&gt;異步處理的利器：Gemini Batch API &amp;amp; Webhooks&lt;/h1&gt;

&lt;p&gt;在開發基於 LLM 的應用程式時，我們常常需要處理大量的數據分析任務——例如一次性分析數十家餐廳的評論、對大量文章進行分類、或是批次生成翻譯。如果採用傳統的同步 API（即時呼叫），不僅會面臨嚴重的 &lt;strong&gt;Rate Limit (速率限制)&lt;/strong&gt; 阻塞，更會因為網路連線逾時（Timeout）與極高的運算成本而宣告失敗。&lt;/p&gt;

&lt;p&gt;為了打破這個限制，Google 推出了 &lt;strong&gt;Gemini Batch API&lt;/strong&gt; 與 &lt;strong&gt;Webhook API&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/batch-api?hl=zh-tw&quot;&gt;Gemini Batch API&lt;/a&gt;&lt;/strong&gt;：允許開發者將大量的請求打包成一個 JSONL 檔案一次性上傳。Gemini 會在後台進行非同步的排程運算，不佔用您日常的即時 API 額度（Rate Limits），且其運算成本通常只有即時 API 的一半，是處理非緊急大數據的完美選擇。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/webhooks?hl=zh-tw&quot;&gt;Webhook API&lt;/a&gt;&lt;/strong&gt;：傳統的 Batch 任務需要我們在本機不斷寫輪詢（Polling）去檢查狀態。而透過 Webhook，當 Gemini 完成 Batch 運算後，會主動向您指定的 URL 發送一個 HTTP POST 回呼，即時通知任務已完成，讓系統架構變得更加優雅與節能。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇文章將紀錄我們如何將這兩項強大的 API 整合進我們的 &lt;strong&gt;LINE Bot 餐廳分析助手&lt;/strong&gt;，實現在行動端一鍵對特定餐廳進行深度評論與招牌菜大數據分析的開發經歷。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;../images/LINE 2026-06-14 17.30.21.tiff&quot; alt=&quot;LINE 2026-06-14 17.30.21&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;原本的餐廳分析功能是當用戶發送位置時，Bot 會列出附近餐廳，並提供一個通用的「深度評論分析 (Batch)」按鈕，點下去會一次性把附近所有餐廳送去分析。然而這帶來了不好的 UX：分析所有餐廳耗時過長，且用戶往往只想針對他感興趣的&lt;strong&gt;某一家&lt;/strong&gt;特定餐廳進行深挖。&lt;/p&gt;

&lt;p&gt;因此，我們將功能優化為&lt;strong&gt;動態 Quick Reply 按鈕&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;用戶傳送定位，Bot 透過 Google Maps Grounding 搜尋附近餐廳。&lt;/li&gt;
  &lt;li&gt;用戶端獲得餐廳純文字列表後，Bot 自動以 Gemini 擷取評分最高的前 3 家餐廳名稱。&lt;/li&gt;
  &lt;li&gt;產生 3 個客製化的 Quick Reply 按鈕（例如：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;🍴 分析 鼎泰豐&lt;/code&gt;）。&lt;/li&gt;
  &lt;li&gt;用戶點擊特定餐廳按鈕後，Bot 立即回覆「處理中」以避免 LINE 逾時，並在背景提交該單一餐廳的 Batch 任務，待 Gemini 運算完畢後主動推播專屬大數據報告。&lt;/li&gt;
&lt;/ol&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;|Location Message| B[Google Maps Grounding 搜尋]
    B --&amp;gt;|餐廳純文字列表| C[Gemini-2.5-flash 擷取前三名餐廳]
    C --&amp;gt;|動態生成 Quick Reply| D[LINE Bot 回覆 3 個客製化分析按鈕]
    D --&amp;gt;|用戶點擊特定分析| E[FastAPI Background Task]
    E --&amp;gt;|立即 Reply ACK| F[LINE 聊天室訊息]
    E --&amp;gt;|打包 JSONL 並上傳| G[Gemini Batch API 提交]
    G --&amp;gt;|運算完成 Webhook/Polling 回呼| H[主動 Push 深度報告給用戶]
&lt;/code&gt;&lt;/pre&gt;

&lt;hr /&gt;

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

&lt;h3 id=&quot;1-使用-gemini-從-grounding-文字中精準提取餐廳名&quot;&gt;1. 使用 Gemini 從 Grounding 文字中精準提取餐廳名&lt;/h3&gt;
&lt;p&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; 中，地圖搜尋返回的是一段富含格式與說明的純文字。我們使用 Gemini-2.5-flash 的 structured output 概念，以 JSON 格式精確擷取餐廳名稱：&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;# 擷取前三大餐廳名稱以供 Quick Reply 使用
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;names&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;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place_type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;restaurant&quot;&lt;/span&gt;&lt;span class=&quot;p&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;n&quot;&gt;extract_prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;請從以下文字中擷取所有餐廳的名稱，並以 JSON 陣列格式返回（例如：[&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;餐廳A&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;餐廳B&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;]）。請直接輸出 JSON 陣列，不要包含任何 markdown 標記（如 ```json）或說明文字。&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;extract_res&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;extract_prompt&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;extract_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;extract_res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&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;extract_res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&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;n&quot;&gt;names&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;loads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extract_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                    &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;re&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;array_match&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;re&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;r&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;n&quot;&gt;extract_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;re&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DOTALL&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;array_match&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                        &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ast&lt;/span&gt;
                        &lt;span class=&quot;n&quot;&gt;names&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ast&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;literal_eval&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;array_match&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;]&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                
                &lt;span class=&quot;n&quot;&gt;names&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;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&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;n&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;names&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;info&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Extracted restaurant names for Quick Reply: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;names&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e_extract&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&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;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Failed to extract restaurant names: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e_extract&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&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;h3 id=&quot;2-動態生成-line-quick-reply-按鈕&quot;&gt;2. 動態生成 LINE Quick Reply 按鈕&lt;/h3&gt;
&lt;p&gt;在 &lt;a href=&quot;file:///Users/al03034132/Documents/linebot-helper-python/main.py&quot;&gt;main.py&lt;/a&gt; 中，我們取得餐廳列表後，動態產生 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;QuickReplyButton&lt;/code&gt;。我們需要特別注意 LINE API 對於按鈕 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;label&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;quick_reply&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place_type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;restaurant&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&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;&quot;status&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;s&quot;&gt;&quot;success&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;restaurant_names&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&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;&quot;restaurant_names&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;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;restaurant_names&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;buttons&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;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;restaurant_names&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;n&quot;&gt;clean_label&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;
                    &lt;span class=&quot;c1&quot;&gt;# LINE label limit is 20 characters
&lt;/span&gt;                    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clean_label&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;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                        &lt;span class=&quot;n&quot;&gt;clean_label&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;clean_label&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;9&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;s&quot;&gt;&quot;…&quot;&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;buttons&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                        &lt;span class=&quot;n&quot;&gt;QuickReplyButton&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                            &lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PostbackAction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                                &lt;span class=&quot;n&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🍴 分析 &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clean_label&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&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;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
                                    &lt;span class=&quot;s&quot;&gt;&quot;action&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;specific_foodie_deep_analysis&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                    &lt;span class=&quot;s&quot;&gt;&quot;restaurant_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;
                                &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
                                &lt;span class=&quot;n&quot;&gt;display_text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🔍 進行「&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;」深度評論與招牌菜色分析&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;n&quot;&gt;quick_reply&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;QuickReply&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;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buttons&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;img src=&quot;../images/Finder 2026-06-14 17.53.52.png&quot; alt=&quot;Finder 2026-06-14 17.53.52&quot; /&gt;&lt;/p&gt;

&lt;p&gt;在將這套動態 Quick Reply 串接 Batch API 的過程中，我們遇到了幾個關鍵的 UX 與 API 限制問題：&lt;/p&gt;

&lt;h3 id=&quot;踩坑一line-20-字元限制導致的-api-發送報錯&quot;&gt;踩坑一：LINE 20 字元限制導致的 API 發送報錯&lt;/h3&gt;
&lt;p&gt;最初實作時，我們直接將餐廳的全名帶入按鈕的 Label，例如：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;🍴 分析 樂釜 Love Hot Pot 極上鍋物&lt;/code&gt;。結果 LINE API 直接回傳了 400 錯誤，訊息完全無法傳送：&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;LineBotApiError: status_code=400, error_message=The property &apos;label&apos; must be less than 20 characters.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;【原因分析與解決方案】&lt;/strong&gt;
LINE 官方對 Quick Reply 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;label&lt;/code&gt; 限制極為嚴格，&lt;strong&gt;包含 Emoji 與空白字元在內，最多只能有 20 個字元&lt;/strong&gt;。
為此，我們在程式碼中加入了字數檢查與動態截斷機制：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;先將原始餐廳名稱（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clean_label&lt;/code&gt;）進行截斷：若長度超過 10 個字，則強行截取前 9 個字並補上「&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;…&lt;/code&gt;」（佔用 10 個字）。&lt;/li&gt;
  &lt;li&gt;加上前置字串 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;🍴 分析 &lt;/code&gt;（共 5 個字元），總長度最大為 15 個字元，安全地保持在 20 個字元的限制之內，從此不再報錯！&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;踩坑二batch-api-異步延遲與-line-webhook-的三秒逾時生存戰&quot;&gt;踩坑二：Batch API 異步延遲與 LINE Webhook 的「三秒逾時生存戰」&lt;/h3&gt;
&lt;p&gt;用戶點選「分析餐廳」按鈕時，Bot 必須呼叫 Google Search Grounding 先行蒐集該餐廳的網路評論，再打包 JSONL 檔並上傳至 Gemini 提交 Batch 任務。這一整套動作耗時通常需要 3 到 8 秒。
然而，&lt;strong&gt;LINE Webhook 伺服器要求 Bot 必須在 3 秒內回傳 HTTP 200 OK 響應&lt;/strong&gt;，否則會判定為連線失敗並重複發送請求，導致伺服器嚴重堵塞。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;【原因分析與解決方案】&lt;/strong&gt;
我們將處理架構徹底異步化（Asynchronous）：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;快速響應&lt;/strong&gt;：當 Bot 攔截到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;specific_foodie_deep_analysis&lt;/code&gt; 的 Postback 動作時，&lt;strong&gt;不直接在 Request 流程中執行分析&lt;/strong&gt;，而是立刻調用 LINE 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reply_message&lt;/code&gt; 回覆用戶：「&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;🔍 收到！正在為您進行深度分析...大約需要 1-2 分鐘...&lt;/code&gt;」，然後瞬間回傳 HTTP 200 結束該 Webhook 請求。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;背景任務派遣&lt;/strong&gt;：使用 Python &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asyncio.create_task&lt;/code&gt; 將繁重的網路搜尋、上傳與提交任務，派發給 FastAPI 的後台 Worker 去執行。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;大數據推播&lt;/strong&gt;：當後台的 Polling 監聽器或是 Gemini Webhook 接收到任務完成通知時，再使用 LINE 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_message&lt;/code&gt; 主動將分析報告推送給特定用戶。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;踩坑三gemini-batch-api-的排隊與-pending-狀態&quot;&gt;踩坑三：Gemini Batch API 的排隊與 Pending 狀態&lt;/h3&gt;
&lt;p&gt;在測試中，用戶有時會困惑「為什麼按下去過了三分鐘都還沒有回覆？是不是 Bot 掛掉了？」。
查詢系統日誌後發現，我們的 JSONL 檔案早已成功上傳，但 Gemini 伺服器端的任務狀態一直卡在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JobState.JOB_STATE_PENDING&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;【解決方案】&lt;/strong&gt;
這是 Batch API 的特性，任務需要排隊，等待 Google 的伺服器資源。
我們採取了兩大優化：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;工作量極小化&lt;/strong&gt;：將批次分析的餐廳數量降為 1 家，把 JSONL 的請求行數縮減到極致，以加快 Gemini 的調度與處理速度。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;UX 優化與去重機制&lt;/strong&gt;：在用戶點擊分析時，我們先檢查該用戶是否已有正在執行的 Batch Job，如果有則回覆「&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;⏳ 您的深度分析任務正在執行中，請耐心等候&lt;/code&gt;」，防止用戶因為焦急重複點擊而提交多個重複的 Batch Job，耗費不必要的資源。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

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

&lt;p&gt;這次針對 &lt;strong&gt;LINE Bot 餐廳助手&lt;/strong&gt; 的 Quick Reply 與 Gemini Batch API 的優化，達到了極佳的實用價值：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;高度客製化的行動端體驗&lt;/strong&gt;：用戶定位後，不需要打字，直接一鍵點選感興趣的餐廳，就能精準獲取該餐廳的招牌菜色與評論雷點摘要。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;穩健的後台架構&lt;/strong&gt;：藉由異步背景任務與 LINE 限制字數的安全閥，徹底解決了 Webhook 逾時與 LINE API 報錯的風險。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;大數據處理的成本优势&lt;/strong&gt;：藉由 Batch API 的半價優勢與 Webhook 的主動回呼，在保障用戶體驗的同時，也為伺服器節省了大量的運算資源與 API 成本。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;透過這套架構，LINE Bot 在行動端真正實現了低延遲、高穩定的大數據深度分析體驗！&lt;/p&gt;

&lt;p&gt;本專案所有的開發程式碼均已開源於 GitHub：&lt;a href=&quot;https://github.com/kkdai/linebot-helper-python&quot;&gt;kkdai/linebot-helper-python&lt;/a&gt;。歡迎大家也去部署並親自測試看看這個一鍵分析功能，相信能為您的 LINE Bot 專案帶來更上一層樓的智慧體驗！&lt;/p&gt;

</description>
				<pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-batch-webhook/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-batch-webhook/</guid>
			</item>
		
			<item>
				<title>[I/O Extended Taipei] 在 Gemini API 家族中建構應用程式：從呼叫 API，到架構一個會自己完成工作的系統</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260612163641980.png&quot; alt=&quot;image-20260612163641980&quot; /&gt;&lt;/p&gt;

&lt;p&gt;(活動：&lt;a href=&quot;https://gdg.community.dev/events/details/google-gdg-taipei-presents-google-io-extended-2026-taipei/&quot;&gt;Google I/O Extended 2026 Taipei&lt;/a&gt; / 簡報：&lt;a href=&quot;https://speakerdeck.com/line_developers_tw/building-applications-in-the-gemini-api-family&quot;&gt;SpeakerDeck&lt;/a&gt;)&lt;/p&gt;

&lt;h1 id=&quot;前情提要gemini-api-已經不是多打一個-prompt而已&quot;&gt;前情提要：Gemini API 已經不是「多打一個 prompt」而已&lt;/h1&gt;

&lt;p&gt;如果你對 Gemini API 的印象還停留在「選一個 model，送一段 prompt，拿回一段文字」，那你看到 2026 年這一輪更新時，很可能會突然意識到一件事：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini API 已經從單純的 API 介面，變成一個可以拿來搭應用、搭代理、搭非同步流程的完整平台。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這篇內容整理自我在 &lt;a href=&quot;https://gdg.community.dev/events/details/google-gdg-taipei-presents-google-io-extended-2026-taipei/&quot;&gt;Google I/O Extended 2026 Taipei&lt;/a&gt; 的分享「在 Gemini API 家族中建構應用程式」。LINE 台灣開發者關係部技術總監 &lt;strong&gt;Evan Lin&lt;/strong&gt; 在現場反覆強調的核心觀察是：開發者現在真正該思考的，不再只是 &lt;em&gt;「我要用 Pro 還是 Flash？」&lt;/em&gt;，而是 &lt;em&gt;「我要怎麼把模型、檢索、代理、回呼與成本控制串成一套系統？」&lt;/em&gt;。&lt;/p&gt;

&lt;p&gt;換句話說，重點正在從 &lt;strong&gt;call API&lt;/strong&gt;，轉向 &lt;strong&gt;design system&lt;/strong&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;先看全景圖2026-gemini-api-家族到底多了什麼&quot;&gt;先看全景圖：2026 Gemini API 家族到底多了什麼？&lt;/h2&gt;

&lt;p&gt;如果把 2026 年的 Gemini API 當成一張 capability map 來看，大致可以拆成三層。&lt;/p&gt;

&lt;h3 id=&quot;第一層核心模型&quot;&gt;第一層：核心模型&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Gemini 3.5 Pro&lt;/strong&gt;：最強推理能力，適合複雜規劃、進階分析與多步驟任務。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Gemini 3.5 Flash&lt;/strong&gt;：主力模型，速度、成本與能力最平衡，適合多數產品流量。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Flash-Lite&lt;/strong&gt;：高頻率、低成本場景的意圖判斷器與前置分類器。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Gemini Embedding 2&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;Retrieval&lt;/strong&gt;：File Search、Google Search Grounding、URL Context。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Agent / Async&lt;/strong&gt;：Agents API、Webhook、Deep Research agent。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;：Context caching、Batch API、Live API。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;第三層系統設計方式&quot;&gt;第三層：系統設計方式&lt;/h3&gt;

&lt;p&gt;這一層反而最重要。因為當上面那幾個能力被做成平台服務之後，很多以前得自己補的「中間層」突然不見了：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;不一定要自己搭一套 RAG pipeline。&lt;/li&gt;
  &lt;li&gt;不一定要自己養 agent loop。&lt;/li&gt;
  &lt;li&gt;不一定要用 polling 卡住主伺服器等結果。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;核心觀察&lt;/strong&gt;：Gemini API 的升級不只是「模型變強」，而是 &lt;strong&gt;Google 把原本屬於應用層的麻煩事，往平台層往下吃掉了&lt;/strong&gt;。這會直接改變我們設計 AI 系統的方式。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;架構轉折點三個工具三次思維切換&quot;&gt;架構轉折點：三個工具，三次思維切換&lt;/h1&gt;

&lt;p&gt;這場分享裡最值得反覆消化的，是這三個工具背後代表的架構變化。&lt;/p&gt;

&lt;h2 id=&quot;1-file-search從手刻-rag轉向-managed-rag&quot;&gt;1. File Search：從手刻 RAG，轉向 Managed RAG&lt;/h2&gt;

&lt;p&gt;以前講到企業知識問答，大家直覺就是：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;切 chunk。&lt;/li&gt;
  &lt;li&gt;做 embedding。&lt;/li&gt;
  &lt;li&gt;存進 vector DB。&lt;/li&gt;
  &lt;li&gt;寫 retrieval code。&lt;/li&gt;
  &lt;li&gt;再自己補 citation 與權限控管。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;現在 File Search 出現後，開發者可以把更多力氣放在「文件怎麼治理、權限怎麼分、回答怎麼呈現」，而不是一直重複寫那套基礎設施。&lt;/p&gt;

&lt;p&gt;更重要的是，它不是只會查文字。&lt;/p&gt;

&lt;h3 id=&quot;為什麼這次的-file-search-特別值得注意&quot;&gt;為什麼這次的 File Search 特別值得注意？&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;圖文同空間&lt;/strong&gt;：PDF 裡的截圖、圖表、圖文混排，不再只是附件，而是模型可理解的內容。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Metadata 過濾&lt;/strong&gt;：可以依部門、系統、文件類型做過濾，這對企業內部知識檢索非常重要。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;精確引用&lt;/strong&gt;：能回到具體頁數與 grounding metadata，讓回答更能被信任。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這代表一件很實際的事：很多企業過去花在 LangChain、向量庫與 chunking 策略上的時間，現在可以大幅往 &lt;strong&gt;權限設計、UX、內容治理&lt;/strong&gt; 轉移。&lt;/p&gt;

&lt;h2 id=&quot;2-agents-api從-client-side-loop轉向-server-side-managed-agent&quot;&gt;2. Agents API：從 client-side loop，轉向 server-side managed agent&lt;/h2&gt;

&lt;p&gt;過去要做 agent，常見寫法是自己維護一個 ReAct 或 tool loop：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;模型想下一步。&lt;/li&gt;
  &lt;li&gt;呼叫工具。&lt;/li&gt;
  &lt;li&gt;收結果。&lt;/li&gt;
  &lt;li&gt;再餵回模型。&lt;/li&gt;
  &lt;li&gt;重複直到完成。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;問題是這裡面充滿了工程細節：狀態保存、timeout、重試、背景執行、長任務監控，最後你會發現自己大半時間都在養一個「agent runtime」。&lt;/p&gt;

&lt;p&gt;Agents API 改變的地方在於：你可以把任務 POST 給 Gemini，讓它在 server side 把長流程跑完，甚至處理到 20 分鐘等級的複雜任務。&lt;/p&gt;

&lt;p&gt;這背後的意義不是「比較方便」而已，而是開發者終於可以把焦點放回：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;任務如何定義？&lt;/li&gt;
  &lt;li&gt;哪些工具可以用？&lt;/li&gt;
  &lt;li&gt;成功條件是什麼？&lt;/li&gt;
  &lt;li&gt;結果回來後產品要怎麼接？&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;3-webhook從-polling轉向-event-driven&quot;&gt;3. Webhook：從 Polling，轉向 Event-Driven&lt;/h2&gt;

&lt;p&gt;一旦任務可能跑到數分鐘，甚至十幾分鐘，傳統同步請求就不合理了。&lt;/p&gt;

&lt;p&gt;所以 Webhook 的角色其實很關鍵：不是小功能，而是讓整套 agent workflow 能真正進入 production 的必要條件。當 Gemini 完工後主動把結果 POST 回你的 server，你的系統就能改成事件驅動：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;前台先回使用者「任務已接收」。&lt;/li&gt;
  &lt;li&gt;背景交給 Agents API 執行。&lt;/li&gt;
  &lt;li&gt;完成後用 webhook 回推結果。&lt;/li&gt;
  &lt;li&gt;再由你的服務通知使用者、更新資料庫或觸發下一步流程。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這對高併發產品特別重要，因為你終於不需要拿一堆 server connection 在那裡傻等。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;從-line-bot-的角度看該怎麼設計一套-gemini-應用&quot;&gt;從 LINE Bot 的角度看，該怎麼設計一套 Gemini 應用？&lt;/h1&gt;

&lt;p&gt;Evan 在分享裡給的一個很實用的建議，是 &lt;strong&gt;在 LLM 前面先放一層 router&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;這個設計聽起來簡單，但幾乎決定了你的成本、延遲與可預測性。&lt;/p&gt;

&lt;h2 id=&quot;一個很務實的路由方式&quot;&gt;一個很務實的路由方式&lt;/h2&gt;

&lt;p&gt;先用便宜的 &lt;strong&gt;Flash-Lite&lt;/strong&gt; 做意圖分流：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;快問快答&lt;/strong&gt;：直接交給 Flash 或 Flash-Lite 生成。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;查公司文件&lt;/strong&gt;：進 File Search。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;複雜長任務&lt;/strong&gt;：進 Agents API。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這麼做有三個好處：&lt;/p&gt;

&lt;ol&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;如果你是做 LINE Bot、客服助手、內部知識助理或工作流代理，這個 router 幾乎應該是預設配置，而不是之後再補。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;基礎建設不是不重要而是不用每次都自己重做&quot;&gt;基礎建設不是不重要，而是不用每次都自己重做&lt;/h2&gt;

&lt;p&gt;這場分享另一個很強的訊息是：開發者的時間應該重新分配。&lt;/p&gt;

&lt;p&gt;以前很多 AI 專案的工時，其實被這些事吃掉：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;向量資料庫維運&lt;/li&gt;
  &lt;li&gt;chunking 與 retrieval 調參&lt;/li&gt;
  &lt;li&gt;長任務排程&lt;/li&gt;
  &lt;li&gt;websocket / polling / callback 流程&lt;/li&gt;
  &lt;li&gt;token 成本優化&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;現在有了 File Search、Agents API、Webhook、Context caching、Batch API 之後，我們比較應該花時間的地方，變成：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;業務規則與工具邊界&lt;/li&gt;
  &lt;li&gt;文件權限與資料治理&lt;/li&gt;
  &lt;li&gt;使用者互動體驗&lt;/li&gt;
  &lt;li&gt;任務拆解與路由策略&lt;/li&gt;
  &lt;li&gt;失敗回復與結果可解釋性&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這也是為什麼我很認同 Evan 那句潛台詞：&lt;strong&gt;真正有價值的，不是你會不會自己造一套向量庫，而是你能不能把 80% 的精力放回產品核心。&lt;/strong&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;三個最值得直接帶走的實戰結論&quot;&gt;三個最值得直接帶走的實戰結論&lt;/h2&gt;

&lt;h3 id=&quot;1-在-llm-之前先放路由層&quot;&gt;1. 在 LLM 之前，先放路由層&lt;/h3&gt;

&lt;p&gt;不要把所有問題都直接送進同一個模型。先分類，再決定是要生成、檢索，還是進 agent 任務。&lt;/p&gt;

&lt;h3 id=&quot;2-擁抱非同步不要硬把長任務塞進同步-api&quot;&gt;2. 擁抱非同步，不要硬把長任務塞進同步 API&lt;/h3&gt;

&lt;p&gt;只要任務可能超過幾秒，就該認真考慮 Agents API + Webhook。這不是優化，而是架構正確性問題。&lt;/p&gt;

&lt;h3 id=&quot;3-把-rag-的工程時間換去做權限與體驗&quot;&gt;3. 把 RAG 的工程時間，換去做權限與體驗&lt;/h3&gt;

&lt;p&gt;當 File Search 已經能處理大量基礎工作時，開發者更該關心的是：資料能不能安全查、答案能不能被驗證、引用能不能讓使用者信任。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;為什麼這場分享值得反覆看&quot;&gt;為什麼這場分享值得反覆看？&lt;/h2&gt;

&lt;p&gt;因為它點破了一個很多團隊現在正處在的轉折點：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;我們已經不再只是替 LLM 寫 prompt，而是在替 AI 應用設計作業系統。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;模型當然還是核心，但真正拉開產品差距的地方，越來越不是「你選哪個模型」，而是：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;你怎麼決定何時用哪個能力。&lt;/li&gt;
  &lt;li&gt;你怎麼讓系統能長時間可靠地跑。&lt;/li&gt;
  &lt;li&gt;你怎麼讓答案可追溯、可驗證、可維運。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果你還用 2024 年那套「一個 chat endpoint 包天下」的方式理解生成式 AI，那看到 2026 年的 Gemini API 家族，會很容易低估它。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;後記從-api-使用者變成-ai-系統設計者&quot;&gt;後記：從 API 使用者，變成 AI 系統設計者&lt;/h2&gt;

&lt;p&gt;這場「在 Gemini API 家族中建構應用程式」最有價值的地方，不是再多教你一個新參數或新 SDK，而是提醒大家一個更根本的轉向：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;下一階段的競爭力，不在於誰比較會 call model，而在於誰比較會把 model、retrieval、agent 與 event flow 組成一個能真正工作的系統。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;如果你正在做 LINE Bot、企業知識庫、內部助理、客服流程或任何需要多步驟 AI 協作的產品，這份架構視角很值得直接拿去重畫一次你現在的系統圖。&lt;/p&gt;

&lt;p&gt;很多時候，真正該重構的不是 prompt，而是整條 pipeline。&lt;/p&gt;
</description>
				<pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/io-extended-taipei-build-app-with-gemini-api-family/</link>
				<guid isPermaLink="true">https://www.evanlin.com/io-extended-taipei-build-app-with-gemini-api-family/</guid>
			</item>
		
			<item>
				<title>[AI 實戰] 用 AGY CLI (Antigravity) 打造 macOS 應用程式的極速 AI 協同開發體驗</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260612102252662.png&quot; alt=&quot;image-20260612102252662&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;寫在前面開發者的全新協同模式&quot;&gt;寫在前面：開發者的全新協同模式&lt;/h1&gt;

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

&lt;p&gt;想像一下這個場景：你正在開發一個結合 macOS 底層音訊（CoreAudio/ScreenCaptureKit）與 Gemini Live API WebSocket 的即時會議翻譯 App。在測試階段，程式突然報錯閃退，且音訊串流出現全 0 的大靜音。&lt;/p&gt;

&lt;p&gt;過去，你的排錯流程可能是：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;打開終端機，撈出 log 檔案。&lt;/li&gt;
  &lt;li&gt;複製整段報錯與相關程式碼。&lt;/li&gt;
  &lt;li&gt;切換到瀏覽器，打開 AI 聊天視窗，貼上並詢問原因。&lt;/li&gt;
  &lt;li&gt;得到修改建議後，複製回編輯器，手動測試。&lt;/li&gt;
  &lt;li&gt;重複以上步驟，直到修復，然後手動寫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.md&lt;/code&gt;、寫部落格、建立 GitHub 倉庫、提交代碼並推送。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;而在這一次的開發中，我們採用了 Google DeepMind 設計的 &lt;strong&gt;AGY CLI (Antigravity-CLI)&lt;/strong&gt; 代理人。我們驚訝地發現，上述所有繁瑣的上下文切換，都可以在終端機內透過與智慧代理的對話&lt;strong&gt;全自動完成&lt;/strong&gt;。這篇文章將還原真實的 Prompt 對話流，分享我們如何與 AGY CLI 協作，從零打造出一個 macOS 會議翻譯 App。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段一靈感碰撞與架構設計&quot;&gt;階段一：靈感碰撞與架構設計&lt;/h1&gt;

&lt;p&gt;一切源自於一個開發想法與一份 Google 新釋出的 API 文件。開發者在終端機對 AGY CLI 貼上了網址，並拋出第一個核心問題：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 跟著這個範例，可能打造出一個 Mac OS App 可以收取電腦裡面的聲音，然後即時翻譯成其他語言? 開始幫我思考該怎麼做，我主要目的是要可以拿來開會 zoom / google meet 直接翻譯其他國家的語言成繁體中文。&lt;/p&gt;

  &lt;p&gt;這邊有其他文件給你參考：https://ai.google.dev/gemini-api/docs/live-api/live-translate?hl=zh-tw&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;收到指令後，AGY CLI 發揮了架構師的角色，迅速分析並給出了一套 macOS 原生的解決方案：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;不用安裝虛擬音效卡&lt;/strong&gt;：推薦使用 Apple 在 macOS 13+ 推出的 &lt;strong&gt;ScreenCaptureKit&lt;/strong&gt; 框架，直接以 Sandbox 安全模式擷取選定應用程式（如 Zoom 或 Chrome）的純淨音軌。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;即時音訊重採樣&lt;/strong&gt;：利用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AVAudioConverter&lt;/code&gt; 將多聲道/立體聲的 48kHz 音訊，實時重採樣為 Gemini Live 支援的 16kHz 單聲道 PCM 格式。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;雙向 WebSocket 通訊&lt;/strong&gt;：利用 Swift 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URLSessionWebSocketTask&lt;/code&gt; 實作雙向長連接，一邊傳送 PCM 音訊塊，一邊接收繁體中文翻譯結果與播報音訊。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段二環境配置與編譯焦慮消除&quot;&gt;階段二：環境配置與編譯焦慮消除&lt;/h1&gt;

&lt;p&gt;在開始動手寫 Swift 代碼前，開發者對於 macOS 的編譯環境提出了疑問：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 我是不是一定要有 xcode 才能 compile?&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 幫我跑 xcode-select –install&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;面對開發者的環境焦慮，AGY CLI 給出了解答：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;雖然建立標準的 macOS SwiftUI GUI 應用程式包最好使用 Xcode，但我們&lt;strong&gt;並不一定需要手動開啟 Xcode IDE&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;AGY CLI 協助撰寫了一個自動建置腳本 &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;swiftc&lt;/code&gt; 命令列工具來編譯所有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.swift&lt;/code&gt; 原始檔，並自動打包成完整的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.app&lt;/code&gt; 目錄結構。&lt;/li&gt;
  &lt;li&gt;同時，對於開發者要求安裝 Command Line Tools 的請求，AGY CLI 主動呼叫了權限工具，直接在本地運行了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcode-select --install&lt;/code&gt;，自動配置好 Swift 編譯環境。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段三連線排障與音訊-bug-修復&quot;&gt;階段三：連線排障與音訊 Bug 修復&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;這時便是 AGY CLI 展示「自主排錯」威力的時刻。收到提示後，它自動定位了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug.log&lt;/code&gt; 檔案，呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tail&lt;/code&gt; 分析運行時日誌，找出了兩個致命問題：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;模型名稱不相容&lt;/strong&gt;：原程式填寫了標準 REST 模型 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;models/gemini-3.5-flash&lt;/code&gt;，而 Live WebSocket API 僅接受 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-live-translate-preview&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;JSON 設定層級出錯&lt;/strong&gt;：API 文件使用的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v1alpha&lt;/code&gt; 版本 SDK，將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inputAudioTranscription&lt;/code&gt; 包在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generationConfig&lt;/code&gt; 中；然而原生 WebSocket 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v1beta&lt;/code&gt; 端點要求這兩個欄位必須放在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setup&lt;/code&gt; 根目錄下。這就是導致 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CloseCode 1007&lt;/code&gt; 閃退的元凶。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;多聲道立體聲靜音 Bug&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ScreenCaptureKit&lt;/code&gt; 擷取到的多聲道音軌，在舊版代碼中因為 AudioBufferList 記憶體配置不足，拷貝時被截斷成全為 0 的靜音。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AGY CLI 隨即主動修改了 &lt;a href=&quot;file:///Users/al03034132/Documents/gemini-live-api-examples/gemini-live-translate-livekit/swift-demo/AudioCaptureManager.swift&quot;&gt;AudioCaptureManager.swift&lt;/a&gt;，引入&lt;strong&gt;「雙呼叫 (Double-Call)」暫存器分配指針技術&lt;/strong&gt;，並重構了 &lt;a href=&quot;file:///Users/al03034132/Documents/gemini-live-api-examples/gemini-live-translate-livekit/swift-demo/GeminiLiveConnection.swift&quot;&gt;GeminiLiveConnection.swift&lt;/a&gt; 的 Payload 結構。&lt;/p&gt;

&lt;p&gt;修改完成後，應用程式順利運行，控制台日誌終於印出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;是否為靜音(全0): false&lt;/code&gt;，且即時雙語字幕與即時播報語音均能順利作動！&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;階段四自動化-devops-與-github-交付&quot;&gt;階段四：自動化 DevOps 與 GitHub 交付&lt;/h1&gt;

&lt;p&gt;當開發者確認程式可以正常工作後，最後一步是將程式碼開源分享：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 我要把 swift-demo 資料夾另外 checkin 到我自己的 github repo，給我建議的 repo 名稱，並且寫一個 README.md 在 swift-demo 底下。&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: 幫我把該資料夾相關變動都寫進 &lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos&quot;&gt;https://github.com/kkdai/gemini-live-translate-macos&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AGY CLI 立刻接手了最後的 DevOps 工作：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;它推薦使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-live-translate-macos&lt;/code&gt; 做為 Repo 名稱，並撰寫了專案的英文 GitHub description 與 topics 標籤。&lt;/li&gt;
  &lt;li&gt;它自動在 &lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos&quot;&gt;README.md&lt;/a&gt; 中補齊了完整的環境準備、Xcode 沙盒 Capabilities 設定、命令行腳本執行步驟與 API 排雷提示。&lt;/li&gt;
  &lt;li&gt;獲得使用者的倉庫網址後，AGY CLI 主動在背景執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git init&lt;/code&gt;，撰寫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt;，將所有程式碼進行 commit，並順利 push 至遠端 GitHub 倉庫！&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;結語開發變革與心得&quot;&gt;結語：開發變革與心得&lt;/h1&gt;

&lt;p&gt;透過這次與 AGY CLI 的合作開發，我們體驗到了前所未有的極速開發流程：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;認知負載降低&lt;/strong&gt;：開發者只需用自然語言表達意圖（如「幫我跑安裝」、「幫我排查為什麼連線中斷」），AI Agent 就會自主翻譯為對應的系統命令與程式碼修改。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;原生系統級的掌控&lt;/strong&gt;：AI 能直接讀取並執行命令，實時與開發環境同步，極大地減少了以往 Web AI Chat 容易產生的幻覺與環境版本不符的問題。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;一站式交付&lt;/strong&gt;：從第一句「思考該怎麼做」到最後一鍵「Push 到 GitHub 倉庫」，AGY CLI 完美縫合了整個軟體工程生命週期。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這項實戰體驗證明，在 Agentic AI 的時代下，一位開發者配合一個強大的 CLI 代理人，就能用極短的時間，高品質地交付一個涉及系統底層與最新 API 的 Native 應用程式。我們下期見！&lt;/p&gt;
</description>
				<pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/agy-macos-app/</link>
				<guid isPermaLink="true">https://www.evanlin.com/agy-macos-app/</guid>
			</item>
		
			<item>
				<title>[Gemini 3.5 Live Translate 實戰] macOS 應用程式音訊擷取與 Gemini Live API 雙向即時翻譯系統開發記</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260610144830233.png&quot; alt=&quot;image-20260610144830233&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;全新-api-亮相gemini-35-live-translate&quot;&gt;全新 API 亮相：Gemini 3.5 Live Translate&lt;/h1&gt;

&lt;p&gt;在 2026 年 6 月 9 日，Google 正式釋出了全新的語音即時翻譯模型 —— &lt;strong&gt;Gemini 3.5 Live Translate&lt;/strong&gt;。這是 Google 在 AI 語音翻譯技術上的又一重大突破，目前已在 Google AI Studio、Gemini Live API 提供開發者公開預覽，並同步導入 Google Translate 與 Google Meet 等服務。&lt;/p&gt;

&lt;p&gt;Gemini 3.5 Live Translate 的核心特點包括：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;流暢自然的雙向語音翻譯&lt;/strong&gt;：支援高達 70 種以上的語言，能自動偵測輸入語音的語言種類，不需人工設定。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;連續串流生成（而非單句輪替）&lt;/strong&gt;：不同於以往必須等說話者完全說完才進行翻譯的 turn-by-turn 系統，Gemini 3.5 Live Translate 會一邊聆聽一邊實時生成翻譯，在上下文理解與即時性之間取得平衡，翻譯僅落後說話者數秒，完全避免了尷尬的停頓。&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;這篇文章將紀錄我們如何使用 Swift 開發一款 native macOS 應用程式 &lt;strong&gt;MeetingTranslator&lt;/strong&gt;，串接這款強大的新 API，實現將特定 App 音訊即時翻譯為繁體中文語音與字幕的實戰經歷。&lt;/p&gt;

&lt;hr /&gt;

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

&lt;p&gt;我們的目標是開發一款 Native SwiftUI 應用程式，它無須安裝像 BlackHole 這樣的虛擬音效卡，而是利用 Apple 官方的 &lt;strong&gt;ScreenCaptureKit&lt;/strong&gt; 框架，直接擷取選定應用程式（如 Google Chrome 的 YouTube 或線上會議）的音訊流，並透過 &lt;strong&gt;Gemini Live WebSocket API&lt;/strong&gt;，實現超低延遲的語音對話式翻譯。&lt;/p&gt;

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

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    A[ScreenCaptureKit &amp;lt;br&amp;gt;擷取應用程式音訊] --&amp;gt;|48kHz Stereo Float32| B[AVAudioConverter &amp;lt;br&amp;gt;重採樣與聲道轉換]
    B --&amp;gt;|16kHz Mono Int16 PCM| C[Gemini Live API &amp;lt;br&amp;gt;WebSocket 連線]
    C --&amp;gt;|實時辨識字幕| D[SwiftUI Subtitle HUD &amp;lt;br&amp;gt;繁體中文雙語字幕]
    C --&amp;gt;|24kHz Mono Int16 PCM 翻譯音訊| E[AudioPlaybackManager &amp;lt;br&amp;gt;AVAudioEngine 播放器]
&lt;/code&gt;&lt;/pre&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;核心實作一screencapturekit-擷取與重採樣&quot;&gt;核心實作一：ScreenCaptureKit 擷取與重採樣&lt;/h1&gt;

&lt;p&gt;macOS 13 推出的 &lt;strong&gt;ScreenCaptureKit&lt;/strong&gt; 讓開發者免去了過去依賴核心音訊虛擬設備的痛苦，能精準過濾並錄製特定應用程式的畫面與音訊。&lt;/p&gt;

&lt;h3 id=&quot;1-篩選與過濾目標-app&quot;&gt;1. 篩選與過濾目標 App&lt;/h3&gt;
&lt;p&gt;我們使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCShareableContent&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;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;fetchShareableApps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&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;SCRunningApplication&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;do&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;content&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;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SCShareableContent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;applications&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;n&quot;&gt;app&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;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;applicationName&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&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;k&quot;&gt;else&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;kc&quot;&gt;false&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;bundleId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bundleIdentifier&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bundleId&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hasPrefix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;com.apple.system&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bundleId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Bundle&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bundleIdentifier&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;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;applicationName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&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;applicationName&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;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;無法獲取可共享內容: &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;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&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;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;2-啟動音訊擷取串流&quot;&gt;2. 啟動音訊擷取串流&lt;/h3&gt;
&lt;p&gt;過濾出目標 App（如 Google Chrome）後，我們為其建立一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCContentFilter&lt;/code&gt;並套用至 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCStream&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;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;appFilter&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SCContentFilter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;displays&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;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;including&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;targetApp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;exceptingWindows&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;config&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SCStreamConfiguration&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;capturesAudio&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;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;width&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;32&lt;/span&gt; &lt;span class=&quot;c1&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;height&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;32&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;kt&quot;&gt;SCStream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;appFilter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;configuration&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;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;delegate&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;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stream&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;addStreamOutput&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;type&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;audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;sampleHandlerQueue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;DispatchQueue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;com.translator.audioQueue&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stream&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;startCapture&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-live-websocket-雙向連線&quot;&gt;核心實作二：Gemini Live WebSocket 雙向連線&lt;/h1&gt;

&lt;p&gt;Gemini Live API 的核心在於使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wss://&lt;/code&gt; 連線，透過單一通道即時傳送麥克風/應用程式音訊，並同步接收模型生成的翻譯文字與翻譯音訊。&lt;/p&gt;

&lt;p&gt;在 &lt;a href=&quot;file:///Users/al03034132/Documents/gemini-live-api-examples/gemini-live-translate-livekit/swift-demo/GeminiLiveConnection.swift&quot;&gt;GeminiLiveConnection.swift&lt;/a&gt; 中，我們透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URLSessionWebSocketTask&lt;/code&gt; 來維護此雙向管道。連線後，必須立即發送一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setup&lt;/code&gt; 控制訊息來初始化模型組態。&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;踩坑一gemini-live-專屬模型限制&quot;&gt;踩坑一：Gemini Live 專屬模型限制&lt;/h3&gt;
&lt;p&gt;最初我們嘗試將標準的 REST API 模型名稱（例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-flash&lt;/code&gt;）帶入 WebSocket 連線中，卻遭遇到伺服器直接中斷連線：&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;❌ WebSocket 被 Gemini 伺服器關閉 (CloseCode: 1008, 原因: models/gemini-3.5-flash is not found for API version v1beta, or is not supported for bidiGenerateContent.)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;【解決方案】&lt;/strong&gt;
Gemini 的雙向 Live API 目前僅支援特定優化過的即時模型。我們必須將模型欄位限制為：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-2.0-flash-exp&lt;/code&gt; (標準雙向對話)&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-live-translate-preview&lt;/code&gt; (專為即時翻譯優化的預覽模型)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;踩坑二json-payload-欄位結構出錯文檔與-api-版本的隱藏差異&quot;&gt;踩坑二：JSON Payload 欄位結構出錯（文檔與 API 版本的隱藏差異）&lt;/h3&gt;
&lt;p&gt;在設定即時口譯組態時，我們參考了 Google 官方文件，將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inputAudioTranscription&lt;/code&gt;（輸入語音轉文字）與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;outputAudioTranscription&lt;/code&gt;（輸出語音轉文字）欄位放進了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generationConfig&lt;/code&gt; 之中，結果引發了 1007 錯誤：&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;❌ WebSocket 被 Gemini 伺服器關閉 (CloseCode: 1007, 原因: Invalid JSON payload received. Unknown name &quot;inputAudioTranscription&quot; at &apos;setup.generation_config&apos;: Cannot find field.)
&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;v1alpha&lt;/code&gt; 與用戶端 SDK（例如 JavaScript / Python SDK）所設計的 JSON 中，將這兩個欄位包在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generationConfig&lt;/code&gt; 內。然而在目前的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v1beta&lt;/code&gt; WebSocket 原生端點：
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;這兩個欄位應該位於 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setup&lt;/code&gt; 物件的&lt;strong&gt;根目錄層級&lt;/strong&gt;，而翻譯特有的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;translationConfig&lt;/code&gt; 則必須放在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generationConfig&lt;/code&gt; 底下。正確的 JSON Payload 結構如下：&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;n&quot;&gt;setupMessage&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;setup&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;model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;models/&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;modelName&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;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;inputAudioTranscription&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;// 啟用輸入端即時字幕，放在 setup 根目錄&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;outputAudioTranscription&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;// 啟用輸出端即時字幕，放在 setup 根目錄&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;responseModalities&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;AUDIO&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;translationConfig&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;targetLanguageCode&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;zh-TW&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;s&quot;&gt;&quot;echoTargetLanguage&quot;&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;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;這樣修改後，WebSocket 設定終於成功握手，不再閃退！&lt;/p&gt;

&lt;h3 id=&quot;踩坑三多聲道立體聲擷取造成的零位元組靜音&quot;&gt;踩坑三：多聲道立體聲擷取造成的「零位元組靜音」&lt;/h3&gt;
&lt;p&gt;在順利建立 WebSocket 管道並開始推送重採樣後的音訊後，我們發現 Gemini 依然沒有任何翻譯回應。觀察日誌輸出，發現發送的音訊區塊內容竟然全為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt; (Silence)：&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;📊 [WebSocket] 已發送 500 個音訊區塊 | 大小: 640 bytes | 是否為靜音(全0): true
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;【原因分析】&lt;/strong&gt;
當擷取對象（如 Google Chrome 播放 YouTube 影片）輸出為立體聲（Stereo，2 Channels）或多聲道音訊時，我們原本用來將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CMSampleBuffer&lt;/code&gt; 轉為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AVAudioPCMBuffer&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;// 舊寫法：直接假設單一 Channel 指標並拷貝&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;audioBufferList&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AudioBufferList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;blockBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CMBlockBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer&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;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;audioBufferList&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;在多聲道環境下會因為配置記憶體不足，導致拷貝中斷或填充失敗，使得後面音訊重採樣器（AVAudioConverter）餵進去的值全是空值（靜音）。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;【解決方案】&lt;/strong&gt;
必須使用 &lt;strong&gt;雙呼叫 (Double-Call) 技巧&lt;/strong&gt; 來動態配置 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioBufferList&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;nil&lt;/code&gt; 作為 buffer 輸出，僅用來精確查詢該 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sampleBuffer&lt;/code&gt; 所需的實體記憶體大小 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bufferListSizeNeededOut&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;UnsafeMutablePointer&amp;lt;AudioBufferList&amp;gt;.allocate&lt;/code&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;：依據多聲道格式（Interleaved/Non-Interleaved），精確使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;memcpy&lt;/code&gt; 將對應的資料段拷貝到暫存 buffer 中，再送進轉換器降噪降頻。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&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;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;audioBufferFromSampleBuffer&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;nv&quot;&gt;sampleBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CMSampleBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;asbd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AudioStreamBasicDescription&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;AVAudioPCMBuffer&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;sourceFormat&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sourceFormat&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;return&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;c1&quot;&gt;// 1. 動態獲取所需要的 AudioBufferList 記憶體大小&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bufferListSize&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;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;sampleBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;bufferListSizeNeededOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bufferListSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;bufferListOut&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;bufferListSize&lt;/span&gt;&lt;span class=&quot;p&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;nv&quot;&gt;blockBufferAllocator&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;blockBufferMemoryAllocator&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;flags&lt;/span&gt;&lt;span class=&quot;p&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;nv&quot;&gt;blockBufferOut&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;guard&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;noErr&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;return&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;c1&quot;&gt;// 2. 分配足夠空間的指標並填充&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bufferListPointer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UnsafeMutablePointer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;AudioBufferList&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;allocate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;capacity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bufferListSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;defer&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bufferListPointer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;deallocate&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;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;blockBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CMBlockBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;sampleBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;bufferListSizeNeededOut&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;bufferListOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bufferListPointer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;bufferListSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bufferListSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;blockBufferAllocator&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;blockBufferMemoryAllocator&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;flags&lt;/span&gt;&lt;span class=&quot;p&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;nv&quot;&gt;blockBufferOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blockBuffer&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;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;noErr&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;return&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;c1&quot;&gt;// 3. 建立符合來源格式的 AVAudioPCMBuffer 並安全拷貝...&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;frameCount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AVAudioFrameCount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CMSampleBufferGetNumSamples&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sampleBuffer&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;pcmBuffer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;pcmFormat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sourceFormat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;frameCapacity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frameCount&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;return&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;n&quot;&gt;pcmBuffer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;frameLength&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frameCount&lt;/span&gt;
    
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;audioBuffers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UnsafeMutableAudioBufferListPointer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bufferListPointer&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;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;audioBuffer&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;n&quot;&gt;audioBuffers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;enumerated&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;mData&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;audioBuffer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;index&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&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;n&quot;&gt;sourceFormat&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channelCount&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;continue&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;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isNonInterleaved&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;asbd&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mFormatFlags&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;kAudioFormatFlagIsNonInterleaved&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;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isNonInterleaved&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;dst&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pcmBuffer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;int16ChannelData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;index&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;nf&quot;&gt;memcpy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dst&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mData&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;n&quot;&gt;audioBuffer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mDataByteSize&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;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;dst&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pcmBuffer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;int16ChannelData&lt;/span&gt;&lt;span class=&quot;p&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;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;offset&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;index&lt;/span&gt; &lt;span class=&quot;o&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;n&quot;&gt;frameCount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;memcpy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;advanced&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;by&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;offset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mData&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;n&quot;&gt;audioBuffer&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mDataByteSize&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;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pcmBuffer&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;這個重構在套用後，我們再次撥放 Chrome 的 YouTube 測試片，控制台終於印出：
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;是否為靜音(全0): false&lt;/code&gt;，且順利取得了 Gemini 的即時語音回傳！&lt;/p&gt;

&lt;hr /&gt;

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

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

&lt;p&gt;完整開發 repo : &lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos&quot;&gt;https://github.com/kkdai/gemini-live-translate-macos&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;透過這次的架構升級與 Bug 修正，&lt;strong&gt;MeetingTranslator&lt;/strong&gt; 展現了極佳的實用價值：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;零外部設備依賴&lt;/strong&gt;：完全不需設定 BlackHole 或 Loopback 等複雜路由，開箱即用。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;精準且即時的字幕&lt;/strong&gt;：Gemini Live API 在幾百毫秒內即可完成英文到繁體中文的翻譯，流暢地將結果顯示在 HUD 懸浮視窗上。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;語音翻譯同步播報&lt;/strong&gt;：透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioPlaybackManager&lt;/code&gt;，使用者可以邊聽原始會議，邊在耳機中聽到高品質的 24kHz 繁體中文口譯配音。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;希望這次在 macOS Core Audio / ScreenCaptureKit 與 Gemini WebSocket API 的踩坑紀錄，能為同樣在探索 AI 即時語音應用的開發者提供有價值的參考！&lt;/p&gt;
</description>
				<pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-live-translation/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-live-translation/</guid>
			</item>
		
	</channel>
</rss>
