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
+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 =
[
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 ✨ 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.IO;
using System.Text.Json;
using System.Windows.Forms;
using ETTerms.Ai;
using ETTerms.Connections;
using ETTerms.Infrastructure;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace ETTerms.App;
/// <summary>
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
///
/// 呈現走「乾淨對話逐字稿」風格(與 ETTerms 終端機/log 美學一致,非氣泡卡片):
/// 使用者訊息靠右 accent 色、AI 回覆靠左段落、工具活動灰字縮排、思考中收進 Send 按鈕不洗版
/// (真氣泡 + Markdown 的 WebView2 版列為 v0.7.0。)
/// v0.7.0:訊息區改用 <b>WebView2</b> 渲染真聊天氣泡(user 右 / AI 左)+ Markdown(程式碼區塊、
/// 表格、清單)+ 送出後的 thinking 動畫泡。底部控制列(輸入框 / 模型下拉 / Send)仍為 WinForms
///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary>
public sealed class AiChatView : UserControl
{
private readonly RichTextBox _log;
private readonly WebView2 _web;
private readonly TextBox _input;
private readonly Button _send;
private readonly Label _hint;
private readonly ComboBox _modelBox;
private bool _suppressModelEvent;
private bool _ready;
private readonly Queue<string> _pending = new();
private AgentHost? _agent;
private OpenAiChatClient? _client;
private AiTools? _tools;
private CancellationTokenSource? _cts;
private const string SendText = "Send ⏎";
private static readonly MarkdownPipeline Md =
new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
public AiChatView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
_log = new RichTextBox
{
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
};
_web = new WebView2 { Dock = DockStyle.Fill };
// ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
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;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save();
AppendDim($"— 模型切換為 {m} —");
AddNote($"— 模型切換為 {m} —");
};
var refreshBtn = new Button
{
@@ -86,7 +90,7 @@ public sealed class AiChatView : UserControl
_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
};
_send.FlatAppearance.BorderColor = Theme.Accent;
@@ -106,12 +110,40 @@ public sealed class AiChatView : UserControl
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。"
};
Controls.Add(_log); // Fill
Controls.Add(_web); // Fill
Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
AppendSystem("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」");
_ = InitWebAsync();
}
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>
@@ -132,9 +164,9 @@ public sealed class AiChatView : UserControl
_client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel);
_tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync };
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt);
_agent.AssistantText += t => Ui(() => AppendAssistant(t));
_agent.ToolActivity += t => Ui(() => AppendTool(t));
// thinking 不印進對話流(改由 Send 按鈕顯示忙碌),避免洗版。
_agent.AssistantText += t => Ui(() => { HideThinking(); AddAI(t); });
_agent.ToolActivity += t => Ui(() => AddTool(t));
_agent.Status += st => Ui(() => { if (st == "thinking") ShowThinking(); });
_ = LoadModelsAsync();
}
@@ -167,7 +199,7 @@ public sealed class AiChatView : UserControl
AppSettings.Instance.Save();
}
}
else AppendError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。");
else AddError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。");
_suppressModelEvent = false;
});
}
@@ -190,59 +222,31 @@ public sealed class AiChatView : UserControl
var text = _input.Text.Trim();
if (text.Length == 0) return;
_input.Clear();
AppendUser(text);
SetBusy(true);
AddUser(text);
ShowThinking();
_send.Enabled = false;
_cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AppendDim("(已取消)"); }
catch (Exception ex) { AppendError(ex.Message); }
finally { SetBusy(false); }
catch (OperationCanceledException) { AddNote("(已取消)"); }
catch (Exception ex) { AddError(ex.Message); }
finally { HideThinking(); _send.Enabled = true; }
}
private void SetBusy(bool busy)
{
_send.Enabled = !busy;
_send.Text = busy ? "…" : SendText;
}
// ── WebView2 interop(呼叫 ChatHtml 裡的 JS 函式)──
private void AddUser(string t) => Exec($"addUser({Js(t)})");
private void AddAI(string markdown) => Exec($"addAI({Js(Markdown.ToHtml(markdown, Md))})");
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)──
/// <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();
}
private static string Js(string s) => JsonSerializer.Serialize(s);
/// <summary>AI 回覆:靠左段落。</summary>
private void AppendAssistant(string t)
private void Exec(string js)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left;
_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();
if (!_ready) { _pending.Enqueue(js); return; }
try { _ = _web.CoreWebView2.ExecuteScriptAsync(js); } catch { }
}
private void Ui(Action a)
@@ -258,6 +262,7 @@ public sealed class AiChatView : UserControl
_cts?.Cancel();
_client?.Dispose();
_tools?.Dispose();
_web?.Dispose();
}
base.Dispose(disposing);
}
+3 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.6.0</Version>
<Version>0.7.0</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
@@ -27,7 +27,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="1.3.2" />
<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="System.IO.Ports" Version="8.0.0" />
</ItemGroup>