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:
@@ -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.",
|
||||
|
||||
@@ -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); // Top(Send 上方間距)
|
||||
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>每次切到本檢視時呼叫,依最新設定重建 client(Provider 改了會生效)。</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); }
|
||||
}
|
||||
|
||||
// ── 輸出 helpers(Claude 風: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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user