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:
2026-07-05 22:31:09 +08:00
co-authored by Claude Fable 5
parent ad0f6c5114
commit 639d10438d
5 changed files with 88 additions and 75 deletions
+2 -1
View File
@@ -748,7 +748,7 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
**包含:** **包含:**
- [x] `AppSettings``AiBaseUrl` / `AiModel` / `AiSystemPrompt`(預設空白);API key 存 `CredentialVault``ETTerms/AiApiKey` - [x] `AppSettings``AiBaseUrl` / `AiModel` / `AiSystemPrompt`(預設空白);API key 存 `CredentialVault``ETTerms/AiApiKey`
- [x] `App/SettingsView.cs`**AI Assistant** 分頁:Base URL / Model / API Key(密碼框)/ 系統提示詞,Save 寫 settings + Credential Manager;空白=停用 - [x] `App/SettingsView.cs`**AI Assistant** 分頁:Base URL / Model / API Key(密碼框)/ 系統提示詞,Save 寫 settings + Credential Manager;空白=停用
- [x] `App/AiChatView.cs`:聊天 UI角色標色訊息串 + 輸入框 + 工具過程顯示);`RefreshProvider()` 開分頁時依最新設定重建 client。**作為 Workspace pane**`WorkspaceView` 工具列 `✨ AI Chat``OpenAiPane()``Session` 抽象化容納 `SessionPage``AiChatView``Content` 屬性),可與 serial 用 Layout 並排;AI 分頁無 Group / Log / Script那些操作自動 skip - [x] `App/AiChatView.cs`:聊天 UI乾淨逐字稿:user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕)+ 底部控制列(輸入框 Fill + 右下角模型下拉 / Send);`RefreshProvider()` 開分頁時依設定重建 client。**作為 Workspace pane**`WorkspaceView` 工具列 `✨ AI Chat``OpenAiPane()``Session` 抽象化容納 `SessionPage``AiChatView``Content` 屬性),可與 serial 用 Layout 並排;AI 分頁無 Group / Log / Script(自動 skip。**模型下拉**打端點 `/v1/models` 列出可選模型、即時切換並記住(Settings 不再設 model,只留 Base URL / Key / 系統提示詞)
- [x] `Ai/OpenAiChatClient.cs`:極簡 OpenAI 相容 chat-completionsHttpClient,非串流) - [x] `Ai/OpenAiChatClient.cs`:極簡 OpenAI 相容 chat-completionsHttpClient,非串流)
- [x] `Ai/AgentHost.cs`:手寫 agent looptool_calls → 執行 → role=tool 餵回 → 迴圈,上限 8 輪) - [x] `Ai/AgentHost.cs`:手寫 agent looptool_calls → 執行 → role=tool 餵回 → 迴圈,上限 8 輪)
- [x] `Ai/AiTools.cs`seriallist/attach/write/read,經 `SerialBridge``[AI]` echo 沿用)+ PDUconnect/status/set_port/power_cycle`ETTerms.PduCore`);破壞性動作經 `ConfirmAsync` 彈框;每筆呼叫寫 AppLogger - [x] `Ai/AiTools.cs`seriallist/attach/write/read,經 `SerialBridge``[AI]` echo 沿用)+ PDUconnect/status/set_port/power_cycle`ETTerms.PduCore`);破壞性動作經 `ConfirmAsync` 彈框;每筆呼叫寫 AppLogger
@@ -764,6 +764,7 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
- ~~**AI / MCP 整合**~~(✅ 已於 [Phase 9](#development-phases) 實作:Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具) - ~~**AI / MCP 整合**~~(✅ 已於 [Phase 9](#development-phases) 實作:Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具)
- ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁) - ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁)
- **AI Chat 氣泡版(WebView2**v0.7.0 候選):目前 AI 分頁走 RichTextBox「乾淨逐字稿」風(與終端機一致、務實);若要真正的聊天氣泡(圓角、限寬、user 右 AI 左)+ Markdown / 程式碼區塊高亮 + 工具呼叫可摺疊卡片,正解是改用 WebView2 + HTML/CSS 渲染。代價:引入 WebView2 依賴、portable 版變大。列為獨立升級,不在 RichTextBox 上硬做氣泡。
- **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel` - **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`
- **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估) - **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估)
- **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm) - **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm)
+1 -1
View File
@@ -10,7 +10,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。 **進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
**v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt` **v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**UI 走乾淨逐字稿風**user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕不洗版;與終端機美學一致,非氣泡——真氣泡+Markdown 的 WebView2 版列 v0.7.0)。**模型下拉**(右下角)打端點 `/v1/models` 列可選模型、即時切換並記住;**Settings 只留 Base URL / API Key / 系統提示詞,不再設 model**(`configured` 只看 Base URL)。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt`
**v0.5.0** 搜尋 / 關鍵字警示 / TTL 大擴充。**(1) Ctrl+F scrollback 搜尋** — `TerminalView` 內建搜尋列(Enter 往上找、Shift+Enter 往下、F3/Esc),全部命中黃底、目前命中橘底;命中以「絕對行號 = `ScreenBuffer.DroppedLines` + abs」錨定,環形緩衝丟舊行不漂移。**(2) 關鍵字高亮 + 分頁警示** — `AppSettings.KeywordRules``KeywordRule{Text,Enabled}` + 全域開關),Settings 新 **Highlight** 分頁可增刪/勾選;`TerminalView` 每次重繪只掃可見行標紅底(不分大小寫),`Feed` 路徑用獨立 Decoder+去 ANSI 偵測觸發 `KeywordAlert`(每關鍵字 2s 冷卻)→ `SessionPage``WorkspaceView` 把**非 active 分頁**的圓點標紅、切過去自動清除。**(3) TTL 對齊 TeraTerm** — 新 `TtlExpression` 運算式解析器(括號/邏輯/比較/十六進位 `0x`/`$`,失敗退回 legacy 規則相容舊腳本);新增控制流 `goto/call(行內執行,迴圈內可用)/return/for-next/do-loop/until-enduntil/break/continue/end/exit/include/mpause`、等待 `waitln/waitregex/recvln/wait 多字串(TeraTerm 語意:逾時 result=0 繼續)/mtimeout`、字串 `strlen/strcompare/strconcat/strcopy/strinsert/strremove/strmatch/strscan/strreplace/strtrim/strsplit/strjoin/tolower/toupper/str2int/int2str/code2str/str2code/sprintf(→inputstr)/expandenv`、檔案 `fileopen/filereadln(result=1 是 EOF)/filewrite(ln)/fileclose/filecreate/filedelete/filesearch/basename/dirname/makepath/foldercreate/folderdelete/foldersearch/getdir/setdir`、雜項 `beep/getdate/gettime(strftime 子集)/getenv/setenv/random/exec/getver/getttdir/uptime/ifdefined/clipb2var/var2clipb/inputbox/yesnobox/crc32/checksum8/16/32/dispstr`、serial 專用 `sendbreak/setbaud/setdtr/setrts/sendfile``SerialChannel` 新增對應方法);單行 `if <expr> <statement>` 支援;`Preprocess` 引號內 `;` 不再被當註解;系統變數 `inputstr/matchstr/groupmatchstr1-9``_vars` 改大小寫不敏感;**單字串 `wait` 保留 ETTerms settle+逾時中止語意(勿改)**,多字串才是 TeraTerm 語意。`SessionPage``runner.Output`trace`[wait]`/`>>`/錯誤)以灰色 echo 進終端機,**可由 Settings → Terminal 的 `ShowScriptTrace` 開關關閉**`dispstr` 走獨立的 `Display` 事件一律顯示。灰色訊息只進畫面 buffer——不進 ⏺ Log 側錄、不進 AI bridge、不送裝置(機台原始 log 乾淨)。指令表+範例:[docs/ttl-script-reference.md](docs/ttl-script-reference.md)(前段 ETTerms 獨有、後段與 TeraTerm 共有)。 **v0.5.0** 搜尋 / 關鍵字警示 / TTL 大擴充。**(1) Ctrl+F scrollback 搜尋** — `TerminalView` 內建搜尋列(Enter 往上找、Shift+Enter 往下、F3/Esc),全部命中黃底、目前命中橘底;命中以「絕對行號 = `ScreenBuffer.DroppedLines` + abs」錨定,環形緩衝丟舊行不漂移。**(2) 關鍵字高亮 + 分頁警示** — `AppSettings.KeywordRules``KeywordRule{Text,Enabled}` + 全域開關),Settings 新 **Highlight** 分頁可增刪/勾選;`TerminalView` 每次重繪只掃可見行標紅底(不分大小寫),`Feed` 路徑用獨立 Decoder+去 ANSI 偵測觸發 `KeywordAlert`(每關鍵字 2s 冷卻)→ `SessionPage``WorkspaceView` 把**非 active 分頁**的圓點標紅、切過去自動清除。**(3) TTL 對齊 TeraTerm** — 新 `TtlExpression` 運算式解析器(括號/邏輯/比較/十六進位 `0x`/`$`,失敗退回 legacy 規則相容舊腳本);新增控制流 `goto/call(行內執行,迴圈內可用)/return/for-next/do-loop/until-enduntil/break/continue/end/exit/include/mpause`、等待 `waitln/waitregex/recvln/wait 多字串(TeraTerm 語意:逾時 result=0 繼續)/mtimeout`、字串 `strlen/strcompare/strconcat/strcopy/strinsert/strremove/strmatch/strscan/strreplace/strtrim/strsplit/strjoin/tolower/toupper/str2int/int2str/code2str/str2code/sprintf(→inputstr)/expandenv`、檔案 `fileopen/filereadln(result=1 是 EOF)/filewrite(ln)/fileclose/filecreate/filedelete/filesearch/basename/dirname/makepath/foldercreate/folderdelete/foldersearch/getdir/setdir`、雜項 `beep/getdate/gettime(strftime 子集)/getenv/setenv/random/exec/getver/getttdir/uptime/ifdefined/clipb2var/var2clipb/inputbox/yesnobox/crc32/checksum8/16/32/dispstr`、serial 專用 `sendbreak/setbaud/setdtr/setrts/sendfile``SerialChannel` 新增對應方法);單行 `if <expr> <statement>` 支援;`Preprocess` 引號內 `;` 不再被當註解;系統變數 `inputstr/matchstr/groupmatchstr1-9``_vars` 改大小寫不敏感;**單字串 `wait` 保留 ETTerms settle+逾時中止語意(勿改)**,多字串才是 TeraTerm 語意。`SessionPage``runner.Output`trace`[wait]`/`>>`/錯誤)以灰色 echo 進終端機,**可由 Settings → Terminal 的 `ShowScriptTrace` 開關關閉**`dispstr` 走獨立的 `Display` 事件一律顯示。灰色訊息只進畫面 buffer——不進 ⏺ Log 側錄、不進 AI bridge、不送裝置(機台原始 log 乾淨)。指令表+範例:[docs/ttl-script-reference.md](docs/ttl-script-reference.md)(前段 ETTerms 獨有、後段與 TeraTerm 共有)。
+1 -1
View File
@@ -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("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.", "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\".", "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.",
+78 -59
View File
@@ -7,10 +7,13 @@ using ETTerms.Infrastructure;
namespace ETTerms.App; namespace ETTerms.App;
/// <summary> /// <summary>
/// 內建 AI Assistant 檢視(Activity Rail 的一個 view)。自然語言驅動 serial + PDU。 /// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
/// Provider 未設定(Base URL 空)時顯示提示,引導到 Settings → AI Assistant。
/// ///
/// ⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。 /// 呈現走「乾淨對話逐字稿」風格(與 ETTerms 終端機/log 美學一致,非氣泡卡片):
/// 使用者訊息靠右 accent 色、AI 回覆靠左段落、工具活動灰字縮排、思考中收進 Send 按鈕不洗版。
/// (真氣泡 + Markdown 的 WebView2 版列為 v0.7.0。)
///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary> /// </summary>
public sealed class AiChatView : UserControl public sealed class AiChatView : UserControl
{ {
@@ -26,6 +29,8 @@ public sealed class AiChatView : UserControl
private AiTools? _tools; private AiTools? _tools;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private const string SendText = "Send ⏎";
public AiChatView() public AiChatView()
{ {
Dock = DockStyle.Fill; Dock = DockStyle.Fill;
@@ -38,10 +43,22 @@ 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 = 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 就能切端點上的模型 _input = new TextBox
var modelRow = new Panel { Dock = DockStyle.Top, Height = 28, BackColor = Theme.RailBack }; {
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 _modelBox = new ComboBox
{ {
Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList,
@@ -53,69 +70,57 @@ public sealed class AiChatView : UserControl
_client.Model = m; _client.Model = m;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個 AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save(); AppSettings.Instance.Save();
AppendDim($"模型切換為 {m}"); AppendDim($"模型切換為 {m}");
}; };
var refreshBtn = new Button 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 ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
refreshBtn.FlatAppearance.BorderColor = Theme.Border; refreshBtn.FlatAppearance.BorderColor = Theme.Border;
refreshBtn.Click += (_, _) => _ = LoadModelsAsync(); refreshBtn.Click += (_, _) => _ = LoadModelsAsync();
var modelLbl = new Label modelWrap.Controls.Add(_modelBox); // Fill
{ modelWrap.Controls.Add(refreshBtn); // Right
Text = "Model", Dock = DockStyle.Left, Width = 46, ForeColor = Theme.TextDim,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft var gap = new Panel { Dock = DockStyle.Top, Height = 6, BackColor = Theme.RailBack };
};
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,
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 _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 ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
_send.FlatAppearance.BorderColor = Theme.Accent; _send.FlatAppearance.BorderColor = Theme.Accent;
_send.Click += (_, _) => OnSend(); _send.Click += (_, _) => OnSend();
inputRow.Controls.Add(_input); // Fill
inputRow.Controls.Add(_send); // Right
bottom.Controls.Add(inputRow); // Fill rightCol.Controls.Add(_send); // Fill 先加
bottom.Controls.Add(modelRow); // Top rightCol.Controls.Add(gap); // TopSend 上方間距)
rightCol.Controls.Add(modelWrap); // Top(最上:模型下拉)
bottom.Controls.Add(_input); // Fill 先加
bottom.Controls.Add(rightCol); // Right(右下角)
_hint = new Label _hint = new Label
{ {
Dock = DockStyle.Top, Height = 40, BackColor = Color.FromArgb(60, 50, 30), ForeColor = Theme.Text, Dock = DockStyle.Top, Height = 40, BackColor = Color.FromArgb(60, 50, 30), ForeColor = Theme.Text,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter, Visible = false, 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(_log); // Fill
Controls.Add(_hint); // Top Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom Controls.Add(bottom); // Bottom
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。\n" + AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
"例:「列出目前的 serial session」/「接上 COM3,送 help 看回應」/「連上 PDU 192.168.1.50,把 outlet 3 重開」\n"); AppendSystem("例:「列出目前的 serial session」「接上 COM3送 help 看回應」「連上 PDU 192.168.1.50把 outlet 3 重開」");
} }
/// <summary>每次切到本檢視時呼叫,依最新設定重建 clientProvider 改了會生效)。</summary> /// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary>
public void RefreshProvider() public void RefreshProvider()
{ {
var s = AppSettings.Instance; var s = AppSettings.Instance;
bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl) && !string.IsNullOrWhiteSpace(s.AiModel); bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl);
_hint.Visible = !configured; _hint.Visible = !configured;
_input.Enabled = _send.Enabled = configured; _input.Enabled = _send.Enabled = _modelBox.Enabled = configured;
_client?.Dispose(); _client = null; _client?.Dispose(); _client = null;
_tools?.Dispose(); _tools = null; _tools?.Dispose(); _tools = null;
@@ -129,12 +134,12 @@ public sealed class AiChatView : UserControl
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt); _agent = new AgentHost(_client, _tools, s.AiSystemPrompt);
_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"); }); // thinking 不印進對話流(改由 Send 按鈕顯示忙碌),避免洗版。
_ = LoadModelsAsync(); _ = LoadModelsAsync();
} }
/// <summary>拉端點可用模型填入下拉選單(GET /v1/models);失敗則只放目前設定的那個。</summary> /// <summary>拉端點可用模型填入下拉選單(GET /v1/models);沒設過模型就自動選第一個。</summary>
private async Task LoadModelsAsync() private async Task LoadModelsAsync()
{ {
if (_client == null) return; if (_client == null) return;
@@ -142,15 +147,27 @@ public sealed class AiChatView : UserControl
List<string> models; List<string> models;
try { models = await _client.ListModelsAsync(CancellationToken.None); } try { models = await _client.ListModelsAsync(CancellationToken.None); }
catch { models = new(); } 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(() => Ui(() =>
{ {
_suppressModelEvent = true; _suppressModelEvent = true;
_modelBox.Items.Clear(); _modelBox.Items.Clear();
foreach (var m in models) _modelBox.Items.Add(m); 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; _suppressModelEvent = false;
}); });
} }
@@ -174,52 +191,54 @@ public sealed class AiChatView : UserControl
if (text.Length == 0) return; if (text.Length == 0) return;
_input.Clear(); _input.Clear();
AppendUser(text); AppendUser(text);
_send.Enabled = false; SetBusy(true);
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); } try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AppendDim("(已取消)"); } catch (OperationCanceledException) { AppendDim("(已取消)"); }
catch (Exception ex) { AppendError(ex.Message); } catch (Exception ex) { AppendError(ex.Message); }
finally { _send.Enabled = true; } finally { SetBusy(false); }
} }
// ── 輸出 helpersClaude 風:user 靠右氣泡、AI 靠左純文字)── private void SetBusy(bool busy)
/// <summary>使用者訊息:右對齊 + accent 底色氣泡感,不加「你:」前綴。</summary> {
_send.Enabled = !busy;
_send.Text = busy ? "…" : SendText;
}
// ── 輸出(乾淨 transcript)──
/// <summary>使用者訊息:靠右 accent 色,無前綴。</summary>
private void AppendUser(string t) private void AppendUser(string t)
{ {
_log.SelectionStart = _log.TextLength; _log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Right; _log.SelectionAlignment = HorizontalAlignment.Right;
_log.SelectionColor = Theme.Text; _log.SelectionColor = Theme.Accent;
_log.SelectionBackColor = Theme.AccentDim; _log.SelectionFont = new Font(_log.Font, FontStyle.Bold);
_log.SelectionFont = _log.Font; _log.AppendText(t + "\n");
_log.AppendText(" " + t + " \n");
_log.SelectionBackColor = _log.BackColor;
_log.SelectionAlignment = HorizontalAlignment.Left; _log.SelectionAlignment = HorizontalAlignment.Left;
_log.AppendText("\n"); _log.AppendText("\n");
_log.ScrollToCaret(); _log.ScrollToCaret();
} }
/// <summary>AI 回應:左對齊純文字。</summary> /// <summary>AI 回覆:靠左段落。</summary>
private void AppendAssistant(string t) private void AppendAssistant(string t)
{ {
_log.SelectionStart = _log.TextLength; _log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left; _log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionColor = Theme.Text; _log.SelectionColor = Theme.Text;
_log.SelectionBackColor = _log.BackColor;
_log.SelectionFont = _log.Font; _log.SelectionFont = _log.Font;
_log.AppendText(t + "\n\n"); _log.AppendText(t + "\n\n");
_log.ScrollToCaret(); _log.ScrollToCaret();
} }
private void AppendTool(string t) => AppendLeftDim("⚙ " + t, Color.FromArgb(190, 170, 120)); private void AppendTool(string t) => AppendLine("⚙ " + t, Color.FromArgb(150, 150, 158));
private void AppendSystem(string t) => AppendLeftDim(t, Theme.TextDim); private void AppendSystem(string t) => AppendLine(t, Theme.TextDim);
private void AppendError(string t) => AppendLeftDim(t, Color.FromArgb(235, 120, 120)); private void AppendError(string t) => AppendLine("⚠ " + t, Color.FromArgb(235, 120, 120));
private void AppendDim(string t) => AppendLeftDim(t, Theme.TextDim); 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.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left; _log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionBackColor = _log.BackColor;
_log.SelectionColor = color; _log.SelectionColor = color;
_log.SelectionFont = _log.Font; _log.SelectionFont = _log.Font;
_log.AppendText(t + "\n"); _log.AppendText(t + "\n");
+6 -13
View File
@@ -312,9 +312,10 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
Text = "Bring your own OpenAI-compatible endpoint (Ollama / LiteLLM / a company gateway / OpenAI…).\n" + 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.", "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) 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, BorderStyle = BorderStyle.FixedSingle, Text = s.AiBaseUrl,
PlaceholderText = "http://localhost:11434/v1" 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 var apiKey = new TextBox
{ {
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, 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("Base URL (with /v1)", baseUrl));
flow.Controls.Add(MakeRow("Model", model));
flow.Controls.Add(MakeRow("API Key", apiKey)); flow.Controls.Add(MakeRow("API Key", apiKey));
flow.Controls.Add(MakeSpacer(4)); flow.Controls.Add(MakeSpacer(4));
flow.Controls.Add(new Label flow.Controls.Add(new Label
@@ -368,16 +362,15 @@ public sealed class SettingsView : UserControl
save.Click += (_, _) => save.Click += (_, _) =>
{ {
s.AiBaseUrl = baseUrl.Text.Trim(); s.AiBaseUrl = baseUrl.Text.Trim();
s.AiModel = model.Text.Trim();
s.AiSystemPrompt = sysPrompt.Text.Trim(); s.AiSystemPrompt = sysPrompt.Text.Trim();
s.Save(); s.Save();
var key = apiKey.Text; var key = apiKey.Text;
if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey"); if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey");
else CredentialVault.Set("ETTerms/AiApiKey", key); else CredentialVault.Set("ETTerms/AiApiKey", key);
MessageBox.Show(this, MessageBox.Show(this,
string.IsNullOrWhiteSpace(s.AiBaseUrl) || string.IsNullOrWhiteSpace(s.AiModel) string.IsNullOrWhiteSpace(s.AiBaseUrl)
? "Saved. AI Assistant stays disabled until Base URL and Model are both set." ? "Saved. AI Assistant stays disabled until you set a Base URL."
: "Saved. Open the AI Assistant view (✨ in the rail) to start.", : "Saved. Open AI Chat in the workspace toolbar, then pick a model from the dropdown.",
"AI Assistant", MessageBoxButtons.OK, MessageBoxIcon.Information); "AI Assistant", MessageBoxButtons.OK, MessageBoxIcon.Information);
}; };
flow.Controls.Add(save); flow.Controls.Add(save);