Skip to content

Commit 2ecdc0f

Browse files
勾勾的数字生命勾勾的数字生命
authored andcommitted
feat: add wecom bot thinking content support
1 parent 33f2679 commit 2ecdc0f

21 files changed

Lines changed: 297 additions & 32 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [1.9.5] - 2026-03-08
8+
9+
### Added
10+
- 新增 Bot `thinking_content` 支持:当模型输出包含 `<think>` / `<thinking>` / `<thought>` 标签时,插件会自动拆分正文与思考内容,并通过企业微信 Bot 原生 `thinking_content` 字段展示折叠思考区
11+
- 新增 `thinking-parser` 与回归测试,覆盖闭合标签、未闭合标签和代码块内标签忽略
12+
13+
### Changed
14+
- Bot block/final 流式状态现在会把思考内容单独写入流状态,不再和可见正文混在一起
15+
- Bot stream refresh 现在会把 `thinking_content` 一并返回给企业微信客户端
16+
- Bot block-fallback 在只有思考内容时也能保留思考区,不会直接丢失
17+
18+
### Fixed
19+
- 修复 Bot 流式输出中 `<think>` 标签内容被直接混入正文的问题
20+
721
## [1.9.4] - 2026-03-08
822

923
### Added

README.en.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ This release is a major operations-focused upgrade: **WeCom now supports visual
5555
| WeCom inbound message handling || text/image/voice/link/file/video (Agent + Bot) |
5656
| AI auto-reply via OpenClaw runtime || routed by session key |
5757
| Native WeCom Bot stream protocol || `msgtype=stream` refresh flow |
58+
| Bot thinking display || parses `<think>/<thinking>/<thought>` into native `thinking_content` |
5859
| Bot card replies || `markdown/template_card` with automatic text fallback |
5960
| Multi-account support || `channels.wecom.accounts.<id>` |
6061
| Sender allowlist and admin bypass || `allowFrom` + `adminUsers` |
@@ -75,6 +76,7 @@ This release is a major operations-focused upgrade: **WeCom now supports visual
7576
| Default callback path | `/wecom/callback` | `/wecom/bot/callback` |
7677
| Reply mechanism | WeCom send APIs | stream response + refresh polling |
7778
| Streaming UX | simulated via multiple messages | native stream protocol |
79+
| Thinking display | not applicable | native `thinking_content` from `<think>` tags |
7880
| Outbound media | full support (image/voice/video/file) | image/file supported (`active_stream msg_item(image)` first, then `response_url` mixed / webhook fallback) |
7981

8082
## 5-Minute Quick Start

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ npm run wecom:bot:selfcheck -- --all-accounts
193193
| 语音/视频/文件发送(出站) || 自动判型上传后发送(语音支持 AMR/SILK) |
194194
| 语音转写(本地) || 企业微信 Recognition 优先,缺失时回退本地 whisper |
195195
| Bot 模式媒体回传 || `active_stream` 优先 `msg_item(image)`;失败自动降级媒体链接,`response_url`/Webhook Bot 继续兜底 |
196+
| Bot 思考过程展示 || 识别 `<think>/<thinking>/<thought>`,映射到原生 `thinking_content` 折叠区 |
196197
| Bot 文件入站 || 支持 `msgtype=file` 下载并注入会话上下文 |
197198
| Bot 引用消息上下文 || 自动将 `quote` 内容前置到本轮上下文 |
198199

@@ -205,6 +206,7 @@ npm run wecom:bot:selfcheck -- --all-accounts
205206
| 回调路径默认值 | `/wecom/callback` | `/wecom/bot/callback` |
206207
| 回复机制 | 主动调用 WeCom 发送 API | 回调响应 `stream` + 轮询刷新 |
207208
| 流式体验 | 多条消息模拟增量 | 原生 stream 协议 |
209+
| 思考展示 | 不适用 | 支持 `<think>` 标签映射到 `thinking_content` |
208210
| 出站媒体(图/语音/视频/文件) | 支持 | 支持(`active_stream msg_item(image)` + `response_url/Webhook` 回包,video 自动按 file 回传) |
209211
| 典型场景 | 标准企业应用、菜单/回调体系 | 对话机器人、连续流式问答 |
210212

docs/channels/wecom.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ Bot E2E (signed/encrypted callback + stream refresh):
197197
npm run wecom:bot:selfcheck
198198
```
199199

200+
Thinking mode:
201+
202+
- Bot replies now recognize `<think>...</think>` / `<thinking>...</thinking>` / `<thought>...</thought>` and send the reasoning via native `thinking_content`.
203+
- Think tags inside fenced code blocks and inline code are ignored.
204+
200205
Remote matrix E2E (against public callback URLs):
201206

202207
```bash

