Commit 4faf9c0cf520dd9d38d8777ff872546a2b01b44b
1 parent
4fec6818
feat: 方案 D - 流式消费 hermes /v1/runs SSE,每个 tool 边界 → 一条独立微信消息
旧路径:/v1/chat/completions 同步阻塞拿完整 reply → 一条微信消息。
长 agent loop 用户体验差("稍等" 之后再无下文)。
新路径:
1) POST /v1/runs { input, conversation_history, session_id, instructions }
拿 run_id 立即返回
2) GET /v1/runs/{run_id}/events SSE 长连接,消费事件:
- message.delta { delta } → 累积到 textBuf
- tool.started → flush(false): textBuf → onUtterance
- run.completed { output } → flush(true): 最后一段
- run.failed/cancelled → reject
3) onUtterance 在串行 Promise 队列(sendChain)里跑:
- idx===0 的 utterance 带 quoteText + atSender(群里 @ 原消息)
- 后续 idx>0 仅文本,不重复 quote/@
- 发完后 await sleep(14000 或 9000) 等 A11y 跑完整一轮 chat-list-click
→ quote → @ → type → send,避免下一个 broadcast 打断当前 FSM 状态
handleMessage:
- 移除旧的 this.replyGenerator + sendTextMessageBroadcast 同步路径
- 改成 callHermesRunStream(messages, sessionId, onUtterance)
- state.markReplied 在 run 整体完成后才标记(避免中间步骤已发但失败时重发漏掉)
诊断:增加 [STREAM EV] 日志统计事件类型 + textBuf 长度,
方便诊断"agent loop 跑很久但没 utterance"等情况(实测发现
深度探索时 hermes 几乎只调 tool 不发 text,多步分割效果依赖 LLM 行为)。
keepalive.sh: HERMES_TIMEOUT 180s → 600s,长 agentic run 经常 3-5min。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
2 changed files
with
194 additions
and
46 deletions
server/services/wechat/auto-reply.js
| ... | ... | @@ -136,7 +136,155 @@ function log(...args) { |
| 136 | 136 | // ═══════════════════════════════════════════════════════════════ |
| 137 | 137 | |
| 138 | 138 | /** |
| 139 | - * 调用 hermes-agent API (OpenAI-compatible) 生成回复 | |
| 139 | + * 调用 hermes-agent 的 /v1/runs 流式 API,按 agent loop 的 tool 边界 | |
| 140 | + * 把每一段 assistant 文本作为独立"utterance"通过 onUtterance 回调推出。 | |
| 141 | + * | |
| 142 | + * 事件流(SSE on /v1/runs/{id}/events): | |
| 143 | + * message.delta { delta: "字符片段" } → 累积到 textBuf | |
| 144 | + * tool.started { tool, preview } → flush textBuf 作为一条 utterance | |
| 145 | + * tool.completed → 下一个 turn 准备开始 | |
| 146 | + * reasoning.available → 不转发(hermes 思考过程) | |
| 147 | + * run.completed { output, usage } → 最终 flush | |
| 148 | + * run.failed / run.cancelled → reject | |
| 149 | + * | |
| 150 | + * @param {Array} messages OpenAI 格式(含 system / user / assistant 多轮) | |
| 151 | + * @param {string} sessionId hermes session(每个 talker 独立) | |
| 152 | + * @param {Function} onUtterance async (text, index, isFinal) => Promise<void> | |
| 153 | + * 串行调用;返回的 Promise 完成前不会发下一条 | |
| 154 | + * @returns {Promise<{output?, usage?}>} | |
| 155 | + */ | |
| 156 | +async function callHermesRunStream(messages, sessionId, onUtterance) { | |
| 157 | + const userMsg = messages[messages.length - 1]?.content || ''; | |
| 158 | + const systemMsg = messages.find(m => m.role === 'system'); | |
| 159 | + const history = messages.slice(0, -1) | |
| 160 | + .filter(m => m.role !== 'system') | |
| 161 | + .map(m => ({ role: m.role, content: m.content })); | |
| 162 | + | |
| 163 | + // 1) POST /v1/runs 拿 run_id | |
| 164 | + const startBody = JSON.stringify({ | |
| 165 | + input: userMsg, | |
| 166 | + conversation_history: history, | |
| 167 | + session_id: sessionId, | |
| 168 | + instructions: systemMsg?.content, | |
| 169 | + }); | |
| 170 | + | |
| 171 | + const runId = await new Promise((resolve, reject) => { | |
| 172 | + const req = http.request({ | |
| 173 | + hostname: CONFIG.hermesHost, | |
| 174 | + port: CONFIG.hermesPort, | |
| 175 | + path: '/v1/runs', | |
| 176 | + method: 'POST', | |
| 177 | + headers: { | |
| 178 | + 'Content-Type': 'application/json', | |
| 179 | + 'Content-Length': Buffer.byteLength(startBody), | |
| 180 | + }, | |
| 181 | + timeout: 15000, | |
| 182 | + }, res => { | |
| 183 | + let buf = ''; | |
| 184 | + res.on('data', c => buf += c.toString()); | |
| 185 | + res.on('end', () => { | |
| 186 | + try { | |
| 187 | + const j = JSON.parse(buf); | |
| 188 | + if (j.run_id) return resolve(j.run_id); | |
| 189 | + reject(new Error('No run_id in response: ' + buf.substring(0, 200))); | |
| 190 | + } catch (e) { reject(e); } | |
| 191 | + }); | |
| 192 | + }); | |
| 193 | + req.on('error', reject); | |
| 194 | + req.on('timeout', () => { req.destroy(); reject(new Error('start timeout')); }); | |
| 195 | + req.write(startBody); | |
| 196 | + req.end(); | |
| 197 | + }); | |
| 198 | + | |
| 199 | + log('[HERMES] Run started:', runId, 'session:', sessionId); | |
| 200 | + | |
| 201 | + // 2) GET /v1/runs/{runId}/events (SSE) | |
| 202 | + return new Promise((resolve, reject) => { | |
| 203 | + const req = http.get({ | |
| 204 | + hostname: CONFIG.hermesHost, | |
| 205 | + port: CONFIG.hermesPort, | |
| 206 | + path: `/v1/runs/${runId}/events`, | |
| 207 | + timeout: CONFIG.hermesTimeoutMs, | |
| 208 | + }, res => { | |
| 209 | + let buf = ''; | |
| 210 | + let textBuf = ''; | |
| 211 | + let utteranceIdx = 0; | |
| 212 | + let sendChain = Promise.resolve(); // 串行队列:每个 flush 在前一个完成后执行 | |
| 213 | + let finalResult = {}; | |
| 214 | + | |
| 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(); | |
| 229 | + const diagLog = (evt) => { | |
| 230 | + evCount[evt] = (evCount[evt] || 0) + 1; | |
| 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); | |
| 234 | + lastDiagLogAt = now; | |
| 235 | + } | |
| 236 | + }; | |
| 237 | + | |
| 238 | + res.setEncoding('utf-8'); | |
| 239 | + res.on('data', chunk => { | |
| 240 | + buf += chunk; | |
| 241 | + let sepIdx; | |
| 242 | + while ((sepIdx = buf.indexOf('\n\n')) >= 0) { | |
| 243 | + const block = buf.substring(0, sepIdx); | |
| 244 | + buf = buf.substring(sepIdx + 2); | |
| 245 | + for (const line of block.split('\n')) { | |
| 246 | + if (!line.startsWith('data: ')) continue; | |
| 247 | + let ev; | |
| 248 | + try { ev = JSON.parse(line.substring(6)); } catch (_) { continue; } | |
| 249 | + diagLog(ev.event); | |
| 250 | + switch (ev.event) { | |
| 251 | + case 'message.delta': | |
| 252 | + textBuf += (ev.delta || ''); | |
| 253 | + break; | |
| 254 | + case 'tool.started': | |
| 255 | + // turn 边界:上一段 assistant 文本完成 | |
| 256 | + flush(false); | |
| 257 | + break; | |
| 258 | + case 'run.completed': | |
| 259 | + flush(true); | |
| 260 | + finalResult = { output: ev.output, usage: ev.usage }; | |
| 261 | + // 等串行队列 drain 完再 resolve | |
| 262 | + sendChain.then(() => resolve(finalResult)); | |
| 263 | + break; | |
| 264 | + case 'run.failed': | |
| 265 | + reject(new Error(ev.error || 'run failed')); | |
| 266 | + break; | |
| 267 | + case 'run.cancelled': | |
| 268 | + reject(new Error('run cancelled')); | |
| 269 | + break; | |
| 270 | + // tool.completed / reasoning.available / approval.* 忽略 | |
| 271 | + } | |
| 272 | + } | |
| 273 | + } | |
| 274 | + }); | |
| 275 | + res.on('end', () => { | |
| 276 | + flush(true); | |
| 277 | + sendChain.then(() => resolve(finalResult)); | |
| 278 | + }); | |
| 279 | + res.on('error', reject); | |
| 280 | + }); | |
| 281 | + req.on('error', reject); | |
| 282 | + req.on('timeout', () => { req.destroy(); reject(new Error('events stream timeout')); }); | |
| 283 | + }); | |
| 284 | +} | |
| 285 | + | |
| 286 | +/** | |
| 287 | + * 调用 hermes-agent API (OpenAI-compatible) 生成回复 — 老的非流式同步 API | |
| 140 | 288 | * |
| 141 | 289 | * @param {Array<{role:string,content:string}>} messages - OpenAI messages 格式 |
| 142 | 290 | * @param {string} sessionId - 会话 ID(每个 talker 独立,作为 X-Hermes-Session-Id) |
| ... | ... | @@ -527,64 +675,64 @@ class AutoReplyBot { |
| 527 | 675 | log('[HISTORY] fetch failed:', e.message); |
| 528 | 676 | } |
| 529 | 677 | |
| 530 | - let replyText; | |
| 531 | - try { | |
| 532 | - replyText = await this.replyGenerator({ | |
| 533 | - type: replyType, | |
| 534 | - talker, | |
| 535 | - displayName, | |
| 536 | - content, | |
| 537 | - senderName, | |
| 538 | - history, | |
| 539 | - raw: msg.raw, | |
| 540 | - }); | |
| 541 | - } catch (e) { | |
| 542 | - log('[REPLY GEN ERROR]', e.message); | |
| 543 | - return; | |
| 544 | - } | |
| 545 | - | |
| 546 | - if (!replyText) { | |
| 547 | - log('[NO REPLY] Generator returned null'); | |
| 548 | - return; | |
| 549 | - } | |
| 550 | - | |
| 551 | - if (replyText.length > CONFIG.maxReplyLength) { | |
| 552 | - replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; | |
| 553 | - } | |
| 554 | - | |
| 555 | - // 群里被 @ 时,给 broadcast 带上 quoteText(引用触发消息)+ atSender(回复里 @ 对方) | |
| 556 | - const sendOpts = { toUserDisplayName: displayName }; | |
| 678 | + // 流式 hermes 调用直接在 onUtterance 里发送,不再先生成完整文本 | |
| 679 | + // 群里被 @ 时,第一条回复带 quote + atSender;多步流式回复后续条仅文本 | |
| 680 | + const baseSendOpts = { toUserDisplayName: displayName }; | |
| 681 | + let firstSendOpts = { ...baseSendOpts }; | |
| 557 | 682 | if (replyType === 'group-at' || replyType === 'group-quote') { |
| 558 | - // 群消息 content 的格式是 "senderWxid:\n实际文本",提取实际文本作为 quote substring | |
| 559 | 683 | let bubbleText = content; |
| 560 | 684 | const colonIdx = bubbleText.indexOf(':\n'); |
| 561 | 685 | if (colonIdx > 0) bubbleText = bubbleText.substring(colonIdx + 2); |
| 562 | - // 取前若干字符作为 substring 匹配;含 XML/特殊字符的部分(如 refermsg)跳过 | |
| 563 | 686 | const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim(); |
| 564 | - if (trimmed) sendOpts.quoteText = trimmed.substring(0, 30); | |
| 565 | - // senderName 是 wxid(如 'yibooo'),群里 @ 面板显示的是昵称(如 '火星人'), | |
| 566 | - // 必须查 rcontact 拿到昵称,否则 A11y 在选人面板里搜不到 | |
| 687 | + if (trimmed) firstSendOpts.quoteText = trimmed.substring(0, 30); | |
| 567 | 688 | if (senderName) { |
| 568 | 689 | try { |
| 569 | 690 | const senderNick = await this.tracker.getContactNickname(senderName); |
| 570 | - sendOpts.atSender = senderNick || senderName; | |
| 691 | + firstSendOpts.atSender = senderNick || senderName; | |
| 571 | 692 | } catch (_) { |
| 572 | - sendOpts.atSender = senderName; | |
| 693 | + firstSendOpts.atSender = senderName; | |
| 573 | 694 | } |
| 574 | 695 | } |
| 575 | - log('[GROUP] quote=' + (sendOpts.quoteText || '-') + ' atSender=' + (sendOpts.atSender || '-')); | |
| 696 | + log('[GROUP] first-reply quote=' + (firstSendOpts.quoteText || '-') | |
| 697 | + + ' atSender=' + (firstSendOpts.atSender || '-')); | |
| 576 | 698 | } |
| 577 | 699 | |
| 578 | - try { | |
| 579 | - const result = await sendTextMessageBroadcast(talker, replyText, sendOpts); | |
| 580 | - if (result.ok) { | |
| 581 | - log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60)); | |
| 582 | - this.state.markReplied(msgId, talker); | |
| 583 | - } else { | |
| 584 | - log('[SEND FAILED]', talker, result.error); | |
| 700 | + // 走流式:每个 hermes turn → 一条独立微信消息 | |
| 701 | + // 第一条带 quote + atSender,后续条仅文本(避免重复引用 / @) | |
| 702 | + const onUtterance = async (text, idx, isFinal) => { | |
| 703 | + // 单条限长保护 | |
| 704 | + let toSend = text; | |
| 705 | + if (toSend.length > CONFIG.maxReplyLength) { | |
| 706 | + toSend = toSend.substring(0, CONFIG.maxReplyLength) + '...'; | |
| 707 | + } | |
| 708 | + const opts = (idx === 0) ? firstSendOpts : baseSendOpts; | |
| 709 | + log('[UTTER]', idx, isFinal ? '(final)' : '', toSend.substring(0, 60)); | |
| 710 | + try { | |
| 711 | + const r = await sendTextMessageBroadcast(talker, toSend, opts); | |
| 712 | + if (r.ok) { | |
| 713 | + log('[SENT]', talker, `#${idx}`, toSend.substring(0, 60)); | |
| 714 | + } else { | |
| 715 | + log('[SEND FAILED]', talker, r.error); | |
| 716 | + } | |
| 717 | + } catch (e) { | |
| 718 | + log('[SEND ERROR]', e.message); | |
| 585 | 719 | } |
| 720 | + // 等 A11y 跑完 chat-list-click → quote → @ → type → send 完整一轮 | |
| 721 | + // 不等的话下一条 broadcast 会打断 A11y 当前状态 | |
| 722 | + await sleep(idx === 0 ? 14000 : 9000); // 第一条带 quote+@ 多花时间 | |
| 723 | + }; | |
| 724 | + | |
| 725 | + try { | |
| 726 | + const sessionId = `wx-${talker}`; | |
| 727 | + const messages = buildHermesMessages({ | |
| 728 | + type: replyType, talker, displayName, content, senderName, history, raw: msg.raw, | |
| 729 | + }); | |
| 730 | + log('[HERMES] Streaming for', talker, 'turns=' + messages.length); | |
| 731 | + const result = await callHermesRunStream(messages, sessionId, onUtterance); | |
| 732 | + log('[HERMES] Run done. usage=' + JSON.stringify(result.usage || {})); | |
| 733 | + this.state.markReplied(msgId, talker); | |
| 586 | 734 | } catch (e) { |
| 587 | - log('[SEND ERROR]', e.message); | |
| 735 | + log('[STREAM ERROR]', e.message); | |
| 588 | 736 | } |
| 589 | 737 | } |
| 590 | 738 | ... | ... |
wechat_plain/scripts/local-testkit/keepalive.sh
| ... | ... | @@ -27,7 +27,7 @@ CHECK_INTERVAL_SEC="${CHECK_INTERVAL_SEC:-15}" |
| 27 | 27 | # auto-reply 启动用的 env(避开 start-ar.sh 的 shell hook 污染) |
| 28 | 28 | MY_WXID="${MY_WXID:-wxid_51ap3m6p9xs722}" |
| 29 | 29 | MY_NICKNAME="${MY_NICKNAME:-小火子}" |
| 30 | -HERMES_TIMEOUT_MS="${HERMES_TIMEOUT_MS:-180000}" | |
| 30 | +HERMES_TIMEOUT_MS="${HERMES_TIMEOUT_MS:-600000}" | |
| 31 | 31 | HISTORY_CONTEXT_SIZE="${HISTORY_CONTEXT_SIZE:-8}" |
| 32 | 32 | |
| 33 | 33 | log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } |
| ... | ... | @@ -108,7 +108,7 @@ start_auto_reply() { |
| 108 | 108 | MY_NICKNAME=\"${MY_NICKNAME:-小火子}\" \ |
| 109 | 109 | HERMES_HOST=127.0.0.1 \ |
| 110 | 110 | HERMES_PORT=$HERMES_PORT \ |
| 111 | - HERMES_TIMEOUT=${HERMES_TIMEOUT_MS:-180000} \ | |
| 111 | + HERMES_TIMEOUT=${HERMES_TIMEOUT_MS:-600000} \ | |
| 112 | 112 | NOTIFICATION_ENABLED=true \ |
| 113 | 113 | HISTORY_CONTEXT_SIZE=${HISTORY_CONTEXT_SIZE:-8} \ |
| 114 | 114 | nohup /data/data/com.termux/files/usr/bin/node auto-reply.js \ | ... | ... |