merge: v0.6.0 — built-in AI Assistant (BYO endpoint, workspace pane)

Drive serial & PDU in plain language from a workspace pane you can tile
next to a live terminal. BYO OpenAI-compatible endpoint (nothing baked
in), model dropdown in the pane, destructive PDU actions gated by a
confirmation, API key in Credential Manager. Separate from the existing
Serial/PDU MCP servers for external AI CLIs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 22:36:54 +08:00
co-authored by Claude Fable 5
13 changed files with 1005 additions and 36 deletions
+53
View File
@@ -33,6 +33,7 @@ ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗
| PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect` | | PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect` |
| 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 | | 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 |
| AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 兩個獨立 server`ETTerms.SerialMcp`(不自己開 port,經本機 named pipe 橋接 GUI 持有的 serial session)與 `ETTerms.PduMcp`(直接打 SNMP 控制 PDU 插座,不需 GUI);把收發 / 電源控制暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp--pdu-mcp-server) | | AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 兩個獨立 server`ETTerms.SerialMcp`(不自己開 port,經本機 named pipe 橋接 GUI 持有的 serial session)與 `ETTerms.PduMcp`(直接打 SNMP 控制 PDU 插座,不需 GUI);把收發 / 電源控制暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp--pdu-mcp-server) |
| 內建 AI AssistantPhase 10 規劃中) | **Microsoft.Extensions.AI**OpenAI 相容 client + function calling | GUI 內建 agent 聊天分頁,in-process 直呼 serial / PDU 工具(不經 MCP);**BYO endpoint**——Provider 預設空白,發佈版不含任何私人端點,API key 存 Credential Manager |
| 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user | | 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user |
> **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。 > **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。
@@ -161,6 +162,11 @@ ETTerms/
│ └── Pdu/ │ └── Pdu/
│ └── PduController.cs# SnmpSharpNet PDU 控制 (pductrl / pduconnect) │ └── PduController.cs# SnmpSharpNet PDU 控制 (pductrl / pduconnect)
├── Ai/ # ── 內建 AI AssistantPhase 10, v0.6.0)──
│ ├── OpenAiChatClient.cs # 極簡 OpenAI 相容 /chat/completionsHttpClient, 非串流;BYO endpoint
│ ├── AgentHost.cs # 手寫 agent looptool_calls → 執行 → 餵回 → 迴圈,上限 8 輪)
│ └── AiTools.cs # 工具集:serial(經 SerialBridge, [AI] echo+ PDUPduCore);破壞性動作經 ConfirmAsync 彈框
└── Infrastructure/ └── Infrastructure/
├── AppLogger.cs # 日誌 (port 自 MyTeraTerm) ├── AppLogger.cs # 日誌 (port 自 MyTeraTerm)
├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json) ├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json)
@@ -468,6 +474,37 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
> 所有工具回傳統一的 `{ "ok": bool, "result"/"error": ... }` JSON。SNMP community 目前沿用 GUI 版的 `"private"`。 > 所有工具回傳統一的 `{ "ok": bool, "result"/"error": ... }` JSON。SNMP community 目前沿用 GUI 版的 `"private"`。
### 內建 AI AssistantPhase 10 — ✅ 已完成 v0.6.0
> 讓 ETTerms **自己就是 agent host**——不經 Claude / Kiro,在 GUI 內建 AI 聊天**分頁**,用自然語言驅動 serial 與 PDU(「接上 COM3,送 help 看回應」「連上 PDU,把 outlet 3 重開」一句話完成)。**AI 分頁是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟),可用 Layout1×2 / 2×2…)與 serial 分頁**並排同時使用**——像 Claude Code / Kiro 那樣一邊聊、一邊看終端機。
**設計原則:BYO Endpoint(使用者自帶 LLM 端點)**
- Provider 設定(**Base URL / API Key / Model**)預設**全空白**;未設定時 AI 面板顯示「未設定 AI Provider」且功能完全停用——**發佈出去的 ETTerms 不內含任何端點,對一般使用者就是一個沒有 AI 的終端機**。
- API Key 只存 **Windows Credential Manager**`ETTerms/AiApiKey`,沿用 `CredentialVault`;Base URL / Model 存 settings.json(本機、非 repo)。
- 介面走 **OpenAI 相容 `/v1/chat/completions` + function calling**,任何供應商皆可接:本機 Ollama(`http://localhost:11434/v1`)、自架 LiteLLM gateway、公司內部 gateway、OpenAI 等。模型需支援 function calling。
- 🚫 **鐵則:任何私人端點 URL / API key 不得出現在程式碼、預設值、文件範例、repo、publish 產物。** 文件範例一律用 `localhost` 或占位符。
**架構(in-process function calling,不經 MCP**
```
AiChatViewWorkspace 的一種 pane,與 SessionPage 並排;工具列 ✨ AI Chat 開啟)
└─ AgentHostAi/AgentHost.cs):維護對話歷史 + 手寫 agent loop(最多 8 輪工具)
├─ OpenAiChatClientAi/OpenAiChatClient.cs):極簡 OpenAI 相容 /chat/completions(非串流)→ 使用者設定的 Base URL
└─ AiToolsAi/AiTools.cs):in-process 直呼,不經子行程 / pipe
├─ serial_list / attach / write / read → SerialBridge 同一套路徑([AI] 標色照舊;read 端自行累積 RX)
└─ pdu_connect / status / set_port / power_cycle → ETTerms.PduCore(本 session 內 IP→controller 登錄)
```
**為何內建 agent 不經 MCP** MCP 解決的是「跨行程 / 跨信任邊界」(Claude / Kiro 是別人的 process,所以需要 stdio JSON-RPC + named pipe 橋接);內建 agent 與工具在**同一個 process**,直呼即可,插一層「子行程 + 序列化 + pipe」繞一圈回自己記憶體裡的物件,只增加故障面。既有的 SerialMcp / PduMcp **不受影響**,繼續服務外部 AI CLISettings → AI MCP)。日後若要開放「使用者掛第三方 MCP server」(ETTerms 作為 **MCP host**,把 MCP client 列出的工具 schema concat 進 `AiTools.GetSchemas()` 的清單即可,agent loop 不需改動。
**安全設計:**
- 破壞性 PDU 動作(`pdu_set_port` off / `pdu_power_cycle`)一律 **C# 端彈確認框**`AiTools.ConfirmAsync` → GUI MessageBox,預設按鈕 No;不信 LLM 自律)。
- 所有 AI 工具呼叫寫 **AppLogger** 留跡(`[AI tool] <name> <args>`)。
- Serial TX 沿 Phase 9 慣例以 `[AI]` 標色 echo`SerialBridgeEndpoint.Write`),使用者全程看得到 AI 打了什麼。
**實作選型:** 手寫 `OpenAiChatClient`HttpClient + System.Text.Json,非串流)+ 手寫 agent loop,**不引入 `Microsoft.Extensions.AI`**——依賴最小、對任意 OpenAI 相容 gateway 相容性自己掌控、無額外 NuGet 演進風險。工具 schema 為手組 JSONOpenAI function-calling 格式)。
--- ---
## Key Constraints & Business Rules ## Key Constraints & Business Rules
@@ -482,6 +519,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
8. **GUI 先行:** Phase 1–2 必須先讓視窗外殼 + 分頁 + 假連線可見可操作,再接真實 channel。 8. **GUI 先行:** Phase 1–2 必須先讓視窗外殼 + 分頁 + 假連線可見可操作,再接真實 channel。
9. **不依賴外部 exe** 不嵌 TeraTerm、不需 com0com;全原生 .NET 元件。 9. **不依賴外部 exe** 不嵌 TeraTerm、不需 com0com;全原生 .NET 元件。
10. **UI 不可被 channel I/O 阻塞:** channel 讀寫在背景,UI 更新一律 `Invoke` 回 UI thread。 10. **UI 不可被 channel I/O 阻塞:** channel 讀寫在背景,UI 更新一律 `Invoke` 回 UI thread。
11. **AI Provider 預設空白(BYO endpoint):** 內建 AI(Phase 10)未設定端點時完全停用;**任何私人端點 / 金鑰不得進程式碼、預設值、文件範例、publish 產物**。API key 只存 Windows Credential Manager。
--- ---
@@ -490,6 +528,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
- **密碼儲存:** 一律使用 **Windows Credential Manager**(透過 `CredentialVault.cs`)。SQLite 內只存索引 `CredentialKey`,無明碼。SSH private key passphrase 同理。 - **密碼儲存:** 一律使用 **Windows Credential Manager**(透過 `CredentialVault.cs`)。SQLite 內只存索引 `CredentialKey`,無明碼。SSH private key passphrase 同理。
- **SSH host key 驗證:** 首次連線顯示 host key 指紋供使用者確認(trust-on-first-use),記錄已信任的指紋,之後比對;指紋不符要警告。 - **SSH host key 驗證:** 首次連線顯示 host key 指紋供使用者確認(trust-on-first-use),記錄已信任的指紋,之後比對;指紋不符要警告。
- **私鑰檔保護:** private key 路徑存設定,但不複製 key 內容進 repo / SQLite。 - **私鑰檔保護:** private key 路徑存設定,但不複製 key 內容進 repo / SQLite。
- **AI ProviderPhase 10):** Base URL / Model 存本機 settings.json、API key 只存 Credential Manager`ETTerms/AiApiKey`);**無任何預設端點**——發佈產物內不含開發者私人伺服器資訊,文件範例一律 `localhost` / 占位符。AI 工具呼叫全程 AppLogger 留跡,破壞性 PDU 動作需 GUI 確認。
- **輸入處理:** 終端機輸入直接透傳給遠端,不做 shell 注入解讀(本來就是終端機);但 UI 載入腳本檔時要防路徑穿越 / 過大檔。 - **輸入處理:** 終端機輸入直接透傳給遠端,不做 shell 注入解讀(本來就是終端機);但 UI 載入腳本檔時要防路徑穿越 / 過大檔。
- **日誌不含密碼:** `AppLogger``logopen` 輸出不可寫入密碼 / passphrase;連線資訊只記主機 / port,不記 credential。 - **日誌不含密碼:** `AppLogger``logopen` 輸出不可寫入密碼 / passphrase;連線資訊只記主機 / port,不記 credential。
- **無 `secret/` 資料夾:** ETTerms 無伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager,因此不設 `secret/` 集中目錄,也不需要 publish 類腳本。若日後做 Release 程式碼簽章,簽章 `.pfx` 請放在 repo 外並以環境變數 / CI secret 傳入。 - **無 `secret/` 資料夾:** ETTerms 無伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager,因此不設 `secret/` 集中目錄,也不需要 publish 類腳本。若日後做 Release 程式碼簽章,簽章 `.pfx` 請放在 repo 外並以環境變數 / CI secret 傳入。
@@ -704,6 +743,19 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
- [x] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md),含「需先在 GUI 開好 port」前提 - [x] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md),含「需先在 GUI 開好 port」前提
**驗收條件:** ✅ GUI 開一條 SerialCOM3)→ 另一分頁 PowerShell 跑 kiro → AI 經 MCP `serial_attach` COM3 → `serial_write` 送指令、`serial_read` 讀回應,**整個過程在 GUI Tab1 即時可見(AI 的 TX 有 `[AI]` 標色)**;全程只有 GUI 開該 port。 **驗收條件:** ✅ GUI 開一條 SerialCOM3)→ 另一分頁 PowerShell 跑 kiro → AI 經 MCP `serial_attach` COM3 → `serial_write` 送指令、`serial_read` 讀回應,**整個過程在 GUI Tab1 即時可見(AI 的 TX 有 `[AI]` 標色)**;全程只有 GUI 開該 port。
### Phase 10 — 內建 AI AssistantBYO endpoint agent)(工作量:M)✅ 已完成(v0.6.0)
**目標:** 不依賴 Claude / KiroGUI 內建 AI 聊天檢視,自然語言驅動 serial + PDU;**發佈版不含任何私人端點**(設計見 [內建 AI Assistant](#內建-ai-assistantphase-10--✅-已完成-v060))。
**包含:**
- [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/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/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 入口在 `WorkspaceView` 工具列(`✨ AI Chat` 按鈕),開成可並排的 pane(非獨立 rail view)——這樣才能與 serial 分頁同時使用
- [ ] (延伸,未做)MCP host:讓使用者掛自己的第三方 MCP serverschema concat 進 `AiTools.GetSchemas()`
**驗收條件:** ✅ 建置 0 錯誤;未設定 Provider 時 AI 面板停用並提示;`src` grep 無任何私人端點 / key(範例一律 localhost / 假 IP)。實機端到端(接 OpenAI 相容端點跑 serial/PDU 一句話流程、PDU 確認框、`[AI]` 標色、AppLogger 紀錄)待使用者驗收。
--- ---
## Future Extensions ## Future Extensions
@@ -712,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)
+6 -2
View File
@@ -8,7 +8,9 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**開發策略:GUI 先行** — 先把視窗外殼 + 分頁 + 連線清單做出來,再逐步補 Serial → SSH → VT100 → 腳本引擎 → Settings/About → PDU/Shell/SFTP。 **開發策略:GUI 先行** — 先把視窗外殼 + 分頁 + 連線清單做出來,再逐步補 Serial → SSH → VT100 → 腳本引擎 → Settings/About → PDU/Shell/SFTP。
**進度:** 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 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。**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 共有)。
@@ -37,6 +39,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
- **連線儲存:** SQLite`Microsoft.Data.Sqlite` - **連線儲存:** SQLite`Microsoft.Data.Sqlite`
- **密碼儲存:** Windows Credential Manager(不落地明碼) - **密碼儲存:** Windows Credential Manager(不落地明碼)
- **PDU** SnmpSharpNetiPoMan II/III via SNMP - **PDU** SnmpSharpNetiPoMan II/III via SNMP
- **內建 AI Assistantv0.6.0):** 手寫 OpenAI 相容 clientHttpClient+ agent loopin-process 直呼 serial/PDU 工具;BYO endpointProvider 預設空白,發佈版不含端點)。`src/ETTerms/Ai/`
- **AI / MCP(選用):** 兩個 stdio MCP server(官方 C# SDK `ModelContextProtocol`)。`ETTerms.SerialMcp`**不自己開 COM port**,經本機 named pipe 接上 GUI 持有的 serial sessionAI 的 TX/RX 同步顯示在 GUI。`ETTerms.PduMcp`v0.3.0):**直接打 SNMP** 控制 PDU 插座,非獨佔故不需 GUI 在跑。皆暴露給 Kiro CLI / Claude CLI - **AI / MCP(選用):** 兩個 stdio MCP server(官方 C# SDK `ModelContextProtocol`)。`ETTerms.SerialMcp`**不自己開 COM port**,經本機 named pipe 接上 GUI 持有的 serial sessionAI 的 TX/RX 同步顯示在 GUI。`ETTerms.PduMcp`v0.3.0):**直接打 SNMP** 控制 PDU 插座,非獨佔故不需 GUI 在跑。皆暴露給 Kiro CLI / Claude CLI
- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json` - **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json`
@@ -83,6 +86,7 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
## 注意事項 / 禁止事項 ## 注意事項 / 禁止事項
- 🚫 **密碼絕不寫進 SQLite / 程式碼 / log**,一律走 Windows Credential Manager。 - 🚫 **密碼絕不寫進 SQLite / 程式碼 / log**,一律走 Windows Credential Manager。
- 🚫 **內建 AIPhase 10)採 BYO endpoint**ProviderBase URL / API Key / Model**預設空白**=功能停用;**開發者私人的 LLM 端點 / API key 絕不寫進程式碼、預設值、文件範例、publish 產物**——只存在開發者本機的 settings.json / Credential Manager。文件與 UI 範例一律用 `http://localhost:11434/v1` 或占位符。發佈前驗收:publish 資料夾 grep 不到任何私人 IP / 網域 / key。
- 🚫 **不嵌 TeraTerm、不依賴 com0com** —— ETTerms 走全原生(這是與舊版 MyTeraTerm 的關鍵差異)。 - 🚫 **不嵌 TeraTerm、不依賴 com0com** —— ETTerms 走全原生(這是與舊版 MyTeraTerm 的關鍵差異)。
- 🚫 不要把 `For_AI/` 內容 commit 進 git。 - 🚫 不要把 `For_AI/` 內容 commit 進 git。
- ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。 - ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。
@@ -95,7 +99,7 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
## 資料夾用途 ## 資料夾用途
- **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。 - **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。內含 **`Ai/`**v0.6.0 內建 AI Assistant`OpenAiChatClient` + `AgentHost` + `AiTools`)與 `App/AiChatView.cs`
- **`src/ETTerms.SerialMcp/`** — ✅ stdio MCP server(給 AI agent 收發 serial)。獨立行程,但**不直接開 COM port**:經本機 named pipe 連到 GUI 的 `SerialBridgeServer`,由 GUI 代為讀寫實體 portnet8.0 console + `ModelContextProtocol` SDK。 - **`src/ETTerms.SerialMcp/`** — ✅ stdio MCP server(給 AI agent 收發 serial)。獨立行程,但**不直接開 COM port**:經本機 named pipe 連到 GUI 的 `SerialBridgeServer`,由 GUI 代為讀寫實體 portnet8.0 console + `ModelContextProtocol` SDK。
- **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDUv0.3.0)。獨立行程,**直接打 SNMP**,不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol`PDU 邏輯用 `ETTerms.PduCore` - **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDUv0.3.0)。獨立行程,**直接打 SNMP**,不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol`PDU 邏輯用 `ETTerms.PduCore`
- **`src/ETTerms.PduCore/`** — ✅ PDU SNMP 控制共用庫(v0.4.0)。GUI 與 PduMcp 共用的唯一 `PduController`(先前兩份複製已移除);診斷 log 走建構子注入委派(GUI→AppLogger、MCP→stderr);含批次查詢 `GetAllPortsStatus()` - **`src/ETTerms.PduCore/`** — ✅ PDU SNMP 控制共用庫(v0.4.0)。GUI 與 PduMcp 共用的唯一 `PduController`(先前兩份複製已移除);診斷 log 走建構子注入委派(GUI→AppLogger、MCP→stderr);含批次查詢 `GetAllPortsStatus()`
+53
View File
@@ -0,0 +1,53 @@
# v0.5.0 — Terminal search, keyword alerts & a major TTL scripting upgrade
## ✨ New features
**Find text in your scroll-back (Ctrl+F)**
* Press **Ctrl+F** to open a search bar right inside the terminal and find any text in the
scroll-back history — **Enter** jumps to the previous match, **Shift+Enter** to the next,
**F3** repeats, **Esc** closes.
* Every match is highlighted in **yellow**, the current one in **orange**, and matches stay
anchored to the right line even as old output scrolls off the top of the buffer.
**Keyword highlighting + per-tab alerts**
* Define your own keyword list under **Settings → Highlight** (add, remove, and enable/disable
each one). Matching text is highlighted on screen wherever it appears, case-insensitive.
* When a keyword shows up on a tab you're **not** currently looking at, that tab's dot turns
**red** so you notice it — switching to the tab clears the alert. Great for watching several
sessions at once for an error string.
**A much bigger, TeraTerm-compatible TTL scripting language**
* The TTL engine now understands a large set of **TeraTerm-compatible commands**, so many
existing TeraTerm macros run with little or no change.
* New control flow — `for`/`next`, `do`/`loop`, `while`/`until`, `call`, `goto`, `break`,
`continue`, `include` — plus a real expression parser (parentheses, logical & comparison
operators, hex `0x` / `$`), and dozens of new string, file/folder and utility commands
(`sprintf`, `strsplit`, `filereadln`, `getdate`, `random`, `crc32`, `inputbox`, `yesnobox`, …).
* New **serial-specific** script commands: `sendbreak`, `setbaud`, `setdtr`, `setrts`, `sendfile`.
* Note: a single-string `wait` keeps ETTerms' existing settle-and-timeout behavior; multi-string
`wait` follows TeraTerm semantics (times out and continues with `result=0`).
**See — or hide — what your scripts are doing**
* Scripts now echo their progress (waits, sends, errors) in **grey** directly in the terminal,
and you can switch this off under **Settings → Terminal** (**Show script trace**).
* The trace is display-only: it never reaches your session log, the AI bridge, or the device,
so your captured machine logs stay clean.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.5.0`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.5.0_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.5.0.exe`.
## 🔗 Links
* TTL reference:
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* PDU MCP & Serial MCP setup guide:
[docs/serial-mcp-guide.md](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
**Full changelog:** [v0.4.0...v0.5.0](https://github.com/ETWen/ETTerms/compare/v0.4.0...v0.5.0)
+37
View File
@@ -0,0 +1,37 @@
# v0.6.0 — Talk to your serial port & PDU: a built-in AI Assistant
## ✨ New features
**Built-in AI Assistant — drive serial & PDU in plain language**
* New **✨ AI Chat** button in the workspace toolbar opens an AI pane. Ask things like *"list the open serial sessions"*, *"attach to COM3, send `help` and show me the reply"*, or *"connect to the PDU and power-cycle outlet 3"* — the assistant calls the right tools for you.
* Whatever the AI sends to a serial port shows up in that terminal tagged `[AI]`, so you always see exactly what it did — no hidden actions.
* Runs entirely against your own machines: it talks to the serial session ETTerms already has open, and to the PDU over SNMP.
**Sits right next to your terminal — like Claude Code / Kiro**
* The AI Chat is a normal workspace pane, so you can use Layout (1×2, 2×2…) to put it side-by-side with a live Serial session — chat on one side, watch the device output on the other.
* Clean transcript layout that matches the terminal: your messages align right, the AI's replies read left, and tool activity stays as quiet gray notes. "Thinking" is folded into the Send button instead of spamming the log.
**Bring your own AI endpoint (nothing baked in)**
* Point it at any OpenAI-compatible endpoint — a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI. Set the Base URL and API key once in **Settings → AI Assistant**; leave it blank and the assistant simply stays off.
* Pick the model from a dropdown in the bottom-right of the chat pane — it lists whatever your endpoint offers and switches on the fly, no trip back to Settings. Your choice is remembered.
* 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 a colleague has the assistant disabled by default.
**Safe by design**
* Turning a PDU outlet off or power-cycling always asks you to confirm 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.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **ETTerms_v0.6.0** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **ETTerms_v0.6.0_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `ETTerms v0.6.0.exe`.
## 🔗 Links
* [Architecture](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md) — see "Built-in AI Assistant (Phase 10)"
* [Serial MCP guide](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
**Full changelog:** [v0.5.0...v0.6.0](https://github.com/ETWen/ETTerms/compare/v0.5.0...v0.6.0)
+89
View File
@@ -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");
}
}
+229
View File
@@ -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 schemafunction 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();
}
}
+98
View File
@@ -0,0 +1,98 @@
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 相容 gatewayOllama / LiteLLM / 公司內部 gateway / OpenAI…)皆可接。
///
/// ⚠️ Base URL 與 API key 皆由使用者於 Settings 設定,不寫死於程式碼(見 AppSettings 註解)。
/// </summary>
public sealed class OpenAiChatClient : IDisposable
{
private readonly HttpClient _http;
/// <summary>目前使用的模型;可即時切換(下一輪對話生效),用於底部模型下拉選單。</summary>
public string Model { get; set; }
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>列出端點可用模型 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>
/// 送出一輪對話(含歷史 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();
}
+11
View File
@@ -183,6 +183,17 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = 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 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.",
"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\".",
"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", 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.", "Press Ctrl+F in any terminal to search everything you've scrolled past — all hits are highlighted, Enter jumps between them.",
+264
View File
@@ -0,0 +1,264 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Ai;
using ETTerms.Connections;
using ETTerms.Infrastructure;
namespace ETTerms.App;
/// <summary>
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
///
/// 呈現走「乾淨對話逐字稿」風格(與 ETTerms 終端機/log 美學一致,非氣泡卡片):
/// 使用者訊息靠右 accent 色、AI 回覆靠左段落、工具活動灰字縮排、思考中收進 Send 按鈕不洗版。
/// (真氣泡 + Markdown 的 WebView2 版列為 v0.7.0。)
///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary>
public sealed class AiChatView : UserControl
{
private readonly RichTextBox _log;
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;
private AiTools? _tools;
private CancellationTokenSource? _cts;
private const string SendText = "Send ⏎";
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
};
// ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, 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(); }
};
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
{
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 = 24, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
refreshBtn.FlatAppearance.BorderColor = Theme.Border;
refreshBtn.Click += (_, _) => _ = LoadModelsAsync();
modelWrap.Controls.Add(_modelBox); // Fill
modelWrap.Controls.Add(refreshBtn); // Right
var gap = new Panel { Dock = DockStyle.Top, Height = 6, BackColor = Theme.RailBack };
_send = new Button
{
Text = SendText, Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
_send.FlatAppearance.BorderColor = Theme.Accent;
_send.Click += (_, _) => OnSend();
rightCol.Controls.Add(_send); // Fill 先加
rightCol.Controls.Add(gap); // TopSend 上方間距)
rightCol.Controls.Add(modelWrap); // Top(最上:模型下拉)
bottom.Controls.Add(_input); // Fill 先加
bottom.Controls.Add(rightCol); // Right(右下角)
_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。"
};
Controls.Add(_log); // Fill
Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
AppendSystem("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」");
}
/// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary>
public void RefreshProvider()
{
var s = AppSettings.Instance;
bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl);
_hint.Visible = !configured;
_input.Enabled = _send.Enabled = _modelBox.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));
// thinking 不印進對話流(改由 Send 按鈕顯示忙碌),避免洗版。
_ = 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 (!string.IsNullOrEmpty(current) && !models.Contains(current)) models.Insert(0, current);
Ui(() =>
{
_suppressModelEvent = true;
_modelBox.Items.Clear();
foreach (var m in models) _modelBox.Items.Add(m);
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;
});
}
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);
SetBusy(true);
_cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AppendDim("(已取消)"); }
catch (Exception ex) { AppendError(ex.Message); }
finally { SetBusy(false); }
}
private void SetBusy(bool busy)
{
_send.Enabled = !busy;
_send.Text = busy ? "…" : SendText;
}
// ── 輸出(乾淨 transcript)──
/// <summary>使用者訊息:靠右 accent 色,無前綴。</summary>
private void AppendUser(string t)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Right;
_log.SelectionColor = Theme.Accent;
_log.SelectionFont = new Font(_log.Font, FontStyle.Bold);
_log.AppendText(t + "\n");
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.AppendText("\n");
_log.ScrollToCaret();
}
/// <summary>AI 回覆:靠左段落。</summary>
private void AppendAssistant(string t)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionColor = Theme.Text;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\n\n");
_log.ScrollToCaret();
}
private void AppendTool(string t) => AppendLine("⚙ " + t, Color.FromArgb(150, 150, 158));
private void AppendSystem(string t) => AppendLine(t, Theme.TextDim);
private void AppendError(string t) => AppendLine("⚠ " + t, Color.FromArgb(235, 120, 120));
private void AppendDim(string t) => AppendLine(t, Theme.TextDim);
private void AppendLine(string t, Color color)
{
_log.SelectionStart = _log.TextLength;
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.SelectionColor = color;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\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);
}
}
+90 -1
View File
@@ -1,10 +1,11 @@
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Connections;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
namespace ETTerms.App; 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 sealed class SettingsView : UserControl
{ {
public SettingsView() public SettingsView()
@@ -53,6 +54,7 @@ public sealed class SettingsView : UserControl
var termBtn = MakeTab("Terminal", BuildTerminalTab()); var termBtn = MakeTab("Terminal", BuildTerminalTab());
tabBar.Controls.Add(termBtn); tabBar.Controls.Add(termBtn);
tabBar.Controls.Add(MakeTab("Highlight", BuildHighlightTab())); tabBar.Controls.Add(MakeTab("Highlight", BuildHighlightTab()));
tabBar.Controls.Add(MakeTab("AI Assistant", BuildAiAssistantTab()));
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab())); tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body); Controls.Add(body);
@@ -290,6 +292,93 @@ public sealed class SettingsView : UserControl
return page; 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. 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.",
AutoSize = false, Width = 620, Height = 68,
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 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("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.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)
? "Saved. AI Assistant stays disabled until you set a Base URL."
: "Saved. Open ✨ AI Chat in the workspace toolbar, then pick a model from the dropdown.",
"AI Assistant", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
flow.Controls.Add(save);
page.Controls.Add(flow);
return page;
}
// ═══ AI MCP Tab ═══ // ═══ AI MCP Tab ═══
private Panel BuildAiMcpTab() private Panel BuildAiMcpTab()
{ {
+64 -32
View File
@@ -17,7 +17,11 @@ public sealed class WorkspaceView : UserControl
{ {
public required string Title; public required string Title;
public required bool IsSsh; public required bool IsSsh;
public required SessionPage Page; public SessionPage? Page; // 連線分頁(serial/ssh/shell);AI 分頁時為 null
public AiChatView? Ai; // AI 聊天分頁;連線分頁時為 null
public bool IsAi => Ai != null;
/// <summary>可塞進格子的內容控制項(SessionPage 或 AiChatView,二擇一)。</summary>
public Control Content => (Control?)Page ?? Ai!;
public Rectangle TabBounds; public Rectangle TabBounds;
public Rectangle CloseRect; public Rectangle CloseRect;
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除 public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
@@ -61,6 +65,17 @@ public sealed class WorkspaceView : UserControl
Dock = DockStyle.Fill, BackColor = Theme.RailBack, Dock = DockStyle.Fill, BackColor = Theme.RailBack,
Padding = new Padding(8, 6, 8, 6), WrapContents = false Padding = new Padding(8, 6, 8, 6), WrapContents = false
}; };
// ── ✨ New AI Chat(開一個 AI 分頁,可用 Layout 與 serial 並排)──
var aiBtn = MakeActionButton("✨ AI Chat", 92, 0, (_, _) => OpenAiPane());
aiBtn.ForeColor = Theme.Accent;
aiBtn.FlatAppearance.BorderColor = Theme.Accent;
_toolbar.Controls.Add(aiBtn);
_toolbar.Controls.Add(new Label
{
Text = "│", AutoSize = true, ForeColor = Theme.Border,
Font = Theme.UiFont, Margin = new Padding(4, 6, 4, 0)
});
_toolbar.Controls.Add(new Label _toolbar.Controls.Add(new Label
{ {
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim, Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
@@ -126,6 +141,17 @@ public sealed class WorkspaceView : UserControl
Relayout(); Relayout();
} }
/// <summary>開啟一個 AI Assistant 分頁(跟連線分頁一樣可用 Layout 並排,與 serial 同時使用)。</summary>
public void OpenAiPane()
{
var view = new AiChatView();
var s = new Session { Title = "AI Assistant", IsSsh = false, Ai = view };
_sessions.Add(s);
_active = s;
Relayout();
view.RefreshProvider();
}
private void OnConnectFailed(Session s, string msg) private void OnConnectFailed(Session s, string msg)
{ {
if (IsDisposed) return; if (IsDisposed) return;
@@ -150,17 +176,18 @@ public sealed class WorkspaceView : UserControl
private void CloseSession(Session s) private void CloseSession(Session s)
{ {
int idx = _sessions.IndexOf(s); int idx = _sessions.IndexOf(s);
s.Page.Parent = null; s.Content.Parent = null;
_sessions.Remove(s); _sessions.Remove(s);
s.Page.Dispose(); s.Content.Dispose();
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null; if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
RefreshGroupLabels(); RefreshGroupLabels();
Relayout(); Relayout();
} }
// ── Group 管理 ─────────────────────────────────────────── // ── Group 管理AI 分頁無 Group─────────────────────────
private void SetSessionGroup(Session s, int group) private void SetSessionGroup(Session s, int group)
{ {
if (s.Page == null) return;
s.Page.Group = group; s.Page.Group = group;
RefreshGroupLabels(); RefreshGroupLabels();
Relayout(); Relayout();
@@ -171,17 +198,17 @@ public sealed class WorkspaceView : UserControl
for (int g = 1; g <= 3; g++) for (int g = 1; g <= 3; g++)
{ {
char letter = 'A'; char letter = 'A';
foreach (var s in _sessions.Where(x => x.Page.Group == g)) foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == g))
s.Page.GroupLabel = $"Group{g}-{letter++}"; s.Page!.GroupLabel = $"Group{g}-{letter++}";
} }
foreach (var s in _sessions.Where(x => x.Page.Group == 0)) foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == 0))
s.Page.GroupLabel = ""; s.Page!.GroupLabel = "";
} }
// ── 依目前 Layout 把分頁鋪進格子 ───────────────────────── // ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
private void Relayout() private void Relayout()
{ {
foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活) foreach (var s in _sessions) s.Content.Parent = null; // 先卸下(保留存活)
_body.SuspendLayout(); _body.SuspendLayout();
for (int i = _body.Controls.Count - 1; i >= 0; i--) for (int i = _body.Controls.Count - 1; i >= 0; i--)
{ {
@@ -229,14 +256,16 @@ public sealed class WorkspaceView : UserControl
private Control MakeCell(Session s, bool withLabel) private Control MakeCell(Session s, bool withLabel)
{ {
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) }; var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
s.Page.Dock = DockStyle.Fill; s.Content.Dock = DockStyle.Fill;
s.Page.Visible = true; s.Content.Visible = true;
cell.Controls.Add(s.Page); // Fill 先加 cell.Controls.Add(s.Content); // Fill 先加
if (withLabel) if (withLabel)
{ {
string labelText = string.IsNullOrEmpty(s.Page.GroupLabel) string icon = s.IsAi ? "✨" : s.IsSsh ? "🖧" : "🔌";
? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}" string glabel = s.Page?.GroupLabel ?? "";
: $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]"; string labelText = string.IsNullOrEmpty(glabel)
? $"{icon} {s.Title}"
: $"{icon} {s.Title} [{glabel}]";
var lbl = new Label var lbl = new Label
{ {
Dock = DockStyle.Bottom, Height = 22, Dock = DockStyle.Bottom, Height = 22,
@@ -253,8 +282,8 @@ public sealed class WorkspaceView : UserControl
private void FocusActive() private void FocusActive()
{ {
if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated) if (_active?.Content is { IsDisposed: false } c && c.IsHandleCreated)
p.Focus(); c.Focus();
} }
// ── 頂部 Tab 列 ────────────────────────────────────────── // ── 頂部 Tab 列 ──────────────────────────────────────────
@@ -276,7 +305,7 @@ public sealed class WorkspaceView : UserControl
{ {
foreach (var s in _sessions) foreach (var s in _sessions)
{ {
if (s.TabBounds.Contains(e.Location)) { ShowGroupMenu(s, e.Location); return; } if (s.TabBounds.Contains(e.Location)) { if (!s.IsAi) ShowGroupMenu(s, e.Location); return; }
} }
return; return;
} }
@@ -303,7 +332,7 @@ public sealed class WorkspaceView : UserControl
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2)); menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3)); menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
// Check current // Check current
int current = s.Page.Group; int current = s.Page?.Group ?? 0;
((ToolStripMenuItem)menu.Items[current]).Checked = true; ((ToolStripMenuItem)menu.Items[current]).Checked = true;
menu.Show(_tabStrip, pt); menu.Show(_tabStrip, pt);
} }
@@ -368,8 +397,10 @@ public sealed class WorkspaceView : UserControl
if (drag) if (drag)
using (var pen = new Pen(Theme.Accent, 1)) using (var pen = new Pen(Theme.Accent, 1))
g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1)); g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1));
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除 // 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。
using (var dot = new SolidBrush(s.Alert ? Color.FromArgb(235, 85, 85) : s.IsSsh ? Theme.SshColor : Theme.SerialColor)) Color dotColor = s.Alert ? Color.FromArgb(235, 85, 85)
: s.IsAi ? Theme.Accent : s.IsSsh ? Theme.SshColor : Theme.SerialColor;
using (var dot = new SolidBrush(dotColor))
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8); g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH); var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH);
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim, TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
@@ -415,19 +446,20 @@ public sealed class WorkspaceView : UserControl
// ── Log All(一次開/關所有分頁側錄) ────────────────────── // ── Log All(一次開/關所有分頁側錄) ──────────────────────
private void OnToggleLogAll(object? sender, EventArgs e) private void OnToggleLogAll(object? sender, EventArgs e)
{ {
if (_sessions.Count == 0) var loggable = _sessions.Where(s => s.Page != null).Select(s => s.Page!).ToList();
if (loggable.Count == 0)
{ {
MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information); MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。 // 只要還有分頁沒在側錄 → 全部開始;否則全部停止。AI 分頁無側錄)
bool startAll = _sessions.Any(s => !s.Page.IsLogging); bool startAll = loggable.Any(p => !p.IsLogging);
if (startAll) if (startAll)
{ {
int failed = 0; int failed = 0;
foreach (var s in _sessions) foreach (var p in loggable)
if (!s.Page.StartLog()) failed++; if (!p.StartLog()) failed++;
SetLogAllActive(true); SetLogAllActive(true);
if (failed > 0) if (failed > 0)
MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.", MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.",
@@ -435,7 +467,7 @@ public sealed class WorkspaceView : UserControl
} }
else else
{ {
foreach (var s in _sessions) s.Page.StopLog(); foreach (var p in loggable) p.StopLog();
SetLogAllActive(false); SetLogAllActive(false);
} }
} }
@@ -451,8 +483,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunAllSerial(object? sender, EventArgs e) private async void OnRunAllSerial(object? sender, EventArgs e)
{ {
var serials = _sessions var serials = _sessions
.Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning) .Where(s => s.Page != null && !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page) .Select(s => s.Page!)
.ToList(); .ToList();
if (serials.Count == 0) if (serials.Count == 0)
@@ -472,8 +504,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunGroup(int group) private async void OnRunGroup(int group)
{ {
var members = _sessions var members = _sessions
.Where(s => s.Page.Group == group && !s.Page.IsScriptRunning) .Where(s => s.Page != null && s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page) .Select(s => s.Page!)
.ToList(); .ToList();
if (members.Count == 0) if (members.Count == 0)
@@ -491,7 +523,7 @@ public sealed class WorkspaceView : UserControl
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)
foreach (var s in _sessions) { try { s.Page.Dispose(); } catch { } } foreach (var s in _sessions) { try { s.Content.Dispose(); } catch { } }
base.Dispose(disposing); base.Dispose(disposing);
} }
+1 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.5.0</Version> <Version>0.6.0</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
+10
View File
@@ -30,6 +30,16 @@ public sealed class AppSettings
public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd
public string ShellStartupDir { get; set; } = ""; public string ShellStartupDir { get; set; } = "";
// ── AI AssistantBYO endpoint)──
// ⚠️ 預設全空白=內建 AI 停用。任何私人端點 / 金鑰不得寫死於此或程式碼——
// 使用者自己在 Settings → AI Assistant 填。API key 存 Credential ManagerETTerms/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 分頁設定)── // ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
public bool KeywordHighlightEnabled { get; set; } = true; public bool KeywordHighlightEnabled { get; set; } = true;
public List<KeywordRule> KeywordRules { get; set; } = new(); public List<KeywordRule> KeywordRules { get; set; } = new();