diff --git a/docs/Mcp_Chat_Design.md b/docs/Mcp_Chat_Design.md new file mode 100644 index 0000000..17fb42f --- /dev/null +++ b/docs/Mcp_Chat_Design.md @@ -0,0 +1,245 @@ +# MCP 远程交谈(P9):AI ↔ 客户端使用者双向对话 + +## 1. 目标与边界 + +让接入服务端 MCP 的大模型与坐在 Windows 客户端电脑前的人进行多轮文字交谈。 + +- **仅 Windows 客户端**:`LNX` / `MAC` / `APK` 直接返回 `-32005`,不发命令。 +- **复用既有功能,协议零改动**:沿用「远程交谈」(`client/ChatManager.cpp` + `IDD_CHAT`), + 报文与握手不变。客户端侧只有两处改动:聊天窗口改为 Unicode 对话框(见 §5.8, + 解决非 936 ACP 系统上的中文乱码),以及加壳部署下对话框改用内嵌模板兜底(见 §5.7)。 +- **独立开关 `McpChat`**(默认 `0`):与 `McpReadonly` 无关;关闭时四个工具不出现在 + `tools/list`,直接调用返回 `-32006`。 +- **直接弹出客户端窗口**:没有确认框;会话建立即代表开始交谈。 +- 验证方式:在虚拟机里运行交互式 Windows 客户端,由人扮演使用者与服务端 AI 对话。 + +## 2. 复用协议 + +| 方向 | 报文 | +|---|---| +| 服务端主连接 → 客户端 | `[COMMAND_CHAT=161]`(1 字节) | +| 客户端新建子连接 | 先 `[TOKEN_CONN_AUTH=246]`(`EnableSubConnAuth`),再 `[TOKEN_CHAT_START=62]` | +| 服务端子连接 → 客户端 | `[COMMAND_NEXT_CHAT=0]`(1 字节,通知客户端弹窗) | +| 双向文本 | `[CP936/GBK 字节][0x00]`,无 token 前缀 | +| 服务端子连接 → 客户端 | `[COMMAND_CHAT_CLOSE=1]` 结束 | + +时序: + +``` +chat_open: + FindMainContext(id) → 校验 Windows → 校验 McpChat + mainCtx->Send2Client([161]) + 客户端建子连接 → [246] 认证 → [62] 就绪 + OnChatSubPacket 绑定 62 → 回 [0] → readyAt = now + 1000ms + chat_open 等到 started && now >= readyAt 才返回 +chat_send: + subCtx->Send2Client(GBK + NUL) → WaitSendIdle(2000) +客户端使用者输入: + subCtx → [GBK + NUL] → OnChatSubPacket → inbox → 唤醒 chat_wait +chat_close: + subCtx->Send2Client([1]) → WaitSendIdle(1000) → CancelIO +``` + +未绑定子连接上的 `TOKEN_CONN_AUTH(246)` 不被聊天钩子消费,仍由 `MessageHandle` +的鉴权分支处理并回 `ConnAuthAck`,认证后子连接的 `clientID` 才可靠(新客户端路径)。 +已绑定子连接上首字节为 `0xF6` 的 GBK 文本不会被误判为鉴权包(绑定连接上所有包都由 +聊天钩子消费)。 + +## 3. 工具 + +| 工具 | 输入 | 输出 | +|---|---|---| +| `chat_open` | `id`;`timeout_ms`(默认 15000,2000–60000) | `{session_id, status:"open", message}` | +| `chat_send` | `id`, `session_id`, `message` | `{session_id, sent:true, bytes}` | +| `chat_wait` | `id`, `session_id`;`timeout_ms`(默认 30000,0–55000)、`max_messages`(默认 20,1–50) | `{session_id, messages:[{id,at,text}], timed_out, closed, dropped}` | +| `chat_close` | `id`, `session_id` | `{session_id, closed:true}`;主机在线时会话已不存在按幂等处理为成功;主机离线返回 `-32002` | + +典型对话循环: + +``` +chat_open(id) → session_id +chat_send(id, sid, "你好,我是助手") +chat_wait(id, sid, 30000) → 对方的回复 +chat_send(id, sid, "……") +chat_wait(id, sid, 30000) +chat_close(id, sid) +``` + +错误码沿用 MCP 约定: + +| 码 | 含义 | +|---|---| +| `-32001` | `chat_open` 等不到客户端建窗(离线、老客户端、非交互桌面) | +| `-32002` | 主机离线 / 会话不存在或已关闭 / `session_id` 不匹配 | +| `-32003` | 同设备已有活跃会话;或同一会话已有 `chat_send` / `chat_wait` 在飞 | +| `-32004` | 发送失败(`Send2Client` 返回 FALSE) | +| `-32005` | 非 Windows 客户端 | +| `-32006` | `McpChat` 关闭 | +| `-32602` | 参数、UTF-8、GBK 可表示性或长度错误 | + +## 4. 编码与长度 + +- **出站**:UTF-8 → CP936 **严格转换**(`MB_ERR_INVALID_CHARS` + + `WC_NO_BEST_FIT_CHARS` + `lpUsedDefaultChar`)。emoji、`ö` 等 GBK 不可表示字符 + 直接返回 `-32602`,绝不静默变成 `?`。 +- **入站**:CP936 → UTF-8,按首个 NUL 截断。 +- 出站上限 4000 GBK 字节(客户端输入缓冲 `char str[4096]`,硬上限 4095)。 +- 入站单条上限 8 KiB;inbox 100 条 / 256 KiB,溢出丢最旧并在 `chat_wait` 的 + `dropped` 中报告。 +- 拒绝空消息、纯空白、`\r\n`、NUL 及所有 C0 控制字符。 + +客户端聊天窗口自 P9 起是 Unicode 对话框(见 §5.8):入站 GBK 字节先转 UTF-16 再 +显示,出站 UTF-16 严格转 GBK 后发送,因此在德语等非 936 系统上中文不再乱码。协议 +本身仍是 GBK,`ö`/`ß`/emoji 等 GBK 外字符两端都无法传输(服务端 `-32602`;客户端 +在发送框下方给出提示并不发送)。 + +## 5. 客户端怪癖与规避(协议不变) + +1. **首字节 101 ('e') 被丢弃**:`client/ChatManager.cpp` 在任何长度判断之前执行 + `if (szBuffer[0] == TOKEN_HEARTBEAT) return;`。服务端在转换后的 GBK 首字节为 + 101 时前置一个空格(显示上无感)。若客户端日后修复此行,可移除该规避。 +2. **窗口创建前的文本被丢弃**:客户端收到 `[0]` 后 `WaitForDialogOpen` 只 + `Sleep(150)`,随后才在线程里 `CreateDialogParamW`;`OnReceive` 仅在 `m_hWnd` + 非空时显示文本。服务端把 `readyAt` 设为绑定后 1000ms,`chat_open` 等满再返回, + `chat_send` 也拒绝早于 `readyAt` 的发送。 +3. **X / 结束交谈只隐藏不关闭**:客户端 `ShowWindow(SW_HIDE)`,会话仍活;下一条 + 服务端文本会 `SW_RESTORE` 重新弹出。因此隐藏不是关闭信号,结束时必须显式 + `chat_close`。 +4. **一字节控制包**:客户端把 `len==1` 的包当命令,故文本必须带尾部 NUL(至少 + 2 字节),空消息在工具层拒绝。 +5. **手动「远程交谈」竞争**:MCP 会话在飞时用户又点菜单会产生第二个 `[62]`; + 先到者绑定,后到者被吸收并 `CancelIO`。极窄竞争下用户这次看不到 MFC 窗口, + 重试即可。 +6. **Session 0 / SYSTEM**:`COMMAND_CHAT` 不像抓屏那样重定向到交互会话;以服务 + 方式运行的客户端没有交互桌面,`chat_open` 会超时。VM 验证必须在交互桌面运行。 +7. **加壳部署的对话框兜底**:EXE + SC_AES/sRDI 打包时, + `g_hInstance = GetModuleHandle(NULL)` 是外壳进程映像,资源里只有清单、没有 + `IDD_CHAT`;载荷以裸 shellcode 在壳进程内运行,未注册进加载器,Windows 的 + `FindResource`/`LoadResource`/`CreateDialogParamW` 取不到对话框 + (实测 err=1812 / 59),聊天线程建窗后立即退出。客户端因此保留 + `CreateDialogParamW` 原路径,失败时回退到 `CreateDialogIndirectParamW` + + 内嵌模板(`client/ChatDialogTemplate.h`,取自载荷 `RT_DIALOG 106`,240 字节), + 图标回退为「直接遍历本模块映像的资源目录取 `RT_GROUP_ICON 105` → + `RT_ICON` → `CreateIconFromResourceEx`」(`client/ImageResource.h`), + 仍失败才用 `LoadIcon(NULL, IDI_APPLICATION)`。**旧客户端无此兜底,加壳部署下 + 不会弹窗**:MDLL 由服务端自动下发新 DLL 即生效,EXE 需重新分发客户端。 +8. **Unicode 对话框(非 936 系统乱码修复)**:客户端是 MBCS 编译,但对话框可以建 + 成 Unicode 窗口:`CreateDialogParamW` / `CreateDialogIndirectParamW` 创建的窗口 + `IsWindowUnicode` 为真,而 `DLGTEMPLATEEX` 的字符串本来就按 UTF-16 存储。因此 + 聊天窗口全部改走 W 版 API——`GetWindowTextW` / `SetDlgItemTextW` / 日志用 + UTF-16 拼接,消息循环用 `GetMessageW` / `IsDialogMessageW` / `DispatchMessageW` + (否则 WM_CHAR / WM_IME_* 会先按 ACP 转换)。入站 + `MultiByteToWideChar(936, …)`,出站 `WideCharToMultiByte(936, + WC_NO_BEST_FIT_CHARS, …, &usedDefault)`;`usedDefault` 置位即 GBK 不可表示 + 字符,提示后不发送。`IDD_CHAT` 字体由点阵 "System" 改为 "MS Shell Dlg" + (可缩放,并经 SystemLink 字体链接到中文字体),否则 Unicode 窗口里同样没有 + 中文字形;前提是系统装有至少一种 CJK 字体(简体中文补充字体或 Microsoft YaHei + 等),否则只能显示方框。静态文案按 `GetUserDefaultUILanguage()` 在中文/英文两套 + 间切换,`Script.rc` 因此直接写英文(德语系统看到的是英文而非乱码)。协议与服务端 + 逻辑不变。 + +## 6. 服务端状态机 + +数据结构(`McpServer.h`,受 `m_ChatMutex` 保护): + +```cpp +struct ChatSession { + std::string sessionId; + context* mainCtx; // 主连接(仅身份判断;主连接断开后置空) + uint64_t mainClientId; + context* subCtx; // 已绑定的子连接;关闭后置空 + std::string peer; // GetPeerName()(老客户端 clientID=0 兜底) + bool started, closed; + bool sendBusy, waitBusy; // 同一会话串行发送 / 只允许一个 chat_wait + steady_clock::time_point readyAt; + time_t lastActiveAt; + std::deque inbox; // UTF-8,未投递 + size_t inboxBytes; + uint64_t nextMsgId; + int dropped; +}; +using ChatSessionPtr = std::shared_ptr; +``` + +- 会话用 `shared_ptr` 持有:`chat_wait` / `chat_send` 在工作线程复制一份后,即使 + map 条目被 `chat_close` 或断线删除也安全。 +- `m_ChatSessions[device]`、`m_ChatContextToDevice[subCtx]`、`m_ChatAbsorbedSubCtx` + 与 `m_ChatRoutes`(原子计数)供 `MessageHandle` 快速路径判断;无会话在飞时 + `OnChatSubPacket` 无锁返回,避免音频/屏幕等高频子连接逐包抢锁。 +- 锁序:`parent->m_cs`(`FindMainContext`)→ `m_ChatMutex`。持锁期间不发送、 + 不 `CancelIO`、不 `WaitSendIdle`。 +- 超时/关闭后到达的 `[62]` 由 5s 墓碑(按 device 与 peer 两个维度)吸收并 + `CancelIO`,避免弹出 MFC 聊天对话框。 +- 空闲回收 600s(任一无 `busy` 且 `lastActiveAt` 超时的会话),每个 chat 工具 + 入口惰性调用。 + +## 7. 钩子与生命周期 + +`2015RemoteDlg.cpp`: + +1. `MessageHandle`:MCP 终端块之后、**P8 `OnManagedSubPacket` 之前**调用 + `OnChatSubPacket`。聊天文本是裸 GBK,首字节可能是 61/255,若先交给 P8 会被 + 误绑定。 +2. `OfflineProc`:P8 清理之后、`RemoveFromHostList` **之前**调用 + `OnChatContextClosed`(连接池会复用 `context*`,晚清理会误路由新连接)。 + 子连接断开 → 会话 `closed` 并唤醒 `chat_wait`;主连接断开 → 关闭其所有会话并 + 锁外 `CancelIO` 子连接。 +3. 启动:`SetChatEnabled(THIS_CFG.GetInt("settings", "McpChat", 0) != 0)`。 + +审计:`AuditChatAction` 与 `AuditManageAction` 同落地方式(`WM_SHOWERRORMSG`), +标题 `_TR("MCP远程交谈")`;只记录 `session=...` 与 `bytes=N`,**绝不记录消息正文**。 +`chat_wait` 不审计,避免刷屏。 + +## 8. 配置 + +| INI key | 默认 | 说明 | +|---|---|---| +| `McpChat` | `0` | 远程交谈工具族开关;改动需重启服务端 | + +「扩展 → MCP设置」新增复选框「启用远程交谈(AI 与用户对话)」。 + +## 9. 验证矩阵 + +准备:`McpEnabled=1`、`McpChat=1` 后重启服务端;MCP 客户端重连使 `tools/list` +刷新;VM 中交互运行 Windows 客户端。 + +| # | 操作 | 期望 | +|---|---|---| +| 1 | `tools/list`(开关开 / 关) | 四个 chat 工具出现 / 消失;关闭时直调 `-32006` | +| 2 | `chat_open(id)` | VM 约 1s 内弹出「远程交谈」;返回 32 位 hex `session_id` | +| 3 | `chat_send` 中文;用户回中文;`chat_wait` | VM 显示「对方: …」;返回 UTF-8 原文 | +| 4 | 连续多轮、用户连发两条 | 下一轮批量返回,顺序正确,`dropped=0` | +| 5 | 发送 `"echo test"`(首字节 'e') | 正常显示(前置空格规避生效) | +| 6 | open 后立刻 send,重复 10 次 | 第一条不丢(1000ms 建窗等待) | +| 7 | 4000 / 4001 字节、emoji | 边界内成功;超长与 emoji 返回 `-32602` | +| 8 | 无回复时 `chat_wait` | 到点 `timed_out:true`,会话仍活 | +| 9 | 用户点 X 隐藏后再 send | 窗口恢复,会话仍活 | +| 10 | `chat_close` | 窗口关闭;旧 id 发送 `-32002`;立即重新 open 成功 | +| 11 | `chat_wait` 阻塞时杀掉客户端 | 返回 `closed:true`;服务端稳定;重连后可重新 open | +| 12 | Linux / macOS / Android 主机 | `-32005`,不发命令 | +| 13 | 同设备第二次 `chat_open` | `-32003` | +| 14 | 无 MCP 会话时手动「远程交谈」 | MFC 聊天窗口照常打开(回归) | +| 15 | 服务端审计日志 | 有 open/send/close 的 session id 与字节数,无消息正文 | +| 16 | 现有 MCP 工具 + 手动主机/连接/终端对话框 | 行为不变 | +| 17 | `chat_wait timeout_ms=55000` | 在常见 60s 工具超时前返回 | +| 18 | 加壳(SC_AES/sRDI)EXE 客户端 `chat_open` | 仍弹出「远程交谈」且标题栏图标正常(内嵌模板 + 自模块映像取图标);客户端日志有 `CreateDialogParamW(1812) 失败,改用内嵌对话框模板` | +| 19 | 非 936 系统(如德语 Windows)`chat_open` + 中文 `chat_send` | 标题、按钮为英文(系统 UI 非中文),中文消息正常显示而非乱码;中文系统为中文按钮 | +| 20 | 德语系统客户端发送 `Grüße` / emoji | 客户端不发送并在日志区提示 GBK 不支持;服务端若直接 `chat_send` 同类字符返回 `-32602` | +| 21 | 未安装任何 CJK 字体的系统 | 中文显示为方框(字体缺失,非编码问题);安装中文补充字体后恢复 | + +## 10. 已知限制 + +- **无投递回执**:`sent:true` 只表示已写入子连接并 `WaitSendIdle` 成功,不保证 + 使用者已看见。 +- **HTTP 工作线程**:`chat_wait` 最长 55s 会占用一个 httplib 工作线程;不要在 + 同一 MCP 连接上并行大量长等待。 +- **连接池复用窄竞争**:发送时捕获的 `subCtx` 在极端情况下可能已断开并被池复用; + 与现有终端/屏幕实现同一取舍,靠 `closed` 检查与 `WaitSendIdle` 缩短窗口。 +- **极慢 VM**:若首条消息仍丢,可把 `kChatOpenSettleMs` 调到 1500–2000ms,代价是 + `chat_open` 变慢。 +- **仍是 GBK 通道**:Unicode 对话框解决了「显示」,但线路上仍是 CP936,德语变音 + 字母(`ö`/`ä`/`ß`)、emoji 等无法传输。要根治需引入会话级编码协商(如 + `TOKEN_CHAT_START` 后追加能力位,双方改用 UTF-8),属未来工作;服务端与客户端 + 必须同时升级,且旧客户端不能误解新报文。 +- **字体依赖**:中文显示依赖系统已安装的 CJK 字体(字体链接到 "MS Shell Dlg" + 的后备字体);仅此一点无法由程序保证,缺少字体时显示为方框。 diff --git a/docs/Mcp_Design.md b/docs/Mcp_Design.md index 73f9ddc..65c400c 100644 --- a/docs/Mcp_Design.md +++ b/docs/Mcp_Design.md @@ -428,6 +428,7 @@ void CMcpSettingsDlg::OnBnClickedBtnSave() - 写操作工具的安全边界评审(操作对象、权限、审计) - 认证升级:复用 `WebServiceAuth` 的签名 token / 过期机制 - 分组过滤:`list_online_hosts` 加 `group` 参数 +- P9 远程交谈(已实现):`chat_open` / `chat_send` / `chat_wait` / `chat_close`,独立开关 `McpChat`(默认 `0`,与只读开关无关);协议不变,客户端聊天窗口改为 Unicode 对话框以支持非中文系统(见 `docs/Mcp_Chat_Design.md`) --- diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index a5da873..5e4c688 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -2423,6 +2423,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog() McpServer().SetMachineEnabled(THIS_CFG.GetInt("settings", "McpMachine", 0) != 0); McpServer().SetMachineDangerousEnabled( THIS_CFG.GetInt("settings", "McpMachineDangerous", 0) != 0); + // P9 远程交谈(chat_*):独立开关默认关,与只读开关无关。 + McpServer().SetChatEnabled(THIS_CFG.GetInt("settings", "McpChat", 0) != 0); if (!McpServer().Start(mcpBind, mcpPort)) { Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); } else { @@ -5527,6 +5529,12 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject) McpServer().OnManagedContextClosed(ContextObject); } + // MCP P9 远程交谈:子连接(或主连接)断开时清路由/标记会话 closed 并唤醒 chat_wait。 + // 同样必须在 RemoveFromHostList 之前,避免连接池复用同一 context 指针后误路由。 + if (McpServer().IsRunning()) { + McpServer().OnChatContextClosed(ContextObject); + } + SOCKET nSocket = ContextObject->sClientSocket; CDialogBase* p = (CDialogBase*)ContextObject->hDlg; @@ -5889,6 +5897,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) return; } + // ===== MCP 远程交谈:子连接路由(P9)===== + // 必须在 P8 之前:聊天文本是裸 GBK,首字节可能是 61(SYSINFOLIST)/255(CONNECTION_LIST), + // 若先交给 P8 会被误当作就绪包绑定。已绑定的子连接由 MCP 消费;未绑定的只有 + // TOKEN_CHAT_START(62) 会被尝试绑定,其余(含 TOKEN_CONN_AUTH=246)原样交回下方分发。 + if (McpServer().IsRunning() && McpServer().OnChatSubPacket(ContextObject, szBuffer, len)) { + return; + } + // ===== MCP 主机管理 / 连接管理:子连接路由(P8)===== // 已绑定的子连接上的所有包由 MCP 消费;未绑定的子连接只有就绪包 // TOKEN_SYSINFOLIST(61) / TOKEN_CONNECTION_LIST(255) 会被尝试绑定,其余原样交回 diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj b/server/2015Remote/2015Remote_vs2015.vcxproj index d2079a1..a56d4e1 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj +++ b/server/2015Remote/2015Remote_vs2015.vcxproj @@ -328,6 +328,7 @@ + @@ -399,6 +400,7 @@ + diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj.filters b/server/2015Remote/2015Remote_vs2015.vcxproj.filters index ef0252b..54a34a6 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj.filters +++ b/server/2015Remote/2015Remote_vs2015.vcxproj.filters @@ -89,6 +89,7 @@ + @@ -205,6 +206,7 @@ + diff --git a/server/2015Remote/McpChat.cpp b/server/2015Remote/McpChat.cpp new file mode 100644 index 0000000..73a189f --- /dev/null +++ b/server/2015Remote/McpChat.cpp @@ -0,0 +1,1040 @@ +// McpChat.cpp : MCP「远程交谈」工具族 + CMcpServer 聊天会话状态机 +// +// 链路(Windows 客户端,零改动): +// 主连接 [COMMAND_CHAT=161] +// → 客户端新建子连接:先 [TOKEN_CONN_AUTH=246](子连接鉴权),再 [TOKEN_CHAT_START=62] +// → 服务端在子连接回 [COMMAND_NEXT_CHAT=0] → 客户端弹出 IDD_CHAT +// → 双向文本 [CP936 字节][NUL],无 token 前缀 +// → 服务端 [COMMAND_CHAT_CLOSE=1] 关闭窗口 +// +// 客户端怪癖(见 docs/Mcp_Chat_Design.md §4): +// 1. ChatManager.cpp 在任何长度判断前丢弃首字节为 TOKEN_HEARTBEAT(101, 'e') 的包, +// 故 GBK 首字节为 101 时前置一个空格。 +// 2. 窗口由子线程 CreateDialogParamA 创建,收到 [0] 后仍有约 150ms 建窗延迟; +// 绑定后 1000ms 内不返回 chat_open,也不接受 chat_send。 +// +// 会话状态全部由 CMcpServer 持有(McpServer.h)。 + +#include "stdafx.h" +#include "McpChat.h" +#include "McpServer.h" +#include "McpInternal.h" +#include "context.h" +#include "2015RemoteDlg.h" +#include "LangManager.h" + +#include +#include +#include + +using namespace McpInternal; + +namespace { + +// ========== 错误码(与 McpServer.cpp / McpMachine.cpp 一致) ========== +const int kErrTimeout = -32001; +const int kErrOffline = -32002; +const int kErrBusy = -32003; +const int kErrSend = -32004; +const int kErrUnsupported = -32005; +const int kErrDisabled = -32006; +const int kErrInvalid = -32602; + +// ========== 参数/协议上限 ========== +// 客户端输入缓冲 char str[4096](ChatManager.cpp),最大 4095 字节; +// 留出尾部 NUL 与余量,服务端上限 4000 GBK 字节。 +const size_t kMaxSendBytes = 4000; +// 单条入站文本上限:正常聊天远小于此,超过即丢弃(防异常客户端把 inbox 撑爆)。 +const size_t kMaxIncomingBytes = 8u * 1024u; +// inbox 上限(条数 / UTF-8 字节数),溢出丢最旧并计数。 +const size_t kInboxMaxMessages = 100; +const size_t kInboxMaxBytes = 256u * 1024u; + +// 绑定后等待客户端建窗的时间(ChatManager::WaitForDialogOpen 为 8s 等事件 + 150ms Sleep; +// 服务端毫秒级回 [0],真正需要覆盖的是那 150ms 的建窗窗口)。 +const int kChatOpenSettleMs = 1000; +// 迟到 62 墓碑:吸收超时/关闭后才到达的子连接。 +const int kChatOrphanMs = 5000; + +const int kOpenDefaultMs = 15000; +const int kOpenMinMs = 2000; +const int kOpenMaxMs = 60000; +const int kWaitDefaultMs = 30000; +const int kWaitMaxMs = 55000; +const int kWaitMaxMessages = 50; +const int kWaitDefaultMessages = 20; + +// ========== 小工具 ========== + +std::string Trim(const std::string& s) { + size_t b = 0, e = s.size(); + while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r' || s[b] == '\n')) ++b; + while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t' || s[e - 1] == '\r' || s[e - 1] == '\n')) --e; + return s.substr(b, e - b); +} + +std::string OptStr(const Json::Value& args, const char* key) { + return Trim(GetStringArg(args, key)); +} + +bool IsWindowsClient(context* ctx) { + if (!ctx) return false; + CString t = ctx->GetAdditionalData(RES_CLIENT_TYPE); + return !(t == "LNX" || t == "MAC" || t == "APK"); +} + +struct HostTool { + uint64_t dev = 0; + context* mainCtx = nullptr; +}; + +bool ResolveHost(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent, + HostTool& ht, bool requireWindows_, std::string& errJson) { + uint64_t dev = 0; + std::string err; + if (!ParseHostIdArg(args, dev, err)) { + errJson = BuildError(id, kErrInvalid, err); + return false; + } + context* ctx = FindMainContext(parent, dev); + if (!ctx) { + errJson = BuildError(id, kErrOffline, "Host not found or offline: " + std::to_string(dev)); + return false; + } + if (requireWindows_ && !IsWindowsClient(ctx)) { + errJson = BuildError(id, kErrUnsupported, + "Remote chat is only supported on Windows hosts"); + return false; + } + ht.dev = dev; + ht.mainCtx = ctx; + return true; +} + +bool GetOptIntArg(const Json::Value& id, const Json::Value& args, const char* key, + int minV, int maxV, int& out, std::string& errJson) { + if (!args.isMember(key)) return true; + int v = 0; + if (!GetIntArg(args, key, v)) { + errJson = BuildError(id, kErrInvalid, std::string("Invalid parameter '") + key + "': expected an integer"); + return false; + } + if (v < minV || v > maxV) { + errJson = BuildError(id, kErrInvalid, std::string("Parameter '") + key + "' out of range [" + + std::to_string(minV) + ", " + std::to_string(maxV) + "]"); + return false; + } + out = v; + return true; +} + +// ========== schema 辅助 ========== + +Json::Value PropStr(const char* desc = nullptr) { + Json::Value p(Json::objectValue); + p["type"] = "string"; + if (desc) p["description"] = desc; + return p; +} + +Json::Value PropInt(const char* desc = nullptr) { + Json::Value p(Json::objectValue); + p["type"] = "integer"; + if (desc) p["description"] = desc; + return p; +} + +Json::Value PropBool(const char* desc = nullptr) { + Json::Value p(Json::objectValue); + p["type"] = "boolean"; + if (desc) p["description"] = desc; + return p; +} + +Json::Value MakeSchema(const Json::Value& props, std::initializer_list required) { + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + if (required.size() > 0) { + Json::Value req(Json::arrayValue); + for (const char* r : required) req.append(r); + schema["required"] = req; + } + return schema; +} + +Json::Value OutputSchema(Json::Value props, std::initializer_list required) { + return MakeSchema(props, required); +} + +Json::Value HostIdProp() { + return PropStr(u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"); +} + +Json::Value SessionIdProp() { + return PropStr(u8"chat_open 返回的 session_id(32 位 hex)"); +} + +void AddTool(Json::Value& tools, const char* name, const char* desc, + const Json::Value& inputSchema, const Json::Value& outputSchema) { + Json::Value tool(Json::objectValue); + tool["name"] = name; + tool["description"] = desc; // 调用方传 u8 字面量,保证 JSON 里是 UTF-8 + tool["inputSchema"] = inputSchema; + tool["outputSchema"] = outputSchema; + tools.append(tool); +} + +std::string BuildStructuredResult(const Json::Value& id, const Json::Value& sc, + const std::string& text) { + Json::Value result(Json::objectValue); + result["structuredContent"] = sc; + + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = text; + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + +// ========== 入参校验 ========== + +// 纯空白(space/tab/CR/LF);控制字符由调用方另行拒绝。 +bool IsBlankMessage(const std::string& s) { + return Trim(s).empty(); +} + +// 拒绝 NUL、CR/LF 与所有 C0 控制字符。UTF-8 多字节字节均 >= 0x80,不会误判。 +bool HasControlChar(const std::string& s) { + for (unsigned char c : s) { + if (c < 0x20 || c == 0x7F) return true; + } + return false; +} + +} // namespace + +namespace McpChat { + +bool Handles(const std::string& toolName) { + static const char* kTools[] = { + "chat_open", "chat_send", "chat_wait", "chat_close", + }; + for (const char* t : kTools) { + if (toolName == t) return true; + } + return false; +} + +void AppendTools(Json::Value& tools) { + CMcpServer& mcp = McpServer(); + // 独立开关(默认关):与 McpReadonly 无关,关闭时工具完全不可见。 + if (!mcp.IsChatEnabled()) return; + + // ---- chat_open ---- + { + Json::Value props(Json::objectValue); + props["id"] = HostIdProp(); + props["timeout_ms"] = PropInt(u8"等待客户端弹出聊天窗口的超时,默认 15000,范围 2000-60000"); + AddTool(tools, "chat_open", + u8"在指定在线 Windows 主机上弹出「远程交谈」窗口,建立一次多轮文字对话。返回 session_id,后续 chat_send / chat_wait / chat_close 都要带上。客户端使用者会直接看到聊天窗口(无确认框)。同一主机同一时刻只允许一个会话;会话在客户端断开或空闲 10 分钟后自动失效。会话建立后应先用 chat_send 发送问候,再用 chat_wait 等待对方回复。", + MakeSchema(props, { "id" }), + OutputSchema([] { + Json::Value p(Json::objectValue); + p["session_id"] = PropStr(); + p["status"] = PropStr(u8"固定为 open"); + p["message"] = PropStr(); + return p; + }(), { "session_id", "status" })); + } + + // ---- chat_send ---- + { + Json::Value props(Json::objectValue); + props["id"] = HostIdProp(); + props["session_id"] = SessionIdProp(); + props["message"] = PropStr(u8"要发给客户端使用者的纯文本(UTF-8)。不能为空、不能含换行/控制字符、不能超过 4000 个 GBK 字节(约 2000 个汉字);emoji 等 GBK 无法表示的字符会被拒绝"); + AddTool(tools, "chat_send", + u8"向会话对端发送一条文本。消息以客户端本地代码页(简体中文为 GBK/CP936)显示;客户端不保证非中文系统能正确显示。发送成功只代表已写入子连接,不代表对方已读。请勿在同一条消息里塞入多段对话;需要等待回复时调用 chat_wait。", + MakeSchema(props, { "id", "session_id", "message" }), + OutputSchema([] { + Json::Value p(Json::objectValue); + p["session_id"] = PropStr(); + p["sent"] = PropBool(); + p["bytes"] = PropInt(u8"实际下发的 GBK 字节数"); + p["message"] = PropStr(); + return p; + }(), { "session_id", "sent" })); + } + + // ---- chat_wait ---- + { + Json::Value props(Json::objectValue); + props["id"] = HostIdProp(); + props["session_id"] = SessionIdProp(); + props["timeout_ms"] = PropInt(u8"最长等待时间,默认 30000,范围 0-55000;0 = 立即返回当前已收到的消息"); + props["max_messages"] = PropInt(u8"单次最多返回条数,默认 20,范围 1-50;其余留到下一次"); + AddTool(tools, "chat_wait", + u8"等待客户端使用者发来的消息。有消息立即返回;没有消息则阻塞到 timeout_ms 或会话关闭。返回 messages(按时间顺序,text 为 UTF-8)、timed_out、closed、dropped(因缓冲区溢出丢弃的最旧消息数)。timed_out=true 只表示这段时间对方没说话,会话仍然有效;closed=true 表示客户端已断开,需要重新 chat_open。一般回复后立刻调用 chat_wait 形成对话循环。", + MakeSchema(props, { "id", "session_id" }), + OutputSchema([] { + Json::Value p(Json::objectValue); + p["session_id"] = PropStr(); + Json::Value arr(Json::objectValue); + arr["type"] = "array"; + Json::Value items(Json::objectValue); + items["type"] = "object"; + Json::Value ip(Json::objectValue); + ip["id"] = PropInt(u8"会话内单调递增序号"); + ip["at"] = PropInt(u8"服务端收到的 Unix 秒"); + ip["text"] = PropStr(u8"UTF-8 文本"); + items["properties"] = ip; + arr["items"] = items; + p["messages"] = arr; + p["timed_out"] = PropBool(); + p["closed"] = PropBool(); + p["dropped"] = PropInt(); + return p; + }(), { "session_id", "messages", "timed_out", "closed" })); + } + + // ---- chat_close ---- + { + Json::Value props(Json::objectValue); + props["id"] = HostIdProp(); + props["session_id"] = SessionIdProp(); + AddTool(tools, "chat_close", + u8"结束会话并关闭客户端上的聊天窗口。主机在线且会话已不存在时按幂等处理为成功;主机已离线时返回 -32002(此时会话已随断线失效)。关闭后旧的 session_id 不能再用;可立即重新 chat_open。", + MakeSchema(props, { "id", "session_id" }), + OutputSchema([] { + Json::Value p(Json::objectValue); + p["session_id"] = PropStr(); + p["closed"] = PropBool(); + p["message"] = PropStr(); + return p; + }(), { "session_id", "closed" })); + } +} + +// ========== 工具实现 ========== + +static std::string BuildChatOpen(const Json::Value& id, const Json::Value& args, + CMy2015RemoteDlg* parent) { + HostTool ht; + std::string errJson; + if (!ResolveHost(id, args, parent, ht, /*requireWindows_=*/true, errJson)) return errJson; + + int timeoutMs = kOpenDefaultMs; + if (!GetOptIntArg(id, args, "timeout_ms", kOpenMinMs, kOpenMaxMs, timeoutMs, errJson)) + return errJson; + + CMcpServer& mcp = McpServer(); + mcp.SweepIdleChats(600); + + const std::string sessionId = GenerateRandomToken(); + if (!mcp.BeginChatOpen(ht.dev, ht.mainCtx, sessionId)) { + return BuildError(id, kErrBusy, + "This host already has an active chat session; close it first " + "(chat_close) or wait for it to expire"); + } + + BYTE cmd = (BYTE)COMMAND_CHAT; + if (!ht.mainCtx->Send2Client(&cmd, 1)) { + context* sub = nullptr; + bool had = false; + mcp.AbortChatOpen(ht.dev, sub, had); + if (sub) sub->CancelIO(); + return BuildError(id, kErrSend, "Failed to send the chat command to the host"); + } + + CMcpServer::ChatReadyResult r = mcp.WaitChatReady(ht.dev, sessionId, timeoutMs); + if (r != CMcpServer::ChatReadyResult::Ok) { + context* sub = nullptr; + bool had = false; + mcp.AbortChatOpen(ht.dev, sub, had); + if (sub) sub->CancelIO(); + switch (r) { + case CMcpServer::ChatReadyResult::Closed: + return BuildError(id, kErrOffline, + "The client closed the chat sub-connection during setup"); + case CMcpServer::ChatReadyResult::NotFound: + return BuildError(id, kErrOffline, "The chat session disappeared during setup"); + case CMcpServer::ChatReadyResult::Timeout: + default: + return BuildError(id, kErrTimeout, + "Timeout waiting for the client to open the chat window " + "(the host may be offline, the client may be too old, or it may " + "not be running in an interactive desktop session)"); + } + } + + AuditChatAction(parent, ht.dev, "chat_open", "session=" + sessionId); + + Json::Value sc(Json::objectValue); + sc["session_id"] = sessionId; + sc["status"] = "open"; + sc["message"] = u8"聊天窗口已打开,可以开始发送消息。"; + std::string text = u8"已与主机 " + std::to_string(ht.dev) + + u8" 建立远程交谈会话;客户端上的聊天窗口已弹出。session_id=" + sessionId; + return BuildStructuredResult(id, sc, text); +} + +static std::string BuildChatSend(const Json::Value& id, const Json::Value& args, + CMy2015RemoteDlg* parent) { + HostTool ht; + std::string errJson; + if (!ResolveHost(id, args, parent, ht, /*requireWindows_=*/true, errJson)) return errJson; + + const std::string sessionId = OptStr(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, kErrInvalid, "Missing required parameter: session_id"); + + // 注意:message 不做 Trim(首尾空格是用户输入的一部分),只做空白判定。 + const std::string message = GetStringArg(args, "message"); + if (message.empty()) + return BuildError(id, kErrInvalid, "Parameter 'message' must not be empty"); + if (IsBlankMessage(message)) + return BuildError(id, kErrInvalid, "Parameter 'message' must not be blank"); + if (HasControlChar(message)) + return BuildError(id, kErrInvalid, + "Parameter 'message' must not contain control characters " + "(newlines, tabs, NUL, ... )"); + if (!IsValidUtf8(message.c_str(), message.size())) + return BuildError(id, kErrInvalid, "Parameter 'message' is not valid UTF-8"); + + // 严格转 CP936:emoji / GBK 外字符直接拒绝,绝不静默变成 '?'。 + std::string gbk; + if (!Utf8ToCpExact(message, 936, gbk) || gbk.empty()) { + return BuildError(id, kErrInvalid, + "Parameter 'message' contains characters that cannot be represented " + "in GBK (the client's local code page); emoji are not supported"); + } + if (gbk.size() > kMaxSendBytes) { + return BuildError(id, kErrInvalid, + "Parameter 'message' is too long: " + std::to_string(gbk.size()) + + " GBK bytes (limit " + std::to_string(kMaxSendBytes) + ")"); + } + + // 客户端在长度判断之前丢弃首字节为 TOKEN_HEARTBEAT(101, 'e') 的包。 + // 前置一个空格规避,显示上无感(2026-10 客户端 ChatManager.cpp:281)。 + if ((BYTE)gbk[0] == (BYTE)TOKEN_HEARTBEAT) gbk.insert(gbk.begin(), ' '); + + std::vector packet(gbk.begin(), gbk.end()); + packet.push_back('\0'); + + CMcpServer& mcp = McpServer(); + context* sub = nullptr; + int br = mcp.BeginChatSend(ht.dev, sessionId, sub); + if (br == 1) { + return BuildError(id, kErrOffline, + "Chat session not found, not ready, or already closed; " + "call chat_open again if needed"); + } + if (br == 2) { + return BuildError(id, kErrBusy, "Another message is still being sent; retry shortly"); + } + + bool ok = sub && sub->Send2Client((PBYTE)packet.data(), (ULONG)packet.size()); + if (ok) sub->WaitSendIdle(2000); + mcp.FinishChatSend(ht.dev, sessionId); + + if (!ok) return BuildError(id, kErrSend, "Failed to send the message to the client"); + + AuditChatAction(parent, ht.dev, "chat_send", + "session=" + sessionId + " bytes=" + std::to_string(gbk.size())); + + Json::Value sc(Json::objectValue); + sc["session_id"] = sessionId; + sc["sent"] = true; + sc["bytes"] = (Json::Int64)gbk.size(); + sc["message"] = u8"消息已发送(客户端无回执,不代表对方已读)。"; + std::string text = u8"已发送 " + std::to_string(gbk.size()) + u8" GBK 字节。"; + return BuildStructuredResult(id, sc, text); +} + +static std::string BuildChatWait(const Json::Value& id, const Json::Value& args, + CMy2015RemoteDlg* parent) { + HostTool ht; + std::string errJson; + if (!ResolveHost(id, args, parent, ht, /*requireWindows_=*/true, errJson)) return errJson; + + const std::string sessionId = OptStr(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, kErrInvalid, "Missing required parameter: session_id"); + + int timeoutMs = kWaitDefaultMs; + if (!GetOptIntArg(id, args, "timeout_ms", 0, kWaitMaxMs, timeoutMs, errJson)) return errJson; + + int maxMessages = kWaitDefaultMessages; + if (!GetOptIntArg(id, args, "max_messages", 1, kWaitMaxMessages, maxMessages, errJson)) + return errJson; + + CMcpServer& mcp = McpServer(); + mcp.SweepIdleChats(600); + + std::vector msgs; + bool timedOut = false, closed = false; + int dropped = 0; + CMcpServer::ChatWaitResult r = mcp.WaitChatMessages( + ht.dev, sessionId, timeoutMs, (size_t)maxMessages, msgs, timedOut, closed, dropped); + + switch (r) { + case CMcpServer::ChatWaitResult::NotFound: + return BuildError(id, kErrOffline, + "Chat session not found; it may have been closed or expired. " + "Call chat_open to start a new one"); + case CMcpServer::ChatWaitResult::Mismatch: + return BuildError(id, kErrOffline, + "session_id does not match the active chat session for this host"); + case CMcpServer::ChatWaitResult::Busy: + return BuildError(id, kErrBusy, + "Another chat_wait is already pending for this session"); + case CMcpServer::ChatWaitResult::Closed: + case CMcpServer::ChatWaitResult::Timeout: + case CMcpServer::ChatWaitResult::Ok: + default: + break; + } + + Json::Value arr(Json::arrayValue); + for (const ChatMessage& m : msgs) { + Json::Value item(Json::objectValue); + item["id"] = (Json::UInt64)m.id; + item["at"] = (Json::Int64)m.at; + item["text"] = m.text; + arr.append(item); + } + + Json::Value sc(Json::objectValue); + sc["session_id"] = sessionId; + sc["messages"] = arr; + sc["timed_out"] = timedOut; + sc["closed"] = closed; + sc["dropped"] = dropped; + + std::string text; + if (!msgs.empty()) { + text = u8"收到 " + std::to_string(msgs.size()) + u8" 条消息"; + if (timedOut) text += u8"(等待超时,但已有消息返回)"; + } else if (closed) { + text = u8"会话已关闭:客户端断开或窗口已结束。"; + } else { + text = u8"等待超时:对方在这段时间内没有发言,会话仍然有效。"; + } + if (dropped > 0) text += u8"(缓冲区溢出丢弃了 " + std::to_string(dropped) + u8" 条最旧消息)"; + + return BuildStructuredResult(id, sc, text); +} + +static std::string BuildChatClose(const Json::Value& id, const Json::Value& args, + CMy2015RemoteDlg* parent) { + HostTool ht; + std::string errJson; + if (!ResolveHost(id, args, parent, ht, /*requireWindows_=*/true, errJson)) return errJson; + + const std::string sessionId = OptStr(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, kErrInvalid, "Missing required parameter: session_id"); + + CMcpServer& mcp = McpServer(); + context* sub = nullptr; + bool hadSession = false; + int cr = mcp.CloseChatSession(ht.dev, sessionId, sub, hadSession); + if (cr == 2) { + return BuildError(id, kErrOffline, + "session_id does not match the active chat session for this host"); + } + + if (cr == 0 && sub) { + // 先通知客户端销毁窗口,再断子连接;客户端 ChatManager 析构时也会销毁窗口作兜底。 + BYTE closeCmd = (BYTE)COMMAND_CHAT_CLOSE; + sub->Send2Client(&closeCmd, 1); + sub->WaitSendIdle(1000); + sub->CancelIO(); + } + + if (cr == 0) AuditChatAction(parent, ht.dev, "chat_close", "session=" + sessionId); + + Json::Value sc(Json::objectValue); + sc["session_id"] = sessionId; + sc["closed"] = true; + sc["message"] = hadSession ? u8"会话已关闭。" : u8"会话不存在,按幂等处理为已关闭。"; + std::string text = hadSession ? u8"远程交谈已结束。" : u8"会话不存在,已是关闭状态。"; + return BuildStructuredResult(id, sc, text); +} + +std::string Call(const Json::Value& id, const std::string& toolName, + const Json::Value& args, CMy2015RemoteDlg* parent) { + CMcpServer& mcp = McpServer(); + if (!mcp.IsChatEnabled()) { + return BuildError(id, kErrDisabled, + "Remote chat tools are disabled (set McpChat=1 to enable)"); + } + if (!args.isObject()) { + return BuildError(id, kErrInvalid, "Invalid params: arguments must be an object"); + } + + if (toolName == "chat_open") return BuildChatOpen(id, args, parent); + if (toolName == "chat_send") return BuildChatSend(id, args, parent); + if (toolName == "chat_wait") return BuildChatWait(id, args, parent); + if (toolName == "chat_close") return BuildChatClose(id, args, parent); + + return BuildError(id, -32601, "Unknown tool: " + toolName); +} + +} // namespace McpChat + +// ============================================================================ +// CMcpServer:聊天会话状态机(UI 线程与工具线程共享) +// ============================================================================ + +using std::chrono::steady_clock; + +void CMcpServer::RefreshChatRoutesLocked() { + m_ChatRoutes.store((int)(m_ChatContextToDevice.size() + m_ChatAbsorbedSubCtx.size()), + std::memory_order_relaxed); +} + +void CMcpServer::PruneChatOrphansLocked(steady_clock::time_point now) { + for (auto it = m_ChatOrphanByDevice.begin(); it != m_ChatOrphanByDevice.end(); ) { + if (it->second.expiry <= now) it = m_ChatOrphanByDevice.erase(it); + else ++it; + } + for (auto it = m_ChatOrphanByPeer.begin(); it != m_ChatOrphanByPeer.end(); ) { + if (it->second.expiry <= now) it = m_ChatOrphanByPeer.erase(it); + else ++it; + } +} + +bool CMcpServer::OnChatSubPacket(context* subCtx, const BYTE* data, ULONG len) { + if (!subCtx || !data || len == 0) return false; + + const BYTE token = data[0]; + const bool readyToken = (token == (BYTE)TOKEN_CHAT_START); + // 快速路径:没有已绑定/已吸收的子连接,且不是就绪包 → 与聊天无关。 + // MessageHandle 每个包都会调用本函数(含音频/屏幕等高频子连接),不能逐包抢锁。 + // 注意:未绑定的 TOKEN_CONN_AUTH(246) 也走这里返回 false,交给鉴权分支;已绑定的 + // 聊天文本首字节可能是 0xF6(GBK 前导字节),必须由情形 1 消费,不能提前放行。 + if (!readyToken && m_ChatRoutes.load(std::memory_order_relaxed) == 0) return false; + + // FindMainContext 要取 parent->m_cs,必须在持 m_ChatMutex 之前完成, + // 维持锁序 m_cs → m_ChatMutex(与 P2/P7/P8 的调用顺序一致)。 + const uint64_t cid = subCtx->GetClientID(); + const bool mainIsSelf = readyToken && cid != 0 && FindMainContext(m_parent, cid) == subCtx; + const std::string peer = subCtx->GetPeerName(); + + std::unique_lock lk(m_ChatMutex); + const steady_clock::time_point now = steady_clock::now(); + PruneChatOrphansLocked(now); + + // ---- 情形 1:已绑定的子连接 → 该连接上的包全部由 MCP 消费 ---- + auto bindIt = m_ChatContextToDevice.find(subCtx); + if (bindIt != m_ChatContextToDevice.end()) { + const uint64_t dev = bindIt->second; + auto sit = m_ChatSessions.find(dev); + if (sit == m_ChatSessions.end() || sit->second->subCtx != subCtx || + sit->second->closed) { + // 会话已结束但包还在路上:吸收并断开 + m_ChatContextToDevice.erase(bindIt); + m_ChatAbsorbedSubCtx.insert(subCtx); + RefreshChatRoutesLocked(); + lk.unlock(); + subCtx->CancelIO(); + return true; + } + + ChatSessionPtr s = sit->second; + s->lastActiveAt = time(nullptr); + + // 单字节包是客户端心跳/控制包,不是文本(客户端发送文本时至少带尾部 NUL)。 + if (len <= 1) return true; + if ((size_t)len > kMaxIncomingBytes) { + ++s->dropped; + Mprintf("[MCP-Chat] drop oversized inbound packet dev=%llu len=%lu\n", + (unsigned long long)dev, (unsigned long)len); + return true; + } + + const size_t textLen = BoundedStrlen((const char*)data, (size_t)len); + if (textLen == 0) return true; + + const std::string raw((const char*)data, textLen); + ChatMessage msg; + msg.id = s->nextMsgId++; + msg.at = (int64_t)time(nullptr); + msg.text = ToUtf8(raw.c_str(), 936); + if (msg.text.empty()) return true; + + s->inbox.push_back(msg); + s->inboxBytes += msg.text.size(); + while (s->inbox.size() > kInboxMaxMessages || s->inboxBytes > kInboxMaxBytes) { + s->inboxBytes -= s->inbox.front().text.size(); + s->inbox.pop_front(); + ++s->dropped; + } + m_ChatCv.notify_all(); + return true; + } + + // ---- 情形 2:已吸收(CancelIO 后仍到达的尾包)→ 丢弃 ---- + if (m_ChatAbsorbedSubCtx.find(subCtx) != m_ChatAbsorbedSubCtx.end()) { + lk.unlock(); + subCtx->CancelIO(); + return true; + } + + // ---- 情形 3:未绑定且不是就绪包 → 交回 MFC ---- + if (!readyToken) return false; + if (mainIsSelf) return false; // 主连接不会发就绪包 + + // 3a. 按 clientID 精确匹配(经 TOKEN_CONN_AUTH 已钉 ID),优先匹配「尚未绑定」的会话。 + uint64_t boundDev = 0; + if (cid != 0) { + auto it = m_ChatSessions.find(cid); + if (it != m_ChatSessions.end()) { + const ChatSessionPtr& s = it->second; + if (!s->closed && !s->started && s->subCtx == nullptr) boundDev = cid; + } + } + + // 3b. 老客户端子连接 clientID 可能为 0:按 peer 兜底,仅在同 peer 唯一候选时绑定。 + if (boundDev == 0 && cid == 0 && !peer.empty()) { + uint64_t candidate = 0; + int matches = 0; + for (auto& kv : m_ChatSessions) { + const ChatSessionPtr& s = kv.second; + if (s->closed || s->started || s->subCtx != nullptr) continue; + if (s->peer == peer) { candidate = kv.first; ++matches; } + } + if (matches == 1) boundDev = candidate; + } + + // 3c. 已有 started 会话(重复子连接):吸收,避免弹出第二个 MFC 聊天窗口。 + bool absorbDuplicate = false; + if (boundDev == 0) { + if (cid != 0) { + auto it = m_ChatSessions.find(cid); + if (it != m_ChatSessions.end() && it->second->started && !it->second->closed) + absorbDuplicate = true; + } else if (!peer.empty()) { + for (auto& kv : m_ChatSessions) { + if (kv.second->started && !kv.second->closed && kv.second->peer == peer) { + absorbDuplicate = true; + break; + } + } + } + } + + // 3d. 无等待中的会话:5s 墓碑内吸收迟到的 62(chat_open 超时/关闭后的尾巴), + // 避免弹出 MFC 对话框。 + bool absorbOrphan = false; + if (boundDev == 0 && !absorbDuplicate) { + if (cid != 0) { + auto o = m_ChatOrphanByDevice.find(cid); + if (o != m_ChatOrphanByDevice.end() && o->second.expiry > now) absorbOrphan = true; + } else if (!peer.empty()) { + auto o = m_ChatOrphanByPeer.find(peer); + if (o != m_ChatOrphanByPeer.end() && o->second.expiry > now) absorbOrphan = true; + } + } + + if (boundDev == 0 && (absorbDuplicate || absorbOrphan)) { + m_ChatAbsorbedSubCtx.insert(subCtx); + RefreshChatRoutesLocked(); + lk.unlock(); + Mprintf("[MCP-Chat] absorb late/duplicate TOKEN_CHAT_START peer=%s cid=%llu dup=%d orphan=%d\n", + peer.c_str(), (unsigned long long)cid, absorbDuplicate ? 1 : 0, absorbOrphan ? 1 : 0); + subCtx->CancelIO(); + return true; + } + + if (boundDev == 0) return false; // 用户手动「远程交谈」→ 保持 MFC 行为 + + auto sit = m_ChatSessions.find(boundDev); + if (sit == m_ChatSessions.end()) return false; + ChatSessionPtr s = sit->second; + + // ---- 绑定:回 COMMAND_NEXT_CHAT(0),客户端据此弹窗 ---- + s->subCtx = subCtx; + s->started = true; + s->lastActiveAt = time(nullptr); + s->readyAt = now + std::chrono::milliseconds(kChatOpenSettleMs); + m_ChatContextToDevice[subCtx] = boundDev; + RefreshChatRoutesLocked(); + lk.unlock(); + + BYTE next = (BYTE)COMMAND_NEXT_CHAT; + subCtx->Send2Client(&next, 1); + subCtx->WaitSendIdle(1000); + + m_ChatCv.notify_all(); + Mprintf("[MCP-Chat] bind dev=%llu sub=%p peer=%s\n", + (unsigned long long)boundDev, (void*)subCtx, peer.c_str()); + return true; +} + +void CMcpServer::OnChatContextClosed(context* ctx) { + if (!ctx) return; + + std::vector toCancel; + { + std::lock_guard lk(m_ChatMutex); + m_ChatAbsorbedSubCtx.erase(ctx); + + // 子连接断开:会话标记 closed、清路由,保留在表中让 chat_wait 报告 closed。 + auto it = m_ChatContextToDevice.find(ctx); + if (it != m_ChatContextToDevice.end()) { + const uint64_t dev = it->second; + m_ChatContextToDevice.erase(it); + auto sit = m_ChatSessions.find(dev); + if (sit != m_ChatSessions.end() && sit->second->subCtx == ctx) { + sit->second->subCtx = nullptr; + sit->second->closed = true; + } + } + + // 主连接断开:关闭该主连接上的所有会话,并锁外断其子连接。 + for (auto& kv : m_ChatSessions) { + ChatSessionPtr s = kv.second; + if (s->mainCtx != ctx || s->closed) continue; + s->closed = true; + s->mainCtx = nullptr; + if (s->subCtx) { + context* sub = s->subCtx; + s->subCtx = nullptr; + m_ChatContextToDevice.erase(sub); + m_ChatAbsorbedSubCtx.insert(sub); + toCancel.push_back(sub); + } + } + + RefreshChatRoutesLocked(); + m_ChatCv.notify_all(); + } + + for (context* c : toCancel) c->CancelIO(); +} + +bool CMcpServer::BeginChatOpen(uint64_t device_id, context* mainCtx, + const std::string& sessionId) { + if (!mainCtx) return false; + + context* staleSub = nullptr; + { + std::lock_guard lk(m_ChatMutex); + auto it = m_ChatSessions.find(device_id); + if (it != m_ChatSessions.end()) { + if (!it->second->closed) return false; // 同设备已有活跃会话 + // 清理已关闭的旧会话(路由/吸收表可能还留着指针) + staleSub = it->second->subCtx; + if (staleSub) { + m_ChatContextToDevice.erase(staleSub); + m_ChatAbsorbedSubCtx.insert(staleSub); + } + m_ChatSessions.erase(it); + } + m_ChatOrphanByDevice.erase(device_id); + + ChatSessionPtr s = std::make_shared(); + s->sessionId = sessionId; + s->mainCtx = mainCtx; + s->mainClientId = device_id; + s->peer = mainCtx->GetPeerName(); + s->lastActiveAt = time(nullptr); + m_ChatSessions[device_id] = s; + RefreshChatRoutesLocked(); + } + if (staleSub) staleSub->CancelIO(); + return true; +} + +CMcpServer::ChatReadyResult CMcpServer::WaitChatReady(uint64_t device_id, + const std::string& sessionId, + int timeoutMs) { + std::unique_lock lk(m_ChatMutex); + auto it = m_ChatSessions.find(device_id); + if (it == m_ChatSessions.end()) return ChatReadyResult::NotFound; + ChatSessionPtr s = it->second; + if (s->sessionId != sessionId) return ChatReadyResult::NotFound; + + const steady_clock::time_point deadline = + steady_clock::now() + std::chrono::milliseconds(timeoutMs); + + while (true) { + const steady_clock::time_point now = steady_clock::now(); + if (s->closed) return ChatReadyResult::Closed; + if (s->started && now >= s->readyAt) return ChatReadyResult::Ok; + if (now >= deadline) return ChatReadyResult::Timeout; + + // 绑定通知到达后只需等到 readyAt,不要一直睡到整个 open 超时。 + steady_clock::time_point wake = deadline; + if (s->started && s->readyAt < wake) wake = s->readyAt; + m_ChatCv.wait_until(lk, wake); + } +} + +int CMcpServer::BeginChatSend(uint64_t device_id, const std::string& sessionId, + context*& subCtx) { + subCtx = nullptr; + std::lock_guard lk(m_ChatMutex); + + auto it = m_ChatSessions.find(device_id); + if (it == m_ChatSessions.end()) return 1; + ChatSessionPtr s = it->second; + if (s->sessionId != sessionId) return 1; + if (s->closed || !s->started || s->subCtx == nullptr) return 1; + if (steady_clock::now() < s->readyAt) return 1; // 客户端窗口尚未创建完成 + if (s->sendBusy) return 2; + + s->sendBusy = true; + s->lastActiveAt = time(nullptr); + subCtx = s->subCtx; + return 0; +} + +void CMcpServer::FinishChatSend(uint64_t device_id, const std::string& sessionId) { + std::lock_guard lk(m_ChatMutex); + auto it = m_ChatSessions.find(device_id); + if (it == m_ChatSessions.end()) return; + if (it->second->sessionId != sessionId) return; // 旧会话已被新会话替换 + it->second->sendBusy = false; +} + +CMcpServer::ChatWaitResult CMcpServer::WaitChatMessages( + uint64_t device_id, const std::string& sessionId, int timeoutMs, size_t maxMessages, + std::vector& out, bool& timedOut, bool& closed, int& dropped) { + out.clear(); + timedOut = false; + closed = false; + dropped = 0; + + std::unique_lock lk(m_ChatMutex); + auto it = m_ChatSessions.find(device_id); + if (it == m_ChatSessions.end()) return ChatWaitResult::NotFound; + ChatSessionPtr s = it->second; + if (s->sessionId != sessionId) return ChatWaitResult::Mismatch; + if (s->waitBusy) return ChatWaitResult::Busy; + + s->waitBusy = true; + const steady_clock::time_point deadline = + steady_clock::now() + std::chrono::milliseconds(timeoutMs); + + while (s->inbox.empty() && !s->closed) { + if (timeoutMs <= 0 || steady_clock::now() >= deadline) break; + m_ChatCv.wait_until(lk, deadline); + } + + while (!s->inbox.empty() && out.size() < maxMessages) { + out.push_back(s->inbox.front()); + s->inboxBytes -= s->inbox.front().text.size(); + s->inbox.pop_front(); + } + + timedOut = s->inbox.empty() && !s->closed; + closed = s->closed; + dropped = s->dropped; + s->dropped = 0; + s->waitBusy = false; + if (!s->closed) s->lastActiveAt = time(nullptr); + + return timedOut ? ChatWaitResult::Timeout : ChatWaitResult::Ok; +} + +int CMcpServer::CloseChatSession(uint64_t device_id, const std::string& sessionId, + context*& subCtxOut, bool& hadSession) { + subCtxOut = nullptr; + hadSession = false; + + std::lock_guard lk(m_ChatMutex); + auto it = m_ChatSessions.find(device_id); + if (it == m_ChatSessions.end()) return 1; // 幂等 + ChatSessionPtr s = it->second; + if (s->sessionId != sessionId) return 2; + + hadSession = true; + s->closed = true; + s->mainCtx = nullptr; + subCtxOut = s->subCtx; + if (subCtxOut) { + m_ChatContextToDevice.erase(subCtxOut); + m_ChatAbsorbedSubCtx.insert(subCtxOut); + s->subCtx = nullptr; + } + m_ChatSessions.erase(it); + m_ChatOrphanByDevice.erase(device_id); + RefreshChatRoutesLocked(); + m_ChatCv.notify_all(); + return 0; +} + +void CMcpServer::AbortChatOpen(uint64_t device_id, context*& subCtxOut, bool& hadSession) { + subCtxOut = nullptr; + hadSession = false; + + std::lock_guard lk(m_ChatMutex); + auto it = m_ChatSessions.find(device_id); + if (it == m_ChatSessions.end()) return; + + hadSession = true; + ChatSessionPtr s = it->second; + s->closed = true; + s->mainCtx = nullptr; + subCtxOut = s->subCtx; + + if (subCtxOut) { + m_ChatContextToDevice.erase(subCtxOut); + m_ChatAbsorbedSubCtx.insert(subCtxOut); + s->subCtx = nullptr; + } else { + // 子连接还没到:打 5s 墓碑,吸收迟到的 TOKEN_CHAT_START,避免弹出 MFC 对话框。 + ChatOrphanMark mk; + mk.expiry = steady_clock::now() + std::chrono::milliseconds(kChatOrphanMs); + m_ChatOrphanByDevice[device_id] = mk; + if (!s->peer.empty()) m_ChatOrphanByPeer[s->peer] = mk; + } + + m_ChatSessions.erase(it); + RefreshChatRoutesLocked(); + m_ChatCv.notify_all(); +} + +int CMcpServer::SweepIdleChats(time_t idleTimeoutSec) { + std::vector toCancel; + int swept = 0; + { + std::lock_guard lk(m_ChatMutex); + const time_t now = time(nullptr); + for (auto it = m_ChatSessions.begin(); it != m_ChatSessions.end(); ) { + ChatSessionPtr s = it->second; + if (s->waitBusy || s->sendBusy || + now - s->lastActiveAt <= idleTimeoutSec) { + ++it; + continue; + } + s->closed = true; + s->mainCtx = nullptr; + if (s->subCtx) { + context* sub = s->subCtx; + s->subCtx = nullptr; + m_ChatContextToDevice.erase(sub); + m_ChatAbsorbedSubCtx.insert(sub); + toCancel.push_back(sub); + } + it = m_ChatSessions.erase(it); + ++swept; + Mprintf("[MCP-Chat] sweep idle session dev=%llu\n", + (unsigned long long)s->mainClientId); + } + if (swept > 0) { + RefreshChatRoutesLocked(); + m_ChatCv.notify_all(); + } + } + for (context* c : toCancel) c->CancelIO(); + return swept; +} diff --git a/server/2015Remote/McpChat.h b/server/2015Remote/McpChat.h new file mode 100644 index 0000000..d2b3229 --- /dev/null +++ b/server/2015Remote/McpChat.h @@ -0,0 +1,30 @@ +#pragma once + +// MCP「远程交谈」工具族:chat_open / chat_send / chat_wait / chat_close。 +// +// 复用 Windows 客户端既有的「远程交谈」链路(协议不变): +// 主连接 [COMMAND_CHAT=161] +// → 客户端子连接先 [TOKEN_CONN_AUTH=246],再 [TOKEN_CHAT_START=62] +// → 服务端在子连接回 [COMMAND_NEXT_CHAT=0](客户端据此弹 IDD_CHAT) +// → 双向文本 [CP936 字节][NUL];服务端 [COMMAND_CHAT_CLOSE=1] 结束。 +// +// 会话状态由 CMcpServer 持有(McpServer.h),本文件只负责工具 schema、参数校验、 +// 编码转换与响应构造。协议、客户端怪癖与验证矩阵见 docs/Mcp_Chat_Design.md。 + +#include "jsoncpp/json.h" + +#include + +class CMy2015RemoteDlg; + +namespace McpChat { + +bool Handles(const std::string& toolName); + +// 开关 close 时不追加任何工具(tools/list 与调用侧一致)。 +void AppendTools(Json::Value& tools); + +std::string Call(const Json::Value& id, const std::string& toolName, + const Json::Value& args, CMy2015RemoteDlg* parent); + +} // namespace McpChat diff --git a/server/2015Remote/McpInternal.cpp b/server/2015Remote/McpInternal.cpp index eb9230b..7d9370d 100644 --- a/server/2015Remote/McpInternal.cpp +++ b/server/2015Remote/McpInternal.cpp @@ -160,6 +160,35 @@ std::string ToAnsi(const std::string& utf8, UINT cp) { return out; } +bool Utf8ToCpExact(const std::string& utf8, UINT cp, std::string& out) { + out.clear(); + if (utf8.empty()) return true; + int wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.c_str(), (int)utf8.size(), NULL, 0); + if (wlen <= 0) return false; + std::wstring w(wlen, L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.c_str(), (int)utf8.size(), + &w[0], wlen) != wlen) + return false; + + BOOL usedDefault = FALSE; + int alen = WideCharToMultiByte(cp, WC_NO_BEST_FIT_CHARS, w.c_str(), wlen, NULL, 0, NULL, &usedDefault); + if (alen <= 0 || usedDefault) return false; + out.assign(alen, '\0'); + usedDefault = FALSE; + if (WideCharToMultiByte(cp, WC_NO_BEST_FIT_CHARS, w.c_str(), wlen, &out[0], alen, + NULL, &usedDefault) != alen || usedDefault) { + out.clear(); + return false; + } + return true; +} + +bool IsValidUtf8(const char* s, size_t n) { + if (!s || n == 0) return true; + int wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, (int)n, NULL, 0); + return wlen > 0; +} + void AuditManageAction(CMy2015RemoteDlg* parent, uint64_t devId, const std::string& tool, const std::string& detail) { if (!parent) return; @@ -169,4 +198,13 @@ void AuditManageAction(CMy2015RemoteDlg* parent, uint64_t devId, (LPARAM)new CString(_TR("MCP管理操作"))); } +void AuditChatAction(CMy2015RemoteDlg* parent, uint64_t devId, + const std::string& tool, const std::string& detail) { + if (!parent) return; + std::string text = "host " + std::to_string(devId) + " " + tool + ": " + detail; + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(text, 936).c_str()), + (LPARAM)new CString(_TR("MCP远程交谈"))); +} + } // namespace McpInternal diff --git a/server/2015Remote/McpInternal.h b/server/2015Remote/McpInternal.h index cd338dd..cc3ce24 100644 --- a/server/2015Remote/McpInternal.h +++ b/server/2015Remote/McpInternal.h @@ -52,8 +52,20 @@ size_t BoundedStrlen(const char* p, size_t avail); std::string ToUtf8(const char* s, UINT cp); std::string ToAnsi(const std::string& utf8, UINT cp); +// 严格 UTF-8 → 目标代码页:任一字符无法表示即失败(不用 '?' 顶替)。 +// UTF-8 本身非法也失败。聊天消息用它拒绝 GBK 不可表示的字符(emoji 等)。 +bool Utf8ToCpExact(const std::string& utf8, UINT cp, std::string& out); + +// 校验字节串是否为合法 UTF-8(n=0 视为合法)。 +bool IsValidUtf8(const char* s, size_t n); + // 写操作审计:PostMessageA(WM_SHOWERRORMSG) 落到服务端消息/审计日志(不可关闭)。 void AuditManageAction(CMy2015RemoteDlg* parent, uint64_t devId, const std::string& tool, const std::string& detail); +// 远程交谈审计:与 AuditManageAction 同落地方式,标题区分(MCP远程交谈)。 +// 只记录会话 id / 字节数,绝不记录消息正文。 +void AuditChatAction(CMy2015RemoteDlg* parent, uint64_t devId, + const std::string& tool, const std::string& detail); + } // namespace McpInternal diff --git a/server/2015Remote/McpMachine.cpp b/server/2015Remote/McpMachine.cpp index 6d71832..f114dc3 100644 --- a/server/2015Remote/McpMachine.cpp +++ b/server/2015Remote/McpMachine.cpp @@ -110,35 +110,7 @@ std::string OptStr(const Json::Value& args, const char* key) { return Trim(GetStringArg(args, key)); } -// 严格 UTF-8 → 目标代码页:任一字符无法表示即失败(不用 '?' 顶替)。 -bool Utf8ToCpExact(const std::string& utf8, UINT cp, std::string& out) { - out.clear(); - if (utf8.empty()) return true; - int wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.c_str(), (int)utf8.size(), NULL, 0); - if (wlen <= 0) return false; - std::wstring w(wlen, L'\0'); - if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.c_str(), (int)utf8.size(), - &w[0], wlen) != wlen) - return false; - - BOOL usedDefault = FALSE; - int alen = WideCharToMultiByte(cp, WC_NO_BEST_FIT_CHARS, w.c_str(), wlen, NULL, 0, NULL, &usedDefault); - if (alen <= 0 || usedDefault) return false; - out.assign(alen, '\0'); - usedDefault = FALSE; - if (WideCharToMultiByte(cp, WC_NO_BEST_FIT_CHARS, w.c_str(), wlen, &out[0], alen, - NULL, &usedDefault) != alen || usedDefault) { - out.clear(); - return false; - } - return true; -} - -bool IsValidUtf8(const char* s, size_t n) { - if (!s || n == 0) return true; - int wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, (int)n, NULL, 0); - return wlen > 0; -} +// Utf8ToCpExact / IsValidUtf8 由 McpInternal 提供(McpChat.cpp 共用)。 bool IsWindowsClient(context* ctx) { if (!ctx) return false; diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp index d170f6b..965171d 100644 --- a/server/2015Remote/McpServer.cpp +++ b/server/2015Remote/McpServer.cpp @@ -10,6 +10,7 @@ #include "common/file_upload.h" // V2 文件传输(RecvFileChunkV2 / HandleFileCompleteV2 / 包结构) #include "McpInternal.h" // 共享助手(McpMachine.cpp 也使用) #include "McpMachine.h" // 主机管理 / 连接管理工具族 +#include "McpChat.h" // 远程交谈工具族 #include @@ -1490,6 +1491,10 @@ std::string BuildToolsListResult(const Json::Value& id) { // McpMachineDangerous / McpReadonly 逐项判断,关闭时不追加任何工具。 McpMachine::AppendTools(tools); + // 20) 远程交谈(P9:chat_open / chat_send / chat_wait / chat_close)。 + // 独立开关 McpChat(默认关),与 McpReadonly 无关;关闭时不追加任何工具。 + McpChat::AppendTools(tools); + result["tools"] = tools; return BuildResult(id, result); } @@ -4533,6 +4538,9 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { // P8:连接管理 / 主机管理工具族(独立分发;门控在 McpMachine::Call 内复核)。 if (McpMachine::Handles(toolName)) return McpMachine::Call(id, toolName, args, parent); + // P9:远程交谈工具族(独立开关 McpChat;门控在 McpChat::Call 内复核)。 + if (McpChat::Handles(toolName)) return McpChat::Call(id, toolName, args, parent); + if (toolName == "list_online_hosts") return BuildListOnlineHosts(id, parent); if (toolName == "search_hosts") return BuildSearchHosts(id, args, parent); if (toolName == "get_host_detail") return BuildGetHostDetail(id, args, parent); diff --git a/server/2015Remote/McpServer.h b/server/2015Remote/McpServer.h index 775a990..0d23186 100644 --- a/server/2015Remote/McpServer.h +++ b/server/2015Remote/McpServer.h @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include @@ -37,6 +39,14 @@ struct FileTransferEntry { std::string sha256; }; +// 远程交谈消息(chat_wait 输出 messages[] 的一项)。 +// text 为 UTF-8;at 为收到消息的 Unix 秒;id 为会话内单调递增序号。 +struct ChatMessage { + uint64_t id = 0; + int64_t at = 0; + std::string text; +}; + // MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。 // 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听 // (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。 @@ -305,6 +315,57 @@ public: // (吸收迟到的 61/255,避免超时后弹出 MFC 对话框)。 void EndManagedSession(uint64_t device_id, bool cancelIo, bool markOrphan); + // ===== P9:远程交谈(chat_open / chat_send / chat_wait / chat_close)===== + // 复用 Windows 客户端既有「远程交谈」:主连接下发 COMMAND_CHAT(161) → 客户端新建子连接 + // (先 TOKEN_CONN_AUTH(246) 再 TOKEN_CHAT_START(62))→ 服务端在该子连接回 + // COMMAND_NEXT_CHAT(0) → 客户端弹 IDD_CHAT。之后双向文本为 raw CP936 + NUL。 + // 协议不变(客户端聊天窗口已改为 Unicode 对话框,仅影响显示);独立 m_ChatMutex, + // 不与 P8 的 m_Pending 共享(不受单设备单飞行约束)。 + // 见 docs/Mcp_Chat_Design.md。 + void SetChatEnabled(bool enabled) { m_chatEnabled = enabled; } + bool IsChatEnabled() const { return m_chatEnabled; } + + // MessageHandle 顶部钩子(MCP 终端之后、P8 之前:聊天文本首字节可能是 61/255/246)。 + // true = 本包已由 MCP 消费。未绑定子连接上的 246(TOKEN_CONN_AUTH) 走快速路径返回 + // false,交给认证分支;已绑定子连接上的 0xF6 前导字节文本则由本函数消费。 + bool OnChatSubPacket(context* subCtx, const BYTE* data, ULONG len); + + // OfflineProc 钩子:接管的子连接断开 → 会话 closed + 清路由 + 唤醒等待者(幂等)。 + void OnChatContextClosed(context* subCtx); + + // 工具线程:登记会话并发 COMMAND_CHAT 之后调用。false = 该设备已有未关闭会话(-32003)。 + bool BeginChatOpen(uint64_t device_id, context* mainCtx, const std::string& sessionId); + + // 工具线程:等待客户端子连接就绪且建窗等待期满(readyAt)。 + enum class ChatReadyResult { Ok, Timeout, Closed, NotFound }; + ChatReadyResult WaitChatReady(uint64_t device_id, const std::string& sessionId, int timeoutMs); + + // 工具线程:发送前登记(校验 sessionId/started/readyAt/sendBusy)。输出 subCtx。 + // 返回:0=ok;1=不存在/不匹配/未就绪/已关;2=忙。 + int BeginChatSend(uint64_t device_id, const std::string& sessionId, context*& subCtx); + + // 工具线程:发送结束(清 sendBusy)。closed 期间发送失败时调用方仍须调用。 + void FinishChatSend(uint64_t device_id, const std::string& sessionId); + + // 工具线程:取走最多 maxMessages 条;inbox 为空则等到 deadline / closed。 + enum class ChatWaitResult { Ok, Timeout, Closed, NotFound, Mismatch, Busy }; + ChatWaitResult WaitChatMessages(uint64_t device_id, const std::string& sessionId, + int timeoutMs, size_t maxMessages, + std::vector& out, bool& timedOut, + bool& closed, int& dropped); + + // 关闭会话(校验 sessionId;标记 closed + 清路由 + 唤醒)。返回 0=已关;1=不存在(幂等); + // 2=sessionId 不匹配。子连接 [1] 与 CancelIO 由调用方在锁外执行。 + int CloseChatSession(uint64_t device_id, const std::string& sessionId, + context*& subCtxOut, bool& hadSession); + + // chat_open 超时/失败收尾:标记 closed + 清路由;已绑定子连接写入 subCtxOut(调用方 CancelIO), + // 未绑定则打 device/peer 5s 墓碑,吸收迟到的 62(避免超时后弹出 MFC 对话框)。 + void AbortChatOpen(uint64_t device_id, context*& subCtxOut, bool& hadSession); + + // idle 回收:关闭 lastActiveAt 超时且无等待/无发送的会话。返回回收数量。 + int SweepIdleChats(time_t idleTimeoutSec); + // 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。 void SetReadonly(bool readonly) { m_readonly = readonly; } void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; } @@ -451,6 +512,44 @@ private: std::condition_variable m_FileXferCv; std::map m_FileXferSessions; // device_id → 会话 + // ===== P9:远程交谈(受 m_ChatMutex 保护;单设备单会话)===== + // 用 shared_ptr 持有会话:chat_wait / chat_send 在工作线程里复制一份后,即使 map 条目 + // 被 chat_close 或断线删除也安全,不需要额外的「迭代器有效」规则。 + struct ChatSession { + std::string sessionId; + context* mainCtx = nullptr; // 主连接(仅身份校验) + uint64_t mainClientId = 0; + context* subCtx = nullptr; // 已绑定的子连接;关闭后置空 + std::string peer; // GetPeerName()(老客户端 clientID=0 兜底) + bool started = false; // 已收到 TOKEN_CHAT_START 并回 [0] + bool closed = false; + bool sendBusy = false; // 同一会话串行发送 + bool waitBusy = false; // 同一会话只允许一个 chat_wait + std::chrono::steady_clock::time_point readyAt; // 建窗等待期满(绑定 + 1000ms) + time_t lastActiveAt = 0; + std::deque inbox; // 未投递消息(UTF-8) + size_t inboxBytes = 0; + uint64_t nextMsgId = 1; + int dropped = 0; // inbox 溢出丢弃的条数(chat_wait 报告) + }; + using ChatSessionPtr = std::shared_ptr; + + std::mutex m_ChatMutex; + std::condition_variable m_ChatCv; + std::map m_ChatSessions; // device_id → 会话 + std::map m_ChatContextToDevice; // subCtx → device_id(顶部路由) + std::set m_ChatAbsorbedSubCtx; // CancelIO 后尾巴包静默丢弃 + struct ChatOrphanMark { std::chrono::steady_clock::time_point expiry; }; + std::map m_ChatOrphanByDevice; // 迟到 62 墓碑(5s) + std::map m_ChatOrphanByPeer; + std::atomic m_ChatRoutes{0}; // 路由集合大小(快速路径) + bool m_chatEnabled = false; // P9 开关(默认关) + + // 持 m_ChatMutex 调用:只更新 m_ChatRoutes(store,relaxed 读取在锁外)。 + void RefreshChatRoutesLocked(); + // 持 m_ChatMutex 调用:清理过期墓碑。 + void PruneChatOrphansLocked(std::chrono::steady_clock::time_point now); + bool m_readonly = true; std::string m_cmdWhitelist; bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false) diff --git a/server/2015Remote/McpSettingsDlg.cpp b/server/2015Remote/McpSettingsDlg.cpp index d76e959..eac9797 100644 --- a/server/2015Remote/McpSettingsDlg.cpp +++ b/server/2015Remote/McpSettingsDlg.cpp @@ -106,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal() { USES_CONVERSION; CString title = _TR("MCP设置"); - BuildDialogTemplate(m_Template, T2CW(title), 320, 480); + BuildDialogTemplate(m_Template, T2CW(title), 320, 510); InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); return CDialog::DoModal(); } @@ -154,6 +154,9 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnMachineDangerous.Create(_TR("启用危险主机操作(冻结/解冻进程、停止/重启服务)"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, r0, this, IDC_MCP_MACHINE_DANGEROUS); + m_btnChat.Create(_TR("启用远程交谈(AI 与用户对话)"), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, + r0, this, IDC_MCP_CHAT); m_lblWhitelist.Create(_TR("命令白名单"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL, @@ -180,6 +183,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnManage.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnMachine.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnMachineDangerous.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_btnChat.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_lblWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); @@ -199,6 +203,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() int manage = THIS_CFG.GetInt("settings", "McpManage", 0); int machine = THIS_CFG.GetInt("settings", "McpMachine", 0); int machineDangerous = THIS_CFG.GetInt("settings", "McpMachineDangerous", 0); + int chat = THIS_CFG.GetInt("settings", "McpChat", 0); std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""); m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED); @@ -212,6 +217,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnManage.SetCheck(manage ? BST_CHECKED : BST_UNCHECKED); m_btnMachine.SetCheck(machine ? BST_CHECKED : BST_UNCHECKED); m_btnMachineDangerous.SetCheck(machineDangerous ? BST_CHECKED : BST_UNCHECKED); + m_btnChat.SetCheck(chat ? BST_CHECKED : BST_UNCHECKED); // 白名单存储为逗号分隔,展示为每行一条。 m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str())); @@ -234,6 +240,7 @@ void CMcpSettingsDlg::OnOK() bool manage = (m_btnManage.GetCheck() == BST_CHECKED); bool machine = (m_btnMachine.GetCheck() == BST_CHECKED); bool machineDangerous = (m_btnMachineDangerous.GetCheck() == BST_CHECKED); + bool chat = (m_btnChat.GetCheck() == BST_CHECKED); // 危险子集依附于主机管理总开关:没开主机管理就没有工具可放开,直接清除并提示。 if (machineDangerous && !machine) { @@ -271,6 +278,7 @@ void CMcpSettingsDlg::OnOK() THIS_CFG.SetInt("settings", "McpManage", manage ? 1 : 0); THIS_CFG.SetInt("settings", "McpMachine", machine ? 1 : 0); THIS_CFG.SetInt("settings", "McpMachineDangerous", machineDangerous ? 1 : 0); + THIS_CFG.SetInt("settings", "McpChat", chat ? 1 : 0); std::string whitelist = CT2A(sWhitelist); whitelist = NormalizeWhitelist(whitelist); THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist); @@ -279,7 +287,8 @@ void CMcpSettingsDlg::OnOK() MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + _TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输/管理操作/主机管理/危险主机操作的改动需重启程序生效。") + _T("\r\n") + _TR("持久终端、远程控制与管理操作仅在只读模式关闭时生效。") + _T("\r\n") + - _TR("主机管理的写操作需只读模式关闭;冻结/解冻进程、停止/重启服务还需启用危险主机操作。"), + _TR("主机管理的写操作需只读模式关闭;冻结/解冻进程、停止/重启服务还需启用危险主机操作。") + _T("\r\n") + + _TR("远程交谈开关的改动需重启程序生效。"), _TR("提示"), MB_ICONINFORMATION); CDialog::OnOK(); @@ -331,6 +340,9 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy) m_btnMachineDangerous.MoveWindow(margin, y, cx - margin * 2, 22); y += 30; + m_btnChat.MoveWindow(margin, y, cx - margin * 2, 22); + y += 30; + const int whitelistH = 90; m_lblWhitelist.MoveWindow(margin, y, labelW, rowH); m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH); diff --git a/server/2015Remote/McpSettingsDlg.h b/server/2015Remote/McpSettingsDlg.h index a398b40..230bc22 100644 --- a/server/2015Remote/McpSettingsDlg.h +++ b/server/2015Remote/McpSettingsDlg.h @@ -34,6 +34,7 @@ private: IDC_MCP_MANAGE = 1010, // 「启用管理操作」复选框(kill/窗口/服务/电源,要求只读关) IDC_MCP_MACHINE = 1011, // 「启用主机管理」复选框(host_* 工具族) IDC_MCP_MACHINE_DANGEROUS = 1012, // 「启用危险主机操作」复选框(冻结/停止/重启) + IDC_MCP_CHAT = 1013, // 「启用远程交谈」复选框(chat_* 工具族,不要求只读关) }; CButton m_btnEnable; @@ -46,6 +47,7 @@ private: CButton m_btnManage; CButton m_btnMachine; CButton m_btnMachineDangerous; + CButton m_btnChat; CStatic m_lblWhitelist; CEdit m_editWhitelist; CButton m_btnOK, m_btnCancel; diff --git a/server/2015Remote/lang/de_DE.ini b/server/2015Remote/lang/de_DE.ini index bcf42ae..f07445d 100644 --- a/server/2015Remote/lang/de_DE.ini +++ b/server/2015Remote/lang/de_DE.ini @@ -2009,6 +2009,9 @@ Token=Token ÆôÓÃÖ÷»ú¹ÜÀí£¨½ø³Ì/´°¿Ú/·þÎñ/ÈÎÎñ/ÍøÂç״̬£©=Maschinenverwaltung aktivieren (Prozess/Fenster/Dienst/Aufgabe/Netzwerkstatus) ÆôÓÃΣÏÕÖ÷»ú²Ù×÷£¨¶³½á/½ⶳ½ø³̡¢ֹͣ/ÖØÆô·þÎñ£©=Gefaehrliche Maschinenoperationen aktivieren (Prozess einfrieren/auftauen, Dienst stoppen/neu starten) ΣÏÕÖ÷»ú²Ù×÷ÒÀ¸½ÓÚÖ÷»ú¹ÜÀí£ºÇëÏÈÆôÓÃÖ÷»ú¹ÜÀí¡£=Gefaehrliche Maschinenoperationen setzen die Maschinenverwaltung voraus. Aktivieren Sie zuerst die Maschinenverwaltung. +ÆôÓÃԶ³̽»̸£¨AI ÓëÓû§¶Ի°£©=Fernchat aktivieren (KI spricht mit dem Benutzer) +MCPԶ³̽»̸=MCP-Fernchat +Զ³̽»̸¿ª¹صĸ͝ÐèÖØÆô³ÌÐòÉúЧ¡£=Aenderungen am Fernchat-Schalter werden nach einem Neustart wirksam. Ö÷»ú¹ÜÀíµÄд²Ù×÷Ðèֻ¶Áģʽ¹رգ»¶³½á/½ⶳ½ø³̡¢ֹͣ/ÖØÆô·þÎñ»¹ÐèÆôÓÃΣÏÕÖ÷»ú²Ù×÷¡£=Schreiboperationen der Maschinenverwaltung erfordern deaktivierten Nur-Lesen-Modus; Einfrieren/Auftauen von Prozessen und Stoppen/Neustarten von Diensten erfordern zusaetzlich gefaehrliche Maschinenoperationen. µÚ %d ¸öǮ°üµØַ²»ºϷ¨!=Wallet-Adresse #%d ist ungueltig! %d ÐÂÁ¬½Ó\r\n=%d Neue Verbindung\r\n diff --git a/server/2015Remote/lang/en_US.ini b/server/2015Remote/lang/en_US.ini index 6bce336..1075e2d 100644 --- a/server/2015Remote/lang/en_US.ini +++ b/server/2015Remote/lang/en_US.ini @@ -2014,6 +2014,9 @@ Token=Token ÆôÓÃÖ÷»ú¹ÜÀí£¨½ø³Ì/´°¿Ú/·þÎñ/ÈÎÎñ/ÍøÂç״̬£©=Enable host management (processes/windows/services/tasks/network state) ÆôÓÃΣÏÕÖ÷»ú²Ù×÷£¨¶³½á/½ⶳ½ø³̡¢ֹͣ/ÖØÆô·þÎñ£©=Enable dangerous host operations (freeze/thaw process, stop/restart service) ΣÏÕÖ÷»ú²Ù×÷ÒÀ¸½ÓÚÖ÷»ú¹ÜÀí£ºÇëÏÈÆôÓÃÖ÷»ú¹ÜÀí¡£=Dangerous host operations depend on host management. Enable host management first. +ÆôÓÃԶ³̽»̸£¨AI ÓëÓû§¶Ի°£©=Enable remote chat (AI talks to the user) +MCPԶ³̽»̸=MCP remote chat +Զ³̽»̸¿ª¹صĸ͝ÐèÖØÆô³ÌÐòÉúЧ¡£=Changes to the remote chat switch take effect after restart. Ö÷»ú¹ÜÀíµÄд²Ù×÷Ðèֻ¶Áģʽ¹رգ»¶³½á/½ⶳ½ø³̡¢ֹͣ/ÖØÆô·þÎñ»¹ÐèÆôÓÃΣÏÕÖ÷»ú²Ù×÷¡£=Host management write operations require read-only mode to be off; freezing/thawing processes and stopping/restarting services also require dangerous host operations. µÚ %d ¸öǮ°üµØַ²»ºϷ¨!=Wallet address #%d is invalid! %d ÐÂÁ¬½Ó\r\n=%d New connection\r\n diff --git a/server/2015Remote/lang/zh_TW.ini b/server/2015Remote/lang/zh_TW.ini index c90a732..a0d5f95 100644 --- a/server/2015Remote/lang/zh_TW.ini +++ b/server/2015Remote/lang/zh_TW.ini @@ -2005,6 +2005,9 @@ Token=Token ÆôÓÃÖ÷»ú¹ÜÀí£¨½ø³Ì/´°¿Ú/·þÎñ/ÈÎÎñ/ÍøÂç״̬£©=†¢ÓÃÖ÷™C¹ÜÀí£¨̎Àí³ÌÐò/ҕ´°/·þ„Õ/Åų̹¤×÷/¾W· î‘B£© ÆôÓÃΣÏÕÖ÷»ú²Ù×÷£¨¶³½á/½ⶳ½ø³̡¢ֹͣ/ÖØÆô·þÎñ£©=†¢ÓÃΣëUÖ÷™C²Ù×÷£¨ƒö½Y/½âƒö̎Àí³ÌÐò¡¢ֹͣ/ÖØÐ†¢„ӷþ„գ© ΣÏÕÖ÷»ú²Ù×÷ÒÀ¸½ÓÚÖ÷»ú¹ÜÀí£ºÇëÏÈÆôÓÃÖ÷»ú¹ÜÀí¡£=ΣëUÖ÷™C²Ù×÷ÒÀ¸½ì¶Ö÷™C¹ÜÀí£ºՈÏȆ¢ÓÃÖ÷™C¹ÜÀí¡£ +ÆôÓÃԶ³̽»̸£¨AI ÓëÓû§¶Ի°£©=†¢ÓÃßh¶˽»Մ£¨AI ÅcʹÓÃÕߌ¦Ԓ£© +MCPԶ³̽»̸=MCPßh¶˽»Մ +Զ³̽»̸¿ª¹صĸ͝ÐèÖØÆô³ÌÐòÉúЧ¡£=ßh¶˽»Մé_êPµÄ׃¸üÐèÖØÐ†¢„ӳÌʽááÉúЧ¡£ Ö÷»ú¹ÜÀíµÄд²Ù×÷Ðèֻ¶Áģʽ¹رգ»¶³½á/½ⶳ½ø³̡¢ֹͣ/ÖØÆô·þÎñ»¹ÐèÆôÓÃΣÏÕÖ÷»ú²Ù×÷¡£=Ö÷™C¹ÜÀíµČ‘Èë²Ù×÷ÐèêPé]Ψ×xģʽ£»ƒö½Y/½âƒö̎Àí³ÌÐò¡¢ֹͣ/ÖØÐ†¢„ӷþ„Õ߀Ð膢ÓÃΣëUÖ÷™C²Ù×÷¡£ µÚ %d ¸öǮ°üµØַ²»ºϷ¨!=µÚ %d ‚€åX°üλַ²»ºϷ¨! %d ÐÂÁ¬½Ó\r\n=%d ÐÂßB¾€\r\n