Commit 5fbe9a6d1a88ae39e5edc25c4255d451edbf04bd
1 parent
41ded07b
feat(stream + docs): /v1/runs SSE 流式 + interim_assistant_callback + 文档更新
代码(server/services/wechat/auto-reply.js):
- 切到 /v1/runs + GET /events SSE 长连接替代 /v1/chat/completions 同步阻塞
- 监听新事件:
message.complete → 主路径多步消息(每 turn 末尾的可见 content)
message.delta → content tokens 兜底
reasoning.delta → 默认丢弃;STREAM_REASONING=true 时加 🧠 前缀
reasoning.available → 同上
tool.started → flush 累积;STREAM_TOOL_PROGRESS=true 时发 🔧 进度
run.completed → 最终 output utterance + resolve
- 串行 sendChain:第一条带 quote+@,后续仅文本;A11y 完成前不发下一条
- 诊断日志节流(高频事件 5s 一次)
- log() 去掉 fs.appendFileSync(sync I/O 在高频流式下会卡死 node)
Hermes upstream 补丁(phone 上 ~/hermes-agent/gateway/platforms/api_server.py):
- 加 _reasoning_cb + reasoning_callback wire-up(_create_agent 签名 / AIAgent 构造)
- 加 _interim_cb + interim_assistant_callback wire-up(同上)
- /v1/runs 启动 agent 时把两个新回调都传进去
- 发现:原 hermes /v1/runs 默认只挂 stream_delta_callback + tool_progress_callback,
漏了 gateway 平台用的另外几个关键回调,导致 /v1/runs 客户端只能收到最终一条回复
实测 Kimi 限制:
- Kimi-k2.6 在 hermes agentic mode 下 assistant_message.content 永远空
- 所有内容在 reasoning_content → interim_assistant_callback 因 content 为空 return → 不 fire
- 短期 workaround: STREAM_REASONING=true
- 长期: prompt 工程 / SOUL.md 优化
HANDOFF.md:
- 新章节 §十一 A11y 显式状态机(5 状态 + classifier + dispatch)
- 新章节 §十二 流式多步消息(SSE 事件表 + hermes 补丁说明 + Kimi 限制 + ENV flag 表)
- 已知待办从 6 条扩到 9 条(新增 Kimi content 沉默、长消息分段、群消息合流、流式重试、多模型 fallback、SOUL.md 优化等)
- 避雷清单加 4 条今天新踩的坑
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
2 changed files
with
209 additions
and
36 deletions
server/services/wechat/HANDOFF.md
| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | > **谁会读这份文档**:刚接手这台 Pixel 部署的 claude-run / 开发者,没有上下文。 |
| 4 | 4 | > **目标**:5 分钟搞清楚正在跑什么、文件在哪、怎么改、怎么验证。 |
| 5 | 5 | > |
| 6 | -> **生成时间**:2026-05-24,由 Mac 端 Claude 部署后写入。请定期核对实际状态, | |
| 6 | +> **生成时间**:2026-05-25(v2,加 FSM + 流式 + hermes 补丁章节)。请定期核对实际状态, | |
| 7 | 7 | > 因为本文件不会随代码自动更新。 |
| 8 | 8 | |
| 9 | 9 | --- |
| ... | ... | @@ -261,17 +261,21 @@ pkill -f auto-reply.js |
| 261 | 261 | |
| 262 | 262 | ## 七、已知待办(任选) |
| 263 | 263 | |
| 264 | +**今日已完成**(5/24):群昵称查询 / A11y 长按引用 + @ 回复 / wake-lock + keepalive 守护 / 清理老凭据 / Frida 残留清理 / A11y FSM 重构 / 流式 SSE / hermes 补丁。 | |
| 265 | + | |
| 264 | 266 | 按优先级: |
| 265 | 267 | |
| 266 | -1. **群聊昵称查询**:`android-wechat-tracker-sqlcipher.js` 的 `getContactNickname` 对 `@chatroom` 后缀直接 return wxid,导致 A11y 搜不到群名。 | |
| 267 | - - 改法:删掉 `if (wxid.endsWith('@chatroom')) return wxid;` 分支,让它走通用 rcontact 查询 | |
| 268 | -2. **群聊引用 + @ 回复**:扩 `HermesAccessibilityService.java`,加长按消息 → 选"引用" → 输入 `@` → 选成员的 UI 自动化 | |
| 269 | -3. **`termux-wake-lock` 启用**:保证息屏深 Doze 时 `termux-notification-list` 仍响应。 | |
| 270 | - - `am startservice -a com.termux.service_wake_lock com.termux/.app.TermuxService` | |
| 271 | - - 写进 `keepalive.sh` | |
| 272 | -4. **清理 auth.json 老凭据**:删 `credential_pool["kimi-coding"]` 里 last_status=exhausted 的项 | |
| 273 | -5. **杀 Frida 残留**:`pkill frida-server` + `rm -rf ~/.frida_queue/` | |
| 274 | -6. **重命名/移除已废弃文件**:`android-wechat-sender-frida.js` 等已加废弃注释,可改名为 `*.deprecated.js` 或直接删 | |
| 268 | +1. **A11y service 重命名**:`android-wechat-sender-frida.js` 等已加废弃注释,可改名为 `*.deprecated.js` 或删除 | |
| 269 | +2. **Kimi content 沉默问题**:模型在 hermes agentic mode 下 `content` 永远空,所有内容在 `reasoning_content`,导致 `interim_assistant_callback` 无法 fire。当前两条路: | |
| 270 | + - 短期:开 `STREAM_REASONING=true`,把 reasoning 加 `🧠` 前缀作为思考过程发给用户 | |
| 271 | + - 长期:改 hermes system prompt 或 SOUL.md,让 Kimi 在 content 字段写"对用户的话"而不是只塞 reasoning_content | |
| 272 | +3. **群里长消息分段**:单条 utterance > 500 字会被截断(`maxReplyLength`),应该改成自动分段成多条 | |
| 273 | +4. **照片 / 文件消息**:tracker 只识别 type=1 文本,type=3 (图片) 等忽略 | |
| 274 | +5. **群聊消息去重 / 合流**:短时间内多条新消息 → bot 可能每条都触发回复,体验差。需要 debounce + 合并 | |
| 275 | +6. **流式重试 / 部分输出落地**:当前 run 失败丢全部进度,应该让已 emit 的 utterance 保留为状态 | |
| 276 | +7. **多模型 fallback**:当前只 Moonshot CN。Anthropic OAuth 还在 auth.json 但已 401。补一个有效的 fallback provider | |
| 277 | +8. **Hermes SOUL.md 优化**:当前是默认描述,可针对微信场景定制 prompt 让回复更"小火子" | |
| 278 | +9. **A11y service 故障自愈**:发送失败时(如 chat 找不到)能不能更智能恢复(如点 BACK / 切微信 tab) | |
| 275 | 279 | |
| 276 | 280 | --- |
| 277 | 281 | |
| ... | ... | @@ -292,6 +296,9 @@ pkill -f auto-reply.js |
| 292 | 296 | | `[HERMES] API timeout` / `socket hang up` 在 turns=9 多上下文场景下偶发 | Moonshot kimi-k2.6 长 context LLM call 可达 60s+,旧的 `HERMES_TIMEOUT=120000` 偶发不够 | 启动 env 设 `HERMES_TIMEOUT=180000`(3 分钟),keepalive.sh 已内联 | |
| 293 | 297 | | A11y 状态机 `CHAT_LIST → click → WRONG_CHAT → BACK` 循环死磕直到 cap=6 给出 | 群聊页 `obn` (id=`/obn`) 标题带成员数后缀(如 `"宜创AI全员群(60)"`),而 broadcast 传的 `toUserDisplayName` 是 `"宜创AI全员群"`(chatroom.displayname 不带后缀),`equals` 永远 false | `classifyState` 拿 `obn.text` 后用正则 `\\(\\d+\\)$` 剥成员数后缀再比对(带/不带都接受) | |
| 294 | 298 | | WeChat `LauncherUI` 不在主"会话" tab,`FLAG_ACTIVITY_CLEAR_TASK` 也无法回去 | LauncherUI 是 fragment 容器,CLEAR_TASK 重置 task 但不重置内部 fragment 状态。从聊天页或其它主 tab 调 startActivity 不会跳回会话列表 | 状态机的 `NO_CHAT_VIEW` 处理优先 `findBottomNavTab("微信")` 点击(按 Y 坐标取最下方那个,区分标题栏同名 text),fallback `GLOBAL_ACTION_BACK` | |
| 299 | +| Hermes `/v1/runs` 长时间一片寂静、无任何 `[UTTER]` 事件输出,最后 `run.completed` 一次性给一大段总结 | Kimi-k2.6 在 hermes agentic mode 下 `assistant_message.content` 永远空,所有内容在 `reasoning_content`;`message.delta`/`interim_assistant_callback`/`message.complete` 都不 fire;只有 `reasoning.delta` 大量喷涌 | 短期开 `STREAM_REASONING=true` 让 reasoning 加 `🧠` 前缀作 utterance;长期改 hermes system prompt 让 Kimi 写 content 字段。详见 §十二.4 | | |
| 300 | +| auto-reply node 进程被 keepalive 反复重启,CPU 占用高、log 文件被洪水般写入 | 流式场景下高频 `message.delta`/`reasoning.delta` 事件触发同步 `fs.appendFileSync` + 每条都 console.log,sync I/O 卡死 node;keepalive 检测心跳超时 → kill → restart | `log()` 去掉 `appendFileSync`(stdout 已由 start 脚本重定向到 log file);诊断事件加节流(高频事件 5 秒打一次) | | |
| 301 | +| Hermes `/v1/runs` 只输出"最终一条回复",没多步进度 | hermes 默认 `/v1/runs` 只挂 `stream_delta_callback` + `tool_progress_callback`,缺 `reasoning_callback` + `interim_assistant_callback`(gateway 平台用 7+ 个回调) | 补丁 `gateway/platforms/api_server.py`,在 `_create_agent` 签名 + AIAgent 构造 + run handler 三处都加上缺的两个回调,emit `reasoning.delta` / `message.complete` SSE 事件。详见 §十二.3 | | |
| 295 | 302 | |
| 296 | 303 | --- |
| 297 | 304 | |
| ... | ... | @@ -341,4 +348,95 @@ State reset |
| 341 | 348 | |
| 342 | 349 | --- |
| 343 | 350 | |
| 351 | +## 十一、A11y 状态机(5/24 重构) | |
| 352 | + | |
| 353 | +旧代码用嵌套 if + ad-hoc relaunch 处理"目标聊天 / 错误聊天 / 主界面 / 微信不在前台"等多种状态,边角情况覆盖不全(特别是 LauncherUI fragment 容器 + CLEAR_TASK 不重置内部 fragment)。 | |
| 354 | + | |
| 355 | +新实现:**显式状态机**(`HermesAccessibilityService.java::driveStateMachine`) | |
| 356 | + | |
| 357 | +| 状态 | classifier 特征 | 动作 | | |
| 358 | +|------|-----------------|------| | |
| 359 | +| `TARGET_CHAT` | `bkk` + `obn.text` 等于 displayName(剥末尾 `(数字)` 后) | `performTypeAndSend` 直走 | | |
| 360 | +| `WRONG_CHAT` | `bkk` + `obn.text` 不匹配 | `GLOBAL_ACTION_BACK` → 再 classify | | |
| 361 | +| `CHAT_LIST` | 无 `bkk`,`kbq` 多个 | `findChatListRow` 点 cj1 → 再 classify | | |
| 362 | +| `NO_CHAT_VIEW` | 无 `bkk` 无 `kbq` 但在 com.tencent.mm | `findBottomNavTab("微信")` 点底部 tab,找不到就 BACK | | |
| 363 | +| `OUTSIDE_WECHAT` | `root.pkg != com.tencent.mm` | `startActivity(LauncherUI)` → 再 classify | | |
| 364 | + | |
| 365 | +- 入口:`onReceive` 拉 LauncherUI 后 3.5s 调 `driveStateMachine(0)` | |
| 366 | +- `MAX_STATE_TRANSITIONS = 6` 防死循环 | |
| 367 | +- 关键修复:**chat list 优先于搜索**(避开"丁鹏、🍋火星人、火星人"被搜索解读成"创建包含 3 人的新群"歧义) | |
| 368 | +- 关键修复:**obn 标题剥末尾 `(\d+)`**(群聊页 obn 是"宜创AI全员群(60)" 含成员数后缀) | |
| 369 | + | |
| 370 | +--- | |
| 371 | + | |
| 372 | +## 十二、流式多步消息(/v1/runs SSE,5/24 实现 + hermes 补丁) | |
| 373 | + | |
| 374 | +### 12.1 架构 | |
| 375 | + | |
| 376 | +``` | |
| 377 | +auto-reply.js hermes (/v1/runs) Moonshot CN | |
| 378 | + │ │ │ | |
| 379 | + ├─POST /v1/runs─────────────────→│ 返回 run_id │ | |
| 380 | + │ { input, conversation_history,│ │ | |
| 381 | + │ session_id, instructions } │ │ | |
| 382 | + │←───────────── run_id ──────────┤ │ | |
| 383 | + │ │ │ | |
| 384 | + ├─GET /v1/runs/{id}/events ─────→│ 长连 SSE │ | |
| 385 | + │ │←──API call #1───────────┤ | |
| 386 | + │←─message.delta / reasoning.delta / │ | |
| 387 | + │ tool.started / message.complete │ | |
| 388 | + │ │ ...iterating tools... │ | |
| 389 | + │ │←──API call #N───────────┤ | |
| 390 | + │←──run.completed ───────────────┤ │ | |
| 391 | +``` | |
| 392 | + | |
| 393 | +每个事件触发一段独立 utterance(微信单独发送一条消息),实现"hermes 多步思考 → 微信多条回复"。 | |
| 394 | + | |
| 395 | +### 12.2 SSE 事件类型(按消费优先级) | |
| 396 | + | |
| 397 | +| 事件 | payload | auto-reply 处理 | | |
| 398 | +|------|---------|----------------| | |
| 399 | +| `message.complete` | `{ text, already_streamed }` | **主路径**:每 turn 结束 / tool 前,emit text 作为一条 utterance(已 strip `<think>` 块,是用户可见内容) | | |
| 400 | +| `message.delta` | `{ delta }` | 累积到 textBuf,tool.started 时兜底 flush | | |
| 401 | +| `reasoning.delta` | `{ text }` | 默认丢弃(模型内心独白);`STREAM_REASONING=true` 时累积 + 加 `🧠` 前缀 emit | | |
| 402 | +| `reasoning.available` | `{ text }` | 同上,仅在 reasoning.delta 没累积时用 | | |
| 403 | +| `tool.started` | `{ tool, preview }` | flush 任何累积;`STREAM_TOOL_PROGRESS=true` 时额外 emit `🔧 工具: 参数` | | |
| 404 | +| `tool.completed` | `{ tool, duration, error }` | 忽略 | | |
| 405 | +| `run.completed` | `{ output, usage }` | emit output 作为最终 utterance + resolve Promise | | |
| 406 | +| `run.failed` | `{ error }` | reject Promise | | |
| 407 | + | |
| 408 | +### 12.3 Hermes API server 补丁(已应用到 phone) | |
| 409 | + | |
| 410 | +`/v1/runs` **upstream** 默认只挂 `stream_delta_callback`(content tokens)+ `tool_progress_callback`,**缺**: | |
| 411 | +- `reasoning_callback` → Kimi/Moonshot 的 chain-of-thought 走不出来 | |
| 412 | +- `interim_assistant_callback` → TUI/Telegram/Discord 用的多步消息机制 | |
| 413 | + | |
| 414 | +我们在 `~/hermes-agent/gateway/platforms/api_server.py` 加了两个回调 + 在 `_create_agent` 签名 / AIAgent 构造调用都补了参数。改动以注释 `# Forward reasoning_content...` 和 `# Forward interim assistant messages...` 标识。 | |
| 415 | + | |
| 416 | +**改动文件**:phone 上 `~/hermes-agent/gateway/platforms/api_server.py`(备份 `.bak`)。Mac repo 上没同步——hermes-agent 不在我们的 git repo 里。如果 hermes 升级要重新补丁。 | |
| 417 | + | |
| 418 | +### 12.4 Kimi-k2.6 在 Hermes agentic mode 的限制 ⚠️ | |
| 419 | + | |
| 420 | +实测发现:**Kimi-k2.6 在 hermes 系统提示下,`assistant_message.content` 永远是空字符串**,所有可见内容塞 `reasoning_content`。导致: | |
| 421 | + | |
| 422 | +- `message.delta` 计数 = 0(content 字段没 token) | |
| 423 | +- `interim_assistant_callback` 内部检查 `visible = strip_think(content).strip()` 为空就 return → 不 fire | |
| 424 | +- `message.complete` 事件也不出现 | |
| 425 | +- 用户只能在 `run.completed.output` 时看到最终一段总结 | |
| 426 | + | |
| 427 | +短期 workaround:**`STREAM_REASONING=true`**(默认 OFF)让 chain-of-thought 加 `🧠` 前缀作为 utterance 发出去。 | |
| 428 | + | |
| 429 | +长期方案:改 hermes system prompt / SOUL.md 让 Kimi 把"对用户说的话"写到 content 字段。 | |
| 430 | + | |
| 431 | +### 12.5 流式相关 ENV flag | |
| 432 | + | |
| 433 | +| 变量 | 默认 | 作用 | | |
| 434 | +|------|------|------| | |
| 435 | +| `STREAM_TOOL_PROGRESS` | false | 每个 `tool.started` 发一条 `🔧 工具: 参数` 进度消息 | | |
| 436 | +| `STREAM_REASONING` | false | 把 Kimi 的 reasoning_content(内心独白)加 `🧠` 前缀作为 utterance 发 | | |
| 437 | +| `HERMES_TIMEOUT` | `180000` (旧) → `600000` (新) | SSE 长连 timeout,长 agentic run 经常 3-5min | | |
| 438 | +| `HISTORY_CONTEXT_SIZE` | 8 | 喂给 hermes 的微信历史消息条数 | | |
| 439 | + | |
| 440 | +--- | |
| 441 | + | |
| 344 | 442 | **END OF HANDOFF** — 改动这份文档时记得更新顶部生成时间。 | ... | ... |
server/services/wechat/auto-reply.js
| ... | ... | @@ -54,6 +54,15 @@ const CONFIG = { |
| 54 | 54 | // 上下文长度:每次调 hermes 时回看多少条最近消息(含本次触发的那条) |
| 55 | 55 | historyContextSize: parseInt(process.env.HISTORY_CONTEXT_SIZE, 10) || 8, |
| 56 | 56 | |
| 57 | + // 流式行为:模型沉默 agentic 调用时,是否发"🔧 调用 X"进度消息 | |
| 58 | + // 默认 OFF(避免群里刷屏);开了之后每个 tool.started 都会发一条进度消息 | |
| 59 | + streamToolProgress: process.env.STREAM_TOOL_PROGRESS === 'true', | |
| 60 | + | |
| 61 | + // 是否把 reasoning_content(模型内心独白)作为消息发给用户 | |
| 62 | + // 默认 OFF — reasoning 是模型自言自语("用户要求..." 第三人称), | |
| 63 | + // 直接发给用户极其诡异。开了之后会带 "🧠 " 前缀作为思考进度发。 | |
| 64 | + streamReasoning: process.env.STREAM_REASONING === 'true', | |
| 65 | + | |
| 57 | 66 | // 通知监听(息屏兜底) |
| 58 | 67 | notificationPollIntervalMs: 3000, |
| 59 | 68 | notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', |
| ... | ... | @@ -125,10 +134,9 @@ class ReplyState { |
| 125 | 134 | |
| 126 | 135 | function log(...args) { |
| 127 | 136 | const line = `[${new Date().toISOString()}] ${args.join(' ')}`; |
| 137 | + // start-ar.sh 已经把 stdout 重定向到 auto-reply.log,console.log 即写入文件。 | |
| 138 | + // 不再额外 fs.appendFileSync — 同步 I/O 在高频流式场景下会卡死 node。 | |
| 128 | 139 | console.log(line); |
| 129 | - try { | |
| 130 | - fs.appendFileSync(CONFIG.logFile, line + '\n'); | |
| 131 | - } catch {} | |
| 132 | 140 | } |
| 133 | 141 | |
| 134 | 142 | // ═══════════════════════════════════════════════════════════════ |
| ... | ... | @@ -207,34 +215,57 @@ async function callHermesRunStream(messages, sessionId, onUtterance) { |
| 207 | 215 | timeout: CONFIG.hermesTimeoutMs, |
| 208 | 216 | }, res => { |
| 209 | 217 | let buf = ''; |
| 210 | - let textBuf = ''; | |
| 218 | + let textBuf = ''; // 累积 message.delta(assistant content tokens) | |
| 219 | + let reasoningBuf = ''; // 累积 reasoning.delta(Kimi 的 chain-of-thought tokens) | |
| 211 | 220 | let utteranceIdx = 0; |
| 212 | - let sendChain = Promise.resolve(); // 串行队列:每个 flush 在前一个完成后执行 | |
| 221 | + let sendChain = Promise.resolve(); // 串行队列:每个 utterance 在前一个完成后执行 | |
| 213 | 222 | let finalResult = {}; |
| 214 | 223 | |
| 215 | - const flush = (isFinal) => { | |
| 216 | - const text = textBuf.trim(); | |
| 217 | - textBuf = ''; | |
| 218 | - if (!text) return; | |
| 219 | - const idx = utteranceIdx++; | |
| 220 | - // 关键:链式 append,不等待 | |
| 221 | - sendChain = sendChain | |
| 222 | - .then(() => onUtterance(text, idx, !!isFinal)) | |
| 223 | - .catch(e => log('[onUtterance error]', e.message)); | |
| 224 | - }; | |
| 225 | - | |
| 226 | - // diagnostic counters | |
| 227 | - const evCount = { 'message.delta': 0 }; | |
| 228 | - let lastDiagLogAt = Date.now(); | |
| 224 | + // diagnostic counters — message.delta / reasoning.delta 都是高频 token 流, | |
| 225 | + // 必须节流否则 600+ events × sync 文件 IO 会卡死 node | |
| 226 | + const evCount = {}; | |
| 227 | + let lastDiagLogAt = 0; | |
| 228 | + const HIGH_FREQ_EVENTS = new Set(['message.delta', 'reasoning.delta']); | |
| 229 | 229 | const diagLog = (evt) => { |
| 230 | 230 | evCount[evt] = (evCount[evt] || 0) + 1; |
| 231 | 231 | const now = Date.now(); |
| 232 | - if (evt !== 'message.delta' || now - lastDiagLogAt > 10000) { | |
| 233 | - log('[STREAM EV]', evt, 'counts=', JSON.stringify(evCount), 'textBuf.len=' + textBuf.length); | |
| 232 | + const isHighFreq = HIGH_FREQ_EVENTS.has(evt); | |
| 233 | + if (!isHighFreq || now - lastDiagLogAt > 5000) { | |
| 234 | + log('[STREAM EV]', evt, 'counts=', JSON.stringify(evCount), | |
| 235 | + 'textBuf.len=' + textBuf.length, 'reasoningBuf.len=' + reasoningBuf.length); | |
| 234 | 236 | lastDiagLogAt = now; |
| 235 | 237 | } |
| 236 | 238 | }; |
| 237 | 239 | |
| 240 | + // 跟踪上一次实际发出去的文本,避免 reasoning.available 和 run.completed 重复发同样内容 | |
| 241 | + let lastEmitted = ''; | |
| 242 | + const emit = (text, isFinal) => { | |
| 243 | + const trimmed = (text || '').trim(); | |
| 244 | + if (!trimmed) return; | |
| 245 | + // 简单 dedupe:完全相同 / 完全包含 / 被包含都跳过 | |
| 246 | + if (trimmed === lastEmitted) return; | |
| 247 | + if (lastEmitted && (trimmed.includes(lastEmitted) || lastEmitted.includes(trimmed))) return; | |
| 248 | + lastEmitted = trimmed; | |
| 249 | + const idx = utteranceIdx++; | |
| 250 | + sendChain = sendChain | |
| 251 | + .then(() => onUtterance(trimmed, idx, !!isFinal)) | |
| 252 | + .catch(e => log('[onUtterance error]', e.message)); | |
| 253 | + }; | |
| 254 | + | |
| 255 | + // 重新定义 flush 让它走 emit 通道 | |
| 256 | + const flushTextBuf = (isFinal) => { | |
| 257 | + const t = textBuf.trim(); | |
| 258 | + textBuf = ''; | |
| 259 | + if (t) emit(t, isFinal); | |
| 260 | + }; | |
| 261 | + | |
| 262 | + // 把累积的 reasoning(Kimi chain-of-thought)作为一段独立 utterance flush | |
| 263 | + const flushReasoningBuf = (isFinal) => { | |
| 264 | + const t = reasoningBuf.trim(); | |
| 265 | + reasoningBuf = ''; | |
| 266 | + if (t) emit(t, isFinal); | |
| 267 | + }; | |
| 268 | + | |
| 238 | 269 | res.setEncoding('utf-8'); |
| 239 | 270 | res.on('data', chunk => { |
| 240 | 271 | buf += chunk; |
| ... | ... | @@ -248,17 +279,61 @@ async function callHermesRunStream(messages, sessionId, onUtterance) { |
| 248 | 279 | try { ev = JSON.parse(line.substring(6)); } catch (_) { continue; } |
| 249 | 280 | diagLog(ev.event); |
| 250 | 281 | switch (ev.event) { |
| 282 | + case 'message.complete': | |
| 283 | + // 🎯 主路径:interim_assistant_callback(gateway 平台用的事件) | |
| 284 | + // 在每个 turn 末尾、tool 之前 fire,携带模型本轮**给用户看的** | |
| 285 | + // 可见 content(已 strip <think> 块)。这是真正的"多步消息"信号。 | |
| 286 | + // already_streamed=true 表示 content 已通过 message.delta 流过,避免重复 | |
| 287 | + if (!ev.already_streamed) { | |
| 288 | + emit(ev.text, false); | |
| 289 | + // 同 turn 的 message.delta 已被 interim 覆盖,重置 textBuf | |
| 290 | + textBuf = ''; | |
| 291 | + } | |
| 292 | + // reasoning 同 turn 的 chain-of-thought 已落地,清掉避免泄露给用户 | |
| 293 | + reasoningBuf = ''; | |
| 294 | + break; | |
| 251 | 295 | case 'message.delta': |
| 296 | + // 模型 content tokens — 主要靠 message.complete 拿干净文本, | |
| 297 | + // message.delta 累积仅做兜底(如果 interim 没 fire) | |
| 252 | 298 | textBuf += (ev.delta || ''); |
| 253 | 299 | break; |
| 300 | + case 'reasoning.delta': | |
| 301 | + // Kimi/Moonshot 的 chain-of-thought tokens — 模型内心独白, | |
| 302 | + // 默认不发给用户(会出现第三人称"用户要求..."的诡异内容)。 | |
| 303 | + // STREAM_REASONING=true 时累积,flush 时加 "🧠 " 前缀 | |
| 304 | + if (CONFIG.streamReasoning) { | |
| 305 | + reasoningBuf += (ev.text || ''); | |
| 306 | + } | |
| 307 | + break; | |
| 308 | + case 'reasoning.available': | |
| 309 | + // 整段 turn 末尾的 thinking,同样默认不发 | |
| 310 | + if (CONFIG.streamReasoning && !reasoningBuf.trim() && ev.text) { | |
| 311 | + emit('🧠 ' + ev.text, false); | |
| 312 | + } | |
| 313 | + break; | |
| 254 | 314 | case 'tool.started': |
| 255 | - // turn 边界:上一段 assistant 文本完成 | |
| 256 | - flush(false); | |
| 315 | + // turn 边界兜底:interim_assistant_callback 已 emit 过本轮 content, | |
| 316 | + // textBuf 仅在 interim 未触发时用 | |
| 317 | + if (CONFIG.streamReasoning) { | |
| 318 | + const r = reasoningBuf.trim(); | |
| 319 | + reasoningBuf = ''; | |
| 320 | + if (r) emit('🧠 ' + r, false); | |
| 321 | + } | |
| 322 | + flushTextBuf(false); | |
| 323 | + if (CONFIG.streamToolProgress) { | |
| 324 | + const toolName = ev.tool || '?'; | |
| 325 | + const preview = (ev.preview || '').toString().split('\n')[0].substring(0, 60); | |
| 326 | + emit(preview ? `🔧 ${toolName}: ${preview}` : `🔧 ${toolName}`, false); | |
| 327 | + } | |
| 257 | 328 | break; |
| 258 | 329 | case 'run.completed': |
| 259 | - flush(true); | |
| 330 | + // 收尾:emit 最终 output(如果跟 last interim 重复会被 dedupe 吃掉) | |
| 331 | + if (ev.output) { | |
| 332 | + emit(ev.output, true); | |
| 333 | + } else { | |
| 334 | + flushTextBuf(true); | |
| 335 | + } | |
| 260 | 336 | finalResult = { output: ev.output, usage: ev.usage }; |
| 261 | - // 等串行队列 drain 完再 resolve | |
| 262 | 337 | sendChain.then(() => resolve(finalResult)); |
| 263 | 338 | break; |
| 264 | 339 | case 'run.failed': |
| ... | ... | @@ -267,7 +342,7 @@ async function callHermesRunStream(messages, sessionId, onUtterance) { |
| 267 | 342 | case 'run.cancelled': |
| 268 | 343 | reject(new Error('run cancelled')); |
| 269 | 344 | break; |
| 270 | - // tool.completed / reasoning.available / approval.* 忽略 | |
| 345 | + // tool.completed / approval.* 忽略 | |
| 271 | 346 | } |
| 272 | 347 | } |
| 273 | 348 | } | ... | ... |