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>
This commit is contained in:
+4
-4
@@ -476,7 +476,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcp(stdio / JSON-RPC)
|
|||||||
|
|
||||||
### 內建 AI Assistant(Phase 10 — ✅ 已完成 v0.6.0)
|
### 內建 AI Assistant(Phase 10 — ✅ 已完成 v0.6.0)
|
||||||
|
|
||||||
> 讓 ETTerms **自己就是 agent host**——不經 Claude / Kiro,在 GUI 內建 AI 聊天檢視(Activity Rail 的 ✨ view),用自然語言驅動 serial 與 PDU(「接上 COM3,送 help 看回應」「連上 PDU,把 outlet 3 重開」一句話完成)。
|
> 讓 ETTerms **自己就是 agent host**——不經 Claude / Kiro,在 GUI 內建 AI 聊天**分頁**,用自然語言驅動 serial 與 PDU(「接上 COM3,送 help 看回應」「連上 PDU,把 outlet 3 重開」一句話完成)。**AI 分頁是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟),可用 Layout(1×2 / 2×2…)與 serial 分頁**並排同時使用**——像 Claude Code / Kiro 那樣一邊聊、一邊看終端機。
|
||||||
|
|
||||||
**設計原則:BYO Endpoint(使用者自帶 LLM 端點)**
|
**設計原則:BYO Endpoint(使用者自帶 LLM 端點)**
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcp(stdio / JSON-RPC)
|
|||||||
**架構(in-process function calling,不經 MCP)**
|
**架構(in-process function calling,不經 MCP)**
|
||||||
|
|
||||||
```
|
```
|
||||||
AiChatView(Activity Rail 的 ✨ view,全頁聊天)
|
AiChatView(Workspace 的一種 pane,與 SessionPage 並排;工具列 ✨ AI Chat 開啟)
|
||||||
└─ AgentHost(Ai/AgentHost.cs):維護對話歷史 + 手寫 agent loop(最多 8 輪工具)
|
└─ AgentHost(Ai/AgentHost.cs):維護對話歷史 + 手寫 agent loop(最多 8 輪工具)
|
||||||
├─ OpenAiChatClient(Ai/OpenAiChatClient.cs):極簡 OpenAI 相容 /chat/completions(非串流)→ 使用者設定的 Base URL
|
├─ OpenAiChatClient(Ai/OpenAiChatClient.cs):極簡 OpenAI 相容 /chat/completions(非串流)→ 使用者設定的 Base URL
|
||||||
└─ AiTools(Ai/AiTools.cs):in-process 直呼,不經子行程 / pipe
|
└─ AiTools(Ai/AiTools.cs):in-process 直呼,不經子行程 / pipe
|
||||||
@@ -748,11 +748,11 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
|
|||||||
**包含:**
|
**包含:**
|
||||||
- [x] `AppSettings` 加 `AiBaseUrl` / `AiModel` / `AiSystemPrompt`(預設空白);API key 存 `CredentialVault`(`ETTerms/AiApiKey`)
|
- [x] `AppSettings` 加 `AiBaseUrl` / `AiModel` / `AiSystemPrompt`(預設空白);API key 存 `CredentialVault`(`ETTerms/AiApiKey`)
|
||||||
- [x] `App/SettingsView.cs` 加 **AI Assistant** 分頁:Base URL / Model / API Key(密碼框)/ 系統提示詞,Save 寫 settings + Credential Manager;空白=停用
|
- [x] `App/SettingsView.cs` 加 **AI Assistant** 分頁:Base URL / Model / API Key(密碼框)/ 系統提示詞,Save 寫 settings + Credential Manager;空白=停用
|
||||||
- [x] `App/AiChatView.cs`:Activity Rail ✨ view,聊天 UI(角色標色訊息串 + 輸入框 + 工具過程顯示);`RefreshProvider()` 切入時依最新設定重建 client
|
- [x] `App/AiChatView.cs`:聊天 UI(角色標色訊息串 + 輸入框 + 工具過程顯示);`RefreshProvider()` 開分頁時依最新設定重建 client。**作為 Workspace pane**:`WorkspaceView` 工具列 `✨ AI Chat` → `OpenAiPane()`,`Session` 抽象化容納 `SessionPage` 或 `AiChatView`(`Content` 屬性),可與 serial 用 Layout 並排;AI 分頁無 Group / Log / Script(那些操作自動 skip)
|
||||||
- [x] `Ai/OpenAiChatClient.cs`:極簡 OpenAI 相容 chat-completions(HttpClient,非串流)
|
- [x] `Ai/OpenAiChatClient.cs`:極簡 OpenAI 相容 chat-completions(HttpClient,非串流)
|
||||||
- [x] `Ai/AgentHost.cs`:手寫 agent loop(tool_calls → 執行 → role=tool 餵回 → 迴圈,上限 8 輪)
|
- [x] `Ai/AgentHost.cs`:手寫 agent loop(tool_calls → 執行 → role=tool 餵回 → 迴圈,上限 8 輪)
|
||||||
- [x] `Ai/AiTools.cs`:serial(list/attach/write/read,經 `SerialBridge`,`[AI]` echo 沿用)+ PDU(connect/status/set_port/power_cycle,`ETTerms.PduCore`);破壞性動作經 `ConfirmAsync` 彈框;每筆呼叫寫 AppLogger
|
- [x] `Ai/AiTools.cs`:serial(list/attach/write/read,經 `SerialBridge`,`[AI]` echo 沿用)+ PDU(connect/status/set_port/power_cycle,`ETTerms.PduCore`);破壞性動作經 `ConfirmAsync` 彈框;每筆呼叫寫 AppLogger
|
||||||
- [x] `ActivityRail` 加 `Ai` view(✨);`MainForm` 掛載並在切入時 `RefreshProvider()`
|
- [x] AI 入口在 `WorkspaceView` 工具列(`✨ AI Chat` 按鈕),開成可並排的 pane(非獨立 rail view)——這樣才能與 serial 分頁同時使用
|
||||||
- [ ] (延伸,未做)MCP host:讓使用者掛自己的第三方 MCP server(schema concat 進 `AiTools.GetSchemas()`)
|
- [ ] (延伸,未做)MCP host:讓使用者掛自己的第三方 MCP server(schema concat 進 `AiTools.GetSchemas()`)
|
||||||
**驗收條件:** ✅ 建置 0 錯誤;未設定 Provider 時 AI 面板停用並提示;`src` grep 無任何私人端點 / key(範例一律 localhost / 假 IP)。實機端到端(接 OpenAI 相容端點跑 serial/PDU 一句話流程、PDU 確認框、`[AI]` 標色、AppLogger 紀錄)待使用者驗收。
|
**驗收條件:** ✅ 建置 0 錯誤;未設定 Provider 時 AI 面板停用並提示;`src` grep 無任何私人端點 / key(範例一律 localhost / 假 IP)。實機端到端(接 OpenAI 相容端點跑 serial/PDU 一句話流程、PDU 確認框、`[AI]` 標色、AppLogger 紀錄)待使用者驗收。
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
|
|||||||
|
|
||||||
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
|
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
|
||||||
|
|
||||||
**v0.6.0:** **內建 AI Assistant(Phase 10)** — 不經 Claude / Kiro,GUI 內建 ✨ AI 聊天檢視(Activity Rail),自然語言驅動 serial + PDU。**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 loop,tool_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/PduMcp(Settings → AI MCP)**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agent(in-process)vs 外部 AI(MCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`(3 檔)+ `App/AiChatView.cs`,`ActivityRail` 加 `Ai` view,`SettingsView` 加 AI Assistant 分頁,`AppSettings` 加 `AiBaseUrl/AiModel/AiSystemPrompt`。
|
**v0.6.0:** **內建 AI Assistant(Phase 10)** — 不經 Claude / Kiro,GUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage` 或 `AiChatView` 的 `Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**BYO endpoint**:`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**,API key 存 Windows Credential Manager(`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP)**:`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`,HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent loop,tool_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/PduMcp(Settings → AI MCP)**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agent(in-process)vs 外部 AI(MCP 跨行程)」的分工。新增 `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 共有)。
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,8 @@ public sealed class AboutView : UserControl
|
|||||||
[
|
[
|
||||||
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
|
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
|
||||||
[
|
[
|
||||||
"New ✨ AI Assistant view: chat in plain language to send serial commands and control PDU outlets — e.g. \"attach to COM3, send help and show me the reply\" or \"connect to the PDU and power-cycle outlet 3\".",
|
"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.",
|
||||||
|
"Talk in plain language to send serial commands and control PDU outlets — e.g. \"attach to COM3, send help and show me the reply\" or \"connect to the PDU and power-cycle outlet 3\".",
|
||||||
"Bring your own AI endpoint: point it at any OpenAI-compatible server (a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI). Set it up in Settings → AI Assistant; leave it blank and the assistant simply stays off.",
|
"Bring your own AI endpoint: point it at any OpenAI-compatible server (a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI). Set it up in Settings → AI Assistant; leave it blank and the assistant simply stays off.",
|
||||||
"Your API key is stored in Windows Credential Manager, never in a settings file — and no endpoint ships inside the app, so a copy you hand to someone else has the assistant disabled by default.",
|
"Your API key is stored in Windows Credential Manager, never in a settings file — and no endpoint ships inside the app, so a copy you hand to someone else has the assistant disabled by default.",
|
||||||
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ETTerms.App;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ActivityRail : UserControl
|
public sealed class ActivityRail : UserControl
|
||||||
{
|
{
|
||||||
public enum RailView { Terminal, Ai, Status, Settings, About }
|
public enum RailView { Terminal, Status, Settings, About }
|
||||||
|
|
||||||
public event EventHandler<RailView>? ViewSelected;
|
public event EventHandler<RailView>? ViewSelected;
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ public sealed class ActivityRail : UserControl
|
|||||||
private static readonly (RailView view, string glyph, string tip)[] Items =
|
private static readonly (RailView view, string glyph, string tip)[] Items =
|
||||||
{
|
{
|
||||||
(RailView.Terminal, "▤", "Terminal"),
|
(RailView.Terminal, "▤", "Terminal"),
|
||||||
(RailView.Ai, "✨", "AI Assistant"),
|
|
||||||
(RailView.Status, "⚡", "Status"),
|
(RailView.Status, "⚡", "Status"),
|
||||||
(RailView.Settings, "⚙", "Settings"),
|
(RailView.Settings, "⚙", "Settings"),
|
||||||
(RailView.About, "ℹ", "About"),
|
(RailView.About, "ℹ", "About"),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ public partial class MainForm : Form
|
|||||||
private readonly ActivityRail _rail = new();
|
private readonly ActivityRail _rail = new();
|
||||||
private readonly ConnectionSidebar _sidebar = new();
|
private readonly ConnectionSidebar _sidebar = new();
|
||||||
private readonly WorkspaceView _workspace = new();
|
private readonly WorkspaceView _workspace = new();
|
||||||
private readonly AiChatView _aiView = new();
|
|
||||||
private readonly StatusView _statusView = new();
|
private readonly StatusView _statusView = new();
|
||||||
private readonly SettingsView _settings = new();
|
private readonly SettingsView _settings = new();
|
||||||
private readonly AboutView _about = new();
|
private readonly AboutView _about = new();
|
||||||
@@ -47,14 +46,12 @@ public partial class MainForm : Form
|
|||||||
private void BuildLayout()
|
private void BuildLayout()
|
||||||
{
|
{
|
||||||
Controls.Add(_workspace); // Fill
|
Controls.Add(_workspace); // Fill
|
||||||
Controls.Add(_aiView); // Fill (hidden)
|
|
||||||
Controls.Add(_statusView); // Fill (hidden)
|
Controls.Add(_statusView); // Fill (hidden)
|
||||||
Controls.Add(_settings); // Fill (hidden)
|
Controls.Add(_settings); // Fill (hidden)
|
||||||
Controls.Add(_about); // Fill (hidden)
|
Controls.Add(_about); // Fill (hidden)
|
||||||
Controls.Add(_sidebar); // Left (內側)
|
Controls.Add(_sidebar); // Left (內側)
|
||||||
Controls.Add(_rail); // Left (最外側)
|
Controls.Add(_rail); // Left (最外側)
|
||||||
|
|
||||||
_aiView.Visible = false;
|
|
||||||
_statusView.Visible = false;
|
_statusView.Visible = false;
|
||||||
_settings.Visible = false;
|
_settings.Visible = false;
|
||||||
_about.Visible = false;
|
_about.Visible = false;
|
||||||
@@ -74,11 +71,9 @@ public partial class MainForm : Form
|
|||||||
_statusLabel.Text = $"View: {view}";
|
_statusLabel.Text = $"View: {view}";
|
||||||
_sidebar.Visible = view == ActivityRail.RailView.Terminal;
|
_sidebar.Visible = view == ActivityRail.RailView.Terminal;
|
||||||
_workspace.Visible = view == ActivityRail.RailView.Terminal;
|
_workspace.Visible = view == ActivityRail.RailView.Terminal;
|
||||||
_aiView.Visible = view == ActivityRail.RailView.Ai;
|
|
||||||
_statusView.Visible = view == ActivityRail.RailView.Status;
|
_statusView.Visible = view == ActivityRail.RailView.Status;
|
||||||
_settings.Visible = view == ActivityRail.RailView.Settings;
|
_settings.Visible = view == ActivityRail.RailView.Settings;
|
||||||
_about.Visible = view == ActivityRail.RailView.About;
|
_about.Visible = view == ActivityRail.RailView.About;
|
||||||
if (view == ActivityRail.RailView.Ai) _aiView.RefreshProvider();
|
|
||||||
AppLogger.LogInfo($"View selected: {view}");
|
AppLogger.LogInfo($"View selected: {view}");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ public sealed class WorkspaceView : UserControl
|
|||||||
{
|
{
|
||||||
public required string Title;
|
public required string Title;
|
||||||
public required bool IsSsh;
|
public required bool IsSsh;
|
||||||
public required SessionPage Page;
|
public SessionPage? Page; // 連線分頁(serial/ssh/shell);AI 分頁時為 null
|
||||||
|
public AiChatView? Ai; // AI 聊天分頁;連線分頁時為 null
|
||||||
|
public bool IsAi => Ai != null;
|
||||||
|
/// <summary>可塞進格子的內容控制項(SessionPage 或 AiChatView,二擇一)。</summary>
|
||||||
|
public Control Content => (Control?)Page ?? Ai!;
|
||||||
public Rectangle TabBounds;
|
public Rectangle TabBounds;
|
||||||
public Rectangle CloseRect;
|
public Rectangle CloseRect;
|
||||||
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
|
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
|
||||||
@@ -61,6 +65,17 @@ public sealed class WorkspaceView : UserControl
|
|||||||
Dock = DockStyle.Fill, BackColor = Theme.RailBack,
|
Dock = DockStyle.Fill, BackColor = Theme.RailBack,
|
||||||
Padding = new Padding(8, 6, 8, 6), WrapContents = false
|
Padding = new Padding(8, 6, 8, 6), WrapContents = false
|
||||||
};
|
};
|
||||||
|
// ── ✨ New AI Chat(開一個 AI 分頁,可用 Layout 與 serial 並排)──
|
||||||
|
var aiBtn = MakeActionButton("✨ AI Chat", 92, 0, (_, _) => OpenAiPane());
|
||||||
|
aiBtn.ForeColor = Theme.Accent;
|
||||||
|
aiBtn.FlatAppearance.BorderColor = Theme.Accent;
|
||||||
|
_toolbar.Controls.Add(aiBtn);
|
||||||
|
_toolbar.Controls.Add(new Label
|
||||||
|
{
|
||||||
|
Text = "│", AutoSize = true, ForeColor = Theme.Border,
|
||||||
|
Font = Theme.UiFont, Margin = new Padding(4, 6, 4, 0)
|
||||||
|
});
|
||||||
|
|
||||||
_toolbar.Controls.Add(new Label
|
_toolbar.Controls.Add(new Label
|
||||||
{
|
{
|
||||||
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
|
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
|
||||||
@@ -126,6 +141,17 @@ public sealed class WorkspaceView : UserControl
|
|||||||
Relayout();
|
Relayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>開啟一個 AI Assistant 分頁(跟連線分頁一樣可用 Layout 並排,與 serial 同時使用)。</summary>
|
||||||
|
public void OpenAiPane()
|
||||||
|
{
|
||||||
|
var view = new AiChatView();
|
||||||
|
var s = new Session { Title = "AI Assistant", IsSsh = false, Ai = view };
|
||||||
|
_sessions.Add(s);
|
||||||
|
_active = s;
|
||||||
|
Relayout();
|
||||||
|
view.RefreshProvider();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnConnectFailed(Session s, string msg)
|
private void OnConnectFailed(Session s, string msg)
|
||||||
{
|
{
|
||||||
if (IsDisposed) return;
|
if (IsDisposed) return;
|
||||||
@@ -150,17 +176,18 @@ public sealed class WorkspaceView : UserControl
|
|||||||
private void CloseSession(Session s)
|
private void CloseSession(Session s)
|
||||||
{
|
{
|
||||||
int idx = _sessions.IndexOf(s);
|
int idx = _sessions.IndexOf(s);
|
||||||
s.Page.Parent = null;
|
s.Content.Parent = null;
|
||||||
_sessions.Remove(s);
|
_sessions.Remove(s);
|
||||||
s.Page.Dispose();
|
s.Content.Dispose();
|
||||||
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
|
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
|
||||||
RefreshGroupLabels();
|
RefreshGroupLabels();
|
||||||
Relayout();
|
Relayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Group 管理 ───────────────────────────────────────────
|
// ── Group 管理(AI 分頁無 Group)─────────────────────────
|
||||||
private void SetSessionGroup(Session s, int group)
|
private void SetSessionGroup(Session s, int group)
|
||||||
{
|
{
|
||||||
|
if (s.Page == null) return;
|
||||||
s.Page.Group = group;
|
s.Page.Group = group;
|
||||||
RefreshGroupLabels();
|
RefreshGroupLabels();
|
||||||
Relayout();
|
Relayout();
|
||||||
@@ -171,17 +198,17 @@ public sealed class WorkspaceView : UserControl
|
|||||||
for (int g = 1; g <= 3; g++)
|
for (int g = 1; g <= 3; g++)
|
||||||
{
|
{
|
||||||
char letter = 'A';
|
char letter = 'A';
|
||||||
foreach (var s in _sessions.Where(x => x.Page.Group == g))
|
foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == g))
|
||||||
s.Page.GroupLabel = $"Group{g}-{letter++}";
|
s.Page!.GroupLabel = $"Group{g}-{letter++}";
|
||||||
}
|
}
|
||||||
foreach (var s in _sessions.Where(x => x.Page.Group == 0))
|
foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == 0))
|
||||||
s.Page.GroupLabel = "";
|
s.Page!.GroupLabel = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
|
// ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
|
||||||
private void Relayout()
|
private void Relayout()
|
||||||
{
|
{
|
||||||
foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活)
|
foreach (var s in _sessions) s.Content.Parent = null; // 先卸下(保留存活)
|
||||||
_body.SuspendLayout();
|
_body.SuspendLayout();
|
||||||
for (int i = _body.Controls.Count - 1; i >= 0; i--)
|
for (int i = _body.Controls.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
@@ -229,14 +256,16 @@ public sealed class WorkspaceView : UserControl
|
|||||||
private Control MakeCell(Session s, bool withLabel)
|
private Control MakeCell(Session s, bool withLabel)
|
||||||
{
|
{
|
||||||
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
|
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
|
||||||
s.Page.Dock = DockStyle.Fill;
|
s.Content.Dock = DockStyle.Fill;
|
||||||
s.Page.Visible = true;
|
s.Content.Visible = true;
|
||||||
cell.Controls.Add(s.Page); // Fill 先加
|
cell.Controls.Add(s.Content); // Fill 先加
|
||||||
if (withLabel)
|
if (withLabel)
|
||||||
{
|
{
|
||||||
string labelText = string.IsNullOrEmpty(s.Page.GroupLabel)
|
string icon = s.IsAi ? "✨" : s.IsSsh ? "🖧" : "🔌";
|
||||||
? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}"
|
string glabel = s.Page?.GroupLabel ?? "";
|
||||||
: $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]";
|
string labelText = string.IsNullOrEmpty(glabel)
|
||||||
|
? $"{icon} {s.Title}"
|
||||||
|
: $"{icon} {s.Title} [{glabel}]";
|
||||||
var lbl = new Label
|
var lbl = new Label
|
||||||
{
|
{
|
||||||
Dock = DockStyle.Bottom, Height = 22,
|
Dock = DockStyle.Bottom, Height = 22,
|
||||||
@@ -253,8 +282,8 @@ public sealed class WorkspaceView : UserControl
|
|||||||
|
|
||||||
private void FocusActive()
|
private void FocusActive()
|
||||||
{
|
{
|
||||||
if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated)
|
if (_active?.Content is { IsDisposed: false } c && c.IsHandleCreated)
|
||||||
p.Focus();
|
c.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 頂部 Tab 列 ──────────────────────────────────────────
|
// ── 頂部 Tab 列 ──────────────────────────────────────────
|
||||||
@@ -276,7 +305,7 @@ public sealed class WorkspaceView : UserControl
|
|||||||
{
|
{
|
||||||
foreach (var s in _sessions)
|
foreach (var s in _sessions)
|
||||||
{
|
{
|
||||||
if (s.TabBounds.Contains(e.Location)) { ShowGroupMenu(s, e.Location); return; }
|
if (s.TabBounds.Contains(e.Location)) { if (!s.IsAi) ShowGroupMenu(s, e.Location); return; }
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,7 +332,7 @@ public sealed class WorkspaceView : UserControl
|
|||||||
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
|
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
|
||||||
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
|
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
|
||||||
// Check current
|
// Check current
|
||||||
int current = s.Page.Group;
|
int current = s.Page?.Group ?? 0;
|
||||||
((ToolStripMenuItem)menu.Items[current]).Checked = true;
|
((ToolStripMenuItem)menu.Items[current]).Checked = true;
|
||||||
menu.Show(_tabStrip, pt);
|
menu.Show(_tabStrip, pt);
|
||||||
}
|
}
|
||||||
@@ -368,8 +397,10 @@ public sealed class WorkspaceView : UserControl
|
|||||||
if (drag)
|
if (drag)
|
||||||
using (var pen = new Pen(Theme.Accent, 1))
|
using (var pen = new Pen(Theme.Accent, 1))
|
||||||
g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1));
|
g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1));
|
||||||
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除
|
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。
|
||||||
using (var dot = new SolidBrush(s.Alert ? Color.FromArgb(235, 85, 85) : s.IsSsh ? Theme.SshColor : Theme.SerialColor))
|
Color dotColor = s.Alert ? Color.FromArgb(235, 85, 85)
|
||||||
|
: s.IsAi ? Theme.Accent : s.IsSsh ? Theme.SshColor : Theme.SerialColor;
|
||||||
|
using (var dot = new SolidBrush(dotColor))
|
||||||
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
|
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
|
||||||
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH);
|
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH);
|
||||||
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
|
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
|
||||||
@@ -415,19 +446,20 @@ public sealed class WorkspaceView : UserControl
|
|||||||
// ── Log All(一次開/關所有分頁側錄) ──────────────────────
|
// ── Log All(一次開/關所有分頁側錄) ──────────────────────
|
||||||
private void OnToggleLogAll(object? sender, EventArgs e)
|
private void OnToggleLogAll(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (_sessions.Count == 0)
|
var loggable = _sessions.Where(s => s.Page != null).Select(s => s.Page!).ToList();
|
||||||
|
if (loggable.Count == 0)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。
|
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。(AI 分頁無側錄)
|
||||||
bool startAll = _sessions.Any(s => !s.Page.IsLogging);
|
bool startAll = loggable.Any(p => !p.IsLogging);
|
||||||
if (startAll)
|
if (startAll)
|
||||||
{
|
{
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
foreach (var s in _sessions)
|
foreach (var p in loggable)
|
||||||
if (!s.Page.StartLog()) failed++;
|
if (!p.StartLog()) failed++;
|
||||||
SetLogAllActive(true);
|
SetLogAllActive(true);
|
||||||
if (failed > 0)
|
if (failed > 0)
|
||||||
MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.",
|
MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.",
|
||||||
@@ -435,7 +467,7 @@ public sealed class WorkspaceView : UserControl
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var s in _sessions) s.Page.StopLog();
|
foreach (var p in loggable) p.StopLog();
|
||||||
SetLogAllActive(false);
|
SetLogAllActive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,8 +483,8 @@ public sealed class WorkspaceView : UserControl
|
|||||||
private async void OnRunAllSerial(object? sender, EventArgs e)
|
private async void OnRunAllSerial(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var serials = _sessions
|
var serials = _sessions
|
||||||
.Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
|
.Where(s => s.Page != null && !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
|
||||||
.Select(s => s.Page)
|
.Select(s => s.Page!)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (serials.Count == 0)
|
if (serials.Count == 0)
|
||||||
@@ -472,8 +504,8 @@ public sealed class WorkspaceView : UserControl
|
|||||||
private async void OnRunGroup(int group)
|
private async void OnRunGroup(int group)
|
||||||
{
|
{
|
||||||
var members = _sessions
|
var members = _sessions
|
||||||
.Where(s => s.Page.Group == group && !s.Page.IsScriptRunning)
|
.Where(s => s.Page != null && s.Page.Group == group && !s.Page.IsScriptRunning)
|
||||||
.Select(s => s.Page)
|
.Select(s => s.Page!)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (members.Count == 0)
|
if (members.Count == 0)
|
||||||
@@ -491,7 +523,7 @@ public sealed class WorkspaceView : UserControl
|
|||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
foreach (var s in _sessions) { try { s.Page.Dispose(); } catch { } }
|
foreach (var s in _sessions) { try { s.Content.Dispose(); } catch { } }
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user