feat: WebView2 chat — real bubbles, Markdown & thinking indicator (v0.7.0)

The AI Chat pane's message area moves from RichTextBox to WebView2:
- Real chat bubbles (user right, AI left) with full Markdown via Markdig
  (code blocks, tables, lists, inline code).
- Sending a prompt shows an animated "…" thinking bubble that clears when
  the reply lands (AgentHost Status "thinking" → showThinking; AssistantText
  → hideThinking + bubble).
- HTML/CSS/JS template inlined in Ai/ChatHtml.cs (NavigateToString, no
  external deps); C# drives it via ExecuteScriptAsync. Calls made before
  WebView2 is ready are queued and flushed on NavigationCompleted. User-data
  folder under %LocalAppData%\ETTerms\WebView2.

Bottom bar (input / model dropdown / Send), [AI] serial tagging and PDU
confirmations are unchanged. Adds Microsoft.Web.WebView2 + Markdig; needs
the WebView2 Runtime (built into Windows 11). Publish verified to include
the native WebView2Loader.dll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 22:59:37 +08:00
co-authored by Claude Fable 5
parent cf1fd2c345
commit 4820de22f8
6 changed files with 179 additions and 68 deletions
+1 -1
View File
@@ -764,7 +764,7 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
- ~~**AI / MCP 整合**~~(✅ 已於 [Phase 9](#development-phases) 實作:Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具) - ~~**AI / MCP 整合**~~(✅ 已於 [Phase 9](#development-phases) 實作:Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具)
- ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁) - ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁)
- **AI Chat 氣泡版(WebView2**v0.7.0 候選):目前 AI 分頁走 RichTextBox「乾淨逐字稿」風(與終端機一致、務實);若要真正的聊天氣泡(圓角、限寬、user 右 AI 左)+ Markdown / 程式碼區塊高亮 + 工具呼叫可摺疊卡片,正解是改用 WebView2 + HTML/CSS 渲染。代價:引入 WebView2 依賴、portable 版變大。列為獨立升級,不在 RichTextBox 上硬做氣泡 - ~~**AI Chat 氣泡版(WebView2**~~(✅ v0.7.0 已實作):AI 分頁訊息區改用 WebView2 渲染真氣泡(user 右 / AI 左)+ MarkdownMarkdig 轉 HTML:程式碼區塊、表格、清單)+ 送出後 thinking 動畫泡。HTML 模板 `Ai/ChatHtml.cs`(全內嵌 CSS/JS,`NavigateToString`,C# 經 `ExecuteScriptAsync` 呼叫 JS`addUser`/`addAI`/`addTool`/`showThinking`/`hideThinking`…;WebView2 未就緒前的呼叫先入佇列,`NavigationCompleted` 後 flush)。WebView2 使用者資料夾 `%LocalAppData%\ETTerms\WebView2`。底部控制列(輸入框 / 模型下拉 / Send)仍 WinForms。依賴 WebView2 RuntimeWin11 內建)
- **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel` - **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`
- **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估) - **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估)
- **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm) - **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm)
+2
View File
@@ -10,6 +10,8 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。 **進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
**v0.7.0** **AI Chat 改用 WebView2 渲染**(真氣泡 + Markdown + thinking 動畫泡)。訊息區從 RichTextBox 換成 `WebView2`user 右泡 / AI 左泡、AI 回覆走 **Markdig** markdown→HTML(程式碼區塊/表格/清單)、送出後顯示會動的 thinking「…」泡(`AgentHost.Status "thinking"``showThinking()`,收到 `AssistantText``hideThinking()` 再加 AI 泡)。HTML 模板全內嵌於 `Ai/ChatHtml.cs``NavigateToString`,無外部依賴),C# 經 `ExecuteScriptAsync` 呼叫 JS 函式;WebView2 async 初始化,就緒前的呼叫先入 `_pending` 佇列、`NavigationCompleted` 後 flush。使用者資料夾 `%LocalAppData%\ETTerms\WebView2`(避開 Program Files 唯讀)。底部控制列(輸入框/模型下拉/Send)仍 WinForms。新增 NuGet `Microsoft.Web.WebView2` + `Markdig`;依賴 WebView2 RuntimeWin11 內建,缺時 hint 顯示錯誤)。⚠️ **publish 要確認 WebView2 native`runtimes/win-x64/native/WebView2Loader.dll`)有進產物**
**v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**UI 走乾淨逐字稿風**user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕不洗版;與終端機美學一致,非氣泡——真氣泡+Markdown 的 WebView2 版列 v0.7.0)。**模型下拉**(右下角)打端點 `/v1/models` 列可選模型、即時切換並記住;**Settings 只留 Base URL / API Key / 系統提示詞,不再設 model**(`configured` 只看 Base URL)。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt` **v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**UI 走乾淨逐字稿風**user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕不洗版;與終端機美學一致,非氣泡——真氣泡+Markdown 的 WebView2 版列 v0.7.0)。**模型下拉**(右下角)打端點 `/v1/models` 列可選模型、即時切換並記住;**Settings 只留 Base URL / API Key / 系統提示詞,不再設 model**(`configured` 只看 Base URL)。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt`
**v0.5.0** 搜尋 / 關鍵字警示 / TTL 大擴充。**(1) Ctrl+F scrollback 搜尋** — `TerminalView` 內建搜尋列(Enter 往上找、Shift+Enter 往下、F3/Esc),全部命中黃底、目前命中橘底;命中以「絕對行號 = `ScreenBuffer.DroppedLines` + abs」錨定,環形緩衝丟舊行不漂移。**(2) 關鍵字高亮 + 分頁警示** — `AppSettings.KeywordRules``KeywordRule{Text,Enabled}` + 全域開關),Settings 新 **Highlight** 分頁可增刪/勾選;`TerminalView` 每次重繪只掃可見行標紅底(不分大小寫),`Feed` 路徑用獨立 Decoder+去 ANSI 偵測觸發 `KeywordAlert`(每關鍵字 2s 冷卻)→ `SessionPage``WorkspaceView` 把**非 active 分頁**的圓點標紅、切過去自動清除。**(3) TTL 對齊 TeraTerm** — 新 `TtlExpression` 運算式解析器(括號/邏輯/比較/十六進位 `0x`/`$`,失敗退回 legacy 規則相容舊腳本);新增控制流 `goto/call(行內執行,迴圈內可用)/return/for-next/do-loop/until-enduntil/break/continue/end/exit/include/mpause`、等待 `waitln/waitregex/recvln/wait 多字串(TeraTerm 語意:逾時 result=0 繼續)/mtimeout`、字串 `strlen/strcompare/strconcat/strcopy/strinsert/strremove/strmatch/strscan/strreplace/strtrim/strsplit/strjoin/tolower/toupper/str2int/int2str/code2str/str2code/sprintf(→inputstr)/expandenv`、檔案 `fileopen/filereadln(result=1 是 EOF)/filewrite(ln)/fileclose/filecreate/filedelete/filesearch/basename/dirname/makepath/foldercreate/folderdelete/foldersearch/getdir/setdir`、雜項 `beep/getdate/gettime(strftime 子集)/getenv/setenv/random/exec/getver/getttdir/uptime/ifdefined/clipb2var/var2clipb/inputbox/yesnobox/crc32/checksum8/16/32/dispstr`、serial 專用 `sendbreak/setbaud/setdtr/setrts/sendfile``SerialChannel` 新增對應方法);單行 `if <expr> <statement>` 支援;`Preprocess` 引號內 `;` 不再被當註解;系統變數 `inputstr/matchstr/groupmatchstr1-9``_vars` 改大小寫不敏感;**單字串 `wait` 保留 ETTerms settle+逾時中止語意(勿改)**,多字串才是 TeraTerm 語意。`SessionPage``runner.Output`trace`[wait]`/`>>`/錯誤)以灰色 echo 進終端機,**可由 Settings → Terminal 的 `ShowScriptTrace` 開關關閉**`dispstr` 走獨立的 `Display` 事件一律顯示。灰色訊息只進畫面 buffer——不進 ⏺ Log 側錄、不進 AI bridge、不送裝置(機台原始 log 乾淨)。指令表+範例:[docs/ttl-script-reference.md](docs/ttl-script-reference.md)(前段 ETTerms 獨有、後段與 TeraTerm 共有)。 **v0.5.0** 搜尋 / 關鍵字警示 / TTL 大擴充。**(1) Ctrl+F scrollback 搜尋** — `TerminalView` 內建搜尋列(Enter 往上找、Shift+Enter 往下、F3/Esc),全部命中黃底、目前命中橘底;命中以「絕對行號 = `ScreenBuffer.DroppedLines` + abs」錨定,環形緩衝丟舊行不漂移。**(2) 關鍵字高亮 + 分頁警示** — `AppSettings.KeywordRules``KeywordRule{Text,Enabled}` + 全域開關),Settings 新 **Highlight** 分頁可增刪/勾選;`TerminalView` 每次重繪只掃可見行標紅底(不分大小寫),`Feed` 路徑用獨立 Decoder+去 ANSI 偵測觸發 `KeywordAlert`(每關鍵字 2s 冷卻)→ `SessionPage``WorkspaceView` 把**非 active 分頁**的圓點標紅、切過去自動清除。**(3) TTL 對齊 TeraTerm** — 新 `TtlExpression` 運算式解析器(括號/邏輯/比較/十六進位 `0x`/`$`,失敗退回 legacy 規則相容舊腳本);新增控制流 `goto/call(行內執行,迴圈內可用)/return/for-next/do-loop/until-enduntil/break/continue/end/exit/include/mpause`、等待 `waitln/waitregex/recvln/wait 多字串(TeraTerm 語意:逾時 result=0 繼續)/mtimeout`、字串 `strlen/strcompare/strconcat/strcopy/strinsert/strremove/strmatch/strscan/strreplace/strtrim/strsplit/strjoin/tolower/toupper/str2int/int2str/code2str/str2code/sprintf(→inputstr)/expandenv`、檔案 `fileopen/filereadln(result=1 是 EOF)/filewrite(ln)/fileclose/filecreate/filedelete/filesearch/basename/dirname/makepath/foldercreate/folderdelete/foldersearch/getdir/setdir`、雜項 `beep/getdate/gettime(strftime 子集)/getenv/setenv/random/exec/getver/getttdir/uptime/ifdefined/clipb2var/var2clipb/inputbox/yesnobox/crc32/checksum8/16/32/dispstr`、serial 專用 `sendbreak/setbaud/setdtr/setrts/sendfile``SerialChannel` 新增對應方法);單行 `if <expr> <statement>` 支援;`Preprocess` 引號內 `;` 不再被當註解;系統變數 `inputstr/matchstr/groupmatchstr1-9``_vars` 改大小寫不敏感;**單字串 `wait` 保留 ETTerms settle+逾時中止語意(勿改)**,多字串才是 TeraTerm 語意。`SessionPage``runner.Output`trace`[wait]`/`>>`/錯誤)以灰色 echo 進終端機,**可由 Settings → Terminal 的 `ShowScriptTrace` 開關關閉**`dispstr` 走獨立的 `Display` 事件一律顯示。灰色訊息只進畫面 buffer——不進 ⏺ Log 側錄、不進 AI bridge、不送裝置(機台原始 log 乾淨)。指令表+範例:[docs/ttl-script-reference.md](docs/ttl-script-reference.md)(前段 ETTerms 獨有、後段與 TeraTerm 共有)。
+96
View File
@@ -0,0 +1,96 @@
namespace ETTerms.Ai;
/// <summary>
/// 內建 AI Assistant 聊天視窗的 WebView2 HTML 模板(v0.7.0)。
/// 全內嵌(CSS + JS,無外部依賴,符合 NavigateToString 的離線/CSP 需求)。
///
/// C# 端透過 ExecuteScriptAsync 呼叫這裡的 JS 函式:
/// addUser(text) / addAI(html) / addTool(text) / addError(text) / addNote(text)
/// showThinking() / hideThinking()
/// AI 回覆的 markdown 由 C#Markdig)先轉成 HTML 再傳入 addAI。
/// </summary>
internal static class ChatHtml
{
public const string Page = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--bg: #1c1c20; --text: #dedee2; --dim: #9696a0;
--user-bg: #56408a; --ai-bg: #2b2b33; --border: #3a3a42;
--tool: #b6a878; --err: #eb7878; --accent: #8a63d2;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif; font-size: 14px; line-height: 1.5;
}
#chat { padding: 14px 14px 20px; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; }
.row.user { justify-content: flex-end; }
.row.ai { justify-content: flex-start; }
.bubble {
max-width: 78%; padding: 8px 12px; border-radius: 14px;
white-space: normal; word-wrap: break-word; overflow-wrap: anywhere;
}
.user .bubble { background: var(--user-bg); border-bottom-right-radius: 4px; }
.ai .bubble { background: var(--ai-bg); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.bubble p { margin: 0 0 8px; } .bubble p:last-child { margin-bottom: 0; }
.bubble pre {
background: #14141a; border: 1px solid var(--border); border-radius: 8px;
padding: 10px; overflow-x: auto; margin: 8px 0;
}
.bubble code { font-family: "Cascadia Mono", Consolas, monospace; font-size: 13px; }
.bubble :not(pre) > code { background: #14141a; padding: 1px 5px; border-radius: 4px; }
.bubble ul, .bubble ol { margin: 6px 0; padding-left: 22px; }
.bubble table { border-collapse: collapse; margin: 8px 0; }
.bubble th, .bubble td { border: 1px solid var(--border); padding: 4px 8px; }
.bubble a { color: #9db4ff; }
.note { color: var(--dim); font-size: 12.5px; text-align: center; padding: 2px 0; }
.tool { color: var(--tool); font-size: 12.5px; font-family: "Cascadia Mono", monospace; padding-left: 4px; }
.err { color: var(--err); font-size: 13px; padding-left: 4px; }
/* thinking 動畫泡 */
#thinking { display: none; }
#thinking.on { display: flex; }
.dots { display: inline-flex; gap: 4px; align-items: center; }
.dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--dim);
animation: blink 1.2s infinite both;
}
.dots span:nth-child(2) { animation-delay: .2s; }
.dots span:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%,80%,100% { opacity: .25; } 40% { opacity: 1; } }
</style>
</head>
<body>
<div id="chat">
<div class="row ai" id="thinking">
<div class="bubble"><span class="dots"><span></span><span></span><span></span></span></div>
</div>
</div>
<script>
var chat = document.getElementById('chat');
var thinking = document.getElementById('thinking');
function atBottom() { return window.innerHeight + window.scrollY >= document.body.scrollHeight - 40; }
function scroll() { window.scrollTo(0, document.body.scrollHeight); }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function bubbleRow(cls, innerHtml) {
var row = document.createElement('div'); row.className = 'row ' + cls;
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = innerHtml;
row.appendChild(b); chat.insertBefore(row, thinking); scroll();
}
function addUser(t) { bubbleRow('user', esc(t).replace(/\n/g, '<br>')); }
function addAI(html) { bubbleRow('ai', html); }
function addTool(t) { var d = document.createElement('div'); d.className = 'tool'; d.textContent = ' ' + t; chat.insertBefore(d, thinking); scroll(); }
function addError(t) { var d = document.createElement('div'); d.className = 'err'; d.textContent = ' ' + t; chat.insertBefore(d, thinking); scroll(); }
function addNote(t) { var d = document.createElement('div'); d.className = 'note'; d.textContent = t; chat.insertBefore(d, thinking); scroll(); }
function showThinking() { thinking.classList.add('on'); scroll(); }
function hideThinking() { thinking.classList.remove('on'); }
</script>
</body>
</html>
""";
}
+6
View File
@@ -183,6 +183,12 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.7.0", new DateOnly(2026, 7, 5), "AI chat gets real bubbles, Markdown & a thinking indicator",
[
"The AI Chat pane now renders proper chat bubbles (your messages on the right, the AI's on the left) with full Markdown — code blocks, tables, lists and inline `code` all display nicely.",
"When you send a prompt, an animated \"…\" thinking bubble appears while the AI works and disappears the moment the reply arrives — so you always know it's running.",
"Under the hood this uses WebView2 (built into Windows 11); the model dropdown, [AI] serial tagging, and PDU confirmations all work exactly as before.",
]),
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language", new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
[ [
"New ✨ AI Chat: click ✨ AI Chat in the toolbar to open an AI pane, then use Layout (1×2, 2×2…) to sit it right next to a Serial session — chat on one side while you watch the terminal on the other, just like Claude Code / Kiro.", "New ✨ AI Chat: click ✨ AI Chat in the toolbar to open an AI pane, then use Layout (1×2, 2×2…) to sit it right next to a Serial session — chat on one side while you watch the terminal on the other, just like Claude Code / Kiro.",
+71 -66
View File
@@ -1,47 +1,51 @@
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Ai; using ETTerms.Ai;
using ETTerms.Connections; using ETTerms.Connections;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace ETTerms.App; namespace ETTerms.App;
/// <summary> /// <summary>
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。 /// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
/// ///
/// 呈現走「乾淨對話逐字稿」風格(與 ETTerms 終端機/log 美學一致,非氣泡卡片): /// v0.7.0:訊息區改用 <b>WebView2</b> 渲染真聊天氣泡(user 右 / AI 左)+ Markdown(程式碼區塊、
/// 使用者訊息靠右 accent 色、AI 回覆靠左段落、工具活動灰字縮排、思考中收進 Send 按鈕不洗版 /// 表格、清單)+ 送出後的 thinking 動畫泡。底部控制列(輸入框 / 模型下拉 / Send)仍為 WinForms
/// (真氣泡 + Markdown 的 WebView2 版列為 v0.7.0。)
/// ///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。 /// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary> /// </summary>
public sealed class AiChatView : UserControl public sealed class AiChatView : UserControl
{ {
private readonly RichTextBox _log; private readonly WebView2 _web;
private readonly TextBox _input; private readonly TextBox _input;
private readonly Button _send; private readonly Button _send;
private readonly Label _hint; private readonly Label _hint;
private readonly ComboBox _modelBox; private readonly ComboBox _modelBox;
private bool _suppressModelEvent; private bool _suppressModelEvent;
private bool _ready;
private readonly Queue<string> _pending = new();
private AgentHost? _agent; private AgentHost? _agent;
private OpenAiChatClient? _client; private OpenAiChatClient? _client;
private AiTools? _tools; private AiTools? _tools;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private const string SendText = "Send ⏎"; private static readonly MarkdownPipeline Md =
new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
public AiChatView() public AiChatView()
{ {
Dock = DockStyle.Fill; Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack; BackColor = Theme.WorkspaceBack;
_log = new RichTextBox _web = new WebView2 { Dock = DockStyle.Fill };
{
Dock = DockStyle.Fill, ReadOnly = true, BorderStyle = BorderStyle.None,
BackColor = Color.FromArgb(28, 28, 32), ForeColor = Theme.Text,
Font = new Font("Cascadia Mono", 10f), DetectUrls = false
};
// ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ── // ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) }; var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) };
@@ -70,7 +74,7 @@ public sealed class AiChatView : UserControl
_client.Model = m; _client.Model = m;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個 AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save(); AppSettings.Instance.Save();
AppendDim($"— 模型切換為 {m} —"); AddNote($"— 模型切換為 {m} —");
}; };
var refreshBtn = new Button var refreshBtn = new Button
{ {
@@ -86,7 +90,7 @@ public sealed class AiChatView : UserControl
_send = new Button _send = new Button
{ {
Text = SendText, Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat, Text = "Send ⏎", Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
_send.FlatAppearance.BorderColor = Theme.Accent; _send.FlatAppearance.BorderColor = Theme.Accent;
@@ -106,12 +110,40 @@ public sealed class AiChatView : UserControl
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。" Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。"
}; };
Controls.Add(_log); // Fill Controls.Add(_web); // Fill
Controls.Add(_hint); // Top Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom Controls.Add(bottom); // Bottom
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。"); _ = InitWebAsync();
AppendSystem("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」"); }
private async Task InitWebAsync()
{
try
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms", "WebView2");
Directory.CreateDirectory(dataDir);
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await _web.EnsureCoreWebView2Async(env);
_web.CoreWebView2.Settings.AreDevToolsEnabled = false;
_web.CoreWebView2.Settings.IsZoomControlEnabled = false;
_web.CoreWebView2.NavigationCompleted += (_, _) =>
{
if (_ready) return;
_ready = true;
while (_pending.Count > 0) Exec(_pending.Dequeue());
AddNote("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
AddNote("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」");
};
_web.NavigateToString(ChatHtml.Page);
}
catch (Exception ex)
{
AppLogger.LogError("WebView2 init failed", ex);
_hint.Text = "AI 聊天需要 WebView2 RuntimeWin11 內建)。初始化失敗:" + ex.Message;
_hint.Visible = true;
}
} }
/// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary> /// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary>
@@ -132,9 +164,9 @@ public sealed class AiChatView : UserControl
_client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel); _client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel);
_tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync }; _tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync };
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt); _agent = new AgentHost(_client, _tools, s.AiSystemPrompt);
_agent.AssistantText += t => Ui(() => AppendAssistant(t)); _agent.AssistantText += t => Ui(() => { HideThinking(); AddAI(t); });
_agent.ToolActivity += t => Ui(() => AppendTool(t)); _agent.ToolActivity += t => Ui(() => AddTool(t));
// thinking 不印進對話流(改由 Send 按鈕顯示忙碌),避免洗版。 _agent.Status += st => Ui(() => { if (st == "thinking") ShowThinking(); });
_ = LoadModelsAsync(); _ = LoadModelsAsync();
} }
@@ -167,7 +199,7 @@ public sealed class AiChatView : UserControl
AppSettings.Instance.Save(); AppSettings.Instance.Save();
} }
} }
else AppendError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。"); else AddError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。");
_suppressModelEvent = false; _suppressModelEvent = false;
}); });
} }
@@ -190,59 +222,31 @@ public sealed class AiChatView : UserControl
var text = _input.Text.Trim(); var text = _input.Text.Trim();
if (text.Length == 0) return; if (text.Length == 0) return;
_input.Clear(); _input.Clear();
AppendUser(text); AddUser(text);
SetBusy(true); ShowThinking();
_send.Enabled = false;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); } try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AppendDim("(已取消)"); } catch (OperationCanceledException) { AddNote("(已取消)"); }
catch (Exception ex) { AppendError(ex.Message); } catch (Exception ex) { AddError(ex.Message); }
finally { SetBusy(false); } finally { HideThinking(); _send.Enabled = true; }
} }
private void SetBusy(bool busy) // ── WebView2 interop(呼叫 ChatHtml 裡的 JS 函式)──
{ private void AddUser(string t) => Exec($"addUser({Js(t)})");
_send.Enabled = !busy; private void AddAI(string markdown) => Exec($"addAI({Js(Markdown.ToHtml(markdown, Md))})");
_send.Text = busy ? "…" : SendText; private void AddTool(string t) => Exec($"addTool({Js(t)})");
} private void AddError(string t) => Exec($"addError({Js(t)})");
private void AddNote(string t) => Exec($"addNote({Js(t)})");
private void ShowThinking() => Exec("showThinking()");
private void HideThinking() => Exec("hideThinking()");
// ── 輸出(乾淨 transcript)── private static string Js(string s) => JsonSerializer.Serialize(s);
/// <summary>使用者訊息:靠右 accent 色,無前綴。</summary>
private void AppendUser(string t)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Right;
_log.SelectionColor = Theme.Accent;
_log.SelectionFont = new Font(_log.Font, FontStyle.Bold);
_log.AppendText(t + "\n");
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.AppendText("\n");
_log.ScrollToCaret();
}
/// <summary>AI 回覆:靠左段落。</summary> private void Exec(string js)
private void AppendAssistant(string t)
{ {
_log.SelectionStart = _log.TextLength; if (!_ready) { _pending.Enqueue(js); return; }
_log.SelectionAlignment = HorizontalAlignment.Left; try { _ = _web.CoreWebView2.ExecuteScriptAsync(js); } catch { }
_log.SelectionColor = Theme.Text;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\n\n");
_log.ScrollToCaret();
}
private void AppendTool(string t) => AppendLine("⚙ " + t, Color.FromArgb(150, 150, 158));
private void AppendSystem(string t) => AppendLine(t, Theme.TextDim);
private void AppendError(string t) => AppendLine("⚠ " + t, Color.FromArgb(235, 120, 120));
private void AppendDim(string t) => AppendLine(t, Theme.TextDim);
private void AppendLine(string t, Color color)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionColor = color;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\n");
_log.ScrollToCaret();
} }
private void Ui(Action a) private void Ui(Action a)
@@ -258,6 +262,7 @@ public sealed class AiChatView : UserControl
_cts?.Cancel(); _cts?.Cancel();
_client?.Dispose(); _client?.Dispose();
_tools?.Dispose(); _tools?.Dispose();
_web?.Dispose();
} }
base.Dispose(disposing); base.Dispose(disposing);
} }
+3 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.6.0</Version> <Version>0.7.0</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
@@ -27,7 +27,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="1.3.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<PackageReference Include="SSH.NET" Version="2024.2.0" /> <PackageReference Include="SSH.NET" Version="2024.2.0" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" /> <PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup> </ItemGroup>