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:
@@ -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>列出端點可用模型 id(OpenAI 相容 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 ──
|
// ── 輸出 helpers(Claude 風: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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user