diff --git a/src/ETTerms/Ai/OpenAiChatClient.cs b/src/ETTerms/Ai/OpenAiChatClient.cs index 25a2496..e96529d 100644 --- a/src/ETTerms/Ai/OpenAiChatClient.cs +++ b/src/ETTerms/Ai/OpenAiChatClient.cs @@ -15,7 +15,9 @@ namespace ETTerms.Ai; public sealed class OpenAiChatClient : IDisposable { private readonly HttpClient _http; - private readonly string _model; + + /// 目前使用的模型;可即時切換(下一輪對話生效),用於底部模型下拉選單。 + public string Model { get; set; } public OpenAiChatClient(string baseUrl, string apiKey, string model) { @@ -26,7 +28,28 @@ public sealed class OpenAiChatClient : IDisposable if (!string.IsNullOrEmpty(apiKey)) _http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); - _model = model; + Model = model; + } + + /// 列出端點可用模型 id(OpenAI 相容 GET /models)。失敗回空清單。 + public async Task> ListModelsAsync(CancellationToken ct) + { + var list = new List(); + 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(); + if (!string.IsNullOrEmpty(id)) list.Add(id); + } + } + catch { /* 端點不支援 /models 或連不上 → 回空,由 caller fallback */ } + return list; } /// @@ -37,7 +60,7 @@ public sealed class OpenAiChatClient : IDisposable { var body = new JsonObject { - ["model"] = _model, + ["model"] = Model, ["messages"] = messages.DeepClone(), ["temperature"] = 0.2, }; diff --git a/src/ETTerms/App/AboutView.cs b/src/ETTerms/App/AboutView.cs index 07a9862..1d5ac45 100644 --- a/src/ETTerms/App/AboutView.cs +++ b/src/ETTerms/App/AboutView.cs @@ -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 ✨ 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\".", "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.", diff --git a/src/ETTerms/App/AiChatView.cs b/src/ETTerms/App/AiChatView.cs index 69fe6a1..a063646 100644 --- a/src/ETTerms/App/AiChatView.cs +++ b/src/ETTerms/App/AiChatView.cs @@ -18,6 +18,8 @@ public sealed class AiChatView : UserControl private readonly TextBox _input; private readonly Button _send; private readonly Label _hint; + private readonly ComboBox _modelBox; + private bool _suppressModelEvent; private AgentHost? _agent; private OpenAiChatClient? _client; @@ -36,7 +38,40 @@ public sealed class AiChatView : UserControl 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 { 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.Click += (_, _) => OnSend(); - bottom.Controls.Add(_input); - bottom.Controls.Add(_send); + inputRow.Controls.Add(_input); // Fill + inputRow.Controls.Add(_send); // Right + + bottom.Controls.Add(inputRow); // Fill + bottom.Controls.Add(modelRow); // Top _hint = new Label { @@ -92,6 +130,29 @@ public sealed class AiChatView : UserControl _agent.AssistantText += t => Ui(() => AppendAssistant(t)); _agent.ToolActivity += t => Ui(() => AppendTool(t)); _agent.Status += st => Ui(() => { if (st == "thinking") AppendDim("…thinking"); }); + + _ = LoadModelsAsync(); + } + + /// 拉端點可用模型填入下拉選單(GET /v1/models);失敗則只放目前設定的那個。 + private async Task LoadModelsAsync() + { + if (_client == null) return; + var current = _client.Model; + List 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 ConfirmOnUiAsync(string message) @@ -121,33 +182,47 @@ public sealed class AiChatView : UserControl finally { _send.Enabled = true; } } - // ── 輸出 helpers ── - private void AppendUser(string t) => Append("你", t, Theme.Accent); - private void AppendAssistant(string t) => Append("AI", t, Theme.SerialColor); - 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) + // ── 輸出 helpers(Claude 風:user 靠右氣泡、AI 靠左純文字)── + /// 使用者訊息:右對齊 + accent 底色氣泡感,不加「你:」前綴。 + private void AppendUser(string t) { _log.SelectionStart = _log.TextLength; - _log.SelectionColor = Theme.TextDim; - _log.AppendText(t + "\n"); + _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.SelectionAlignment = HorizontalAlignment.Left; + _log.AppendText("\n"); _log.ScrollToCaret(); } - private void Append(string who, string text, Color color) + /// AI 回應:左對齊純文字。 + private void AppendAssistant(string t) { _log.SelectionStart = _log.TextLength; - if (who.Length > 0) - { - _log.SelectionColor = color; - _log.SelectionFont = new Font(_log.Font, FontStyle.Bold); - _log.AppendText($"{who}: "); - } - _log.SelectionColor = who.Length > 0 ? Theme.Text : color; + _log.SelectionAlignment = HorizontalAlignment.Left; + _log.SelectionColor = Theme.Text; + _log.SelectionBackColor = _log.BackColor; _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(); }