feat(ai): chat bubbles + on-the-fly model dropdown

- Chat UI now Claude-style: user messages right-aligned with an accent
  bubble (no "你:" prefix), AI replies left-aligned.
- Model dropdown at the bottom, populated from the endpoint's GET
  /v1/models, lets you switch models without opening Settings; the choice
  is remembered. OpenAiChatClient.Model is now mutable + ListModelsAsync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 22:12:19 +08:00
co-authored by Claude Fable 5
parent 6e955ba247
commit ad0f6c5114
3 changed files with 124 additions and 25 deletions
+26 -3
View File
@@ -15,7 +15,9 @@ namespace ETTerms.Ai;
public sealed class OpenAiChatClient : IDisposable public sealed class OpenAiChatClient : IDisposable
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly string _model;
/// <summary>目前使用的模型;可即時切換(下一輪對話生效),用於底部模型下拉選單。</summary>
public string Model { get; set; }
public OpenAiChatClient(string baseUrl, string apiKey, string model) public OpenAiChatClient(string baseUrl, string apiKey, string model)
{ {
@@ -26,7 +28,28 @@ public sealed class OpenAiChatClient : IDisposable
if (!string.IsNullOrEmpty(apiKey)) if (!string.IsNullOrEmpty(apiKey))
_http.DefaultRequestHeaders.Authorization = _http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
_model = model; Model = model;
}
/// <summary>列出端點可用模型 idOpenAI 相容 GET /models)。失敗回空清單。</summary>
public async Task<List<string>> ListModelsAsync(CancellationToken ct)
{
var list = new List<string>();
try
{
using var resp = await _http.GetAsync("models", ct);
if (!resp.IsSuccessStatusCode) return list;
var text = await resp.Content.ReadAsStringAsync(ct);
var data = JsonNode.Parse(text)?["data"]?.AsArray();
if (data == null) return list;
foreach (var m in data)
{
var id = m?["id"]?.GetValue<string>();
if (!string.IsNullOrEmpty(id)) list.Add(id);
}
}
catch { /* 端點不支援 /models 或連不上 → 回空,由 caller fallback */ }
return list;
} }
/// <summary> /// <summary>
@@ -37,7 +60,7 @@ public sealed class OpenAiChatClient : IDisposable
{ {
var body = new JsonObject var body = new JsonObject
{ {
["model"] = _model, ["model"] = Model,
["messages"] = messages.DeepClone(), ["messages"] = messages.DeepClone(),
["temperature"] = 0.2, ["temperature"] = 0.2,
}; };
+1
View File
@@ -186,6 +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("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.",
"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.",
"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\".", "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.", "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.", "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.",
+97 -22
View File
@@ -18,6 +18,8 @@ public sealed class AiChatView : UserControl
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 bool _suppressModelEvent;
private AgentHost? _agent; private AgentHost? _agent;
private OpenAiChatClient? _client; private OpenAiChatClient? _client;
@@ -36,7 +38,40 @@ public sealed class AiChatView : UserControl
Font = new Font("Cascadia Mono", 10f), DetectUrls = false Font = new Font("Cascadia Mono", 10f), DetectUrls = false
}; };
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 92, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) }; var bottom = new Panel { Dock = DockStyle.Bottom, Height = 122, BackColor = Theme.RailBack, Padding = new Padding(10, 6, 10, 8) };
// 模型列(下拉選單 + 重新整理)——不必進 Settings 就能切端點上的模型
var modelRow = new Panel { Dock = DockStyle.Top, Height = 28, BackColor = Theme.RailBack };
_modelBox = new ComboBox
{
Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList,
BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat, Font = Theme.UiFont
};
_modelBox.SelectedIndexChanged += (_, _) =>
{
if (_suppressModelEvent || _client == null || _modelBox.SelectedItem is not string m) return;
_client.Model = m;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save();
AppendDim($"模型切換為 {m}");
};
var refreshBtn = new Button
{
Text = "↻", Dock = DockStyle.Right, Width = 30, 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
var inputRow = new Panel { Dock = DockStyle.Fill, BackColor = Theme.RailBack, Padding = new Padding(0, 6, 0, 0) };
_input = new TextBox _input = new TextBox
{ {
Dock = DockStyle.Fill, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text, Dock = DockStyle.Fill, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
@@ -53,8 +88,11 @@ public sealed class AiChatView : UserControl
}; };
_send.FlatAppearance.BorderColor = Theme.Accent; _send.FlatAppearance.BorderColor = Theme.Accent;
_send.Click += (_, _) => OnSend(); _send.Click += (_, _) => OnSend();
bottom.Controls.Add(_input); inputRow.Controls.Add(_input); // Fill
bottom.Controls.Add(_send); inputRow.Controls.Add(_send); // Right
bottom.Controls.Add(inputRow); // Fill
bottom.Controls.Add(modelRow); // Top
_hint = new Label _hint = new Label
{ {
@@ -92,6 +130,29 @@ public sealed class AiChatView : UserControl
_agent.AssistantText += t => Ui(() => AppendAssistant(t)); _agent.AssistantText += t => Ui(() => AppendAssistant(t));
_agent.ToolActivity += t => Ui(() => AppendTool(t)); _agent.ToolActivity += t => Ui(() => AppendTool(t));
_agent.Status += st => Ui(() => { if (st == "thinking") AppendDim("…thinking"); }); _agent.Status += st => Ui(() => { if (st == "thinking") AppendDim("…thinking"); });
_ = LoadModelsAsync();
}
/// <summary>拉端點可用模型填入下拉選單(GET /v1/models);失敗則只放目前設定的那個。</summary>
private async Task LoadModelsAsync()
{
if (_client == null) return;
var current = _client.Model;
List<string> models;
try { models = await _client.ListModelsAsync(CancellationToken.None); }
catch { models = new(); }
if (!models.Contains(current) && !string.IsNullOrEmpty(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;
_suppressModelEvent = false;
});
} }
private Task<bool> ConfirmOnUiAsync(string message) private Task<bool> ConfirmOnUiAsync(string message)
@@ -121,33 +182,47 @@ public sealed class AiChatView : UserControl
finally { _send.Enabled = true; } finally { _send.Enabled = true; }
} }
// ── 輸出 helpers ── // ── 輸出 helpersClaude 風:user 靠右氣泡、AI 靠左純文字)──
private void AppendUser(string t) => Append("你", t, Theme.Accent); /// <summary>使用者訊息:右對齊 + accent 底色氣泡感,不加「你:」前綴。</summary>
private void AppendAssistant(string t) => Append("AI", t, Theme.SerialColor); private void AppendUser(string t)
private void AppendTool(string t) => Append("⚙ tool", t, Color.FromArgb(190, 170, 120));
private void AppendSystem(string t) => Append("", t, Theme.TextDim);
private void AppendError(string t) => Append("錯誤", t, Color.FromArgb(235, 120, 120));
private void AppendDim(string t)
{ {
_log.SelectionStart = _log.TextLength; _log.SelectionStart = _log.TextLength;
_log.SelectionColor = Theme.TextDim; _log.SelectionAlignment = HorizontalAlignment.Right;
_log.AppendText(t + "\n"); _log.SelectionColor = Theme.Text;
_log.SelectionBackColor = Theme.AccentDim;
_log.SelectionFont = _log.Font;
_log.AppendText(" " + t + " \n");
_log.SelectionBackColor = _log.BackColor;
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.AppendText("\n");
_log.ScrollToCaret(); _log.ScrollToCaret();
} }
private void Append(string who, string text, Color color) /// <summary>AI 回應:左對齊純文字。</summary>
private void AppendAssistant(string t)
{ {
_log.SelectionStart = _log.TextLength; _log.SelectionStart = _log.TextLength;
if (who.Length > 0) _log.SelectionAlignment = HorizontalAlignment.Left;
{ _log.SelectionColor = Theme.Text;
_log.SelectionColor = color; _log.SelectionBackColor = _log.BackColor;
_log.SelectionFont = new Font(_log.Font, FontStyle.Bold);
_log.AppendText($"{who}: ");
}
_log.SelectionColor = who.Length > 0 ? Theme.Text : color;
_log.SelectionFont = _log.Font; _log.SelectionFont = _log.Font;
_log.AppendText(text + "\n"); _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 AppendLeftDim(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");
_log.ScrollToCaret(); _log.ScrollToCaret();
} }