diff --git a/console.md b/console.md deleted file mode 100644 index 26df470..0000000 --- a/console.md +++ /dev/null @@ -1,401 +0,0 @@ -16:43:01 [qqbot] [qqbot:default] Connecting to wss://api.sgroup.qq.com/websocket -16:43:01 [qqbot] [qqbot:default] WebSocket connected -16:43:01 [qqbot] [qqbot:default] Message processor started -16:43:01 [qqbot] [qqbot-api] Background token refresh started -16:43:01 [qqbot] [qqbot-api] Token valid, next refresh in 6891s -16:43:01 [qqbot] [qqbot:default] Received op=10 t=undefined -16:43:01 [qqbot] [qqbot:default] Hello received -16:43:01 [qqbot] [qqbot:default] Sending identify with intents: 1107300352 (群聊+私信+频道) -16:43:01 [qqbot] [qqbot:default] Received op=0 t=READY -16:43:01 [qqbot] [qqbot:default] Ready with 群聊+私信+频道, session: b879fa59-5251-4a98-b50a-b88e46d174ed -16:43:01 [session-store] Saved session for default: sessionId=b879fa59-5251-4a98-b50a-b88e46d174ed, lastSeq=1 -16:43:01 [qqbot] [qqbot:default] Gateway ready -16:43:01 [ws] ← open remoteAddr=127.0.0.1 conn=225c0e81…5c15 -16:43:02 [ws] ← open remoteAddr=127.0.0.1 conn=bc20a8ce…0a0f -16:43:02 [ws] ← connect client=openclaw-control-ui version=dev mode=webchat clientId=openclaw-control-ui platform=MacIntel auth=device-token conn=225c0e81…5c15 -16:43:02 [ws] webchat connected conn=225c0e81-0f25-41cb-b22f-96645dd55c15 remote=127.0.0.1 client=openclaw-control-ui webchat vdev -16:43:02 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=2 -16:43:02 [ws] → event health seq=2 clients=1 presenceVersion=2 healthVersion=3 -16:43:02 [ws] ⇄ res ✓ agent.identity.get 15ms id=a2c56be6…9e47 -16:43:02 [ws] ⇄ res ✓ agents.list 5ms id=3765ad30…e172 -16:43:02 [ws] ⇄ res ✓ sessions.list 2ms id=486adb2f…aa4e -16:43:02 [ws] ← open remoteAddr=127.0.0.1 conn=46458cc1…e736 -16:43:02 [ws] ← open remoteAddr=127.0.0.1 conn=5da3d69e…5ff8 -16:43:02 [ws] ⇄ res ✓ device.pair.list 67ms conn=225c0e81…5c15 id=1cc7c4d9…4df1 -16:43:02 [ws] ⇄ res ✓ node.list 71ms id=2114b4c2…b2bc -16:43:02 [ws] ⇄ res ✓ chat.history 116ms id=5d02d989…c6de -16:43:02 [ws] ← connect client=openclaw-control-ui version=dev mode=webchat clientId=openclaw-control-ui platform=MacIntel auth=device-token conn=bc20a8ce…0a0f -16:43:02 [ws] webchat connected conn=bc20a8ce-f484-4990-8b68-2d47e7110a0f remote=127.0.0.1 client=openclaw-control-ui webchat vdev -16:43:02 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=3 -16:43:02 [ws] → event health seq=3 clients=2 presenceVersion=3 healthVersion=4 -16:43:02 [ws] ⇄ res ✓ agent.identity.get 1ms id=bfa4ae92…444a -16:43:02 [ws] ⇄ res ✓ agents.list 0ms id=fae89b26…1603 -16:43:02 [ws] ⇄ res ✓ sessions.list 2ms id=4209d85a…a3f1 -16:43:02 [ws] ⇄ res ✓ chat.history 20ms id=4d88edad…42b2 -16:43:02 [ws] ⇄ res ✓ device.pair.list 30ms id=b2c11fbd…54c0 -16:43:02 [ws] ⇄ res ✓ node.list 34ms id=876f77e9…2054 -16:43:02 [ws] ← connect client=openclaw-control-ui version=dev mode=webchat clientId=openclaw-control-ui platform=MacIntel auth=device-token conn=46458cc1…e736 -16:43:02 [ws] webchat connected conn=46458cc1-bece-4b13-9019-b38a4a35e736 remote=127.0.0.1 client=openclaw-control-ui webchat vdev -16:43:02 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=4 -16:43:02 [ws] → event health seq=4 clients=3 presenceVersion=4 healthVersion=5 -16:43:02 [ws] ← connect client=openclaw-control-ui version=dev mode=webchat clientId=openclaw-control-ui platform=MacIntel auth=device-token conn=5da3d69e…5ff8 -16:43:02 [ws] webchat connected conn=5da3d69e-fe34-46d3-a334-0a64d1165ff8 remote=127.0.0.1 client=openclaw-control-ui webchat vdev -16:43:02 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=5 -16:43:02 [ws] → event health seq=5 clients=4 presenceVersion=5 healthVersion=6 -16:43:02 [ws] ⇄ res ✓ agent.identity.get 5ms conn=46458cc1…e736 id=662c1327…1825 -16:43:02 [ws] ⇄ res ✓ agents.list 1ms id=fca54a59…274f -16:43:02 [ws] ⇄ res ✓ sessions.list 5ms id=46491b9c…ad0e -16:43:02 [ws] ⇄ res ✓ chat.history 114ms id=79b90e8b…8f1f -16:43:02 [ws] ⇄ res ✓ agent.identity.get 6ms conn=5da3d69e…5ff8 id=9d2035c5…2085 -16:43:02 [ws] ⇄ res ✓ agents.list 0ms id=0dcab6b0…4454 -16:43:02 [ws] ⇄ res ✓ sessions.list 1ms id=3d659adc…c458 -16:43:02 [ws] ⇄ res ✓ device.pair.list 57ms id=ed58f910…2276 -16:43:02 [ws] ⇄ res ✓ node.list 222ms conn=46458cc1…e736 id=052f8dd7…5eb8 -16:43:02 [ws] ⇄ res ✓ device.pair.list 216ms id=15036c28…aeb8 -16:43:02 [ws] ⇄ res ✓ node.list 81ms conn=5da3d69e…5ff8 id=0ae64f32…9c76 -16:43:15 [ws] ← open remoteAddr=127.0.0.1 conn=323cc0ed…16ff -16:43:15 [ws] ← connect client=openclaw-control-ui version=dev mode=webchat clientId=openclaw-control-ui platform=MacIntel auth=device-token -16:43:15 [ws] webchat connected conn=323cc0ed-9652-4b5d-be48-38f3630c16ff remote=127.0.0.1 client=openclaw-control-ui webchat vdev -16:43:15 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:43:15 [ws] → event health seq=6 clients=5 presenceVersion=6 healthVersion=7 -16:43:15 [ws] ⇄ res ✓ agent.identity.get 0ms id=fbe3c892…59db -16:43:15 [ws] ⇄ res ✓ agents.list 1ms id=8e48ad4e…a979 -16:43:15 [ws] ⇄ res ✓ sessions.list 4ms id=95813123…be20 -16:43:15 [ws] ⇄ res ✓ chat.history 37ms id=4f9bcddd…77cb -16:43:15 [ws] ⇄ res ✓ node.list 43ms id=5bc9dc66…afe0 -16:43:15 [ws] ⇄ res ✓ device.pair.list 45ms id=dc2c4ea8…4c94 -16:43:16 [ws] ⇄ res ✓ node.list 3ms conn=225c0e81…5c15 id=f27f7c56…64ae -16:43:16 [ws] ⇄ res ✓ node.list 6ms conn=46458cc1…e736 id=cd62e734…442b -16:43:16 [ws] ⇄ res ✓ node.list 19ms conn=5da3d69e…5ff8 id=b5bbde31…90e2 -16:43:16 [ws] ⇄ res ✓ node.list 29ms conn=bc20a8ce…0a0f id=a81eb5c7…420e -16:43:17 [session-store] Saved session for default: sessionId=b879fa59-5251-4a98-b50a-b88e46d174ed, lastSeq=2 -16:43:17 [qqbot] [qqbot:default] Received op=0 t=C2C_MESSAGE_CREATE -16:43:17 [known-users] Loaded 1 users from file -16:43:17 [known-users] Updated user 207A5B8339D01F6582911C014668B77B, interactions: 26 -16:43:17 [qqbot] [qqbot:default] Message enqueued, queue size: 1 -16:43:17 [qqbot] [qqbot:default] Processing message from 207A5B8339D01F6582911C014668B77B: 1分钟后提醒我喝水 -16:43:17 [qqbot] [qqbot:default] Stream enabled: false -16:43:17 [qqbot] [qqbot:default] Stream support: false (type=c2c, enabled=false) -16:43:17 [diagnostic] lane enqueue: lane=session:agent:main:main queueSize=1 -16:43:17 [diagnostic] lane dequeue: lane=session:agent:main:main waitMs=5 queueSize=0 -16:43:17 [diagnostic] lane enqueue: lane=main queueSize=1 -16:43:17 [diagnostic] lane dequeue: lane=main waitMs=1 queueSize=0 -16:43:17 [agent/embedded] embedded run start: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f sessionId=ba108bac-c99c-498f-b33f-06245ade1363 provider=qwen-portal model=coder-model thinking=off messageChannel=qqbot -16:43:17 [diagnostic] session state: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 sessionKey=unknown prev=idle new=processing reason="run_started" queueDepth=0 -16:43:17 [diagnostic] run registered: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 totalActive=1 -16:43:17 [agent/embedded] embedded run prompt start: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f sessionId=ba108bac-c99c-498f-b33f-06245ade1363 -16:43:17 [agent/embedded] embedded run agent start: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f -16:43:17 [ws] → event agent seq=7 clients=5 run=69e77a59…1c8f agent=main session=main stream=lifecycle aseq=1 phase=start -16:43:20 [ws] ⇄ res ✓ node.list 5ms conn=323cc0ed…16ff id=b56be68f…015b -16:43:22 [known-users] Saved 1 users to file -16:43:25 [ws] ⇄ res ✓ node.list 1ms id=834715ce…caa4 -16:43:30 [ws] ⇄ res ✓ node.list 2ms id=1948a1de…9895 -16:43:31 [ws] → event tick seq=8 clients=5 dropIfSlow=true -16:43:31 [ws] → event agent seq=9 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=2 text=我 -16:43:31 [ws] → event chat seq=10 clients=5 dropIfSlow=true -16:43:31 [ws] → event agent seq=11 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=3 text=我来为您设置一个 -16:43:31 [ws] → event agent seq=12 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=4 text=我来为您设置一个1分钟后提醒喝水 -16:43:31 [ws] → event agent seq=13 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=5 text=我来为您设置一个1分钟后提醒喝水的任务: -16:43:31 [ws] → event chat seq=14 clients=5 dropIfSlow=true -16:43:32 [agent/embedded] embedded run tool start: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f tool=exec toolCallId=call_fcf2dfd8801340ea80cb052f -16:43:32 [qqbot] [qqbot:default] deliver called, kind: block, payload keys: text, replyToId, audioAsVoice -16:43:32 [qqbot] [qqbot:default] deliver: updated streamBuffer, replyText=20, total=20 -16:43:32 [qqbot] [qqbot:default] Sent text reply (c2c, non-stream) -16:43:35 [ws] ⇄ res ✓ node.list 164ms id=391c63f3…12d1 -16:43:40 [ws] ⇄ res ✓ node.list 3ms id=644e852e…b958 -16:43:42 [agent/embedded] embedded run tool end: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f tool=exec toolCallId=call_fcf2dfd8801340ea80cb052f -16:43:43 [qqbot] [qqbot:default] Heartbeat sent -16:43:43 [qqbot] [qqbot:default] Received op=11 t=undefined -16:43:43 [qqbot] [qqbot:default] Heartbeat ACK -16:43:43 [ws] ← open remoteAddr=127.0.0.1 conn=f5252266…7c2a -16:43:43 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token -16:43:43 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:43:43 [ws] → event health seq=15 clients=6 presenceVersion=6 healthVersion=8 -16:43:43 [ws] → event cron seq=16 clients=6 dropIfSlow=true -16:43:43 [ws] ⇄ res ✓ cron.add 4ms id=e8bdbb55…9286 -16:43:43 [ws] ← open remoteAddr=127.0.0.1 conn=677f535c…c4f2 -16:43:43 [ws] → close code=1005 reason= durationMs=47 handshake=connected lastFrameType=req lastFrameMethod=cron.add lastFrameId=e8bdbb55-ab71-4095-bbf6-6d2be7fc9286 conn=f5252266…7c2a -16:43:43 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token conn=677f535c…c4f2 -16:43:43 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:43:43 [ws] → event health seq=17 clients=6 presenceVersion=6 healthVersion=9 -16:43:43 [ws] ⇄ res ✓ cron.status 1ms id=ede03142…1a30 -16:43:43 [ws] → close code=1005 reason= durationMs=20 handshake=connected lastFrameType=req lastFrameMethod=cron.status lastFrameId=ede03142-7e12-4d87-b4e4-68ac3f9c1a30 -16:43:45 [ws] ⇄ res ✓ node.list 1ms conn=323cc0ed…16ff id=f44c9b12…680b -16:43:50 [ws] ⇄ res ✓ node.list 3ms id=c0f27c31…c51d -16:43:55 [ws] ⇄ res ✓ node.list 1ms id=01002807…e188 -16:43:57 [agent/embedded] embedded run tool start: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f tool=process toolCallId=call_a5b895ffef304d7395d48838 -16:43:57 [agent/embedded] embedded run tool end: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f tool=process toolCallId=call_a5b895ffef304d7395d48838 -16:43:59 [agent/embedded] embedded run tool start: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f tool=exec toolCallId=call_18bf1944d51442cc9e9ddbee -16:44:00 [ws] ⇄ res ✓ node.list 4ms id=600b91b3…eab6 -16:44:01 [ws] → event health seq=18 clients=5 presenceVersion=6 healthVersion=10 -16:44:01 [ws] → event tick seq=19 clients=5 dropIfSlow=true -16:44:05 [ws] ⇄ res ✓ node.list 2ms id=80fbe5ce…a67a -16:44:08 [ws] ← open remoteAddr=127.0.0.1 conn=13e64828…0a4a -16:44:08 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token -16:44:08 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:44:08 [ws] → event health seq=20 clients=6 presenceVersion=6 healthVersion=11 -16:44:08 [ws] ⇄ res ✓ cron.list 2ms id=c68b2bd5…49fe -16:44:08 [ws] → close code=1005 reason= durationMs=71 handshake=connected lastFrameType=req lastFrameMethod=cron.list lastFrameId=c68b2bd5-687b-4e9e-9b7d-38b3524f49fe -16:44:08 [agent/embedded] embedded run tool end: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f tool=exec toolCallId=call_18bf1944d51442cc9e9ddbee -16:44:10 [ws] ⇄ res ✓ node.list 1ms conn=323cc0ed…16ff id=2c9fd79a…a47b -16:44:11 [ws] → event agent seq=21 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=15 text=✅ -16:44:11 [ws] → event chat seq=22 clients=5 dropIfSlow=true -16:44:11 [ws] → event agent seq=23 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=16 text=✅ 提醒已 -16:44:11 [ws] → event agent seq=24 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=17 text=✅ 提醒已设置成功! 📝 -16:44:11 [ws] → event agent seq=25 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=18 text=✅ 提醒已设置成功! 📝 内容: -16:44:11 [ws] → event agent seq=26 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=19 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间 -16:44:11 [ws] → event agent seq=27 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=20 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 ( -16:44:11 [ws] → event agent seq=28 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=21 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 -16:44:11 [ws] → event chat seq=29 clients=5 dropIfSlow=true -16:44:11 [ws] → event agent seq=30 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=22 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00: -16:44:11 [ws] → event agent seq=31 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=23 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候 -16:44:11 [ws] → event agent seq=32 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=24 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒 -16:44:11 [ws] → event agent seq=33 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=25 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒 -16:44:11 [ws] → event agent seq=34 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=26 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并 -16:44:11 [ws] → event chat seq=35 clients=5 dropIfSlow=true -16:44:11 [ws] → event agent seq=36 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=27 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行 -16:44:11 [ws] → event agent seq=37 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=28 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会 -16:44:11 [ws] → event agent seq=38 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=29 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 -16:44:11 [ws] → event agent seq=39 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=30 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦 -16:44:11 [ws] → event chat seq=40 clients=5 dropIfSlow=true -16:44:11 [ws] → event agent seq=41 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=31 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的 -16:44:11 [ws] → event agent seq=42 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=32 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对 -16:44:11 [ws] → event agent seq=43 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=33 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦 -16:44:11 [ws] → event chat seq=44 clients=5 dropIfSlow=true -16:44:12 [ws] → event agent seq=45 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=34 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。 -16:44:12 [ws] → event agent seq=46 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=35 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后 -16:44:12 [ws] → event agent seq=47 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=36 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如 -16:44:12 [ws] → event agent seq=48 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=37 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的 -16:44:12 [ws] → event agent seq=49 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=38 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的, -16:44:12 [ws] → event agent seq=50 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=39 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置 -16:44:12 [ws] → event chat seq=51 clients=5 dropIfSlow=true -16:44:12 [ws] → event agent seq=52 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=40 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐 -16:44:12 [ws] → event agent seq=53 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=41 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=54 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=42 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event chat seq=55 clients=5 dropIfSlow=true -16:44:12 [ws] → event agent seq=56 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=43 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=57 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=44 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=58 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=45 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event chat seq=59 clients=5 dropIfSlow=true -16:44:12 [ws] → event agent seq=60 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=46 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=61 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=47 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=62 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=48 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event chat seq=63 clients=5 dropIfSlow=true -16:44:12 [ws] → event agent seq=64 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=49 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=65 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=50 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [ws] → event agent seq=66 clients=5 run=69e77a59…1c8f agent=main session=main stream=assistant aseq=51 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:44) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入,对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。 正如我之前提到的,如果您觉得频繁设置饮水提醒有些繁琐,… -16:44:12 [agent/embedded] embedded run agent end: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f -16:44:12 [ws] → event agent seq=67 clients=5 run=69e77a59…1c8f agent=main session=main stream=lifecycle aseq=52 phase=end -16:44:12 [ws] → event chat seq=68 clients=5 -16:44:12 [agent/embedded] embedded run prompt end: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f sessionId=ba108bac-c99c-498f-b33f-06245ade1363 durationMs=55147 -16:44:12 [diagnostic] session state: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 sessionKey=unknown prev=processing new=idle reason="run_completed" queueDepth=0 -16:44:13 [diagnostic] run cleared: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 totalActive=0 -16:44:13 [ws] ⇄ res ✓ chat.history 73ms conn=225c0e81…5c15 id=9bda295a…2635 -16:44:13 [ws] ⇄ res ✓ chat.history 25ms conn=bc20a8ce…0a0f id=d4f0e67a…785a -16:44:13 [ws] ⇄ res ✓ chat.history 170ms conn=46458cc1…e736 id=3ddab88b…0e80 -16:44:13 [ws] ⇄ res ✓ chat.history 35ms conn=323cc0ed…16ff id=2219df62…11d3 -16:44:13 [ws] ⇄ res ✓ chat.history 62ms conn=5da3d69e…5ff8 id=8d871c4c…c1f0 -16:44:13 [agent/embedded] embedded run done: runId=69e77a59-7632-4447-a7e4-4a24b2621c8f sessionId=ba108bac-c99c-498f-b33f-06245ade1363 durationMs=55777 aborted=false -16:44:13 [diagnostic] lane task done: lane=main durationMs=55791 active=0 queued=0 -16:44:13 [diagnostic] lane task done: lane=session:agent:main:main durationMs=55794 active=0 queued=0 -16:44:13 [qqbot] [qqbot:default] deliver called, kind: block, payload keys: text, replyToId, audioAsVoice -16:44:13 [qqbot] [qqbot:default] deliver: updated streamBuffer, replyText=230, total=230 -16:44:13 [qqbot] [qqbot:default] Sent text reply (c2c, non-stream) -16:44:14 [ws] → event heartbeat seq=69 clients=5 dropIfSlow=true -16:44:14 [session-store] Saved session for default: sessionId=b879fa59-5251-4a98-b50a-b88e46d174ed, lastSeq=3 -16:44:14 [qqbot] [qqbot:default] Received op=0 t=C2C_MESSAGE_CREATE -16:44:14 [known-users] Updated user 207A5B8339D01F6582911C014668B77B, interactions: 27 -16:44:14 [qqbot] [qqbot:default] Message enqueued, queue size: 1 -16:44:14 [qqbot] [qqbot:default] Processing message from 207A5B8339D01F6582911C014668B77B: 1分钟后提醒我喝水 -16:44:14 [qqbot] [qqbot:default] Stream enabled: false -16:44:14 [qqbot] [qqbot:default] Stream support: false (type=c2c, enabled=false) -16:44:14 [diagnostic] lane enqueue: lane=session:agent:main:main queueSize=1 -16:44:14 [diagnostic] lane dequeue: lane=session:agent:main:main waitMs=2 queueSize=0 -16:44:14 [diagnostic] lane enqueue: lane=main queueSize=1 -16:44:14 [diagnostic] lane dequeue: lane=main waitMs=1 queueSize=0 -16:44:14 [agent/embedded] embedded run start: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 sessionId=ba108bac-c99c-498f-b33f-06245ade1363 provider=qwen-portal model=coder-model thinking=off messageChannel=qqbot -16:44:14 [diagnostic] session state: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 sessionKey=unknown prev=idle new=processing reason="run_started" queueDepth=0 -16:44:14 [diagnostic] run registered: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 totalActive=1 -16:44:14 [agent/embedded] embedded run prompt start: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 sessionId=ba108bac-c99c-498f-b33f-06245ade1363 -16:44:14 [agent/embedded] embedded run agent start: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 -16:44:14 [ws] → event agent seq=70 clients=5 run=9dca9346…c1b2 agent=main session=main stream=lifecycle aseq=1 phase=start -16:44:15 [ws] ⇄ res ✓ node.list 15ms conn=323cc0ed…16ff id=7459bca9…85d2 -16:44:16 [ws] ⇄ res ✓ node.list 1ms conn=225c0e81…5c15 id=cf44f720…01a2 -16:44:16 [ws] ⇄ res ✓ node.list 1ms conn=bc20a8ce…0a0f id=5d87baf2…7446 -16:44:16 [ws] ⇄ res ✓ node.list 12ms conn=46458cc1…e736 id=a8ac366b…689d -16:44:16 [ws] ⇄ res ✓ node.list 23ms conn=5da3d69e…5ff8 id=c615dcdc…e89a -16:44:16 [ws] → event agent seq=71 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=2 text=我 -16:44:16 [ws] → event chat seq=72 clients=5 dropIfSlow=true -16:44:16 [ws] → event agent seq=73 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=3 text=我来为您设置 -16:44:16 [ws] → event agent seq=74 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=4 text=我来为您设置一个1分钟后提醒 -16:44:16 [ws] → event agent seq=75 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=5 text=我来为您设置一个1分钟后提醒喝水的任务: -16:44:17 [agent/embedded] embedded run tool start: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 tool=exec toolCallId=call_8e66dce8daba4961add53c7d -16:44:17 [qqbot] [qqbot:default] deliver called, kind: block, payload keys: text, replyToId, audioAsVoice -16:44:17 [qqbot] [qqbot:default] deliver: updated streamBuffer, replyText=20, total=20 -16:44:17 [qqbot] [qqbot:default] Sent text reply (c2c, non-stream) -16:44:19 [known-users] Saved 1 users to file -16:44:20 [ws] ⇄ res ✓ node.list 8ms conn=323cc0ed…16ff id=90c37a1a…54aa -16:44:23 [ws] ← open remoteAddr=127.0.0.1 conn=9f789ce2…8ada -16:44:23 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token -16:44:23 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:44:23 [ws] → event health seq=76 clients=6 presenceVersion=6 healthVersion=12 -16:44:23 [ws] → event cron seq=77 clients=6 dropIfSlow=true -16:44:23 [ws] ⇄ res ✓ cron.add 7ms id=2b23f7eb…0f7c -16:44:23 [ws] → close code=1005 reason= durationMs=84 handshake=connected lastFrameType=req lastFrameMethod=cron.add lastFrameId=2b23f7eb-b8ad-4d45-a8a1-16ba53190f7c -16:44:23 [ws] ← open remoteAddr=127.0.0.1 conn=7cdae80c…8021 -16:44:23 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token -16:44:23 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:44:23 [ws] → event health seq=78 clients=6 presenceVersion=6 healthVersion=13 -16:44:23 [ws] ⇄ res ✓ cron.status 1ms id=307ff75d…dfc5 -16:44:23 [ws] → close code=1005 reason= durationMs=18 handshake=connected lastFrameType=req lastFrameMethod=cron.status lastFrameId=307ff75d-4398-4a98-b2e5-6472b84edfc5 -16:44:23 [agent/embedded] embedded run tool end: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 tool=exec toolCallId=call_8e66dce8daba4961add53c7d -16:44:24 [qqbot] [qqbot:default] Heartbeat sent -16:44:24 [qqbot] [qqbot:default] Received op=11 t=undefined -16:44:24 [qqbot] [qqbot:default] Heartbeat ACK -16:44:25 [ws] ⇄ res ✓ node.list 1ms conn=323cc0ed…16ff id=5ee8c04d…5076 -16:44:25 [agent/embedded] embedded run tool start: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 tool=exec toolCallId=call_1609d0a32d8d41dfa73a91e8 -16:44:30 [ws] ⇄ res ✓ node.list 2ms id=5f141923…4789 -16:44:31 [ws] → event tick seq=79 clients=5 dropIfSlow=true -16:44:33 [ws] ← open remoteAddr=127.0.0.1 conn=8771457d…0ee9 -16:44:33 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token -16:44:33 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 -16:44:33 [ws] → event health seq=80 clients=6 presenceVersion=6 healthVersion=14 -16:44:33 [ws] ⇄ res ✓ cron.list 0ms id=8ab8a305…5b60 -16:44:33 [ws] → close code=1005 reason= durationMs=256 handshake=connected lastFrameType=req lastFrameMethod=cron.list lastFrameId=8ab8a305-fd19-42eb-a333-2693de625b60 -16:44:34 [agent/embedded] embedded run tool end: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 tool=exec toolCallId=call_1609d0a32d8d41dfa73a91e8 -16:44:35 [ws] ⇄ res ✓ node.list 1ms conn=323cc0ed…16ff id=c33c4ad4…e4c5 -16:44:35 [ws] → event agent seq=81 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=12 text=✅ -16:44:35 [ws] → event chat seq=82 clients=5 dropIfSlow=true -16:44:35 [ws] → event agent seq=83 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=13 text=✅ 提醒已 -16:44:35 [ws] → event agent seq=84 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=14 text=✅ 提醒已设置成功! 📝 -16:44:35 [ws] → event agent seq=85 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=15 text=✅ 提醒已设置成功! 📝 内容: -16:44:36 [ws] → event agent seq=86 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=16 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间 -16:44:36 [ws] → event agent seq=87 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=17 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 ( -16:44:36 [ws] → event agent seq=88 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=18 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 -16:44:36 [ws] → event chat seq=89 clients=5 dropIfSlow=true -16:44:36 [ws] → event agent seq=90 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=19 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00: -16:44:36 [ws] → event agent seq=91 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=20 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候 -16:44:36 [ws] → event agent seq=92 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=21 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您 -16:44:36 [ws] → event agent seq=93 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=22 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您 -16:44:36 [ws] → event agent seq=94 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=23 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在 -16:44:36 [ws] → event agent seq=95 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=24 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在 -16:44:36 [ws] → event chat seq=96 clients=5 dropIfSlow=true -16:44:36 [ws] → event agent seq=97 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=25 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继 -16:44:36 [ws] → event agent seq=98 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=26 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. -16:44:36 [ws] → event agent seq=99 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=27 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第 -16:44:36 [ws] → event chat seq=100 clients=5 dropIfSlow=true -16:44:36 [ws] → event agent seq=101 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=28 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约 -16:44:36 [ws] → event agent seq=102 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=29 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00 -16:44:36 [ws] → event agent seq=103 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=30 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44( -16:44:36 [ws] → event agent seq=104 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=31 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2 -16:44:36 [ws] → event chat seq=105 clients=5 dropIfSlow=true -16:44:36 [ws] → event agent seq=106 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=32 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第 -16:44:36 [ws] → event agent seq=107 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=33 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒: -16:44:36 [ws] → event agent seq=108 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=34 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 0 -16:44:36 [ws] → event agent seq=109 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=35 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45 -16:44:36 [ws] → event agent seq=110 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=36 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1 -16:44:36 [ws] → event agent seq=111 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=37 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒 -16:44:36 [ws] → event agent seq=112 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=38 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务 -16:44:36 [ws] → event agent seq=113 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=39 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在 -16:44:36 [ws] → event chat seq=114 clients=5 dropIfSlow=true -16:44:36 [ws] → event agent seq=115 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=40 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行 -16:44:36 [ws] → event agent seq=116 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=41 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会 -16:44:36 [ws] → event agent seq=117 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=42 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"💧 -16:44:36 [ws] → event agent seq=118 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=43 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:36 [ws] → event agent seq=119 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=44 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:36 [ws] → event agent seq=120 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=45 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:36 [ws] → event agent seq=121 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=46 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:36 [ws] → event chat seq=122 clients=5 dropIfSlow=true -16:44:37 [ws] → event agent seq=123 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=47 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=124 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=48 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=125 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=49 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=126 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=50 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event chat seq=127 clients=5 dropIfSlow=true -16:44:37 [ws] → event agent seq=128 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=51 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=129 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=52 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=130 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=53 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=131 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=54 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=132 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=55 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=133 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=56 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=134 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=57 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event chat seq=135 clients=5 dropIfSlow=true -16:44:37 [ws] → event agent seq=136 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=58 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=137 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=59 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=138 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=60 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:�… -16:44:37 [ws] → event agent seq=139 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=61 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event chat seq=140 clients=5 dropIfSlow=true -16:44:37 [ws] → event agent seq=141 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=62 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=142 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=63 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [ws] → event agent seq=143 clients=5 run=9dca9346…c1b2 agent=main session=main stream=assistant aseq=64 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:1分钟后 (大约在 00:45) 到时候我会准时提醒您~ 我看到您现在有两个饮水提醒将在1分钟内相继触发: 1. 第一个提醒:大约在 00:44(即将到达) 2. 第二个提醒:大约在 00:45(1分钟后) 您的提醒任务已经创建并将在1分钟后执行,届时您会收到一条"�… -16:44:37 [agent/embedded] embedded run agent end: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 -16:44:37 [ws] → event agent seq=144 clients=5 run=9dca9346…c1b2 agent=main session=main stream=lifecycle aseq=65 phase=end -16:44:37 [ws] → event chat seq=145 clients=5 -16:44:37 [agent/embedded] embedded run prompt end: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 sessionId=ba108bac-c99c-498f-b33f-06245ade1363 durationMs=22852 -16:44:37 [diagnostic] session state: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 sessionKey=unknown prev=processing new=idle reason="run_completed" queueDepth=0 -16:44:37 [diagnostic] run cleared: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 totalActive=0 -16:44:37 [ws] ⇄ res ✓ chat.history 42ms conn=46458cc1…e736 id=082bb4ac…4cc1 -16:44:37 [ws] ⇄ res ✓ chat.history 24ms conn=bc20a8ce…0a0f id=893e3249…4d11 -16:44:37 [ws] ⇄ res ✓ chat.history 26ms conn=225c0e81…5c15 id=a8494243…70cb -16:44:37 [ws] ⇄ res ✓ chat.history 25ms conn=5da3d69e…5ff8 id=108caefd…91a3 -16:44:37 [ws] ⇄ res ✓ chat.history 18ms conn=323cc0ed…16ff id=d8bd82f6…e8a4 -16:44:38 [agent/embedded] embedded run done: runId=9dca9346-fbfa-4817-b06b-73bc8a8dc1b2 sessionId=ba108bac-c99c-498f-b33f-06245ade1363 durationMs=23281 aborted=false -16:44:38 [diagnostic] lane task done: lane=main durationMs=23358 active=0 queued=0 -16:44:38 [diagnostic] lane task done: lane=session:agent:main:main durationMs=23364 active=0 queued=0 -16:44:38 [qqbot] [qqbot:default] deliver called, kind: block, payload keys: text, replyToId, audioAsVoice -16:44:38 [qqbot] [qqbot:default] deliver: updated streamBuffer, replyText=297, total=297 -16:44:38 [qqbot] [qqbot:default] Sent text reply (c2c, non-stream) -16:44:40 [ws] ⇄ res ✓ node.list 1ms id=74dab786…b31e -16:44:43 [ws] → event cron seq=146 clients=5 dropIfSlow=true -16:44:43 [diagnostic] lane enqueue: lane=session:agent:main:cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b queueSize=1 -16:44:43 [diagnostic] lane dequeue: lane=session:agent:main:cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b waitMs=6 queueSize=0 -16:44:43 [diagnostic] lane enqueue: lane=cron queueSize=1 -16:44:43 [diagnostic] lane dequeue: lane=cron waitMs=1 queueSize=0 -16:44:43 [agent/embedded] embedded run start: runId=9a5c9cb8-c657-48e1-9edb-38b422d6218f sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f provider=qwen-portal model=coder-model thinking=off messageChannel=qqbot -16:44:43 [diagnostic] session state: sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f sessionKey=unknown prev=idle new=processing reason="run_started" queueDepth=0 -16:44:43 [diagnostic] run registered: sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f totalActive=1 -16:44:43 [agent/embedded] embedded run prompt start: runId=9a5c9cb8-c657-48e1-9edb-38b422d6218f sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f -16:44:43 [agent/embedded] embedded run agent start: runId=9a5c9cb8-c657-48e1-9edb-38b422d6218f -16:44:43 [ws] → event agent seq=147 clients=5 run=9a5c9cb8…218f agent=main session=cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b stream=lifecycle aseq=1 phase=start -16:44:45 [ws] ⇄ res ✓ node.list 1ms id=c0af7c96…6691 -16:44:45 [ws] → event agent seq=148 clients=5 run=9a5c9cb8…218f agent=main session=cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b stream=assistant aseq=2 text=HE -16:44:45 [ws] → event chat seq=149 clients=5 dropIfSlow=true -16:44:45 [ws] → event agent seq=150 clients=5 run=9a5c9cb8…218f agent=main session=cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b stream=assistant aseq=3 text=HEARTBE -16:44:45 [ws] → event agent seq=151 clients=5 run=9a5c9cb8…218f agent=main session=cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b stream=assistant aseq=4 text=HEARTBEAT_OK -16:44:45 [agent/embedded] embedded run agent end: runId=9a5c9cb8-c657-48e1-9edb-38b422d6218f -16:44:45 [ws] → event agent seq=152 clients=5 run=9a5c9cb8…218f agent=main session=cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b stream=lifecycle aseq=5 phase=end -16:44:45 [ws] → event chat seq=153 clients=5 -16:44:45 [agent/embedded] embedded run prompt end: runId=9a5c9cb8-c657-48e1-9edb-38b422d6218f sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f durationMs=2129 -16:44:45 [diagnostic] session state: sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f sessionKey=unknown prev=processing new=idle reason="run_completed" queueDepth=0 -16:44:45 [diagnostic] run cleared: sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f totalActive=0 -16:44:46 [agent/embedded] embedded run done: runId=9a5c9cb8-c657-48e1-9edb-38b422d6218f sessionId=9a5c9cb8-c657-48e1-9edb-38b422d6218f durationMs=2180 aborted=false -16:44:46 [diagnostic] lane task done: lane=cron durationMs=2185 active=0 queued=0 -16:44:46 [diagnostic] lane task done: lane=session:agent:main:cron:d6c70f30-edae-4c6e-9fe7-0e7f18f7354b durationMs=2187 active=0 queued=0 -16:44:46 [ws] → event cron seq=154 clients=5 dropIfSlow=true -16:44:46 [ws] → event cron seq=155 clients=5 dropIfSlow=true -16:44:50 [ws] ⇄ res ✓ node.list 1ms id=deb7a77e…47aa -16:44:55 [ws] ⇄ res ✓ node.list 35ms id=8dddbf25…3d2d -^C16:44:59 [gateway] signal SIGINT received -16:44:59 [gateway] received SIGINT; shutting down -16:44:59 [gateway] signal SIGINT received -16:44:59 [gateway] received SIGINT during shutdown; ignoring -16:44:59 [qqbot] [qqbot-api] Background token refresh stopped -16:44:59 [gmail-watcher] gmail watcher stopped -16:44:59 [ws] → event shutdown seq=156 clients=5 -16:44:59 [qqbot] [qqbot:default] Message processor stopped -16:44:59 [qqbot] [qqbot:default] WebSocket closed: 1005 -16:44:59 [ws] webchat disconnected code=1012 reason=service restart conn=323cc0ed-9652-4b5d-be48-38f3630c16ff -16:44:59 [ws] → event presence seq=157 clients=0 dropIfSlow=true presenceVersion=7 healthVersion=14 -16:44:59 [ws] → close code=1012 reason=service restart durationMs=104402 handshake=connected lastFrameType=req lastFrameMethod=node.list lastFrameId=8dddbf25-76c8-4c51-86b3-0dc244cb3d2d -16:44:59 [ws] webchat disconnected code=1012 reason=service restart conn=5da3d69e-fe34-46d3-a334-0a64d1165ff8 -16:44:59 [ws] → event presence seq=158 clients=0 dropIfSlow=true presenceVersion=8 healthVersion=14 -16:44:59 [ws] → close code=1012 reason=service restart durationMs=117432 handshake=connected lastFrameType=req lastFrameMethod=chat.history lastFrameId=108caefd-374d-4a97-983a-0b1a38ae91a3 conn=5da3d69e…5ff8 -16:44:59 [ws] webchat disconnected code=1012 reason=service restart conn=46458cc1-bece-4b13-9019-b38a4a35e736 -16:44:59 [ws] → event presence seq=159 clients=0 dropIfSlow=true presenceVersion=9 healthVersion=14 -16:44:59 [ws] → close code=1012 reason=service restart durationMs=117462 handshake=connected lastFrameType=req lastFrameMethod=chat.history lastFrameId=082bb4ac-6e0e-43d3-a53e-bbdfcca94cc1 conn=46458cc1…e736 -16:44:59 [ws] webchat disconnected code=1012 reason=service restart conn=bc20a8ce-f484-4990-8b68-2d47e7110a0f -16:44:59 [ws] → event presence seq=160 clients=0 dropIfSlow=true presenceVersion=10 healthVersion=14 -16:44:59 [ws] → close code=1012 reason=service restart durationMs=117585 handshake=connected lastFrameType=req lastFrameMethod=chat.history lastFrameId=893e3249-ee93-4f11-98ee-22f2e7d44d11 conn=bc20a8ce…0a0f -16:44:59 [ws] webchat disconnected code=1012 reason=service restart conn=225c0e81-0f25-41cb-b22f-96645dd55c15 -16:44:59 [ws] → event presence seq=161 clients=0 dropIfSlow=true presenceVersion=11 healthVersion=14 -16:44:59 [ws] → close code=1012 reason=service restart durationMs=117623 handshake=connected lastFrameType=req lastFrameMethod=chat.history lastFrameId=a8494243-9054-4da1-ad2b-5901df5670cb conn=225c0e81…5c15 -^C^C% lishoushuai@RIANLI-MC3 qqbot % diff --git a/openclaw.plugin.json b/openclaw.plugin.json index f0a7017..d755651 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "qqbot", "name": "QQ Bot Channel", - "description": "QQ Bot channel plugin with streaming message support, cron jobs, and proactive messaging", + "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging", "channels": ["qqbot"], "skills": ["skills/qqbot-cron"], "capabilities": { diff --git a/skills/qqbot-cron/SKILL.md b/skills/qqbot-cron/SKILL.md index 6084c12..ba18b71 100644 --- a/skills/qqbot-cron/SKILL.md +++ b/skills/qqbot-cron/SKILL.md @@ -55,31 +55,18 @@ openclaw cron add \ --name "{任务名}" \ --at "{时间}" \ --session isolated \ - --system-event '{"type":"reminder","user_openid":"{openid}","user_name":"{用户名称}","original_message_id":"{message_id}","reminder_content":"{提醒内容}","created_at":"{当前时间ISO格式}"}' \ - --system-prompt ' -**极端重要角色设定**: -你是一个定时提醒机器人,现在是用户之前设置的提醒时间。 - -**你的唯一任务**:直接输出提醒消息,提醒用户:{提醒内容} - -**输出格式**:类似 "🔔 时间到了!该{提醒内容}了~" 的提醒语 - -**绝对禁止**: -- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语 -- 你是提醒者,不是被提醒者 -- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的 -- 不要对 [系统触发] 消息做任何回复,只需输出提醒内容 -' \ - --message "[系统触发] 定时任务已触发,请执行提醒。" \ + --message "🔔 {提醒内容}时间到!" \ --deliver \ --channel qqbot \ --to "{openid}" \ --delete-after-run ``` -> 💡 **`--system-event` 说明**:用于存储用户上下文信息,提醒触发时 AI 可以获取这些信息来提供更个性化的提醒。 - -> ⚠️ **重要**:`--message` 参数会被 AI 视为“用户消息”,所以必须在 `--system-prompt` 中**极其强调**角色定位。 +> ⚠️ **极其重要**: +> - `--message` 参数直接写最终要发送的提醒内容 +> - 提醒内容格式:`🔔 {内容}时间到!` +> - **不要**使用 `--system-prompt` 或 `--system-event`(cron 不支持这些参数) +> - 保持消息简洁,如:`🔔 喝水时间到!`、`📅 开会时间到!` > ⚠️ **注意**:`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。 @@ -120,38 +107,27 @@ openclaw message send \ ## 💬 用户交互模板 -> **创建提醒后,必须给用户反馈确认** +> **创建提醒后的反馈要简洁友好,不要啰嗦** -### 创建成功反馈 +### 创建成功反馈(推荐简洁版) **一次性提醒**: ``` -✅ 提醒已设置! - -📝 内容:{提醒内容} -⏰ 时间:{具体时间} - -到时候我会准时提醒你~ +⏰ 好的,{时间}后提醒你{提醒内容}~ ``` **周期提醒**: ``` -✅ 周期提醒已设置! - -📝 内容:{提醒内容} -🔄 周期:{周期描述,如"每天早上8:00"} - -提醒会持续生效,说"取消xx提醒"可停止~ +⏰ 收到,{周期描述}提醒你{提醒内容}~ ``` ### 查询提醒反馈 ``` -📋 你当前的提醒: +📋 你的提醒: -1. ⏰ 喝水提醒 - 5分钟后 (一次性) -2. 🔄 打卡提醒 - 每天08:00 (周期) -3. 🔄 日报提醒 - 工作日18:00 (周期) +1. ⏰ {提醒名} - {时间} +2. 🔄 {提醒名} - {周期} 说"取消xx提醒"可删除~ ``` @@ -159,15 +135,15 @@ openclaw message send \ ### 无提醒时反馈 ``` -📋 你当前没有设置任何提醒 +📋 目前没有提醒哦~ -说"5分钟后提醒我xxx"可创建提醒~ +说"5分钟后提醒我xxx"试试? ``` ### 删除成功反馈 ``` -✅ 已取消"{提醒名称}"提醒 +✅ 已取消"{提醒名称}" ``` --- @@ -213,84 +189,39 @@ openclaw message send \ | 参数 | 说明 | 示例 | |------|------|------| -| `--name` | 任务名,含用户标识 | `"喝水提醒-小明"` | +| `--name` | 任务名,含用户标识 | `"喝水提醒"` | | `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` | | `--session isolated` | 隔离会话 | 固定值 | -| `--message` | **系统触发指令**(AI 会视为用户消息) | `"[系统触发] 定时任务已触发,请执行提醒。"` | +| `--message` | **提醒内容**(见下方模板) | `"🔔 喝水时间到!"` | | `--deliver` | 启用投递 | 固定值 | | `--channel qqbot` | QQ 渠道 | 固定值 | | `--to` | 接收者 openid | 从系统消息获取 | -### 推荐参数(用于存储用户上下文) +### 推荐参数 | 参数 | 说明 | 何时使用 | |------|------|----------| -| `--system-event` | 用户上下文 JSON | **建议所有任务都使用** | -| `--system-prompt` | AI 角色指引 | **建议所有任务都使用** | | `--delete-after-run` | 执行后删除 | **一次性任务必须** | | `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** | -### --system-prompt 参数说明 +### --message 提醒内容模板(最关键) -`--system-prompt` 用于在提醒触发时给 AI 一个明确的角色指引,避免 AI 混淆角色。 +> ⚠️ **`--message` 的内容会直接发送给用户**,所以要写清楚提醒内容! -**为什么需要?** -- 提醒触发时是一个隔离会话(`--session isolated`) -- AI 没有原始对话的上下文 -- 如果不明确角色,AI 可能误以为自己是"被提醒者"而说"谢谢提醒" - -**推荐格式**(**极其重要,必须完整复制**): +**模板格式**: ``` -' -**极端重要角色设定**: -你是一个定时提醒机器人,现在是用户之前设置的提醒时间。 - -**你的唯一任务**:直接输出提醒消息,提醒用户:{提醒内容} - -**输出格式**:类似 "🔔 时间到了!该{提醒内容}了~" 的提醒语 - -**绝对禁止**: -- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语 -- 你是提醒者,不是被提醒者 -- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的 -- 不要对 [系统触发] 消息做任何回应,只需输出提醒内容 -' +--message "🔔 {提醒内容}时间到!" ``` -**关键点**: -- `--message` 参数会被 AI 视为“用户消息”,无法改变 -- 所以必须在 `--system-prompt` 中**极其强调**角色定位 -- 明确告诉 AI:`[系统触发]` 消息不是用户发的,不要回复它 -- 使用禁止列表明确告知不能说的话 +**示例**: +- 喝水:`--message "💧 喝水时间到!"` +- 开会:`--message "📅 开会时间到!"` +- 打卡:`--message "🌅 打卡时间到!"` +- 日报:`--message "📝 写日报时间到!"` -### --system-event 字段说明 - -`--system-event` 用于存储提醒的上下文信息,格式为 JSON: - -```json -{ - "type": "reminder", - "user_openid": "用户的openid", - "user_name": "用户名称(如有)", - "original_message_id": "创建提醒时的message_id", - "reminder_content": "提醒内容摘要", - "created_at": "创建时间ISO格式" -} -``` - -| 字段 | 说明 | 来源 | -|------|------|------| -| `type` | 事件类型,固定为 `"reminder"` | 固定值 | -| `user_openid` | 用户 openid | 从系统消息获取 | -| `user_name` | 用户名称 | 从系统消息获取(如有) | -| `original_message_id` | 创建时的消息 ID | 从系统消息获取 | -| `reminder_content` | 提醒内容摘要 | AI 根据用户请求生成 | -| `created_at` | 提醒创建时间 | 当前时间 ISO 格式 | - -> 💡 **为什么需要 `--system-event`?** -> - 提醒触发时,AI 可以获取完整的用户上下文 -> - 便于追踪提醒来源和调试 -> - 为未来可能的功能扩展预留信息 +**为什么这样写?** +- 消息内容会直接发送,不经过 AI 处理 +- 保持简洁,一目了然 --- @@ -303,24 +234,10 @@ openclaw message send \ **AI 执行**: ```bash openclaw cron add \ - --name "喝水提醒-用户" \ + --name "喝水提醒" \ --at "5m" \ --session isolated \ - --system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"喝水","created_at":"2026-02-01T16:50:00+08:00"}' \ - --system-prompt ' -**极端重要角色设定**: -你是一个定时提醒机器人,现在是用户之前设置的提醒时间。5分钟前用户设置了喝水提醒。 - -**你的唯一任务**:直接输出提醒消息,提醒用户喝水 - -**输出格式**:类似 "💧 时间到!该喝水啦~" 的提醒语 - -**绝对禁止**: -- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语 -- 你是提醒者,不是被提醒者 -- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的 -' \ - --message "[系统触发] 定时任务已触发,请执行提醒。" \ + --message "💧 喝水时间到!" \ --deliver \ --channel qqbot \ --to "{openid}" \ @@ -329,12 +246,12 @@ openclaw cron add \ **AI 回复**: ``` -✅ 提醒已设置! +⏰ 好的,5分钟后提醒你喝水~ +``` -📝 内容:喝水 -⏰ 时间:5分钟后 - -到时候我会准时提醒你~ +**5分钟后用户收到**: +``` +💧 喝水时间到! ``` --- @@ -346,25 +263,11 @@ openclaw cron add \ **AI 执行**: ```bash openclaw cron add \ - --name "打卡提醒-用户" \ + --name "打卡提醒" \ --cron "0 8 * * *" \ --tz "Asia/Shanghai" \ --session isolated \ - --system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"打卡","created_at":"2026-02-01T16:50:00+08:00"}' \ - --system-prompt ' -**极端重要角色设定**: -你是一个定时提醒机器人,现在是用户设置的每日早上8点打卡提醒时间。 - -**你的唯一任务**:直接输出提醒消息,提醒用户打卡 - -**输出格式**:类似 "🌅 早上好!该打卡了~" 的提醒语 - -**绝对禁止**: -- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语 -- 你是提醒者,不是被提醒者 -- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的 -' \ - --message "[系统触发] 定时任务已触发,请执行提醒。" \ + --message "🌅 打卡时间到!" \ --deliver \ --channel qqbot \ --to "{openid}" @@ -372,12 +275,7 @@ openclaw cron add \ **AI 回复**: ``` -✅ 周期提醒已设置! - -📝 内容:打卡 -🔄 周期:每天早上 8:00 - -提醒会持续生效,说"取消打卡提醒"可停止~ +⏰ 收到,每天早上8点提醒你打卡~ ``` > 💡 周期任务**不加** `--delete-after-run` @@ -391,58 +289,64 @@ openclaw cron add \ **AI 执行**: ```bash openclaw cron add \ - --name "日报提醒-用户" \ + --name "日报提醒" \ --cron "0 18 * * 1-5" \ --tz "Asia/Shanghai" \ --session isolated \ - --system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"写日报","created_at":"2026-02-01T16:50:00+08:00"}' \ - --system-prompt ' -**极端重要角色设定**: -你是一个定时提醒机器人,现在是用户设置的工作日下午6点写日报提醒时间。 - -**你的唯一任务**:直接输出提醒消息,提醒用户写日报 - -**输出格式**:类似 "📝 下班啦!别忘了提交今日日报~" 的提醒语 - -**绝对禁止**: -- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语 -- 你是提醒者,不是被提醒者 -- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的 -' \ - --message "[系统触发] 定时任务已触发,请执行提醒。" \ + --message "📝 写日报时间到!" \ --deliver \ --channel qqbot \ --to "{openid}" ``` +**AI 回复**: +``` +⏰ 收到,工作日下午6点提醒你写日报~ +``` + --- -### 场景4:群组提醒 +### 场景4:会议提醒 + +**用户**: 3分钟后提醒我开会 + +**AI 执行**: +```bash +openclaw cron add \ + --name "开会提醒" \ + --at "3m" \ + --session isolated \ + --message "📅 开会时间到!" \ + --deliver \ + --channel qqbot \ + --to "{openid}" \ + --delete-after-run +``` + +**AI 回复**: +``` +⏰ 好的,3分钟后提醒你开会~ +``` + +**3分钟后用户收到**: +``` +📅 开会时间到! +``` + +--- + +### 场景5:群组提醒 **用户**(群聊): 每天早上9点提醒大家站会 **AI 执行**: ```bash openclaw cron add \ - --name "站会提醒-群" \ + --name "站会提醒" \ --cron "0 9 * * 1-5" \ --tz "Asia/Shanghai" \ --session isolated \ - --system-event '{"type":"reminder","user_openid":"group:{group_openid}","original_message_id":"{message_id}","reminder_content":"站会","created_at":"2026-02-01T16:50:00+08:00"}' \ - --system-prompt ' -**极端重要角色设定**: -你是一个定时提醒机器人,现在是工作日早上9点的站会提醒时间。 - -**你的唯一任务**:直接输出提醒消息,提醒群成员参加站会 - -**输出格式**:类似 "📢 站会时间到!请各位同事准时参加~" 的提醒语 - -**绝对禁止**: -- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语 -- 你是提醒者,不是被提醒者 -- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的 -' \ - --message "[系统触发] 定时任务已触发,请执行提醒。" \ + --message "📢 站会时间到!" \ --deliver \ --channel qqbot \ --to "group:{group_openid}" @@ -452,7 +356,7 @@ openclaw cron add \ --- -### 场景5:查询提醒 +### 场景6:查询提醒 **用户**: 我有哪些提醒? @@ -461,19 +365,19 @@ openclaw cron add \ openclaw cron list ``` -**AI 回复**(根据返回结果组织): +**AI 回复**(根据返回结果): ``` -📋 你当前的提醒: +📋 你的提醒: -1. ⏰ 喝水提醒 - 3分钟后 (一次性) -2. 🔄 打卡提醒 - 每天08:00 (周期) +1. ⏰ 喝水提醒 - 3分钟后 +2. 🔄 打卡提醒 - 每天08:00 说"取消xx提醒"可删除~ ``` --- -### 场景6:取消提醒 +### 场景7:取消提醒 **用户**: 取消打卡提醒 @@ -567,15 +471,15 @@ openclaw cron list ## 📝 消息模板 -| 场景 | 模板 | Emoji | -|------|------|-------| -| 喝水 | 该喝水啦! | 💧 🚰 | -| 打卡 | 早上好!记得打卡~ | 🌅 ✅ | -| 会议 | xx会议马上开始! | 📅 👥 | +| 场景 | 触发时输出 | Emoji | +|------|------------|-------| +| 喝水 | 喝水时间到啦! | 💧 🚰 | +| 打卡 | 早上好,打卡时间到! | 🌅 ✅ | +| 会议 | 开会时间到! | 📅 👥 | | 休息 | 该休息一下了~ | 😴 💤 | -| 日报 | 今日日报别忘了~ | 📝 ✍️ | -| 运动 | 该运动了! | 🏃 💪 | -| 吃药 | 记得按时吃药~ | 💊 🏥 | +| 日报 | 下班前别忘了写日报哦~ | 📝 ✍️ | +| 运动 | 运动时间到! | 🏃 💪 | +| 吃药 | 该吃药了~ | 💊 🏥 | | 生日 | 今天是xx的生日! | 🎂 🎉 | --- diff --git a/src/api.ts b/src/api.ts index 2f0534f..c9f18d6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,9 +1,7 @@ /** - * QQ Bot API 鉴权和请求封装(支持流式消息) + * QQ Bot API 鉴权和请求封装 */ -import { StreamState, type StreamConfig } from "./types.js"; - const API_BASE = "https://api.sgroup.qq.com"; const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; @@ -12,10 +10,10 @@ let currentMarkdownSupport = false; /** * 初始化 API 配置 - * @param options.markdownSupport - 是否支持 markdown 消息 + * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */ export function initApiConfig(options: { markdownSupport?: boolean }): void { - currentMarkdownSupport = options.markdownSupport === true; // 默认为 false + currentMarkdownSupport = options.markdownSupport === true; // 默认为 false,需要机器人具备 markdown 消息权限才能启用 } /** @@ -245,45 +243,35 @@ export async function getGatewayUrl(accessToken: string): Promise { return data.url; } -// ============ 流式消息发送接口 ============ +// ============ 消息发送接口 ============ /** - * 流式消息响应 + * 消息响应 */ -export interface StreamMessageResponse { +export interface MessageResponse { id: string; timestamp: number | string; - /** 流式消息ID,用于后续分片 */ - stream_id?: string; } /** - * 构建流式消息体 + * 构建消息体 * 根据 markdownSupport 配置决定消息格式: * - markdown 模式: { markdown: { content }, msg_type: 2 } * - 纯文本模式: { content, msg_type: 0 } */ -function buildStreamBody( +function buildMessageBody( content: string, msgId: string | undefined, - msgSeq: number, - stream?: StreamConfig + msgSeq: number ): Record { - // 流式 markdown 消息要求每个分片内容必须以换行符结尾 - // QQ API 错误码 40034017: "流式消息md分片需要\n结束" - let finalContent = content; - if (stream && currentMarkdownSupport && content && !content.endsWith("\n")) { - finalContent = content + "\n"; - } - const body: Record = currentMarkdownSupport ? { - markdown: { content: finalContent }, + markdown: { content }, msg_type: 2, msg_seq: msgSeq, } : { - content: finalContent, + content, msg_type: 0, msg_seq: msgSeq, }; @@ -292,29 +280,20 @@ function buildStreamBody( body.msg_id = msgId; } - if (stream) { - body.stream = { - state: stream.state, - index: stream.index, - ...(stream.id ? { id: stream.id } : {}), - }; - } - return body; } /** - * 发送 C2C 单聊消息(支持流式) + * 发送 C2C 单聊消息 */ export async function sendC2CMessage( accessToken: string, openid: string, content: string, - msgId?: string, - stream?: StreamConfig -): Promise { + msgId?: string +): Promise { const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = buildStreamBody(content, msgId, msgSeq, stream); + const body = buildMessageBody(content, msgId, msgSeq); return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body); } @@ -358,17 +337,16 @@ export async function sendChannelMessage( } /** - * 发送群聊消息(支持流式) + * 发送群聊消息 */ export async function sendGroupMessage( accessToken: string, groupOpenid: string, content: string, - msgId?: string, - stream?: StreamConfig -): Promise { + msgId?: string +): Promise { const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = buildStreamBody(content, msgId, msgSeq, stream); + const body = buildMessageBody(content, msgId, msgSeq); return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body); } @@ -458,36 +436,64 @@ export interface UploadMediaResponse { /** * 上传富媒体文件到 C2C 单聊 + * @param url - 公网可访问的图片 URL(与 fileData 二选一) + * @param fileData - Base64 编码的文件内容(与 url 二选一) */ export async function uploadC2CMedia( accessToken: string, openid: string, fileType: MediaFileType, - url: string, + url?: string, + fileData?: string, srvSendMsg = false ): Promise { - return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, { + if (!url && !fileData) { + throw new Error("uploadC2CMedia: url or fileData is required"); + } + + const body: Record = { file_type: fileType, - url, srv_send_msg: srvSendMsg, - }); + }; + + if (url) { + body.url = url; + } else if (fileData) { + body.file_data = fileData; + } + + return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body); } /** * 上传富媒体文件到群聊 + * @param url - 公网可访问的图片 URL(与 fileData 二选一) + * @param fileData - Base64 编码的文件内容(与 url 二选一) */ export async function uploadGroupMedia( accessToken: string, groupOpenid: string, fileType: MediaFileType, - url: string, + url?: string, + fileData?: string, srvSendMsg = false ): Promise { - return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, { + if (!url && !fileData) { + throw new Error("uploadGroupMedia: url or fileData is required"); + } + + const body: Record = { file_type: fileType, - url, srv_send_msg: srvSendMsg, - }); + }; + + if (url) { + body.url = url; + } else if (fileData) { + body.file_data = fileData; + } + + return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body); } /** @@ -532,6 +538,9 @@ export async function sendGroupMediaMessage( /** * 发送带图片的 C2C 单聊消息(封装上传+发送) + * @param imageUrl - 图片来源,支持: + * - 公网 URL: https://example.com/image.png + * - Base64 Data URL: data:image/png;base64,xxxxx */ export async function sendC2CImageMessage( accessToken: string, @@ -540,14 +549,32 @@ export async function sendC2CImageMessage( msgId?: string, content?: string ): Promise<{ id: string; timestamp: number }> { - // 先上传图片获取 file_info - const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false); - // 再发送富媒体消息 + let uploadResult: UploadMediaResponse; + + // 检查是否是 Base64 Data URL + if (imageUrl.startsWith("data:")) { + // 解析 Base64 Data URL: data:image/png;base64,xxxxx + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + throw new Error("Invalid Base64 Data URL format"); + } + const base64Data = matches[2]; + // 使用 file_data 上传 + uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false); + } else { + // 公网 URL,使用 url 参数上传 + uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false); + } + + // 发送富媒体消息 return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content); } /** * 发送带图片的群聊消息(封装上传+发送) + * @param imageUrl - 图片来源,支持: + * - 公网 URL: https://example.com/image.png + * - Base64 Data URL: data:image/png;base64,xxxxx */ export async function sendGroupImageMessage( accessToken: string, @@ -556,9 +583,24 @@ export async function sendGroupImageMessage( msgId?: string, content?: string ): Promise<{ id: string; timestamp: string }> { - // 先上传图片获取 file_info - const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false); - // 再发送富媒体消息 + let uploadResult: UploadMediaResponse; + + // 检查是否是 Base64 Data URL + if (imageUrl.startsWith("data:")) { + // 解析 Base64 Data URL: data:image/png;base64,xxxxx + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + throw new Error("Invalid Base64 Data URL format"); + } + const base64Data = matches[2]; + // 使用 file_data 上传 + uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false); + } else { + // 公网 URL,使用 url 参数上传 + uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false); + } + + // 发送富媒体消息 return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); } @@ -707,4 +749,4 @@ async function sleep(ms: number, signal?: AbortSignal): Promise { signal.addEventListener("abort", onAbort, { once: true }); } }); -} +} \ No newline at end of file diff --git a/src/channel.ts b/src/channel.ts index 4d355ab..2a2288a 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -8,15 +8,14 @@ import { import type { ResolvedQQBotAccount } from "./types.js"; import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js"; -import { sendText, sendMedia, createStreamSender, sendTextStream, StreamSender } from "./outbound.js"; +import { sendText, sendMedia } from "./outbound.js"; import { startGateway } from "./gateway.js"; import { qqbotOnboardingAdapter } from "./onboarding.js"; import { getQQBotRuntime } from "./runtime.js"; /** * 简单的文本分块函数 - * QQ Bot 使用流式消息时,不需要预先分块,而是在发送时逐步累积 - * 但框架可能调用此函数来预分块长文本 + * 用于预先分块长文本 */ function chunkText(text: string, limit: number): string[] { if (text.length <= limit) return [text]; @@ -52,10 +51,10 @@ export const qqbotPlugin: ChannelPlugin = { id: "qqbot", meta: { id: "qqbot", - label: "QQ Bot (Stream)", - selectionLabel: "QQ Bot (Stream)", + label: "QQ Bot", + selectionLabel: "QQ Bot", docsPath: "/docs/channels/qqbot", - blurb: "Connect to QQ via official QQ Bot API with streaming message support", + blurb: "Connect to QQ via official QQ Bot API", order: 50, }, capabilities: { @@ -67,7 +66,7 @@ export const qqbotPlugin: ChannelPlugin = { * blockStreaming: true 表示该 Channel 支持块流式 * 框架会收集流式响应,然后通过 deliver 回调发送 */ - blockStreaming: true, + blockStreaming: false, }, reload: { configPrefixes: ["channels.qqbot"] }, // CLI onboarding wizard @@ -180,7 +179,7 @@ export const qqbotPlugin: ChannelPlugin = { startAccount: async (ctx) => { const { account, abortSignal, log, cfg } = ctx; - log?.info(`[qqbot:${account.accountId}] Starting gateway (stream-enabled)`); + log?.info(`[qqbot:${account.accountId}] Starting gateway`); await startGateway({ account, @@ -282,36 +281,3 @@ export const qqbotPlugin: ChannelPlugin = { }), }, }; - -/** - * 导出流式消息工具函数,供外部使用 - * - * 使用示例: - * ```typescript - * import { createStreamSender } from "qqbot"; - * - * // 创建流式发送器 - * const sender = createStreamSender(account, "group:xxx", replyMsgId); - * - * // 发送第一个分片 (state=1, index=0, id="") - * await sender.send("Hello, ", false); - * - * // 发送中间分片 (state=1, index=1, id=从上次响应获取) - * await sender.send("Hello, this is ", false); - * - * // 发送最后分片并结束 (state=10, index=2) - * await sender.end("Hello, this is a streaming message!"); - * ``` - * - * 或使用 AsyncGenerator: - * ```typescript - * async function* generateText() { - * yield "Hello, "; - * yield "this is "; - * yield "a streaming message!"; - * } - * - * await sendTextStream(ctx, generateText()); - * ``` - */ -export { createStreamSender, sendTextStream, StreamSender }; diff --git a/src/config.ts b/src/config.ts index 38e3621..99678d2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,7 +77,6 @@ export function resolveQQBotAccount( systemPrompt: qqbot?.systemPrompt, imageServerBaseUrl: qqbot?.imageServerBaseUrl, markdownSupport: qqbot?.markdownSupport, - streamEnabled: qqbot?.streamEnabled, }; appId = qqbot?.appId ?? ""; } else { @@ -114,7 +113,6 @@ export function resolveQQBotAccount( systemPrompt: accountConfig.systemPrompt, imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL, markdownSupport: accountConfig.markdownSupport, - streamEnabled: accountConfig.streamEnabled, config: accountConfig, }; } diff --git a/src/gateway.ts b/src/gateway.ts index 306ca9d..1ebe187 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -1,13 +1,12 @@ import WebSocket from "ws"; import path from "node:path"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; -import { StreamState } from "./types.js"; import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js"; import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js"; import { recordKnownUser, flushKnownUsers } from "./known-users.js"; import { getQQBotRuntime } from "./runtime.js"; -import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js"; -import { createStreamSender } from "./outbound.js"; +import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js"; +import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js"; // QQ Bot intents - 按权限级别分组 const INTENTS = { @@ -54,64 +53,6 @@ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765 // 使用绝对路径,确保文件保存和读取使用同一目录 const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-images"); -// 流式消息配置 -const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒) -const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符) -const STREAM_KEEPALIVE_FIRST_DELAY = 3000; // 首次状态保持延迟(毫秒),openclaw 3s 内未回复时发送 -const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫秒) -const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量 -const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束 - -// ============ 智能断句配置 ============ -// 首个分片:必须在语义边界处断句,避免奇怪的换行 -const FIRST_CHUNK_MIN_LENGTH_SOFT = 20; // 软下限:达到此长度后,遇到语义边界就可以发送 -const FIRST_CHUNK_MIN_LENGTH_HARD = 80; // 硬下限:超过此长度必须发送,避免等待太久 -const FIRST_CHUNK_MAX_WAIT_TIME = 3000; // 首个分片最长等待时间(毫秒) - -// 语义边界检测:判断文本是否在自然断句位置结束 -function isAtSemanticBoundary(text: string): boolean { - if (!text) return false; - const trimmed = text.trimEnd(); - if (!trimmed) return false; - - // 检查最后一个字符是否是断句标点 - const lastChar = trimmed[trimmed.length - 1]; - const sentenceEnders = ['。', '!', '?', '~', '…', '.', '!', '?', '\n']; - if (sentenceEnders.includes(lastChar)) return true; - - // 检查是否以 emoji 结尾(常见于提醒消息) - const emojiRegex = /[\u{1F300}-\u{1F9FF}]$/u; - if (emojiRegex.test(trimmed)) return true; - - // 检查最后几个字符是否是 markdown 列表项结束(如 "- xxx" 后面) - // 不算边界,因为列表通常有多项 - - return false; -} - -// 查找最近的语义边界位置 -function findLastSemanticBoundary(text: string, minPos: number = 0): number { - if (!text || text.length <= minPos) return -1; - - const sentenceEnders = ['。', '!', '?', '~', '.', '!', '?']; - let lastBoundary = -1; - - for (let i = text.length - 1; i >= minPos; i--) { - const char = text[i]; - if (sentenceEnders.includes(char)) { - lastBoundary = i + 1; // 包含这个标点符号 - break; - } - // 换行符也是边界 - if (char === '\n') { - lastBoundary = i + 1; - break; - } - } - - return lastBoundary; -} - // 消息队列配置(异步处理,防止阻塞心跳) const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度 const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值 @@ -181,6 +122,94 @@ function recordMessageReply(messageId: string): void { } } +// ============ 图片发送时的文本智能简化 ============ +// 当 AI 发送图片时,检测并移除冗余的解释性文字 + +/** + * 冗余文本模式 - 这些模式表示 AI 在"解释"而不是"回应" + * 通常出现在 AI 不确定图片是否发送成功时 + */ +const REDUNDANT_TEXT_PATTERNS = [ + // 中文冗余模式 + /让我总结一下[^\n]*/gi, + /目前的情况[是::][^\n]*/gi, + /由于[^\n]*(?:工具[集]?|插件|集成|API)[^\n]*(?:限制|问题)[^\n]*/gi, + /我已经[^\n]*(?:尝试|下载|保存)[^\n]*/gi, + /最实用的(?:方法|解决方案)[是::][^\n]*/gi, + /如果你希望我继续[^\n]*/gi, + /你可以[直接]?点击[^\n]*链接[^\n]*/gi, + /我注意到你重复[^\n]*/gi, + /我[已经]?多次尝试[^\n]*/gi, + /(?:已经|成功)?(?:保存|下载)到本地[^\n]*/gi, + /(?:直接)?(?:查看|访问)[该这]?(?:图片|文件|链接)[^\n]*/gi, + // 英文冗余模式 + /let me summarize[^\n]*/gi, + /i(?:'ve| have) tried[^\n]*(?:multiple|several)[^\n]*/gi, + /due to[^\n]*(?:tool|plugin|integration)[^\n]*limitation[^\n]*/gi, + /the most practical[^\n]*solution[^\n]*/gi, +]; + +/** + * 检查文本是否为纯冗余解释 + * 如果整个文本都是在解释发送过程,而不是描述图片内容,则返回 true + */ +function isEntirelyRedundantExplanation(text: string): boolean { + // 移除空行和空格 + const trimmed = text.trim(); + if (!trimmed) return true; + + // 检查是否包含"步骤列表"类的解释 + const hasStepList = /^\d+\.\s+/m.test(trimmed) && + (trimmed.includes("下载") || trimmed.includes("尝试") || trimmed.includes("发送")); + + // 检查是否主要由冗余模式组成 + let cleaned = trimmed; + for (const pattern of REDUNDANT_TEXT_PATTERNS) { + cleaned = cleaned.replace(pattern, ""); + } + + // 如果清理后只剩下很少的文字(主要是标点和连接词),认为整体都是冗余 + const cleanedWords = cleaned.replace(/[\s\n\r.,;:!?,。;:!?·…—""''()()【】[\]{}]+/g, "").trim(); + const significantContentRemaining = cleanedWords.length > 20; + + return hasStepList || !significantContentRemaining; +} + +/** + * 智能简化图片发送时的文本 + * 当检测到发送图片时,移除冗余的解释性文字 + * + * @param text 原始文本 + * @param hasImages 是否包含图片 + * @returns 简化后的文本 + */ +function simplifyTextForImageSend(text: string, hasImages: boolean): string { + if (!hasImages || !text) return text; + + const trimmed = text.trim(); + + // 如果整个文本都是冗余解释,替换为简短的成功提示 + if (isEntirelyRedundantExplanation(trimmed)) { + return "图片如上 ☝️"; + } + + // 否则,只移除明显的冗余段落 + let result = trimmed; + for (const pattern of REDUNDANT_TEXT_PATTERNS) { + result = result.replace(pattern, ""); + } + + // 清理多余的空行 + result = result.replace(/\n{3,}/g, "\n\n").trim(); + + // 如果清理后文本太短,恢复原文 + if (result.length < 10 && trimmed.length > 50) { + return "图片如上 ☝️"; + } + + return result || trimmed; +} + export interface GatewayContext { account: ResolvedQQBotAccount; abortSignal: AbortSignal; @@ -250,7 +279,7 @@ export async function startGateway(ctx: GatewayContext): Promise { initApiConfig({ markdownSupport: account.markdownSupport, }); - log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport !== false}`); + log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`); // 如果配置了公网 URL,启动图床服务器 let imageServerBaseUrl: string | null = null; @@ -444,10 +473,6 @@ export async function startGateway(ctx: GatewayContext): Promise { log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); } - // 流式消息开关(默认禁用,仅 c2c 支持,需要在配置中明确启用) - const streamEnabled = account.streamEnabled === true; - log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`); - pluginRuntime.channel.activity.record({ channel: "qqbot", accountId: account.accountId, @@ -516,8 +541,7 @@ openclaw cron add \\ ⚠️ 重要注意事项: 1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式 -2. 定时提醒消息不支持流式发送,命令中不要添加 --stream 参数 -3. --message 参数必须有实际内容,不能为空字符串`; +2. --message 参数必须有实际内容,不能为空字符串`; // 只有配置了图床公网地址,才告诉 AI 可以发送图片 if (imageServerBaseUrl) { @@ -661,315 +685,11 @@ openclaw cron add \\ }, responseTimeout); }); - // ============ 流式消息发送器 ============ + // ============ 消息发送目标 ============ // 确定发送目标 const targetTo = event.type === "c2c" ? event.senderId : event.type === "group" ? `group:${event.groupOpenid}` : `channel:${event.channelId}`; - - // 判断是否支持流式(仅 c2c 支持,群聊不支持流式,且需要开关启用) - const supportsStream = event.type === "c2c" && streamEnabled; - log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`); - - // 创建流式发送器 - let streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null; - let streamBuffer = ""; // 累积的全部文本(用于记录完整内容) - let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量) - let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落) - let currentSegmentStart = 0; // 当前段落在 streamBuffer 中的起始位置 - let lastStreamSendTime = 0; // 上次流式发送时间 - let streamStarted = false; // 是否已开始流式发送 - let streamEnded = false; // 流式是否已结束 - let streamStartTime = 0; // 流式消息开始时间(用于超时检查) - let sendingLock = false; // 发送锁,防止并发发送 - let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累) - let firstChunkWaitStart = 0; // 首个分片开始等待的时间(用于超时判断) - let keepaliveTimer: ReturnType | null = null; // 心跳定时器 - let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量 - let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持) - - // 清理心跳定时器 - const clearKeepalive = () => { - if (keepaliveTimer) { - clearTimeout(keepaliveTimer); - keepaliveTimer = null; - } - }; - - // 重置心跳定时器(每次发送后调用) - // isContentChunk: 是否为内容分片(非状态保持消息) - const resetKeepalive = (isContentChunk: boolean = false) => { - clearKeepalive(); - - // 如果是内容分片,重置状态保持计数器和时间 - if (isContentChunk) { - keepaliveCountSinceLastChunk = 0; - lastChunkSendTime = Date.now(); - } - - if (streamSender && streamStarted && !streamEnded) { - // 计算下次状态保持消息的延迟时间 - // - 首次:3s(STREAM_KEEPALIVE_FIRST_DELAY) - // - 后续:10s(STREAM_KEEPALIVE_GAP) - const delay = keepaliveCountSinceLastChunk === 0 - ? STREAM_KEEPALIVE_FIRST_DELAY - : STREAM_KEEPALIVE_GAP; - - keepaliveTimer = setTimeout(async () => { - // 检查流式消息是否超时(超过 3 分钟自动结束) - const elapsed = Date.now() - streamStartTime; - if (elapsed >= STREAM_MAX_DURATION) { - log?.info(`[qqbot:${account.accountId}] Stream timeout after ${Math.round(elapsed / 1000)}s, auto ending stream`); - if (!streamEnded && !sendingLock) { - sendingLock = true; - try { - // 发送结束标记 - await streamSender!.send("", true); - streamEnded = true; - clearKeepalive(); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Stream auto-end failed: ${err}`); - } finally { - sendingLock = false; - } - } - return; // 超时后不再继续心跳 - } - - // 检查是否已达到每2个分片之间的最大状态保持消息数量 - if (keepaliveCountSinceLastChunk >= STREAM_KEEPALIVE_MAX_PER_CHUNK) { - log?.debug?.(`[qqbot:${account.accountId}] Max keepalive reached (${keepaliveCountSinceLastChunk}/${STREAM_KEEPALIVE_MAX_PER_CHUNK}), waiting for next content chunk`); - // 不再发送状态保持,但继续监控超时 - resetKeepalive(false); - return; - } - - // 检查距上次分片是否超过 3s - const timeSinceLastChunk = Date.now() - lastChunkSendTime; - if (timeSinceLastChunk < STREAM_KEEPALIVE_FIRST_DELAY) { - // 还未到发送状态保持的时机,继续等待 - resetKeepalive(false); - return; - } - - // 发送状态保持消息 - if (!streamEnded && !sendingLock) { - log?.info(`[qqbot:${account.accountId}] Sending keepalive #${keepaliveCountSinceLastChunk + 1} (elapsed: ${Math.round(elapsed / 1000)}s, since chunk: ${Math.round(timeSinceLastChunk / 1000)}s)`); - sendingLock = true; - try { - // 发送空内容 - await streamSender!.send("", false); - lastStreamSendTime = Date.now(); - keepaliveCountSinceLastChunk++; - resetKeepalive(false); // 继续下一个状态保持(非内容分片) - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`); - } finally { - sendingLock = false; - } - } - }, delay); - } - }; - - // 流式发送函数 - 用于 onPartialReply 实时发送(增量模式) - // 注意:不要在分片后强制添加换行符,否则会导致消息在奇怪的位置断句 - const sendStreamChunk = async (text: string, isEnd: boolean): Promise => { - if (!streamSender || streamEnded) return false; - - // 直接发送文本内容,不添加任何额外换行符 - // 换行应该由 AI 生成的内容本身决定,而非强制添加 - const contentToSend = text; - - const result = await streamSender.send(contentToSend, isEnd); - if (result.error) { - log?.error(`[qqbot:${account.accountId}] Stream send error: ${result.error}`); - return false; - } else { - log?.debug?.(`[qqbot:${account.accountId}] Stream chunk sent, index: ${streamSender.getContext().index - 1}, isEnd: ${isEnd}, text: "${text.slice(0, 50)}..."`); - } - - if (isEnd) { - streamEnded = true; - clearKeepalive(); - } else { - // 发送成功后重置心跳,如果是有内容的分片则重置计数器 - const isContentChunk = text.length > 0; - resetKeepalive(isContentChunk); - } - return true; - }; - - // 执行一次流式发送(带锁保护) - const doStreamSend = async (fullText: string, forceEnd: boolean = false): Promise => { - // 如果正在发送,记录待发送的完整文本,稍后处理 - if (sendingLock) { - pendingFullText = fullText; - return; - } - - sendingLock = true; - try { - // 发送当前增量 - if (fullText.length > lastSentLength) { - const increment = fullText.slice(lastSentLength); - // 首次发送前,先设置流式状态和开始时间 - if (!streamStarted) { - streamStarted = true; - streamStartTime = Date.now(); - log?.info(`[qqbot:${account.accountId}] Stream started, max duration: ${STREAM_MAX_DURATION / 1000}s`); - } - const success = await sendStreamChunk(increment, forceEnd); - if (success) { - lastSentLength = fullText.length; - lastSentText = fullText; // 记录完整发送文本,用于检测新段落 - lastStreamSendTime = Date.now(); - log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`); - } - } else if (forceEnd && !streamEnded) { - // 没有新内容但需要结束 - await sendStreamChunk("", true); - } - } finally { - sendingLock = false; - } - - // 处理在锁定期间积累的内容 - if (pendingFullText && pendingFullText.length > lastSentLength && !streamEnded) { - const pending = pendingFullText; - pendingFullText = ""; - // 递归发送积累的内容(不强制结束) - await doStreamSend(pending, false); - } - }; - - // onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文) - // 注意:agent 在一次对话中可能产生多个回复段落(如思考、工具调用后继续回复) - // 每个新段落的 text 会从头开始累积,需要检测并处理 - const handlePartialReply = async (payload: { text?: string }) => { - if (!streamSender || streamEnded) { - log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`); - return; - } - - const fullText = payload.text ?? ""; - if (!fullText) { - log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: empty text`); - return; - } - - hasResponse = true; - - // 检测是否是新段落: - // 1. lastSentText 不为空(说明已经发送过内容) - // 2. 当前文本不是以 lastSentText 开头(说明不是同一段落的增量) - // 3. 当前文本长度小于 lastSentLength(说明文本被重置了) - const isNewSegment = lastSentText.length > 0 && - (fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length)))); - - if (isNewSegment) { - // 新段落开始,结束当前流并创建新流 - log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`); - - // 保存旧的 sender 用于结束流 - const oldStreamSender = streamSender; - const oldStreamStarted = streamStarted; - const oldStreamEnded = streamEnded; - - // 1. 先创建新的流式发送器并重置所有状态 - // 这样在 await 期间到达的新消息会使用新 sender - streamSender = createStreamSender(account, targetTo, event.messageId); - lastSentLength = 0; - lastSentText = ""; - streamStarted = false; - streamEnded = false; - streamStartTime = 0; - keepaliveCountSinceLastChunk = 0; - lastChunkSendTime = 0; - firstChunkWaitStart = 0; // 重置首个分片等待时间 - - // 记录当前段落在 streamBuffer 中的起始位置 - currentSegmentStart = streamBuffer.length; - - // 追加换行分隔符(如果前面有内容且不以换行结尾) - if (streamBuffer.length > 0 && !streamBuffer.endsWith("\n")) { - streamBuffer += "\n\n"; - currentSegmentStart = streamBuffer.length; - } - - // 2. 结束旧流(如果已开始)- 使用旧的 sender - if (oldStreamSender && oldStreamStarted && !oldStreamEnded) { - log?.info(`[qqbot:${account.accountId}] Ending current stream before starting new segment`); - clearKeepalive(); - sendingLock = true; - try { - await oldStreamSender.send("", true); // 发送结束标记 - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to end stream: ${err}`); - } finally { - sendingLock = false; - } - } - } - - // 更新当前段落内容到 streamBuffer - // streamBuffer = 之前的段落内容 + 当前段落的完整内容 - const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart); - streamBuffer = beforeCurrentSegment + fullText; - - log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}, streamBuffer.length=${streamBuffer.length}, isNewSegment=${isNewSegment}`); - - // 如果没有新内容,跳过 - if (fullText.length <= lastSentLength) return; - - const now = Date.now(); - - // 初始化首个分片等待开始时间(如果还没有开始) - if (!streamStarted && !firstChunkWaitStart) { - firstChunkWaitStart = now; - } - - // 控制发送频率:首次发送或间隔超过阈值 - if (!streamStarted) { - // 首个分片:智能断句,在语义边界处发送 - const waitTime = firstChunkWaitStart ? now - firstChunkWaitStart : 0; - const atBoundary = isAtSemanticBoundary(fullText); - const reachedSoftLimit = fullText.length >= FIRST_CHUNK_MIN_LENGTH_SOFT; - const reachedHardLimit = fullText.length >= FIRST_CHUNK_MIN_LENGTH_HARD; - const timedOut = waitTime >= FIRST_CHUNK_MAX_WAIT_TIME; - - // 发送条件(优先级从高到低): - // 1. 达到硬下限:必须发送,避免等待太久 - // 2. 等待超时:必须发送,避免无响应 - // 3. 达到软下限 + 在语义边界:可以发送 - if (reachedHardLimit || timedOut) { - // 硬性条件:必须发送 - if (timedOut && !reachedSoftLimit) { - log?.info(`[qqbot:${account.accountId}] handlePartialReply: first chunk timeout, sending anyway, length=${fullText.length}, wait=${waitTime}ms`); - } else { - log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending first chunk (hard limit), length=${fullText.length}`); - } - await doStreamSend(fullText, false); - firstChunkWaitStart = 0; // 重置等待时间 - } else if (reachedSoftLimit && atBoundary) { - // 软性条件:在语义边界处发送 - log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending first chunk (at boundary), length=${fullText.length}`); - await doStreamSend(fullText, false); - firstChunkWaitStart = 0; - } else { - // 还需要等待更多内容 - log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: waiting for semantic boundary, length=${fullText.length}, atBoundary=${atBoundary}, wait=${waitTime}ms`); - pendingFullText = fullText; - } - } else if (now - lastStreamSendTime >= STREAM_CHUNK_INTERVAL) { - // 后续分片:基于时间间隔发送 - log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending stream chunk, length=${fullText.length}`); - await doStreamSend(fullText, false); - } else { - // 不到发送时间,但记录待发送内容,确保最终会被发送 - pendingFullText = fullText; - } - }; const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, @@ -987,169 +707,244 @@ openclaw cron add \\ let replyText = payload.text ?? ""; - // 更新当前段落内容到 streamBuffer - // deliver 中的 replyText 是当前段落的完整文本 - if (replyText.length > 0) { - const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart); - const newStreamBuffer = beforeCurrentSegment + replyText; - if (newStreamBuffer.length > streamBuffer.length) { - streamBuffer = newStreamBuffer; - log?.debug?.(`[qqbot:${account.accountId}] deliver: updated streamBuffer, replyText=${replyText.length}, total=${streamBuffer.length}`); - } - } - - // 收集所有图片路径 + // 收集所有图片(公网 URL 或 Base64 Data URL) + // 注意:本地文件路径由 OpenClaw 负责转换(通过对象存储上传或读取为 Base64) const imageUrls: string[] = []; - // 处理 mediaUrls 和 mediaUrl 字段(本地文件路径) - const mediaPaths: string[] = []; - if (payload.mediaUrls?.length) { - mediaPaths.push(...payload.mediaUrls); - } - if (payload.mediaUrl && !mediaPaths.includes(payload.mediaUrl)) { - mediaPaths.push(payload.mediaUrl); - } - - for (const localPath of mediaPaths) { - if (localPath && imageServerBaseUrl) { - try { - const savedUrl = saveImageFromPath(localPath); - if (savedUrl) { - imageUrls.push(savedUrl); - log?.info(`[qqbot:${account.accountId}] Saved media to server: ${localPath}`); + /** + * 检查并收集图片 URL + * 支持:公网 URL (http/https) 和 Base64 Data URL (data:image/...) + */ + const collectImageUrl = (url: string | undefined | null): boolean => { + if (!url) return false; + + const isHttpUrl = url.startsWith("http://") || url.startsWith("https://"); + const isDataUrl = url.startsWith("data:image/"); + + if (isHttpUrl || isDataUrl) { + if (!imageUrls.includes(url)) { + imageUrls.push(url); + if (isDataUrl) { + log?.info(`[qqbot:${account.accountId}] Collected Base64 image (length: ${url.length})`); } else { - log?.error(`[qqbot:${account.accountId}] Failed to save media (not found or not image): ${localPath}`); + log?.info(`[qqbot:${account.accountId}] Collected media URL: ${url.slice(0, 80)}...`); } - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to save media: ${err}`); } + return true; } + + // 检测本地文件路径 + const isLocalPath = url.startsWith("/") || + /^[a-zA-Z]:[\\/]/.test(url) || + url.startsWith("./") || + url.startsWith("../"); + + if (isLocalPath) { + log?.info(`[qqbot:${account.accountId}] Skipped local file path (OpenClaw should convert to Base64 or upload): ${url.slice(0, 80)}`); + } else { + log?.info(`[qqbot:${account.accountId}] Skipped unsupported media format: ${url.slice(0, 50)}`); + } + return false; + }; + + // 处理 mediaUrls 和 mediaUrl 字段 + if (payload.mediaUrls?.length) { + for (const url of payload.mediaUrls) { + collectImageUrl(url); + } + } + if (payload.mediaUrl) { + collectImageUrl(payload.mediaUrl); } - // 提取文本中的各种图片格式 - // 0. 提取 MEDIA: 前缀的本地文件路径 - const mediaPathRegex = /MEDIA:([^\s\n]+)/gi; - const mediaMatches = [...replyText.matchAll(mediaPathRegex)]; - for (const match of mediaMatches) { - const localPath = match[1]; - if (localPath && imageServerBaseUrl) { - try { - const savedUrl = saveImageFromPath(localPath); - if (savedUrl) { - imageUrls.push(savedUrl); - log?.info(`[qqbot:${account.accountId}] Saved local image to server: ${localPath}`); - } - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to save local image: ${err}`); - } - } - replyText = replyText.replace(match[0], "").trim(); - } - - // 0.5. 提取本地绝对文件路径 - const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi; - const localPathMatches = [...replyText.matchAll(localPathRegex)]; - for (const match of localPathMatches) { - let localPath = match[1].trim(); - if (localPath && imageServerBaseUrl) { - localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, "."); - try { - const savedUrl = saveImageFromPath(localPath); - if (savedUrl) { - imageUrls.push(savedUrl); - log?.info(`[qqbot:${account.accountId}] Saved local path image to server: ${localPath}`); - } - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to save local path image: ${err}`); - } - } - replyText = replyText.replace(match[0], "").trim(); - } - - // 1. 提取 base64 图片 - const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(? 0; - if (!hasImages && textWithoutImages) { - const originalText = textWithoutImages; - textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); - if (textWithoutImages !== originalText && textWithoutImages.trim()) { - textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)"; + // 2. 提取裸 URL 图片(仅在非 markdown 模式下移除) + const bareUrlRegex = /(?]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi; + const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)]; + for (const match of bareUrlMatches) { + const url = match[1]; + if (url && !imageUrls.includes(url)) { + imageUrls.push(url); + log?.info(`[qqbot:${account.accountId}] Extracted bare image URL: ${url.slice(0, 80)}...`); } } - - try { - // 发送图片(如果有) - for (const imageUrl of imageUrls) { + + // 判断是否使用 markdown 模式 + const useMarkdown = account.markdownSupport === true; + log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`); + + let textWithoutImages = replyText; + + // 🎯 智能简化文本:当发送图片时,移除冗余的解释性文字 + // 这解决了 AI 不确定图片是否发送成功而输出大量废话的问题 + if (imageUrls.length > 0) { + const originalLength = textWithoutImages.length; + textWithoutImages = simplifyTextForImageSend(textWithoutImages, true); + if (textWithoutImages.length !== originalLength) { + log?.info(`[qqbot:${account.accountId}] Simplified text for image send: ${originalLength} -> ${textWithoutImages.length} chars`); + } + } + + // 根据模式处理图片 + if (useMarkdown) { + // ============ Markdown 模式:使用 ![#宽px #高px](url) 格式 ============ + // QQBot 的 markdown 图片格式要求:![#宽px #高px](url) + // 需要自动获取图片尺寸,或使用默认尺寸 + + // 记录已存在于文本中的 markdown 图片 URL + const existingMdUrls = new Set(mdMatches.map(m => m[2])); + + // 需要追加的图片(从 mediaUrl/mediaUrls 来的) + const imagesToAppend: string[] = []; + + // 处理需要追加的图片:获取尺寸并格式化 + for (const url of imageUrls) { + if (!existingMdUrls.has(url)) { + // 这个 URL 不在文本的 markdown 格式中,需要追加 + // 尝试获取图片尺寸 + try { + const size = await getImageSize(url); + const mdImage = formatQQBotMarkdownImage(url, size); + imagesToAppend.push(mdImage); + log?.info(`[qqbot:${account.accountId}] Formatted image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`); + } catch (err) { + // 获取尺寸失败,使用默认尺寸 + log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`); + const mdImage = formatQQBotMarkdownImage(url, null); + imagesToAppend.push(mdImage); + } + } + } + + // 处理文本中已有的 markdown 图片:检查是否需要补充尺寸信息 + for (const match of mdMatches) { + const fullMatch = match[0]; // ![alt](url) + const altText = match[1]; // alt 部分 + const imgUrl = match[2]; // url 部分 + + // 检查是否已经有 QQBot 格式的尺寸 ![#宽px #高px](url) + if (!hasQQBotImageSize(fullMatch)) { + // 没有尺寸信息,需要补充 + try { + const size = await getImageSize(imgUrl); + const newMdImage = formatQQBotMarkdownImage(imgUrl, size); + textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage); + log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`); + } catch (err) { + // 获取尺寸失败,使用默认尺寸 + log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`); + const newMdImage = formatQQBotMarkdownImage(imgUrl, null); + textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage); + } + } + // 如果已经有尺寸信息,保留原格式 + } + + // 从文本中移除裸 URL 图片(已转换为 markdown 格式) + for (const match of bareUrlMatches) { + textWithoutImages = textWithoutImages.replace(match[0], "").trim(); + } + + // 追加需要添加的图片到文本末尾 + if (imagesToAppend.length > 0) { + textWithoutImages = textWithoutImages.trim(); + if (textWithoutImages) { + textWithoutImages += "\n\n" + imagesToAppend.join("\n"); + } else { + textWithoutImages = imagesToAppend.join("\n"); + } + } + + // 发送带图片的 markdown 消息(文本+图片一起发送) + if (textWithoutImages.trim()) { try { await sendWithTokenRetry(async (token) => { if (event.type === "c2c") { - await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); + await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId); } else if (event.type === "group" && event.groupOpenid) { - await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); + await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId); + } else if (event.channelId) { + await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); } }); - log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`); - } catch (imgErr) { - log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`); + log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${imageUrls.length} images (${event.type})`); + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`); } } - - // 非流式模式下,在 deliver 中发送文本 - // 流式模式下,c2c 的文本通过 onPartialReply 流式发送 - if (!supportsStream && textWithoutImages.trim()) { - await sendWithTokenRetry(async (token) => { - if (event.type === "c2c") { - // c2c 非流式消息发送 - await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId); - } else if (event.type === "group" && event.groupOpenid) { - await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId); - } else if (event.channelId) { - await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); - } - }); - log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type}, non-stream)`); + } else { + // ============ 普通文本模式:使用富媒体 API 发送图片 ============ + // 从文本中移除所有图片相关内容 + for (const match of mdMatches) { + textWithoutImages = textWithoutImages.replace(match[0], "").trim(); } + for (const match of bareUrlMatches) { + textWithoutImages = textWithoutImages.replace(match[0], "").trim(); + } + + // 处理文本中的 URL 点号(防止被 QQ 解析为链接) + if (textWithoutImages) { + const originalText = textWithoutImages; + textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); + if (textWithoutImages !== originalText && textWithoutImages.trim()) { + textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)"; + } + } + + try { + // 发送图片(通过富媒体 API) + for (const imageUrl of imageUrls) { + try { + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); + } else if (event.channelId) { + // 频道暂不支持富媒体,发送文本 URL + await sendChannelMessage(token, event.channelId, imageUrl, event.messageId); + } + }); + log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`); + } catch (imgErr) { + log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`); + } + } - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); + // 发送文本消息 + if (textWithoutImages.trim()) { + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId); + } else if (event.channelId) { + await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); + } + }); + log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`); + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); + } } + + pluginRuntime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "outbound", + }); }, onError: async (err: unknown) => { log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); @@ -1159,27 +954,6 @@ openclaw cron add \\ timeoutId = null; } - // 清理心跳定时器 - clearKeepalive(); - - // 如果在流式模式中出错,发送结束标记(增量模式) - if (streamSender && !streamEnded && streamBuffer) { - try { - // 等待发送锁释放 - while (sendingLock) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - // 发送剩余增量 + 错误标记 - const remainingIncrement = streamBuffer.slice(lastSentLength); - const errorIncrement = remainingIncrement + "\n\n[生成中断]"; - await streamSender.end(errorIncrement); - streamEnded = true; - log?.info(`[qqbot:${account.accountId}] Stream ended due to error`); - } catch (endErr) { - log?.error(`[qqbot:${account.accountId}] Failed to end stream: ${endErr}`); - } - } - // 发送错误提示给用户,显示完整错误信息 const errMsg = String(err); if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { @@ -1190,47 +964,13 @@ openclaw cron add \\ } }, }, - replyOptions: { - // 使用 onPartialReply 实现真正的流式消息 - // 这个回调在 AI 生成过程中被实时调用 - onPartialReply: supportsStream ? handlePartialReply : undefined, - // 禁用 block streaming,因为我们用 onPartialReply 实现更实时的流式 - disableBlockStreaming: supportsStream, - }, + replyOptions: {}, }); // 等待分发完成或超时 try { await Promise.race([dispatchPromise, timeoutPromise]); - - // 清理心跳定时器 - clearKeepalive(); - - // 分发完成后,如果使用了流式且有内容,发送结束标记 - if (streamSender && !streamEnded) { - // 等待发送锁释放 - while (sendingLock) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - - // 确保所有待发送内容都发送出去 - // 当前段落的最新完整文本 - const currentSegmentText = pendingFullText && pendingFullText.length > (streamBuffer.length - currentSegmentStart) - ? pendingFullText - : streamBuffer.slice(currentSegmentStart); - - // 计算当前段落剩余未发送的增量内容 - const remainingIncrement = currentSegmentText.slice(lastSentLength); - if (remainingIncrement || streamStarted) { - // 有剩余内容或者已开始流式,都需要发送结束标记 - await streamSender.end(remainingIncrement); - streamEnded = true; - log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total streamBuffer: ${streamBuffer.length} chars, chunks: ${streamSender.getContext().index}`); - } - } } catch (err) { - // 清理心跳定时器 - clearKeepalive(); if (timeoutId) { clearTimeout(timeoutId); } diff --git a/src/image-server.ts b/src/image-server.ts index 8bda378..80996d4 100644 --- a/src/image-server.ts +++ b/src/image-server.ts @@ -385,6 +385,34 @@ export function isImageServerRunning(): boolean { return serverInstance !== null; } +/** + * 确保图床服务器正在运行 + * 如果未运行,则自动启动 + * @param publicBaseUrl 公网访问的基础 URL(如 http://your-server:18765) + * @returns 基础 URL,启动失败返回 null + */ +export async function ensureImageServer(publicBaseUrl?: string): Promise { + if (isImageServerRunning()) { + return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`; + } + + try { + const config: Partial = { + port: DEFAULT_CONFIG.port, + storageDir: DEFAULT_CONFIG.storageDir, + // 使用用户配置的公网地址 + baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`, + ttlSeconds: 3600, // 1 小时过期 + }; + await startImageServer(config); + console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`); + return config.baseUrl!; + } catch (err) { + console.error(`[image-server] Failed to auto-start: ${err}`); + return null; + } +} + /** * 下载远程文件并保存到本地 * @param url 远程文件 URL diff --git a/src/onboarding.ts b/src/onboarding.ts index 7d9b504..f634b5e 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -63,7 +63,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = { return { channel: "qqbot" as any, configured, - statusLines: [`QQ Bot (Stream): ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`], +statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`], selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)", quickstartScore: configured ? 1 : 20, }; @@ -119,7 +119,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = { "", "此版本支持流式消息发送!", ].join("\n"), - "QQ Bot (Stream) 配置", +"QQ Bot 配置", ); } diff --git a/src/outbound.ts b/src/outbound.ts index 2a00889..5b249ab 100644 --- a/src/outbound.ts +++ b/src/outbound.ts @@ -1,10 +1,11 @@ /** - * QQ Bot 消息发送模块(支持流式消息) + * QQ Bot 消息发送模块 */ -import type { ResolvedQQBotAccount, StreamContext } from "./types.js"; -import { StreamState } from "./types.js"; -import { +import * as fs from "fs"; +import * as path from "path"; +import type { ResolvedQQBotAccount } from "./types.js"; +import { getAccessToken, sendC2CMessage, sendChannelMessage, @@ -13,7 +14,6 @@ import { sendProactiveGroupMessage, sendC2CImageMessage, sendGroupImageMessage, - type StreamMessageResponse, } from "./api.js"; // ============ 消息回复限流器 ============ @@ -160,155 +160,6 @@ export interface OutboundResult { messageId?: string; timestamp?: string | number; error?: string; - /** 流式消息ID,用于后续分片 */ - streamId?: string; -} - -/** - * 流式消息发送器 - * 用于管理一个完整的流式消息会话 - */ -export class StreamSender { - private context: StreamContext; - private accessToken: string | null = null; - private targetType: "c2c" | "group" | "channel"; - private targetId: string; - private msgId?: string; - private account: ResolvedQQBotAccount; - - constructor( - account: ResolvedQQBotAccount, - to: string, - replyToId?: string | null - ) { - this.account = account; - this.msgId = replyToId ?? undefined; - this.context = { - index: 0, - streamId: "", - ended: false, - }; - - // 解析目标地址 - const target = parseTarget(to); - this.targetType = target.type; - this.targetId = target.id; - } - - /** - * 发送流式消息分片 - * @param text 分片内容 - * @param isEnd 是否是最后一个分片 - * @returns 发送结果 - */ - async send(text: string, isEnd = false): Promise { - if (this.context.ended) { - return { channel: "qqbot", error: "Stream already ended" }; - } - - if (!this.account.appId || !this.account.clientSecret) { - return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; - } - - try { - // 获取或复用 accessToken - if (!this.accessToken) { - this.accessToken = await getAccessToken(this.account.appId, this.account.clientSecret); - } - - const streamConfig = { - state: isEnd ? StreamState.END : StreamState.STREAMING, - index: this.context.index, - id: this.context.streamId, - }; - - let result: StreamMessageResponse; - - if (this.targetType === "c2c") { - result = await sendC2CMessage( - this.accessToken, - this.targetId, - text, - this.msgId, - streamConfig - ); - } else if (this.targetType === "group") { - // 群聊不支持流式,直接发送普通消息 - const groupResult = await sendGroupMessage( - this.accessToken, - this.targetId, - text, - this.msgId - // 不传 streamConfig - ); - return { - channel: "qqbot", - messageId: groupResult.id, - timestamp: groupResult.timestamp - }; - } else { - // 频道不支持流式,直接发送普通消息 - const channelResult = await sendChannelMessage( - this.accessToken, - this.targetId, - text, - this.msgId - ); - return { - channel: "qqbot", - messageId: channelResult.id, - timestamp: channelResult.timestamp - }; - } - - // 更新流式上下文 - // 第一次发送后,服务端会返回 stream_id(或在 id 字段中),后续需要带上 - if (this.context.index === 0 && result.stream_id) { - this.context.streamId = result.stream_id; - } else if (this.context.index === 0 && result.id && !this.context.streamId) { - // 某些情况下 stream_id 可能在 id 字段返回 - this.context.streamId = result.id; - } - - this.context.index++; - - if (isEnd) { - this.context.ended = true; - } - - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - streamId: this.context.streamId, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { channel: "qqbot", error: message }; - } - } - - /** - * 结束流式消息 - * @param text 最后一个分片的内容(可选) - */ - async end(text?: string): Promise { - return this.send(text ?? "", true); - } - - /** - * 获取当前流式上下文状态 - */ - getContext(): Readonly { - return { ...this.context }; - } - - /** - * 是否已结束 - */ - isEnded(): boolean { - return this.context.ended; - } } /** @@ -440,69 +291,6 @@ export async function sendText(ctx: OutboundContext): Promise { } } -/** - * 流式发送文本消息 - * - * @param ctx 发送上下文 - * @param textGenerator 异步文本生成器,每次 yield 一个分片 - * @returns 最终发送结果 - * - * @example - * ```typescript - * async function* generateText() { - * yield "Hello, "; - * yield "this is "; - * yield "a streaming "; - * yield "message!"; - * } - * - * const result = await sendTextStream(ctx, generateText()); - * ``` - */ -export async function sendTextStream( - ctx: OutboundContext, - textGenerator: AsyncIterable -): Promise { - const { to, replyToId, account } = ctx; - - const sender = new StreamSender(account, to, replyToId); - let lastResult: OutboundResult = { channel: "qqbot" }; - let buffer = ""; - - try { - for await (const chunk of textGenerator) { - buffer += chunk; - - // 发送当前分片 - lastResult = await sender.send(buffer, false); - - if (lastResult.error) { - return lastResult; - } - } - - // 发送结束标记 - lastResult = await sender.end(buffer); - - return lastResult; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { channel: "qqbot", error: message }; - } -} - -/** - * 创建流式消息发送器 - * 提供更细粒度的控制 - */ -export function createStreamSender( - account: ResolvedQQBotAccount, - to: string, - replyToId?: string | null -): StreamSender { - return new StreamSender(account, to, replyToId); -} - /** * 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群) * @@ -543,11 +331,17 @@ export async function sendProactiveMessage( /** * 发送富媒体消息(图片) * + * 支持以下 mediaUrl 格式: + * - 公网 URL: https://example.com/image.png + * - Base64 Data URL: data:image/png;base64,xxxxx + * - 本地文件路径: /path/to/image.png(自动读取并转换为 Base64) + * * @param ctx - 发送上下文,包含 mediaUrl * @returns 发送结果 * * @example * ```typescript + * // 发送网络图片 * const result = await sendMedia({ * to: "group:xxx", * text: "这是图片说明", @@ -555,10 +349,29 @@ export async function sendProactiveMessage( * account, * replyToId: msgId, * }); + * + * // 发送 Base64 图片 + * const result = await sendMedia({ + * to: "group:xxx", + * text: "这是图片说明", + * mediaUrl: "data:image/png;base64,iVBORw0KGgo...", + * account, + * replyToId: msgId, + * }); + * + * // 发送本地文件(自动读取并转换为 Base64) + * const result = await sendMedia({ + * to: "group:xxx", + * text: "这是图片说明", + * mediaUrl: "/tmp/generated-chart.png", + * account, + * replyToId: msgId, + * }); * ``` */ export async function sendMedia(ctx: MediaOutboundContext): Promise { - const { to, text, mediaUrl, replyToId, account } = ctx; + const { to, text, replyToId, account } = ctx; + const { mediaUrl } = ctx; if (!account.appId || !account.clientSecret) { return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; @@ -568,17 +381,87 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + }; + + const mimeType = mimeTypes[ext]; + if (!mimeType) { + return { + channel: "qqbot", + error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}` + }; + } + + // 构造 Data URL + processedMediaUrl = `data:${mimeType};base64,${base64Data}`; + console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`); + + } catch (readErr) { + const errMsg = readErr instanceof Error ? readErr.message : String(readErr); + console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`); + return { + channel: "qqbot", + error: `读取本地文件失败: ${errMsg}` + }; + } + } else if (!isHttpUrl && !isDataUrl) { + console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`); + return { + channel: "qqbot", + error: `不支持的图片格式: ${mediaUrl.slice(0, 50)}...。支持的格式: 公网 URL (http/https)、Base64 Data URL (data:image/...) 或本地文件路径。` + }; + } else if (isDataUrl) { + console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`); + } else { + console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`); + } + try { const accessToken = await getAccessToken(account.appId, account.clientSecret); const target = parseTarget(to); - // 先发送图片 + // 先发送图片(使用处理后的 URL,可能是 Base64 Data URL) let imageResult: { id: string; timestamp: number | string }; if (target.type === "c2c") { imageResult = await sendC2CImageMessage( accessToken, target.id, - mediaUrl, + processedMediaUrl, replyToId ?? undefined, undefined // content 参数,图片消息不支持同时带文本 ); @@ -586,13 +469,14 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise