@wenbin_wb/dsh-bridge 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/qq/node.js +123 -43
  2. package/package.json +1 -1
package/lib/qq/node.js CHANGED
@@ -10,6 +10,73 @@ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conve
10
10
  import { gatewayConstants } from './gateway.js'
11
11
 
12
12
  const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
14
+
15
+ // QQ 流式消息:markdown 内容每片最大长度(append 模式下每片为追加片段)
16
+ const STREAM_CHUNK_SIZE = 400
17
+
18
+ /**
19
+ * 将文本转成 QQ 安全 Markdown:
20
+ * - 表格(| a | b | / |---|)降级为纯文本(QQ Markdown 不支持表格,会截断/报错)
21
+ * - markdown 图片语法 ![alt](url) 转为链接 [alt](url)(避免图片转存失败导致整条失败)
22
+ * - 代码块内容原样保留
23
+ */
24
+ function sanitizeQQMarkdown(text) {
25
+ if (!text) return ''
26
+ const lines = String(text).split('\n')
27
+ const out = []
28
+ let inFence = false
29
+ let inTable = false
30
+ const isSep = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l) && l.includes('-')
31
+ const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(l)
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i]
34
+ if (/^\s*```/.test(line)) {
35
+ inFence = !inFence
36
+ out.push(line)
37
+ continue
38
+ }
39
+ if (inFence) { out.push(line); continue }
40
+ const nextIsSep = i + 1 < lines.length && isSep(lines[i + 1])
41
+ if (isTableRow(line) && (inTable || nextIsSep)) {
42
+ inTable = true
43
+ if (!isSep(line)) {
44
+ // 去掉首尾 |,内部 | 转空格
45
+ out.push(line.replace(/^\s*\|\s*/, '').replace(/\s*\|\s*$/, '').replace(/\s*\|\s*/g, ' '))
46
+ }
47
+ continue
48
+ }
49
+ inTable = false
50
+ out.push(line.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)'))
51
+ }
52
+ return out.join('\n')
53
+ }
54
+
55
+ /**
56
+ * 按段落边界切分内容(避免切断行内内容),单块时拆两片保证流式过渡
57
+ * @param {string} content - 要分片的内容
58
+ * @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
59
+ */
60
+ function splitIntoChunks(content, maxChunk) {
61
+ const chunks = []
62
+ let start = 0
63
+ while (start < content.length) {
64
+ if (content.length - start <= maxChunk) { chunks.push(content.slice(start)); break }
65
+ const windowStart = start + Math.floor(maxChunk * 0.6)
66
+ const windowEnd = start + maxChunk
67
+ let cut = content.lastIndexOf('\n', windowEnd)
68
+ if (cut <= windowStart || cut === -1) cut = windowEnd
69
+ chunks.push(content.slice(start, cut))
70
+ start = cut
71
+ }
72
+ // 单块时拆两片,保证「生成中 → 生成结束」的流式过渡
73
+ if (chunks.length === 1 && content.length > 0) {
74
+ const half = Math.ceil(content.length / 2)
75
+ chunks.length = 0
76
+ chunks.push(content.slice(0, half), content.slice(half))
77
+ }
78
+ return chunks
79
+ }
13
80
 
14
81
  // 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口
15
82
  function makePlatform(gateway) {
@@ -94,55 +161,65 @@ export class QqConversationNode extends ConversationBridge {
94
161
  return this.gateway.sendKeyboard(peerId, text, keyboard, { scope, msgId: replyMsgId })
95
162
  }
96
163
 
97
- // 其他消息使用流式发送
164
+ // 其他回复统一走「流式 Markdown」:content_type=markdown 让手机端渲染,append 模式逐段追加
98
165
  const content = String(text || '').trim()
99
166
  if (content.length === 0) return { success: true }
100
167
 
101
- const STREAM_CHUNK_SIZE = 500
102
-
103
- // 如果消息较短,直接发送普通消息
104
- if (content.length <= STREAM_CHUNK_SIZE) {
105
- const result = await this.gateway.sendText(peerId, content, { scope, msgId: replyMsgId })
106
- if (result?.id) this.lastMessageId = result.id
107
- return result
108
- }
109
-
110
- // 流式发送:把内容切分成多段(append 模式,服务端拼接为同一条消息)
111
- const chunks = []
112
- for (let i = 0; i < content.length; i += STREAM_CHUNK_SIZE) {
113
- chunks.push(content.slice(i, i + STREAM_CHUNK_SIZE))
114
- }
115
-
116
- let streamMsgId = null
117
- for (let i = 0; i < chunks.length; i++) {
118
- const chunk = chunks[i]
119
- const isLast = i === chunks.length - 1
120
- const result = await this.gateway.sendStream(peerId, chunk, {
121
- scope,
122
- msgId: i === 0 ? replyMsgId : undefined, // 首片带被动回复 msg_id
123
- streamMsgId, // 后续片携带服务端返回的 stream_msg_id
124
- index: i, // 分片序号从 0 递增
125
- inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
126
- inputMode: 'append', // 追加模式:服务端拼接到同一条消息
127
- })
128
-
129
- // 首片返回 stream_msg_id,后续片需携带
130
- if (i === 0 && result?.id) {
131
- streamMsgId = result.id
132
- this.lastMessageId = result.id // 保存消息 ID 用于消息引用
168
+ const md = sanitizeQQMarkdown(content)
169
+ // 使用用户配置的 maxMessageChars(默认 2000),而非硬编码 200
170
+ const maxChars = this.config.maxMessageChars || 2000
171
+ const chunks = splitIntoChunks(md, maxChars)
172
+
173
+ try {
174
+ let streamMsgId = null
175
+ for (let i = 0; i < chunks.length; i++) {
176
+ const isLast = i === chunks.length - 1
177
+ const result = await this.gateway.sendStream(peerId, chunks[i], {
178
+ scope,
179
+ contentType: 'markdown', // 流式 Markdown,手机端正常渲染
180
+ msgId: i === 0 ? replyMsgId : undefined, // 首片带被动回复 msg_id
181
+ streamMsgId, // 后续片携带服务端返回的 stream_msg_id
182
+ index: i, // 分片序号从 0 递增
183
+ inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
184
+ inputMode: 'append', // 追加模式:服务端拼接到同一条消息
185
+ })
186
+
187
+ // 首片返回 stream_msg_id,后续片需携带
188
+ if (i === 0 && result?.id) {
189
+ streamMsgId = result.id
190
+ this.lastMessageId = result.id // 保存消息 ID 用于消息引用
191
+ }
192
+
193
+ if (result?.code !== undefined && result.code !== 0) {
194
+ throw new Error(result.message || `QQ API error ${result.code}`)
195
+ }
196
+
197
+ // 使用用户配置的 sendChunkDelayMs(默认 1500ms),而非硬编码 120ms
198
+ if (!isLast) {
199
+ const delayMs = this.config.sendChunkDelayMs ?? 1500
200
+ if (delayMs > 0) await sleep(delayMs)
201
+ }
133
202
  }
134
-
135
- if (result?.code !== undefined && result.code !== 0) {
136
- return { success: false, error: result.message || `QQ API error ${result.code}` }
137
- }
138
-
139
- // 流式发送间隔稍短,避免刷屏
140
- if (!isLast) {
141
- await new Promise(resolve => setTimeout(resolve, 100))
203
+ return { success: true }
204
+ } catch (err) {
205
+ // 流式失败(如被动回复 msg_id 过期)→ 依次回退:带 msg_id Markdown 主动 Markdown
206
+ this.logger?.warn?.('[dsh-bridge qq] stream send failed, fallback to markdown: %s', err?.message ?? err)
207
+ try {
208
+ const result = await this.gateway.sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
209
+ if (result?.id) this.lastMessageId = result.id
210
+ return result
211
+ } catch (fallbackErr1) {
212
+ try {
213
+ // 主动消息兜底(digest 心跳等非回复场景,msg_id 已过期或无)
214
+ const result = await this.gateway.sendMarkdown(peerId, md, { scope })
215
+ if (result?.id) this.lastMessageId = result.id
216
+ return result
217
+ } catch (fallbackErr2) {
218
+ this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
219
+ return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
220
+ }
142
221
  }
143
222
  }
144
-
145
- return { success: true }
146
223
  }
147
224
 
148
225
  /** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒) */
@@ -250,4 +327,7 @@ export const qqNodeHelpers = {
250
327
  textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
251
328
  listSessions: conversationBridgeHelpers.listSessions,
252
329
  sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
330
+ sanitizeQQMarkdown,
331
+ splitIntoChunks,
332
+ STREAM_CHUNK_SIZE,
253
333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",