15 Commits
Author SHA1 Message Date
etwenandClaude Opus 4.8 90c1c45a56 merge: v0.7.1 — sendlnretry: stop losing commands to a booting device
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 10:13:06 +08:00
etwenandClaude Opus 4.8 6d84de819f docs(about): Add v0.7.1 to the in-app changelog
The About page changelog is a hardcoded array, so it did not list v0.7.1
even though the title bar and About header already read 0.7.1 (those come
from the assembly version).

Also rework docs/release-notes/v0.7.1.md to the house format: New features
before Bug fixes, bold group headings, and the standard Downloads table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 10:12:59 +08:00
etwenandClaude Opus 4.8 3c90df0e83 docs: add v0.7.1 release notes (and bump version to 0.7.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 09:22:49 +08:00
etwenandClaude Opus 4.8 fae5ca7337 fix(ttl): add sendlnretry — resend until the device confirms it ran the command
A device that is still booting can silently discard console input the moment its
shell takes over the tty (tty reopen / termios flush). The line sent by `sendln`
vanishes — the device neither echoes nor runs it — so the following `wait` blocks
forever and the rig sits dead.

This is a race, not a delay: `pause` before `sendln` only lowers the odds of hitting
the window, it can never close it. Evidence from a 45-cycle overnight power-cycle run
(For_AI/[COM121]_20260715): 3 cycles hung this way, and the swallowed sends landed at
the same DUT uptime (31.8-34.8s) as the 42 that worked. The `random: crng init done`
line those hangs share is a symptom, not the cause — it only appears because a hung
script stops power-cycling, letting the DUT reach uptime 89.7s it never otherwise sees.

Single-string `wait` aborts the script on timeout (deliberate, see CLAUDE.md), so the
TeraTerm idiom `wait` -> `if result = 0 then goto retry` cannot express a resend here.
Hence a new command rather than a semantics change:

    sendlnretry '<text>' '<confirm keyword>' [max attempts]

Sends, then waits for proof the device actually ran it, and resends if that proof does
not arrive. Attempts omitted = retry until it gets through. On success result=1; when
attempts run out result=0 and the script continues so it can handle the failure.

Semantics: clears the receive buffer before each send (a match can only come from this
send); on a hit consumes only up to the first occurrence, leaving the rest for the next
`wait`; deliberately no settle (the keyword appearing is itself proof). Per-attempt
timeout follows timeout/mtimeout when set, else 3s — unlike `wait`, 0 cannot mean
"forever" here since that would mean never retrying.

Not yet run against hardware: compiles clean (cross-built win-x64 on Linux), but the
DUT power-cycle rig is the first real execution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 09:22:49 +08:00
etwenandClaude Fable 5 4f1aff07be merge: v0.7.0 — WebView2 AI chat (bubbles, Markdown, thinking) + configurable tool limit
The AI Assistant pane moves to WebView2: real chat bubbles (user right, AI
left), full Markdown, and an animated thinking indicator while the AI works.
The tool-call limit is now a Settings value (0 = unlimited) with a Stop button
in the chat to abort long/automation runs. Verified end-to-end against an
Arduino serial DUT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:05:58 +08:00
etwenandClaude Fable 5 cc134d56c9 docs: add v0.7.0 release notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:05:58 +08:00
etwenandClaude Fable 5 7ce65536f6 feat(ai): configurable tool-call limit (0=unlimited) + Stop button
Replace the fixed 30-round cap with a Settings value (AiMaxToolRounds,
Settings → AI Assistant). 0 = unlimited, for long automation runs left
going for hours. The chat's Send button turns into Stop while the agent
is running so any run — bounded or unlimited — can be aborted (via the
CancellationToken; the loop also checks it each round).

Docs: ARCHITECTURE gains the configurable-limit note and a generic
"pair with a self-hosted gateway = hardware assistant" usage section
(no private endpoints).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:54:40 +08:00
etwenandClaude Fable 5 d6135237e8 fix(ai): raise agent tool-call limit 8 -> 30
Multi-step hardware tasks (operate several ports, repeated read/write,
power-cycle waits) hit the old 8-round cap easily — e.g. "send help 3
times" needs 8 calls and stopped short of the final summary. 30 keeps
the runaway-loop guard while allowing normal workflows to finish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:45:57 +08:00
etwenandClaude Fable 5 4820de22f8 feat: WebView2 chat — real bubbles, Markdown & thinking indicator (v0.7.0)
The AI Chat pane's message area moves from RichTextBox to WebView2:
- Real chat bubbles (user right, AI left) with full Markdown via Markdig
  (code blocks, tables, lists, inline code).
- Sending a prompt shows an animated "…" thinking bubble that clears when
  the reply lands (AgentHost Status "thinking" → showThinking; AssistantText
  → hideThinking + bubble).
- HTML/CSS/JS template inlined in Ai/ChatHtml.cs (NavigateToString, no
  external deps); C# drives it via ExecuteScriptAsync. Calls made before
  WebView2 is ready are queued and flushed on NavigationCompleted. User-data
  folder under %LocalAppData%\ETTerms\WebView2.

Bottom bar (input / model dropdown / Send), [AI] serial tagging and PDU
confirmations are unchanged. Adds Microsoft.Web.WebView2 + Markdig; needs
the WebView2 Runtime (built into Windows 11). Publish verified to include
the native WebView2Loader.dll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:59:37 +08:00
etwenandClaude Fable 5 cf1fd2c345 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>
2026-07-05 22:36:54 +08:00
etwenandClaude Fable 5 62d3aa7807 docs: add v0.6.0 release notes (and backfill v0.5.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:36:45 +08:00
etwenandClaude Fable 5 639d10438d 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>
2026-07-05 22:31:09 +08:00
etwenandClaude Fable 5 ad0f6c5114 feat(ai): chat bubbles + on-the-fly model dropdown
- Chat UI now Claude-style: user messages right-aligned with an accent
  bubble (no "你:" prefix), AI replies left-aligned.
- Model dropdown at the bottom, populated from the endpoint's GET
  /v1/models, lets you switch models without opening Settings; the choice
  is remembered. OpenAiChatClient.Model is now mutable + ListModelsAsync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:12:19 +08:00
etwenandClaude Fable 5 6e955ba247 feat: make AI Assistant a tileable workspace pane (not a rail view)
Open it from the toolbar ( AI Chat) and lay it out next to a Serial
session with Layout (1×2, 2×2…) — chat on one side, watch the terminal
on the other, like Claude Code / Kiro. Previously it was a full-page
Activity Rail view that couldn't sit beside a live session.

WorkspaceView.Session is now abstracted to hold either a SessionPage or
an AiChatView via a Content property; group/log/script actions skip AI
panes. Removed the standalone Ai rail view; Settings → AI Assistant
(provider config) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:57:09 +08:00
etwenandClaude Fable 5 d9e0e8fabf feat: built-in AI Assistant with BYO endpoint (v0.6.0, Phase 10)
Add a GUI AI Assistant view ( rail) that drives serial + PDU in plain
language, without depending on Claude/Kiro. In-process function calling
(no MCP hop): Ai/OpenAiChatClient (minimal OpenAI-compatible client) +
Ai/AgentHost (hand-written agent loop) + Ai/AiTools (serial via the
existing SerialBridge with [AI] echo; PDU via ETTerms.PduCore).

BYO endpoint: Base URL / Model / API Key set in Settings → AI Assistant,
blank by default = disabled. No private endpoint ships in the app; API
key lives in Windows Credential Manager, never in settings.json or code.

Safety: destructive PDU actions (outlet off / power-cycle) require a GUI
confirmation; every tool call is written to AppLogger. Existing Serial/
PDU MCP servers (Settings → AI MCP) are unaffected and keep serving
external AI CLIs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:10:02 +08:00
18 changed files with 1378 additions and 37 deletions
+57
View File
@@ -33,6 +33,7 @@ ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗
| PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect` |
| 日誌 | 自製 **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 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 |
> **與舊版 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/
│ └── 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/
├── AppLogger.cs # 日誌 (port 自 MyTeraTerm)
├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json)
@@ -468,6 +474,41 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
> 所有工具回傳統一的 `{ "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 打了什麼。
- **工具呼叫上限可設定**`AppSettings.AiMaxToolRounds`,Settings → AI Assistant):單次訊息最多鏈幾輪工具的保險,**0 = 無上限**(給放著跑一天的自動化腳本;每輪都燒 token,執行中聊天視窗的 **Stop** 鈕可隨時中止,經 `CancellationToken`)。預設 30。
**典型應用(搭配自架 OpenAI 相容 gateway = 硬體工程助理):**
把 Provider 指向你自己的 LLM gateway(本機 Ollama / LiteLLM / 公司 gateway…),ETTerms 就變成一個能用自然語言操作實體硬體的助理:查/送 serial console 指令、依裝置回應判斷、控制 PDU power-cycle DUT、跑重複性測試序列(工具上限設 0 可長跑)。AI 的 serial TX 以 `[AI]` 顯示在終端機,與手動操作同一條 channel,所見即所得。⚠️ 端點由使用者自帶,發佈版不含任何端點(見下方 Security)。
**實作選型:** 手寫 `OpenAiChatClient`HttpClient + System.Text.Json,非串流)+ 手寫 agent loop,**不引入 `Microsoft.Extensions.AI`**——依賴最小、對任意 OpenAI 相容 gateway 相容性自己掌控、無額外 NuGet 演進風險。工具 schema 為手組 JSONOpenAI function-calling 格式)。
---
## Key Constraints & Business Rules
@@ -482,6 +523,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
8. **GUI 先行:** Phase 1–2 必須先讓視窗外殼 + 分頁 + 假連線可見可操作,再接真實 channel。
9. **不依賴外部 exe** 不嵌 TeraTerm、不需 com0com;全原生 .NET 元件。
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 +532,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
- **密碼儲存:** 一律使用 **Windows Credential Manager**(透過 `CredentialVault.cs`)。SQLite 內只存索引 `CredentialKey`,無明碼。SSH private key passphrase 同理。
- **SSH host key 驗證:** 首次連線顯示 host key 指紋供使用者確認(trust-on-first-use),記錄已信任的指紋,之後比對;指紋不符要警告。
- **私鑰檔保護:** 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 載入腳本檔時要防路徑穿越 / 過大檔。
- **日誌不含密碼:** `AppLogger``logopen` 輸出不可寫入密碼 / passphrase;連線資訊只記主機 / port,不記 credential。
- **無 `secret/` 資料夾:** ETTerms 無伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager,因此不設 `secret/` 集中目錄,也不需要 publish 類腳本。若日後做 Release 程式碼簽章,簽章 `.pfx` 請放在 repo 外並以環境變數 / CI secret 傳入。
@@ -704,6 +747,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」前提
**驗收條件:** ✅ 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
@@ -712,6 +768,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 工具)
- ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁)
- ~~**AI Chat 氣泡版(WebView2**~~(✅ v0.7.0 已實作):AI 分頁訊息區改用 WebView2 渲染真氣泡(user 右 / AI 左)+ MarkdownMarkdig 轉 HTML:程式碼區塊、表格、清單)+ 送出後 thinking 動畫泡。HTML 模板 `Ai/ChatHtml.cs`(全內嵌 CSS/JS,`NavigateToString`,C# 經 `ExecuteScriptAsync` 呼叫 JS`addUser`/`addAI`/`addTool`/`showThinking`/`hideThinking`…;WebView2 未就緒前的呼叫先入佇列,`NavigationCompleted` 後 flush)。WebView2 使用者資料夾 `%LocalAppData%\ETTerms\WebView2`。底部控制列(輸入框 / 模型下拉 / Send)仍 WinForms。依賴 WebView2 RuntimeWin11 內建)。
- **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`
- **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估)
- **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm)
+10 -2
View File
@@ -8,7 +8,13 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**開發策略: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.7.1** 新增 TTL 指令 **`sendlnretry '文字' '確認關鍵字' [最多送出次數]`**`TTLInterpreter.SendLnRetry` + `WaitConfirm`)——送出後等確認關鍵字,沒等到就**重送**;次數省略 = 無限重送,命中 `result=1`、用完次數 `result=0` 且**繼續執行**(不中止腳本)。**動機**:裝置開機、console 剛被 shell 接手的瞬間會丟棄輸入(tty 重開 / termios flush),`sendln` 送出的整行無聲消失(裝置不回顯、不執行),後面的 `wait` 就永遠卡住;這是機率性的,`pause` 只能調機率、無法根治(實測 45 輪 power-cycle 中 3 輪中招,且**中招時間點與成功時完全重疊**)。**為何需要新指令而非用 `wait` 重試**:單字串 `wait` 逾時是 throw 中止腳本(刻意設計,見下方「注意事項」),TeraTerm 的 `wait``if result = 0 then goto retry` 重送寫法在 ETTerms 寫不出來。**語意**:每次送出前清空 `_recv`(確保比對到的是這次送出的回應);命中只消費到**第一次**出現處,後續輸出留給接下來的 `wait`;刻意**不套 `SettleAndConsume` 的 settle**(關鍵字出現本身就證明裝置收到了);每次等待逾時 `timeout`/`mtimeout` 有設就用設定值、**沒設預設 3 秒**(此處 0=無限等於永不重送,故不沿用 `wait` 的 0=無限)。⚠️ 確認關鍵字要挑「命令真的有跑」的輸出,**不要挑指令回顯**——tty echo 由核心產生,不保證命令被 shell 讀走。文件:[docs/ttl-script-reference.md](docs/ttl-script-reference.md#送出並確認sendlnretry)。
**v0.7.0** **AI Chat 改用 WebView2 渲染**(真氣泡 + Markdown + thinking 動畫泡)。訊息區從 RichTextBox 換成 `WebView2`user 右泡 / AI 左泡、AI 回覆走 **Markdig** markdown→HTML(程式碼區塊/表格/清單)、送出後顯示會動的 thinking「…」泡(`AgentHost.Status "thinking"``showThinking()`,收到 `AssistantText``hideThinking()` 再加 AI 泡)。HTML 模板全內嵌於 `Ai/ChatHtml.cs``NavigateToString`,無外部依賴),C# 經 `ExecuteScriptAsync` 呼叫 JS 函式;WebView2 async 初始化,就緒前的呼叫先入 `_pending` 佇列、`NavigationCompleted` 後 flush。使用者資料夾 `%LocalAppData%\ETTerms\WebView2`(避開 Program Files 唯讀)。底部控制列(輸入框/模型下拉/Send)仍 WinForms。新增 NuGet `Microsoft.Web.WebView2` + `Markdig`;依賴 WebView2 RuntimeWin11 內建,缺時 hint 顯示錯誤)。⚠️ **publish 要確認 WebView2 native`runtimes/win-x64/native/WebView2Loader.dll`)有進產物**。**工具呼叫上限可設定**`AppSettings.AiMaxToolRounds`,Settings → AI Assistant,`AgentHost` 建構子傳入):**0 = 無上限**(自動化長跑;每輪燒 token,聊天視窗 Send 鈕在執行中變 **Stop**,經 `CancellationToken` 中止),預設 30。
**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 共有)。
@@ -37,6 +43,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
- **連線儲存:** SQLite`Microsoft.Data.Sqlite`
- **密碼儲存:** Windows Credential Manager(不落地明碼)
- **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
- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json`
@@ -83,6 +90,7 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
## 注意事項 / 禁止事項
- 🚫 **密碼絕不寫進 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 的關鍵差異)。
- 🚫 不要把 `For_AI/` 內容 commit 進 git。
- ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。
@@ -95,7 +103,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.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()`
+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)
+29
View File
@@ -0,0 +1,29 @@
# v0.7.0 — The AI Assistant grows up: real chat bubbles, Markdown & a thinking indicator
## ✨ New features
**A proper chat UI for the AI Assistant**
* The AI Chat pane now renders real chat bubbles — your messages on the right, the AI's on the left — with full **Markdown**: code blocks, tables, lists and inline `code` all display cleanly.
* When you send a prompt, an animated **"…" thinking bubble** appears while the AI works and disappears the moment the reply lands, so you always know it's running.
* Built on WebView2 (part of Windows 11). The model dropdown, `[AI]` serial tagging, and PDU confirmations all work exactly as before.
**Control how far the AI runs**
* New setting **Max tool calls / message** (Settings → AI Assistant): how many tool calls the assistant may chain before it stops — a runaway-loop guard. Default is 30.
* Set it to **0 for unlimited**, handy for long automation runs you leave going for hours.
* While the AI is working, the **Send button turns into Stop** so you can abort any run — bounded or unlimited — at any time.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **ETTerms_v0.7.0** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **ETTerms_v0.7.0_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `ETTerms v0.7.0.exe`. (The WebView2 Runtime ships with Windows 11; both builds include the native loader.)
## 🔗 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.6.0...v0.7.0](https://github.com/ETWen/ETTerms/compare/v0.6.0...v0.7.0)
+59
View File
@@ -0,0 +1,59 @@
# v0.7.1 — Scripts stop losing commands to a booting device
## ✨ New features
**`sendlnretry` — send a command, confirm it ran, resend if it didn't**
* New TTL command: `sendlnretry '<text>' '<confirm keyword>' [max attempts]`.
It sends the text, then **waits for proof the device actually ran it**, and **resends** if that
proof never arrives. Leave the attempt count out and it retries until it gets through.
* Drop it in wherever you send a command right after a boot — the fragile
`pause` + `sendln` + `wait` dance becomes one line:
```
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0' ; retry until the device really runs it
wait 'PASS'
```
* Cap the attempts and handle the failure yourself — on success `result` is **1**;
when the attempts run out `result` is **0** and **the script carries on** instead of dying:
```
sendlnretry 'tpm2' 'TPM 2p0' 3
if result = 0 then
dispstr 'tpm2 got no response after 3 tries'
endif
```
* Pick the confirm keyword from the command's **own output** (`TPM 2p0`), not its echo (`tpm2`) —
an echo is produced by the device's tty and doesn't prove the shell ever read the line.
* Each attempt waits **3 seconds** by default, or your `timeout` / `mtimeout` if you've set one.
(Unlike `wait`, `0` doesn't mean "wait forever" here — that would mean never retrying.)
## 🐛 Bug fixes
**Scripts no longer hang forever when a booting device swallows a command**
* Fixed: a device that is still booting can **silently discard console input** the moment its shell
takes over the tty. The line you sent is gone — the device never echoes it and never runs it —
so the `wait` that follows blocks forever and the test rig sits dead until someone notices.
* This is a race, not a delay: adding `pause` before `sendln` only lowers the odds of hitting the
window, it can never close it. In one overnight 45-cycle power-cycle run, **3 cycles hung this
way** — and the swallowed sends landed at exactly the same moment as the 42 that worked.
* Use the new `sendlnretry` above to make these sends reliable.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.7.1`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.7.1_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.7.1.exe`.
## 🔗 Links
* TTL reference (see **送出並確認 / sendlnretry**):
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* Architecture:
[ARCHITECTURE.md](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md)
**Full changelog:** [v0.7.0...v0.7.1](https://github.com/ETWen/ETTerms/compare/v0.7.0...v0.7.1)
+37 -1
View File
@@ -43,6 +43,42 @@
# 一、ETTerms 獨有指令
## 送出並確認(sendlnretry
| 指令 | 語法 | 說明 |
|------|------|------|
| `sendlnretry` | `sendlnretry '文字' '確認關鍵字' [最多送出次數]` | 送出 `文字` + `\r\n`,然後等 `確認關鍵字`;沒等到就**重送**。次數省略 = 一直重送到收到為止。命中 `result=1`;用完次數 `result=0` 且**繼續執行**(不中止腳本)。 |
**為什麼需要它**:裝置在開機、console 剛被 shell 接手的瞬間,可能把收到的輸入直接丟掉
tty 重開 / termios flush)。此時 `sendln` 送出的整行會**無聲無息地消失**——裝置不會回顯、
也不會執行,後面的 `wait` 就永遠等不到,腳本整個卡死。這是**機率性**的:
`sendln` 前面加 `pause` 只是降低撞上那個窗口的機率,**不可能根治**。
`sendlnretry` 的作法是送完就確認對方真的有反應,沒反應就再送一次。
```
; 開機後下 tpm2,沒跑到就自動重送(無限重送)
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0'
wait 'PASS'
; 最多送 3 次,還是不行就自己處置
sendlnretry 'tpm2' 'TPM 2p0' 3
if result = 0 then
dispstr 'tpm2 送了 3 次都沒反應'
endif
```
使用要點:
- **確認關鍵字要挑「命令真的有跑」才會出現的輸出**(例:`TPM 2p0`),
**不要挑指令回顯**(例:`tpm2`)——tty 的 echo 由核心產生,不保證命令有被 shell 讀走。
- **每次送出前會清空接收緩衝**,確保比對到的是「這次送出」的回應而不是殘留輸出。
命中後只消費到**第一次**出現處,後面的輸出留在緩衝裡給接下來的 `wait`
(所以上例的 `wait 'PASS'` 照常會等到)。
- 每次送出後等確認的逾時:`timeout`/`mtimeout` 有設就用設定值,**沒設預設 3 秒**
(注意這與 `wait` 不同——`wait` 的 0 是無限等,但無限等在這裡等於永不重送)。
- **指令最好是可重複執行的**:若逾時設得太短、而裝置其實只是回應慢,會造成同一個指令送兩次。
## Group 同步(多分頁協同)
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。
@@ -89,7 +125,7 @@ wait 'login:'
| 項目 | ETTerms 行為 |
|------|--------------|
| `wait`(**單字串**) | 命中後需裝置**安靜 300ms**(無新資料)才接受,並取「最後一次」出現——排除輸出中途的指令回顯(如 `SVOS> help`)造成腳本搶跑。**逾時會中止腳本**(TeraTerm 是 `result=0` 繼續)。 |
| `wait`(**單字串**) | 命中後需裝置**安靜 300ms**(無新資料)才接受,並取「最後一次」出現——排除輸出中途的指令回顯(如 `SVOS> help`)造成腳本搶跑。**逾時會中止腳本**(TeraTerm 是 `result=0` 繼續),因此 TeraTerm 常見的 `wait``if result = 0 then goto retry` 重送寫法在單字串 `wait` 上做不到;要「送出後確認、沒回應就重送」請用 [`sendlnretry`](#送出並確認sendlnretry)。 |
| `wait`**多字串** | TeraTerm 相容:任一命中即繼續,`result` = 第幾個字串(1 起算);逾時 `result=0` **繼續執行**、無 settle。 |
| `goto` / 跨區塊跳轉 | `goto` 跳出 `if`/`while` 區塊後,該區塊的迴圈控制即結束(同層繼續直行)。避免 goto 跳「進」區塊中間。 |
| `call` | 可以在迴圈 / if 內使用(行內執行,返回後迴圈續跑)。 |
+94
View File
@@ -0,0 +1,94 @@
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();
// 單次 SendAsync 的工具呼叫輪數上限(防失控迴圈的保險)。由 Settings 設定,
// 0 = 無上限(自動化長跑用;執行中可按 Stop 中止,取消透過 CancellationToken)。
private readonly int _maxRounds;
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, int maxRounds)
{
_client = client;
_tools = tools;
_maxRounds = maxRounds;
_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();
// _maxRounds <= 0 → 無上限(自動化長跑;靠 Stop / CancellationToken 中止)
for (int round = 0; _maxRounds <= 0 || round < _maxRounds; round++)
{
ct.ThrowIfCancellationRequested();
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($"(已達工具呼叫上限 {_maxRounds} 次,停止。可到 Settings → AI Assistant 調高或設 0 = 無上限,或分步再試。)");
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();
}
}
+96
View File
@@ -0,0 +1,96 @@
namespace ETTerms.Ai;
/// <summary>
/// 內建 AI Assistant 聊天視窗的 WebView2 HTML 模板(v0.7.0)。
/// 全內嵌(CSS + JS,無外部依賴,符合 NavigateToString 的離線/CSP 需求)。
///
/// C# 端透過 ExecuteScriptAsync 呼叫這裡的 JS 函式:
/// addUser(text) / addAI(html) / addTool(text) / addError(text) / addNote(text)
/// showThinking() / hideThinking()
/// AI 回覆的 markdown 由 C#Markdig)先轉成 HTML 再傳入 addAI。
/// </summary>
internal static class ChatHtml
{
public const string Page = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--bg: #1c1c20; --text: #dedee2; --dim: #9696a0;
--user-bg: #56408a; --ai-bg: #2b2b33; --border: #3a3a42;
--tool: #b6a878; --err: #eb7878; --accent: #8a63d2;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif; font-size: 14px; line-height: 1.5;
}
#chat { padding: 14px 14px 20px; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; }
.row.user { justify-content: flex-end; }
.row.ai { justify-content: flex-start; }
.bubble {
max-width: 78%; padding: 8px 12px; border-radius: 14px;
white-space: normal; word-wrap: break-word; overflow-wrap: anywhere;
}
.user .bubble { background: var(--user-bg); border-bottom-right-radius: 4px; }
.ai .bubble { background: var(--ai-bg); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.bubble p { margin: 0 0 8px; } .bubble p:last-child { margin-bottom: 0; }
.bubble pre {
background: #14141a; border: 1px solid var(--border); border-radius: 8px;
padding: 10px; overflow-x: auto; margin: 8px 0;
}
.bubble code { font-family: "Cascadia Mono", Consolas, monospace; font-size: 13px; }
.bubble :not(pre) > code { background: #14141a; padding: 1px 5px; border-radius: 4px; }
.bubble ul, .bubble ol { margin: 6px 0; padding-left: 22px; }
.bubble table { border-collapse: collapse; margin: 8px 0; }
.bubble th, .bubble td { border: 1px solid var(--border); padding: 4px 8px; }
.bubble a { color: #9db4ff; }
.note { color: var(--dim); font-size: 12.5px; text-align: center; padding: 2px 0; }
.tool { color: var(--tool); font-size: 12.5px; font-family: "Cascadia Mono", monospace; padding-left: 4px; }
.err { color: var(--err); font-size: 13px; padding-left: 4px; }
/* thinking 動畫泡 */
#thinking { display: none; }
#thinking.on { display: flex; }
.dots { display: inline-flex; gap: 4px; align-items: center; }
.dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--dim);
animation: blink 1.2s infinite both;
}
.dots span:nth-child(2) { animation-delay: .2s; }
.dots span:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%,80%,100% { opacity: .25; } 40% { opacity: 1; } }
</style>
</head>
<body>
<div id="chat">
<div class="row ai" id="thinking">
<div class="bubble"><span class="dots"><span></span><span></span><span></span></span></div>
</div>
</div>
<script>
var chat = document.getElementById('chat');
var thinking = document.getElementById('thinking');
function atBottom() { return window.innerHeight + window.scrollY >= document.body.scrollHeight - 40; }
function scroll() { window.scrollTo(0, document.body.scrollHeight); }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function bubbleRow(cls, innerHtml) {
var row = document.createElement('div'); row.className = 'row ' + cls;
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = innerHtml;
row.appendChild(b); chat.insertBefore(row, thinking); scroll();
}
function addUser(t) { bubbleRow('user', esc(t).replace(/\n/g, '<br>')); }
function addAI(html) { bubbleRow('ai', html); }
function addTool(t) { var d = document.createElement('div'); d.className = 'tool'; d.textContent = ' ' + t; chat.insertBefore(d, thinking); scroll(); }
function addError(t) { var d = document.createElement('div'); d.className = 'err'; d.textContent = ' ' + t; chat.insertBefore(d, thinking); scroll(); }
function addNote(t) { var d = document.createElement('div'); d.className = 'note'; d.textContent = t; chat.insertBefore(d, thinking); scroll(); }
function showThinking() { thinking.classList.add('on'); scroll(); }
function hideThinking() { thinking.classList.remove('on'); }
</script>
</body>
</html>
""";
}
+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();
}
+26
View File
@@ -183,6 +183,32 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog =
[
new("0.7.1", new DateOnly(2026, 7, 16), "Scripts no longer hang when a booting device swallows a command",
[
"Fixed: a device that is still booting can silently throw away what you send it, the moment its shell takes over the console — the line never echoes and never runs, so the wait after it sat there forever and your test rig was dead until someone noticed. Adding a pause before the send only made it rarer: in one overnight 45-cycle power-cycle run, 3 cycles still hung this way.",
"New scripting command: sendlnretry '<text>' '<confirm keyword>' [max attempts] it sends, waits for proof the device actually ran the command, and sends again if that proof never arrives. Leave the attempt count out and it keeps trying until it gets through.",
"Use it anywhere you currently send a command right after a boot: sendlnretry 'tpm2' 'TPM 2p0' followed by wait 'PASS'. Pick the confirm keyword from the command's own output, not its echo — an echo comes from the device's tty and doesn't prove the command was ever read.",
"If it runs out of attempts, result is 0 and the script carries on, so you can handle the failure yourself. Each attempt waits 3 seconds, or your timeout / mtimeout if you've set one.",
"See docs/ttl-script-reference.md for the full details.",
]),
new("0.7.0", new DateOnly(2026, 7, 5), "AI chat gets real bubbles, Markdown & a thinking indicator",
[
"The AI Chat pane now renders proper chat bubbles (your messages on the right, the AI's on the left) with full Markdown — code blocks, tables, lists and inline `code` all display nicely.",
"When you send a prompt, an animated \"…\" thinking bubble appears while the AI works and disappears the moment the reply arrives — so you always know it's running.",
"Under the hood this uses WebView2 (built into Windows 11); the model dropdown, [AI] serial tagging, and PDU confirmations all work exactly as before.",
"New setting: Max tool calls per message (Settings → AI Assistant). Set it to 0 for unlimited — handy for long automation runs you leave going — and press Stop in the chat to abort any run in progress.",
]),
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",
[
"Press Ctrl+F in any terminal to search everything you've scrolled past — all hits are highlighted, Enter jumps between them.",
+280
View File
@@ -0,0 +1,280 @@
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Windows.Forms;
using ETTerms.Ai;
using ETTerms.Connections;
using ETTerms.Infrastructure;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace ETTerms.App;
/// <summary>
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
///
/// v0.7.0:訊息區改用 <b>WebView2</b> 渲染真聊天氣泡(user 右 / AI 左)+ Markdown(程式碼區塊、
/// 表格、清單)+ 送出後的 thinking 動畫泡。底部控制列(輸入框 / 模型下拉 / Send)仍為 WinForms。
///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary>
public sealed class AiChatView : UserControl
{
private readonly WebView2 _web;
private readonly TextBox _input;
private readonly Button _send;
private readonly Label _hint;
private readonly ComboBox _modelBox;
private bool _suppressModelEvent;
private bool _ready;
private readonly Queue<string> _pending = new();
private AgentHost? _agent;
private OpenAiChatClient? _client;
private AiTools? _tools;
private CancellationTokenSource? _cts;
private static readonly MarkdownPipeline Md =
new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
public AiChatView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
_web = new WebView2 { Dock = DockStyle.Fill };
// ── 底部控制列:輸入框(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 && !_running) { 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();
AddNote($"— 模型切換為 {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 = "Send ⏎", 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(_web); // Fill
Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom
_ = InitWebAsync();
}
private async Task InitWebAsync()
{
try
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms", "WebView2");
Directory.CreateDirectory(dataDir);
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await _web.EnsureCoreWebView2Async(env);
_web.CoreWebView2.Settings.AreDevToolsEnabled = false;
_web.CoreWebView2.Settings.IsZoomControlEnabled = false;
_web.CoreWebView2.NavigationCompleted += (_, _) =>
{
if (_ready) return;
_ready = true;
while (_pending.Count > 0) Exec(_pending.Dequeue());
AddNote("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
AddNote("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」");
};
_web.NavigateToString(ChatHtml.Page);
}
catch (Exception ex)
{
AppLogger.LogError("WebView2 init failed", ex);
_hint.Text = "AI 聊天需要 WebView2 RuntimeWin11 內建)。初始化失敗:" + ex.Message;
_hint.Visible = true;
}
}
/// <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, s.AiMaxToolRounds);
_agent.AssistantText += t => Ui(() => { HideThinking(); AddAI(t); });
_agent.ToolActivity += t => Ui(() => AddTool(t));
_agent.Status += st => Ui(() => { if (st == "thinking") ShowThinking(); });
_ = 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 AddError("端點未回報任何模型 — 確認 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 bool _running;
private async void OnSend()
{
if (_agent == null) return;
if (_running) { _cts?.Cancel(); return; } // 執行中再按 = 中止(長跑用)
var text = _input.Text.Trim();
if (text.Length == 0) return;
_input.Clear();
AddUser(text);
ShowThinking();
SetRunning(true);
_cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AddNote("(已停止)"); }
catch (Exception ex) { AddError(ex.Message); }
finally { HideThinking(); SetRunning(false); }
}
private void SetRunning(bool running)
{
_running = running;
_send.Text = running ? "■ Stop" : "Send ⏎";
_send.FlatAppearance.BorderColor = running ? Color.FromArgb(210, 120, 120) : Theme.Accent;
}
// ── WebView2 interop(呼叫 ChatHtml 裡的 JS 函式)──
private void AddUser(string t) => Exec($"addUser({Js(t)})");
private void AddAI(string markdown) => Exec($"addAI({Js(Markdown.ToHtml(markdown, Md))})");
private void AddTool(string t) => Exec($"addTool({Js(t)})");
private void AddError(string t) => Exec($"addError({Js(t)})");
private void AddNote(string t) => Exec($"addNote({Js(t)})");
private void ShowThinking() => Exec("showThinking()");
private void HideThinking() => Exec("hideThinking()");
private static string Js(string s) => JsonSerializer.Serialize(s);
private void Exec(string js)
{
if (!_ready) { _pending.Enqueue(js); return; }
try { _ = _web.CoreWebView2.ExecuteScriptAsync(js); } catch { }
}
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();
_web?.Dispose();
}
base.Dispose(disposing);
}
}
+105 -1
View File
@@ -1,10 +1,11 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Connections;
using ETTerms.Infrastructure;
namespace ETTerms.App;
/// <summary>Settings page with tabs: Terminal / AI MCP.</summary>
/// <summary>Settings page with tabs: Terminal / Highlight / AI Assistant / AI MCP.</summary>
public sealed class SettingsView : UserControl
{
public SettingsView()
@@ -53,6 +54,7 @@ public sealed class SettingsView : UserControl
var termBtn = MakeTab("Terminal", BuildTerminalTab());
tabBar.Controls.Add(termBtn);
tabBar.Controls.Add(MakeTab("Highlight", BuildHighlightTab()));
tabBar.Controls.Add(MakeTab("AI Assistant", BuildAiAssistantTab()));
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body);
@@ -290,6 +292,108 @@ public sealed class SettingsView : UserControl
return page;
}
// ═══ AI Assistant Tab(內建 agent 的 BYO endpoint 設定)═══
private Panel BuildAiAssistantTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var s = AppSettings.Instance;
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
flow.Controls.Add(new Label
{
Text = "Built-in AI Assistant", AutoSize = true,
ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
});
flow.Controls.Add(new Label
{
Text = "Bring your own OpenAI-compatible endpoint (Ollama / LiteLLM / a company gateway / OpenAI…).\n" +
"Leave blank to keep the AI Assistant disabled. 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"
};
var maxRounds = new NumericUpDown
{
Width = 100, Minimum = 0, Maximum = 100000, Increment = 10, Value = s.AiMaxToolRounds,
BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle
};
flow.Controls.Add(MakeRow("Base URL (with /v1)", baseUrl));
flow.Controls.Add(MakeRow("API Key", apiKey));
flow.Controls.Add(MakeRow("Max tool calls / message", maxRounds));
flow.Controls.Add(new Label
{
Text = "How many tool calls the assistant may chain per message before it stops (a runaway-loop guard).\n" +
"0 = unlimited — for long automation runs. Every round costs tokens; press Stop in the chat to abort.",
AutoSize = false, Width = 620, Height = 34,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6)
});
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.AiMaxToolRounds = (int)maxRounds.Value;
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 ═══
private Panel BuildAiMcpTab()
{
+64 -32
View File
@@ -17,7 +17,11 @@ public sealed class WorkspaceView : UserControl
{
public required string Title;
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 CloseRect;
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
@@ -61,6 +65,17 @@ public sealed class WorkspaceView : UserControl
Dock = DockStyle.Fill, BackColor = Theme.RailBack,
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
{
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
@@ -126,6 +141,17 @@ public sealed class WorkspaceView : UserControl
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)
{
if (IsDisposed) return;
@@ -150,17 +176,18 @@ public sealed class WorkspaceView : UserControl
private void CloseSession(Session s)
{
int idx = _sessions.IndexOf(s);
s.Page.Parent = null;
s.Content.Parent = null;
_sessions.Remove(s);
s.Page.Dispose();
s.Content.Dispose();
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
RefreshGroupLabels();
Relayout();
}
// ── Group 管理 ───────────────────────────────────────────
// ── Group 管理AI 分頁無 Group─────────────────────────
private void SetSessionGroup(Session s, int group)
{
if (s.Page == null) return;
s.Page.Group = group;
RefreshGroupLabels();
Relayout();
@@ -171,17 +198,17 @@ public sealed class WorkspaceView : UserControl
for (int g = 1; g <= 3; g++)
{
char letter = 'A';
foreach (var s in _sessions.Where(x => x.Page.Group == g))
s.Page.GroupLabel = $"Group{g}-{letter++}";
foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == g))
s.Page!.GroupLabel = $"Group{g}-{letter++}";
}
foreach (var s in _sessions.Where(x => x.Page.Group == 0))
s.Page.GroupLabel = "";
foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == 0))
s.Page!.GroupLabel = "";
}
// ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
private void Relayout()
{
foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活)
foreach (var s in _sessions) s.Content.Parent = null; // 先卸下(保留存活)
_body.SuspendLayout();
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)
{
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
s.Page.Dock = DockStyle.Fill;
s.Page.Visible = true;
cell.Controls.Add(s.Page); // Fill 先加
s.Content.Dock = DockStyle.Fill;
s.Content.Visible = true;
cell.Controls.Add(s.Content); // Fill 先加
if (withLabel)
{
string labelText = string.IsNullOrEmpty(s.Page.GroupLabel)
? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}"
: $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]";
string icon = s.IsAi ? "✨" : s.IsSsh ? "🖧" : "🔌";
string glabel = s.Page?.GroupLabel ?? "";
string labelText = string.IsNullOrEmpty(glabel)
? $"{icon} {s.Title}"
: $"{icon} {s.Title} [{glabel}]";
var lbl = new Label
{
Dock = DockStyle.Bottom, Height = 22,
@@ -253,8 +282,8 @@ public sealed class WorkspaceView : UserControl
private void FocusActive()
{
if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated)
p.Focus();
if (_active?.Content is { IsDisposed: false } c && c.IsHandleCreated)
c.Focus();
}
// ── 頂部 Tab 列 ──────────────────────────────────────────
@@ -276,7 +305,7 @@ public sealed class WorkspaceView : UserControl
{
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;
}
@@ -303,7 +332,7 @@ public sealed class WorkspaceView : UserControl
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
// Check current
int current = s.Page.Group;
int current = s.Page?.Group ?? 0;
((ToolStripMenuItem)menu.Items[current]).Checked = true;
menu.Show(_tabStrip, pt);
}
@@ -368,8 +397,10 @@ public sealed class WorkspaceView : UserControl
if (drag)
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));
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除
using (var dot = new SolidBrush(s.Alert ? Color.FromArgb(235, 85, 85) : s.IsSsh ? Theme.SshColor : Theme.SerialColor))
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。
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);
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,
@@ -415,19 +446,20 @@ public sealed class WorkspaceView : UserControl
// ── Log All(一次開/關所有分頁側錄) ──────────────────────
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);
return;
}
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。
bool startAll = _sessions.Any(s => !s.Page.IsLogging);
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。AI 分頁無側錄)
bool startAll = loggable.Any(p => !p.IsLogging);
if (startAll)
{
int failed = 0;
foreach (var s in _sessions)
if (!s.Page.StartLog()) failed++;
foreach (var p in loggable)
if (!p.StartLog()) failed++;
SetLogAllActive(true);
if (failed > 0)
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
{
foreach (var s in _sessions) s.Page.StopLog();
foreach (var p in loggable) p.StopLog();
SetLogAllActive(false);
}
}
@@ -451,8 +483,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunAllSerial(object? sender, EventArgs e)
{
var serials = _sessions
.Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page)
.Where(s => s.Page != null && !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page!)
.ToList();
if (serials.Count == 0)
@@ -472,8 +504,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunGroup(int group)
{
var members = _sessions
.Where(s => s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page)
.Where(s => s.Page != null && s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page!)
.ToList();
if (members.Count == 0)
@@ -491,7 +523,7 @@ public sealed class WorkspaceView : UserControl
protected override void Dispose(bool 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);
}
+3 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.5.0</Version>
<Version>0.7.1</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
@@ -27,7 +27,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="1.3.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<PackageReference Include="SSH.NET" Version="2024.2.0" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
+13
View File
@@ -30,6 +30,19 @@ public sealed class AppSettings
public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd
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; } = "";
/// <summary>AI agent 單次訊息的工具呼叫輪數上限(防失控迴圈的保險)。
/// **0 = 無上限**(自動化長跑用;注意每輪都燒 token/費用,執行中可按 Stop 中止)。</summary>
public int AiMaxToolRounds { get; set; } = 30;
// ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
public bool KeywordHighlightEnabled { get; set; } = true;
public List<KeywordRule> KeywordRules { get; set; } = new();
+88
View File
@@ -33,6 +33,7 @@ public sealed class TTLInterpreter : IDisposable
private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯
private const int MaxRecvChars = 1_000_000; // _recv 上限:長時間無 wait 消費時避免無限成長
private const int SendRetryTimeoutMs = 3000; // sendlnretry 每次送出後等確認關鍵字的預設逾時(timeout/mtimeout 有設就以設定值為準)
private GroupSyncContext? _groupSync;
private string _groupMemberLabel = "";
@@ -193,6 +194,7 @@ public sealed class TTLInterpreter : IDisposable
// ── 通訊 ──
case "send": Send(args, false); break;
case "sendln": Send(args, true); break;
case "sendlnretry": SendLnRetry(TokenizeArgs(args)); break;
case "wait": DispatchWait(args); break;
case "waitln": WaitLn(TokenizeArgs(args)); break;
case "waitregex": WaitRegex(TokenizeArgs(args)); break;
@@ -740,6 +742,92 @@ public sealed class TTLInterpreter : IDisposable
Output?.Invoke($">> {text}");
}
/// <summary>
/// <c>sendlnretry '文字' '確認關鍵字' [最多送出次數]</c>
/// —— 送出後確認裝置真的收到;沒等到確認關鍵字就重送。次數省略 = 一直重送到收到為止。
/// 命中 <c>result = 1</c>;用完次數 <c>result = 0</c> 且**繼續執行**(不中止腳本,讓腳本自行處置)。
/// <para>存在理由:裝置在開機/console 剛接手的瞬間可能丟棄輸入(tty 重開 / flush),
/// 此時 <c>sendln</c> 送出的整行會被吃掉、後面的 <c>wait</c> 便永遠等不到,
/// 而任何固定長度的 <c>pause</c> 都只是在調機率、無法根治。</para>
/// <para>每次送出前會清空接收緩衝,確保比對到的是「這次送出」的回應而非殘留輸出;
/// 命中後只消費到**第一次**出現處為止,後續輸出留在緩衝裡給接下來的 wait 用。</para>
/// <para>確認關鍵字請挑「命令真的有跑」才會出現的輸出,不要挑指令回顯——
/// tty 的 echo 不保證命令有被 shell 讀走。</para>
/// </summary>
private void SendLnRetry(List<string> tokens)
{
if (tokens.Count < 2)
{
Output?.Invoke("[ttl] sendlnretry: 用法 sendlnretry '文字' '確認關鍵字' [最多送出次數]");
return;
}
string text = ValS(tokens[0]);
string confirm = ValS(tokens[1]);
if (confirm.Length == 0)
{
Output?.Invoke("[ttl] sendlnretry: 確認關鍵字不可為空");
return;
}
int maxAttempts = tokens.Count > 2 ? ValI(tokens[2]) : 0; // 省略 / <= 0 = 無限
int perTryMs = EffectiveTimeoutMs > 0 ? EffectiveTimeoutMs : SendRetryTimeoutMs;
for (int attempt = 1; maxAttempts <= 0 || attempt <= maxAttempts; attempt++)
{
ThrowIfCancelled();
lock (_recv) _recv.Clear(); // 只認這次送出之後的回應
_channel.Write(_enc.GetBytes(text + "\r\n"));
_logWriter?.WriteLine($">> {text}");
Output?.Invoke(attempt == 1 ? $">> {text}" : $">> {text} (attempt {attempt})");
if (WaitConfirm(confirm, perTryMs))
{
_result = 1;
if (attempt > 1) _logWriter?.WriteLine($"[sendlnretry] '{text}' 於第 {attempt} 次送出後確認");
return;
}
Output?.Invoke($"[sendlnretry] {perTryMs}ms 內未見 '{confirm}' — 重送");
_logWriter?.WriteLine($"[sendlnretry] no '{confirm}' after attempt {attempt}: {text}");
}
_result = 0;
Output?.Invoke($"[sendlnretry] 送出 {maxAttempts} 次仍未確認 '{confirm}' — 放棄(result = 0");
_logWriter?.WriteLine($"[sendlnretry] gave up after {maxAttempts} attempt(s): {text}");
}
/// <summary>等確認關鍵字出現,逾時回 false(不丟例外、不中止腳本)。
/// 刻意不套 <see cref="SettleAndConsume"/> 的 settle:關鍵字「有出現」本身就證明裝置收到了,
/// 且只消費到第一次出現處,後面的輸出要留給後續的 wait。</summary>
private bool WaitConfirm(string text, int timeoutMs)
{
int elapsed = 0, lastLen = -1;
while (elapsed < timeoutMs)
{
ThrowIfCancelled();
lock (_recv)
{
if (_recv.Length != lastLen)
{
lastLen = _recv.Length;
string buf = _recv.ToString();
int idx = buf.IndexOf(text, StringComparison.Ordinal);
if (idx >= 0)
{
_recv.Clear();
_recv.Append(buf[(idx + text.Length)..]);
return true;
}
}
}
Thread.Sleep(50);
elapsed += 50;
}
return false;
}
private int EffectiveTimeoutMs => _timeout + _mtimeout; // 0 = 無限
private void DispatchWait(string args)