9 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
13 changed files with 444 additions and 72 deletions
+5 -1
View File
@@ -502,6 +502,10 @@ AiChatViewWorkspace 的一種 pane,與 SessionPage 並排;工具列 ✨ A
- 破壞性 PDU 動作(`pdu_set_port` off / `pdu_power_cycle`)一律 **C# 端彈確認框**`AiTools.ConfirmAsync` → GUI MessageBox,預設按鈕 No;不信 LLM 自律)。 - 破壞性 PDU 動作(`pdu_set_port` off / `pdu_power_cycle`)一律 **C# 端彈確認框**`AiTools.ConfirmAsync` → GUI MessageBox,預設按鈕 No;不信 LLM 自律)。
- 所有 AI 工具呼叫寫 **AppLogger** 留跡(`[AI tool] <name> <args>`)。 - 所有 AI 工具呼叫寫 **AppLogger** 留跡(`[AI tool] <name> <args>`)。
- Serial TX 沿 Phase 9 慣例以 `[AI]` 標色 echo`SerialBridgeEndpoint.Write`),使用者全程看得到 AI 打了什麼。 - 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 格式)。 **實作選型:** 手寫 `OpenAiChatClient`HttpClient + System.Text.Json,非串流)+ 手寫 agent loop,**不引入 `Microsoft.Extensions.AI`**——依賴最小、對任意 OpenAI 相容 gateway 相容性自己掌控、無額外 NuGet 演進風險。工具 schema 為手組 JSONOpenAI function-calling 格式)。
@@ -764,7 +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 工具) - ~~**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 上硬做氣泡 - ~~**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` - **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`
- **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估) - **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估)
- **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm) - **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm)
+4
View File
@@ -10,6 +10,10 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。 **進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
**v0.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.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 共有)。
+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 獨有指令 # 一、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 同步(多分頁協同) ## Group 同步(多分頁協同)
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。 以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。
@@ -89,7 +125,7 @@ wait 'login:'
| 項目 | ETTerms 行為 | | 項目 | 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。 | | `wait`**多字串** | TeraTerm 相容:任一命中即繼續,`result` = 第幾個字串(1 起算);逾時 `result=0` **繼續執行**、無 settle。 |
| `goto` / 跨區塊跳轉 | `goto` 跳出 `if`/`while` 區塊後,該區塊的迴圈控制即結束(同層繼續直行)。避免 goto 跳「進」區塊中間。 | | `goto` / 跨區塊跳轉 | `goto` 跳出 `if`/`while` 區塊後,該區塊的迴圈控制即結束(同層繼續直行)。避免 goto 跳「進」區塊中間。 |
| `call` | 可以在迴圈 / if 內使用(行內執行,返回後迴圈續跑)。 | | `call` | 可以在迴圈 / if 內使用(行內執行,返回後迴圈續跑)。 |
+9 -4
View File
@@ -14,7 +14,9 @@ public sealed class AgentHost
private readonly OpenAiChatClient _client; private readonly OpenAiChatClient _client;
private readonly AiTools _tools; private readonly AiTools _tools;
private readonly JsonArray _messages = new(); private readonly JsonArray _messages = new();
private const int MaxToolRounds = 8; // 單次 SendAsync 的工具呼叫輪數上限(防失控迴圈的保險)。由 Settings 設定,
// 0 = 無上限(自動化長跑用;執行中可按 Stop 中止,取消透過 CancellationToken)。
private readonly int _maxRounds;
public event Action<string>? AssistantText; // 最終文字回應 public event Action<string>? AssistantText; // 最終文字回應
public event Action<string>? ToolActivity; // 「呼叫 serial_write …」之類過程 public event Action<string>? ToolActivity; // 「呼叫 serial_write …」之類過程
@@ -26,10 +28,11 @@ public sealed class AgentHost
"破壞性動作(關插座 / power-cycle)會由使用者在 GUI 確認,你只需正常呼叫工具。" + "破壞性動作(關插座 / power-cycle)會由使用者在 GUI 確認,你只需正常呼叫工具。" +
"serial 操作前必須先 serial_attach 到 GUI 已開啟的 session。"; "serial 操作前必須先 serial_attach 到 GUI 已開啟的 session。";
public AgentHost(OpenAiChatClient client, AiTools tools, string? systemPrompt) public AgentHost(OpenAiChatClient client, AiTools tools, string? systemPrompt, int maxRounds)
{ {
_client = client; _client = client;
_tools = tools; _tools = tools;
_maxRounds = maxRounds;
_messages.Add(new JsonObject _messages.Add(new JsonObject
{ {
["role"] = "system", ["role"] = "system",
@@ -43,8 +46,10 @@ public sealed class AgentHost
_messages.Add(new JsonObject { ["role"] = "user", ["content"] = userText }); _messages.Add(new JsonObject { ["role"] = "user", ["content"] = userText });
var tools = _tools.GetSchemas(); var tools = _tools.GetSchemas();
for (int round = 0; round < MaxToolRounds; round++) // _maxRounds <= 0 → 無上限(自動化長跑;靠 Stop / CancellationToken 中止)
for (int round = 0; _maxRounds <= 0 || round < _maxRounds; round++)
{ {
ct.ThrowIfCancellationRequested();
Status?.Invoke("thinking"); Status?.Invoke("thinking");
var msg = await _client.CompleteAsync(_messages, tools, ct); var msg = await _client.CompleteAsync(_messages, tools, ct);
_messages.Add((JsonObject)msg.DeepClone()); _messages.Add((JsonObject)msg.DeepClone());
@@ -83,7 +88,7 @@ public sealed class AgentHost
} }
} }
AssistantText?.Invoke("(已達工具呼叫上限,停止。請縮小問題或分步再試。)"); AssistantText?.Invoke($"(已達工具呼叫上限 {_maxRounds} 次,停止。可到 Settings → AI Assistant 調高或設 0 = 無上限,或分步再試。)");
Status?.Invoke("done"); Status?.Invoke("done");
} }
} }
+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>
""";
}
+15
View File
@@ -183,6 +183,21 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = 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("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
[ [
"New ✨ AI Chat: click ✨ AI Chat in the toolbar to open an AI pane, then use Layout (1×2, 2×2…) to sit it right next to a Serial session — chat on one side while you watch the terminal on the other, just like Claude Code / Kiro.", "New ✨ AI Chat: click ✨ AI Chat in the toolbar to open an AI pane, then use Layout (1×2, 2×2…) to sit it right next to a Serial session — chat on one side while you watch the terminal on the other, just like Claude Code / Kiro.",
+81 -65
View File
@@ -1,47 +1,51 @@
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Ai; using ETTerms.Ai;
using ETTerms.Connections; using ETTerms.Connections;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace ETTerms.App; namespace ETTerms.App;
/// <summary> /// <summary>
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。 /// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
/// ///
/// 呈現走「乾淨對話逐字稿」風格(與 ETTerms 終端機/log 美學一致,非氣泡卡片): /// v0.7.0:訊息區改用 <b>WebView2</b> 渲染真聊天氣泡(user 右 / AI 左)+ Markdown(程式碼區塊、
/// 使用者訊息靠右 accent 色、AI 回覆靠左段落、工具活動灰字縮排、思考中收進 Send 按鈕不洗版 /// 表格、清單)+ 送出後的 thinking 動畫泡。底部控制列(輸入框 / 模型下拉 / Send)仍為 WinForms
/// (真氣泡 + Markdown 的 WebView2 版列為 v0.7.0。)
/// ///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。 /// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary> /// </summary>
public sealed class AiChatView : UserControl public sealed class AiChatView : UserControl
{ {
private readonly RichTextBox _log; private readonly WebView2 _web;
private readonly TextBox _input; private readonly TextBox _input;
private readonly Button _send; private readonly Button _send;
private readonly Label _hint; private readonly Label _hint;
private readonly ComboBox _modelBox; private readonly ComboBox _modelBox;
private bool _suppressModelEvent; private bool _suppressModelEvent;
private bool _ready;
private readonly Queue<string> _pending = new();
private AgentHost? _agent; private AgentHost? _agent;
private OpenAiChatClient? _client; private OpenAiChatClient? _client;
private AiTools? _tools; private AiTools? _tools;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private const string SendText = "Send ⏎"; private static readonly MarkdownPipeline Md =
new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
public AiChatView() public AiChatView()
{ {
Dock = DockStyle.Fill; Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack; BackColor = Theme.WorkspaceBack;
_log = new RichTextBox _web = new WebView2 { Dock = DockStyle.Fill };
{
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 在下) ── // ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) }; var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) };
@@ -53,7 +57,7 @@ public sealed class AiChatView : UserControl
}; };
_input.KeyDown += (_, e) => _input.KeyDown += (_, e) =>
{ {
if (e.KeyCode == Keys.Enter && !e.Shift) { e.Handled = e.SuppressKeyPress = true; OnSend(); } 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 rightCol = new Panel { Dock = DockStyle.Right, Width = 178, BackColor = Theme.RailBack, Padding = new Padding(8, 0, 0, 0) };
@@ -70,7 +74,7 @@ public sealed class AiChatView : UserControl
_client.Model = m; _client.Model = m;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個 AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save(); AppSettings.Instance.Save();
AppendDim($"— 模型切換為 {m} —"); AddNote($"— 模型切換為 {m} —");
}; };
var refreshBtn = new Button var refreshBtn = new Button
{ {
@@ -86,7 +90,7 @@ public sealed class AiChatView : UserControl
_send = new Button _send = new Button
{ {
Text = SendText, Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat, Text = "Send ⏎", Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
_send.FlatAppearance.BorderColor = Theme.Accent; _send.FlatAppearance.BorderColor = Theme.Accent;
@@ -106,12 +110,40 @@ public sealed class AiChatView : UserControl
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。" Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。"
}; };
Controls.Add(_log); // Fill Controls.Add(_web); // Fill
Controls.Add(_hint); // Top Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom Controls.Add(bottom); // Bottom
AppendSystem("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。"); _ = InitWebAsync();
AppendSystem("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」"); }
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> /// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary>
@@ -131,10 +163,10 @@ public sealed class AiChatView : UserControl
var key = CredentialVault.Get("ETTerms/AiApiKey") ?? ""; var key = CredentialVault.Get("ETTerms/AiApiKey") ?? "";
_client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel); _client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel);
_tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync }; _tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync };
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt); _agent = new AgentHost(_client, _tools, s.AiSystemPrompt, s.AiMaxToolRounds);
_agent.AssistantText += t => Ui(() => AppendAssistant(t)); _agent.AssistantText += t => Ui(() => { HideThinking(); AddAI(t); });
_agent.ToolActivity += t => Ui(() => AppendTool(t)); _agent.ToolActivity += t => Ui(() => AddTool(t));
// thinking 不印進對話流(改由 Send 按鈕顯示忙碌),避免洗版。 _agent.Status += st => Ui(() => { if (st == "thinking") ShowThinking(); });
_ = LoadModelsAsync(); _ = LoadModelsAsync();
} }
@@ -167,7 +199,7 @@ public sealed class AiChatView : UserControl
AppSettings.Instance.Save(); AppSettings.Instance.Save();
} }
} }
else AppendError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。"); else AddError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。");
_suppressModelEvent = false; _suppressModelEvent = false;
}); });
} }
@@ -184,65 +216,48 @@ public sealed class AiChatView : UserControl
return tcs.Task; return tcs.Task;
} }
private bool _running;
private async void OnSend() private async void OnSend()
{ {
if (_agent == null) return; if (_agent == null) return;
if (_running) { _cts?.Cancel(); return; } // 執行中再按 = 中止(長跑用)
var text = _input.Text.Trim(); var text = _input.Text.Trim();
if (text.Length == 0) return; if (text.Length == 0) return;
_input.Clear(); _input.Clear();
AppendUser(text); AddUser(text);
SetBusy(true); ShowThinking();
SetRunning(true);
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); } try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AppendDim("(已取消"); } catch (OperationCanceledException) { AddNote("(已停止"); }
catch (Exception ex) { AppendError(ex.Message); } catch (Exception ex) { AddError(ex.Message); }
finally { SetBusy(false); } finally { HideThinking(); SetRunning(false); }
} }
private void SetBusy(bool busy) private void SetRunning(bool running)
{ {
_send.Enabled = !busy; _running = running;
_send.Text = busy ? "" : SendText; _send.Text = running ? "■ Stop" : "Send ⏎";
_send.FlatAppearance.BorderColor = running ? Color.FromArgb(210, 120, 120) : Theme.Accent;
} }
// ── 輸出(乾淨 transcript)── // ── WebView2 interop(呼叫 ChatHtml 裡的 JS 函式)──
/// <summary>使用者訊息:靠右 accent 色,無前綴。</summary> private void AddUser(string t) => Exec($"addUser({Js(t)})");
private void AppendUser(string t) private void AddAI(string markdown) => Exec($"addAI({Js(Markdown.ToHtml(markdown, Md))})");
{ private void AddTool(string t) => Exec($"addTool({Js(t)})");
_log.SelectionStart = _log.TextLength; private void AddError(string t) => Exec($"addError({Js(t)})");
_log.SelectionAlignment = HorizontalAlignment.Right; private void AddNote(string t) => Exec($"addNote({Js(t)})");
_log.SelectionColor = Theme.Accent; private void ShowThinking() => Exec("showThinking()");
_log.SelectionFont = new Font(_log.Font, FontStyle.Bold); private void HideThinking() => Exec("hideThinking()");
_log.AppendText(t + "\n");
_log.SelectionAlignment = HorizontalAlignment.Left;
_log.AppendText("\n");
_log.ScrollToCaret();
}
/// <summary>AI 回覆:靠左段落。</summary> private static string Js(string s) => JsonSerializer.Serialize(s);
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 Exec(string js)
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; if (!_ready) { _pending.Enqueue(js); return; }
_log.SelectionAlignment = HorizontalAlignment.Left; try { _ = _web.CoreWebView2.ExecuteScriptAsync(js); } catch { }
_log.SelectionColor = color;
_log.SelectionFont = _log.Font;
_log.AppendText(t + "\n");
_log.ScrollToCaret();
} }
private void Ui(Action a) private void Ui(Action a)
@@ -258,6 +273,7 @@ public sealed class AiChatView : UserControl
_cts?.Cancel(); _cts?.Cancel();
_client?.Dispose(); _client?.Dispose();
_tools?.Dispose(); _tools?.Dispose();
_web?.Dispose();
} }
base.Dispose(disposing); base.Dispose(disposing);
} }
+15
View File
@@ -339,8 +339,22 @@ public sealed class SettingsView : UserControl
PlaceholderText = "(optional) override the assistant persona / system prompt" 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("Base URL (with /v1)", baseUrl));
flow.Controls.Add(MakeRow("API Key", apiKey)); 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(MakeSpacer(4));
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
@@ -363,6 +377,7 @@ public sealed class SettingsView : UserControl
{ {
s.AiBaseUrl = baseUrl.Text.Trim(); s.AiBaseUrl = baseUrl.Text.Trim();
s.AiSystemPrompt = sysPrompt.Text.Trim(); s.AiSystemPrompt = sysPrompt.Text.Trim();
s.AiMaxToolRounds = (int)maxRounds.Value;
s.Save(); s.Save();
var key = apiKey.Text; var key = apiKey.Text;
if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey"); if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey");
+3 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.6.0</Version> <Version>0.7.1</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
@@ -27,7 +27,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="1.3.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" /> <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="SSH.NET" Version="2024.2.0" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" /> <PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup> </ItemGroup>
@@ -39,6 +39,9 @@ public sealed class AppSettings
public string AiModel { get; set; } = ""; public string AiModel { get; set; } = "";
/// <summary>系統提示詞(人設);空則用內建預設。</summary> /// <summary>系統提示詞(人設);空則用內建預設。</summary>
public string AiSystemPrompt { get; set; } = ""; public string AiSystemPrompt { get; set; } = "";
/// <summary>AI agent 單次訊息的工具呼叫輪數上限(防失控迴圈的保險)。
/// **0 = 無上限**(自動化長跑用;注意每輪都燒 token/費用,執行中可按 Stop 中止)。</summary>
public int AiMaxToolRounds { get; set; } = 30;
// ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)── // ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
public bool KeywordHighlightEnabled { get; set; } = true; public bool KeywordHighlightEnabled { get; set; } = true;
+88
View File
@@ -33,6 +33,7 @@ public sealed class TTLInterpreter : IDisposable
private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯 private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯
private const int MaxRecvChars = 1_000_000; // _recv 上限:長時間無 wait 消費時避免無限成長 private const int MaxRecvChars = 1_000_000; // _recv 上限:長時間無 wait 消費時避免無限成長
private const int SendRetryTimeoutMs = 3000; // sendlnretry 每次送出後等確認關鍵字的預設逾時(timeout/mtimeout 有設就以設定值為準)
private GroupSyncContext? _groupSync; private GroupSyncContext? _groupSync;
private string _groupMemberLabel = ""; private string _groupMemberLabel = "";
@@ -193,6 +194,7 @@ public sealed class TTLInterpreter : IDisposable
// ── 通訊 ── // ── 通訊 ──
case "send": Send(args, false); break; case "send": Send(args, false); break;
case "sendln": Send(args, true); break; case "sendln": Send(args, true); break;
case "sendlnretry": SendLnRetry(TokenizeArgs(args)); break;
case "wait": DispatchWait(args); break; case "wait": DispatchWait(args); break;
case "waitln": WaitLn(TokenizeArgs(args)); break; case "waitln": WaitLn(TokenizeArgs(args)); break;
case "waitregex": WaitRegex(TokenizeArgs(args)); break; case "waitregex": WaitRegex(TokenizeArgs(args)); break;
@@ -740,6 +742,92 @@ public sealed class TTLInterpreter : IDisposable
Output?.Invoke($">> {text}"); 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 int EffectiveTimeoutMs => _timeout + _mtimeout; // 0 = 無限
private void DispatchWait(string args) private void DispatchWait(string args)