feat: built-in AI Assistant with BYO endpoint (v0.6.0, Phase 10)
Add a GUI AI Assistant view (✨ rail) that drives serial + PDU in plain language, without depending on Claude/Kiro. In-process function calling (no MCP hop): Ai/OpenAiChatClient (minimal OpenAI-compatible client) + Ai/AgentHost (hand-written agent loop) + Ai/AiTools (serial via the existing SerialBridge with [AI] echo; PDU via ETTerms.PduCore). BYO endpoint: Base URL / Model / API Key set in Settings → AI Assistant, blank by default = disabled. No private endpoint ships in the app; API key lives in Windows Credential Manager, never in settings.json or code. Safety: destructive PDU actions (outlet off / power-cycle) require a GUI confirmation; every tool call is written to AppLogger. Existing Serial/ PDU MCP servers (Settings → AI MCP) are unaffected and keep serving external AI CLIs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// 內建 AI Assistant 的 agent 迴圈:維護對話歷史,呼叫 <see cref="OpenAiChatClient"/>,
|
||||
/// 模型回 tool_calls 時執行 <see cref="AiTools"/> 再把結果餵回,直到模型給出最終文字回應。
|
||||
///
|
||||
/// UI 事件(Status / AssistantText / ToolActivity)皆在背景緒觸發,訂閱者需自行 Invoke 回 UI thread。
|
||||
/// </summary>
|
||||
public sealed class AgentHost
|
||||
{
|
||||
private readonly OpenAiChatClient _client;
|
||||
private readonly AiTools _tools;
|
||||
private readonly JsonArray _messages = new();
|
||||
private const int MaxToolRounds = 8;
|
||||
|
||||
public event Action<string>? AssistantText; // 最終文字回應
|
||||
public event Action<string>? ToolActivity; // 「呼叫 serial_write …」之類過程
|
||||
public event Action<string>? Status; // thinking / done
|
||||
|
||||
private static string DefaultSystemPrompt =>
|
||||
"你是 ETTerms 內建的硬體工程助理。可透過工具收發序列埠(serial)與控制 PDU 電源插座。" +
|
||||
"回答用繁體中文、簡潔。動手操作前先說明你要做什麼。" +
|
||||
"破壞性動作(關插座 / power-cycle)會由使用者在 GUI 確認,你只需正常呼叫工具。" +
|
||||
"serial 操作前必須先 serial_attach 到 GUI 已開啟的 session。";
|
||||
|
||||
public AgentHost(OpenAiChatClient client, AiTools tools, string? systemPrompt)
|
||||
{
|
||||
_client = client;
|
||||
_tools = tools;
|
||||
_messages.Add(new JsonObject
|
||||
{
|
||||
["role"] = "system",
|
||||
["content"] = string.IsNullOrWhiteSpace(systemPrompt) ? DefaultSystemPrompt : systemPrompt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>送出一句使用者訊息,跑完 agent 迴圈(含工具呼叫)。</summary>
|
||||
public async Task SendAsync(string userText, CancellationToken ct)
|
||||
{
|
||||
_messages.Add(new JsonObject { ["role"] = "user", ["content"] = userText });
|
||||
var tools = _tools.GetSchemas();
|
||||
|
||||
for (int round = 0; round < MaxToolRounds; round++)
|
||||
{
|
||||
Status?.Invoke("thinking");
|
||||
var msg = await _client.CompleteAsync(_messages, tools, ct);
|
||||
_messages.Add((JsonObject)msg.DeepClone());
|
||||
|
||||
var toolCalls = msg["tool_calls"]?.AsArray();
|
||||
if (toolCalls == null || toolCalls.Count == 0)
|
||||
{
|
||||
var content = msg["content"]?.GetValue<string>() ?? "";
|
||||
AssistantText?.Invoke(content);
|
||||
Status?.Invoke("done");
|
||||
return;
|
||||
}
|
||||
|
||||
// 執行每個 tool call,把結果以 role=tool 加回歷史
|
||||
foreach (var tcNode in toolCalls)
|
||||
{
|
||||
var tc = tcNode!.AsObject();
|
||||
string id = tc["id"]?.GetValue<string>() ?? "";
|
||||
var fn = tc["function"]?.AsObject();
|
||||
string fname = fn?["name"]?.GetValue<string>() ?? "";
|
||||
string argStr = fn?["arguments"]?.GetValue<string>() ?? "{}";
|
||||
|
||||
JsonObject args;
|
||||
try { args = JsonNode.Parse(string.IsNullOrWhiteSpace(argStr) ? "{}" : argStr)!.AsObject(); }
|
||||
catch { args = new JsonObject(); }
|
||||
|
||||
ToolActivity?.Invoke($"{fname}({argStr})");
|
||||
string result = await _tools.InvokeAsync(fname, args, ct);
|
||||
|
||||
_messages.Add(new JsonObject
|
||||
{
|
||||
["role"] = "tool",
|
||||
["tool_call_id"] = id,
|
||||
["content"] = result
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
AssistantText?.Invoke("(已達工具呼叫上限,停止。請縮小問題或分步再試。)");
|
||||
Status?.Invoke("done");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using ETTerms.Infrastructure;
|
||||
using ETTerms.PduCore;
|
||||
using ETTerms.Sessions;
|
||||
|
||||
namespace ETTerms.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// 內建 AI Assistant 的工具集:serial 收發(重用 GUI 持有的 <see cref="SerialBridge"/> session,
|
||||
/// AI 的 TX 照樣以 [AI] 標色顯示在終端機)+ PDU 電源控制(<see cref="PduController"/>)。
|
||||
///
|
||||
/// 全部 in-process 直呼——不經 MCP 子行程 / named pipe(那是給外部 AI CLI 用的)。
|
||||
/// 破壞性 PDU 動作(關插座 / power-cycle)一律經 <see cref="ConfirmAsync"/> 由 GUI 彈確認框;
|
||||
/// 每筆工具呼叫寫 AppLogger 留跡。
|
||||
/// </summary>
|
||||
public sealed class AiTools : IDisposable
|
||||
{
|
||||
/// <summary>破壞性動作確認:回 true 才執行。由 UI 提供(彈 MessageBox)。</summary>
|
||||
public Func<string, Task<bool>> ConfirmAsync { get; set; } = _ => Task.FromResult(false);
|
||||
|
||||
// ── serial attach 狀態(供 serial_read 累積 RX)──
|
||||
private SerialBridgeEndpoint? _attached;
|
||||
private readonly StringBuilder _rxBuffer = new();
|
||||
private readonly object _rxLock = new();
|
||||
private Action<byte[]>? _rxHandler;
|
||||
private readonly System.Text.Decoder _dec = Encoding.UTF8.GetDecoder();
|
||||
|
||||
// ── PDU 連線登錄(本 AI session 內,IP → controller)──
|
||||
private readonly Dictionary<string, PduController> _pdus = new(StringComparer.OrdinalIgnoreCase);
|
||||
private const int PduPortCount = 12;
|
||||
|
||||
/// <summary>OpenAI tools schema(function calling 用)。</summary>
|
||||
public JsonArray GetSchemas()
|
||||
{
|
||||
JsonObject Fn(string name, string desc, JsonObject props, params string[] required)
|
||||
{
|
||||
var req = new JsonArray();
|
||||
foreach (var r in required) req.Add(r);
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "function",
|
||||
["function"] = new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["description"] = desc,
|
||||
["parameters"] = new JsonObject
|
||||
{
|
||||
["type"] = "object",
|
||||
["properties"] = props,
|
||||
["required"] = req
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
JsonObject Str(string d) => new() { ["type"] = "string", ["description"] = d };
|
||||
JsonObject Int(string d) => new() { ["type"] = "integer", ["description"] = d };
|
||||
JsonObject Bool(string d) => new() { ["type"] = "boolean", ["description"] = d };
|
||||
|
||||
return new JsonArray
|
||||
{
|
||||
Fn("serial_list", "List the serial sessions currently open in the ETTerms GUI (name + baud).",
|
||||
new JsonObject()),
|
||||
Fn("serial_attach", "Bind to an open GUI serial session by name (e.g. COM3). Required before write/read.",
|
||||
new JsonObject { ["session"] = Str("Session name / COM port, e.g. COM3") }, "session"),
|
||||
Fn("serial_write", "Send text to the attached serial session (echoes to the terminal tagged [AI]).",
|
||||
new JsonObject { ["text"] = Str("Text to send"), ["appendNewline"] = Bool("Append the session newline (default true)") }, "text"),
|
||||
Fn("serial_read", "Read accumulated RX from the attached serial session; optionally wait for a substring.",
|
||||
new JsonObject { ["waitFor"] = Str("Optional substring to wait for"), ["timeoutMs"] = Int("Max wait ms (default 3000)") }),
|
||||
Fn("pdu_connect", "Connect to an SNMP PDU by IP and verify it responds. Required before other pdu_* calls.",
|
||||
new JsonObject { ["ip"] = Str("PDU IP address") }, "ip"),
|
||||
Fn("pdu_status", "Read all outlets' state / current(mA) / power(W) of a connected PDU.",
|
||||
new JsonObject { ["ip"] = Str("PDU IP address") }, "ip"),
|
||||
Fn("pdu_set_port", "Turn a PDU outlet on or off (turning OFF requires user confirmation).",
|
||||
new JsonObject { ["ip"] = Str("PDU IP"), ["port"] = Int("Outlet number"), ["on"] = Bool("true=on, false=off") }, "ip", "port", "on"),
|
||||
Fn("pdu_power_cycle", "Power-cycle a PDU outlet (off → wait → on). Requires user confirmation.",
|
||||
new JsonObject { ["ip"] = Str("PDU IP"), ["port"] = Int("Outlet number"), ["offSeconds"] = Int("Off duration seconds (default 5)") }, "ip", "port"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>執行一個工具呼叫,回傳給模型的 JSON 字串(統一 {ok, result/error})。</summary>
|
||||
public async Task<string> InvokeAsync(string name, JsonObject args, CancellationToken ct)
|
||||
{
|
||||
AppLogger.Info($"[AI tool] {name} {args.ToJsonString()}");
|
||||
try
|
||||
{
|
||||
return name switch
|
||||
{
|
||||
"serial_list" => SerialList(),
|
||||
"serial_attach" => SerialAttach(Str(args, "session")),
|
||||
"serial_write" => SerialWrite(Str(args, "text"), Bool(args, "appendNewline", true)),
|
||||
"serial_read" => await SerialRead(Str(args, "waitFor"), Int(args, "timeoutMs", 3000), ct),
|
||||
"pdu_connect" => PduConnect(Str(args, "ip")),
|
||||
"pdu_status" => PduStatus(Str(args, "ip")),
|
||||
"pdu_set_port" => await PduSetPort(Str(args, "ip"), Int(args, "port", 0), Bool(args, "on", false)),
|
||||
"pdu_power_cycle" => await PduPowerCycle(Str(args, "ip"), Int(args, "port", 0), Int(args, "offSeconds", 5), ct),
|
||||
_ => Err($"unknown tool '{name}'")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.LogWarning($"[AI tool] {name} failed: {ex.Message}");
|
||||
return Err(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── serial ──
|
||||
private string SerialList()
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
foreach (var e in SerialBridge.All) arr.Add(new JsonObject { ["name"] = e.Name, ["baud"] = e.BaudRate });
|
||||
return Ok(new JsonObject { ["sessions"] = arr });
|
||||
}
|
||||
|
||||
private string SerialAttach(string session)
|
||||
{
|
||||
DetachRx();
|
||||
var ep = SerialBridge.Find(session);
|
||||
if (ep == null) return Err($"no open serial session '{session}' in the GUI — open it first");
|
||||
_attached = ep;
|
||||
lock (_rxLock) _rxBuffer.Clear();
|
||||
_rxHandler = data =>
|
||||
{
|
||||
lock (_rxLock)
|
||||
{
|
||||
var chars = new char[data.Length];
|
||||
int n = _dec.GetChars(data, 0, data.Length, chars, 0);
|
||||
if (n > 0) _rxBuffer.Append(chars, 0, n);
|
||||
if (_rxBuffer.Length > 1_000_000) _rxBuffer.Remove(0, _rxBuffer.Length - 1_000_000);
|
||||
}
|
||||
};
|
||||
ep.Rx += _rxHandler;
|
||||
return Ok(new JsonObject { ["attached"] = ep.Name });
|
||||
}
|
||||
|
||||
private string SerialWrite(string text, bool appendNewline)
|
||||
{
|
||||
if (_attached == null) return Err("not attached — call serial_attach first");
|
||||
_attached.Write(text, appendNewline);
|
||||
return Ok(new JsonObject { ["sent"] = text });
|
||||
}
|
||||
|
||||
private async Task<string> SerialRead(string? waitFor, int timeoutMs, CancellationToken ct)
|
||||
{
|
||||
if (_attached == null) return Err("not attached — call serial_attach first");
|
||||
var deadline = Environment.TickCount64 + Math.Clamp(timeoutMs, 0, 120_000);
|
||||
while (true)
|
||||
{
|
||||
string cur;
|
||||
lock (_rxLock) cur = _rxBuffer.ToString();
|
||||
if (string.IsNullOrEmpty(waitFor) || cur.Contains(waitFor)) { lock (_rxLock) _rxBuffer.Clear(); return Ok(new JsonObject { ["data"] = cur }); }
|
||||
if (Environment.TickCount64 >= deadline) { lock (_rxLock) _rxBuffer.Clear(); return Ok(new JsonObject { ["data"] = cur, ["timedOut"] = true }); }
|
||||
await Task.Delay(80, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachRx()
|
||||
{
|
||||
if (_attached != null && _rxHandler != null) _attached.Rx -= _rxHandler;
|
||||
_attached = null; _rxHandler = null;
|
||||
}
|
||||
|
||||
// ── PDU ──
|
||||
private PduController GetOrThrow(string ip) =>
|
||||
_pdus.TryGetValue(ip, out var c) ? c : throw new InvalidOperationException($"PDU {ip} not connected — call pdu_connect first");
|
||||
|
||||
private string PduConnect(string ip)
|
||||
{
|
||||
if (_pdus.ContainsKey(ip)) return Ok(new JsonObject { ["ip"] = ip, ["already"] = true });
|
||||
var c = new PduController(ip, m => AppLogger.Info(m), m => AppLogger.LogWarning(m));
|
||||
var model = c.GetModelName();
|
||||
if (string.IsNullOrEmpty(model)) { c.Dispose(); return Err($"PDU {ip} did not respond to SNMP"); }
|
||||
_pdus[ip] = c;
|
||||
return Ok(new JsonObject { ["ip"] = ip, ["model"] = model });
|
||||
}
|
||||
|
||||
private string PduStatus(string ip)
|
||||
{
|
||||
var c = GetOrThrow(ip);
|
||||
var all = c.GetAllPortsStatus(PduPortCount);
|
||||
var arr = new JsonArray();
|
||||
for (int i = 0; i < all.Length; i++)
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["port"] = i + 1,
|
||||
["state"] = all[i].State is bool b ? (b ? "on" : "off") : "unknown",
|
||||
["mA"] = all[i].CurrentMilliAmps,
|
||||
["W"] = all[i].PowerWatts
|
||||
});
|
||||
return Ok(new JsonObject { ["ip"] = ip, ["ports"] = arr });
|
||||
}
|
||||
|
||||
private async Task<string> PduSetPort(string ip, int port, bool on)
|
||||
{
|
||||
var c = GetOrThrow(ip);
|
||||
if (!on && !await ConfirmAsync($"AI 要求關閉 PDU {ip} 的 outlet {port}。確定?"))
|
||||
return Err("user declined");
|
||||
bool ok = on ? c.SetPortOn(port) : c.SetPortOff(port);
|
||||
return ok ? Ok(new JsonObject { ["ip"] = ip, ["port"] = port, ["state"] = on ? "on" : "off" }) : Err("SNMP set failed");
|
||||
}
|
||||
|
||||
private async Task<string> PduPowerCycle(string ip, int port, int offSeconds, CancellationToken ct)
|
||||
{
|
||||
var c = GetOrThrow(ip);
|
||||
offSeconds = Math.Clamp(offSeconds, 1, 60);
|
||||
if (!await ConfirmAsync($"AI 要求 power-cycle PDU {ip} 的 outlet {port}(關 {offSeconds}s 再開)。確定?"))
|
||||
return Err("user declined");
|
||||
if (!c.SetPortOff(port)) return Err("SNMP set (off) failed");
|
||||
await Task.Delay(offSeconds * 1000, ct);
|
||||
if (!c.SetPortOn(port)) return Err("SNMP set (on) failed");
|
||||
return Ok(new JsonObject { ["ip"] = ip, ["port"] = port, ["cycled"] = true, ["offSeconds"] = offSeconds });
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
private static string Ok(JsonObject result) => new JsonObject { ["ok"] = true, ["result"] = result }.ToJsonString();
|
||||
private static string Err(string msg) => new JsonObject { ["ok"] = false, ["error"] = msg }.ToJsonString();
|
||||
|
||||
private static string Str(JsonObject a, string k) => a[k]?.GetValue<string>() ?? "";
|
||||
private static string? Str(JsonObject a, string k, string? def) => a[k]?.GetValue<string>() ?? def;
|
||||
private static int Int(JsonObject a, string k, int def) { try { return a[k]?.GetValue<int>() ?? def; } catch { return def; } }
|
||||
private static bool Bool(JsonObject a, string k, bool def) { try { return a[k]?.GetValue<bool>() ?? def; } catch { return def; } }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DetachRx();
|
||||
foreach (var c in _pdus.Values) c.Dispose();
|
||||
_pdus.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ETTerms.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// 極簡 OpenAI 相容 chat-completions client(非串流)。
|
||||
/// 只依賴 HttpClient + System.Text.Json,不引入 SDK——因為端點是使用者自帶(BYO),
|
||||
/// 任何 OpenAI 相容 gateway(Ollama / LiteLLM / 公司內部 gateway / OpenAI…)皆可接。
|
||||
///
|
||||
/// ⚠️ Base URL 與 API key 皆由使用者於 Settings 設定,不寫死於程式碼(見 AppSettings 註解)。
|
||||
/// </summary>
|
||||
public sealed class OpenAiChatClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _model;
|
||||
|
||||
public OpenAiChatClient(string baseUrl, string apiKey, string model)
|
||||
{
|
||||
// baseUrl 例:"http://localhost:11434/v1" → endpoint = baseUrl + "/chat/completions"
|
||||
var root = baseUrl.TrimEnd('/');
|
||||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
|
||||
_http.BaseAddress = new Uri(root + "/");
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
_http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
_model = model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 送出一輪對話(含歷史 messages 與可用 tools),回傳 assistant 的回應訊息節點
|
||||
/// (可能含 content 或 tool_calls)。呼叫端負責 agent loop。
|
||||
/// </summary>
|
||||
public async Task<JsonObject> CompleteAsync(JsonArray messages, JsonArray? tools, CancellationToken ct)
|
||||
{
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = _model,
|
||||
["messages"] = messages.DeepClone(),
|
||||
["temperature"] = 0.2,
|
||||
};
|
||||
if (tools != null && tools.Count > 0)
|
||||
{
|
||||
body["tools"] = tools.DeepClone();
|
||||
body["tool_choice"] = "auto";
|
||||
}
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
||||
{
|
||||
Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct);
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"AI endpoint HTTP {(int)resp.StatusCode}: {Trunc(text, 400)}");
|
||||
|
||||
JsonObject root;
|
||||
try { root = JsonNode.Parse(text)!.AsObject(); }
|
||||
catch (Exception ex) { throw new InvalidOperationException($"AI response parse error: {ex.Message}\n{Trunc(text, 400)}"); }
|
||||
|
||||
var choices = root["choices"]?.AsArray();
|
||||
if (choices == null || choices.Count == 0)
|
||||
throw new InvalidOperationException($"AI response has no choices: {Trunc(text, 400)}");
|
||||
var msg = choices[0]?["message"]?.AsObject();
|
||||
if (msg == null)
|
||||
throw new InvalidOperationException($"AI response has no message: {Trunc(text, 400)}");
|
||||
return (JsonObject)msg.DeepClone();
|
||||
}
|
||||
|
||||
private static string Trunc(string s, int n) => s.Length <= n ? s : s.Substring(0, n) + "…";
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
@@ -183,6 +183,15 @@ public sealed class AboutView : UserControl
|
||||
|
||||
private static readonly ChangelogEntry[] Changelog =
|
||||
[
|
||||
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
|
||||
[
|
||||
"New ✨ AI Assistant view: chat 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.",
|
||||
"The AI drives your existing open Serial session, and everything it sends shows up in that terminal tagged [AI] so you always see what it did.",
|
||||
"Turning an outlet off or power-cycling always pops up a confirmation first — the AI can't cut power on its own. Every tool call is written to the app log.",
|
||||
"This is separate from the existing Serial/PDU MCP servers (Settings → AI MCP), which keep working for external AI CLIs like Claude Code / Kiro.",
|
||||
]),
|
||||
new("0.5.0", new DateOnly(2026, 7, 2), "Search, keyword alerts & a much bigger scripting language",
|
||||
[
|
||||
"Press Ctrl+F in any terminal to search everything you've scrolled past — all hits are highlighted, Enter jumps between them.",
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ETTerms.App;
|
||||
/// </summary>
|
||||
public sealed class ActivityRail : UserControl
|
||||
{
|
||||
public enum RailView { Terminal, Status, Settings, About }
|
||||
public enum RailView { Terminal, Ai, Status, Settings, About }
|
||||
|
||||
public event EventHandler<RailView>? ViewSelected;
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class ActivityRail : UserControl
|
||||
private static readonly (RailView view, string glyph, string tip)[] Items =
|
||||
{
|
||||
(RailView.Terminal, "▤", "Terminal"),
|
||||
(RailView.Ai, "✨", "AI Assistant"),
|
||||
(RailView.Status, "⚡", "Status"),
|
||||
(RailView.Settings, "⚙", "Settings"),
|
||||
(RailView.About, "ℹ", "About"),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using ETTerms.Ai;
|
||||
using ETTerms.Connections;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.App;
|
||||
|
||||
/// <summary>
|
||||
/// 內建 AI Assistant 檢視(Activity Rail 的一個 view)。自然語言驅動 serial + PDU。
|
||||
/// Provider 未設定(Base URL 空)時顯示提示,引導到 Settings → AI Assistant。
|
||||
///
|
||||
/// ⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
|
||||
/// </summary>
|
||||
public sealed class AiChatView : UserControl
|
||||
{
|
||||
private readonly RichTextBox _log;
|
||||
private readonly TextBox _input;
|
||||
private readonly Button _send;
|
||||
private readonly Label _hint;
|
||||
|
||||
private AgentHost? _agent;
|
||||
private OpenAiChatClient? _client;
|
||||
private AiTools? _tools;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public AiChatView()
|
||||
{
|
||||
Dock = DockStyle.Fill;
|
||||
BackColor = Theme.WorkspaceBack;
|
||||
|
||||
_log = new RichTextBox
|
||||
{
|
||||
Dock = DockStyle.Fill, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||
BackColor = Color.FromArgb(28, 28, 32), ForeColor = Theme.Text,
|
||||
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) };
|
||||
_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,
|
||||
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
|
||||
};
|
||||
_send.FlatAppearance.BorderColor = Theme.Accent;
|
||||
_send.Click += (_, _) => OnSend();
|
||||
bottom.Controls.Add(_input);
|
||||
bottom.Controls.Add(_send);
|
||||
|
||||
_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。"
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>每次切到本檢視時呼叫,依最新設定重建 client(Provider 改了會生效)。</summary>
|
||||
public void RefreshProvider()
|
||||
{
|
||||
var s = AppSettings.Instance;
|
||||
bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl) && !string.IsNullOrWhiteSpace(s.AiModel);
|
||||
_hint.Visible = !configured;
|
||||
_input.Enabled = _send.Enabled = configured;
|
||||
|
||||
_client?.Dispose(); _client = null;
|
||||
_tools?.Dispose(); _tools = null;
|
||||
_agent = null;
|
||||
|
||||
if (!configured) return;
|
||||
|
||||
var key = CredentialVault.Get("ETTerms/AiApiKey") ?? "";
|
||||
_client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel);
|
||||
_tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync };
|
||||
_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"); });
|
||||
}
|
||||
|
||||
private Task<bool> ConfirmOnUiAsync(string message)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
Ui(() =>
|
||||
{
|
||||
var r = MessageBox.Show(this, message, "AI 動作確認",
|
||||
MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2);
|
||||
tcs.SetResult(r == DialogResult.Yes);
|
||||
});
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
private async void OnSend()
|
||||
{
|
||||
if (_agent == null) return;
|
||||
var text = _input.Text.Trim();
|
||||
if (text.Length == 0) return;
|
||||
_input.Clear();
|
||||
AppendUser(text);
|
||||
_send.Enabled = false;
|
||||
_cts = new CancellationTokenSource();
|
||||
try { await _agent.SendAsync(text, _cts.Token); }
|
||||
catch (OperationCanceledException) { AppendDim("(已取消)"); }
|
||||
catch (Exception ex) { AppendError(ex.Message); }
|
||||
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)
|
||||
{
|
||||
_log.SelectionStart = _log.TextLength;
|
||||
_log.SelectionColor = Theme.TextDim;
|
||||
_log.AppendText(t + "\n");
|
||||
_log.ScrollToCaret();
|
||||
}
|
||||
|
||||
private void Append(string who, string text, Color color)
|
||||
{
|
||||
_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.SelectionFont = _log.Font;
|
||||
_log.AppendText(text + "\n");
|
||||
_log.ScrollToCaret();
|
||||
}
|
||||
|
||||
private void Ui(Action a)
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated) return;
|
||||
if (InvokeRequired) BeginInvoke(a); else a();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_client?.Dispose();
|
||||
_tools?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public partial class MainForm : Form
|
||||
private readonly ActivityRail _rail = new();
|
||||
private readonly ConnectionSidebar _sidebar = new();
|
||||
private readonly WorkspaceView _workspace = new();
|
||||
private readonly AiChatView _aiView = new();
|
||||
private readonly StatusView _statusView = new();
|
||||
private readonly SettingsView _settings = new();
|
||||
private readonly AboutView _about = new();
|
||||
@@ -46,12 +47,14 @@ public partial class MainForm : Form
|
||||
private void BuildLayout()
|
||||
{
|
||||
Controls.Add(_workspace); // Fill
|
||||
Controls.Add(_aiView); // Fill (hidden)
|
||||
Controls.Add(_statusView); // Fill (hidden)
|
||||
Controls.Add(_settings); // Fill (hidden)
|
||||
Controls.Add(_about); // Fill (hidden)
|
||||
Controls.Add(_sidebar); // Left (內側)
|
||||
Controls.Add(_rail); // Left (最外側)
|
||||
|
||||
_aiView.Visible = false;
|
||||
_statusView.Visible = false;
|
||||
_settings.Visible = false;
|
||||
_about.Visible = false;
|
||||
@@ -71,9 +74,11 @@ public partial class MainForm : Form
|
||||
_statusLabel.Text = $"View: {view}";
|
||||
_sidebar.Visible = view == ActivityRail.RailView.Terminal;
|
||||
_workspace.Visible = view == ActivityRail.RailView.Terminal;
|
||||
_aiView.Visible = view == ActivityRail.RailView.Ai;
|
||||
_statusView.Visible = view == ActivityRail.RailView.Status;
|
||||
_settings.Visible = view == ActivityRail.RailView.Settings;
|
||||
_about.Visible = view == ActivityRail.RailView.About;
|
||||
if (view == ActivityRail.RailView.Ai) _aiView.RefreshProvider();
|
||||
AppLogger.LogInfo($"View selected: {view}");
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using ETTerms.Connections;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.App;
|
||||
|
||||
/// <summary>Settings page with tabs: Terminal / AI MCP.</summary>
|
||||
/// <summary>Settings page with tabs: Terminal / Highlight / AI Assistant / AI MCP.</summary>
|
||||
public sealed class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView()
|
||||
@@ -53,6 +54,7 @@ public sealed class SettingsView : UserControl
|
||||
var termBtn = MakeTab("Terminal", BuildTerminalTab());
|
||||
tabBar.Controls.Add(termBtn);
|
||||
tabBar.Controls.Add(MakeTab("Highlight", BuildHighlightTab()));
|
||||
tabBar.Controls.Add(MakeTab("AI Assistant", BuildAiAssistantTab()));
|
||||
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
|
||||
|
||||
Controls.Add(body);
|
||||
@@ -290,6 +292,100 @@ public sealed class SettingsView : UserControl
|
||||
return page;
|
||||
}
|
||||
|
||||
// ═══ AI Assistant Tab(內建 agent 的 BYO endpoint 設定)═══
|
||||
private Panel BuildAiAssistantTab()
|
||||
{
|
||||
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
|
||||
var s = AppSettings.Instance;
|
||||
|
||||
var flow = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
|
||||
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
|
||||
};
|
||||
|
||||
flow.Controls.Add(new Label
|
||||
{
|
||||
Text = "Built-in AI Assistant", AutoSize = true,
|
||||
ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
|
||||
});
|
||||
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" +
|
||||
"The API key is stored in Windows Credential Manager, never in settings.json or the app.",
|
||||
AutoSize = false, Width = 620, Height = 54,
|
||||
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 10)
|
||||
});
|
||||
|
||||
var baseUrl = new TextBox
|
||||
{
|
||||
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
|
||||
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,
|
||||
BorderStyle = BorderStyle.FixedSingle, UseSystemPasswordChar = true,
|
||||
Text = CredentialVault.Get("ETTerms/AiApiKey") ?? "",
|
||||
PlaceholderText = "(stored in Credential Manager)"
|
||||
};
|
||||
var sysPrompt = new TextBox
|
||||
{
|
||||
Width = 560, Height = 70, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
|
||||
Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle, Text = s.AiSystemPrompt,
|
||||
PlaceholderText = "(optional) override the assistant persona / system prompt"
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
Text = "System prompt (optional):", AutoSize = true,
|
||||
ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 4, 0, 2)
|
||||
});
|
||||
flow.Controls.Add(sysPrompt);
|
||||
flow.Controls.Add(MakeSpacer(6));
|
||||
|
||||
flow.Controls.Add(new Label
|
||||
{
|
||||
Text = "Tools the assistant can call: serial send/read (via the GUI's open Serial session, shown as [AI]),\n" +
|
||||
"and PDU control over SNMP. Turning an outlet off / power-cycling always asks you to confirm.",
|
||||
AutoSize = false, Width = 620, Height = 36,
|
||||
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
|
||||
});
|
||||
|
||||
var save = MakeButton("Save", Theme.Accent);
|
||||
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.",
|
||||
"AI Assistant", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
};
|
||||
flow.Controls.Add(save);
|
||||
|
||||
page.Controls.Add(flow);
|
||||
return page;
|
||||
}
|
||||
|
||||
// ═══ AI MCP Tab ═══
|
||||
private Panel BuildAiMcpTab()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<AssemblyName>ETTerms</AssemblyName>
|
||||
|
||||
<!-- 版本資訊 -->
|
||||
<Version>0.5.0</Version>
|
||||
<Version>0.6.0</Version>
|
||||
<Product>ETTerms</Product>
|
||||
<Company>ETTerms Project</Company>
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ public sealed class AppSettings
|
||||
public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd
|
||||
public string ShellStartupDir { get; set; } = "";
|
||||
|
||||
// ── AI Assistant(BYO endpoint)──
|
||||
// ⚠️ 預設全空白=內建 AI 停用。任何私人端點 / 金鑰不得寫死於此或程式碼——
|
||||
// 使用者自己在 Settings → AI Assistant 填。API key 存 Credential Manager(ETTerms/AiApiKey),不在此檔。
|
||||
/// <summary>OpenAI 相容端點,含 /v1(例:http://localhost:11434/v1)。空=AI 停用。</summary>
|
||||
public string AiBaseUrl { get; set; } = "";
|
||||
/// <summary>模型名(需支援 function calling)。</summary>
|
||||
public string AiModel { get; set; } = "";
|
||||
/// <summary>系統提示詞(人設);空則用內建預設。</summary>
|
||||
public string AiSystemPrompt { get; set; } = "";
|
||||
|
||||
// ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
|
||||
public bool KeywordHighlightEnabled { get; set; } = true;
|
||||
public List<KeywordRule> KeywordRules { get; set; } = new();
|
||||
|
||||
Reference in New Issue
Block a user