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:
@@ -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>
|
||||
""";
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 Runtime(Win11 內建)。初始化失敗:" + 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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user