<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>亂馬客</title>
  <icon>https://www.gravatar.com/avatar/cd3aed042ccd7a5a5d9956b0bc07dc81</icon>
  <subtitle>Stay Hungry, Stay Foolish.</subtitle>
  <link href="https://rainmakerho.github.io/atom.xml" rel="self"/>
  
  <link href="https://rainmakerho.github.io/"/>
  <updated>2026-05-29T03:19:22.193Z</updated>
  <id>https://rainmakerho.github.io/</id>
  
  <author>
    <name>亂馬客</name>
    <email>rainmaker_ho@gss.com.tw</email>
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>.NET 記憶體飆高問題</title>
    <link href="https://rainmakerho.github.io/2026/05/29/net-high-memory/"/>
    <id>https://rainmakerho.github.io/2026/05/29/net-high-memory/</id>
    <published>2026-05-29T02:24:57.000Z</published>
    <updated>2026-05-29T03:19:22.193Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>最近遇到朋友分享 2 個 .NET 記憶體飆高的問題。<br>以往最常聽到的是字串串接，例如 <code>s += &quot;abc&quot;;</code>，<br>而最近聽到的則是 <strong>ToArray()</strong></p><h4 id="案例一-高頻率解密-Connection-String-導致的-GC-壓力"><a href="#案例一-高頻率解密-Connection-String-導致的-GC-壓力" class="headerlink" title="案例一: 高頻率解密 Connection String 導致的 GC 壓力"></a>案例一: 高頻率解密 Connection String 導致的 GC 壓力</h4><p>在許多企業級應用中，為了資安考量，資料庫連接字串（Connection String）會經過 AES 加密後存放在 <code>appsettings.json</code> 中。每次程式需要建立連線時，都會呼叫類似底下的方法：</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="built_in">string</span> <span class="title">getConnStr</span>(<span class="params"><span class="built_in">string</span> connStrName = <span class="string">&quot;Default&quot;</span></span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// 每次呼叫都會執行 string.Format、Base64 解碼與 AES 解密</span></span><br><span class="line">    <span class="keyword">return</span> getDecryptStr(_configuration.GetValue&lt;<span class="built_in">string</span>&gt;(<span class="built_in">string</span>.Format(<span class="string">&quot;ConnectionStrings:&#123;0&#125;&quot;</span>, connStrName)));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>初看這段程式碼，你可能會覺得：「這只是讀取一個幾百個字元的字串，能耗費多少記憶體？」</p><p>但如果我們把 getDecryptStr 內部呼叫的核心解密邏輯 Decrypt(string encryptedText) 攤開來看，就可以發現它用許多的<code>ToArray()</code></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="built_in">string</span> <span class="title">Decrypt</span>(<span class="params"><span class="built_in">string</span> encryptedText</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// 1. 這裡配置了一次全新的 byte[]</span></span><br><span class="line">    <span class="keyword">var</span> fullData = Convert.FromBase64String(encryptedText);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 2. LINQ 的 Take 與 Skip 每次都會在託管堆積（Heap）上</span></span><br><span class="line">    <span class="comment">// 「重新配置」並「複製」出兩份全新的 byte[] 陣列！</span></span><br><span class="line">    <span class="built_in">byte</span>[] iv = fullData.Take(<span class="number">16</span>).ToArray();        <span class="comment">// 又配置一次 byte[]</span></span><br><span class="line">    <span class="built_in">byte</span>[] cipherBytes = fullData.Skip(<span class="number">16</span>).ToArray(); <span class="comment">// 又配置一次 byte[]</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">using</span> <span class="keyword">var</span> aes = Aes.Create();</span><br><span class="line">    aes.Key = rgbKey;</span><br><span class="line">    aes.IV = iv;</span><br><span class="line">    <span class="comment">// ... 後續解密流處理 ...</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 3. 這裡又配置了全新的字串物件</span></span><br><span class="line">    <span class="keyword">return</span> sr.ReadToEnd();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>系統在頻繁連線 DB 時，就會有以下問題：</p><ol><li>頻繁的 <code>ToArray()</code> 記憶體配置： 解密高頻率發生時，<code>fullData.Take(16).ToArray()</code> 和 <code>fullData.Skip(16).ToArray()</code> 會在極短時間內在記憶體中產生海量的暫時性 <code>byte[]</code> 垃圾。</li><li>字串不可變性（Immutable）： <code>string.Format</code> 與最終解密出來的連線字串，每次執行都會在 Heap 上配置新字串。</li><li>如果這個解密動作發生在每次資料庫請求（例如每次開新連線就解密一次），在高併發（High Concurrency）的情境下，系統會在幾毫秒內堆積出數萬個短命的陣列與字串。垃圾回收器（GC）來不及清理，你就會看到伺服器的 RAM 持續往上飆升。</li></ol><h5 id="加入-Cache"><a href="#加入-Cache" class="headerlink" title="加入 Cache"></a>加入 Cache</h5><p>由於連線字串在程式啟動後幾乎固定不變，我們根本不需要讓系統反覆去踩 <code>ToArray()</code> 和解密的效能地雷！</p><p>另外，若要避免高併發下「同一個 key 被重複解密」，建議直接用 <code>GetOrAdd</code> 搭配 <code>Lazy&lt;string&gt;</code>：</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">readonly</span> ConcurrentDictionary&lt;<span class="built_in">string</span>, Lazy&lt;<span class="built_in">string</span>&gt;&gt; _connStrCache =</span><br><span class="line">    <span class="keyword">new</span> ConcurrentDictionary&lt;<span class="built_in">string</span>, Lazy&lt;<span class="built_in">string</span>&gt;&gt;();</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="built_in">string</span> <span class="title">getConnStr</span>(<span class="params"><span class="built_in">string</span> connStrName = <span class="string">&quot;Default&quot;</span></span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">var</span> lazy = _connStrCache.GetOrAdd(connStrName, key =&gt;</span><br><span class="line">        <span class="keyword">new</span> Lazy&lt;<span class="built_in">string</span>&gt;(() =&gt;</span><br><span class="line">        &#123;</span><br><span class="line">            IConfigurationRoot configuration = CommonHelper.GetConfigurationRoot();</span><br><span class="line">            <span class="keyword">if</span> (configuration == <span class="literal">null</span>) <span class="keyword">return</span> <span class="built_in">string</span>.Empty;</span><br><span class="line"></span><br><span class="line">            <span class="built_in">string</span> encrypted = configuration.GetValue&lt;<span class="built_in">string</span>&gt;(<span class="string">$&quot;ConnectionStrings:<span class="subst">&#123;key&#125;</span>&quot;</span>);</span><br><span class="line">            <span class="keyword">return</span> getDecryptStr(encrypted);</span><br><span class="line">        &#125;, LazyThreadSafetyMode.ExecutionAndPublication));</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> lazy.Value;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>如果無法做 Cache，至少把 <code>Take/Skip/ToArray</code> 改為 <code>ReadOnlySpan&lt;byte&gt;</code> 切片，減少不必要的陣列配置：</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="built_in">string</span> <span class="title">Decrypt</span>(<span class="params"><span class="built_in">string</span> encryptedText</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="built_in">byte</span>[] fullData = Convert.FromBase64String(encryptedText);</span><br><span class="line">    ReadOnlySpan&lt;<span class="built_in">byte</span>&gt; data = fullData;</span><br><span class="line"></span><br><span class="line">    ReadOnlySpan&lt;<span class="built_in">byte</span>&gt; ivSpan = data.Slice(<span class="number">0</span>, <span class="number">16</span>);</span><br><span class="line">    ReadOnlySpan&lt;<span class="built_in">byte</span>&gt; cipherSpan = data.Slice(<span class="number">16</span>);</span><br><span class="line"></span><br><span class="line">    <span class="built_in">byte</span>[] iv = ivSpan.ToArray();          <span class="comment">// Aes.IV 需要 byte[]</span></span><br><span class="line">    <span class="built_in">byte</span>[] cipherBytes = cipherSpan.ToArray();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">using</span> <span class="keyword">var</span> aes = Aes.Create();</span><br><span class="line">    aes.Key = rgbKey;</span><br><span class="line">    aes.IV = iv;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// ... 後續解密流處理 ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h4 id="案例二：大檔案下載時-byte-導致的大物件堆積（LOH）"><a href="#案例二：大檔案下載時-byte-導致的大物件堆積（LOH）" class="headerlink" title="案例二：大檔案下載時 byte[] 導致的大物件堆積（LOH）"></a>案例二：大檔案下載時 byte[] 導致的大物件堆積（LOH）</h4><p>在處理檔案或 Stream 轉換的共用工具類別時，也發現了極為相似的 <code>ToArray()</code> 問題，</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 舊的寫法：將 Stream 轉成 byte[]</span></span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="built_in">byte</span>[] <span class="title">CopyToByte</span>(<span class="params"><span class="keyword">this</span> Stream input</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">if</span> (input == <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line">    <span class="keyword">if</span> (input <span class="keyword">is</span> MemoryStream) <span class="keyword">return</span> (input <span class="keyword">as</span> MemoryStream).ToArray();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">using</span> (MemoryStream ms = <span class="keyword">new</span> MemoryStream())</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">if</span> (input.CanSeek) input.Position = <span class="number">0</span>;</span><br><span class="line">        input.CopyTo(ms);</span><br><span class="line">        <span class="keyword">return</span> ms.ToArray(); <span class="comment">// 內部複製一份全新 byte[] 回傳</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>它的問題為，</p><ol><li>呼叫 <code>.ToArray()</code> 時，.NET 會在記憶體中再複製一份全新的陣列回傳。若檔案 50MB，這瞬間就需要至少 100MB 空間。</li><li>大物件堆積（LOH, Large Object Heap）碎片化： 在 .NET 中，大於 85,000 位元組（約 83 KB） 的物件會進入 LOH。LOH 在預設情境下通常不會頻繁壓縮（可透過設定或特定時機調整），頻繁配置大 byte[] 仍可能導致碎片化，即便用完了，RAM 也常常降不下來。</li></ol><h5 id="Stream-改用-RecyclableMemoryStream"><a href="#Stream-改用-RecyclableMemoryStream" class="headerlink" title="Stream 改用 RecyclableMemoryStream"></a>Stream 改用 RecyclableMemoryStream</h5><p>在 <code>ToArray()</code> 的部份，如果來源是 <code>Stream</code> 就不再轉成 <code>byte[]</code>，改完後，再查看使用<br><code>MemoryStream</code>的部份，改用 <code>RecyclableMemoryStream</code></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">class</span> <span class="title">StreamPool</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// Manager 本身是執行緒安全的，全域宣告一個即可</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">readonly</span> RecyclableMemoryStreamManager Manager = <span class="keyword">new</span> RecyclableMemoryStreamManager();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>不再強行轉換成 <code>byte[]</code>，而是直接回傳由 Pool 管理的 Stream。</p><ul><li>注意：這裡不能使用 using，否則 Stream 會在離開方法時被提早關閉並歸還，導致外部讀取失敗！</li></ul><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">static</span> Stream <span class="title">GetRecyclableStream</span>(<span class="params">Stream inputStream</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">if</span> (inputStream == <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 1. 從池子裡借一個專屬的記憶體流 (不寫 using，改由外部釋放)</span></span><br><span class="line">    <span class="keyword">var</span> memoryStream = StreamPool.Manager.GetStream();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">if</span> (inputStream.CanSeek) inputStream.Position = <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 將資料複製到池化 MemoryStream 中</span></span><br><span class="line">        inputStream.CopyTo(memoryStream);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 移回開頭，這樣外部讀取才能從第 0 個位元組開始</span></span><br><span class="line">        memoryStream.Position = <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> memoryStream;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">catch</span></span><br><span class="line">    &#123;</span><br><span class="line">        memoryStream.Dispose(); <span class="comment">// 發生異常時才由內部確保釋放</span></span><br><span class="line">        <span class="keyword">throw</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>補充：<code>RecyclableMemoryStream</code> 的定位是「降低配置與 GC 壓力」，不是把超大檔案都先吃進 RAM 的萬用解法。若來源本來就是檔案或網路串流，優先考慮直接串流回傳（例如 <code>FileStream</code> &#x2F; 直接寫入 Response Body）。</p><p>API 直接將這個 <code>Stream</code> 餵給 <code>File()</code> 回傳</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">[<span class="meta">HttpGet(<span class="string">&quot;download&quot;</span>)</span>]</span><br><span class="line"><span class="function"><span class="keyword">public</span> IActionResult <span class="title">DownloadFile</span>()</span></span><br><span class="line">&#123;</span><br><span class="line">    Stream memoryInputStream = 原本的 MemoryStream ;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 轉換成高效能的池化 Stream</span></span><br><span class="line">    Stream fileStream = GetRecyclableStream(memoryInputStream);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// ASP.NET Core 框架會在檔案傳輸完成後，自動幫你呼叫 fileStream.Dispose()</span></span><br><span class="line">    <span class="comment">// 進而觸發機制，將記憶體完美還給 StreamPool</span></span><br><span class="line">    <span class="keyword">return</span> File(fileStream, <span class="string">&quot;application/pdf&quot;</span>, <span class="string">&quot;report.pdf&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="建議觀測指標（壓測前後可對比）"><a href="#建議觀測指標（壓測前後可對比）" class="headerlink" title="建議觀測指標（壓測前後可對比）"></a>建議觀測指標（壓測前後可對比）</h3><ol><li>配置率（Allocation Rate &#x2F; MB&#x2F;s）</li><li>Gen 2 GC 次數與停頓時間</li><li>LOH 大小與成長趨勢</li></ol><p>所以現在除了<strong>字串串接</strong>外，也要注意有沒有不必要的 <strong>ToArray()</strong> </p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://learn.microsoft.com/zh-tw/dotnet/standard/garbage-collection/large-object-heap">Windows 系統上的大型物件堆積</a><br><a href="https://www.cnblogs.com/InCerry/p/Use-RecyclableMemoryStream-instead-of-MemoryStream.html">.NET性能优化-使用RecyclableMemoryStream替代MemoryStream</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;最近遇到朋友分享 2 個 .NET 記憶體飆高的問題。&lt;br&gt;以往最常聽到的是字串串接，例如 &lt;code&gt;s += &amp;quot;abc&amp;qu</summary>
      
    
    
    
    
    <category term="ToArray" scheme="https://rainmakerho.github.io/tags/ToArray/"/>
    
    <category term="Memory Optimization" scheme="https://rainmakerho.github.io/tags/Memory-Optimization/"/>
    
    <category term="RecyclableMemoryStream" scheme="https://rainmakerho.github.io/tags/RecyclableMemoryStream/"/>
    
    <category term="Connection String Cache" scheme="https://rainmakerho.github.io/tags/Connection-String-Cache/"/>
    
    <category term="Garbage Collection Pressure" scheme="https://rainmakerho.github.io/tags/Garbage-Collection-Pressure/"/>
    
    <category term=".NET Stream Pooling" scheme="https://rainmakerho.github.io/tags/NET-Stream-Pooling/"/>
    
    <category term="LOH" scheme="https://rainmakerho.github.io/tags/LOH/"/>
    
  </entry>
  
  <entry>
    <title>如何設定 GitHub Copilot 使用 Microsoft Foundry 的自訂模型</title>
    <link href="https://rainmakerho.github.io/2026/05/14/copilot-models/"/>
    <id>https://rainmakerho.github.io/2026/05/14/copilot-models/</id>
    <published>2026-05-14T05:55:54.000Z</published>
    <updated>2026-05-14T08:57:36.774Z</updated>
    
    <content type="html"><![CDATA[<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>GitHub Copilot 除了使用內建模型外，也可以使用我們提供的自訂模型。<br>以下說明如何設定 Microsoft Foundry 的 模型</p><h3 id="新增-Microsoft-Foundry-的-模型"><a href="#新增-Microsoft-Foundry-的-模型" class="headerlink" title="新增 Microsoft Foundry 的 模型"></a>新增 Microsoft Foundry 的 模型</h3><p>在 Azure Portal 中，新增 Microsoft Foundry (如果已有就不用新增)<br>到 Microsoft Foundry(<code>https://ai.azure.com/</code>)新增要提供的 Model，<br>部署完成後，到<code>聊天遊樂場</code>測試沒問題後，點擊<strong>檢視程式碼</strong>，<br>會有<strong>endpoint</strong>及<strong>API 金鑰</strong></p><h3 id="設定-Github-Copilot-Custom-Models"><a href="#設定-Github-Copilot-Custom-Models" class="headerlink" title="設定  Github Copilot Custom Models"></a>設定  Github Copilot Custom Models</h3><p>到 Github Copilot 的 Models 功能，<br>進入 <strong>Copilot models</strong> 功能後，點選<strong>Custom models</strong>(Preview) Tab，</p><h5 id="新增-API-key"><a href="#新增-API-key" class="headerlink" title="新增 API key"></a>新增 API key</h5><p>點選<strong>Add API key</strong>，Provider 選擇<code>OpenAI Compatible</code>，<br>輸入<code>名稱</code>, <code>API Key</code>及<code>Base API URL</code>(<strong>檢視程式碼</strong>中的<strong>endpoint</strong>)</p><ul><li>註: <strong>Base API URL</strong> 要填 Foundry endpoint，且保留<code>/openai/v1</code></li></ul><p>然後就在<code>Available models</code>的 Search 輸入框中輸入前面新增模型的deployment_name，再按下**+** Button，如下圖，</p><img src="/2026/05/14/copilot-models/01.png" class="" title="API keys"><ul><li><code>deployment_name</code> 是你在 Microsoft Foundry 部署模型時設定的名稱</li><li>一定自已輸入<code>deployment_name</code>，再按下<code>+</code>新增模型</li></ul><h5 id="設定-模型"><a href="#設定-模型" class="headerlink" title="設定 模型"></a>設定 模型</h5><p>選好要用的 模型 後，點選<strong>Added models</strong>設定剛才加入的模型，如下圖，</p><img src="/2026/05/14/copilot-models/02.png" class="" title="Configure"> <ul><li>也可以設定要不要啟用它</li></ul><h3 id="VS-Code-中使用"><a href="#VS-Code-中使用" class="headerlink" title="VS Code 中使用"></a>VS Code 中使用</h3><p>等待約 2 分鐘後，在 Copilot 中，點擊 Manage Language Model 功能，如下圖，</p><img src="/2026/05/14/copilot-models/03.png" class="" title="Manage Language Model"> <p>就可以在 Language Models 看到我們新增的 Models，如下圖，</p><img src="/2026/05/14/copilot-models/04.png" class="" title="Custom Model"> <p>之後就可以在 Copilot 切換到該 模型 來使用它了哦~</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/authenticate-copilot-sdk/bring-your-own-key">Bring your own key (BYOK)</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h3&gt;&lt;p&gt;GitHub Copilot 除了使用內建模型外，也可以使用我們提供的自訂模型。&lt;br&gt;以下說明如何設定 Microsoft Foundry</summary>
      
    
    
    
    
    <category term="GitHub Copilot" scheme="https://rainmakerho.github.io/tags/GitHub-Copilot/"/>
    
    <category term="Microsoft Foundry" scheme="https://rainmakerho.github.io/tags/Microsoft-Foundry/"/>
    
    <category term="Azure AI Foundry" scheme="https://rainmakerho.github.io/tags/Azure-AI-Foundry/"/>
    
    <category term="Custom Models" scheme="https://rainmakerho.github.io/tags/Custom-Models/"/>
    
    <category term="BYOK" scheme="https://rainmakerho.github.io/tags/BYOK/"/>
    
  </entry>
  
  <entry>
    <title>別讓技術瓶頸，成為公司的發展上限</title>
    <link href="https://rainmakerho.github.io/2026/05/05/professional-dev-mindset-overcoming-technical-bottlenecks-in-enterprise-rpa/"/>
    <id>https://rainmakerho.github.io/2026/05/05/professional-dev-mindset-overcoming-technical-bottlenecks-in-enterprise-rpa/</id>
    <published>2026-05-05T03:57:38.000Z</published>
    <updated>2026-05-05T08:45:59.665Z</updated>
    
    <content type="html"><![CDATA[<h3 id="消失的n年與「永不結案」的自動化"><a href="#消失的n年與「永不結案」的自動化" class="headerlink" title="消失的n年與「永不結案」的自動化"></a>消失的n年與「永不結案」的自動化</h3><p>在大型企業的開發職涯中，我們偶爾會遇到一種令人費解的現象：一個原本應提升效率、簡化流程的 RPA（流程自動化）專案，執行了n年卻依然無法穩定「結案」。</p><p>每當腳本失效，開發人員總是以「網銀改版」、「網路不穩」或「環境變動」作為理由。但身為技術人員，我們必須誠實地自問：<strong>這究竟是外部環境的限制，還是我們選用的工具與思維，早已跟不上現代網頁架構的演進？</strong></p><h3 id="座標模擬的窮途末路：AutoIt-的侷限性"><a href="#座標模擬的窮途末路：AutoIt-的侷限性" class="headerlink" title="座標模擬的窮途末路：AutoIt 的侷限性"></a>座標模擬的窮途末路：AutoIt 的侷限性</h3><p>在過去的桌面軟體時代，AutoIt 憑藉簡單腳本邏輯與 UI 模擬能力，確實解決了不少問題。然而面對現代複雜網頁，傳統 UI 模擬（甚至包含 UIA 模式）已顯得力不從心：</p><ol><li>「盲人摸象」的操作邏輯：AutoIt 的 UIA 本質上是透過作業系統層級的輔助介面去「窺探」瀏覽器。它並不真正理解網頁 DOM 結構，面對動態渲染（React&#x2F;Vue）時，常發生定位偏移或找不到元素。</li><li>無法感知的網路狀態：這是最致命的缺點。AutoIt 難以判斷瀏覽器底層請求是否真的完成，開發者被迫塞入大量 <code>Sleep</code>，靠「猜測」與「盲等」維持運作。這不僅效率低，更是穩定性問題的核心來源。</li><li>數據傳輸的風險：依賴系統剪貼簿（Ctrl+C &#x2F; Ctrl+V）進行數據交換，極易受到系統彈窗、輸入法切換或人為操作干擾。</li></ol><h3 id="職場的慣性陷阱：當「習慣錯誤」成為阻力"><a href="#職場的慣性陷阱：當「習慣錯誤」成為阻力" class="headerlink" title="職場的慣性陷阱：當「習慣錯誤」成為阻力"></a>職場的慣性陷阱：當「習慣錯誤」成為阻力</h3><p>比技術債更可怕的，是「心態債」。在推動轉型時，最常遇到的阻力往往來自於：</p><ol><li>承辦人員的舒適圈：長期合作的承辦人員往往習慣了與原維護人員的對話模式：「出錯、報修、微調」。雖然效率低下，但因為熟悉而感到安全。面對更先進、穩定的新技術，反而可能因擔心失去流程掌控感而排斥。</li><li>技術壟斷下的資訊不透明：當開發者不與同仁交流，並將工具缺陷包裝成環境限制時，主管與承辦人就會被誤導，認為現狀已是最好。這種閉門造車，本質上是把個人技術瓶頸，強加成公司的發展上限。</li></ol><h3 id="現代化轉型：Playwright-C-的實戰優勢"><a href="#現代化轉型：Playwright-C-的實戰優勢" class="headerlink" title="現代化轉型：Playwright + C# 的實戰優勢"></a>現代化轉型：Playwright + C# 的實戰優勢</h3><p>為了終結惡性循環，我們需要的是具備「可觀測性」與「可維護性」的現代化架構：</p><ol><li>原生驅動（Playwright）：直接與瀏覽器引擎溝通，並可搭配等待條件與斷言（load state、locator assertions）降低流程不確定性。相較傳統 UI 模擬，在動態網頁場景通常有更高穩定性。</li><li>精準定位與自動等待：透過語意化 locator 與內建 auto-wait，可大幅減少使用 <code>Sleep</code>。即使網頁小幅改版，通常也能以較小成本完成調整。</li><li>無感數據整合（C#）：透過 C# 直接處理資料流（檔案、記憶體物件、API），降低對剪貼簿與前景視窗的依賴，明顯減少非預期干擾。</li></ol><h3 id="成效要能被驗證：用-KPI-讓轉型不是口號"><a href="#成效要能被驗證：用-KPI-讓轉型不是口號" class="headerlink" title="成效要能被驗證：用 KPI 讓轉型不是口號"></a>成效要能被驗證：用 KPI 讓轉型不是口號</h3><p>若要讓主管與承辦人信服，除了技術語言，更需要可被追蹤的成果。建議至少建立以下三個指標，按月回顧：</p><ol><li>自動化失敗率：定義每月排程總次數中，失敗任務所占比例。目標不是零失敗，而是持續下降且可解釋。</li><li>平均修復時間（MTTR）：從告警到恢復正常所需時間。框架標準化後，MTTR 應顯著縮短。</li><li>人工介入次數：統計每月需要人工接手的次數。若轉型有效，介入頻率應下降，且集中在真正例外情境。</li></ol><h3 id="警惕「技術斷層」與營運危機"><a href="#警惕「技術斷層」與營運危機" class="headerlink" title="警惕「技術斷層」與營運危機"></a>警惕「技術斷層」與營運危機</h3><p>大型企業面臨最嚴重的危機，莫過於「人退程式亡」。當關鍵自動化流程依賴單一人員，且工具過時又無人願意接手時，企業營運連續性將面臨巨大考驗。</p><p>引入現代化、標準化開發框架（如 C# 與 Playwright），目的不只是讓專案結案，更是建立一套<strong>透明、可維護且具備知識傳承價值</strong>的企業數位資產。</p><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>一個專案做了n年還沒結案，本身就是對效率的警示。身為開發者，我們不應滿足於「程式會動」，而應追求「程式優雅且穩定」。</p><p>打破技術孤島，主動引進更高效的工具，是我們對專業的負責，也是對企業價值的守護。真正成熟的技術人，不只會把功能做出來，更能把流程做穩、把知識交接出去、把風險透明化。</p><p><strong>當團隊從「靠人撐系統」走向「靠系統撐營運」，這才是企業數位轉型真正的完成態。</strong></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;消失的n年與「永不結案」的自動化&quot;&gt;&lt;a href=&quot;#消失的n年與「永不結案」的自動化&quot; class=&quot;headerlink&quot; title=&quot;消失的n年與「永不結案」的自動化&quot;&gt;&lt;/a&gt;消失的n年與「永不結案」的自動化&lt;/h3&gt;&lt;p&gt;在大型企業的開發職涯中，我們</summary>
      
    
    
    
    
    <category term="C#" scheme="https://rainmakerho.github.io/tags/C/"/>
    
    <category term="Playwright" scheme="https://rainmakerho.github.io/tags/Playwright/"/>
    
    <category term="專業開發者" scheme="https://rainmakerho.github.io/tags/%E5%B0%88%E6%A5%AD%E9%96%8B%E7%99%BC%E8%80%85/"/>
    
    <category term="技術債" scheme="https://rainmakerho.github.io/tags/%E6%8A%80%E8%A1%93%E5%82%B5/"/>
    
    <category term="RPA 轉型" scheme="https://rainmakerho.github.io/tags/RPA-%E8%BD%89%E5%9E%8B/"/>
    
    <category term="自動化效率" scheme="https://rainmakerho.github.io/tags/%E8%87%AA%E5%8B%95%E5%8C%96%E6%95%88%E7%8E%87/"/>
    
    <category term="AutoIt" scheme="https://rainmakerho.github.io/tags/AutoIt/"/>
    
    <category term="企業數位轉型" scheme="https://rainmakerho.github.io/tags/%E4%BC%81%E6%A5%AD%E6%95%B8%E4%BD%8D%E8%BD%89%E5%9E%8B/"/>
    
  </entry>
  
  <entry>
    <title>.NET 10 全新功能：在 ASP.NET Core Razor Pages 中實作 Passkey 無密碼登入</title>
    <link href="https://rainmakerho.github.io/2026/04/29/getting-started-with-passkeys-dotnet10/"/>
    <id>https://rainmakerho.github.io/2026/04/29/getting-started-with-passkeys-dotnet10/</id>
    <published>2026-04-29T08:54:40.000Z</published>
    <updated>2026-04-29T09:42:56.445Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>密碼已是過去式。忘記密碼、弱密碼、密碼被釣魚攻擊——這些問題困擾開發者與使用者多年。在 .NET 10 中，ASP.NET Core Identity 正式內建支援 <strong>Passkey（密碼金鑰）</strong>，讓你不需依賴第三方函式庫，就能為應用程式加上現代化、抗釣魚攻擊的無密碼認證機制。</p><p>本文將以 <strong>Razor Pages</strong> 為範例，帶你從零開始實作完整的 Passkey 註冊與登入流程。</p><hr><h2 id="什麼是-Passkey？"><a href="#什麼是-Passkey？" class="headerlink" title="什麼是 Passkey？"></a>什麼是 Passkey？</h2><p>Passkey 是基於 <a href="https://developer.mozilla.org/docs/Web/API/Web_Authentication_API">Web Authentication API（WebAuthn）</a> 與 FIDO2 標準所設計的密碼替代方案。它使用 <strong>非對稱金鑰加密</strong>：</p><ul><li><strong>私鑰（Private Key）</strong>：安全儲存在使用者裝置上（例如 Windows Hello、Touch ID、Face ID，或密碼管理器）。</li><li><strong>公鑰（Public Key）</strong>：儲存在伺服器端。</li></ul><p>認證時，使用者用裝置上的生物辨識或 PIN 驗證身分，私鑰永遠不會離開裝置，公鑰也沒有密碼外洩的風險。</p><h3 id="主要優點"><a href="#主要優點" class="headerlink" title="主要優點"></a>主要優點</h3><table><thead><tr><th>特性</th><th>說明</th></tr></thead><tbody><tr><td>抗釣魚</td><td>Passkey 與特定網站綁定，無法在假網站使用</td></tr><tr><td>無共享秘密</td><td>伺服器只存公鑰，資料庫洩露不影響用戶安全</td></tr><tr><td>使用方便</td><td>指紋、臉部辨識或 PIN 取代複雜密碼</td></tr><tr><td>跨裝置同步</td><td>透過 iCloud Keychain、Google Password Manager 等跨裝置同步</td></tr></tbody></table><hr><h2 id="NET-10-的-Identity-Passkey-支援"><a href="#NET-10-的-Identity-Passkey-支援" class="headerlink" title=".NET 10 的 Identity Passkey 支援"></a>.NET 10 的 Identity Passkey 支援</h2><p>.NET 10 將 Passkey 支援直接整合進 ASP.NET Core Identity，提供以下能力：</p><ul><li>為既有帳號新增 Passkey 作為額外認證方式</li><li>無密碼帳號建立（直接以 Passkey 註冊）</li><li>純 Passkey 登入（完全不需要密碼）</li></ul><blockquote><p><strong>重要限制：</strong> 此實作專注於 Identity 認證情境，不是完整 WebAuthn 函式庫。如需完整協定支援，可考慮社群套件如 <a href="https://github.com/passwordless-lib/fido2-net-lib">fido2-net-lib</a>。</p></blockquote><hr><h2 id="核心概念"><a href="#核心概念" class="headerlink" title="核心概念"></a>核心概念</h2><h3 id="Attestation（證明-x2F-註冊）"><a href="#Attestation（證明-x2F-註冊）" class="headerlink" title="Attestation（證明 &#x2F; 註冊）"></a>Attestation（證明 &#x2F; 註冊）</h3><p>使用者建立並註冊新 Passkey 的過程：</p><ol><li>伺服器產生唯一挑戰（Challenge）</li><li>認證器建立金鑰對，回傳公鑰與 attestation 資料</li><li>伺服器驗證並儲存公鑰，供日後認證使用</li></ol><h3 id="Assertion（斷言-x2F-登入）"><a href="#Assertion（斷言-x2F-登入）" class="headerlink" title="Assertion（斷言 &#x2F; 登入）"></a>Assertion（斷言 &#x2F; 登入）</h3><p>使用已存在的 Passkey 進行認證的過程：</p><ol><li>伺服器產生新的挑戰</li><li>認證器用私鑰對挑戰簽名並回傳</li><li>伺服器用已儲存的公鑰驗證簽名，簽名正確即完成認證</li></ol><hr><h2 id="環境需求"><a href="#環境需求" class="headerlink" title="環境需求"></a>環境需求</h2><ul><li>.NET 10 SDK 或更新版本</li><li>支援 WebAuthn 的現代瀏覽器（Chrome、Edge、Safari）</li><li>具備平台認證器的裝置（Windows Hello、Apple Secure Enclave、實體安全金鑰等）</li></ul><hr><h2 id="安全性考量"><a href="#安全性考量" class="headerlink" title="安全性考量"></a>安全性考量</h2><p>在實作前，請務必了解以下安全要求：</p><h3 id="明確設定-ServerDomain"><a href="#明確設定-ServerDomain" class="headerlink" title="明確設定 ServerDomain"></a>明確設定 ServerDomain</h3><p>若未設定 <code>ServerDomain</code>，框架會從 Host Header 推斷，可能導致 credential-scoping 攻擊。建議在正式環境明確設定：</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">builder.Services.Configure&lt;IdentityPasskeyOptions&gt;(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    options.ServerDomain = <span class="string">&quot;yourdomain.com&quot;</span>; </span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><h3 id="子網域安全"><a href="#子網域安全" class="headerlink" title="子網域安全"></a>子網域安全</h3><p>在設定的 <code>ServerDomain</code> 範圍內，不可提供未受信任的內容。例如：在 <code>contoso.com</code> 註冊的 Passkey 也適用於 <code>*.contoso.com</code>，需確保所有子網域都受到控制。</p><hr><h2 id="實作步驟"><a href="#實作步驟" class="headerlink" title="實作步驟"></a>實作步驟</h2><p>以下我們使用**ASP.NET Core Web應用程式(Razor Pages)**專案來練習，</p><h3 id="1-建立-Razor-Pages-專案"><a href="#1-建立-Razor-Pages-專案" class="headerlink" title="1. 建立 Razor Pages 專案"></a>1. 建立 Razor Pages 專案</h3><p>建立 ASP.NET Core Web應用程式(Razor Pages)，架構選擇 **.NET 10.0(長期支援)**，驗證類型選擇 <strong>個別帳戶</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">dotnet new webapp --auth Individual -f net10.0 -o PasskeyDemo</span><br><span class="line"><span class="built_in">cd</span> PasskeyDemo</span><br></pre></td></tr></table></figure><h3 id="2-設定-Identity-與資料庫"><a href="#2-設定-Identity-與資料庫" class="headerlink" title="2. 設定 Identity 與資料庫"></a>2. 設定 Identity 與資料庫</h3><ul><li>預設會使用 SQLite, 資料庫名稱為 <code>app.db</code></li></ul><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Program.cs</span></span><br><span class="line">builder.Services.AddDefaultIdentity&lt;IdentityUser&gt;(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// 測試用，所以先關掉驗證 Email 的設定</span></span><br><span class="line">    options.SignIn.RequireConfirmedAccount = <span class="literal">false</span>;</span><br><span class="line">    <span class="comment">// 重要：必須指定 Version3 才能使用 Passkey 功能</span></span><br><span class="line">    options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;</span><br><span class="line">&#125;)</span><br><span class="line">.AddEntityFrameworkStores&lt;ApplicationDbContext&gt;()</span><br><span class="line">.AddSignInManager()</span><br><span class="line">.AddDefaultTokenProviders();</span><br></pre></td></tr></table></figure><h3 id="3-設定-Passkey-選項"><a href="#3-設定-Passkey-選項" class="headerlink" title="3. 設定 Passkey 選項"></a>3. 設定 Passkey 選項</h3><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">builder.Services.Configure&lt;IdentityPasskeyOptions&gt;(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    options.ServerDomain = <span class="string">&quot;localhost&quot;</span>;            <span class="comment">// 伺服器網域</span></span><br><span class="line">    options.AuthenticatorTimeout = TimeSpan.FromMinutes(<span class="number">3</span>); <span class="comment">// 等待逾時</span></span><br><span class="line">    options.UserVerificationRequirement = <span class="string">&quot;required&quot;</span>;       <span class="comment">// 強制使用者驗證（生物辨識/PIN）</span></span><br><span class="line">    options.ResidentKeyRequirement = <span class="string">&quot;preferred&quot;</span>;</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><h3 id="4-執行-Migration"><a href="#4-執行-Migration" class="headerlink" title="4. 執行 Migration"></a>4. 執行 Migration</h3><p>因為 <code>IdentitySchemaVersions.Version3</code> 新增了 <code>AspNetUserPasskeys</code> 資料表，需要執行 migration：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">dotnet ef migrations add SyncIdentitySchemaForNet10</span><br><span class="line">dotnet ef database update</span><br><span class="line">dotnet build</span><br></pre></td></tr></table></figure><p>執行後資料庫會新增 <code>AspNetUserPasskeys</code> 資料表，用於儲存每把 Passkey 的公鑰等資料。如下圖，</p><img src="/2026/04/29/getting-started-with-passkeys-dotnet10/01.png" class="" title="app.db"><hr><h2 id="後端實作"><a href="#後端實作" class="headerlink" title="後端實作"></a>後端實作</h2><h3 id="4-1-PageModel：取得登入者的-Passkey-清單（OnGetAsync）"><a href="#4-1-PageModel：取得登入者的-Passkey-清單（OnGetAsync）" class="headerlink" title="4.1 PageModel：取得登入者的 Passkey 清單（OnGetAsync）"></a>4.1 PageModel：取得登入者的 Passkey 清單（OnGetAsync）</h3><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.AspNetCore.Mvc;</span><br><span class="line"><span class="keyword">using</span> Microsoft.AspNetCore.Mvc.RazorPages;</span><br><span class="line"><span class="keyword">using</span> Microsoft.AspNetCore.Identity;</span><br><span class="line"><span class="keyword">using</span> Microsoft.AspNetCore.WebUtilities;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">IndexModel</span> : <span class="title">PageModel</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">readonly</span> UserManager&lt;IdentityUser&gt; _userManager;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">readonly</span> SignInManager&lt;IdentityUser&gt; _signInManager;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">readonly</span> ILogger&lt;IndexModel&gt; _logger;</span><br><span class="line">    <span class="keyword">public</span> IList&lt;UserPasskeyInfo&gt; Passkeys &#123; <span class="keyword">get</span>; <span class="keyword">private</span> <span class="keyword">set</span>; &#125; = [];</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="title">IndexModel</span>(<span class="params">UserManager&lt;IdentityUser&gt; userManager</span></span></span><br><span class="line"><span class="params"><span class="function">    , SignInManager&lt;IdentityUser&gt; signInManager</span></span></span><br><span class="line"><span class="params"><span class="function">    , ILogger&lt;IndexModel&gt; logger</span>)</span></span><br><span class="line">    &#123;</span><br><span class="line">        _userManager = userManager;</span><br><span class="line">        _signInManager = signInManager;</span><br><span class="line">        _logger = logger;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task <span class="title">OnGetAsync</span>()</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">if</span> (User.Identity?.IsAuthenticated != <span class="literal">true</span>) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">var</span> user = <span class="keyword">await</span> _userManager.GetUserAsync(User);</span><br><span class="line">        <span class="keyword">if</span> (user <span class="keyword">is</span> <span class="literal">null</span>) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 查詢該使用者所有已註冊的 Passkey</span></span><br><span class="line">        Passkeys = <span class="keyword">await</span> _userManager.GetPasskeysAsync(user);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4-2-Passkey-註冊流程"><a href="#4-2-Passkey-註冊流程" class="headerlink" title="4.2 Passkey 註冊流程"></a>4.2 Passkey 註冊流程</h3><p><strong>Step 1：產生 Creation Options</strong></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task&lt;IActionResult&gt; <span class="title">OnPostPasskeyCreationOptions</span>()</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">if</span> (User.Identity?.IsAuthenticated != <span class="literal">true</span>) <span class="keyword">return</span> Unauthorized();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> user = <span class="keyword">await</span> _userManager.GetUserAsync(User);</span><br><span class="line">    <span class="keyword">if</span> (user <span class="keyword">is</span> <span class="literal">null</span>) <span class="keyword">return</span> NotFound();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> userId = <span class="keyword">await</span> _userManager.GetUserIdAsync(user);</span><br><span class="line">    <span class="keyword">var</span> userName = <span class="keyword">await</span> _userManager.GetUserNameAsync(user);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 向 SignInManager 取得 WebAuthn 註冊選項 JSON</span></span><br><span class="line">    <span class="comment">// 此方法會查詢 DB 取得既有 Passkey，並填入 excludeCredentials 避免重複</span></span><br><span class="line">    <span class="keyword">var</span> optionsJson = <span class="keyword">await</span> _signInManager.MakePasskeyCreationOptionsAsync(<span class="keyword">new</span> PasskeyUserEntity</span><br><span class="line">    &#123;</span><br><span class="line">        Id = userId,</span><br><span class="line">        Name = userName,</span><br><span class="line">        DisplayName = userName</span><br><span class="line">    &#125;);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> Content(optionsJson, <span class="string">&quot;application/json&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>Step 2：驗證並儲存 Passkey</strong></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task&lt;IActionResult&gt; <span class="title">OnPostPasskeyRegistration</span>(<span class="params">[FromBody] <span class="built_in">string</span> credentialJson</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">if</span> (User.Identity?.IsAuthenticated != <span class="literal">true</span>) <span class="keyword">return</span> Unauthorized();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> user = <span class="keyword">await</span> _userManager.GetUserAsync(User);</span><br><span class="line">    <span class="keyword">if</span> (user <span class="keyword">is</span> <span class="literal">null</span>) <span class="keyword">return</span> NotFound();</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 驗證 attestation（確認 challenge、origin、rpId 等都正確）</span></span><br><span class="line">    <span class="keyword">var</span> attestationResult = <span class="keyword">await</span> _signInManager.PerformPasskeyAttestationAsync(credentialJson);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (!attestationResult.Succeeded)</span><br><span class="line">        <span class="keyword">return</span> BadRequest(<span class="string">$&quot;Attestation failed: <span class="subst">&#123;attestationResult.Failure?.Message&#125;</span>&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 將 Passkey 公鑰等資料寫入 AspNetUserPasskeys 資料表</span></span><br><span class="line">    <span class="keyword">var</span> addResult = <span class="keyword">await</span> _userManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (!addResult.Succeeded)</span><br><span class="line">        <span class="keyword">return</span> BadRequest(<span class="string">&quot;Failed to store passkey&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> JsonResult(<span class="keyword">new</span> &#123; message = <span class="string">&quot;Passkey registered successfully&quot;</span> &#125;);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4-3-Passkey-登入流程"><a href="#4-3-Passkey-登入流程" class="headerlink" title="4.3 Passkey 登入流程"></a>4.3 Passkey 登入流程</h3><p><strong>Step 1：產生 Request Options</strong></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task&lt;IActionResult&gt; <span class="title">OnPostPasskeyRequestOptions</span>()</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// 傳 null 表示不限定使用者（讓裝置自行選擇 discoverable credential）</span></span><br><span class="line">    <span class="keyword">var</span> optionsJson = <span class="keyword">await</span> _signInManager.MakePasskeyRequestOptionsAsync(<span class="literal">null</span>);</span><br><span class="line">    <span class="keyword">return</span> Content(optionsJson, <span class="string">&quot;application/json&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><p><code>MakePasskeyRequestOptionsAsync(user)</code> 若帶入特定使用者，response 的 <code>allowCredentials</code> 只會列出該使用者的 Passkey。傳 <code>null</code> 則適合「無帳號輸入」的登入情境。</p></blockquote><p><strong>Step 2：驗證 Assertion 並登入</strong></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task&lt;IActionResult&gt; <span class="title">OnPostPasskeySignIn</span>(<span class="params">[FromBody] <span class="built_in">string</span> credentialJson</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// PasskeySignInAsync 內部呼叫 PerformPasskeyAssertionAsync，驗證後自動建立 Session</span></span><br><span class="line">    <span class="keyword">var</span> result = <span class="keyword">await</span> _signInManager.PasskeySignInAsync(credentialJson);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (result.Succeeded)</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> OkObjectResult(<span class="keyword">new</span> &#123; message = <span class="string">&quot;Sign in successful&quot;</span> &#125;);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> Unauthorized();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4-4-刪除-Passkey"><a href="#4-4-刪除-Passkey" class="headerlink" title="4.4 刪除 Passkey"></a>4.4 刪除 Passkey</h3><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task&lt;IActionResult&gt; <span class="title">OnPostDeletePasskey</span>(<span class="params">[FromBody] <span class="built_in">string</span> credentialIdBase64Url</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">if</span> (User.Identity?.IsAuthenticated != <span class="literal">true</span>) <span class="keyword">return</span> Unauthorized();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> user = <span class="keyword">await</span> _userManager.GetUserAsync(User);</span><br><span class="line">    <span class="keyword">if</span> (user <span class="keyword">is</span> <span class="literal">null</span>) <span class="keyword">return</span> NotFound();</span><br><span class="line"></span><br><span class="line">    <span class="comment">// credentialId 以 Base64Url 格式傳遞，需先解碼</span></span><br><span class="line">    <span class="built_in">byte</span>[] credentialId = WebEncoders.Base64UrlDecode(credentialIdBase64Url);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> result = <span class="keyword">await</span> _userManager.RemovePasskeyAsync(user, credentialId);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (!result.Succeeded) <span class="keyword">return</span> BadRequest(<span class="string">&quot;Failed to delete passkey&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> JsonResult(<span class="keyword">new</span> &#123; message = <span class="string">&quot;Passkey deleted successfully&quot;</span> &#125;);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="UI-Index-cshtml-實作"><a href="#UI-Index-cshtml-實作" class="headerlink" title="UI(Index.cshtml) 實作"></a>UI(Index.cshtml) 實作</h2><p>在 Razor Pages 中，建議把 UI 分成「已登入」與「未登入」兩個區塊，讓流程清楚：</p><ul><li>已登入：可註冊 Passkey、查看已註冊清單、刪除指定 Passkey。</li><li>未登入：提供「使用 Passkey 登入」按鈕。</li></ul><p>同時放一個隱藏表單承載 Anti-Forgery Token，供 AJAX 呼叫後端 handler 時附帶。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br></pre></td><td class="code"><pre><span class="line">@page</span><br><span class="line">@model IndexModel</span><br><span class="line">@using Microsoft.AspNetCore.WebUtilities</span><br><span class="line"></span><br><span class="line">&lt;form id=&quot;passkeyAntiForgeryForm&quot; method=&quot;post&quot; style=&quot;display:none;&quot;&gt;</span><br><span class="line">  @Html.AntiForgeryToken()</span><br><span class="line">&lt;/form&gt;</span><br><span class="line"></span><br><span class="line">@if (User.Identity?.IsAuthenticated == true)</span><br><span class="line">&#123;</span><br><span class="line">  &lt;div class=&quot;text-center&quot;&gt;</span><br><span class="line">    &lt;h1 class=&quot;display-4&quot;&gt;歡迎，@User.Identity.Name！&lt;/h1&gt;</span><br><span class="line">    &lt;p&gt;您已成功登入。&lt;/p&gt;</span><br><span class="line">  &lt;/div&gt;</span><br><span class="line"></span><br><span class="line">  &lt;button class=&quot;btn btn-primary&quot; id=&quot;btnRegisterPasskey&quot;&gt;註冊 Passkey&lt;/button&gt;</span><br><span class="line"></span><br><span class="line">  &lt;hr /&gt;</span><br><span class="line">  &lt;h2&gt;已註冊的 Passkeys（@Model.Passkeys.Count）&lt;/h2&gt;</span><br><span class="line"></span><br><span class="line">  @if (Model.Passkeys.Count == 0)</span><br><span class="line">  &#123;</span><br><span class="line">    &lt;p&gt;目前尚未註冊任何 Passkey。&lt;/p&gt;</span><br><span class="line">  &#125;</span><br><span class="line">  else</span><br><span class="line">  &#123;</span><br><span class="line">    &lt;ul class=&quot;list-group mb-3&quot;&gt;</span><br><span class="line">      @foreach (var passkey in Model.Passkeys)</span><br><span class="line">      &#123;</span><br><span class="line">        var credentialId = WebEncoders.Base64UrlEncode(passkey.CredentialId);</span><br><span class="line">        var label = string.IsNullOrWhiteSpace(passkey.Name) ? &quot;未命名 Passkey&quot; : passkey.Name;</span><br><span class="line"></span><br><span class="line">        &lt;li class=&quot;list-group-item d-flex justify-content-between align-items-center&quot;&gt;</span><br><span class="line">          &lt;div&gt;</span><br><span class="line">            &lt;strong&gt;@label&lt;/strong&gt;&lt;br /&gt;</span><br><span class="line">            &lt;small&gt;建立時間：@passkey.CreatedAt.ToLocalTime()&lt;/small&gt;</span><br><span class="line">          &lt;/div&gt;</span><br><span class="line">          &lt;button class=&quot;btn btn-danger btn-sm btnDeletePasskey&quot;</span><br><span class="line">              data-credential-id=&quot;@credentialId&quot;&gt;</span><br><span class="line">            刪除</span><br><span class="line">          &lt;/button&gt;</span><br><span class="line">        &lt;/li&gt;</span><br><span class="line">      &#125;</span><br><span class="line">    &lt;/ul&gt;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line">else</span><br><span class="line">&#123;</span><br><span class="line">  &lt;div class=&quot;text-center&quot;&gt;</span><br><span class="line">    &lt;h1 class=&quot;display-4&quot;&gt;&lt;a href=&quot;/Identity/Account/Login&quot;&gt;使用帳號密碼登入&lt;/a&gt;&lt;/h1&gt;</span><br><span class="line">  &lt;/div&gt;</span><br><span class="line">  &lt;div class=&quot;text-center mt-3&quot;&gt;</span><br><span class="line">    &lt;button class=&quot;btn btn-success&quot; id=&quot;btnSignInPasskey&quot;&gt;使用 Passkey 登入&lt;/button&gt;</span><br><span class="line">  &lt;/div&gt;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="UI-設計重點"><a href="#UI-設計重點" class="headerlink" title="UI 設計重點"></a>UI 設計重點</h3><ol><li>顯示清單時，將 <code>CredentialId</code> 轉成 Base64Url 字串再放入 <code>data-credential-id</code>，方便前端傳回後端刪除。</li><li><code>Passkeys</code> 來源是 <code>OnGetAsync</code> 透過 <code>UserManager.GetPasskeysAsync(user)</code> 載入，畫面與資料一致。</li><li>每次註冊&#x2F;刪除&#x2F;登入成功後重新整理頁面，確保清單、登入狀態與 Cookie 同步。</li></ol><hr><h2 id="前端-JavaScript-實作"><a href="#前端-JavaScript-實作" class="headerlink" title="前端 JavaScript 實作"></a>前端 JavaScript 實作</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">@section Scripts &#123;</span><br><span class="line">    &lt;script&gt;</span><br><span class="line">    $(document).ready(function () &#123;</span><br><span class="line">        //JavaScript 實作</span><br><span class="line">    &#125;);</span><br><span class="line">    &lt;/script&gt;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="5-1-確認瀏覽器支援"><a href="#5-1-確認瀏覽器支援" class="headerlink" title="5.1 確認瀏覽器支援"></a>5.1 確認瀏覽器支援</h3><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> browserSupportsPasskeys =</span><br><span class="line">  <span class="keyword">typeof</span> navigator.<span class="property">credentials</span> !== <span class="string">&quot;undefined&quot;</span> &amp;&amp;</span><br><span class="line">  <span class="keyword">typeof</span> <span class="variable language_">window</span>.<span class="property">PublicKeyCredential</span> !== <span class="string">&quot;undefined&quot;</span> &amp;&amp;</span><br><span class="line">  <span class="keyword">typeof</span> <span class="variable language_">window</span>.<span class="property">PublicKeyCredential</span>.<span class="property">parseCreationOptionsFromJSON</span> ===</span><br><span class="line">    <span class="string">&quot;function&quot;</span> &amp;&amp;</span><br><span class="line">  <span class="keyword">typeof</span> <span class="variable language_">window</span>.<span class="property">PublicKeyCredential</span>.<span class="property">parseRequestOptionsFromJSON</span> === <span class="string">&quot;function&quot;</span>;</span><br></pre></td></tr></table></figure><h3 id="5-2-Passkey-註冊"><a href="#5-2-Passkey-註冊" class="headerlink" title="5.2 Passkey 註冊"></a>5.2 Passkey 註冊</h3><ul><li>按下 <code>註冊 Passkey</code> Button</li></ul><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br></pre></td><td class="code"><pre><span class="line">$(<span class="string">&quot;#btnRegisterPasskey&quot;</span>).<span class="title function_">click</span>(<span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (browserSupportsPasskeys) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Browser supports Passkeys&quot;</span>);</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">warn</span>(<span class="string">&quot;Browser does NOT support Passkeys&quot;</span>);</span><br><span class="line">    <span class="title function_">alert</span>(</span><br><span class="line">      <span class="string">&quot;您的瀏覽器不支援 Passkeys 功能，請使用最新版本的 Chrome、Edge 或 Safari 瀏覽器。&quot;</span>,</span><br><span class="line">    );</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> antiForgeryToken = $(</span><br><span class="line">    <span class="string">&#x27;#passkeyAntiForgeryForm input[name=&quot;__RequestVerificationToken&quot;]&#x27;</span>,</span><br><span class="line">  ).<span class="title function_">val</span>();</span><br><span class="line">  <span class="comment">// Step 1：向 Server 取得 User&#x27;s Creation Options</span></span><br><span class="line">  $.<span class="title function_">ajax</span>(&#123;</span><br><span class="line">    <span class="attr">url</span>: <span class="string">&#x27;@Url.Page(&quot;/Index&quot;, &quot;PasskeyCreationOptions&quot;)&#x27;</span>,</span><br><span class="line">    <span class="attr">method</span>: <span class="string">&quot;POST&quot;</span>,</span><br><span class="line">    <span class="attr">headers</span>: &#123;</span><br><span class="line">      <span class="title class_">RequestVerificationToken</span>: antiForgeryToken,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">success</span>: <span class="keyword">function</span> (<span class="params">optionsJson</span>) &#123;</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Received options from server:&quot;</span>, optionsJson);</span><br><span class="line"></span><br><span class="line">      <span class="comment">// Step 2：Parse Options 並呼叫 WebAuthn API</span></span><br><span class="line">      <span class="keyword">const</span> options =</span><br><span class="line">        <span class="title class_">PublicKeyCredential</span>.<span class="title function_">parseCreationOptionsFromJSON</span>(optionsJson);</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Parsed options object:&quot;</span>, options);</span><br><span class="line">      navigator.<span class="property">credentials</span></span><br><span class="line">        .<span class="title function_">create</span>(&#123; <span class="attr">publicKey</span>: options &#125;)</span><br><span class="line">        .<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">credential</span>) &#123;</span><br><span class="line">          <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Credential created:&quot;</span>, credential);</span><br><span class="line"></span><br><span class="line">          <span class="comment">// Step 3：序列化後送回 Server 驗證並儲存（驗題）</span></span><br><span class="line">          <span class="keyword">const</span> credentialJson = <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(credential);</span><br><span class="line">          <span class="keyword">const</span> antiForgeryToken = $(</span><br><span class="line">            <span class="string">&#x27;#passkeyAntiForgeryForm input[name=&quot;__RequestVerificationToken&quot;]&#x27;</span>,</span><br><span class="line">          ).<span class="title function_">val</span>();</span><br><span class="line"></span><br><span class="line">          $.<span class="title function_">ajax</span>(&#123;</span><br><span class="line">            <span class="attr">url</span>: <span class="string">&#x27;@Url.Page(&quot;/Index&quot;, &quot;PasskeyRegistration&quot;)&#x27;</span>,</span><br><span class="line">            <span class="attr">method</span>: <span class="string">&quot;POST&quot;</span>,</span><br><span class="line">            <span class="attr">headers</span>: &#123;</span><br><span class="line">              <span class="title class_">RequestVerificationToken</span>: antiForgeryToken,</span><br><span class="line">              <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span>,</span><br><span class="line">            &#125;,</span><br><span class="line">            <span class="attr">data</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(credentialJson),</span><br><span class="line">            <span class="attr">success</span>: <span class="keyword">function</span> (<span class="params">response</span>) &#123;</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Passkey registered successfully:&quot;</span>, response);</span><br><span class="line">              <span class="title function_">alert</span>(<span class="string">&quot;Passkey 註冊成功！&quot;</span>);</span><br><span class="line">              <span class="variable language_">window</span>.<span class="property">location</span>.<span class="title function_">reload</span>();</span><br><span class="line">            &#125;,</span><br><span class="line">            <span class="attr">error</span>: <span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Error registering passkey:&quot;</span>, err);</span><br><span class="line">              <span class="title function_">alert</span>(</span><br><span class="line">                <span class="string">&quot;Passkey 註冊失敗：&quot;</span> +</span><br><span class="line">                  (err.<span class="property">responseJSON</span>?.<span class="property">message</span> || err.<span class="property">responseText</span> || <span class="string">&quot;未知錯誤&quot;</span>),</span><br><span class="line">              );</span><br><span class="line">            &#125;,</span><br><span class="line">          &#125;);</span><br><span class="line">        &#125;)</span><br><span class="line">        .<span class="title function_">catch</span>(<span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">          <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Error creating credential:&quot;</span>, err);</span><br><span class="line">          <span class="title function_">alert</span>(<span class="string">&quot;建立 Passkey 失敗：&quot;</span> + err.<span class="property">message</span>);</span><br><span class="line">        &#125;);</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">error</span>: <span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Error fetching options:&quot;</span>, err);</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><h3 id="5-3-Passkey-登入"><a href="#5-3-Passkey-登入" class="headerlink" title="5.3 Passkey 登入"></a>5.3 Passkey 登入</h3><ul><li>按下 <code>使用 Passkey 登入</code> Button</li></ul><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br></pre></td><td class="code"><pre><span class="line">$(<span class="string">&quot;#btnSignInPasskey&quot;</span>).<span class="title function_">click</span>(<span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!browserSupportsPasskeys) &#123;</span><br><span class="line">    <span class="title function_">alert</span>(</span><br><span class="line">      <span class="string">&quot;您的瀏覽器不支援 Passkeys 功能，請使用最新版本的 Chrome、Edge 或 Safari 瀏覽器。&quot;</span>,</span><br><span class="line">    );</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> antiForgeryToken = $(</span><br><span class="line">    <span class="string">&#x27;#passkeyAntiForgeryForm input[name=&quot;__RequestVerificationToken&quot;]&#x27;</span>,</span><br><span class="line">  ).<span class="title function_">val</span>();</span><br><span class="line">  <span class="comment">// Step 1：向 Server 取得 Request Options</span></span><br><span class="line">  $.<span class="title function_">ajax</span>(&#123;</span><br><span class="line">    <span class="attr">url</span>: <span class="string">&#x27;@Url.Page(&quot;/Index&quot;, &quot;PasskeyRequestOptions&quot;)&#x27;</span>,</span><br><span class="line">    <span class="attr">method</span>: <span class="string">&quot;POST&quot;</span>,</span><br><span class="line">    <span class="attr">headers</span>: &#123; <span class="title class_">RequestVerificationToken</span>: antiForgeryToken &#125;,</span><br><span class="line">    <span class="attr">success</span>: <span class="keyword">function</span> (<span class="params">optionsJson</span>) &#123;</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Received request options:&quot;</span>, optionsJson);</span><br><span class="line">      <span class="comment">// Step 2：Parse Options 並呼叫 WebAuthn API 取得 Assertion</span></span><br><span class="line">      <span class="keyword">const</span> options =</span><br><span class="line">        <span class="title class_">PublicKeyCredential</span>.<span class="title function_">parseRequestOptionsFromJSON</span>(optionsJson);</span><br><span class="line">      navigator.<span class="property">credentials</span></span><br><span class="line">        .<span class="title function_">get</span>(&#123; <span class="attr">publicKey</span>: options &#125;)</span><br><span class="line">        .<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">credential</span>) &#123;</span><br><span class="line">          <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Assertion credential:&quot;</span>, credential);</span><br><span class="line">          <span class="comment">// Step 3：送回 Server 驗證，成功後 Server 就使用 Passkey 登入</span></span><br><span class="line">          <span class="keyword">const</span> credentialJson = <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(credential);</span><br><span class="line">          <span class="keyword">const</span> token = $(</span><br><span class="line">            <span class="string">&#x27;#passkeyAntiForgeryForm input[name=&quot;__RequestVerificationToken&quot;]&#x27;</span>,</span><br><span class="line">          ).<span class="title function_">val</span>();</span><br><span class="line">          $.<span class="title function_">ajax</span>(&#123;</span><br><span class="line">            <span class="attr">url</span>: <span class="string">&#x27;@Url.Page(&quot;/Index&quot;, &quot;PasskeySignIn&quot;)&#x27;</span>,</span><br><span class="line">            <span class="attr">method</span>: <span class="string">&quot;POST&quot;</span>,</span><br><span class="line">            <span class="attr">headers</span>: &#123;</span><br><span class="line">              <span class="title class_">RequestVerificationToken</span>: token,</span><br><span class="line">              <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span>,</span><br><span class="line">            &#125;,</span><br><span class="line">            <span class="attr">data</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(credentialJson),</span><br><span class="line">            <span class="attr">success</span>: <span class="keyword">function</span> (<span class="params">response</span>) &#123;</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&quot;Sign in successful:&quot;</span>, response);</span><br><span class="line">              <span class="variable language_">window</span>.<span class="property">location</span>.<span class="title function_">reload</span>();</span><br><span class="line">            &#125;,</span><br><span class="line">            <span class="attr">error</span>: <span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Sign in failed:&quot;</span>, err);</span><br><span class="line">              <span class="title function_">alert</span>(<span class="string">&quot;Passkey 登入失敗：&quot;</span> + (err.<span class="property">responseText</span> || <span class="string">&quot;未知錯誤&quot;</span>));</span><br><span class="line">            &#125;,</span><br><span class="line">          &#125;);</span><br><span class="line">        &#125;)</span><br><span class="line">        .<span class="title function_">catch</span>(<span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">          <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Error getting credential:&quot;</span>, err);</span><br><span class="line">          <span class="title function_">alert</span>(<span class="string">&quot;取得 Passkey 失敗：&quot;</span> + err.<span class="property">message</span>);</span><br><span class="line">        &#125;);</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">error</span>: <span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Error fetching request options:&quot;</span>, err);</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><h3 id="5-4-刪除-Passkey"><a href="#5-4-刪除-Passkey" class="headerlink" title="5.4 刪除 Passkey"></a>5.4 刪除 Passkey</h3><ul><li>按下 <code>刪除</code> Passkey 的 Button</li></ul><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line">$(<span class="variable language_">document</span>).<span class="title function_">on</span>(<span class="string">&quot;click&quot;</span>, <span class="string">&quot;.btnDeletePasskey&quot;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> credentialId = $(<span class="variable language_">this</span>).<span class="title function_">data</span>(<span class="string">&quot;credential-id&quot;</span>);</span><br><span class="line">  <span class="keyword">if</span> (!credentialId) &#123;</span><br><span class="line">    <span class="title function_">alert</span>(<span class="string">&quot;找不到要刪除的 Passkey 識別碼。&quot;</span>);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (!<span class="title function_">confirm</span>(<span class="string">&quot;確定要刪除此 Passkey 嗎？&quot;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> antiForgeryToken = $(</span><br><span class="line">    <span class="string">&#x27;#passkeyAntiForgeryForm input[name=&quot;__RequestVerificationToken&quot;]&#x27;</span>,</span><br><span class="line">  ).<span class="title function_">val</span>();</span><br><span class="line">  $.<span class="title function_">ajax</span>(&#123;</span><br><span class="line">    <span class="attr">url</span>: <span class="string">&#x27;@Url.Page(&quot;/Index&quot;, &quot;DeletePasskey&quot;)&#x27;</span>,</span><br><span class="line">    <span class="attr">method</span>: <span class="string">&quot;POST&quot;</span>,</span><br><span class="line">    <span class="attr">headers</span>: &#123;</span><br><span class="line">      <span class="title class_">RequestVerificationToken</span>: antiForgeryToken,</span><br><span class="line">      <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">data</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(credentialId),</span><br><span class="line">    <span class="attr">success</span>: <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">      <span class="title function_">alert</span>(<span class="string">&quot;Passkey 已刪除。&quot;</span>);</span><br><span class="line">      <span class="variable language_">window</span>.<span class="property">location</span>.<span class="title function_">reload</span>();</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">error</span>: <span class="keyword">function</span> (<span class="params">err</span>) &#123;</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&quot;Error deleting passkey:&quot;</span>, err);</span><br><span class="line">      <span class="title function_">alert</span>(<span class="string">&quot;刪除失敗：&quot;</span> + (err.<span class="property">responseText</span> || <span class="string">&quot;未知錯誤&quot;</span>));</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><hr><h2 id="完整程式碼"><a href="#完整程式碼" class="headerlink" title="完整程式碼"></a>完整程式碼</h2><p>本文只拆解關鍵片段，完整可執行版本我放在 Gist：</p><ul><li><a href="https://gist.github.com/rainmakerho/fa7955373f3470c944d6e0fe3b515621#file-index-cshtml">完整 Index.cshtml</a></li><li><a href="https://gist.github.com/rainmakerho/fa7955373f3470c944d6e0fe3b515621#file-index-cshtml-cs">完整 Index.cshtml.cs</a></li></ul><hr><h2 id="操作過程"><a href="#操作過程" class="headerlink" title="操作過程"></a>操作過程</h2><h3 id="6-1-登入後註冊-Passkey"><a href="#6-1-登入後註冊-Passkey" class="headerlink" title="6.1 登入後註冊 Passkey"></a>6.1 登入後註冊 Passkey</h3><img src="/2026/04/29/getting-started-with-passkeys-dotnet10/02.png" class="" title="登入後註冊 Passkey"><h3 id="6-2-註冊-Passkey-成功後，會在-Passkey-列出註冊的資料"><a href="#6-2-註冊-Passkey-成功後，會在-Passkey-列出註冊的資料" class="headerlink" title="6.2 註冊 Passkey 成功後，會在 Passkey 列出註冊的資料"></a>6.2 註冊 Passkey 成功後，會在 Passkey 列出註冊的資料</h3><img src="/2026/04/29/getting-started-with-passkeys-dotnet10/03.png" class="" title="Passkey 成功後"><h3 id="6-3-預設會存在-Browser-的密碼中"><a href="#6-3-預設會存在-Browser-的密碼中" class="headerlink" title="6.3 預設會存在 Browser 的密碼中"></a>6.3 預設會存在 Browser 的密碼中</h3><img src="/2026/04/29/getting-started-with-passkeys-dotnet10/04.png" class="" title="Browser 的密碼"><ul><li>重覆註冊 Passkey 會發出 <code>建立 Passkey 失敗：The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.</code> 的錯誤。</li></ul><h3 id="6-4-按下-「使用-Passkey-登入」-Button"><a href="#6-4-按下-「使用-Passkey-登入」-Button" class="headerlink" title="6.4 按下 「使用 Passkey 登入」 Button"></a>6.4 按下 「使用 Passkey 登入」 Button</h3><img src="/2026/04/29/getting-started-with-passkeys-dotnet10/05.png" class="" title="使用 Passkey 登入"><hr><h2 id="關鍵-API-整理"><a href="#關鍵-API-整理" class="headerlink" title="關鍵 API 整理"></a>關鍵 API 整理</h2><table><thead><tr><th>API</th><th>說明</th></tr></thead><tbody><tr><td><code>SignInManager.MakePasskeyCreationOptionsAsync(user)</code></td><td>產生 WebAuthn 註冊選項 JSON（出題），並查詢 DB 填入 <code>excludeCredentials</code></td></tr><tr><td><code>SignInManager.PerformPasskeyAttestationAsync(json)</code></td><td>驗證前端回傳的 attestation（驗題），回傳 <code>PasskeyAttestationResult</code></td></tr><tr><td><code>UserManager.AddOrUpdatePasskeyAsync(user, passkey)</code></td><td>將 Passkey 公鑰等資料寫入 <code>AspNetUserPasskeys</code> 資料表</td></tr><tr><td><code>SignInManager.MakePasskeyRequestOptionsAsync(user?)</code></td><td>產生 WebAuthn 登入選項 JSON（出題），<code>null</code> 表示不指定使用者</td></tr><tr><td><code>SignInManager.PasskeySignInAsync(json)</code></td><td>驗證 assertion 並自動 Sign In，回傳 <code>SignInResult</code></td></tr><tr><td><code>UserManager.GetPasskeysAsync(user)</code></td><td>查詢使用者所有已註冊的 Passkey 清單（<code>IList&lt;UserPasskeyInfo&gt;</code>）</td></tr><tr><td><code>UserManager.RemovePasskeyAsync(user, credentialId)</code></td><td>從 DB 刪除指定 Passkey</td></tr></tbody></table><hr><h2 id="常見問題"><a href="#常見問題" class="headerlink" title="常見問題"></a>常見問題</h2><h3 id="Q：只刪除-Server-端的-Passkey-資料就夠了嗎？"><a href="#Q：只刪除-Server-端的-Passkey-資料就夠了嗎？" class="headerlink" title="Q：只刪除 Server 端的 Passkey 資料就夠了嗎？"></a>Q：只刪除 Server 端的 Passkey 資料就夠了嗎？</h3><p>不完全夠。Passkey 是雙邊資料：</p><ul><li><strong>Server 端</strong>：公鑰、credentialId（你刪除的部分）</li><li><strong>使用者裝置端</strong>：私鑰（存在 Keychain、密碼管理器等）</li></ul><p>刪除 Server 端後，這把 Passkey <strong>無法再登入你的網站</strong>，但裝置上仍有殘留憑證（orphan credential）。建議 UI 上提示使用者到裝置端（瀏覽器設定 &#x2F; 系統密碼管理器）一併刪除。</p><h3 id="Q：同一把-Passkey-刪掉後可以重新註冊嗎？"><a href="#Q：同一把-Passkey-刪掉後可以重新註冊嗎？" class="headerlink" title="Q：同一把 Passkey 刪掉後可以重新註冊嗎？"></a>Q：同一把 Passkey 刪掉後可以重新註冊嗎？</h3><p>可以。刪除 Server 端資料後，<code>MakePasskeyCreationOptionsAsync</code> 產生的 options 不會再帶舊的 <code>excludeCredentials</code>，瀏覽器呼叫 <code>navigator.credentials.create()</code> 就不會被擋，可正常重新註冊。</p><h3 id="Q：MakePasskeyCreationOptionsAsync-會查資料庫嗎？"><a href="#Q：MakePasskeyCreationOptionsAsync-會查資料庫嗎？" class="headerlink" title="Q：MakePasskeyCreationOptionsAsync 會查資料庫嗎？"></a>Q：<code>MakePasskeyCreationOptionsAsync</code> 會查資料庫嗎？</h3><p><strong>會</strong>。它在產生選項時，會查詢 <code>AspNetUserPasskeys</code> 資料表取得該使用者既有的 Passkey，並填入 <code>excludeCredentials</code>，目的是讓瀏覽器&#x2F;認證器避免在同一裝置上建立重複的 credential，防止重複綁定。</p><h3 id="Q：JavaScript-可以直接刪除裝置上的-Passkey-嗎？"><a href="#Q：JavaScript-可以直接刪除裝置上的-Passkey-嗎？" class="headerlink" title="Q：JavaScript 可以直接刪除裝置上的 Passkey 嗎？"></a>Q：JavaScript 可以直接刪除裝置上的 Passkey 嗎？</h3><p>不能。WebAuthn API 提供 <code>create</code> 與 <code>get</code>，但<strong>不提供網站主動刪除認證器內憑證的能力</strong>。這是設計上的安全考量，避免任意網站偷偷移除使用者的憑證。</p><hr><h2 id="小結"><a href="#小結" class="headerlink" title="小結"></a>小結</h2><p>.NET 10 將 Passkey 支援內建進 ASP.NET Core Identity，大幅降低了無密碼認證的實作門檻。整個流程的核心可以用一句話概括：</p><blockquote><p><strong><code>MakePasskey*OptionsAsync</code> 是「出題」，<code>PerformPasskeyAttestation / PasskeySignIn</code> 是「驗題」。</strong></p></blockquote><p>只要掌握這個觀念，搭配 WebAuthn API 的 <code>navigator.credentials.create()</code> 與 <code>navigator.credentials.get()</code>，就能在 Razor Pages 應用程式中快速實現現代化的無密碼登入體驗。</p><hr><h2 id="參考資料"><a href="#參考資料" class="headerlink" title="參考資料"></a>參考資料</h2><ul><li><a href="https://learn.microsoft.com/aspnet/core/security/authentication/passkeys/">ASP.NET Core 官方文件：Enable WebAuthn Passkeys</a></li><li><a href="https://www.microsoft.com/security/business/security-101/what-is-fido2">FIDO2 介紹</a></li></ul>]]></content>
    
    
      
      
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h2&gt;&lt;p&gt;密碼已是過去式。忘記密碼、弱密碼、密碼被釣魚攻擊——這些問題困擾開發者與使用者多年。在 .NET 10 中，ASP.NET Core Ide</summary>
      
    
    
    
    
    <category term="ASP.NET Core 10" scheme="https://rainmakerho.github.io/tags/ASP-NET-Core-10/"/>
    
    <category term="Passkey" scheme="https://rainmakerho.github.io/tags/Passkey/"/>
    
    <category term="WebAuthn" scheme="https://rainmakerho.github.io/tags/WebAuthn/"/>
    
    <category term="FIDO2" scheme="https://rainmakerho.github.io/tags/FIDO2/"/>
    
    <category term="無密碼登入" scheme="https://rainmakerho.github.io/tags/%E7%84%A1%E5%AF%86%E7%A2%BC%E7%99%BB%E5%85%A5/"/>
    
    <category term="Identity" scheme="https://rainmakerho.github.io/tags/Identity/"/>
    
    <category term="Razor Pages" scheme="https://rainmakerho.github.io/tags/Razor-Pages/"/>
    
    <category term=".NET 10" scheme="https://rainmakerho.github.io/tags/NET-10/"/>
    
    <category term="生物辨識" scheme="https://rainmakerho.github.io/tags/%E7%94%9F%E7%89%A9%E8%BE%A8%E8%AD%98/"/>
    
    <category term="密碼金鑰" scheme="https://rainmakerho.github.io/tags/%E5%AF%86%E7%A2%BC%E9%87%91%E9%91%B0/"/>
    
  </entry>
  
  <entry>
    <title>Remote MCP Server 整合 Azure AD（Entra ID）OAuth 2.0 驗證（.NET/C#）</title>
    <link href="https://rainmakerho.github.io/2026/04/21/mcp-azuread/"/>
    <id>https://rainmakerho.github.io/2026/04/21/mcp-azuread/</id>
    <published>2026-04-21T05:57:03.000Z</published>
    <updated>2026-04-21T07:24:38.315Z</updated>
    
    <content type="html"><![CDATA[<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>隨著 AI 應用的普及，MCP（Model Context Protocol）Server 讓 AI 助理能夠透過標準化的方式呼叫企業內部工具與資料。然而，在企業環境中部署 MCP Server 時，身份驗證是不可或缺的一環——不能讓任何人都能直接存取敏感的內部資源。</p><p>本文說明如何將 Remote MCP Server（以 .NET &#x2F; C# 實作）與 Azure AD（Entra ID）進行 OAuth 2.0 整合，包含：</p><ul><li>Entra ID 應用程式的設定流程（Server App &amp; Client App）</li><li>.NET 程式中的 JWT 驗證設定</li><li>使用 IIS 部署的注意事項</li><li>使用 MCP Inspector 進行測試</li><li>常見錯誤 <code>AADSTS9010010</code> 的解法</li></ul><p><strong>適用情境</strong>：已有 Azure AD（Entra ID）Tenant、想在企業內部安全地對外提供 MCP Server 的 .NET 開發者。</p><h3 id="實作"><a href="#實作" class="headerlink" title="實作"></a>實作</h3><h5 id="應用程式註冊-App-registrations"><a href="#應用程式註冊-App-registrations" class="headerlink" title="應用程式註冊(App registrations)"></a>應用程式註冊(App registrations)</h5><p>1.建立 MCP Server App</p><p>1.1.在 Entra ID 的 <strong>應用程式註冊</strong> 中，建立 MCP Server App<br>在<strong>Expose an API</strong>新增<strong>Scope</strong>，並填入 consent 的說明，如下，</p><img src="/2026/04/21/mcp-azuread/01.png" class="" title="Expose an API"><p><code>Application ID URI</code>設定為<code>https://mcpserver.公司domain/mcp</code>(mcp server 的 url)</p><p>1.2.修改<strong>Manifest</strong><br>在 api 區段中，把<code>&quot;requestedAccessTokenVersion&quot;: null</code>改成<code>&quot;requestedAccessTokenVersion&quot;: 2</code></p><p>2.建立 MCP Client App<br>2.1.在 Entra ID 的 <strong>應用程式註冊</strong> 中，建立 MCP Client App<br>2.2.在 <strong>API permissions</strong> 加入<strong>MCP Server App</strong>的Scope，如下，</p><img src="/2026/04/21/mcp-azuread/02.png" class="" title="API permissions"><p>2.3.在<strong>Authentication</strong>中在<code>Web</code> or <code>SPA</code> or … 中設定<strong>Redirect URI</strong>，例如<code>http://localhost:6274/oauth/callback/debug</code> …</p><ul><li>註: 建議依不同 Client App 的Type建立不同的 App</li></ul><h5 id="程式實作"><a href="#程式實作" class="headerlink" title="程式實作"></a>程式實作</h5><p>Entra ID Authority V2 URL 為 <code>https://login.microsoftonline.com/&#123;tenantId&#125;/v2.0</code>, Audience 為 MCP Server App 的 應用程式 (用戶端) 識別碼 例如:<code>34dc497e-b607-4ee0-b552-2b015c547454</code></p><p><code>Program.cs</code> 設定如下：</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="comment">// 開發環境使用，請依實際狀況進行調整</span></span><br><span class="line">builder.Services.AddCors(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    options.AddPolicy(<span class="string">&quot;AllowAll&quot;</span>, policy =&gt;</span><br><span class="line">    &#123;</span><br><span class="line">        policy.AllowAnyOrigin()</span><br><span class="line">              .AllowAnyHeader()</span><br><span class="line">              .AllowAnyMethod();</span><br><span class="line">    &#125;);</span><br><span class="line"></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> tenantId = <span class="string">&quot;&lt;YOUR_TENANT_ID&gt;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> authorityV2 = <span class="string">$&quot;https://login.microsoftonline.com/<span class="subst">&#123;tenantId&#125;</span>/v2.0&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> mcpScope = <span class="string">&quot;https://mcpserver.公司domain/mcp/&lt;YOUR_SCOPE_NAME&gt;&quot;</span>;</span><br><span class="line"></span><br><span class="line">builder.Services.AddAuthentication(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;</span><br><span class="line">    options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;</span><br><span class="line">&#125;).AddJwtBearer(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    options.Authority = authorityV2;</span><br><span class="line">    options.TokenValidationParameters = <span class="keyword">new</span> TokenValidationParameters</span><br><span class="line">    &#123;</span><br><span class="line">        ValidateIssuer = <span class="literal">true</span>,</span><br><span class="line">        ValidateAudience = <span class="literal">true</span>,</span><br><span class="line">        ValidateLifetime = <span class="literal">true</span>,</span><br><span class="line">        ValidateIssuerSigningKey = <span class="literal">true</span>,</span><br><span class="line">        ValidAudiences = <span class="keyword">new</span>[]</span><br><span class="line">        &#123;</span><br><span class="line">            <span class="string">&quot;&lt;YOUR_MCP_SERVER_APP_CLIENT_ID&gt;&quot;</span></span><br><span class="line">        &#125;,</span><br><span class="line">        ValidIssuers = <span class="keyword">new</span>[]</span><br><span class="line">            &#123;</span><br><span class="line">                authorityV2</span><br><span class="line">            &#125;,</span><br><span class="line"></span><br><span class="line">        NameClaimType = <span class="string">&quot;name&quot;</span>,</span><br><span class="line">        RoleClaimType = <span class="string">&quot;roles&quot;</span></span><br><span class="line">    &#125;;</span><br><span class="line"></span><br><span class="line">    options.Events = <span class="keyword">new</span> JwtBearerEvents</span><br><span class="line">    &#123;</span><br><span class="line">        OnTokenValidated = context =&gt;</span><br><span class="line">        &#123;</span><br><span class="line">            <span class="keyword">var</span> name = context.Principal?.Identity?.Name ?? <span class="string">&quot;unknown&quot;</span>;</span><br><span class="line">            <span class="keyword">var</span> email = context.Principal?.FindFirstValue(<span class="string">&quot;preferred_username&quot;</span>) ?? <span class="string">&quot;unknown&quot;</span>;</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Token validated for: <span class="subst">&#123;name&#125;</span> (<span class="subst">&#123;email&#125;</span>)&quot;</span>);</span><br><span class="line">            <span class="keyword">return</span> Task.CompletedTask;</span><br><span class="line">        &#125;,</span><br><span class="line">        OnAuthenticationFailed = context =&gt;</span><br><span class="line">        &#123;</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Authentication failed: <span class="subst">&#123;context.Exception.Message&#125;</span>&quot;</span>);</span><br><span class="line">            <span class="keyword">return</span> Task.CompletedTask;</span><br><span class="line">        &#125;,</span><br><span class="line">        OnChallenge = context =&gt;</span><br><span class="line">        &#123;</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Challenging client to authenticate with Entra ID&quot;</span>);</span><br><span class="line">            <span class="keyword">return</span> Task.CompletedTask;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;;</span><br><span class="line">&#125;)</span><br><span class="line">.AddMcp(options =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    options.ResourceMetadata = <span class="keyword">new</span>()</span><br><span class="line">    &#123;</span><br><span class="line">        AuthorizationServers = &#123; authorityV2 &#125;,</span><br><span class="line">        ScopesSupported = &#123; mcpScope &#125;,</span><br><span class="line">    &#125;;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">builder.Services.AddAuthorization();</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> mcpBuilder = builder.Services.AddMcpServer()</span><br><span class="line">    .WithHttpTransport(options =&gt;</span><br><span class="line">    &#123;</span><br><span class="line">        options.Stateless = <span class="literal">true</span>;</span><br><span class="line">    &#125;)</span><br><span class="line">    .WithResourcesFromAssembly()</span><br><span class="line">    .WithToolsFromAssembly()</span><br><span class="line">    .WithPromptsFromAssembly();</span><br><span class="line"></span><br><span class="line">mcpBuilder.AddAuthorizationFilters();</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> app = builder.Build();</span><br><span class="line"></span><br><span class="line"><span class="comment">// Configure the HTTP request pipeline.</span></span><br><span class="line"></span><br><span class="line">app.UseHttpsRedirection();</span><br><span class="line">app.UseCors(<span class="string">&quot;AllowAll&quot;</span>);</span><br><span class="line"></span><br><span class="line">app.UseAuthentication();</span><br><span class="line">app.UseAuthorization();</span><br><span class="line"></span><br><span class="line"><span class="comment">// api</span></span><br><span class="line">app.MapGet(<span class="string">&quot;/api/healthy&quot;</span>, () =&gt; <span class="string">&quot;Healthy&quot;</span>);</span><br><span class="line"></span><br><span class="line">app.MapGet(<span class="string">&quot;/api/docs&quot;</span>, () =&gt;</span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span></span><br><span class="line">    &#123;</span><br><span class="line">        name = <span class="string">&quot;HR MCP Server&quot;</span>,</span><br><span class="line">        description = <span class="string">&quot;提供請假、行事曆、文件等企業服務的 MCP 伺服器&quot;</span>,</span><br><span class="line">        version = <span class="string">&quot;1.0.0&quot;</span>,</span><br><span class="line">        tools = <span class="keyword">new</span>[] &#123; <span class="string">&quot;RequestTimeOff&quot;</span>, <span class="string">&quot;PlanTimeOff&quot;</span>, <span class="string">&quot;GetCalendarInfo&quot;</span> &#125;,</span><br><span class="line">        scopes = <span class="keyword">new</span>[] &#123; mcpScope &#125;</span><br><span class="line">    &#125;;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">app.MapMcp(<span class="string">&quot;/mcp&quot;</span>)</span><br><span class="line">    .RequireAuthorization();</span><br><span class="line"></span><br><span class="line">app.Run();</span><br><span class="line"></span><br></pre></td></tr></table></figure><h5 id="部署"><a href="#部署" class="headerlink" title="部署"></a>部署</h5><p>將程式部署到根目錄下，如果是IIS的話，可以建立多個站台透過 HTTP Host Header 在 IIS 設定多個站台綁定。<br>如果在Local測試可以調整 <code>hosts</code> 檔案，加入<code>127.0.0.1 mcp-server-url</code> 來測試。<br>因為Metadata Discovery會在根目錄下去找，例如<code>https://host/.well-known/oauth-protected-resource</code></p><h3 id="測試"><a href="#測試" class="headerlink" title="測試"></a>測試</h3><h5 id="modelcontextprotocol-x2F-inspector"><a href="#modelcontextprotocol-x2F-inspector" class="headerlink" title="modelcontextprotocol&#x2F;inspector"></a>modelcontextprotocol&#x2F;inspector</h5><p>1.執行<code>npx @modelcontextprotocol/inspector</code>會開啟<strong>inspector</strong>，<br>2.<strong>url</strong>輸入<code>mcp-server-url</code><br>3.<strong>Transport Type</strong>選擇<code>Streamable HTTP</code><br>4.<strong>Connection Type</strong> 選 <code>Direct</code><br>5.<strong>Authentication</strong>區段中的<strong>client ID</strong>輸入<code>MCP Client App的ClientId</code><br>6.按下<strong>Open Auth Settings</strong>來進行<code>OAuth Authentication</code>測試，<br>在<strong>OAuth Flow Progress</strong>區段中，可以依<code>Metadata Discovery</code>、<code>Client Registration</code>、<code>Preparing Authorization</code> … 按下<strong>Continue</strong> 按鈕 來進行一步步的測試。如下，</p><img src="/2026/04/21/mcp-azuread/03.png" class="" title="modelcontextprotocol&#x2F;inspector"><h5 id="AADSTS9010010-錯誤"><a href="#AADSTS9010010-錯誤" class="headerlink" title="AADSTS9010010 錯誤"></a>AADSTS9010010 錯誤</h5><p>The resource parameter doesn’t match requested scopes<br>解法:scope 必須包含 resource prefix，例如<strong>resource&#x3D;<a href="https://host/mcp">https://host/mcp</a></strong>, <strong>scope&#x3D;<a href="https://host/mcp/%E4%BD%A0%E7%9A%84scope">https://host/mcp/你的scope</a></strong></p><ul><li>註: 要注意 path <code>https://host/</code> 會不會多一個 <strong>&#x2F;</strong> ，變成了 <code>https://host//</code></li></ul><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://engincanveske.substack.com/p/building-your-first-mcp-server-with">Building Your First MCP Server with .NET: A Developer’s Guide to Model Context Protocol</a><br><a href="https://devblogs.microsoft.com/dotnet/release-v10-of-the-official-mcp-csharp-sdk/">Release v1.0 of the official MCP C# SDK</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h3&gt;&lt;p&gt;隨著 AI 應用的普及，MCP（Model Context Protocol）Server 讓 AI 助理能夠透過標準化的方式呼叫企業內部工</summary>
      
    
    
    
    
    <category term="IIS" scheme="https://rainmakerho.github.io/tags/IIS/"/>
    
    <category term="C#" scheme="https://rainmakerho.github.io/tags/C/"/>
    
    <category term=".NET" scheme="https://rainmakerho.github.io/tags/NET/"/>
    
    <category term="OAuth" scheme="https://rainmakerho.github.io/tags/OAuth/"/>
    
    <category term="AzureAD" scheme="https://rainmakerho.github.io/tags/AzureAD/"/>
    
    <category term="Entra ID" scheme="https://rainmakerho.github.io/tags/Entra-ID/"/>
    
    <category term="MCP" scheme="https://rainmakerho.github.io/tags/MCP/"/>
    
    <category term="AADSTS9010010" scheme="https://rainmakerho.github.io/tags/AADSTS9010010/"/>
    
  </entry>
  
  <entry>
    <title>影像 OCR 實戰心得：LLM 與 Azure Document Intelligence 的選擇策略</title>
    <link href="https://rainmakerho.github.io/2026/01/23/ocr-llm-vs-azure-document-intelligence-experience/"/>
    <id>https://rainmakerho.github.io/2026/01/23/ocr-llm-vs-azure-document-intelligence-experience/</id>
    <published>2026-01-23T09:00:32.000Z</published>
    <updated>2026-01-23T09:33:54.525Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>在實務上使用大型語言模型（LLM）進行影像內容解析（OCR + 內容理解）時，<br>並不存在一個模型可以適用所有情境的解法。</p><p>影像的<strong>數量、解析度、內容多寡，以及是否包含表格或手寫文字</strong>，<br>都會大幅影響解析結果的正確性與穩定度。</p><p>本文整理實際專案中，針對 <strong>LLM（GPT-4.1-mini、Gemini）</strong><br>以及 <strong>Azure Document Intelligence</strong> 在不同影像解析場景下的使用心得。</p><hr><h2 id="情境一：單張或少量影像、內容單純（無表格）"><a href="#情境一：單張或少量影像、內容單純（無表格）" class="headerlink" title="情境一：單張或少量影像、內容單純（無表格）"></a>情境一：單張或少量影像、內容單純（無表格）</h2><h3 id="特性"><a href="#特性" class="headerlink" title="特性"></a>特性</h3><ul><li>單張或少量影像</li><li>內容不多</li><li>無表格結構</li><li>以印刷體文字為主</li></ul><h3 id="建議工具"><a href="#建議工具" class="headerlink" title="建議工具"></a>建議工具</h3><ul><li><strong>GPT-4.1-mini&#x2F;Gemini 3 Flash</strong></li></ul><h3 id="實際案例"><a href="#實際案例" class="headerlink" title="實際案例"></a>實際案例</h3><ul><li>加油發票</li><li>簡單收據</li><li>欄位固定、格式單純的文件</li></ul><p>在這類情境中，直接使用 LLM 解析影像即可，<br>具備速度快、成本低且彈性高的優勢。</p><hr><h2 id="情境二：多張影像同時辨識（解析度影響明顯）"><a href="#情境二：多張影像同時辨識（解析度影響明顯）" class="headerlink" title="情境二：多張影像同時辨識（解析度影響明顯）"></a>情境二：多張影像同時辨識（解析度影響明顯）</h2><h3 id="特性-1"><a href="#特性-1" class="headerlink" title="特性"></a>特性</h3><ul><li>一次輸入多張影像</li><li>例如：一頁中包含 5 張加油發票</li><li>單張影像解析度偏低</li></ul><h3 id="實務觀察"><a href="#實務觀察" class="headerlink" title="實務觀察"></a>實務觀察</h3><ul><li><p><strong>GPT-4.1-mini</strong></p><ul><li>當影像解析度不足時，中文辨識可能出現錯字或漏字</li></ul></li><li><p><strong>Gemini 3 Flash</strong></p><ul><li>在相同條件下，能完整且正確擷取中文內容</li></ul></li><li><p><strong>Azure Document Intelligence</strong></p><ul><li>因缺乏表格結構，多張發票內容容易發生欄位或文字錯置</li></ul></li><li><p>註:在「多張影像、低解析度、無表格」的情境下，<br><strong>Gemini 3 Flash 的穩定度與中文辨識表現較佳</strong>。</p></li></ul><hr><h2 id="情境三：影像包含表格、內容多或有手寫文字"><a href="#情境三：影像包含表格、內容多或有手寫文字" class="headerlink" title="情境三：影像包含表格、內容多或有手寫文字"></a>情境三：影像包含表格、內容多或有手寫文字</h2><h3 id="特性-2"><a href="#特性-2" class="headerlink" title="特性"></a>特性</h3><ul><li>具備明確表格結構</li><li>內容量大</li><li>同時包含印刷體與手寫文字</li></ul><h3 id="建議工具-1"><a href="#建議工具-1" class="headerlink" title="建議工具"></a>建議工具</h3><ul><li><strong>Azure Document Intelligence</strong></li></ul><h3 id="實際案例-1"><a href="#實際案例-1" class="headerlink" title="實際案例"></a>實際案例</h3><ul><li>電費單</li><li>水費單</li><li>帳單、報表類文件</li></ul><p>這類文件結構明確，<br>Azure Document Intelligence 在<strong>表格解析、欄位對齊與手寫文字辨識</strong>方面表現穩定，<br>比純 LLM 更適合長篇且結構化的文件。</p><hr><h2 id="整體選擇建議總結"><a href="#整體選擇建議總結" class="headerlink" title="整體選擇建議總結"></a>整體選擇建議總結</h2><p>可簡單歸納為以下原則：</p><ul><li><strong>有表格、內容多、包含手寫文字</strong><ul><li>優先使用 <strong>Azure Document Intelligence</strong></li></ul></li><li><strong>內容少、無表格</strong><ul><li>使用 <strong>LLM（GPT-4.1-mini 或 Gemini）</strong></li></ul></li><li><strong>多張影像、解析度偏低</strong><ul><li>優先考慮 <strong>Gemini 3 Flash</strong></li></ul></li></ul><p>但使用 <strong>Azure Document Intelligence</strong> 後，通常會再把內容給 LLM 來整理出需要的內容。<br>如果只能在地端的話，以<strong>中國</strong>的模型效果比較好，例如 Qwen 的模型。<br>如果是特別的領域，可以拿<strong>中國</strong>的模型再來 Finetune 成自家需要的 Model。</p><hr><h2 id="結語"><a href="#結語" class="headerlink" title="結語"></a>結語</h2><p>影像 OCR 並不存在「一個工具打天下」的最佳解法，<br><strong>理解文件特性並選擇合適的工具</strong>，往往比追求最新模型更重要。</p><p>在實務系統中，混合使用 <strong>LLM 與文件解析服務</strong>，<br>通常能在成本、準確率與穩定度之間取得更好的平衡。</p><p>希望這些實戰心得，能對正在進行影像解析或文件自動化的你有所幫助。</p>]]></content>
    
    
      
      
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h2&gt;&lt;p&gt;在實務上使用大型語言模型（LLM）進行影像內容解析（OCR + 內容理解）時，&lt;br&gt;並不存在一個模型可以適用所有情境的解法。&lt;/p&gt;
&lt;p</summary>
      
    
    
    
    
    <category term="azure document intelligence" scheme="https://rainmakerho.github.io/tags/azure-document-intelligence/"/>
    
    <category term="gpt" scheme="https://rainmakerho.github.io/tags/gpt/"/>
    
    <category term="gemini" scheme="https://rainmakerho.github.io/tags/gemini/"/>
    
    <category term="ocr" scheme="https://rainmakerho.github.io/tags/ocr/"/>
    
  </entry>
  
  <entry>
    <title>推動「綠色軟體」的同時，我們是否正陷入地端 AI 的排碳陷阱?</title>
    <link href="https://rainmakerho.github.io/2025/12/25/tw-green-software/"/>
    <id>https://rainmakerho.github.io/2025/12/25/tw-green-software/</id>
    <published>2025-12-25T08:07:40.000Z</published>
    <updated>2025-12-25T08:43:15.273Z</updated>
    
    <content type="html"><![CDATA[<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>最近資訊業界最熱門的話題莫過於「綠色軟體（Green Software）」，其核心目標在於優化資訊架構，達成節能減碳。<br>但是隨著 AI 的推動，卻發現一個巨大的矛盾。</p><blockquote><p>政府與企業一邊高喊節能減碳，另一邊各單位卻為了資安焦慮，紛紛編列預算各自採購地端 GPU 伺服器</p></blockquote><p>這種各地的 GPU 伺服器，真的符合綠色永續嗎？</p><h4 id="地端-AI-的真實困境：低效能與高能耗的雙重夾擊"><a href="#地端-AI-的真實困境：低效能與高能耗的雙重夾擊" class="headerlink" title="地端 AI 的真實困境：低效能與高能耗的雙重夾擊"></a>地端 AI 的真實困境：低效能與高能耗的雙重夾擊</h4><p>地端的 Model 跟 雲端 AI 模型（如 GPT-5, Gemini）相比，地端 AI 的理解力與準確率還是遜於雲端，最後或許還要耗費大量人工校對。</p><p>AI 技術快速更新。雲端服務能即時導入最新演算法，但地端系統從採購到建置完成可能已過時，會不會陷入「花大錢買舊技術」的循環之中呢。<br>一台 GPU Server 隨便都是上百萬台幣，這筆經費若轉為雲端算力，足以供應單位使用數年且隨時保持在最強效能。</p><p>自購伺服器多半僅在特定專案或上班時間運作，但為了維持機房恆溫與待機，閒置時依然消耗大量電力。<br>比起雲端資料中心極致的能源使用效率，地端機房才是真正的排碳大戶。</p><h3 id="資料分級：打破「一刀切」的資安迷思"><a href="#資料分級：打破「一刀切」的資安迷思" class="headerlink" title="資料分級：打破「一刀切」的資安迷思"></a>資料分級：打破「一刀切」的資安迷思</h3><p>整個單位中，並不是所有的資料都是機密，透過資料的分級，可以讓 AI 效能最大化。<br>一些非機敏資料使用雲端 AI 來改善單位流程及效率，而不是一刀切，完全不能使用雲端 AI。</p><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>「綠色軟體」政策不應僅止於程式碼的優化，更應包含佈署架構的智慧化。<br>我們必須承認：分散式的 GPU 伺服器往往是能源效率的殺手。</p><h3 id="參考來源"><a href="#參考來源" class="headerlink" title="參考來源"></a>參考來源</h3><p><a href="https://www.teema.org.tw/industry-information-detail.aspx?infoid=50589">從程式碼到雲端：實踐數位永續的綠色軟體全攻略</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h3&gt;&lt;p&gt;最近資訊業界最熱門的話題莫過於「綠色軟體（Green Software）」，其核心目標在於優化資訊架構，達成節能減碳。&lt;br&gt;但是隨著 AI</summary>
      
    
    
    
    
    <category term="綠色軟體" scheme="https://rainmakerho.github.io/tags/%E7%B6%A0%E8%89%B2%E8%BB%9F%E9%AB%94/"/>
    
    <category term="GPU" scheme="https://rainmakerho.github.io/tags/GPU/"/>
    
  </entry>
  
  <entry>
    <title>.NET 6 System.Data.SqlClient 查看 Connection 效能計數器</title>
    <link href="https://rainmakerho.github.io/2025/12/22/net6-system-data-sqlclient-perfmon-connection-pool/"/>
    <id>https://rainmakerho.github.io/2025/12/22/net6-system-data-sqlclient-perfmon-connection-pool/</id>
    <published>2025-12-22T02:55:04.000Z</published>
    <updated>2025-12-22T03:38:49.437Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>.NET 6 透過 System.Data.SqlClient 連接資料庫，想要在 Performance Monitor 中加入 <strong>.NET Data Provider for SqlServer</strong> 來查看 Connection Pool 的相關資料，但是在 <strong>Instances of selected object:</strong> 中，卻找不到對應的 Process Id</p><p>在 .NET 6 可以透過 <strong>dotnet-counters</strong> 來查看<strong>Microsoft.Data.SqlClient</strong>，而不是<strong>System.Data.SqlClient</strong></p><h3 id="解法"><a href="#解法" class="headerlink" title="解法"></a>解法</h3><p>Performance Monitor 工具是查看 .NET Framework 的程式，.NET 6 要使用 <strong>dotnet-counters</strong> 。<br>但是<strong>dotnet-counters</strong>可以看的是<strong>Microsoft.Data.SqlClient</strong>的 EventCounters。</p><h5 id="1-安裝-Microsoft-Data-SqlClient-套件-5-2-3"><a href="#1-安裝-Microsoft-Data-SqlClient-套件-5-2-3" class="headerlink" title="1.安裝 Microsoft.Data.SqlClient 套件(5.2.3)"></a>1.安裝 Microsoft.Data.SqlClient 套件(5.2.3)</h5><h5 id="2-調整-System-Data-SqlClient-Namespace"><a href="#2-調整-System-Data-SqlClient-Namespace" class="headerlink" title="2.調整 System.Data.SqlClient Namespace"></a>2.調整 System.Data.SqlClient Namespace</h5><p>將 System.Data.SqlClient Namespace 改成 Microsoft.Data.SqlClient Namespace</p><h5 id="3-重新建置"><a href="#3-重新建置" class="headerlink" title="3.重新建置"></a>3.重新建置</h5><h3 id="dotnet-counters-monitor"><a href="#dotnet-counters-monitor" class="headerlink" title="dotnet-counters monitor"></a>dotnet-counters monitor</h3><p>1.安裝<strong>dotnet-counters</strong><br><code>dotnet tool install --global dotnet-counters --version 6.0.327302</code></p><p>2.查看可以 monitor 的 process<br><code>dotnet-counters ps</code></p><ul><li>請確定程式已經在執行</li></ul><p>3.監看 Microsoft.Data.SqlClient.EventSource<br><code>dotnet-counters monitor --counters Microsoft.Data.SqlClient.EventSource -p &lt;process-id&gt; --refresh-interval 3</code></p><p>也可以將資料存成 CSV &#x2F;JSON 格式，例如，<br><code>dotnet-counters collect --counters Microsoft.Data.SqlClient.EventSource --process-id &lt;process-id&gt; --refresh-interval 3 --format csv</code></p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://rainmakerho.github.io/2022/08/29/timeout-expired-max-pool-size-reached/">Timeout expired. all pooled connections were in use and max pool size was reached. 自動關閉 Connection ?</a><br><a href="https://dotblogs.azurewebsites.net/rainmaker/2017/04/26/143316">已超過連接逾時的設定。在取得集區連接之前超過逾時等待的時間，可能的原因為所有的共用連接已在使用中，並已達共用集區大小的最大值。</a><br><a href="https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters">dotnet-counters</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;.NET 6 透過 System.Data.SqlClient 連接資料庫，想要在 Performance Monitor 中加入 &lt;str</summary>
      
    
    
    
    
    <category term=".net6" scheme="https://rainmakerho.github.io/tags/net6/"/>
    
    <category term="System.Data.SqlClient" scheme="https://rainmakerho.github.io/tags/System-Data-SqlClient/"/>
    
    <category term="Microsoft.Data.SqlClient" scheme="https://rainmakerho.github.io/tags/Microsoft-Data-SqlClient/"/>
    
    <category term="PerfMon" scheme="https://rainmakerho.github.io/tags/PerfMon/"/>
    
    <category term="Connection Pool" scheme="https://rainmakerho.github.io/tags/Connection-Pool/"/>
    
    <category term="performance-counters" scheme="https://rainmakerho.github.io/tags/performance-counters/"/>
    
    <category term="dotnet-counters" scheme="https://rainmakerho.github.io/tags/dotnet-counters/"/>
    
  </entry>
  
  <entry>
    <title>MSB4018 工作發生未預期的失敗 DirectoryNotFoundException</title>
    <link href="https://rainmakerho.github.io/2025/12/18/MSB4018-VS-ERROR/"/>
    <id>https://rainmakerho.github.io/2025/12/18/MSB4018-VS-ERROR/</id>
    <published>2025-12-18T02:15:36.000Z</published>
    <updated>2025-12-18T08:05:27.870Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>透過 VS.NET 建置專案時，會發生以下的錯誤，</p><blockquote><p>GenerateStaticWebAssetsPropsFile 工作發生未預期的失敗<br>System.IO.DirectoryNotFoundException: 找不到路徑 …</p></blockquote><img src="/2025/12/18/MSB4018-VS-ERROR/01.png" class="" title="GenerateStaticWebAssetsPropsFile"><p>但是如果透過命令視窗卻可以正常建置。</p><h3 id="解法"><a href="#解法" class="headerlink" title="解法"></a>解法</h3><p>發生這個問題是因為專案的路徑太長，所以解法有以下的方式，</p><h4 id="1-縮短路徑"><a href="#1-縮短路徑" class="headerlink" title="1.縮短路徑:"></a>1.縮短路徑:</h4><p>所以可以依 <a href="https://www.youtube.com/watch?v=GtqQn36onAg">How to fix: The GenerateStaticWebAsssetsPropsFile task failed unexpectedly in Visual Studio</a> 的方式，讓專案所在的整個路徑不要那麼長。</p><h4 id="2-允許長路徑-允許超過-260"><a href="#2-允許長路徑-允許超過-260" class="headerlink" title="2.允許長路徑(允許超過 260)"></a>2.允許長路徑(允許超過 260)</h4><p>使用 PowerShell 修改 (需管理員權限)： 開啟 PowerShell 並輸入以下指令：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">New-ItemProperty</span> <span class="literal">-Path</span> <span class="string">&quot;HKLM:\System\CurrentControlSet\Control\FileSystem&quot;</span> <span class="literal">-Name</span> <span class="string">&quot;LongPathsEnabled&quot;</span> <span class="literal">-Value</span> <span class="number">1</span> <span class="literal">-PropertyType</span> DWORD <span class="literal">-Force</span></span><br></pre></td></tr></table></figure><p>完成後，重新開啟方案來建置，應該就可以建置成功了。</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://www.youtube.com/watch?v=GtqQn36onAg">How to fix: The GenerateStaticWebAsssetsPropsFile task failed unexpectedly in Visual Studio</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;透過 VS.NET 建置專案時，會發生以下的錯誤，&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;GenerateStaticWebAssetsP</summary>
      
    
    
    
    
    <category term="Visual Studio" scheme="https://rainmakerho.github.io/tags/Visual-Studio/"/>
    
    <category term="DirectoryNotFoundException" scheme="https://rainmakerho.github.io/tags/DirectoryNotFoundException/"/>
    
    <category term="MSB4018" scheme="https://rainmakerho.github.io/tags/MSB4018/"/>
    
    <category term="LongPathsEnabled" scheme="https://rainmakerho.github.io/tags/LongPathsEnabled/"/>
    
  </entry>
  
  <entry>
    <title>Playwright 部署到 Azure App Service 發生 Driver not found</title>
    <link href="https://rainmakerho.github.io/2025/12/02/playwright-Driver-not-found/"/>
    <id>https://rainmakerho.github.io/2025/12/02/playwright-Driver-not-found/</id>
    <published>2025-12-02T05:38:33.000Z</published>
    <updated>2025-12-02T05:46:27.969Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>最近同事使用 <a href="https://playwright.dev/dotnet/docs/intro">Playwright for .NET</a> 將程式部署到 Azure 後，會發生以下的錯誤，</p><blockquote><p>Driver not found: c:\home\site\wwwroot\.playwright\node\win32_x64\node.exe</p></blockquote><h3 id="解法"><a href="#解法" class="headerlink" title="解法"></a>解法</h3><p>這個錯應該是因為在 Publish 後，部署的程式的包含 <strong>.playwright</strong> 的目錄，在部署到 Azure 時，沒有一併將 <strong>.playwright</strong> 的目錄部署上去。<br>所以將 <strong>.playwright</strong> 的目錄一併部署上去就可以了哦!</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://stackoverflow.com/questions/79585756/playwright-driver-not-found-error-in-azure-function-azure-portal-flex-consump">Playwright driver not found error in Azure Function (Azure Portal - Flex consumption plan)</a><br><a href="https://www.linkedin.com/pulse/using-playwright-windows-hosted-azure-function-devis-giacopuzzi-bqz5f/">Using Playwright in a Windows-hosted Azure Function</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;最近同事使用 &lt;a href=&quot;https://playwright.dev/dotnet/docs/intro&quot;&gt;Playwright f</summary>
      
    
    
    
    
    <category term=".NET" scheme="https://rainmakerho.github.io/tags/NET/"/>
    
    <category term="Azure" scheme="https://rainmakerho.github.io/tags/Azure/"/>
    
    <category term="Playwright" scheme="https://rainmakerho.github.io/tags/Playwright/"/>
    
  </entry>
  
  <entry>
    <title>Windows 11 24H2 安裝 Update 後造成 localhost 連不到 ERR_CONNECTION_RESET or hostname is invalid</title>
    <link href="https://rainmakerho.github.io/2025/10/16/iis-express-failing-after-install-2025-10-update-for-windows11-24h2/"/>
    <id>https://rainmakerho.github.io/2025/10/16/iis-express-failing-after-install-2025-10-update-for-windows11-24h2/</id>
    <published>2025-10-15T16:16:15.000Z</published>
    <updated>2025-10-15T16:32:27.414Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>今天同事的 Windows 11 24H2 安裝完 Windows 更新重開機後，從 VS.NET 跑 IIS Express 起來後，<br>會發生 <strong>ERR_CONNECTION_RESET</strong> or <strong>hostname is invalid</strong> 的錯誤</p><h3 id="解法"><a href="#解法" class="headerlink" title="解法"></a>解法</h3><p>可以參考 <a href="https://stackoverflow.com/questions/79790827/localhost-applications-failing-after-installing-2025-10-cumulative-update-for-w">Localhost applications failing after installing “2025-10 Cumulative Update for Windows 11 Version 24H2 for x64-based Systems (KB5066835) (26100.6899)”</a> 解法如下:</p><ol><li>更新到 <strong>Windows 11 25H2</strong></li><li>修改機碼，將 <code>HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\HTTP\</code> 中新增 2 個機碼(<code>EnableHttp2Tls</code>及<code>EnableHttp2Cleartext</code>)，並設定<code>DWORD (32-bit) Value</code>值為<code>0</code> (未驗證)</li><li>延後更新，並移除 <code>KB5066835</code>, <code>KB5066131</code>, <code>KB5065789</code></li></ol><ul><li>註: 感謝同事 Henry, Simon &amp; Ryan 的幫忙</li></ul><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://stackoverflow.com/questions/79790827/localhost-applications-failing-after-installing-2025-10-cumulative-update-for-w">Localhost applications failing after installing “2025-10 Cumulative Update for Windows 11 Version 24H2 for x64-based Systems (KB5066835) (26100.6899)”</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;今天同事的 Windows 11 24H2 安裝完 Windows 更新重開機後，從 VS.NET 跑 IIS Express 起來後，&lt;b</summary>
      
    
    
    
    
    <category term="ERR_CONNECTION_RESET" scheme="https://rainmakerho.github.io/tags/ERR-CONNECTION-RESET/"/>
    
    <category term="400" scheme="https://rainmakerho.github.io/tags/400/"/>
    
    <category term="Windows 11" scheme="https://rainmakerho.github.io/tags/Windows-11/"/>
    
    <category term="24H2" scheme="https://rainmakerho.github.io/tags/24H2/"/>
    
    <category term="hostname is invalid" scheme="https://rainmakerho.github.io/tags/hostname-is-invalid/"/>
    
    <category term="KB5066835" scheme="https://rainmakerho.github.io/tags/KB5066835/"/>
    
    <category term="KB5066131" scheme="https://rainmakerho.github.io/tags/KB5066131/"/>
    
    <category term="KB5065789" scheme="https://rainmakerho.github.io/tags/KB5065789/"/>
    
  </entry>
  
  <entry>
    <title>.NET Build task failed unexpectedly. System.IO.DirectoryNotFoundException Could not find a part of the path</title>
    <link href="https://rainmakerho.github.io/2025/09/15/dotnet-build-directorynotfoundexception/"/>
    <id>https://rainmakerho.github.io/2025/09/15/dotnet-build-directorynotfoundexception/</id>
    <published>2025-09-15T05:48:05.000Z</published>
    <updated>2025-09-15T06:01:31.852Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>在 .NET Build 專案時，會出現以下的錯誤訊息:</p><blockquote><p>The “GenerateStaticWebAssetEndpointsPropsFile” task failed unexpectedly. System.IO.DirectoryNotFoundException: Could not find a part of the path</p></blockquote><h3 id="解法"><a href="#解法" class="headerlink" title="解法"></a>解法</h3><p>這通常是因為整個專案的路徑過長(超過 260 )，所以可以參考 <a href="https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?utm_source=chatgpt.com&tabs=registry">Maximum Path Length Limitation</a> 去<strong>Enable long paths</strong>，例如加 Windows 機碼的設定，如下:</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">New-ItemProperty</span> <span class="literal">-Path</span> <span class="string">&quot;HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem&quot;</span> <span class="literal">-Name</span> <span class="string">&quot;LongPathsEnabled&quot;</span> <span class="literal">-Value</span> <span class="number">1</span> <span class="literal">-PropertyType</span> DWORD <span class="literal">-Force</span></span><br></pre></td></tr></table></figure><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?utm_source=chatgpt.com&tabs=registry">Maximum Path Length Limitation</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;在 .NET Build 專案時，會出現以下的錯誤訊息:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The “GenerateStaticWe</summary>
      
    
    
    
    
    <category term=".NET" scheme="https://rainmakerho.github.io/tags/NET/"/>
    
    <category term="DirectoryNotFoundException" scheme="https://rainmakerho.github.io/tags/DirectoryNotFoundException/"/>
    
    <category term="long paths" scheme="https://rainmakerho.github.io/tags/long-paths/"/>
    
    <category term="260" scheme="https://rainmakerho.github.io/tags/260/"/>
    
    <category term="GenerateStaticWebAssetEndpointsPropsFile" scheme="https://rainmakerho.github.io/tags/GenerateStaticWebAssetEndpointsPropsFile/"/>
    
    <category term="路徑過長" scheme="https://rainmakerho.github.io/tags/%E8%B7%AF%E5%BE%91%E9%81%8E%E9%95%B7/"/>
    
  </entry>
  
  <entry>
    <title>Dapper 使用 DynamicParameters 被 Checkmarx 掃出 SQL Injection 的問題與解法</title>
    <link href="https://rainmakerho.github.io/2025/09/12/checkmarx-dapper-dynamicparameters-sqlinjection/"/>
    <id>https://rainmakerho.github.io/2025/09/12/checkmarx-dapper-dynamicparameters-sqlinjection/</id>
    <published>2025-09-12T06:28:17.000Z</published>
    <updated>2025-09-12T06:50:28.296Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>Checkmarx 升級到 V9.6.7.1005 HF20 後, 原本使用 Dapper 的程式居然被掃出有 <strong>SQL Injection</strong> 的風險。<br>程式以 Console 程式來說明，大約如下，</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">string</span> dbFile = <span class="string">&quot;mydb.sqlite&quot;</span>;</span><br><span class="line"><span class="built_in">string</span> connectionString = <span class="string">$&quot;Data Source=<span class="subst">&#123;dbFile&#125;</span>&quot;</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">var</span> connection = <span class="keyword">new</span> SqliteConnection(connectionString);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 查詢 table_1</span></span><br><span class="line"><span class="built_in">string</span> sql = <span class="string">&quot;SELECT Id, Name FROM table_1 WHERE Name=@name&quot;</span>;</span><br><span class="line"><span class="built_in">string</span> name = args[<span class="number">0</span>];</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> p1 = <span class="keyword">new</span> DynamicParameters();</span><br><span class="line">p1.Add(<span class="string">&quot;name&quot;</span>, name);</span><br><span class="line">IEnumerable&lt;Table1&gt; results = connection.Query&lt;Table1&gt;(sql, p1);</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">Table1</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="built_in">int</span> Id &#123; <span class="keyword">get</span>; <span class="keyword">set</span>; &#125;</span><br><span class="line">    <span class="keyword">public</span> <span class="built_in">string</span> Name &#123; <span class="keyword">get</span>; <span class="keyword">set</span>; &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在 <code>IEnumerable&lt;Table1&gt; results = connection.Query&lt;Table1&gt;(sql, p1);</code> 會被 Checkmarx 掃出有 <strong>SQL Injection</strong> 的風險。</p><h3 id="解法"><a href="#解法" class="headerlink" title="解法"></a>解法</h3><p>經過多方的測試，發現 Checkmarx 並不認得上述的那種做法，所以就改用匿名物件放在<strong>Query</strong>的第二個參數之中才會 Pass ，如下，</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">IEnumerable&lt;Table1&gt; results = connection.Query&lt;Table1&gt;(sql, <span class="keyword">new</span> &#123; name &#125;);</span><br></pre></td></tr></table></figure><ul><li>註: 感謝同事 unciax_wu 的幫忙</li></ul><h3 id="參考資訊"><a href="#參考資訊" class="headerlink" title="參考資訊"></a>參考資訊</h3><p><a href="https://www.learndapper.com/parameters">Using Parameters With Dapper</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;Checkmarx 升級到 V9.6.7.1005 HF20 後, 原本使用 Dapper 的程式居然被掃出有 &lt;strong&gt;SQL In</summary>
      
    
    
    
    
    <category term="Checkmarx" scheme="https://rainmakerho.github.io/tags/Checkmarx/"/>
    
    <category term="SQL Injection" scheme="https://rainmakerho.github.io/tags/SQL-Injection/"/>
    
    <category term="Dapper" scheme="https://rainmakerho.github.io/tags/Dapper/"/>
    
    <category term="DynamicParameters" scheme="https://rainmakerho.github.io/tags/DynamicParameters/"/>
    
    <category term="V9.6.7" scheme="https://rainmakerho.github.io/tags/V9-6-7/"/>
    
  </entry>
  
  <entry>
    <title>Teams Bot 發送訊息給 Teams User 回傳 401 錯誤解決方案</title>
    <link href="https://rainmakerho.github.io/2025/09/12/teams-bot-post-message-401/"/>
    <id>https://rainmakerho.github.io/2025/09/12/teams-bot-post-message-401/</id>
    <published>2025-09-12T01:25:01.000Z</published>
    <updated>2025-09-12T02:06:57.109Z</updated>
    
    <content type="html"><![CDATA[<h3 id="摘要"><a href="#摘要" class="headerlink" title="摘要"></a>摘要</h3><p>2025&#x2F;7&#x2F;31 後，Azure Bot 不再支援 Multi Tenant App，導致 Teams Bot 發送訊息時可能回傳 401 Unauthorized 錯誤。本文說明原因與解決方法。</p><h3 id="問題描述"><a href="#問題描述" class="headerlink" title="問題描述"></a>問題描述</h3><p>最近在建立 Teams Bot 後，透 Bot App 發送訊息給使用者時，會發生 <strong>401 Unauthorized</strong> 的錯誤。錯誤訊息如下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Request failed with status code 401</span><br></pre></td></tr></table></figure><h3 id="環境說明"><a href="#環境說明" class="headerlink" title="環境說明"></a>環境說明</h3><p>Teams Bot 是透過在 Portal Microsoft Entra ID 中的 App 身份來發送訊息，<br>以往我們都是先在 App registrations 中註冊 App (設定為 <strong>Multitenant</strong>)，<br>然後在<strong>Azure Bot</strong>中設定<strong>Microsoft App ID</strong>，<br>在裡面的<strong>Type of App</strong>也一併設定成<strong>Multi Tenant</strong>，<br>再選擇前面建立的 App。</p><h3 id="問題分析"><a href="#問題分析" class="headerlink" title="問題分析"></a>問題分析</h3><p>最近在<strong>Azure Bot</strong>中的<strong>Type of App</strong>卻只剩下<strong>Single Tenant</strong>及<strong>User-Assigned Managed Identity</strong>，<br><strong>Multi Tenant</strong>不見了!!! 如下圖:</p><img src="/2025/09/12/teams-bot-post-message-401/01.png" class="" title="Type of App"><p>而在<a href="https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration?view=azure-bot-service-4.0&viewFallbackFrom=azure-bot-service-3.0&tabs=userassigned">Register a bot with Azure</a>中有備註</p><blockquote><p>Multi-tenant bot 在 2025&#x2F;7&#x2F;31 後就不能用了，如果在這之前建立的一樣可以用，但在 2025&#x2F;7&#x2F;31 後就不能用了~~</p></blockquote><p><strong>Multi Tenant App</strong>它取得 Token 的 URL 是 <code>https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token</code>，<br><strong>Single Tenant App</strong>它取得 Token 的 URL 是 <code>https://login.microsoftonline.com/$&#123;tenantId&#125;/oauth2/v2.0/token</code></p><p>所以拿 <strong>Multi Tenant</strong>的 Token 去送訊息自然就會驗證錯誤，然後回<strong>401</strong>的錯誤。</p><p>所以<strong>2025&#x2F;7&#x2F;31</strong>之後 App 就建立要建立為<strong>Single Tenant</strong>哦~<br>目前發現，不管是 Single Tenant or Multi Tenant ，只要是<strong>Single Tenant</strong>的 Token 就可以順利發送訊息。</p><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>2025&#x2F;7&#x2F;31 後，Azure Bot 必須使用 Single Tenant App。遇到 401 錯誤時，請檢查 Token 是否為 Single Tenant 的 Token。</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration?view=azure-bot-service-4.0&viewFallbackFrom=azure-bot-service-3.0&tabs=userassigned">Register a bot with Azure</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;摘要&quot;&gt;&lt;a href=&quot;#摘要&quot; class=&quot;headerlink&quot; title=&quot;摘要&quot;&gt;&lt;/a&gt;摘要&lt;/h3&gt;&lt;p&gt;2025&amp;#x2F;7&amp;#x2F;31 後，Azure Bot 不再支援 Multi Tenant App，導致 Teams Bot 發送訊</summary>
      
    
    
    
    
    <category term="Unauthorized" scheme="https://rainmakerho.github.io/tags/Unauthorized/"/>
    
    <category term="Teams" scheme="https://rainmakerho.github.io/tags/Teams/"/>
    
    <category term="Bot" scheme="https://rainmakerho.github.io/tags/Bot/"/>
    
    <category term="API" scheme="https://rainmakerho.github.io/tags/API/"/>
    
    <category term="Azure Bot" scheme="https://rainmakerho.github.io/tags/Azure-Bot/"/>
    
    <category term="Single Tenant" scheme="https://rainmakerho.github.io/tags/Single-Tenant/"/>
    
    <category term="401" scheme="https://rainmakerho.github.io/tags/401/"/>
    
    <category term="Multi Tenant" scheme="https://rainmakerho.github.io/tags/Multi-Tenant/"/>
    
    <category term="Microsoft Entra ID" scheme="https://rainmakerho.github.io/tags/Microsoft-Entra-ID/"/>
    
    <category term="Token" scheme="https://rainmakerho.github.io/tags/Token/"/>
    
  </entry>
  
  <entry>
    <title>使用 C# 和 Semantic Kernel 打造 AI 應用：第二章 - 深入 Plugins</title>
    <link href="https://rainmakerho.github.io/2025/09/02/semantic-kernel-plugins-csharp/"/>
    <id>https://rainmakerho.github.io/2025/09/02/semantic-kernel-plugins-csharp/</id>
    <published>2025-09-02T00:44:21.000Z</published>
    <updated>2025-09-02T00:58:21.152Z</updated>
    
    <content type="html"><![CDATA[<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>在上一章中，我們初探了 Semantic Kernel 的核心概念，並學習如何透過 Kernel 輕鬆地將 AI 服務整合到你的 C# 應用程式中。我們看到了 AI 基礎能力的強大，也理解到單靠 LLM 本身的知識庫仍有其限制。</p><p>這個限制，正是本章將要解決的核心問題。</p><p>想像一下，如果 AI 不僅能回答你提出的問題，還能執行更複雜的任務，例如：查詢即時的天氣、從資料庫中提取特定資訊，或是自動發送一封電子郵件。這將大大提升 AI 的應用潛力。</p><p>Semantic Kernel 提供了強大的 Plugins（插件） 機制，就像為你的 AI 助理裝備了一整個「工具箱」，讓它不再只是個知識淵博的大腦，更能動手執行任務，成為能解決現實問題的超能力者。<br>本章，我們將深入探索 Plugins 的世界，從最基礎的內建函數 (Native Functions) 開始，手把手帶你學習如何創建和使用自己的工具，並進一步了解如何透過檔案式提示 (File-based Prompts)，將 AI 的能力提升到一個全新的層次。</p><h3 id="Using-a-Function-with-a-Complex-Type-Parameter-and-Return-Type"><a href="#Using-a-Function-with-a-Complex-Type-Parameter-and-Return-Type" class="headerlink" title="Using a Function with a Complex Type Parameter and Return Type"></a>Using a Function with a Complex Type Parameter and Return Type</h3><p>以下我們就來建立一個包含地點及日期的參數，做為查詢天氣預報的參數，試看看 LLM 是否能順利地從使用者的問題中，取出合適的參數來呼叫天氣預報的 Function ，如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.ComponentModel;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">WeatherPlugin</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">readonly</span> <span class="built_in">string</span>[] Conditions = &#123; <span class="string">&quot;晴天&quot;</span>,<span class="string">&quot;多雲&quot;</span>,<span class="string">&quot;下雨&quot;</span>, <span class="string">&quot;豪雨&quot;</span> &#125;;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">readonly</span> Random Random = <span class="keyword">new</span> Random();</span><br><span class="line"></span><br><span class="line"></span><br><span class="line">    [<span class="meta">KernelFunction</span>]</span><br><span class="line">    [<span class="meta">Description(<span class="string">&quot;取得特定日期及特定地點的天氣預測&quot;</span>)</span>]</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="built_in">string</span> <span class="title">GetWeatherForecastForLocationAndDate</span>(<span class="params">[Description(<span class="string">&quot;取得特定日期及特定地點的天氣預測的參數&quot;</span></span>)] WeatherRequest weatherRequest)</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">var</span> condition = Conditions[Random.Next(Conditions.Length)];</span><br><span class="line">        <span class="keyword">var</span> highTemp = Random.Next(<span class="number">0</span>, <span class="number">40</span>);</span><br><span class="line">        <span class="keyword">var</span> lowTemp = Random.Next(<span class="number">10</span>, <span class="number">20</span>);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> <span class="string">$&quot;在 <span class="subst">&#123;weatherRequest.Date.ToShortDateString()&#125;</span>，<span class="subst">&#123;weatherRequest.Location&#125;</span> 的天氣預測是 <span class="subst">&#123;condition&#125;</span>，氣溫方面最高 <span class="subst">&#123;highTemp&#125;</span>°F，最低 <span class="subst">&#123;lowTemp&#125;</span>°F。&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">WeatherRequest</span></span><br><span class="line">&#123;</span><br><span class="line">    [<span class="meta">Description(<span class="string">&quot;查詢天氣的地點&quot;</span>)</span>]</span><br><span class="line">    <span class="keyword">public</span> <span class="built_in">string</span> Location &#123; <span class="keyword">get</span>; <span class="keyword">set</span>; &#125;</span><br><span class="line"></span><br><span class="line">    [<span class="meta">Description(<span class="string">&quot;查詢天氣的日期&quot;</span>)</span>]</span><br><span class="line">    <span class="keyword">public</span> DateTime Date &#123; <span class="keyword">get</span>; <span class="keyword">set</span>; &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>然後在 Kernel 中註冊這個 Plugin 後，詢問<code>今2024/9/1 在台中的天氣如何?</code>，如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line"><span class="comment">// 註冊 plugin</span></span><br><span class="line">kernel.ImportPluginFromType&lt;WeatherPlugin&gt;();</span><br><span class="line">OpenAIPromptExecutionSettings settings = <span class="keyword">new</span>() &#123; ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions &#125;;</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line"><span class="comment">// 直接輸出結果</span></span><br><span class="line">Console.WriteLine(<span class="keyword">await</span> kernel.InvokePromptAsync(<span class="string">&quot;今2024/9/1 在台中的天氣如何?&quot;</span>, <span class="keyword">new</span>(settings)));</span><br></pre></td></tr></table></figure><p>在 <code>GetWeatherForecastForLocationAndDate</code> Method 設定中斷點，可以看到 LLM 從使用者的問題中取出<strong>地點</strong>及<strong>日期</strong>組成<code>WeatherRequest</code>參數傳到 Method 之中，如下圖:</p><img src="/2025/09/02/semantic-kernel-plugins-csharp/01.png" class="" title="Complex Type"><h3 id="Built-in-Plugins"><a href="#Built-in-Plugins" class="headerlink" title="Built-in Plugins"></a>Built-in Plugins</h3><p>Semantic kernel 核心的 Plugin 包含<code>Time plugin</code>、<code>HTTP plugin</code>、<code>FileIO plugin</code>、<code>ConversationSummary plugin</code>及<code>Text plugin</code>等等，<br>想知道更多，可以查看 <a href="https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Plugins">Semantic kernel Plugins</a></p><p>1.加入 <code>Microsoft.SemanticKernel.Plugins.Core</code> Nuget 套件(preview)</p><p>2.使用<code>ConversationSummary plugin</code>， 整理一下對話內容，如下，</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel.Plugins.Core;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line"><span class="comment">// 註冊 plugin</span></span><br><span class="line">kernel.ImportPluginFromType&lt;ConversationSummaryPlugin&gt;();</span><br><span class="line">OpenAIPromptExecutionSettings settings = <span class="keyword">new</span>() &#123; ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions &#125;;</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line"><span class="keyword">var</span> chatTranscript = <span class="string">&quot;&quot;</span><span class="string">&quot;</span></span><br><span class="line"><span class="string">George：Mary，我在想我們是不是應該跟銀行借點錢來擴大生意。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">Mary：嗯，我也有這個想法。不過你打算借多少？</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">George：大概新台幣兩百萬，主要是要添購新的設備。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">Mary：兩百萬不算小數目，你覺得我們的還款能力足夠嗎？</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">George：以目前的營收，加上新設備帶來的產能，應該能在五年內還清。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">Mary：那利率部分你有查過嗎？現在銀行大概是年利率 2.5% 到 3%。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">George：我看過一些資料，如果有公司財報跟資產擔保，應該可以談到 2.2% 左右。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">Mary：聽起來不錯，但我們還需要準備好貸款計畫書，銀行才會比較容易審核。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">George：對，我打算先整理最近三年的財務報表，還有未來三年的營運計畫。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">Mary：好，那我來準備市場分析的部分，讓銀行看到我們成長的潛力。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">George：太好了！等資料都準備好，我們就一起去銀行洽談吧。</span></span><br><span class="line"><span class="string">&quot;</span><span class="string">&quot;&quot;</span>;</span><br><span class="line">Console.WriteLine(<span class="keyword">await</span> kernel.InvokePromptAsync(<span class="string">$&quot;請總結以下的對話內容，<span class="subst">&#123;chatTranscript&#125;</span>&quot;</span>, <span class="keyword">new</span>(settings)));</span><br></pre></td></tr></table></figure><p>輸出結果如下:</p><img src="/2025/09/02/semantic-kernel-plugins-csharp/02.png" class="" title="ConversationSummary"><h3 id="File-based-Prompt-Functions-prompt-plugin"><a href="#File-based-Prompt-Functions-prompt-plugin" class="headerlink" title="File-based Prompt Functions(prompt plugin)"></a>File-based Prompt Functions(prompt plugin)</h3><p>prompt plugin 包含 <strong>skprompt.txt</strong> 及 <strong>config.json</strong><br>skprompt.txt: prompt 的內容(可包含變數)<br>config.json: prompt 的描述、執行的設定及變數的說明<br>以下使用人民陳情公文生成的例子來測試，找到相似的公文來生成來依使用者的陳情內容來生成新的公文，如下: 1.建立<code>Prompts\ComposeGovDoc</code>目錄</p><p>2.在目錄中新增<code>skprompt.txt</code></p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br></pre></td><td class="code"><pre><span class="line">你是台灣政府機關的公文撰擬專家，需根據人民陳情與過往相似公文，產出正式可用的「&#123;&#123;$doc_type&#125;&#125;」。</span><br><span class="line"></span><br><span class="line">【輸入資料】</span><br><span class="line">- 陳情內容：</span><br><span class="line">&#123;&#123;$user_complaint&#125;&#125;</span><br><span class="line"></span><br><span class="line">- 檢索到的相似公文片段（供參考，避免照抄，需改寫與去個資）：</span><br><span class="line">&#123;&#123;$similar_examples&#125;&#125;</span><br><span class="line"></span><br><span class="line">【寫作規範】</span><br><span class="line">1. 採用台灣公文常用結構：「主旨／說明／辦法」。</span><br><span class="line">2. 用語正式、精簡、條列清楚；避免口語與情緒性文字。</span><br><span class="line">3. 優先參考相似公文的處理邏輯，但需以本案事實改寫；不可捏造未提供的事證。</span><br><span class="line">4. 有缺資料時，以「請查明…」、「請會同…」等措辭引導，勿編造細節。</span><br><span class="line">5. 保護個資：姓名、電話、住址等以「[個資已遮蔽]」表述。</span><br><span class="line">6. 若涉及工務、照明、交通等，務必指明承辦單位（如：&#123;&#123;$target_agency&#125;&#125;）與回覆時程（例如：&#123;&#123;$deadline_days&#125;&#125;日內）。</span><br><span class="line">7. 產出兩個部分：</span><br><span class="line">   A) JSON（機器可讀結構）</span><br><span class="line">   B) 正式正文（人工可讀）</span><br><span class="line"></span><br><span class="line">【JSON 輸出格式】（請只輸出有效 JSON 物件，不要加註解）</span><br><span class="line">&#123;</span><br><span class="line">  &quot;doc_type&quot;: &quot;&#123;&#123;$doc_type&#125;&#125;&quot;,</span><br><span class="line">  &quot;subject&quot;: &quot;…（一句話說明主旨）&quot;,</span><br><span class="line">  &quot;facts&quot;: [</span><br><span class="line">    &quot;…（歸納已知事實1）&quot;,</span><br><span class="line">    &quot;…（歸納已知事實2）&quot;</span><br><span class="line">  ],</span><br><span class="line">  &quot;actions&quot;: [</span><br><span class="line">    &quot;…（應辦事項1）&quot;,</span><br><span class="line">    &quot;…（應辦事項2）&quot;</span><br><span class="line">  ],</span><br><span class="line">  &quot;target_agency&quot;: &quot;&#123;&#123;$target_agency&#125;&#125;&quot;,</span><br><span class="line">  &quot;deadline_days&quot;: &#123;&#123;$deadline_days&#125;&#125;,</span><br><span class="line">  &quot;legal_basis&quot;: [&quot;…（如有法源依據，無則留空陣列）&quot;]</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">【正文輸出格式】</span><br><span class="line">主旨：……。</span><br><span class="line">說明：</span><br><span class="line">一、……。</span><br><span class="line">二、……。</span><br><span class="line">辦法：</span><br><span class="line">一、請&#123;&#123;$target_agency&#125;&#125;……。</span><br><span class="line">二、請於&#123;&#123;$deadline_days&#125;&#125;日內將辦理情形回復並副知本府。</span><br><span class="line"></span><br><span class="line">【產出】</span><br><span class="line">請先輸出 JSON，緊接著輸出「正文」。兩者中間空一行。</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>3.在目錄中新增<code>config.json</code></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;schema&quot;</span><span class="punctuation">:</span> <span class="number">1</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;依人民陳情內容與相似公文，擬出正式可用的台灣公文。&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;execution_settings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;default&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;max_tokens&quot;</span><span class="punctuation">:</span> <span class="number">1024</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;temperature&quot;</span><span class="punctuation">:</span> <span class="number">0.3</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">&quot;input_variables&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;user_complaint&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;人民陳情的原始文字&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;default&quot;</span><span class="punctuation">:</span> <span class="string">&quot;&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;similar_examples&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;相似公文片段&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;default&quot;</span><span class="punctuation">:</span> <span class="string">&quot;&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;doc_type&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;公文文別&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;default&quot;</span><span class="punctuation">:</span> <span class="string">&quot;函&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;target_agency&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;承辦單位&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;default&quot;</span><span class="punctuation">:</span> <span class="string">&quot;&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;deadline_days&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;回覆期限天數&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;default&quot;</span><span class="punctuation">:</span> <span class="string">&quot;14&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>4.設定<code>skprompt.txt</code>及<code>config.json</code> Copy 到 Output 目錄</p><p>接下來，就來看看程式如何使用<strong>prompt plugin</strong>, 如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel.Plugins.Core;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line">OpenAIPromptExecutionSettings settings = <span class="keyword">new</span>() &#123; ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions &#125;;</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 註冊 plugin</span></span><br><span class="line"><span class="keyword">var</span> prompts = kernel.CreatePluginFromPromptDirectory(<span class="string">&quot;Prompts&quot;</span>);</span><br><span class="line"><span class="keyword">var</span> fun = prompts[<span class="string">&quot;ComposeGovDoc&quot;</span>];</span><br><span class="line"><span class="comment">// 民眾陳情內容</span></span><br><span class="line"><span class="keyword">var</span> userMessage = <span class="string">@&quot;陳情人:陳芊城陳述，在114年7月1日，在台北市林森北路52號巷口路燈不亮&quot;</span>;</span><br><span class="line"><span class="comment">// 透過 RAG 取出相似的公文內容</span></span><br><span class="line"><span class="keyword">var</span> ragResult = <span class="string">@&quot;主旨：關於民眾反映里內路燈故障一案，請查照。</span></span><br><span class="line"><span class="string">說明：</span></span><br><span class="line"><span class="string">一、依據民眾於113年9月1日陳情台北市承德路三段199號巷口路燈不亮。</span></span><br><span class="line"><span class="string">二、經查現場路燈編號A12、A13不亮。</span></span><br><span class="line"><span class="string">辦法：請工務課儘速派員檢修，完成後回復。&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> arguments = <span class="keyword">new</span> KernelArguments</span><br><span class="line">&#123;</span><br><span class="line">    [<span class="string">&quot;user_complaint&quot;</span>] = userMessage,</span><br><span class="line">    [<span class="string">&quot;similar_examples&quot;</span>] = ragResult,</span><br><span class="line">    [<span class="string">&quot;doc_type&quot;</span>] = <span class="string">&quot;函&quot;</span>,</span><br><span class="line">    [<span class="string">&quot;target_agency&quot;</span>] = <span class="string">&quot;工務課&quot;</span>,</span><br><span class="line">    [<span class="string">&quot;deadline_days&quot;</span>] = <span class="string">&quot;14&quot;</span></span><br><span class="line">&#125;;</span><br><span class="line"><span class="comment">// 呼叫並傳入參數</span></span><br><span class="line"><span class="keyword">var</span> result = <span class="keyword">await</span> kernel.InvokeAsync(fun,  arguments);</span><br><span class="line">Console.WriteLine(result);</span><br></pre></td></tr></table></figure><p>輸出結果如下:</p><img src="/2025/09/02/semantic-kernel-plugins-csharp/03.png" class="" title="File-based Prompt"><h3 id="Filters"><a href="#Filters" class="headerlink" title="Filters"></a>Filters</h3><p><a href="https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/filters?pivots=programming-language-csharp">Semantic Kernel Filters</a>提供三種類型:<br>Function Invocation Filter: 每次呼叫 <strong>KernelFunction</strong> 時，都會執行此過濾器。<br>Prompt Render Filter: 在將 prompt 給 AI 前，會執行此過濾器。<br>Auto Function Invocation Filter: 與<code>Function Invocation Filter</code>類似，提供額外的上下文信息(包含聊天歷史記錄、所有待執行函數的列表以及迭代計數器)。它還允許終止自動函數呼叫過程(<code>context.Terminate = true;</code>)<br>接下來使用<code>Function Invocation Filter</code>的程式碼會模擬一個對話流程，當 AI 試圖呼叫 ChangePrice 函式時，我們的 ApprovalFilter 會被觸發，並詢問使用者是否同意執行。<br>修改價格的 Plugin 及 Filter 如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.ComponentModel;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">BookManagementPlugin</span></span><br><span class="line">&#123;</span><br><span class="line">    [<span class="meta">KernelFunction</span>]</span><br><span class="line">    [<span class="meta">Description(<span class="string">&quot;Change the price of a book in the database &quot;</span>)</span>]</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">ChangePrice</span>(<span class="params">[Description(<span class="string">&quot;The book id to update&quot;</span></span>)] <span class="built_in">int</span> bookId, [<span class="title">Description</span>(<span class="params"><span class="string">&quot;The new price&quot;</span></span>)] <span class="built_in">int</span> newPrice)</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="comment">//update the price of the book in the database</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">ApprovalFilter</span>() : IFunctionInvocationFilter</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">async</span> Task <span class="title">OnFunctionInvocationAsync</span>(<span class="params">FunctionInvocationContext context, Func&lt;FunctionInvocationContext, Task&gt; next</span>)</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">if</span> (context.Function.PluginName == <span class="string">&quot;BookManagementPlugin&quot;</span> &amp;&amp; context.Function.Name == <span class="string">&quot;ChangePrice&quot;</span>)</span><br><span class="line">        &#123;</span><br><span class="line"></span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;系統想要更新書本的價格，您要繼續嗎？ （Y/N）&quot;</span>);</span><br><span class="line">            <span class="built_in">string</span> shouldProceed = Console.ReadLine()!;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (shouldProceed != <span class="string">&quot;Y&quot;</span>)</span><br><span class="line">            &#123;</span><br><span class="line">                context.Result = <span class="keyword">new</span> FunctionResult(context.Result, <span class="string">&quot;價格變動未獲得批准&quot;</span>);</span><br><span class="line">                <span class="keyword">return</span>;</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">await</span> next(context);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Console 程式如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel.Plugins.Core;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 註冊 Plugin</span></span><br><span class="line">kernelBuilder.Plugins.AddFromType&lt;BookManagementPlugin&gt;();</span><br><span class="line"><span class="comment">// 加入 ApprovalFilter</span></span><br><span class="line">kernelBuilder.Services.AddSingleton&lt;IFunctionInvocationFilter, ApprovalFilter&gt;();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line">OpenAIPromptExecutionSettings settings = <span class="keyword">new</span>() &#123; ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions &#125;;</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line"><span class="keyword">var</span> chatCompletionService = kernel.GetRequiredService&lt;IChatCompletionService&gt;();</span><br><span class="line">ChatHistory chatHistory = <span class="keyword">new</span>();</span><br><span class="line"><span class="built_in">string</span> userMessage = <span class="built_in">string</span>.Empty;</span><br><span class="line"><span class="keyword">while</span> (userMessage != <span class="string">&quot;quit&quot;</span>)</span><br><span class="line">&#123;</span><br><span class="line">    Console.WriteLine(<span class="string">&quot;Enter your question:&quot;</span>);</span><br><span class="line">    userMessage = Console.ReadLine();</span><br><span class="line">    chatHistory.AddUserMessage(userMessage);</span><br><span class="line">    <span class="keyword">var</span> assistantMessage = <span class="keyword">await</span> chatCompletionService.GetChatMessageContentAsync(chatHistory, settings, kernel);</span><br><span class="line">    Console.WriteLine(assistantMessage);</span><br><span class="line">    chatHistory.Add(assistantMessage);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>執行過程如下:</p><img src="/2025/09/02/semantic-kernel-plugins-csharp/04.png" class="" title="IFunctionInvocationFilter"><h3 id="Using-OpenAPI-Plugins"><a href="#Using-OpenAPI-Plugins" class="headerlink" title="Using OpenAPI Plugins"></a>Using OpenAPI Plugins</h3><p>企業可能有寫好的 API，只要有 OpenAPI 規格檔，用於描述其 API 的功能、參數、驗證方式等細節。Semantic Kernel 可以直接讀取這些規格檔，自動生成對應的函數，省下額外開發 AI Plugin 的時間。<br>以下以預設天氣 API 來測試， 1.加入 <code>Microsoft.SemanticKernel.Plugins.OpenApi</code> Nuget 套件 2.使用 <code>kernel.ImportPluginFromOpenApiAsync</code> 匯入 API 3.詢問 <code>今天天氣的如何</code> 就會呼叫 <code>GetWeatherForecast</code></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel.Plugins.Core;</span><br><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel.Plugins.OpenApi;</span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line"><span class="comment">// 註冊 OpenAPI Plugin</span></span><br><span class="line"><span class="keyword">await</span> kernel.ImportPluginFromOpenApiAsync(</span><br><span class="line">     pluginName: <span class="string">&quot;weatherforecast&quot;</span>,</span><br><span class="line">     uri: <span class="keyword">new</span> Uri(<span class="string">&quot;http://localhost:5129/openapi/v1.json&quot;</span>),</span><br><span class="line">               executionParameters: <span class="keyword">new</span> OpenApiFunctionExecutionParameters()</span><br><span class="line">               &#123;</span><br><span class="line">                   EnablePayloadNamespacing = <span class="literal">true</span></span><br><span class="line">               &#125;</span><br><span class="line">    );</span><br><span class="line">OpenAIPromptExecutionSettings settings = <span class="keyword">new</span>() &#123; ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions &#125;;</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.WriteLine(<span class="keyword">await</span> kernel.InvokePromptAsync(<span class="string">&quot;今天天氣的如何&quot;</span>, <span class="keyword">new</span>(settings)));</span><br><span class="line">Console.ReadLine();</span><br></pre></td></tr></table></figure><p>結果會輸出類似的結果 <code>根據今天的天氣預報，今天的氣溫約為23°C，天氣較冷。請注意保暖。</code></p><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>在本章中，我們深入探索了 Semantic Kernel 的核心能力之一：Plugins。</p><p>我們從實例中見證了 Plugins 如何突破大型語言模型 (LLM) 的資訊限制，讓 AI 不僅能回答問題，更能與外部世界互動。無論是處理內建的複雜型別參數、利用現成的內建 Plugins，或是透過 File-based Prompts 來定義客製化的行為，Semantic Kernel 都提供了一套簡潔而強大的框架。此外，我們也了解了如何使用 Filters 來為 AI 流程加入額外的邏輯控制，以及如何將既有的 OpenAPI 規格快速轉化為可用的 Plugins，大幅提升開發效率。</p><p>透過這些功能，Semantic Kernel 賦予了我們為 LLM 打造專屬工具的能力，使 AI 應用程式變得更加智慧、靈活且具備處理現實世界任務的能力。<br>在下一章中，我們將深入探討 Semantic Kernel 如何讓你輕鬆地在不同的 LLM 服務提供者（例如 OpenAI、Azure OpenAI 等）之間切換，而無需修改核心程式碼。</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://github.com/microsoft/semantic-kernel">Semantic Kernel</a><br><a href="https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/filters?pivots=programming-language-csharp">Semantic Kernel Filters</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h3&gt;&lt;p&gt;在上一章中，我們初探了 Semantic Kernel 的核心概念，並學習如何透過 Kernel 輕鬆地將 AI 服務整合到你的 C# 應用</summary>
      
    
    
    
    
    <category term="C#" scheme="https://rainmakerho.github.io/tags/C/"/>
    
    <category term=".NET" scheme="https://rainmakerho.github.io/tags/NET/"/>
    
    <category term="Semantic Kernel" scheme="https://rainmakerho.github.io/tags/Semantic-Kernel/"/>
    
    <category term="LLM" scheme="https://rainmakerho.github.io/tags/LLM/"/>
    
    <category term="人工智慧" scheme="https://rainmakerho.github.io/tags/%E4%BA%BA%E5%B7%A5%E6%99%BA%E6%85%A7/"/>
    
    <category term="Semantic Kernel Plugins" scheme="https://rainmakerho.github.io/tags/Semantic-Kernel-Plugins/"/>
    
    <category term="AI Application" scheme="https://rainmakerho.github.io/tags/AI-Application/"/>
    
    <category term="Native Functions" scheme="https://rainmakerho.github.io/tags/Native-Functions/"/>
    
    <category term="File-based Prompts" scheme="https://rainmakerho.github.io/tags/File-based-Prompts/"/>
    
    <category term="OpenAPI" scheme="https://rainmakerho.github.io/tags/OpenAPI/"/>
    
    <category term="Filters" scheme="https://rainmakerho.github.io/tags/Filters/"/>
    
    <category term="工具呼叫" scheme="https://rainmakerho.github.io/tags/%E5%B7%A5%E5%85%B7%E5%91%BC%E5%8F%AB/"/>
    
    <category term="插件開發" scheme="https://rainmakerho.github.io/tags/%E6%8F%92%E4%BB%B6%E9%96%8B%E7%99%BC/"/>
    
  </entry>
  
  <entry>
    <title>使用 C# 和 Semantic Kernel 打造 AI 應用：第一章 - 核心概念與 Plugins 介紹</title>
    <link href="https://rainmakerho.github.io/2025/08/31/using-csharp-semantic-kernel-build-ai-apps-chapter-1-concepts-plugins/"/>
    <id>https://rainmakerho.github.io/2025/08/31/using-csharp-semantic-kernel-build-ai-apps-chapter-1-concepts-plugins/</id>
    <published>2025-08-31T12:23:45.000Z</published>
    <updated>2025-08-31T12:40:59.987Z</updated>
    
    <content type="html"><![CDATA[<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>隨著大型語言模型 (LLM) 的崛起，人工智慧已不再是遙不可及的技術，而是正在深刻地影響著每個企業與工作流程。若想在 AI 浪潮中保持領先，最有效的方式就是將其應用於日常業務，實現流程自動化。</p><p>想像一下，過去需要耗費大量人力進行的客戶資料分類工作，比如綜合比對客戶的產業別、上市櫃資訊、營運項目，甚至是網路搜尋結果，現在都能藉由 AI 輕鬆完成。你只需上傳一份 Excel 文件，AI 便能自動處理大部分分類工作，員工只需專注於處理少數 AI 無法判斷的例外情況，大幅提升效率。</p><p>要將 AI 整合到現有應用程式 (AP) 中，我們可以透過多種方式，例如呼叫 AI 工具的 API，但當需求更複雜、需要更精確地控制整個過程時，直接呼叫 LLM 的 API 成了更好的選擇。<br>不過，這也帶來了新的挑戰：不同的 LLM 提供者 (如 OpenAI、Azure OpenAI) 有各自的 API 規範，開發者需要處理各種不同的參數設定、端點 (Endpoint) 和模型名稱 (Model Name)。<br>這種差異性不僅增加開發的複雜度，也讓未來切換或擴充 LLM 服務變得困難。</p><p>這正是 <a href="https://github.com/microsoft/semantic-kernel">Semantic Kernel</a> 派上用場的時候。作為一個由微軟開源的 SDK，Semantic Kernel 提供了一套統一且強大的框架，能夠協助開發者輕鬆、靈活地管理與 LLM 的互動。<br>它不僅簡化了與不同 LLM 服務的整合過程，更讓開發者能專注於打造具備「語義」理解能力的應用程式，而不用被底層技術細節所困擾。</p><p>在這個系列文章中，我們將深入探索如何使用 C# 和 Semantic Kernel，一步步構建具備 AI 智慧的應用。<br>我們將從基礎概念開始，逐步實作各種功能，帶你親身體驗 Semantic Kernel 如何幫助你輕鬆將 AI 能力整合至你的應用程式中。</p><h3 id="Semantic-Kernel"><a href="#Semantic-Kernel" class="headerlink" title="Semantic Kernel"></a>Semantic Kernel</h3><p>Semantic Kernel 是一個可擴充的輕量級的 .NET AI SDK，目標是讓 AP 可以輕易地與 AI 整合。<br>提供一個統一的介面，讓開發者可以用相同的方式去使用不同的 AI 服務(text generation, image geration, chat…)，而不用在意每個服務的細節差異。<br>要快速掌握 Semantic Kernel 的核心，可以從它的六個主要元件開始理解：Kernel、AI Service Connectors、Functions and Plugins、Prompts and Prompt Templates、Memory 和 Filters。<br>以下我們建立 Console 程式來看看如何使用這些元件，</p><p>1.建立 Console App</p><p>2.加入 <code>Microsoft.SemanticKernel</code> Nuget 套件</p><p>3.準備好 OpenAI(或 AOAI, …) 的 API key</p><h3 id="Kernel-amp-AI-Service-Connectors"><a href="#Kernel-amp-AI-Service-Connectors" class="headerlink" title="Kernel &amp; AI Service Connectors"></a>Kernel &amp; AI Service Connectors</h3><p>在 Semantic Kernel 的世界裡，Kernel 扮演著核心中樞的角色。它不只負責串接應用程式和 AI 模型，更是所有 AI 服務與插件的協調者。想像它是一個大型工具箱，裡面裝滿了各式各樣的 AI 功能；Kernel 的任務就是確保這些工具隨時可用，讓開發者能隨心所欲地取用。<br>以下透過 <code>IKernelBuilder</code> 來建立 <code>Kernel</code> 後，讓使用者輸入訊息來與 LLM 對話，如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line"><span class="built_in">string</span> userMessage = <span class="built_in">string</span>.Empty;</span><br><span class="line"><span class="comment">// 讓使用者輸入訊息來與 LLM 對話</span></span><br><span class="line"><span class="keyword">while</span>(userMessage != <span class="string">&quot;quit&quot;</span>)</span><br><span class="line">&#123;</span><br><span class="line">    Console.WriteLine(<span class="string">&quot;Enter you message:&quot;</span>);</span><br><span class="line">    userMessage = Console.ReadLine();</span><br><span class="line">    Console.WriteLine(<span class="keyword">await</span> kernel.InvokePromptAsync(userMessage));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><img src="/2025/08/31/using-csharp-semantic-kernel-build-ai-apps-chapter-1-concepts-plugins/01.png" class="" title="kernel.InvokePromptAsync"><p>接下來，讓使用者輸入訊息來產生圖片，如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel.TextToImage;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> imageModelId = <span class="string">&quot;dall-e-3&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI Text to image 服務</span></span><br><span class="line"><span class="meta">#<span class="keyword">pragma</span> <span class="keyword">warning</span> disable SKEXP0010, SKEXP0001</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAITextToImage(modelId: imageModelId, apiKey: apikey)</span><br><span class="line">            .Build();</span><br><span class="line"><span class="comment">// 取得 TextToImage Service</span></span><br><span class="line">ITextToImageService imageService = kernel.GetRequiredService&lt;ITextToImageService&gt;();</span><br><span class="line"><span class="built_in">string</span> prompt =</span><br><span class="line"><span class="string">&quot;&quot;</span><span class="string">&quot;</span></span><br><span class="line"><span class="string">創建一幅逼真的圖片，描繪RM的咖啡店。這家店擁有迷人的鄉村外觀，紅磚外牆、大型玻璃窗，和在入口上方懸掛的經典木製招牌，</span></span><br><span class="line"><span class="string">上面用優雅的手繪字體寫著「RM&#x27;s Coffee Shop」。入口處有一道復古風格的木製門，上面有一個小鈴鐺，以及門外顯示今日特價的黑板招牌，</span></span><br><span class="line"><span class="string">包括「柚香拿鐵」、「櫻桃咖啡」和「南瓜咖啡」。</span></span><br><span class="line"><span class="string">&quot;</span><span class="string">&quot;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> image = <span class="keyword">await</span> imageService.GenerateImageAsync(prompt, <span class="number">1792</span>, <span class="number">1024</span>);</span><br><span class="line">Console.WriteLine(<span class="string">&quot;Image URL: &quot;</span> + image);</span><br></pre></td></tr></table></figure><img src="/2025/08/31/using-csharp-semantic-kernel-build-ai-apps-chapter-1-concepts-plugins/02.png" class="" title="GenerateImageAsync"><h3 id="Functions-and-Plugins"><a href="#Functions-and-Plugins" class="headerlink" title="Functions and Plugins"></a>Functions and Plugins</h3><p>大型語言模型 (LLM) 雖然強大，但它們的知識庫僅限於訓練時所使用的資料。<br>這意味著，LLM 無法存取即時資訊，也無法執行外部系統中的特定操作，例如查詢資料庫、發送電子郵件或進行外部 API 呼叫。<br>這使得單純使用 LLM 的應用程式難以處理需要最新資訊或與外部世界互動的任務。<br>就像當我在上面圖片中，輸入<code>今天日期是?</code> LLM 回答是 <code>今天的日期是2023年6月13日。</code></p><p>Semantic Kernel 提供了一套函數 (Functions) 和插件 (Plugins)，專門用來解決這個問題。<br>你可以將這些函數視為 LLM 的「工具」或「外掛」，讓 LLM 能夠：</p><ul><li>存取即時或私有資料：例如，查詢你的內部產品庫存、客戶資料，或是最新的天氣資訊。</li><li>執行特定操作：如自動發送通知郵件、在客戶關係管理 (CRM) 系統中創建新記錄，或在網路上搜尋特定內容。</li></ul><p>這些函數讓 LLM 不再受限於其訓練資料，而是能像一個聰明的代理人，在需要時調用適當的工具來完成任務。<br>以下我們就來建立一個<strong>Plugin</strong>，Function 設定<code>[KernelFunction]</code>屬性，並透過<code>Description</code>來說<strong>Function</strong>的用途，如下:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.ComponentModel;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title">TimePlugin</span></span><br><span class="line">&#123;</span><br><span class="line">    [<span class="meta">KernelFunction</span>]</span><br><span class="line">    [<span class="meta">Description(<span class="string">&quot;取得現在UTC的日期及時間&quot;</span>)</span>]</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="built_in">string</span> <span class="title">GetCurrentDateAndTime</span>()</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">return</span> DateTime.UtcNow.ToString(<span class="string">&quot;R&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>然後將它 Import 到 Kernel 中，並設定 LLM 自動執行 Function ，並詢問 LLM <code>今天日期是?</code>，就會回答正確的日期，而不再是之前的<code>2023年6月13日</code>，如下，</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> Microsoft.SemanticKernel;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> apikey = <span class="string">&quot;你的 openai api key&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> modelId = <span class="string">&quot;gpt-4.1&quot;</span>;</span><br><span class="line"><span class="comment">// 建立 KernelBuilder</span></span><br><span class="line"><span class="keyword">var</span> kernelBuilder = Kernel.CreateBuilder();</span><br><span class="line"><span class="comment">// 加入 OpenAI ChatCompletion 服務</span></span><br><span class="line"><span class="keyword">var</span> kernel = kernelBuilder</span><br><span class="line">            .AddOpenAIChatCompletion(modelId, apikey)</span><br><span class="line">            .Build();</span><br><span class="line"><span class="comment">// 註冊 plugin</span></span><br><span class="line">kernel.ImportPluginFromType&lt;TimePlugin&gt;();</span><br><span class="line">OpenAIPromptExecutionSettings settings = <span class="keyword">new</span>() &#123; ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions &#125;;</span><br><span class="line">Console.OutputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.InputEncoding = UTF8Encoding.UTF8;</span><br><span class="line">Console.WriteLine(<span class="keyword">await</span> kernel.InvokePromptAsync(<span class="string">&quot;今天日期是? &quot;</span>, <span class="keyword">new</span>(settings)));</span><br></pre></td></tr></table></figure><p>Semantic Kernel 在呼叫 LLM 之前，會將你註冊的 Plugins 描述（包含其名稱、描述和參數）序列化後，作為提示 (prompt) 的一部分傳送給 LLM。如果你的 Plugins 數量過多或描述過於冗長，會佔用大量的提示令牌 (token)，增加成本並可能導致提示被截斷。<br>每次呼叫 LLM 時，建議最多只使用 10 到 20 個。若超過這個數量，模型會難以準確地選擇和使用正確的工具，容易產生錯誤或不穩定的行為。</p><ul><li>註: Funciton Calling 的過程，可以參考 <code>https://platform.openai.com/docs/guides/function-calling</code> 的圖片來了解。</li></ul><img src="https://cdn.openai.com/API/docs/images/function-calling-diagram-steps.png" width="50%" height="50%" ><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>在本篇文章中，我們一起探索了 Semantic Kernel 的核心概念，並透過實際範例，體驗了如何使用 Kernel 輕鬆串接多種 AI 服務（如文字生成與圖片生成）。<br>我們也看到，藉由 Plugins 如何突破 LLM 的資訊限制，讓 AI 能存取外部資料並執行真實世界的任務，將其從一個『資訊庫』轉變為一個『智慧代理人』。</p><p>在下一篇，我們將會深入探討 Plugins 的細節，包括如何建立和使用內建的 Plugins，以及如何利用 File-based Prompt Functions，讓你的應用程式具備更強大的語義理解與自動化能力。敬請期待！</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://github.com/microsoft/semantic-kernel">Semantic Kernel</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;前言&quot;&gt;&lt;a href=&quot;#前言&quot; class=&quot;headerlink&quot; title=&quot;前言&quot;&gt;&lt;/a&gt;前言&lt;/h3&gt;&lt;p&gt;隨著大型語言模型 (LLM) 的崛起，人工智慧已不再是遙不可及的技術，而是正在深刻地影響著每個企業與工作流程。若想在 AI 浪潮中保持領先，</summary>
      
    
    
    
    
    <category term="C#" scheme="https://rainmakerho.github.io/tags/C/"/>
    
    <category term=".NET" scheme="https://rainmakerho.github.io/tags/NET/"/>
    
    <category term="Plugins" scheme="https://rainmakerho.github.io/tags/Plugins/"/>
    
    <category term="Semantic Kernel" scheme="https://rainmakerho.github.io/tags/Semantic-Kernel/"/>
    
    <category term="OpenAI" scheme="https://rainmakerho.github.io/tags/OpenAI/"/>
    
    <category term="LLM" scheme="https://rainmakerho.github.io/tags/LLM/"/>
    
    <category term="AI 應用" scheme="https://rainmakerho.github.io/tags/AI-%E6%87%89%E7%94%A8/"/>
    
    <category term="開源 SDK" scheme="https://rainmakerho.github.io/tags/%E9%96%8B%E6%BA%90-SDK/"/>
    
    <category term="核心概念" scheme="https://rainmakerho.github.io/tags/%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/"/>
    
    <category term="語義核心" scheme="https://rainmakerho.github.io/tags/%E8%AA%9E%E7%BE%A9%E6%A0%B8%E5%BF%83/"/>
    
    <category term="程式開發" scheme="https://rainmakerho.github.io/tags/%E7%A8%8B%E5%BC%8F%E9%96%8B%E7%99%BC/"/>
    
    <category term="人工智慧" scheme="https://rainmakerho.github.io/tags/%E4%BA%BA%E5%B7%A5%E6%99%BA%E6%85%A7/"/>
    
    <category term="函式呼叫" scheme="https://rainmakerho.github.io/tags/%E5%87%BD%E5%BC%8F%E5%91%BC%E5%8F%AB/"/>
    
  </entry>
  
  <entry>
    <title>透過 Microsoft Graph API 取得 Teams 線上會議文字記錄完整教學</title>
    <link href="https://rainmakerho.github.io/2025/08/21/microsoft-graph-api-get-teams-online-meeting-transcript/"/>
    <id>https://rainmakerho.github.io/2025/08/21/microsoft-graph-api-get-teams-online-meeting-transcript/</id>
    <published>2025-08-21T01:16:52.000Z</published>
    <updated>2025-08-21T01:46:44.070Z</updated>
    
    <content type="html"><![CDATA[<p>在 Teams 中啟用會議錄影時，系統同時也會產生會議的文字記錄 (Transcript)。<br>這些文字記錄不僅能協助參與者回顧內容，還能交給 GPT 或其他工具產生更完整的會議紀錄。<br>一般情況下，只有會議主持人才能手動下載這些文字記錄。<br>那麼，是否能讓應用程式自動存取並下載會議文字記錄呢？本文將逐步示範實作方式。</p><h3 id="實作"><a href="#實作" class="headerlink" title="實作"></a>實作</h3><h5 id="1-註冊要存取的-App"><a href="#1-註冊要存取的-App" class="headerlink" title="1.註冊要存取的 App"></a>1.註冊要存取的 App</h5><p>由於必須透過 AP 存取，因此需先註冊一個 Azure AD App。完成後建立 Client Secret，並保存其 Value，稍後將用於取得存取 Token。詳細可以參考<a href="https://rainmakerho.github.io/2022/04/29/teams-app-access-meetings-behalf-user/">Teams App 代替使用者建立線上會議，讓該使用者為會議主持人</a>的說明來註冊 App。</p><h4 id="2-設定-APP-需要的權限"><a href="#2-設定-APP-需要的權限" class="headerlink" title="2.設定 APP 需要的權限"></a>2.設定 APP 需要的權限</h4><p>需要 Microsoft Graph API, Type 為 Application 的以下權限，</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">User.ReadBasic.All</span><br><span class="line">Calendars.ReadBasic.All</span><br><span class="line">OnlineMeetings.Read.All</span><br><span class="line">OnlineMeetingTranscript.Read.All</span><br><span class="line">Team.ReadBasic.All</span><br><span class="line">TeamSettings.Read.All</span><br></pre></td></tr></table></figure><p>設定完成後，請記得該 Azure 管理者 按一下 <strong>Grant admin consent for …</strong> 允許 App 可以用這些 API，如下圖所示:</p><img src="/2025/08/21/microsoft-graph-api-get-teams-online-meeting-transcript/01.png" class="" title="api permissons"><h3 id="Console-程式碼實作"><a href="#Console-程式碼實作" class="headerlink" title="Console 程式碼實作"></a>Console 程式碼實作</h3><p>以下為 C# Console 的程式碼，先設定需要的變數，例如 clientId, clientSecret …</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> System.Dynamic;</span><br><span class="line"><span class="keyword">using</span> System.Net.Http.Headers;</span><br><span class="line"><span class="keyword">using</span> System.Text;</span><br><span class="line"><span class="keyword">using</span> System.Text.Json;</span><br><span class="line"><span class="keyword">using</span> System.Text.Json.Nodes;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> clientId = <span class="string">&quot;&#123;Application (client) ID&#125;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> clientSecret = <span class="string">&quot;&#123;clientSecret&#125;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> tenantId = <span class="string">&quot;&#123;Directory (tenant) ID&#125;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> userPrincipalName = <span class="string">&quot;&#123;通常是email&#125;&quot;</span>;</span><br><span class="line"></span><br><span class="line">Console.OutputEncoding = Encoding.UTF8;</span><br><span class="line"><span class="keyword">var</span> contentType = <span class="string">&quot;application/json&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> client = <span class="keyword">new</span> HttpClient();</span><br><span class="line"></span><br></pre></td></tr></table></figure><h4 id="3-取得-App-的-access-token"><a href="#3-取得-App-的-access-token" class="headerlink" title="3.取得 App 的 access token"></a>3.取得 App 的 access token</h4><p>先取得 App 的 access token，用於呼叫 Graph API</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> getTokenUrl = <span class="string">$&quot;https://login.microsoftonline.com/<span class="subst">&#123;tenantId&#125;</span>/oauth2/v2.0/token&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> authBody = <span class="string">$&quot;grant_type=client_credentials&amp;client_id=<span class="subst">&#123;clientId&#125;</span>&amp;client_secret=<span class="subst">&#123;clientSecret&#125;</span>&amp;scope=https://graph.microsoft.com/.default&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> authResponse = <span class="keyword">await</span> client.PostAsync(getTokenUrl, <span class="keyword">new</span> StringContent(authBody, Encoding.UTF8, <span class="string">&quot;application/x-www-form-urlencoded&quot;</span>));</span><br><span class="line"><span class="comment">//取得 AccessToken</span></span><br><span class="line"><span class="keyword">var</span> authResult = <span class="keyword">await</span> authResponse.Content.ReadAsStringAsync();</span><br><span class="line"><span class="keyword">var</span> options = <span class="keyword">new</span> JsonSerializerOptions</span><br><span class="line">&#123;</span><br><span class="line">    PropertyNameCaseInsensitive = <span class="literal">true</span></span><br><span class="line">&#125;;</span><br><span class="line"><span class="built_in">dynamic</span> tokenResponse = JsonSerializer.Deserialize&lt;ExpandoObject&gt;(authResult, options);</span><br><span class="line"><span class="keyword">var</span> authObj = JsonNode.Parse(authResult);</span><br><span class="line">accessToken = (<span class="built_in">string</span>)authObj[<span class="string">&quot;access_token&quot;</span>];</span><br><span class="line"></span><br><span class="line">Console.WriteLine(<span class="string">&quot;======== access Token =========&quot;</span>);</span><br><span class="line">Console.WriteLine(accessToken);</span><br></pre></td></tr></table></figure><h4 id="4-取得使用者的-AAD-UserId"><a href="#4-取得使用者的-AAD-UserId" class="headerlink" title="4.取得使用者的 AAD UserId"></a>4.取得使用者的 AAD UserId</h4><p>呼叫 API 取得使用者的 Azure AD User Id (<code>User.ReadBasic.All 權限</code>)</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> getUserUrl = <span class="string">$&quot;https://graph.microsoft.com/v1.0/users/<span class="subst">&#123;userPrincipalName&#125;</span>&quot;</span>;</span><br><span class="line">client.DefaultRequestHeaders.Accept.Add(<span class="keyword">new</span> MediaTypeWithQualityHeaderValue(contentType));</span><br><span class="line">client.DefaultRequestHeaders.Add(<span class="string">&quot;Authorization&quot;</span>, <span class="string">$&quot;Bearer <span class="subst">&#123;accessToken&#125;</span>&quot;</span>);</span><br><span class="line"><span class="keyword">var</span> userResponse = <span class="keyword">await</span> client.GetAsync(getUserUrl);</span><br><span class="line"><span class="keyword">var</span> userResult = <span class="keyword">await</span> userResponse.Content.ReadAsStringAsync();</span><br><span class="line"><span class="keyword">var</span> userObj = JsonNode.Parse(userResult);</span><br><span class="line"><span class="keyword">var</span> userId = (<span class="built_in">string</span>)userObj[<span class="string">&quot;id&quot;</span>];</span><br><span class="line">Console.WriteLine(<span class="string">&quot;======== Azure AD User Id =========&quot;</span>);</span><br><span class="line">Console.WriteLine(userId);</span><br></pre></td></tr></table></figure><ul><li>註: 使用者必需要是會議的參與者，否則會取不到資料</li></ul><h4 id="5-依會議主旨來取得行事曆事件"><a href="#5-依會議主旨來取得行事曆事件" class="headerlink" title="5.依會議主旨來取得行事曆事件"></a>5.依會議主旨來取得行事曆事件</h4><p>依會議主旨查詢 Events，並取得 Join URL</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> searchSubject = <span class="string">&quot;TID&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> getEventsBySubjectFilterUrl = <span class="string">$&quot;https://graph.microsoft.com/v1.0/users/<span class="subst">&#123;userId&#125;</span>/events?$filter=startswith(subject, &#x27;<span class="subst">&#123;searchSubject&#125;</span>&#x27;)&quot;</span>;</span><br><span class="line"><span class="comment">//也可以用 日期 Filter</span></span><br><span class="line"><span class="comment">//var getEventsByDateFilterUrl = $&quot;https://graph.microsoft.com/v1.0/users/&#123;userId&#125;/events?$filter=start/dateTime ge &#x27;2025/03/19&#x27; and start/dateTime le &#x27;2025/03/20&#x27; &quot;;</span></span><br><span class="line"><span class="keyword">var</span> eventsResponse = <span class="keyword">await</span> client.GetAsync(getEventsBySubjectFilterUrl);</span><br><span class="line"><span class="keyword">var</span> eventsResult = <span class="keyword">await</span> eventsResponse.Content.ReadAsStringAsync();</span><br><span class="line"><span class="keyword">var</span> eventsObj = JsonNode.Parse(eventsResult);</span><br><span class="line"><span class="keyword">var</span> eventList = eventsObj[<span class="string">&quot;value&quot;</span>].AsArray();</span><br><span class="line">Console.WriteLine(<span class="string">&quot;======== Events =========&quot;</span>);</span><br><span class="line"><span class="keyword">foreach</span> (<span class="keyword">var</span> eventItem <span class="keyword">in</span> eventList)</span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">var</span> onlineMeeting = eventItem[<span class="string">&quot;onlineMeeting&quot;</span>];</span><br><span class="line">    <span class="keyword">if</span> (onlineMeeting != <span class="literal">null</span>)</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">var</span> subject = eventItem[<span class="string">&quot;subject&quot;</span>].ToString();</span><br><span class="line">        Console.WriteLine(<span class="string">$&quot;<span class="subst">&#123;subject&#125;</span>:<span class="subst">&#123;onlineMeeting[<span class="string">&quot;joinUrl&quot;</span>].ToString()&#125;</span>&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h4 id="6-透過線上會議的-Link-來取得線上會議的-Id"><a href="#6-透過線上會議的-Link-來取得線上會議的-Id" class="headerlink" title="6.透過線上會議的 Link 來取得線上會議的 Id"></a>6.透過線上會議的 Link 來取得線上會議的 Id</h4><p>如果一開始就有線上會議的 Link，就可以省略<strong>4.依會議主旨來取得行事曆事件</strong></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> joinWebUrl = <span class="string">@&quot;https://teams.microsoft.com/l/meetup-join/.....&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> getOnlineMeetingsUrl = <span class="string">$&quot;https://graph.microsoft.com/v1.0/users/<span class="subst">&#123;userId&#125;</span>/onlineMeetings?$filter=JoinWebUrl eq &#x27;<span class="subst">&#123;joinWebUrl&#125;</span>&#x27;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> onlineMeetingsResponse = <span class="keyword">await</span> client.GetAsync(getOnlineMeetingsUrl);</span><br><span class="line"><span class="keyword">var</span> onlineMeetingsResult = <span class="keyword">await</span> onlineMeetingsResponse.Content.ReadAsStringAsync();</span><br><span class="line">Console.WriteLine(<span class="string">&quot;======== Online Meeting Id =========&quot;</span>);</span><br><span class="line"><span class="keyword">var</span> meetingsObj = JsonNode.Parse(onlineMeetingsResult);</span><br><span class="line"><span class="keyword">var</span> meetingId = <span class="string">&quot;&quot;</span>;</span><br><span class="line"><span class="comment">//onlineMeetingResult</span></span><br><span class="line"><span class="keyword">if</span> (meetingsObj[<span class="string">&quot;error&quot;</span>] != <span class="literal">null</span>)</span><br><span class="line">&#123;</span><br><span class="line">    Console.WriteLine(meetingsObj[<span class="string">&quot;error&quot;</span>][<span class="string">&quot;message&quot;</span>]);</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">&#123;</span><br><span class="line">    meetingId = (<span class="built_in">string</span>)meetingsObj[<span class="string">&quot;value&quot;</span>][<span class="number">0</span>][<span class="string">&quot;id&quot;</span>];</span><br><span class="line">    Console.WriteLine(meetingId);</span><br><span class="line">    Console.WriteLine((<span class="built_in">string</span>)meetingsObj[<span class="string">&quot;value&quot;</span>][<span class="number">0</span>][<span class="string">&quot;subject&quot;</span>]);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li>註: 如果出現<code>No application access policy found for this app.</code>的錯誤，請依<a href="https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy">Allow applications to access online meetings on behalf of a user</a>方式來設定讓 App 有權限去執行</li><li>註: 建議一開始先設定測試的使用者比較快生效，例如<code>Grant-CsApplicationAccessPolicy -PolicyName &quot;teams-meetings-policy&quot; -Identity &quot;&#123;userId&#125;&quot;</code>，如果設定<strong>Global</strong>需要等蠻久一段時間(超過 30 分鐘)才會生效</li></ul><h4 id="7-取得線上會議的文字記錄-多筆-的資訊"><a href="#7-取得線上會議的文字記錄-多筆-的資訊" class="headerlink" title="7.取得線上會議的文字記錄(多筆)的資訊"></a>7.取得線上會議的文字記錄(多筆)的資訊</h4><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> getMeetingTranscriptsUrl = <span class="string">$&quot;https://graph.microsoft.com/v1.0/users/<span class="subst">&#123;userId&#125;</span>/onlineMeetings/<span class="subst">&#123;meetingId&#125;</span>/transcripts&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> meetingTranscriptsResponse = <span class="keyword">await</span> client.GetAsync(getMeetingTranscriptsUrl);</span><br><span class="line"><span class="keyword">var</span> meetingTranscriptsResult = <span class="keyword">await</span> meetingTranscriptsResponse.Content.ReadAsStringAsync();</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> meetingTranscriptsObj = JsonNode.Parse(meetingTranscriptsResult);</span><br><span class="line"><span class="keyword">var</span> transcriptsCount = meetingTranscriptsObj[<span class="string">&quot;@odata.count&quot;</span>];</span><br><span class="line">meetingTranscriptsObj[<span class="string">&quot;value&quot;</span>].AsArray();</span><br><span class="line"></span><br><span class="line">Console.WriteLine(<span class="string">$&quot;Total Transcripts Count:<span class="subst">&#123;transcriptsCount&#125;</span>&quot;</span>);</span><br><span class="line"><span class="comment">//the latest transcript</span></span><br><span class="line"><span class="keyword">var</span> lastTranscript = meetingTranscriptsObj[<span class="string">&quot;value&quot;</span>].AsArray().LastOrDefault();</span><br><span class="line"><span class="keyword">var</span> lastTranscriptId = (<span class="built_in">string</span>)lastTranscript[<span class="string">&quot;id&quot;</span>];</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>線上會議的文字記錄可能會有多筆，可以依<code>createdDateTime</code>過濾所需的記錄。本文示範取最後一筆作為測試。</p><h4 id="8-取得最後一筆線上會議的文字記錄"><a href="#8-取得最後一筆線上會議的文字記錄" class="headerlink" title="8.取得最後一筆線上會議的文字記錄"></a>8.取得最後一筆線上會議的文字記錄</h4><p>有了 TranscriptId 就可以取得文字記錄，格式選擇<code>text/vtt</code></p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> meetingTranscriptUrl = <span class="string">$&quot;https://graph.microsoft.com/v1.0/users/<span class="subst">&#123;userId&#125;</span>/onlineMeetings/<span class="subst">&#123;meetingId&#125;</span>/transcripts(&#x27;<span class="subst">&#123;lastTranscriptId&#125;</span>&#x27;)/content?$format=text/vtt&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> meetingTranscriptResponse = <span class="keyword">await</span> client.GetAsync(meetingTranscriptUrl);</span><br><span class="line"><span class="keyword">var</span> meetingTranscriptResult = <span class="keyword">await</span> meetingTranscriptResponse.Content.ReadAsStringAsync();</span><br><span class="line">Console.WriteLine(<span class="string">$&quot;=== Transcript ===============&quot;</span>);</span><br><span class="line">Console.WriteLine(meetingTranscriptResult.Substring(<span class="number">0</span>, <span class="number">500</span>));</span><br></pre></td></tr></table></figure><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>綜合以上步驟，我們可以透過 Microsoft Graph API 依主旨或日期找到會議事件，取得 Join URL，再進一步查詢 OnlineMeeting Id，最後存取該會議的 Transcript。<br>此流程能協助開發者自動化取得 Teams 線上會議的逐字稿，進一步應用於會議紀錄、智慧摘要或 NLP 分析。</p><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://rainmakerho.github.io/2022/04/29/teams-app-access-meetings-behalf-user/">Teams App 代替使用者建立線上會議，讓該使用者為會議主持人</a><br><a href="https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0&tabs=http">Microsoft Graph API Get a user</a><br><a href="https://learn.microsoft.com/en-us/graph/api/onlinemeeting-get?view=graph-rest-1.0&tabs=http">Microsoft Graph API Get onlineMeeting</a><br><a href="https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy">Allow applications to access online meetings on behalf of a user</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;在 Teams 中啟用會議錄影時，系統同時也會產生會議的文字記錄 (Transcript)。&lt;br&gt;這些文字記錄不僅能協助參與者回顧內容，還能交給 GPT 或其他工具產生更完整的會議紀錄。&lt;br&gt;一般情況下，只有會議主持人才能手動下載這些文字記錄。&lt;br&gt;那麼，是否能讓應用</summary>
      
    
    
    
    
    <category term="Microsoft Graph API" scheme="https://rainmakerho.github.io/tags/Microsoft-Graph-API/"/>
    
    <category term="Teams 會議記錄" scheme="https://rainmakerho.github.io/tags/Teams-%E6%9C%83%E8%AD%B0%E8%A8%98%E9%8C%84/"/>
    
    <category term="Teams Transcript" scheme="https://rainmakerho.github.io/tags/Teams-Transcript/"/>
    
    <category term="Teams 線上會議" scheme="https://rainmakerho.github.io/tags/Teams-%E7%B7%9A%E4%B8%8A%E6%9C%83%E8%AD%B0/"/>
    
    <category term="Graph API OnlineMeeting" scheme="https://rainmakerho.github.io/tags/Graph-API-OnlineMeeting/"/>
    
    <category term="Graph API Event" scheme="https://rainmakerho.github.io/tags/Graph-API-Event/"/>
    
    <category term="取得 Teams 逐字稿" scheme="https://rainmakerho.github.io/tags/%E5%8F%96%E5%BE%97-Teams-%E9%80%90%E5%AD%97%E7%A8%BF/"/>
    
    <category term="No application access policy found for this app" scheme="https://rainmakerho.github.io/tags/No-application-access-policy-found-for-this-app/"/>
    
  </entry>
  
  <entry>
    <title>GPT-4.1 與 GPT-5 API 價格比較：成本差異與使用情境解析</title>
    <link href="https://rainmakerho.github.io/2025/08/11/gpt4-1-vs-gpt5-api-pricing-comparison/"/>
    <id>https://rainmakerho.github.io/2025/08/11/gpt4-1-vs-gpt5-api-pricing-comparison/</id>
    <published>2025-08-11T02:37:22.000Z</published>
    <updated>2025-08-11T02:41:37.613Z</updated>
    
    <content type="html"><![CDATA[<p>隨著 OpenAI 推出 GPT-5，開發者在選擇模型時除了性能外，價格也是關鍵考量因素。本文將針對 <strong>GPT-4.1</strong> 與 <strong>GPT-5</strong> 的 API 計價方式，進行詳細對照與分析，幫助你在不同應用場景下作出最佳選擇。</p><hr><h2 id="1-價格對照表（每百萬-tokens-x2F-美元）"><a href="#1-價格對照表（每百萬-tokens-x2F-美元）" class="headerlink" title="1. 價格對照表（每百萬 tokens&#x2F;美元）"></a>1. 價格對照表（每百萬 tokens&#x2F;美元）</h2><table><thead><tr><th>模型</th><th>Input（輸入）</th><th>Cached Input（快取輸入）</th><th>Output（輸出）</th></tr></thead><tbody><tr><td><strong>GPT-4.1</strong></td><td>$2.00</td><td>$0.50</td><td>$8.00</td></tr><tr><td><strong>GPT-5</strong></td><td>$1.25</td><td>$0.125</td><td>$10.00</td></tr></tbody></table><blockquote><p>註：Cached Input 是指重複使用的輸入 tokens，計價更低，適合多輪對話或相似請求。</p></blockquote><hr><h2 id="2-價格差異分析"><a href="#2-價格差異分析" class="headerlink" title="2. 價格差異分析"></a>2. 價格差異分析</h2><h3 id="2-1-輸入成本"><a href="#2-1-輸入成本" class="headerlink" title="2.1 輸入成本"></a>2.1 輸入成本</h3><ul><li>GPT-5 輸入價格 <strong>比 GPT-4.1 便宜 37.5%（$1.25 vs $2.00）</strong>。</li><li>Cached Input 成本更大幅下降到 GPT-4.1 的 <strong>1&#x2F;4</strong>（$0.125 vs $0.50）。</li></ul><h3 id="2-2-輸出成本"><a href="#2-2-輸出成本" class="headerlink" title="2.2 輸出成本"></a>2.2 輸出成本</h3><ul><li>GPT-5 輸出價格 <strong>比 GPT-4.1 高 25%（$10.00 vs $8.00）</strong>。</li><li>在長輸出的情境下，GPT-4.1 可能更具成本優勢。</li></ul><hr><h2 id="3-使用情境建議"><a href="#3-使用情境建議" class="headerlink" title="3. 使用情境建議"></a>3. 使用情境建議</h2><table><thead><tr><th>使用情境</th><th>建議選擇</th><th>理由</th></tr></thead><tbody><tr><td><strong>長 prompt + 短輸出</strong></td><td>GPT-5</td><td>輸入便宜，總成本低</td></tr><tr><td><strong>短 prompt + 長輸出</strong></td><td>GPT-4.1</td><td>輸出便宜，適合生成大量文字</td></tr><tr><td><strong>多輪對話、快取重用多</strong></td><td>GPT-5</td><td>Cached Input 成本極低</td></tr></tbody></table><hr><h2 id="4-結論"><a href="#4-結論" class="headerlink" title="4. 結論"></a>4. 結論</h2><ul><li>如果你的應用場景 <strong>輸入量大、輸出量小</strong>，GPT-5 的成本優勢明顯。</li><li>如果你的應用場景 <strong>輸出文字長</strong>，GPT-4.1 可能更划算。</li><li>建議根據實際 token 使用比例，計算預估費用再決定模型選擇。</li></ul><hr><p><strong>延伸閱讀：</strong></p><ul><li><a href="https://platform.openai.com/docs/models/gpt-4.1">OpenAI GPT-4.1 官方文件</a></li><li><a href="https://platform.openai.com/docs/models/gpt-5">OpenAI GPT-5 官方文件</a></li></ul>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;隨著 OpenAI 推出 GPT-5，開發者在選擇模型時除了性能外，價格也是關鍵考量因素。本文將針對 &lt;strong&gt;GPT-4.1&lt;/strong&gt; 與 &lt;strong&gt;GPT-5&lt;/strong&gt; 的 API 計價方式，進行詳細對照與分析，幫助你在不同應用場景下作出最佳</summary>
      
    
    
    
    
    <category term="OpenAI" scheme="https://rainmakerho.github.io/tags/OpenAI/"/>
    
    <category term="GPT-4.1" scheme="https://rainmakerho.github.io/tags/GPT-4-1/"/>
    
    <category term="GPT-5" scheme="https://rainmakerho.github.io/tags/GPT-5/"/>
    
    <category term="API pricing" scheme="https://rainmakerho.github.io/tags/API-pricing/"/>
    
    <category term="token cost" scheme="https://rainmakerho.github.io/tags/token-cost/"/>
    
    <category term="AI model comparison" scheme="https://rainmakerho.github.io/tags/AI-model-comparison/"/>
    
    <category term="GPT-4.1 price" scheme="https://rainmakerho.github.io/tags/GPT-4-1-price/"/>
    
    <category term="GPT-5 price" scheme="https://rainmakerho.github.io/tags/GPT-5-price/"/>
    
    <category term="cached input" scheme="https://rainmakerho.github.io/tags/cached-input/"/>
    
    <category term="input token cost" scheme="https://rainmakerho.github.io/tags/input-token-cost/"/>
    
    <category term="output token cost" scheme="https://rainmakerho.github.io/tags/output-token-cost/"/>
    
    <category term="GPT-4.1 vs GPT-5" scheme="https://rainmakerho.github.io/tags/GPT-4-1-vs-GPT-5/"/>
    
    <category term="AI cost optimization" scheme="https://rainmakerho.github.io/tags/AI-cost-optimization/"/>
    
    <category term="GPT API" scheme="https://rainmakerho.github.io/tags/GPT-API/"/>
    
    <category term="AI pricing guide" scheme="https://rainmakerho.github.io/tags/AI-pricing-guide/"/>
    
  </entry>
  
  <entry>
    <title>C# 用 Enum + Dictionary 實作 狀態機：以會員狀態轉換為例</title>
    <link href="https://rainmakerho.github.io/2025/08/06/csharp-state-machine-enum-dictionary/"/>
    <id>https://rainmakerho.github.io/2025/08/06/csharp-state-machine-enum-dictionary/</id>
    <published>2025-08-06T09:30:35.000Z</published>
    <updated>2025-08-06T09:43:52.450Z</updated>
    
    <content type="html"><![CDATA[<p>在軟體開發中，有限狀態機（Finite State Machine, FSM） 是處理狀態之間有明確規則的轉換時非常常用的設計。例如，會員從「入會」可以轉為「暫停」或「退會」，「退會」又可以重新「入會」等。若直接用 if-else 判斷，不僅難以維護，日後擴充更易出錯。本文將介紹如何利用 C# 的 Enum 搭配 Dictionary，簡潔又彈性地實作狀態轉換邏輯。</p><h3 id="實作練習"><a href="#實作練習" class="headerlink" title="實作練習"></a>實作練習</h3><h5 id="1-用-Enum-定義所有狀態"><a href="#1-用-Enum-定義所有狀態" class="headerlink" title="1.用 Enum 定義所有狀態"></a>1.用 Enum 定義所有狀態</h5><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="built_in">enum</span> MemberStatus</span><br><span class="line">&#123;</span><br><span class="line">    入會,</span><br><span class="line">    暫停,</span><br><span class="line">    退會</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h5 id="2-用-Dictionary-定義合法狀態轉換"><a href="#2-用-Dictionary-定義合法狀態轉換" class="headerlink" title="2.用 Dictionary 定義合法狀態轉換"></a>2.用 Dictionary 定義合法狀態轉換</h5><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">class</span> <span class="title">MemberStatusExtensions</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">readonly</span> Dictionary&lt;MemberStatus, List&lt;MemberStatus&gt;&gt; AllowedTransitions = <span class="keyword">new</span>()</span><br><span class="line">    &#123;</span><br><span class="line">        [<span class="meta">MemberStatus.入會</span>] = [MemberStatus.暫停, MemberStatus.退會],</span><br><span class="line">        [<span class="meta">MemberStatus.暫停</span>] = [MemberStatus.入會, MemberStatus.退會],</span><br><span class="line">        [<span class="meta">MemberStatus.退會</span>] = [MemberStatus.入會]</span><br><span class="line">    &#125;;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="built_in">bool</span> <span class="title">CanTransitionTo</span>(<span class="params"><span class="keyword">this</span> MemberStatus current, MemberStatus target</span>)</span></span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">return</span> AllowedTransitions.TryGetValue(current, <span class="keyword">out</span> <span class="keyword">var</span> nexts) &amp;&amp; nexts.Contains(target);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h5 id="3-使用"><a href="#3-使用" class="headerlink" title="3.使用"></a>3.使用</h5><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> current = MemberStatus.入會;</span><br><span class="line"><span class="keyword">var</span> target = MemberStatus.退會;</span><br><span class="line"><span class="keyword">if</span> (current.CanTransitionTo(target))</span><br><span class="line">&#123;</span><br><span class="line">    Console.WriteLine(<span class="string">&quot;允許轉換&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">&#123;</span><br><span class="line">    Console.WriteLine(<span class="string">&quot;不允許轉換&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h3><p>這種設計方式具有以下優點：</p><ul><li>可讀性高：狀態與轉換規則集中管理，一目瞭然。</li><li>易於擴充：日後如需新增狀態，只需修改 Enum 與 Dictionary 即可。</li><li>易於維護：轉換規則變更時，不需翻找大量 if-else，只需調整字典內容。</li></ul>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;在軟體開發中，有限狀態機（Finite State Machine, FSM） 是處理狀態之間有明確規則的轉換時非常常用的設計。例如，會員從「入會」可以轉為「暫停」或「退會」，「退會」又可以重新「入會」等。若直接用 if-else 判斷，不僅難以維護，日後擴充更易出錯。本文</summary>
      
    
    
    
    
    <category term="C#" scheme="https://rainmakerho.github.io/tags/C/"/>
    
    <category term="Dictionary" scheme="https://rainmakerho.github.io/tags/Dictionary/"/>
    
    <category term="Finite State Machine" scheme="https://rainmakerho.github.io/tags/Finite-State-Machine/"/>
    
    <category term="Enum" scheme="https://rainmakerho.github.io/tags/Enum/"/>
    
    <category term="狀態轉換" scheme="https://rainmakerho.github.io/tags/%E7%8B%80%E6%85%8B%E8%BD%89%E6%8F%9B/"/>
    
    <category term="會員管理" scheme="https://rainmakerho.github.io/tags/%E6%9C%83%E5%93%A1%E7%AE%A1%E7%90%86/"/>
    
  </entry>
  
  <entry>
    <title>GPT 給 Image Base64 字串花費的 Token數比給 Url 還來得多很多?</title>
    <link href="https://rainmakerho.github.io/2025/07/31/gpt-image-token-calculation-url-vs-base64/"/>
    <id>https://rainmakerho.github.io/2025/07/31/gpt-image-token-calculation-url-vs-base64/</id>
    <published>2025-07-31T08:12:08.000Z</published>
    <updated>2025-08-01T02:45:09.716Z</updated>
    
    <content type="html"><![CDATA[<h3 id="問題"><a href="#問題" class="headerlink" title="問題"></a>問題</h3><p>有人說使用 gpt-4o, gpt-4.1 這種多模態 LLM，呼叫 ChatCompletion API 給 Image 時，<br>如果給 Image 的 Base64 內容，所花的 Token 數會比給 Image URL 來得多很多 (´− ｀) ﾝｰ (¬_¬)<br>是因為 Image 的 Base64 字串長度比 Image URL 的字串內容多很多。</p><p>所以，如果要給圖檔時，要想儘辦法讓 OpenAI API 可以讀取到圖檔，<br>也就是要允許圖檔可以讓 internet 連到 !!!</p><blockquote><p>Image 的 Base64 內容，所花的 Token 數會比給 Image URL 來得多很多，這是真的嗎?</p></blockquote><p>以下我們就來驗證看看，</p><h3 id="測試"><a href="#測試" class="headerlink" title="測試"></a>測試</h3><p>使用 Semantic Kernel C#，使用 <code>ImageContent</code> 分別給 url 及 file bytes (base64)，程式如下，</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br></pre></td><td class="code"><pre><span class="line">IKernelBuilder builder = Kernel.CreateBuilder();</span><br><span class="line"><span class="keyword">const</span> <span class="built_in">string</span> apikey = <span class="string">&quot;sk-請給 openai apikey&quot;</span>;</span><br><span class="line"><span class="keyword">const</span> <span class="built_in">string</span> model = <span class="string">&quot;gpt-4.1-mini&quot;</span>;</span><br><span class="line"></span><br><span class="line">builder.AddOpenAIChatCompletion(model, apikey);</span><br><span class="line">Kernel kernel = builder.Build();</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> chatCompletionService = kernel.GetRequiredService&lt;IChatCompletionService&gt;();</span><br><span class="line"></span><br><span class="line">ChatHistory chatHistory = <span class="keyword">new</span>();</span><br><span class="line"><span class="built_in">string</span> textContent = <span class="string">&quot;請將摘要這張圖片中的文字。\r\n&quot;</span>;</span><br><span class="line"><span class="built_in">bool</span> isUseUri = <span class="literal">true</span>; <span class="comment">//or false</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (isUseUri)</span><br><span class="line">&#123;</span><br><span class="line">    chatHistory.Add(</span><br><span class="line">        <span class="keyword">new</span>()</span><br><span class="line">        &#123;</span><br><span class="line">            Role = AuthorRole.User,</span><br><span class="line">            Items = [</span><br><span class="line">                <span class="keyword">new</span> TextContent(textContent),</span><br><span class="line">            <span class="keyword">new</span> ImageContent(<span class="keyword">new</span> Uri(<span class="string">$&quot;<span class="subst">&#123;對外的ImageUrl&#125;</span>&quot;</span>))</span><br><span class="line">            ]</span><br><span class="line">        &#125;</span><br><span class="line">    );</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="built_in">byte</span>[] imageBytes = File.ReadAllBytes(<span class="string">&quot;path/to/your/image.png&quot;</span>);</span><br><span class="line">    chatHistory.Add(</span><br><span class="line">        <span class="keyword">new</span>()</span><br><span class="line">        &#123;</span><br><span class="line">            Role = AuthorRole.User,</span><br><span class="line">            Items = [</span><br><span class="line">               <span class="keyword">new</span> TextContent(textContent),</span><br><span class="line">                <span class="keyword">new</span> ImageContent(imageBytes, <span class="string">&quot;image/png&quot;</span>)</span><br><span class="line">            ]</span><br><span class="line">        &#125;</span><br><span class="line">    );</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> reply = <span class="keyword">await</span> chatCompletionService.GetChatMessageContentAsync(chatHistory);</span><br><span class="line">Console.WriteLine(<span class="string">&quot;================&quot;</span>);</span><br><span class="line">Console.WriteLine(reply.Content);</span><br><span class="line">Console.WriteLine(<span class="string">&quot;================&quot;</span>);</span><br><span class="line">Helper.OutputInnerContent(reply.InnerContent <span class="keyword">as</span> OpenAI.Chat.ChatCompletion);</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">OutputInnerContent</span>(<span class="params">OpenAI.Chat.ChatCompletion innerContent</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Message role: <span class="subst">&#123;innerContent.Role&#125;</span>&quot;</span>); <span class="comment">// Available as a property of ChatMessageContent</span></span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Message content: <span class="subst">&#123;innerContent.Content[<span class="number">0</span>].Text&#125;</span>&quot;</span>); <span class="comment">// Available as a property of ChatMessageContent</span></span><br><span class="line"></span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Model: <span class="subst">&#123;innerContent.Model&#125;</span>&quot;</span>); <span class="comment">// Model doesn&#x27;t change per chunk, so we can get it from the first chunk only</span></span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Created At: <span class="subst">&#123;innerContent.CreatedAt&#125;</span>&quot;</span>);</span><br><span class="line"></span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Finish reason: <span class="subst">&#123;innerContent.FinishReason&#125;</span>&quot;</span>);</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Input tokens usage: <span class="subst">&#123;innerContent.Usage.InputTokenCount&#125;</span>&quot;</span>);</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Output tokens usage: <span class="subst">&#123;innerContent.Usage.OutputTokenCount&#125;</span>&quot;</span>);</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Total tokens usage: <span class="subst">&#123;innerContent.Usage.TotalTokenCount&#125;</span>&quot;</span>);</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Refusal: <span class="subst">&#123;innerContent.Refusal&#125;</span> &quot;</span>);</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;Id: <span class="subst">&#123;innerContent.Id&#125;</span>&quot;</span>);</span><br><span class="line">    Console.WriteLine(<span class="string">$&quot;System fingerprint: <span class="subst">&#123;innerContent.SystemFingerprint&#125;</span>&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (innerContent.ContentTokenLogProbabilities.Count &gt; <span class="number">0</span>)</span><br><span class="line">    &#123;</span><br><span class="line">        Console.WriteLine(<span class="string">&quot;Content token log probabilities:&quot;</span>);</span><br><span class="line">        <span class="keyword">foreach</span> (<span class="keyword">var</span> contentTokenLogProbability <span class="keyword">in</span> innerContent.ContentTokenLogProbabilities)</span><br><span class="line">        &#123;</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Token: <span class="subst">&#123;contentTokenLogProbability.Token&#125;</span>&quot;</span>);</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Log probability: <span class="subst">&#123;contentTokenLogProbability.LogProbability&#125;</span>&quot;</span>);</span><br><span class="line"></span><br><span class="line">            Console.WriteLine(<span class="string">&quot;   Top log probabilities for this token:&quot;</span>);</span><br><span class="line">            <span class="keyword">foreach</span> (<span class="keyword">var</span> topLogProbability <span class="keyword">in</span> contentTokenLogProbability.TopLogProbabilities)</span><br><span class="line">            &#123;</span><br><span class="line">                Console.WriteLine(<span class="string">$&quot;   Token: <span class="subst">&#123;topLogProbability.Token&#125;</span>&quot;</span>);</span><br><span class="line">                Console.WriteLine(<span class="string">$&quot;   Log probability: <span class="subst">&#123;topLogProbability.LogProbability&#125;</span>&quot;</span>);</span><br><span class="line">                Console.WriteLine(<span class="string">&quot;   =======&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            Console.WriteLine(<span class="string">&quot;--------------&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (innerContent.RefusalTokenLogProbabilities.Count &gt; <span class="number">0</span>)</span><br><span class="line">    &#123;</span><br><span class="line">        Console.WriteLine(<span class="string">&quot;Refusal token log probabilities:&quot;</span>);</span><br><span class="line">        <span class="keyword">foreach</span> (<span class="keyword">var</span> refusalTokenLogProbability <span class="keyword">in</span> innerContent.RefusalTokenLogProbabilities)</span><br><span class="line">        &#123;</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Token: <span class="subst">&#123;refusalTokenLogProbability.Token&#125;</span>&quot;</span>);</span><br><span class="line">            Console.WriteLine(<span class="string">$&quot;Log probability: <span class="subst">&#123;refusalTokenLogProbability.LogProbability&#125;</span>&quot;</span>);</span><br><span class="line"></span><br><span class="line">            Console.WriteLine(<span class="string">&quot;   Refusal top log probabilities for this token:&quot;</span>);</span><br><span class="line">            <span class="keyword">foreach</span> (<span class="keyword">var</span> topLogProbability <span class="keyword">in</span> refusalTokenLogProbability.TopLogProbabilities)</span><br><span class="line">            &#123;</span><br><span class="line">                Console.WriteLine(<span class="string">$&quot;   Token: <span class="subst">&#123;topLogProbability.Token&#125;</span>&quot;</span>);</span><br><span class="line">                Console.WriteLine(<span class="string">$&quot;   Log probability: <span class="subst">&#123;topLogProbability.LogProbability&#125;</span>&quot;</span>);</span><br><span class="line">                Console.WriteLine(<span class="string">&quot;   =======&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><ul><li>註: <code>new ImageContent(imageBytes, &quot;image/png&quot;)</code>中的 <code>imageBytes</code>會被轉成 Base64 字串(<a href="https://github.com/openai/openai-dotnet/blob/main/src/Utility/DataEncodingHelpers.cs#L37">DataEncodingHelpers.cs</a>)，如下程式，</li></ul><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="built_in">string</span> <span class="title">CreateDataUri</span>(<span class="params">BinaryData bytes, <span class="built_in">string</span> bytesMediaType</span>)</span></span><br><span class="line">&#123;</span><br><span class="line">    <span class="built_in">string</span> base64Bytes = Convert.ToBase64String(bytes.ToArray());</span><br><span class="line">    <span class="keyword">return</span> <span class="string">$&quot;data:<span class="subst">&#123;bytesMediaType&#125;</span>;base64,<span class="subst">&#123;base64Bytes&#125;</span>&quot;</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>程式 Log 出來的輸入 Token 是 2,456 個，如下圖:</p><img src="/2025/07/31/gpt-image-token-calculation-url-vs-base64/02.png" class="" title="AP Log"><p>從 OpenAI 的 Log 來看，2 次的 Input Token 都是 2,456 個，跟我們程式 Log 出來的結果相同，如下圖:</p><img src="/2025/07/31/gpt-image-token-calculation-url-vs-base64/01.png" class="" title="OpenAI Log"><h3 id="總結"><a href="#總結" class="headerlink" title="總結"></a>總結</h3><p>使用多模態 LLM，給 Image 的 Url 或是給 Base64 字串，所花費的 Input Token 數是<strong>一樣的</strong>!<br>差別就在於 Post API 時的 Payload 大小而已。<br>如果是企業內的圖檔，建議使用 Base64 的方式，也不會有圖檔要對外的問題。</p><p>最後，再強調一次，</p><blockquote><p>使用多模態 LLM，給 Image 的 Url 或是給 Base64 字串，所花費的 Input Token 數是<strong>一樣的</strong> (&gt;人&lt;)</p></blockquote><h3 id="參考資源"><a href="#參考資源" class="headerlink" title="參考資源"></a>參考資源</h3><p><a href="https://learn.microsoft.com/en-us/semantic-kernel/concepts/ai-services/chat-completion/multi-modal-chat-completion?pivots=programming-language-csharp">Multi-modal chat completion</a><br><a href="https://github.com/openai/openai-dotnet/blob/main/src/Utility/DataEncodingHelpers.cs#L37">DataEncodingHelpers.cs - CreateDataUri</a></p>]]></content>
    
    
      
      
    <summary type="html">&lt;h3 id=&quot;問題&quot;&gt;&lt;a href=&quot;#問題&quot; class=&quot;headerlink&quot; title=&quot;問題&quot;&gt;&lt;/a&gt;問題&lt;/h3&gt;&lt;p&gt;有人說使用 gpt-4o, gpt-4.1 這種多模態 LLM，呼叫 ChatCompletion API 給 Image 時，&lt;br&gt;如果</summary>
      
    
    
    
    
    <category term="C#" scheme="https://rainmakerho.github.io/tags/C/"/>
    
    <category term="Base64" scheme="https://rainmakerho.github.io/tags/Base64/"/>
    
    <category term="Semantic Kernel" scheme="https://rainmakerho.github.io/tags/Semantic-Kernel/"/>
    
    <category term="URL" scheme="https://rainmakerho.github.io/tags/URL/"/>
    
    <category term="Image" scheme="https://rainmakerho.github.io/tags/Image/"/>
    
    <category term="GPT" scheme="https://rainmakerho.github.io/tags/GPT/"/>
    
    <category term="token 計費" scheme="https://rainmakerho.github.io/tags/token-%E8%A8%88%E8%B2%BB/"/>
    
    <category term="API token usage" scheme="https://rainmakerho.github.io/tags/API-token-usage/"/>
    
    <category term="ChatCompletion" scheme="https://rainmakerho.github.io/tags/ChatCompletion/"/>
    
  </entry>
  
</feed>