openclaw.plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "openclaw-wechat",
33
"name": "OpenClaw-Wechat",
4-
"version": "1.9.4",
4+
"version": "1.9.5",
55
"description": "WeCom (企业微信) channel plugin for OpenClaw (自建应用回调 + 发送 API).",
66
"channels": [
77
"wecom"

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@dingxiang-me/openclaw-wechat",
3-
"version": "1.9.4",
3+
"version": "1.9.5",
44
"type": "module",
55
"description": "WeCom (企业微信) channel plugin for OpenClaw (自建应用回调 + 发送 API).",
66
"license": "MIT",

src/core/stream-manager.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ function normalizeStreamText(text) {
77
return String(text ?? "");
88
}
99

10+
function normalizeThinkingContent(text, maxBytes = DEFAULT_STREAM_MAX_BYTES) {
11+
return enforceUtf8ByteLimit(String(text ?? ""), maxBytes);
12+
}
13+
1014
function enforceUtf8ByteLimit(text, maxBytes = DEFAULT_STREAM_MAX_BYTES) {
1115
const normalized = normalizeStreamText(text);
1216
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return normalized;
@@ -73,6 +77,7 @@ export class WecomStreamManager {
7377
const stream = {
7478
id,
7579
content,
80+
thinkingContent: "",
7681
finished: false,
7782
feedbackId: String(feedbackId ?? "").trim() || null,
7883
msgItem: [],
@@ -117,7 +122,7 @@ export class WecomStreamManager {
117122
return queued;
118123
}
119124

120-
update(streamId, content, { append = false, finished = false, msgItem } = {}) {
125+
update(streamId, content, { append = false, finished = false, msgItem, thinkingContent } = {}) {
121126
const id = String(streamId ?? "").trim();
122127
if (!id) return null;
123128
const existing = this.streams.get(id);
@@ -129,12 +134,15 @@ export class WecomStreamManager {
129134
if (Array.isArray(msgItem)) {
130135
existing.msgItem = normalizeStreamMsgItems(msgItem);
131136
}
137+
if (thinkingContent !== undefined) {
138+
existing.thinkingContent = normalizeThinkingContent(thinkingContent, this.maxBytes);
139+
}
132140
existing.updatedAt = Date.now();
133141
this.streams.set(id, existing);
134142
return existing;
135143
}
136144

137-
finish(streamId, content = null, { msgItem } = {}) {
145+
finish(streamId, content = null, { msgItem, thinkingContent } = {}) {
138146
const id = String(streamId ?? "").trim();
139147
if (!id) return null;
140148
const existing = this.streams.get(id);
@@ -145,6 +153,9 @@ export class WecomStreamManager {
145153
if (Array.isArray(msgItem)) {
146154
existing.msgItem = normalizeStreamMsgItems(msgItem);
147155
}
156+
if (thinkingContent !== undefined) {
157+
existing.thinkingContent = normalizeThinkingContent(thinkingContent, this.maxBytes);
158+
}
148159
existing.queuedMedia = [];
149160
existing.finished = true;
150161
existing.updatedAt = Date.now();

src/wecom/bot-dispatch-fallback.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parseThinkingContent } from "./thinking-parser.js";
2+
13
function assertFunction(name, value) {
24
if (typeof value !== "function") {
35
throw new Error(`bot-dispatch-fallback: ${name} is required`);
@@ -26,9 +28,17 @@ export async function handleWecomBotPostDispatchFallback({
2628
const filledFromTranscript = await tryFinishFromTranscript(dispatchStartedAt);
2729
if (filledFromTranscript) return false;
2830

29-
const fallback = markdownToWecomText(dispatchState.blockText).trim();
30-
if (fallback) {
31-
await safeDeliverReply(fallback, "block-fallback");
31+
const parsed = parseThinkingContent(dispatchState.blockText);
32+
const fallback = markdownToWecomText(parsed.visibleContent).trim();
33+
const thinkingContent = markdownToWecomText(parsed.thinkingContent).trim();
34+
if (fallback || thinkingContent) {
35+
await safeDeliverReply(
36+
{
37+
text: fallback,
38+
thinkingContent,
39+
},
40+
"block-fallback",
41+
);
3242
return false;
3343
}
3444

src/wecom/bot-dispatch-handlers.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parseThinkingContent } from "./thinking-parser.js";
2+
13
function assertFunction(name, value) {
24
if (typeof value !== "function") {
35
throw new Error(`createWecomBotDispatchHandlers: ${name} is required`);
@@ -41,6 +43,17 @@ export function createWecomBotDispatchHandlers({
4143

4244
const logger = api?.logger;
4345

46+
const buildThinkingState = (rawText) => {
47+
const parsed = parseThinkingContent(rawText);
48+
const visibleRaw = String(parsed.visibleContent ?? "").trim();
49+
const thinkingRaw = String(parsed.thinkingContent ?? "").trim();
50+
return {
51+
rawText: String(rawText ?? ""),
52+
visibleText: visibleRaw ? markdownToWecomText(visibleRaw).trim() : "",
53+
thinkingContent: thinkingRaw ? markdownToWecomText(thinkingRaw).trim() : "",
54+
};
55+
};
56+
4457
return {
4558
deliver: async (payload, info) => {
4659
if (!hasBotStream(streamId)) return;
@@ -57,7 +70,12 @@ export function createWecomBotDispatchHandlers({
5770
}
5871
if (!payload?.text) return;
5972
state.blockText = normalizeWecomBotBlockText(state.blockText, payload.text);
60-
updateBotStream(streamId, markdownToWecomText(state.blockText), { append: false, finished: false });
73+
const blockState = buildThinkingState(state.blockText);
74+
updateBotStream(streamId, blockState.visibleText, {
75+
append: false,
76+
finished: false,
77+
thinkingContent: blockState.thinkingContent,
78+
});
6179
return;
6280
}
6381
if (info.kind !== "final") return;
@@ -66,9 +84,15 @@ export function createWecomBotDispatchHandlers({
6684
state.streamFinished = await safeDeliverReply(`抱歉,请求失败:${payload.text}`, "upstream-failure");
6785
return;
6886
}
69-
const finalText = markdownToWecomText(payload.text).trim();
70-
if (finalText) {
71-
state.streamFinished = await safeDeliverReply(finalText, "final");
87+
const finalState = buildThinkingState(payload.text);
88+
if (finalState.visibleText || finalState.thinkingContent) {
89+
state.streamFinished = await safeDeliverReply(
90+
{
91+
text: finalState.visibleText,
92+
thinkingContent: finalState.thinkingContent,
93+
},
94+
"final",
95+
);
7296
return;
7397
}
7498
}

0 commit comments

Comments
 (0)