refactor(ai): clean transcript chat + model dropdown; drop Settings model field

Settle the AI chat pane on a clean, terminal-consistent layout instead of
faux bubbles in a RichTextBox:
- Messages: user right-aligned accent, AI left, tool activity quiet gray,
  "thinking" folded into the Send button (no transcript spam).
- Bottom bar: input (fill) + bottom-right column (model dropdown over Send).
- Model dropdown lists the endpoint's /v1/models, switches on the fly, and
  is remembered — Settings → AI Assistant now only holds Base URL / API Key
  / system prompt (configured = Base URL set). OpenAiChatClient.Model is
  mutable + ListModelsAsync.

Real chat bubbles + Markdown via WebView2 are logged as a v0.7.0 option in
ARCHITECTURE Future Extensions rather than hacked onto RichTextBox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 22:31:09 +08:00
co-authored by Claude Fable 5
parent ad0f6c5114
commit 639d10438d
5 changed files with 88 additions and 75 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ public sealed class AboutView : UserControl
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.",
"Chat-style bubbles: your messages sit on the right, the AI's replies on the left. A model dropdown at the bottom lists the models your endpoint offers, so you can switch models on the fly without going into Settings.",
"Clean transcript layout: your messages align right in accent color, the AI's replies read left, and tool activity stays as quiet gray notes — same tidy feel as the terminal. A model dropdown at the bottom-right lists the models your endpoint offers, so you pick and switch models right there, no trip to Settings.",
"Talk in plain language to send serial commands and control PDU outlets — e.g. \"attach to COM3, send help and show me the reply\" or \"connect to the PDU and power-cycle outlet 3\".",
"Bring your own AI endpoint: point it at any OpenAI-compatible server (a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI). Set it up in Settings → AI Assistant; leave it blank and the assistant simply stays off.",
"Your API key is stored in Windows Credential Manager, never in a settings file — and no endpoint ships inside the app, so a copy you hand to someone else has the assistant disabled by default.",
+78 -59
View File
@@ -7,10 +7,13 @@ using ETTerms.Infrastructure;
namespace ETTerms.App;
/// <summary>
/// 內建 AI Assistant 檢視(Activity Rail 的一個 view)。自然語言驅動 serial + PDU。
/// Provider 未設定(Base URL 空)時顯示提示,引導到 Settings → AI Assistant。
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
///
/// ⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// 呈現走「乾淨對話逐字稿」風格(與 ETTerms 終端機/log 美學一致,非氣泡卡片):
/// 使用者訊息靠右 accent 色、AI 回覆靠左段落、工具活動灰字縮排、思考中收進 Send 按鈕不洗版。
/// (真氣泡 + Markdown 的 WebView2 版列為 v0.7.0。)
///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary>
public sealed class AiChatView : UserControl
{
@@ -26,6 +29,8 @@ public sealed class AiChatView : UserControl
private AiTools? _tools;
private CancellationTokenSource? _cts;
private const string SendText = "Send ⏎";
public AiChatView()
{
Dock = DockStyle.Fill;
@@ -38,10 +43,22 @@ public sealed class AiChatView : UserControl
Font = new Font("Cascadia Mono", 10f), DetectUrls = false
};
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 122, BackColor = Theme.RailBack, Padding = new Padding(10, 6, 10, 8) };
// ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) };
// 模型列(下拉選單 + 重新整理)——不必進 Settings 就能切端點上的模型
var modelRow = new Panel { Dock = DockStyle.Top, Height = 28, BackColor = Theme.RailBack };
_input = new TextBox
{
Dock = DockStyle.Fill, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle
};
_input.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter && !e.Shift) { e.Handled = e.SuppressKeyPress = true; OnSend(); }
};
var rightCol = new Panel { Dock = DockStyle.Right, Width = 178, BackColor = Theme.RailBack, Padding = new Padding(8, 0, 0, 0) };
var modelWrap = new Panel { Dock = DockStyle.Top, Height = 24, BackColor = Theme.RailBack };
_modelBox = new ComboBox
{
Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList,
@@ -53,69 +70,57 @@ public sealed class AiChatView : UserControl
_client.Model = m;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save();
AppendDim($"模型切換為 {m}");
AppendDim($"模型切換為 {m}");
};
var refreshBtn = new Button
{
Text = "↻", Dock = DockStyle.Right, Width = 30, FlatStyle = FlatStyle.Flat,
Text = "↻", Dock = DockStyle.Right, Width = 24, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
refreshBtn.FlatAppearance.BorderColor = Theme.Border;
refreshBtn.Click += (_, _) => _ = LoadModelsAsync();
var modelLbl = new Label
{
Text = "Model", Dock = DockStyle.Left, Width = 46, ForeColor = Theme.TextDim,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft
};
modelRow.Controls.Add(_modelBox); // Fill
modelRow.Controls.Add(refreshBtn); // Right
modelRow.Controls.Add(modelLbl); // Left
modelWrap.Controls.Add(_modelBox); // Fill
modelWrap.Controls.Add(refreshBtn); // Right
var gap = new Panel { Dock = DockStyle.Top, Height = 6, BackColor = Theme.RailBack };
var inputRow = new Panel { Dock = DockStyle.Fill, BackColor = Theme.RailBack, Padding = new Padding(0, 6, 0, 0) };
_input = new TextBox
{
Dock = DockStyle.Fill, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle
};
_input.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter && !e.Shift) { e.Handled = e.SuppressKeyPress = true; OnSend(); }
};
_send = new Button
{
Text = "Send ⏎", Dock = DockStyle.Right, Width = 90, FlatStyle = FlatStyle.Flat,
Text = SendText, Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
_send.FlatAppearance.BorderColor = Theme.Accent;
_send.Click += (_, _) => OnSend();
inputRow.Controls.Add(_input); // Fill
inputRow.Controls.Add(_send); // Right
bottom.Controls.Add(inputRow); // Fill
bottom.Controls.Add(modelRow); // Top
rightCol.Controls.Add(_send); // Fill 先加
rightCol.Controls.Add(gap); // TopSend 上方間距)
rightCol.Controls.Add(modelWrap); // Top(最上:模型下拉)
bottom.Controls.Add(_input); // Fill 先加
bottom.Controls.Add(rightCol); // Right(右下角)
_hint = new Label
{
Dock = DockStyle.Top, Height = 40, BackColor = Color.FromArgb(60, 50, 30), ForeColor = Theme.Text,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter, Visible = false,
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL / API Key / Model。"
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL API Key。"
};
Controls.Add(_log); // Fill
Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。\n" +
"例:「列出目前的 serial session」/「接上 COM3,送 help 看回應」/「連上 PDU 192.168.1.50,把 outlet 3 重開」\n");
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
AppendSystem("例:「列出目前的 serial session」「接上 COM3送 help 看回應」「連上 PDU 192.168.1.50把 outlet 3 重開」");
}
/// <summary>每次切到本檢視時呼叫,依最新設定重建 clientProvider 改了會生效)。</summary>
/// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary>
public void RefreshProvider()
{
var s = AppSettings.Instance;
bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl) && !string.IsNullOrWhiteSpace(s.AiModel);
bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl);
_hint.Visible = !configured;
_input.Enabled = _send.Enabled = configured;
_input.Enabled = _send.Enabled = _modelBox.Enabled = configured;
_client?.Dispose(); _client = null;
_tools?.Dispose(); _tools = null;
@@ -129,12 +134,12 @@ public sealed class AiChatView : UserControl
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt);
_agent.AssistantText += t => Ui(() => AppendAssistant(t));
_agent.ToolActivity += t => Ui(() => AppendTool(t));
_agent.Status += st => Ui(() => { if (st == "thinking") AppendDim("…thinking"); });
// thinking 不印進對話流(改由 Send 按鈕顯示忙碌),避免洗版。
_ = LoadModelsAsync();
}
/// <summary>拉端點可用模型填入下拉選單(GET /v1/models);失敗則只放目前設定的那個。</summary>
/// <summary>拉端點可用模型填入下拉選單(GET /v1/models);沒設過模型就自動選第一個。</summary>
private async Task LoadModelsAsync()
{
if (_client == null) return;
@@ -142,15 +147,27 @@ public sealed class AiChatView : UserControl
List<string> models;
try { models = await _client.ListModelsAsync(CancellationToken.None); }
catch { models = new(); }
if (!models.Contains(current) && !string.IsNullOrEmpty(current)) models.Insert(0, current);
if (!string.IsNullOrEmpty(current) && !models.Contains(current)) models.Insert(0, current);
Ui(() =>
{
_suppressModelEvent = true;
_modelBox.Items.Clear();
foreach (var m in models) _modelBox.Items.Add(m);
if (_modelBox.Items.Contains(current)) _modelBox.SelectedItem = current;
else if (_modelBox.Items.Count > 0) _modelBox.SelectedIndex = 0;
string pick = !string.IsNullOrEmpty(current) && models.Contains(current) ? current
: models.Count > 0 ? models[0] : "";
if (pick.Length > 0)
{
_modelBox.SelectedItem = pick;
_client!.Model = pick;
if (AppSettings.Instance.AiModel != pick)
{
AppSettings.Instance.AiModel = pick;
AppSettings.Instance.Save();
}
}
else AppendError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。");
_suppressModelEvent = false;
});
}
@@ -174,52 +191,54 @@ public sealed class AiChatView : UserControl
if (text.Length == 0) return;
_input.Clear();
AppendUser(text);
_send.Enabled = false;
SetBusy(true);
_cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AppendDim("(已取消)"); }
catch (Exception ex) { AppendError(ex.Message); }
finally { _send.Enabled = true; }
finally { SetBusy(false); }
}
// ── 輸出 helpersClaude 風:user 靠右氣泡、AI 靠左純文字)──
/// <summary>使用者訊息:右對齊 + accent 底色氣泡感,不加「你:」前綴。</summary>
private void SetBusy(bool busy)
{
_send.Enabled = !busy;
_send.Text = busy ? "…" : SendText;
}
// ── 輸出(乾淨 transcript)──
/// <summary>使用者訊息:靠右 accent 色,無前綴。</summary>
private void AppendUser(string t)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Right;
_log.SelectionColor = Theme.Text;
_log.SelectionBackColor = Theme.AccentDim;
_log.SelectionFont = _log.Font;
_log.AppendText(" " + t + " \n");
_log.SelectionBackColor = _log.BackColor;
_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>
/// <summary>AI 回覆:靠左段落。</summary>
private void AppendAssistant(string t)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionColor = Theme.Text;
_log.SelectionBackColor = _log.BackColor;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\n\n");
_log.ScrollToCaret();
}
private void AppendTool(string t) => AppendLeftDim("⚙ " + t, Color.FromArgb(190, 170, 120));
private void AppendSystem(string t) => AppendLeftDim(t, Theme.TextDim);
private void AppendError(string t) => AppendLeftDim(t, Color.FromArgb(235, 120, 120));
private void AppendDim(string t) => AppendLeftDim(t, Theme.TextDim);
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 AppendLeftDim(string t, Color color)
private void AppendLine(string t, Color color)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionBackColor = _log.BackColor;
_log.SelectionColor = color;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\n");
+6 -13
View File
@@ -312,9 +312,10 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(new Label
{
Text = "Bring your own OpenAI-compatible endpoint (Ollama / LiteLLM / a company gateway / OpenAI…).\n" +
"Leave blank to keep the AI Assistant disabled. The model must support function calling.\n" +
"Leave blank to keep the AI Assistant disabled. Pick the model from the dropdown inside the\n" +
"AI Chat pane (it lists what your endpoint offers). The model must support function calling.\n" +
"The API key is stored in Windows Credential Manager, never in settings.json or the app.",
AutoSize = false, Width = 620, Height = 54,
AutoSize = false, Width = 620, Height = 68,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 10)
});
@@ -324,12 +325,6 @@ public sealed class SettingsView : UserControl
BorderStyle = BorderStyle.FixedSingle, Text = s.AiBaseUrl,
PlaceholderText = "http://localhost:11434/v1"
};
var model = new TextBox
{
Width = 260, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.FixedSingle, Text = s.AiModel,
PlaceholderText = "e.g. a function-calling model name"
};
var apiKey = new TextBox
{
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
@@ -345,7 +340,6 @@ public sealed class SettingsView : UserControl
};
flow.Controls.Add(MakeRow("Base URL (with /v1)", baseUrl));
flow.Controls.Add(MakeRow("Model", model));
flow.Controls.Add(MakeRow("API Key", apiKey));
flow.Controls.Add(MakeSpacer(4));
flow.Controls.Add(new Label
@@ -368,16 +362,15 @@ public sealed class SettingsView : UserControl
save.Click += (_, _) =>
{
s.AiBaseUrl = baseUrl.Text.Trim();
s.AiModel = model.Text.Trim();
s.AiSystemPrompt = sysPrompt.Text.Trim();
s.Save();
var key = apiKey.Text;
if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey");
else CredentialVault.Set("ETTerms/AiApiKey", key);
MessageBox.Show(this,
string.IsNullOrWhiteSpace(s.AiBaseUrl) || string.IsNullOrWhiteSpace(s.AiModel)
? "Saved. AI Assistant stays disabled until Base URL and Model are both set."
: "Saved. Open the AI Assistant view (✨ in the rail) to start.",
string.IsNullOrWhiteSpace(s.AiBaseUrl)
? "Saved. AI Assistant stays disabled until you set a Base URL."
: "Saved. Open AI Chat in the workspace toolbar, then pick a model from the dropdown.",
"AI Assistant", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
flow.Controls.Add(save);