# 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" 的后备字体);仅此一点无法由程序保证,缺少字体时显示为方框。