@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.0
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.
- package/README.md +2 -15
- package/README.zh.md +3 -16
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +69 -34
- package/dist/src/api.d.ts +105 -1
- package/dist/src/api.js +164 -15
- package/dist/src/channel.js +13 -0
- package/dist/src/config.js +3 -10
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +450 -248
- package/dist/src/image-server.d.ts +27 -8
- package/dist/src/image-server.js +179 -71
- package/dist/src/inbound-attachments.d.ts +3 -1
- package/dist/src/inbound-attachments.js +28 -14
- package/dist/src/outbound-deliver.js +77 -148
- package/dist/src/outbound.d.ts +6 -4
- package/dist/src/outbound.js +266 -442
- package/dist/src/reply-dispatcher.js +4 -4
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/slash-commands.js +277 -32
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/streaming.d.ts +244 -0
- package/dist/src/streaming.js +907 -0
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.js +17 -1
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/audio-convert.d.ts +9 -0
- package/dist/src/utils/audio-convert.js +51 -0
- package/dist/src/utils/chunked-upload.d.ts +59 -0
- package/dist/src/utils/chunked-upload.js +289 -0
- package/dist/src/utils/file-utils.d.ts +7 -1
- package/dist/src/utils/file-utils.js +24 -2
- package/dist/src/utils/media-send.d.ts +147 -0
- package/dist/src/utils/media-send.js +434 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/node_modules/ws/index.js +15 -6
- package/node_modules/ws/lib/permessage-deflate.js +6 -6
- package/node_modules/ws/lib/websocket-server.js +5 -5
- package/node_modules/ws/lib/websocket.js +6 -6
- package/node_modules/ws/package.json +4 -3
- package/node_modules/ws/wrapper.mjs +14 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +11 -22
- package/scripts/postinstall-link-sdk.js +113 -0
- package/scripts/upgrade-via-npm.ps1 +161 -6
- package/scripts/upgrade-via-npm.sh +311 -104
- package/scripts/upgrade-via-source.sh +117 -0
- package/skills/qqbot-media/SKILL.md +9 -5
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +76 -35
- package/src/api.ts +284 -12
- package/src/channel.ts +12 -0
- package/src/config.ts +3 -10
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +277 -67
- package/src/image-server.ts +213 -77
- package/src/inbound-attachments.ts +32 -15
- package/src/outbound-deliver.ts +77 -157
- package/src/outbound.ts +304 -451
- package/src/reply-dispatcher.ts +4 -4
- package/src/request-context.ts +39 -0
- package/src/slash-commands.ts +303 -33
- package/src/startup-greeting.ts +35 -13
- package/src/streaming.ts +1096 -0
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +111 -0
- package/src/update-checker.ts +2 -7
- package/src/utils/audio-convert.ts +56 -0
- package/src/utils/chunked-upload.ts +419 -0
- package/src/utils/file-utils.ts +28 -2
- package/src/utils/media-send.ts +563 -0
- package/src/utils/pkg-version.ts +54 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/clawdbot.plugin.json +0 -16
- package/dist/src/user-messages.d.ts +0 -8
- package/dist/src/user-messages.js +0 -8
- package/moltbot.plugin.json +0 -16
- package/scripts/upgrade-via-alt-pkg.sh +0 -307
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
- package/src/gateway.log +0 -43
- package/src/openclaw-2026-03-21.log +0 -3729
- package/src/user-messages.ts +0 -7
package/src/streaming.ts
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 流式消息控制器(简化版)
|
|
3
|
+
*
|
|
4
|
+
* 核心原则:
|
|
5
|
+
* 1. 绝对不修改原始内容(不 trim、不 strip),避免 PREFIX MISMATCH
|
|
6
|
+
* 2. 媒体标签同步等待发送完成
|
|
7
|
+
* 3. 碰到富媒体标签(包括未闭合前缀)时,先终结当前流式会话再处理
|
|
8
|
+
* 4. 纯空白分片处理:
|
|
9
|
+
* - 首分片空白 → 暂停发送(不开启流式),但内容保留
|
|
10
|
+
* - 被媒体标签打断或结束时,如果还都是空白 → 不发送
|
|
11
|
+
* - 结束时已有活跃流式会话(之前有非空白分片)→ 可以发送当前空白分片
|
|
12
|
+
* 5. 回复边界检测:通过前缀匹配判断(而非仅长度缩短),
|
|
13
|
+
* 如果新文本不是上次处理文本的前缀延续,视为新消息
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ResolvedQQBotAccount, StreamMessageResponse } from "./types.js";
|
|
17
|
+
import { StreamInputMode, StreamInputState, StreamContentType } from "./types.js";
|
|
18
|
+
import { getAccessToken, sendC2CStreamMessage, getNextMsgSeq } from "./api.js";
|
|
19
|
+
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
20
|
+
import {
|
|
21
|
+
stripIncompleteMediaTag,
|
|
22
|
+
findFirstClosedMediaTag,
|
|
23
|
+
executeSendQueue,
|
|
24
|
+
type SendQueueItem,
|
|
25
|
+
type MediaSendContext,
|
|
26
|
+
} from "./utils/media-send.js";
|
|
27
|
+
import type { MediaTargetContext } from "./outbound.js";
|
|
28
|
+
|
|
29
|
+
// ============ 常量 ============
|
|
30
|
+
|
|
31
|
+
/** 流式消息节流常量(毫秒) */
|
|
32
|
+
const THROTTLE_CONSTANTS = {
|
|
33
|
+
/** 默认节流间隔 */
|
|
34
|
+
DEFAULT_MS: 500,
|
|
35
|
+
/** 最小节流间隔 */
|
|
36
|
+
MIN_MS: 300,
|
|
37
|
+
/** 长间隔阈值:超过此时间后的首次 flush 延迟处理 */
|
|
38
|
+
LONG_GAP_THRESHOLD_MS: 2000,
|
|
39
|
+
/** 长间隔后的批处理窗口 */
|
|
40
|
+
BATCH_AFTER_GAP_MS: 300,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** 流式状态机阶段 */
|
|
44
|
+
type StreamingPhase = "idle" | "streaming" | "completed" | "aborted";
|
|
45
|
+
|
|
46
|
+
/** 终态集合 */
|
|
47
|
+
const TERMINAL_PHASES = new Set<StreamingPhase>(["completed", "aborted"]);
|
|
48
|
+
|
|
49
|
+
/** 允许的状态转换 */
|
|
50
|
+
const PHASE_TRANSITIONS: Record<StreamingPhase, Set<StreamingPhase>> = {
|
|
51
|
+
idle: new Set(["streaming", "aborted"]),
|
|
52
|
+
streaming: new Set(["completed", "aborted"]),
|
|
53
|
+
completed: new Set(),
|
|
54
|
+
aborted: new Set(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ============ FlushController ============
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 节流刷新控制器(纯调度原语,不含业务逻辑)
|
|
61
|
+
*/
|
|
62
|
+
class FlushController {
|
|
63
|
+
private doFlush: () => Promise<void>;
|
|
64
|
+
private flushInProgress = false;
|
|
65
|
+
private flushResolvers: Array<() => void> = [];
|
|
66
|
+
private needsReflush = false;
|
|
67
|
+
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
private lastUpdateTime = 0;
|
|
69
|
+
private isCompleted = false;
|
|
70
|
+
private _ready = false;
|
|
71
|
+
|
|
72
|
+
constructor(doFlush: () => Promise<void>) {
|
|
73
|
+
this.doFlush = doFlush;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 标记为已完成 —— 当前 flush 之后不再调度新 flush */
|
|
77
|
+
complete(): void {
|
|
78
|
+
this.isCompleted = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 取消待执行的延迟 flush */
|
|
82
|
+
cancelPendingFlush(): void {
|
|
83
|
+
if (this.pendingFlushTimer) {
|
|
84
|
+
clearTimeout(this.pendingFlushTimer);
|
|
85
|
+
this.pendingFlushTimer = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 等待当前进行中的 flush 完成 */
|
|
90
|
+
waitForFlush(): Promise<void> {
|
|
91
|
+
if (!this.flushInProgress) return Promise.resolve();
|
|
92
|
+
return new Promise<void>((resolve) => this.flushResolvers.push(resolve));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 取消所有 pending timer + 等待正在执行的 flush 完成,确保 flush 活动彻底停止 */
|
|
96
|
+
async cancelPendingAndWait(): Promise<void> {
|
|
97
|
+
this.cancelPendingFlush();
|
|
98
|
+
this.needsReflush = false;
|
|
99
|
+
await this.waitForFlush();
|
|
100
|
+
// flush 完成后可能又触发了 reflush timer,再次清理
|
|
101
|
+
this.cancelPendingFlush();
|
|
102
|
+
this.needsReflush = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 标记流式会话就绪(首次 API 调用成功后) */
|
|
106
|
+
setReady(ready: boolean): void {
|
|
107
|
+
this._ready = ready;
|
|
108
|
+
if (ready) {
|
|
109
|
+
this.lastUpdateTime = Date.now();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get ready(): boolean {
|
|
114
|
+
return this._ready;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 重置为初始状态(用于流式会话恢复) */
|
|
118
|
+
reset(doFlush: () => Promise<void>): void {
|
|
119
|
+
this.cancelPendingFlush();
|
|
120
|
+
this.doFlush = doFlush;
|
|
121
|
+
this.flushInProgress = false;
|
|
122
|
+
this.flushResolvers = [];
|
|
123
|
+
this.needsReflush = false;
|
|
124
|
+
this.lastUpdateTime = 0;
|
|
125
|
+
this.isCompleted = false;
|
|
126
|
+
this._ready = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 执行一次 flush(互斥锁 + 冲突时 reflush) */
|
|
130
|
+
async flush(): Promise<void> {
|
|
131
|
+
if (!this._ready || this.flushInProgress || this.isCompleted) {
|
|
132
|
+
if (this.flushInProgress && !this.isCompleted) {
|
|
133
|
+
this.needsReflush = true;
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.flushInProgress = true;
|
|
139
|
+
this.needsReflush = false;
|
|
140
|
+
this.lastUpdateTime = Date.now();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await this.doFlush();
|
|
144
|
+
this.lastUpdateTime = Date.now();
|
|
145
|
+
} finally {
|
|
146
|
+
this.flushInProgress = false;
|
|
147
|
+
const resolvers = this.flushResolvers;
|
|
148
|
+
this.flushResolvers = [];
|
|
149
|
+
for (const resolve of resolvers) resolve();
|
|
150
|
+
|
|
151
|
+
// flush 期间有新事件到达 → 立即跟进
|
|
152
|
+
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
|
|
153
|
+
this.needsReflush = false;
|
|
154
|
+
this.pendingFlushTimer = setTimeout(() => {
|
|
155
|
+
this.pendingFlushTimer = null;
|
|
156
|
+
void this.flush();
|
|
157
|
+
}, 0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 节流入口:根据 throttleMs 控制 flush 频率 */
|
|
163
|
+
async throttledUpdate(throttleMs: number): Promise<void> {
|
|
164
|
+
if (!this._ready) return;
|
|
165
|
+
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const elapsed = now - this.lastUpdateTime;
|
|
168
|
+
|
|
169
|
+
if (elapsed >= throttleMs) {
|
|
170
|
+
this.cancelPendingFlush();
|
|
171
|
+
if (elapsed > THROTTLE_CONSTANTS.LONG_GAP_THRESHOLD_MS) {
|
|
172
|
+
// 长间隔后首次 flush 延迟,等待更多文本积累
|
|
173
|
+
this.lastUpdateTime = now;
|
|
174
|
+
this.pendingFlushTimer = setTimeout(() => {
|
|
175
|
+
this.pendingFlushTimer = null;
|
|
176
|
+
void this.flush();
|
|
177
|
+
}, THROTTLE_CONSTANTS.BATCH_AFTER_GAP_MS);
|
|
178
|
+
} else {
|
|
179
|
+
await this.flush();
|
|
180
|
+
}
|
|
181
|
+
} else if (!this.pendingFlushTimer) {
|
|
182
|
+
// 在节流窗口内 → 延迟 flush
|
|
183
|
+
const delay = throttleMs - elapsed;
|
|
184
|
+
this.pendingFlushTimer = setTimeout(() => {
|
|
185
|
+
this.pendingFlushTimer = null;
|
|
186
|
+
void this.flush();
|
|
187
|
+
}, delay);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ============ StreamingController ============
|
|
193
|
+
|
|
194
|
+
/** StreamingController 的依赖注入 */
|
|
195
|
+
export interface StreamingControllerDeps {
|
|
196
|
+
/** QQ Bot 账户配置 */
|
|
197
|
+
account: ResolvedQQBotAccount;
|
|
198
|
+
/** 目标用户 openid(流式 API 仅支持 C2C) */
|
|
199
|
+
userId: string;
|
|
200
|
+
/** 被动回复的消息 ID */
|
|
201
|
+
replyToMsgId: string;
|
|
202
|
+
/** 事件 ID */
|
|
203
|
+
eventId: string;
|
|
204
|
+
/** 日志前缀 */
|
|
205
|
+
logPrefix?: string;
|
|
206
|
+
/** 日志对象(直接传 gateway 的 log) */
|
|
207
|
+
log?: {
|
|
208
|
+
info(msg: string): void;
|
|
209
|
+
error(msg: string): void;
|
|
210
|
+
warn?(msg: string): void;
|
|
211
|
+
debug?(msg: string): void;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* 媒体发送上下文(用于在流式模式下发送富媒体)
|
|
215
|
+
* 如果不提供,遇到媒体标签时会抛出错误导致 fallback
|
|
216
|
+
*/
|
|
217
|
+
mediaContext?: StreamingMediaContext;
|
|
218
|
+
/**
|
|
219
|
+
* 回复边界回调:检测到 text 长度缩短(新回复开始)时触发。
|
|
220
|
+
*
|
|
221
|
+
* 触发时当前 controller 已经 finalize(终结当前流式会话,处理完之前的内容),
|
|
222
|
+
* 调用方应创建新的 StreamingController 并用 newReplyText 调其 onPartialReply。
|
|
223
|
+
*
|
|
224
|
+
* @param newReplyText 新回复的初始文本(已 strip reasoning tags)
|
|
225
|
+
*/
|
|
226
|
+
onReplyBoundary?: (newReplyText: string) => void | Promise<void>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* QQ Bot 流式消息控制器
|
|
231
|
+
*
|
|
232
|
+
* 管理 C2C 流式消息的完整生命周期:
|
|
233
|
+
* 1. idle: 初始状态,等待首次文本
|
|
234
|
+
* 2. streaming: 流式发送中,通过 API 逐步更新消息内容
|
|
235
|
+
* 3. completed: 正常完成,已发送 input_state="10"
|
|
236
|
+
* 4. aborted: 中止(进程退出/错误)
|
|
237
|
+
*
|
|
238
|
+
* 富媒体标签处理流程:
|
|
239
|
+
* 当检测到富媒体标签时:
|
|
240
|
+
* 1. 将标签前的文本通过流式发完 → 结束当前流式会话 (input_state="10")
|
|
241
|
+
* 2. 同步等待媒体发送完成
|
|
242
|
+
* 3. 创建新的流式会话 → 继续发送标签后的剩余文本
|
|
243
|
+
*/
|
|
244
|
+
export class StreamingController {
|
|
245
|
+
// ---- 状态机 ----
|
|
246
|
+
private phase: StreamingPhase = "idle";
|
|
247
|
+
|
|
248
|
+
// ---- 核心文本状态(仅两个) ----
|
|
249
|
+
/**
|
|
250
|
+
* 最后一次收到的完整 normalized 全量文本。
|
|
251
|
+
* - onPartialReply 每次更新(回复边界时会拼接前缀)
|
|
252
|
+
* - performFlush 从 sentIndex 开始切片来获取当前会话的显示内容
|
|
253
|
+
* - onIdle 校验时用于前缀匹配
|
|
254
|
+
*/
|
|
255
|
+
private lastNormalizedFull = "";
|
|
256
|
+
/**
|
|
257
|
+
* 在 lastNormalizedFull 中已经"消费"到的位置。
|
|
258
|
+
* "消费"包括:已通过流式发送并终结的文本段、已处理的媒体标签。
|
|
259
|
+
* - 每次流式会话终结(endCurrentStreamIfNeeded)后推进到终结点
|
|
260
|
+
* - 每次媒体标签处理后推进到标签结束位置
|
|
261
|
+
* - resetStreamSession 后,新的流式会话从 sentIndex 开始
|
|
262
|
+
*/
|
|
263
|
+
private sentIndex = 0;
|
|
264
|
+
|
|
265
|
+
// ---- 流式会话 ----
|
|
266
|
+
private streamMsgId: string | null = null;
|
|
267
|
+
/** 当前流式会话的 msg_seq,同一会话内所有 chunk 共享;null 表示需要重新生成 */
|
|
268
|
+
private msgSeq: number | null = null;
|
|
269
|
+
private streamIndex = 0;
|
|
270
|
+
private dispatchFullyComplete = false;
|
|
271
|
+
|
|
272
|
+
// ---- 串行队列:确保 onPartialReply / onIdle 严格按序执行 ----
|
|
273
|
+
/** Promise 链,回调的实际逻辑都挂到链尾,保证串行 */
|
|
274
|
+
private _callbackChain: Promise<void> = Promise.resolve();
|
|
275
|
+
|
|
276
|
+
// ---- 互斥:首个到达的回调锁定控制权 ----
|
|
277
|
+
/**
|
|
278
|
+
* 记录首先到达的回调来源,后续其他来源的回调将被忽略。
|
|
279
|
+
* - null: 尚未确定
|
|
280
|
+
* - 非 null: 已锁定,只有相同来源的回调才允许继续执行
|
|
281
|
+
*/
|
|
282
|
+
private firstCallbackSource: string | null = null;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 尝试获取回调互斥锁。
|
|
286
|
+
* - 尚未锁定 → 锁定为 source,返回 true
|
|
287
|
+
* - 已锁定且来源相同 → 返回 true
|
|
288
|
+
* - 已锁定且来源不同 → 返回 false(调用方应跳过)
|
|
289
|
+
*/
|
|
290
|
+
private acquireCallbackLock(source: string): boolean {
|
|
291
|
+
if (this.firstCallbackSource === null) {
|
|
292
|
+
this.firstCallbackSource = source;
|
|
293
|
+
this.logInfo(`acquireCallbackLock: locked to "${source}"`);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (this.firstCallbackSource === source) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
this.logDebug(`acquireCallbackLock: rejected "${source}" (locked by "${this.firstCallbackSource}")`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- 降级 ----
|
|
304
|
+
/** 成功发送的流式分片数或媒体数(用于 onDeliver 互斥判断 + 降级判断) */
|
|
305
|
+
private sentStreamChunkCount = 0;
|
|
306
|
+
/** 是否成功发送过至少一个媒体文件 */
|
|
307
|
+
private sentMediaCount = 0;
|
|
308
|
+
|
|
309
|
+
// ---- 启动锁 ----
|
|
310
|
+
private startingPromise: Promise<void> | null = null;
|
|
311
|
+
|
|
312
|
+
// ---- 子控制器 ----
|
|
313
|
+
private flush: FlushController;
|
|
314
|
+
|
|
315
|
+
// ---- 配置 ----
|
|
316
|
+
private throttleMs: number;
|
|
317
|
+
|
|
318
|
+
// ---- 注入依赖 ----
|
|
319
|
+
private deps: StreamingControllerDeps;
|
|
320
|
+
|
|
321
|
+
constructor(deps: StreamingControllerDeps) {
|
|
322
|
+
this.deps = deps;
|
|
323
|
+
this.flush = new FlushController(() => this.performFlush());
|
|
324
|
+
this.throttleMs = THROTTLE_CONSTANTS.DEFAULT_MS;
|
|
325
|
+
if (this.throttleMs < THROTTLE_CONSTANTS.MIN_MS) {
|
|
326
|
+
this.throttleMs = THROTTLE_CONSTANTS.MIN_MS;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ------------------------------------------------------------------
|
|
331
|
+
// 公共访问器
|
|
332
|
+
// ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
get isTerminalPhase(): boolean {
|
|
335
|
+
return TERMINAL_PHASES.has(this.phase);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
get currentPhase(): StreamingPhase {
|
|
339
|
+
return this.phase;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 是否应降级到非流式(普通消息)发送
|
|
344
|
+
*
|
|
345
|
+
* 条件:流式会话进入终态,且从未成功发出过任何一个流式分片或媒体
|
|
346
|
+
*/
|
|
347
|
+
get shouldFallbackToStatic(): boolean {
|
|
348
|
+
return this.isTerminalPhase && this.sentStreamChunkCount === 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** debug 用:暴露发送计数给 gateway 日志 */
|
|
352
|
+
get sentChunkCount_debug(): number {
|
|
353
|
+
return this.sentStreamChunkCount;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ------------------------------------------------------------------
|
|
357
|
+
// 状态机
|
|
358
|
+
// ------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
private transition(to: StreamingPhase, source: string, reason?: string): boolean {
|
|
361
|
+
const from = this.phase;
|
|
362
|
+
if (from === to) return false;
|
|
363
|
+
if (!PHASE_TRANSITIONS[from].has(to)) {
|
|
364
|
+
this.logWarn(`phase transition rejected: ${from} → ${to} (source: ${source})`);
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
this.phase = to;
|
|
368
|
+
this.logInfo(`phase: ${from} → ${to} (source: ${source}${reason ? `, reason: ${reason}` : ""})`);
|
|
369
|
+
if (TERMINAL_PHASES.has(to)) {
|
|
370
|
+
this.onEnterTerminalPhase();
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private onEnterTerminalPhase(): void {
|
|
376
|
+
this.flush.cancelPendingFlush();
|
|
377
|
+
this.flush.complete();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private get prefix(): string {
|
|
381
|
+
return this.deps.logPrefix ?? "[qqbot:streaming]";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private logInfo(msg: string): void {
|
|
385
|
+
const m = `${this.prefix} ${msg}`;
|
|
386
|
+
this.deps.log ? this.deps.log.info?.(m) : console.log(m);
|
|
387
|
+
}
|
|
388
|
+
private logError(msg: string): void {
|
|
389
|
+
const m = `${this.prefix} ${msg}`;
|
|
390
|
+
this.deps.log ? this.deps.log.error?.(m) : console.error(m);
|
|
391
|
+
}
|
|
392
|
+
private logWarn(msg: string): void {
|
|
393
|
+
const m = `${this.prefix} ${msg}`;
|
|
394
|
+
this.deps.log ? (this.deps.log.warn ?? this.deps.log.info)(m) : console.warn(m);
|
|
395
|
+
}
|
|
396
|
+
private logDebug(msg: string): void {
|
|
397
|
+
const m = `${this.prefix} ${msg}`;
|
|
398
|
+
this.deps.log ? this.deps.log.debug?.(m) : console.debug(m);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ------------------------------------------------------------------
|
|
402
|
+
// SDK 回调绑定
|
|
403
|
+
// ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 处理 onPartialReply 回调(流式文本全量更新)
|
|
407
|
+
*
|
|
408
|
+
* ★ 通过 Promise 链严格串行化:前一次处理完成后才执行下一次,
|
|
409
|
+
* 避免并发交叉导致的状态不一致。
|
|
410
|
+
*
|
|
411
|
+
* payload.text 是从头到尾的完整当前文本(每次回调都是全量)。
|
|
412
|
+
* 核心逻辑:normalize → 更新 lastNormalizedFull → 从 sentIndex 开始 processMediaTags
|
|
413
|
+
*/
|
|
414
|
+
async onPartialReply(payload: { text?: string }): Promise<void> {
|
|
415
|
+
if (this.isTerminalPhase) return;
|
|
416
|
+
if (!payload.text) return;
|
|
417
|
+
|
|
418
|
+
// ★ 互斥锁在入口检查:如果已被 deliver 锁定,直接跳过,无需排队
|
|
419
|
+
if (!this.acquireCallbackLock("partial")) return;
|
|
420
|
+
|
|
421
|
+
// 将实际逻辑挂到 Promise 链尾部,保证串行执行
|
|
422
|
+
this._callbackChain = this._callbackChain.then(
|
|
423
|
+
() => this._doPartialReply(payload),
|
|
424
|
+
(err) => {
|
|
425
|
+
// 上一次如果异常,不阻塞后续调用
|
|
426
|
+
this.logError(`onPartialReply chain error: ${err}`);
|
|
427
|
+
return this._doPartialReply(payload);
|
|
428
|
+
}
|
|
429
|
+
);
|
|
430
|
+
return this._callbackChain;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** onPartialReply 的实际逻辑(由 _callbackChain 保证串行调用) */
|
|
434
|
+
private async _doPartialReply(payload: { text?: string }): Promise<void> {
|
|
435
|
+
this.logDebug(`onPartialReply: rawLen=${payload.text?.length ?? 0}, phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}, firstCB=${this.firstCallbackSource}`);
|
|
436
|
+
if (this.isTerminalPhase) {
|
|
437
|
+
this.logDebug(`onPartialReply: skipped (terminal phase)`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const text = payload.text ?? "";
|
|
442
|
+
if (!text) {
|
|
443
|
+
this.logDebug(`onPartialReply: skipped (empty text)`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ★ 回复边界检测:新文本与上次处理的内容前缀不同 → 新回复开始
|
|
448
|
+
// 比较方式:新文本 normalize 后,检查是否以上次的 lastNormalizedFull 为前缀
|
|
449
|
+
// 如果不是前缀关系(内容发生了非追加的变化),终结当前 controller,通知调用方创建新的
|
|
450
|
+
const normalized = normalizeMediaTags(text);
|
|
451
|
+
if (this.lastNormalizedFull && normalized.length > 0 && !normalized.startsWith(this.lastNormalizedFull)) {
|
|
452
|
+
this.logInfo(`onPartialReply: reply boundary detected — prefix mismatch (new len=${normalized.length}, prev len=${this.lastNormalizedFull.length}), finalizing current controller`);
|
|
453
|
+
|
|
454
|
+
// 终结当前流式会话,处理完当前内容(包括可能的未闭合媒体标签)
|
|
455
|
+
this.dispatchFullyComplete = true;
|
|
456
|
+
await this.finalizeOnIdle();
|
|
457
|
+
|
|
458
|
+
// 通知调用方:新回复开始,请创建新 controller
|
|
459
|
+
if (this.deps.onReplyBoundary) {
|
|
460
|
+
this.logInfo(`onPartialReply: invoking onReplyBoundary callback with newText len=${text.length}`);
|
|
461
|
+
await this.deps.onReplyBoundary(text);
|
|
462
|
+
} else {
|
|
463
|
+
this.logWarn(`onPartialReply: reply boundary detected but no onReplyBoundary callback registered, new reply text will be lost`);
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 正常增长:使用已 normalize 的文本
|
|
469
|
+
this.lastNormalizedFull = normalized;
|
|
470
|
+
|
|
471
|
+
// ★ 核心:从 sentIndex 开始,处理增量文本(串行队列保证不会并发进入)
|
|
472
|
+
await this.processMediaTags(this.lastNormalizedFull);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 处理 deliver 回调
|
|
477
|
+
*
|
|
478
|
+
* ★ 与 onPartialReply 互斥:首先到达的回调锁定控制权,后到的被忽略。
|
|
479
|
+
*/
|
|
480
|
+
async onDeliver(payload: { text?: string }): Promise<void> {
|
|
481
|
+
const rawLen = payload.text?.length ?? 0;
|
|
482
|
+
const preview = (payload.text ?? "").slice(0, 60).replace(/\n/g, "\\n");
|
|
483
|
+
this.logDebug(`onDeliver: rawLen=${rawLen}, phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}, sentChunks=${this.sentStreamChunkCount}, firstCB=${this.firstCallbackSource}, preview="${preview}"`);
|
|
484
|
+
if (this.isTerminalPhase) {
|
|
485
|
+
this.logDebug(`onDeliver: skipped (terminal phase)`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const text = payload.text ?? "";
|
|
490
|
+
if (!text.trim()) {
|
|
491
|
+
this.logDebug(`onDeliver: skipped (empty text)`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ★ 互斥锁
|
|
496
|
+
if (!this.acquireCallbackLock("deliver")) return;
|
|
497
|
+
|
|
498
|
+
this.logInfo(`onDeliver: deliver in control, falling back to static`);
|
|
499
|
+
this.transition("aborted", "onDeliver", "deliver_arrived_first_fallback_to_static");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 处理 onIdle 回调(分发完成时调用)
|
|
504
|
+
*
|
|
505
|
+
* ★ 挂到 _callbackChain 上,保证在所有 onPartialReply 执行完之后才执行。
|
|
506
|
+
*
|
|
507
|
+
* onIdle 会传入最终的全量文本。如果该文本**包含**之前存储的 lastNormalizedFull,
|
|
508
|
+
* 说明一致,继续处理剩余内容;否则忽略(防止 onIdle 修改文本导致的不一致)。
|
|
509
|
+
*/
|
|
510
|
+
async onIdle(payload?: { text?: string }): Promise<void> {
|
|
511
|
+
if (!this.dispatchFullyComplete) {
|
|
512
|
+
this.logDebug(`onIdle: skipped (dispatch not fully complete)`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (this.isTerminalPhase) return;
|
|
516
|
+
|
|
517
|
+
// 挂到串行队列尾部,等所有 onPartialReply 执行完再处理
|
|
518
|
+
this._callbackChain = this._callbackChain.then(
|
|
519
|
+
() => this._doIdle(payload),
|
|
520
|
+
(err) => {
|
|
521
|
+
this.logError(`onIdle chain error: ${err}`);
|
|
522
|
+
return this._doIdle(payload);
|
|
523
|
+
}
|
|
524
|
+
);
|
|
525
|
+
return this._callbackChain;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** onIdle 的实际逻辑(由 _callbackChain 保证在 onPartialReply 之后执行) */
|
|
529
|
+
private async _doIdle(payload?: { text?: string }): Promise<void> {
|
|
530
|
+
this.logDebug(`onIdle: dispatchFullyComplete=${this.dispatchFullyComplete}, phase=${this.phase}, streamChunks=${this.sentStreamChunkCount}, mediaCount=${this.sentMediaCount}, sentIndex=${this.sentIndex}`);
|
|
531
|
+
if (this.isTerminalPhase) {
|
|
532
|
+
this.logDebug(`onIdle: skipped (terminal phase)`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ★ onIdle 文本校验:如果传了文本,检查是否包含之前的全量文本
|
|
537
|
+
if (payload?.text) {
|
|
538
|
+
const idleNormalized = normalizeMediaTags(payload.text);
|
|
539
|
+
if (idleNormalized.includes(this.lastNormalizedFull)) {
|
|
540
|
+
// onIdle 文本包含之前的全量 → 一致,使用 onIdle 的文本作为最终全量
|
|
541
|
+
this.logDebug(`onIdle: text contains lastNormalizedFull, updating (${this.lastNormalizedFull.length} → ${idleNormalized.length})`);
|
|
542
|
+
this.lastNormalizedFull = idleNormalized;
|
|
543
|
+
} else if (this.lastNormalizedFull.includes(idleNormalized)) {
|
|
544
|
+
// 之前的全量包含 onIdle 文本 → onIdle 文本是子集,保留之前的
|
|
545
|
+
this.logDebug(`onIdle: lastNormalizedFull contains idle text, keeping current`);
|
|
546
|
+
} else {
|
|
547
|
+
// 不一致 → 忽略 onIdle
|
|
548
|
+
this.logWarn(`onIdle: text mismatch with lastNormalizedFull, ignoring onIdle (idle len=${idleNormalized.length}, last len=${this.lastNormalizedFull.length})`);
|
|
549
|
+
// 虽然忽略文本处理,但仍需要终结当前流式会话
|
|
550
|
+
await this.finalizeOnIdle();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ★ 处理 sentIndex 之后的剩余内容
|
|
556
|
+
const remaining = this.lastNormalizedFull.slice(this.sentIndex);
|
|
557
|
+
if (remaining) {
|
|
558
|
+
const hasClosedTag = findFirstClosedMediaTag(remaining);
|
|
559
|
+
if (hasClosedTag) {
|
|
560
|
+
this.logDebug(`onIdle: unprocessed media tags in remaining text, processing now`);
|
|
561
|
+
await this.processMediaTags(this.lastNormalizedFull);
|
|
562
|
+
if (this.isTerminalPhase) return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await this.finalizeOnIdle();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* onIdle 的终结逻辑:终结流式会话或标记完成/降级
|
|
571
|
+
*/
|
|
572
|
+
private async finalizeOnIdle(): Promise<void> {
|
|
573
|
+
// 等待正在进行的流式启动请求完成
|
|
574
|
+
if (this.startingPromise) {
|
|
575
|
+
this.logDebug(`finalizeOnIdle: waiting for pending stream start`);
|
|
576
|
+
await this.startingPromise;
|
|
577
|
+
}
|
|
578
|
+
if (this.isTerminalPhase) return;
|
|
579
|
+
|
|
580
|
+
// 等待所有 pending flush 完成
|
|
581
|
+
await this.flush.waitForFlush();
|
|
582
|
+
|
|
583
|
+
// ---- 判断如何终结 ----
|
|
584
|
+
if (this.streamMsgId) {
|
|
585
|
+
// 有活跃流式会话 → 发终结分片
|
|
586
|
+
this.transition("completed", "onIdle", "normal");
|
|
587
|
+
try {
|
|
588
|
+
// 当前会话的显示内容 = sentIndex 之后的纯文本(去掉未闭合标签)
|
|
589
|
+
const sessionText = this.lastNormalizedFull.slice(this.sentIndex);
|
|
590
|
+
const [safeText] = stripIncompleteMediaTag(sessionText);
|
|
591
|
+
this.logDebug(`finalizeOnIdle: sending DONE chunk, len=${safeText.length}`);
|
|
592
|
+
await this.sendStreamChunk(safeText, StreamInputState.DONE, "onIdle");
|
|
593
|
+
this.logInfo(`streaming completed, final text length: ${safeText.length}`);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
this.logError(`failed to send final stream chunk: ${err}`);
|
|
596
|
+
}
|
|
597
|
+
} else if (this.sentStreamChunkCount > 0) {
|
|
598
|
+
// 没有活跃流式会话,但之前发过流式分片或媒体 → 正常完成
|
|
599
|
+
this.logInfo(`finalizeOnIdle: no active stream session, but sent ${this.sentStreamChunkCount} chunks (including ${this.sentMediaCount} media), marking completed`);
|
|
600
|
+
this.transition("completed", "onIdle", "no_active_session_but_sent");
|
|
601
|
+
} else {
|
|
602
|
+
// 什么都没发过 → 降级
|
|
603
|
+
this.logInfo(`no chunk or media sent, marking fallback to static`);
|
|
604
|
+
this.transition("aborted", "onIdle", "fallback_to_static_nothing_sent");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 处理错误
|
|
610
|
+
*/
|
|
611
|
+
async onError(err: unknown): Promise<void> {
|
|
612
|
+
this.logError(`reply error: ${err}`);
|
|
613
|
+
|
|
614
|
+
if (this.isTerminalPhase) return;
|
|
615
|
+
|
|
616
|
+
// 等待正在进行的流式启动请求完成
|
|
617
|
+
if (this.startingPromise) {
|
|
618
|
+
this.logDebug(`onError: waiting for pending stream start`);
|
|
619
|
+
await this.startingPromise;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (this.isTerminalPhase) return;
|
|
623
|
+
|
|
624
|
+
// 如果从未发出任何内容 → 降级
|
|
625
|
+
if (this.sentStreamChunkCount === 0) {
|
|
626
|
+
this.logInfo(`no chunk or media sent, marking fallback to static for error handling`);
|
|
627
|
+
this.transition("aborted", "onError", "fallback_to_static_error");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 如果有活跃流式会话,发送错误终结分片
|
|
632
|
+
if (this.streamMsgId) {
|
|
633
|
+
try {
|
|
634
|
+
const sessionText = this.lastNormalizedFull.slice(this.sentIndex);
|
|
635
|
+
const [safeText] = stripIncompleteMediaTag(sessionText);
|
|
636
|
+
const errorText = safeText
|
|
637
|
+
? `${safeText}\n\n---\n**Error**: 生成响应时发生错误。`
|
|
638
|
+
: "**Error**: 生成响应时发生错误。";
|
|
639
|
+
await this.sendStreamChunk(errorText, StreamInputState.DONE, "onError");
|
|
640
|
+
} catch (sendErr) {
|
|
641
|
+
this.logError(`failed to send error stream chunk: ${sendErr}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
this.transition("completed", "onError", "error");
|
|
646
|
+
await this.flush.waitForFlush();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ------------------------------------------------------------------
|
|
650
|
+
// 外部控制
|
|
651
|
+
// ------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
/** 标记分发已全部完成 */
|
|
654
|
+
markFullyComplete(): void {
|
|
655
|
+
this.dispatchFullyComplete = true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 中止流式消息 */
|
|
659
|
+
async abortStreaming(): Promise<void> {
|
|
660
|
+
if (!this.transition("aborted", "abortStreaming", "abort")) return;
|
|
661
|
+
|
|
662
|
+
await this.flush.waitForFlush();
|
|
663
|
+
|
|
664
|
+
if (this.streamMsgId) {
|
|
665
|
+
try {
|
|
666
|
+
const sessionText = this.lastNormalizedFull.slice(this.sentIndex);
|
|
667
|
+
const [safeText] = stripIncompleteMediaTag(sessionText);
|
|
668
|
+
const abortText = safeText || "(已中止)";
|
|
669
|
+
await this.sendStreamChunk(abortText, StreamInputState.DONE, "abortStreaming");
|
|
670
|
+
this.logInfo(`streaming aborted, sent final chunk`);
|
|
671
|
+
} catch (err) {
|
|
672
|
+
this.logError(`abort send failed: ${err}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ------------------------------------------------------------------
|
|
678
|
+
// 内部:富媒体标签中断/恢复
|
|
679
|
+
// ------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 处理富媒体标签(循环消费模型)
|
|
683
|
+
*
|
|
684
|
+
* 从 sentIndex 开始,对增量文本:
|
|
685
|
+
* 1. 优先找闭合标签 → 终结当前流式 → 同步发媒体 → 推进 sentIndex → reset → 继续
|
|
686
|
+
* 2. 没有闭合标签但有未闭合前缀 → 标签前的安全文本仍需通过流式发送 → 推进 sentIndex → 等待标签闭合
|
|
687
|
+
* 3. 纯文本 → 触发流式发送(performFlush 会动态计算要发的内容)
|
|
688
|
+
*/
|
|
689
|
+
private async processMediaTags(normalizedFull: string): Promise<void> {
|
|
690
|
+
try {
|
|
691
|
+
// ---- 1. 循环消费所有已闭合的媒体标签 ----
|
|
692
|
+
while (true) {
|
|
693
|
+
if (this.isTerminalPhase) return;
|
|
694
|
+
|
|
695
|
+
const incremental = normalizedFull.slice(this.sentIndex);
|
|
696
|
+
const found = findFirstClosedMediaTag(incremental);
|
|
697
|
+
|
|
698
|
+
if (!found) break;
|
|
699
|
+
|
|
700
|
+
this.logInfo(`processMediaTags: found <${found.tagName}> at offset ${this.sentIndex}, textBefore="${found.textBefore.slice(0, 40)}"`);
|
|
701
|
+
|
|
702
|
+
// ---- 1.1 终结当前流式会话(如果有的话) ----
|
|
703
|
+
// endCurrentStreamIfNeeded 会用 sentIndex 到标签前文本结束的位置来发送终结分片
|
|
704
|
+
// 先临时推进 sentIndex 到标签前文本结束的位置(用于终结分片的内容计算)
|
|
705
|
+
// 不,我们不需要推进——endCurrentStreamIfNeeded 发的是从 sentIndex 开始到当前文本前部分
|
|
706
|
+
// 实际上需要把 textBefore 的内容加入到当前会话的显示范围
|
|
707
|
+
// 终结时 performFlush/sendStreamChunk 用 lastNormalizedFull.slice(sentIndex) 中 textBefore 之前的部分
|
|
708
|
+
// 但 endCurrentStreamIfNeeded 需要知道要发到哪里……
|
|
709
|
+
|
|
710
|
+
// 简化:计算标签前文本在全量中的结束位置
|
|
711
|
+
const textBeforeEndInFull = this.sentIndex + found.textBefore.length;
|
|
712
|
+
|
|
713
|
+
await this.endCurrentStreamIfNeeded("processMediaTags:closedTag", textBeforeEndInFull);
|
|
714
|
+
if (this.isTerminalPhase) return;
|
|
715
|
+
|
|
716
|
+
// ---- 1.2 同步发送媒体文件 ----
|
|
717
|
+
if (found.mediaPath && this.deps.mediaContext) {
|
|
718
|
+
const item: SendQueueItem = { type: found.itemType, content: found.mediaPath };
|
|
719
|
+
this.logDebug(`processMediaTags: sending ${found.itemType}: ${found.mediaPath.slice(0, 80)}`);
|
|
720
|
+
await sendMediaQueue([item], this.deps.mediaContext);
|
|
721
|
+
this.sentMediaCount++;
|
|
722
|
+
this.sentStreamChunkCount++;
|
|
723
|
+
this.logDebug(`processMediaTags: media sent, sentMediaCount=${this.sentMediaCount}, sentStreamChunkCount=${this.sentStreamChunkCount}`);
|
|
724
|
+
} else if (found.mediaPath && !this.deps.mediaContext) {
|
|
725
|
+
this.logWarn(`processMediaTags: no mediaContext provided, cannot send ${found.itemType}`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ---- 1.3 推进 sentIndex,重置流式状态 ----
|
|
729
|
+
this.sentIndex += found.tagEndIndex;
|
|
730
|
+
this.logDebug(`processMediaTags: sentIndex updated to ${this.sentIndex}`);
|
|
731
|
+
this.resetStreamSession();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ---- 循环结束:没有更多闭合标签 ----
|
|
735
|
+
const remaining = normalizedFull.slice(this.sentIndex);
|
|
736
|
+
|
|
737
|
+
if (!remaining) {
|
|
738
|
+
this.logDebug(`processMediaTags: no remaining text after media tags`);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ---- 2. 检查是否有未闭合的标签前缀 ----
|
|
743
|
+
const [safeText, hasIncomplete] = stripIncompleteMediaTag(remaining);
|
|
744
|
+
|
|
745
|
+
if (hasIncomplete) {
|
|
746
|
+
this.logDebug(`processMediaTags: incomplete tag detected, safe text len=${safeText.length}, remaining len=${remaining.length}`);
|
|
747
|
+
|
|
748
|
+
// 先终结当前流式会话(把标签前的安全文本发完 DONE),
|
|
749
|
+
// 避免流式会话在等待标签闭合期间一直卡在 GENERATING 状态
|
|
750
|
+
const safeEndInFull = this.sentIndex + (safeText?.length ?? 0);
|
|
751
|
+
await this.endCurrentStreamIfNeeded("processMediaTags:incompleteTag", safeEndInFull);
|
|
752
|
+
if (this.isTerminalPhase) return;
|
|
753
|
+
|
|
754
|
+
// 推进 sentIndex 到安全文本结束位置,重置流式会话状态
|
|
755
|
+
if (safeText) {
|
|
756
|
+
this.sentIndex = safeEndInFull;
|
|
757
|
+
this.logDebug(`processMediaTags: sentIndex advanced to ${this.sentIndex} after ending stream for incomplete tag`);
|
|
758
|
+
this.resetStreamSession();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 未闭合标签部分留待下次 onPartialReply 带来更多文本后再处理
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ---- 3. 纯文本 → 触发流式发送 ----
|
|
766
|
+
// performFlush 会动态计算 lastNormalizedFull.slice(sentIndex) 的安全部分来发送
|
|
767
|
+
this.logDebug(`processMediaTags: pure text, remaining len=${remaining.length}`);
|
|
768
|
+
|
|
769
|
+
if (!remaining.trim()) {
|
|
770
|
+
// 纯空白文本 → 不启动流式
|
|
771
|
+
this.logDebug(`processMediaTags: pure whitespace, skipping stream start`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
await this.ensureStreamingStarted(normalizedFull.length);
|
|
776
|
+
if (this.isTerminalPhase) return;
|
|
777
|
+
await this.flush.throttledUpdate(this.throttleMs);
|
|
778
|
+
|
|
779
|
+
} catch (err) {
|
|
780
|
+
this.logError(`processMediaTags failed: ${err}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* 终结当前流式会话(如果有的话)
|
|
786
|
+
*
|
|
787
|
+
* @param caller 调用者标识(日志用)
|
|
788
|
+
* @param textEndInFull 本次终结需要发送到的全量文本位置(不含)。
|
|
789
|
+
* 终结分片的内容 = lastNormalizedFull.slice(sentIndex, textEndInFull)
|
|
790
|
+
*
|
|
791
|
+
* 逻辑:
|
|
792
|
+
* - 有活跃 streamMsgId → 等待 flush 完成 → 发 DONE 分片终结
|
|
793
|
+
* - 没有 streamMsgId 但有非空白文本 → 启动流式 → 立即终结
|
|
794
|
+
* - 纯空白且无活跃流式 → 不发送
|
|
795
|
+
*/
|
|
796
|
+
private async endCurrentStreamIfNeeded(caller: string, textEndInFull: number): Promise<void> {
|
|
797
|
+
// 先等待启动完成
|
|
798
|
+
if (this.startingPromise) {
|
|
799
|
+
this.logDebug(`${caller}: waiting for pending stream start`);
|
|
800
|
+
await this.startingPromise;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// 停止所有 flush 活动
|
|
804
|
+
await this.flush.cancelPendingAndWait();
|
|
805
|
+
|
|
806
|
+
// 计算当前会话要发的文本
|
|
807
|
+
const sessionText = this.lastNormalizedFull.slice(this.sentIndex, textEndInFull);
|
|
808
|
+
const [safeText] = stripIncompleteMediaTag(sessionText);
|
|
809
|
+
|
|
810
|
+
if (this.streamMsgId) {
|
|
811
|
+
// 有活跃流式会话 → 终结它
|
|
812
|
+
try {
|
|
813
|
+
await this.sendStreamChunk(safeText, StreamInputState.DONE, caller);
|
|
814
|
+
this.logDebug(`${caller}: current stream session ended`);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
this.logError(`${caller}: failed to end stream: ${err}`);
|
|
817
|
+
}
|
|
818
|
+
} else if (safeText && safeText.trim()) {
|
|
819
|
+
// 没有活跃流式会话,但有非空白文本未发送 → 启动流式 → 立即终结
|
|
820
|
+
// 先临时存储到 _pendingSessionText 以便 doStartStreaming 使用
|
|
821
|
+
this._pendingSessionText = safeText;
|
|
822
|
+
await this.ensureStreamingStarted(textEndInFull);
|
|
823
|
+
this._pendingSessionText = null;
|
|
824
|
+
if (this.isTerminalPhase) return;
|
|
825
|
+
if (this.startingPromise) await this.startingPromise;
|
|
826
|
+
if (this.streamMsgId) {
|
|
827
|
+
try {
|
|
828
|
+
await this.sendStreamChunk(safeText, StreamInputState.DONE, caller);
|
|
829
|
+
this.logDebug(`${caller}: started and ended stream for pre-tag text`);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
this.logError(`${caller}: failed to send pre-tag text: ${err}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// 如果纯空白且没有活跃流式 → 不发送
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** 临时存储 endCurrentStreamIfNeeded 需要立即发送的文本(用于 doStartStreaming) */
|
|
839
|
+
private _pendingSessionText: string | null = null;
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* 重置流式会话状态(用于媒体中断后恢复)
|
|
843
|
+
*
|
|
844
|
+
* 只重置会话相关状态,不重置 sentIndex 和 dispatch 标记。
|
|
845
|
+
* 新流式会话从当前 sentIndex 开始(performFlush 动态计算内容)。
|
|
846
|
+
*/
|
|
847
|
+
private resetStreamSession(): void {
|
|
848
|
+
const prevPhase = this.phase;
|
|
849
|
+
this.phase = "idle";
|
|
850
|
+
this.logDebug(`phase: ${prevPhase} → idle (source: resetStreamSession, forced reset for media resume)`);
|
|
851
|
+
this.streamMsgId = null;
|
|
852
|
+
this.streamIndex = 0;
|
|
853
|
+
this.msgSeq = null;
|
|
854
|
+
this.startingPromise = null;
|
|
855
|
+
this.flush.reset(() => this.performFlush());
|
|
856
|
+
// 注意:不重置 sentIndex、lastNormalizedFull、dispatchFullyComplete、sentStreamChunkCount、sentMediaCount
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ------------------------------------------------------------------
|
|
860
|
+
// 内部:流式会话管理
|
|
861
|
+
// ------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
/** 确保流式会话已开始(首次调用创建;并发调用者会等待首次完成) */
|
|
864
|
+
private async ensureStreamingStarted(textEndInFull: number): Promise<void> {
|
|
865
|
+
if (this.streamMsgId || this.isTerminalPhase) return;
|
|
866
|
+
|
|
867
|
+
if (this.startingPromise) {
|
|
868
|
+
this.logDebug(`ensureStreamingStarted: waiting for pending start request`);
|
|
869
|
+
await this.startingPromise;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (!this.transition("streaming", "ensureStreamingStarted")) return;
|
|
874
|
+
|
|
875
|
+
this.startingPromise = this.doStartStreaming(textEndInFull);
|
|
876
|
+
try {
|
|
877
|
+
await this.startingPromise;
|
|
878
|
+
} finally {
|
|
879
|
+
this.startingPromise = null;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** 实际执行流式启动逻辑 */
|
|
884
|
+
private async doStartStreaming(textEndInFull: number): Promise<void> {
|
|
885
|
+
try {
|
|
886
|
+
// 计算当前会话要发送的文本
|
|
887
|
+
// 优先使用 _pendingSessionText(endCurrentStreamIfNeeded 需要立即发送的文本)
|
|
888
|
+
// 否则使用调用处预先确定的 sentIndex → textEndInFull 范围
|
|
889
|
+
const sessionText = this._pendingSessionText
|
|
890
|
+
?? this.lastNormalizedFull.slice(this.sentIndex, textEndInFull);
|
|
891
|
+
const [safeText] = stripIncompleteMediaTag(sessionText);
|
|
892
|
+
|
|
893
|
+
// 全空白文本 → 不开启流式,退回 idle
|
|
894
|
+
if (!safeText?.trim()) {
|
|
895
|
+
this.logDebug(`doStartStreaming: skipped (session text is empty or whitespace-only)`);
|
|
896
|
+
this.transition("idle", "doStartStreaming", "whitespace_only_text");
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const firstText = safeText;
|
|
900
|
+
const resp = await this.sendStreamChunk(firstText, StreamInputState.GENERATING, "doStartStreaming");
|
|
901
|
+
|
|
902
|
+
if (resp.code && resp.code > 0) {
|
|
903
|
+
throw new Error(`Stream API error: code=${resp.code}, message=${resp.message}`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (!resp.id) {
|
|
907
|
+
throw new Error(`Stream API returned no id: ${JSON.stringify(resp)}`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
this.streamMsgId = resp.id;
|
|
911
|
+
this.flush.setReady(true);
|
|
912
|
+
this.logInfo(`stream started, stream_msg_id=${resp.id}`);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
this.logError(`failed to start streaming: ${err}`);
|
|
915
|
+
this.transition("idle", "doStartStreaming", "start_failed_will_retry");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** 发送一个流式分片(不做任何文本修改) */
|
|
920
|
+
private async sendStreamChunk(
|
|
921
|
+
content: string,
|
|
922
|
+
inputState: StreamInputState,
|
|
923
|
+
caller: string,
|
|
924
|
+
): Promise<StreamMessageResponse> {
|
|
925
|
+
this.logDebug(`sendStreamChunk: caller=${caller}, inputState=${inputState}, contentLen=${content.length}, streamMsgId=${this.streamMsgId}, index=${this.streamIndex}`);
|
|
926
|
+
|
|
927
|
+
// 同一流式会话内所有 chunk 共享同一个 msgSeq;新会话首次发送时生成
|
|
928
|
+
if (this.msgSeq === null) {
|
|
929
|
+
this.msgSeq = getNextMsgSeq(this.deps.replyToMsgId);
|
|
930
|
+
}
|
|
931
|
+
const currentIndex = this.streamIndex++;
|
|
932
|
+
|
|
933
|
+
const token = await getAccessToken(
|
|
934
|
+
this.deps.account.appId,
|
|
935
|
+
this.deps.account.clientSecret,
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
const resp = await sendC2CStreamMessage(token, this.deps.userId, {
|
|
939
|
+
input_mode: StreamInputMode.REPLACE,
|
|
940
|
+
input_state: inputState,
|
|
941
|
+
content_type: StreamContentType.MARKDOWN,
|
|
942
|
+
content_raw: content,
|
|
943
|
+
event_id: this.deps.eventId,
|
|
944
|
+
msg_id: this.deps.replyToMsgId,
|
|
945
|
+
stream_msg_id: this.streamMsgId ?? undefined,
|
|
946
|
+
msg_seq: this.msgSeq,
|
|
947
|
+
index: currentIndex,
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// 只有 code 存在且 > 0 才是失败
|
|
951
|
+
if (resp.code && resp.code > 0) {
|
|
952
|
+
throw new Error(`Stream API error: code=${resp.code}, message=${resp.message}`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// 分片发送成功
|
|
956
|
+
this.sentStreamChunkCount++;
|
|
957
|
+
|
|
958
|
+
return resp;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ------------------------------------------------------------------
|
|
962
|
+
// 内部:flush 实现
|
|
963
|
+
// ------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
/** 执行一次实际的流式内容更新 */
|
|
966
|
+
private async performFlush(): Promise<void> {
|
|
967
|
+
this.logDebug(`performFlush: phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}`);
|
|
968
|
+
if (!this.streamMsgId || this.isTerminalPhase) {
|
|
969
|
+
this.logDebug(`performFlush: skipped (streamMsgId=${this.streamMsgId}, terminal=${this.isTerminalPhase})`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// 动态计算当前会话要发送的文本 = 从 sentIndex 开始的增量
|
|
974
|
+
const sessionText = this.lastNormalizedFull.slice(this.sentIndex);
|
|
975
|
+
if (!sessionText) {
|
|
976
|
+
this.logDebug(`performFlush: skipped (empty session text)`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// 安全检查:确保不会把未闭合的媒体标签前缀发给用户
|
|
981
|
+
const [safeText, hasIncomplete] = stripIncompleteMediaTag(sessionText);
|
|
982
|
+
if (hasIncomplete) {
|
|
983
|
+
this.logDebug(`flush: detected incomplete media tag, sending safe text (${safeText.length}/${sessionText.length} chars)`);
|
|
984
|
+
}
|
|
985
|
+
if (!safeText) {
|
|
986
|
+
this.logDebug(`performFlush: skipped (safeText empty after stripIncompleteMediaTag)`);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
this.logDebug(`performFlush: sending chunk, safeText len=${safeText.length}`);
|
|
991
|
+
try {
|
|
992
|
+
await this.sendStreamChunk(safeText, StreamInputState.GENERATING, "performFlush");
|
|
993
|
+
this.logDebug(`performFlush: chunk sent OK, sentStreamChunks=${this.sentStreamChunkCount}`);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
996
|
+
this.logError(`stream flush failed, will retry on next scheduled flush: ${msg}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ============ 辅助函数 ============
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
// ============ 流式媒体发送 ============
|
|
1005
|
+
|
|
1006
|
+
/** 流式媒体发送上下文(由 gateway 注入到 StreamingController) */
|
|
1007
|
+
export interface StreamingMediaContext {
|
|
1008
|
+
/** 账户信息 */
|
|
1009
|
+
account: ResolvedQQBotAccount;
|
|
1010
|
+
/** 事件信息 */
|
|
1011
|
+
event: {
|
|
1012
|
+
type: "c2c" | "group" | "channel";
|
|
1013
|
+
senderId: string;
|
|
1014
|
+
messageId: string;
|
|
1015
|
+
groupOpenid?: string;
|
|
1016
|
+
channelId?: string;
|
|
1017
|
+
};
|
|
1018
|
+
/** 日志 */
|
|
1019
|
+
log?: {
|
|
1020
|
+
info: (msg: string) => void;
|
|
1021
|
+
error: (msg: string) => void;
|
|
1022
|
+
debug?: (msg: string) => void;
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* 将 StreamingMediaContext 转换为公共的 MediaSendContext
|
|
1028
|
+
*/
|
|
1029
|
+
function toMediaSendContext(ctx: StreamingMediaContext): MediaSendContext {
|
|
1030
|
+
const { account, event, log } = ctx;
|
|
1031
|
+
|
|
1032
|
+
const mediaTarget: MediaTargetContext = {
|
|
1033
|
+
targetType: event.type,
|
|
1034
|
+
targetId:
|
|
1035
|
+
event.type === "c2c"
|
|
1036
|
+
? event.senderId
|
|
1037
|
+
: event.type === "group"
|
|
1038
|
+
? event.groupOpenid!
|
|
1039
|
+
: event.channelId!,
|
|
1040
|
+
account,
|
|
1041
|
+
replyToId: event.messageId,
|
|
1042
|
+
logPrefix: `[qqbot:${account.accountId}]`,
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const qualifiedTarget =
|
|
1046
|
+
event.type === "group"
|
|
1047
|
+
? `qqbot:group:${event.groupOpenid}`
|
|
1048
|
+
: `qqbot:c2c:${event.senderId}`;
|
|
1049
|
+
|
|
1050
|
+
return {
|
|
1051
|
+
mediaTarget,
|
|
1052
|
+
qualifiedTarget,
|
|
1053
|
+
account,
|
|
1054
|
+
replyToId: event.messageId,
|
|
1055
|
+
log,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* 按顺序发送媒体队列中的所有项(流式场景专用)
|
|
1061
|
+
*/
|
|
1062
|
+
async function sendMediaQueue(
|
|
1063
|
+
queue: SendQueueItem[],
|
|
1064
|
+
ctx: StreamingMediaContext,
|
|
1065
|
+
): Promise<void> {
|
|
1066
|
+
const sendCtx = toMediaSendContext(ctx);
|
|
1067
|
+
|
|
1068
|
+
await executeSendQueue(queue, sendCtx, {
|
|
1069
|
+
// 流式场景下跳过 inter-tag 文本(由新流式会话处理)
|
|
1070
|
+
skipInterTagText: true,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ============ 流式模式判断 ============
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* 判断是否应该对当前消息使用流式模式
|
|
1078
|
+
*
|
|
1079
|
+
* 条件:
|
|
1080
|
+
* 1. 账户配置 streaming 未显式设为 false(默认启用)
|
|
1081
|
+
* 2. 目标类型为 c2c(私聊)—— 流式 API 仅支持 C2C
|
|
1082
|
+
*/
|
|
1083
|
+
export function shouldUseStreaming(
|
|
1084
|
+
account: ResolvedQQBotAccount,
|
|
1085
|
+
targetType: "c2c" | "group" | "channel",
|
|
1086
|
+
): boolean {
|
|
1087
|
+
// 开关默认关闭,设置 streaming: true 可开启
|
|
1088
|
+
if (account.config?.streaming !== true) {
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
// 目前流式 API 仅支持 C2C 私聊
|
|
1092
|
+
if (targetType !== "c2c") {
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|