簡單寫個能讀網頁會看圖的 LINE AI 助理 - 我的 .NET 黑殼蝦
| | | 0 | |
前陣子學會用 Agent Framework 開發 Agent 或聊天程式,接著來試些有趣的應用。上回玩過用 AIChatWeb 寫出可傳圖檔的 AI 聊天室功能,這回我想試試為 LINE 寫個可以傳圖的 AI 聊天機器人。
類似的題目之前做過,像是 ChatGPT 翻譯鳳梨酥 LINE Bot,原則上就是用 David 老師的 LineBotSDK 處理與 LINE 通訊的部分再串接語言模型的聊天 API。MS Agent Framework 有 AgentSession 幫忙處理聊天歷史記錄,不必自己保存及打包歷史對話內容,程式寫起來輕鬆不少。甚至還可自訂儲存機制(預設是存在記憶體)、設定壓縮策略(https://learn.microsoft.com/zh-tw/agent-framework/agents/conversations/compaction?pivots=programming-language-csharp)(歷史對話過多時進行摘要濃縮,以免超過上下文窗口長度上限,避免 AI 抓錯重點,另一方面也減少 Token 省點錢),是個進行攻退可守的彈性架構。
程式的主架構是用 OpenAI 或 AOAI 的 API Url 及 Key 建立 ChatClient,傳入 Prompt 提示、工具函式清單建立 AIAgent。另一方面,程式會執行 ASP.NET Minimal API 跑網站,並用 MapPost() 加入一段訊息處理邏輯,並向 LINE 註冊這個 URL。當 LINE 聊天室有人發言,LINE 會呼叫這個 Callback 網址並以 JSON 格式傳入訊息內容,程式從 JSON 中可取得使用者訊息文字或上傳的圖片,將文字及圖片當成輸入參數呼叫 AIAgent.RunAsync() 取得 AI 模型的回應。但這裡有個重點是,每次呼叫 AI 模型時,必須帶入先前的交談內容,不然模型不會接續先前的話題。例如:你問天氣如何?AI 問你想知道哪裡的天氣,你回答"台北"後第二次呼叫模型,若沒帶入先前天氣的討論,AI 看到沒頭沒腦的台北二字,哪知道你在問天氣。每次要記得帶入先前的討論挺煩人,Agent Framework 提供了一個 AgentSession 物件,我們可以用 AIAgent.CreateSessionAsync() 建立,每次呼叫 .RunAsync() 時當成參數帶入,AgentSession 將自動幫忙保留交談記錄,我們只需要每次呼叫 AIAgent.RunAsync() 時傳入同一個 AgentSession 就好了。
但還有兩個問題要解決。第一,不管 ChatGPT/Gemini/Claude/Copliot 都有個開啟一段新對話的功能,對映到 LINE 聊天,相當於丟掉先前的交談記錄,新建一個 AgentSession 重新開始,因此要設計像是 /clear 之類的指令觸發重建 Session 的動作。第二是 LINE Bot 可能同時跟多人交談,每個人要有一個專屬的 AgentSession 不能共用,生命週期也不同。我的解法是設計一個 SessionState 類別,除了 AgentSession 外再加上暫存圖檔(因應先傳圖,之後才說需求的情境)、清除重建、寫 Log 等功能。SessionState 長這樣:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace AgentFxLineBot
{
public class SessionState
{
NLog.Logger logger = null!;
public string SessionId { get; set; } = "NA";
public string LineUserId { get; set; } = "NA";
public Microsoft.Agents.AI.AgentSession Session { get; set; } = null!;
public SessionState(string lineUserId, Microsoft.Agents.AI.AgentSession session)
{
Console.WriteLine($"建立新 Session: {lineUserId}");
LineUserId = lineUserId;
Reset(session);
}
public void Reset(Microsoft.Agents.AI.AgentSession session)
{
SessionId = $"{LineUserId}-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString().Substring(0, 4)}";
logger = NLog.LogManager.GetLogger(SessionId);
Session = session;
Images.Clear();
}
public List<AIContent> Images = new List<AIContent>();
public void LogInput(string msg) => logger.Debug("INPUT\n" + msg);
public void LogOutput(string msg) => logger.Debug("OUTPUT\n" + msg);
}
}
主程式不長,不到 150 行搞定:
using Microsoft.Extensions.Configuration;
using Azure.AI.OpenAI;
using System.ClientModel;
using System.ComponentModel;
using Microsoft.Extensions.AI;
using isRock.LineBot;
using System.Text.RegularExpressions;
using System.Collections.Concurrent;
using AgentFxLineBot;
using OpenAI.Chat;
using MSChatMessage = Microsoft.Extensions.AI.ChatMessage;
using AngleSharp;
using AngleSharp.Io;
var builder = WebApplication.CreateBuilder(args);
const string modelName = "gpt-5.2-chat";
var chanAccToken = builder.Configuration["LineBotToken"] ?? throw new Exception("No LineBotToken setting");
var azureChatClient = new AzureOpenAIClient(
new Uri(builder.Configuration["AoaiEndPoint"] ?? throw new Exception("No AoaiEndPoint setting")),
new ApiKeyCredential(builder.Configuration["AoaiApiKey"] ?? throw new Exception("No AoaiApiKey setting"))
).GetChatClient(modelName);
var agent = azureChatClient
.AsAIAgent(
instructions:
"""
你是 LINE 聊天助理,負責完成以下任務:
- 若使用者提供 URL,依其指示處理該網頁內容
- 若使用者提供圖片,依其指示分析圖片內容
## 執行說明
- 使用者除 URL 及圖片外需說明需求,請先確認需求,再進行分析
- 使用 ReadWebPage 工具取得網頁內容
- 不要使用 Emoji 與 Markdown
""",
tools: [
AIFunctionFactory.Create(ReadWebPageContent)
]
);
string helpMsg =
"""
MS Agent Framework 版 LINE 聊天機器人 PoC
====
使用 gpt-5.2-chat,支援讀取網頁與圖片解析
## 指令
/help 顯示此說明
/clear 清除對話記憶
""";
var app = builder.Build();
var bot = new isRock.LineBot.Bot(chanAccToken);
app.MapGet("/", () => "Hello World!");
var sessionPool = new ConcurrentDictionary<string, SessionState>();
app.MapPost("/MsgCallback", async (HttpContext context) =>
{
var baseUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}";
var body = context.Request.Body;
using var reader = new StreamReader(body);
var json = await reader.ReadToEndAsync();
var msg = Utility.Parsing(json);
foreach (var evt in msg.events)
{
if (evt.type == "message")
{
if (evt.source.type != "user")
{
// 不支援群組聊天室,僅支援與單人對話
continue;
}
var lineUserId = evt.source.userId;
var sessionState = sessionPool.GetOrAdd(lineUserId, _ => new SessionState(lineUserId, agent.CreateSessionAsync().Result));
if (evt.message.type == "image")
{
var image = await AgentFxLineBot.LineBotUtils.GetUserUploadedContentAsync(evt.message.id, chanAccToken);
var mediaType = image.MimeType;
var data = new DataContent(image.Content, mediaType);
data.AdditionalProperties = new() { ["name"] = (object?)image.Id + "." + mediaType.Split('/').Last() };
Console.WriteLine($"Received image with media type: {mediaType}");
sessionState.Images.Add(data);
}
else if (evt.message.type == "text")
{
var text = evt.message.text;
switch (text)
{
case "/help":
bot.ReplyMessage(evt.replyToken, helpMsg);
break;
case "/clear":
sessionState.Reset(agent.CreateSessionAsync().Result);
bot.ReplyMessage(evt.replyToken, "已重設交談階段");
break;
default:
sessionState.LogInput(text);
string outMsg;
try
{
var chatMsg = new MSChatMessage(ChatRole.User, text);
if (sessionState.Images.Any())
{
chatMsg = new MSChatMessage(ChatRole.User, new List<AIContent>() { new TextContent(text) }.Concat(sessionState.Images).ToList());
sessionState.Images.Clear();
}
var res = await agent.RunAsync(chatMsg, sessionState.Session);
outMsg = res.Text;
sessionState.LogOutput(outMsg);
}
catch (Exception ex)
{
sessionState.LogOutput("Error: " + ex.Message);
outMsg = $"發生錯誤:{ex.Message}";
}
bot.ReplyMessage(evt.replyToken, outMsg);
break;
}
}
}
}
await context.Response.WriteAsync("OK");
});
app.UsePathBase("/line-bot");
app.Run();
[Description("輸入 URL 讀取網頁 HTML 內容")]
static async Task<string> ReadWebPageContent([Description("網頁 URL")] string url)
{
Console.WriteLine($"讀取網頁內容: {url}");
try
{
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(url);
return document?.Body?.TextContent ?? string.Empty;
}
catch (Exception ex)
{
return $"無法讀取網頁內容: {ex.Message}";
}
}
中間有個小眉角,isRock.LineBot SDK 用 GetUserUploadedContent() 取圖檔時只會拿到 byte[],但後續要轉 DataContent 交給模型時需指定 MIME Type,為此我寫了個函式自己呼叫 LINE WebApi,在下載時一併由 Response Header 取得 MIME Type。
就醬,一個可以讀取網頁總結內容,還能上傳圖檔分析的 AI 聊天 LINE 機器人就寫好了,核心採用 GPT-5.2-Chat,靠 Agent Framework 框架可以很容易加掛各種工具、MCP、Agent 無限擴充能力,化身強大的 AI Agent。這種 AI 助理的能力自然不能跟小龍蝦相提並論,那就叫它黑殼蝦吧~

程式碼我放上 Github 了,需要的同學請自取。
Demonstrates building a LINE AI chatbot with Microsoft Agent Framework, supporting images and web reading. Uses AgentSession to manage per-user conversation history, reset commands, and tool integration, showcasing a scalable, extensible design powered by GPT‑5.2‑Chat.
Comments
Be the first to post a comment