lightclawbot 1.2.6 → 1.2.8
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/dist/src/config.js +30 -3
- package/dist/src/gateway.js +58 -12
- package/dist/src/group/constants/index.js +20 -0
- package/dist/src/group/inbound/index.js +254 -0
- package/dist/src/group/index.js +15 -0
- package/dist/src/group/orchestrator/execution/agent-runner.js +299 -0
- package/dist/src/group/orchestrator/execution/index.js +7 -0
- package/dist/src/group/orchestrator/execution/prompt-builder.js +288 -0
- package/dist/src/group/orchestrator/execution/soul-resolver.js +38 -0
- package/dist/src/group/orchestrator/execution/types.js +7 -0
- package/dist/src/group/orchestrator/index.js +14 -0
- package/dist/src/group/orchestrator/lifecycle/conversation-state.js +162 -0
- package/dist/src/group/orchestrator/lifecycle/index.js +7 -0
- package/dist/src/group/orchestrator/lifecycle/ledger-writer.js +96 -0
- package/dist/src/group/orchestrator/lifecycle/run-registry.js +174 -0
- package/dist/src/group/orchestrator/orchestrator.js +265 -0
- package/dist/src/group/orchestrator/planning/index.js +13 -0
- package/dist/src/group/orchestrator/planning/plan-validator.js +233 -0
- package/dist/src/group/orchestrator/planning/planning-parser.js +207 -0
- package/dist/src/group/orchestrator/planning/subtask-executor.js +345 -0
- package/dist/src/group/orchestrator/planning/summarizer-runner.js +224 -0
- package/dist/src/group/orchestrator/routes/index.js +9 -0
- package/dist/src/group/orchestrator/routes/leader-dispatch.js +229 -0
- package/dist/src/group/orchestrator/routes/leader-orchestration-route.js +179 -0
- package/dist/src/group/orchestrator/routes/leader-planning.js +92 -0
- package/dist/src/group/orchestrator/routes/leader-self-answer.js +223 -0
- package/dist/src/group/orchestrator/routes/mention-concurrent-route.js +226 -0
- package/dist/src/group/orchestrator/routes/route-helpers.js +186 -0
- package/dist/src/group/orchestrator/routes/types.js +8 -0
- package/dist/src/group/services/group-cleanup-service.js +183 -0
- package/dist/src/group/services/group-creation-service.js +122 -0
- package/dist/src/group/services/group-deletion-service.js +111 -0
- package/dist/src/group/services/group-history-service.js +73 -0
- package/dist/src/group/services/group-member-service.js +169 -0
- package/dist/src/group/services/group-query-service.js +133 -0
- package/dist/src/group/services/group-update-service.js +144 -0
- package/dist/src/group/services/index.js +20 -0
- package/dist/src/group/storage/concurrency-manager.js +119 -0
- package/dist/src/group/storage/group-storage-core.js +227 -0
- package/dist/src/group/storage/index.js +12 -0
- package/dist/src/group/storage/message-reader.js +213 -0
- package/dist/src/group/storage/message-writer.js +229 -0
- package/dist/src/group/storage/slice-manager.js +165 -0
- package/dist/src/group/types/common.js +5 -0
- package/dist/src/group/types/index.js +5 -0
- package/dist/src/group/types/message.js +5 -0
- package/dist/src/group/types/orchestrator.js +5 -0
- package/dist/src/group/types/storage.js +5 -0
- package/dist/src/group/utils/id-generator.js +15 -0
- package/dist/src/group/utils/index.js +12 -0
- package/dist/src/group/utils/mime.js +36 -0
- package/dist/src/group/utils/normalize.js +32 -0
- package/dist/src/group/utils/run-helpers.js +36 -0
- package/dist/src/history/session-reader.js +8 -2
- package/dist/src/outbound.js +12 -19
- package/dist/src/shared.js +4 -3
- package/dist/src/socket/events/agents-request.js +147 -0
- package/dist/src/socket/events/chat-request.js +67 -0
- package/dist/src/socket/events/file-download.js +121 -0
- package/dist/src/socket/events/group-abort.js +59 -0
- package/dist/src/socket/events/group-history.js +59 -0
- package/dist/src/socket/events/group-member.js +83 -0
- package/dist/src/socket/events/group-request.js +91 -0
- package/dist/src/socket/events/history-request.js +95 -0
- package/dist/src/socket/events/index.js +39 -0
- package/dist/src/socket/events/message-private.js +82 -0
- package/dist/src/socket/handlers.js +53 -568
- package/dist/src/socket/native-socket.js +21 -20
- package/dist/src/socket/registry.js +6 -3
- package/dist/src/socket/reliable-emitter.js +16 -13
- package/dist/src/socket/service/chat-common.js +36 -0
- package/dist/src/socket/service/chat-create.js +75 -0
- package/dist/src/socket/service/chat-delete.js +94 -0
- package/dist/src/socket/service/chat-list.js +82 -0
- package/dist/src/socket/service/chat-update.js +83 -0
- package/dist/src/socket/service/group-abort.js +104 -0
- package/dist/src/socket/service/group-history.js +140 -0
- package/dist/src/socket/service/group-member.js +209 -0
- package/dist/src/socket/service/group.js +233 -0
- package/dist/src/socket/service/history.js +102 -0
- package/dist/src/socket/service/index.js +14 -0
- package/dist/src/socket/types/index.js +7 -0
- package/dist/src/socket/types/request.js +8 -0
- package/dist/src/socket/types/service.js +8 -0
- package/dist/src/socket/utils/agent-soul.js +95 -0
- package/dist/src/socket/utils/index.js +8 -0
- package/dist/src/socket/utils/message.js +83 -0
- package/dist/src/socket/utils/validate.js +42 -0
- package/dist/src/streaming/index.js +1 -0
- package/dist/src/streaming/stream-reply-sink.js +367 -20
- package/dist/src/streaming/thinking-formatter.js +325 -0
- package/dist/src/streaming/types.js +20 -1
- package/dist/src/{download-tool.js → tools/download-tool.js} +41 -35
- package/dist/src/tools/group-history-tool.js +172 -0
- package/dist/src/{upload-tool.js → tools/upload-tool.js} +2 -2
- package/dist/src/tools.js +4 -3
- package/dist/src/utils/index.js +1 -0
- package/dist/src/utils/logger.js +38 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/dist/src/socket/agent-soul.js +0 -41
- package/dist/src/socket/chat.js +0 -257
|
@@ -17,6 +17,8 @@ import { CHANNEL_KEY } from "../config.js";
|
|
|
17
17
|
import { uploadFileToServer } from "../file-storage.js";
|
|
18
18
|
import { mediaUrlsToFiles } from "../media.js";
|
|
19
19
|
import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js";
|
|
20
|
+
import { buildRunningStep, buildDoneStep, genStepId } from "./thinking-formatter.js";
|
|
21
|
+
import { DEFAULT_PARTIAL_COALESCE } from "./types.js";
|
|
20
22
|
import { emitSignal } from "../utils/common.js";
|
|
21
23
|
import { readSessionHistoryTail } from "../history/index.js";
|
|
22
24
|
/** 派生/管理 subagent 的工具名;前端可据此特化渲染 tool_start。 */
|
|
@@ -33,6 +35,39 @@ function localizeAbortReplyText(text) {
|
|
|
33
35
|
}
|
|
34
36
|
export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
35
37
|
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
|
|
38
|
+
// ── 群聊扩展字段 ──
|
|
39
|
+
const { groupId, runId: optRunId, parentRunId, conversationId, isSubtask, parentMsgId } = opts;
|
|
40
|
+
/** 是否为群聊模式 */
|
|
41
|
+
const isGroupMode = !!groupId;
|
|
42
|
+
/** 群聊场景下可变的 runId(可能由 SDK 回填) */
|
|
43
|
+
let knownRunId = optRunId;
|
|
44
|
+
/**
|
|
45
|
+
* 构建群聊 extra 数据载荷(仅群聊模式使用)
|
|
46
|
+
* 协议字段与前端 useGroupChat.ts 对齐
|
|
47
|
+
*/
|
|
48
|
+
const buildGroupDataExtra = (streamStatus) => {
|
|
49
|
+
if (!isGroupMode)
|
|
50
|
+
return undefined;
|
|
51
|
+
const extra = { groupId, agentId, streamStatus };
|
|
52
|
+
if (knownRunId)
|
|
53
|
+
extra.runId = knownRunId;
|
|
54
|
+
if (parentRunId !== undefined && parentRunId !== null)
|
|
55
|
+
extra.parentRunId = parentRunId;
|
|
56
|
+
if (conversationId)
|
|
57
|
+
extra.conversationId = conversationId;
|
|
58
|
+
if (isSubtask)
|
|
59
|
+
extra.isSubtask = true;
|
|
60
|
+
if (parentMsgId)
|
|
61
|
+
extra.parentMsgId = parentMsgId;
|
|
62
|
+
return extra;
|
|
63
|
+
};
|
|
64
|
+
/** 群聊场景下所有信号帧的顶层 extra(注入 chatKind: 'group') */
|
|
65
|
+
const groupSignalExtra = isGroupMode ? { chatKind: 'group' } : undefined;
|
|
66
|
+
// ── partial 通道二次聚合参数(详见 ADR-017) ──
|
|
67
|
+
const coalesceCfg = {
|
|
68
|
+
...DEFAULT_PARTIAL_COALESCE,
|
|
69
|
+
...(opts.partialCoalesce ?? {}),
|
|
70
|
+
};
|
|
36
71
|
// ── 增量追踪 & 已推送文本 ──
|
|
37
72
|
let partialReplyState = createDeltaTrackerState();
|
|
38
73
|
let streamedText = "";
|
|
@@ -46,6 +81,21 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
46
81
|
const counts = { tool: 0, block: 0, final: 0 };
|
|
47
82
|
let toolStartCount = 0;
|
|
48
83
|
let assistantMessageCount = 0;
|
|
84
|
+
// ── thinking_step 关联:onToolStart 生成 stepId,缓存到这里;
|
|
85
|
+
// onItemEvent 拿不到 itemId 时回退到这个 stepId,保证 running ↔ done 能配对。
|
|
86
|
+
let lastToolStepId = null;
|
|
87
|
+
let lastToolName = null;
|
|
88
|
+
// ── onItemEvent(start) 预热信息:
|
|
89
|
+
// 实测 SDK 时序是 onItemEvent(start) 先于 onToolStart(毫秒级),而工具调用串行,
|
|
90
|
+
// 所以用一对全局变量缓存最近一次 start 的 meta/title,onToolStart 取走后由 sink 使用。
|
|
91
|
+
// args 摸不出内容时(如 browser 工具 args.action="open" 没信息量),running 用这些兜底。
|
|
92
|
+
let pendingItemMeta = null;
|
|
93
|
+
let pendingItemTitle = null;
|
|
94
|
+
// ── thinking_step 时间线序号 ──
|
|
95
|
+
// seqCounter: 全局自增,每次 running 帧 +1
|
|
96
|
+
// stepIdToSeq: running 时记录 stepId → seq;done 帧复用此 seq,保证合并行的位置稳定
|
|
97
|
+
let thinkingSeqCounter = 0;
|
|
98
|
+
const thinkingStepSeqMap = new Map();
|
|
49
99
|
// ── thinking/reasoning 观测(不面向用户,仅为首字延迟归因) ──
|
|
50
100
|
let reasoningChars = 0;
|
|
51
101
|
let reasoningFirstAt = 0;
|
|
@@ -56,6 +106,89 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
56
106
|
let completed = false;
|
|
57
107
|
/** openclaw abort 检测:sendFinalReply 收到 abort 文案时置 true,markComplete 据此跳过 NO_REPLY。 */
|
|
58
108
|
let abortDetected = false;
|
|
109
|
+
// ── Coalescer 状态:缓冲区 + idle 定时器 + 统计 ──
|
|
110
|
+
let coalesceBuf = "";
|
|
111
|
+
let idleTimer = null;
|
|
112
|
+
let firstFrameEmitted = false;
|
|
113
|
+
/** 自然 flush(size/idle)的最近一次时间戳,用于统计 maxIdleGap */
|
|
114
|
+
let lastFlushAt = 0;
|
|
115
|
+
/** 首字(首帧)emit 时刻,用于 firstByteMs 统计 */
|
|
116
|
+
let firstByteAt = 0;
|
|
117
|
+
/** 本次会话的 sink 启动时间(首次 onPartialReply 进入时戳一次) */
|
|
118
|
+
let sinkStartAt = 0;
|
|
119
|
+
const coalesceStats = {
|
|
120
|
+
frames: 0,
|
|
121
|
+
totalChars: 0,
|
|
122
|
+
triggerStats: { first: 0, size: 0, idle: 0, tool: 0, final: 0, round: 0, block: 0, complete: 0 },
|
|
123
|
+
maxIdleGap: 0,
|
|
124
|
+
/** 模型 onPartialReply 累计的 token 增量条数(用于评估 "模型给了多少条" → 合并比) */
|
|
125
|
+
totalTokenDeltas: 0,
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* 当前合并周期内累计的模型 token 增量条数。
|
|
129
|
+
*
|
|
130
|
+
* - onPartialReply 每收到一个非空 delta 就 +1
|
|
131
|
+
* - emitChunk 时打入日志后清零
|
|
132
|
+
* - finalDelta 路径不计入(不是模型流式 token)
|
|
133
|
+
*/
|
|
134
|
+
let pendingTokenCount = 0;
|
|
135
|
+
/** 清掉 idle 定时器(不 flush) */
|
|
136
|
+
const clearIdleTimer = () => {
|
|
137
|
+
if (idleTimer !== null) {
|
|
138
|
+
clearTimeout(idleTimer);
|
|
139
|
+
idleTimer = null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
/** 重置 idle 定时器:到期触发 idle flush */
|
|
143
|
+
const armIdleTimer = () => {
|
|
144
|
+
clearIdleTimer();
|
|
145
|
+
if (coalesceCfg.idleMs > 0) {
|
|
146
|
+
idleTimer = setTimeout(() => {
|
|
147
|
+
idleTimer = null;
|
|
148
|
+
flushCoalesceBuffer("idle");
|
|
149
|
+
}, coalesceCfg.idleMs);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* 真正向 socket 发出 stream_chunk 帧 + 维护 coalesceStats。
|
|
154
|
+
* trigger 仅用于日志归因,行为完全一致。
|
|
155
|
+
*/
|
|
156
|
+
const emitChunk = (text, trigger) => {
|
|
157
|
+
if (!text)
|
|
158
|
+
return;
|
|
159
|
+
emitSignal(signalCtx, "stream_chunk", text, groupSignalExtra, buildGroupDataExtra('delta'));
|
|
160
|
+
emittedUserVisible = true;
|
|
161
|
+
coalesceStats.frames++;
|
|
162
|
+
coalesceStats.totalChars += text.length;
|
|
163
|
+
coalesceStats.triggerStats[trigger]++;
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (firstByteAt === 0)
|
|
166
|
+
firstByteAt = now;
|
|
167
|
+
if (lastFlushAt > 0) {
|
|
168
|
+
const gap = now - lastFlushAt;
|
|
169
|
+
if (gap > coalesceStats.maxIdleGap)
|
|
170
|
+
coalesceStats.maxIdleGap = gap;
|
|
171
|
+
}
|
|
172
|
+
lastFlushAt = now;
|
|
173
|
+
// 当前周期内累计的模型 token 增量条数,emit 后清零;final 路径未累加,会显示为 0
|
|
174
|
+
const tokensInCycle = pendingTokenCount;
|
|
175
|
+
coalesceStats.totalTokenDeltas += tokensInCycle;
|
|
176
|
+
pendingTokenCount = 0;
|
|
177
|
+
log?.info(`[${CHANNEL_KEY}] [stream] [coalesce] flush: trigger=${trigger} ` +
|
|
178
|
+
`bufLen=${coalesceBuf.length} deltaLen=${text.length} tokens=${tokensInCycle}`);
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* 把 buffer 中堆积的字符整体 emit 出去并复位 buffer/timer。
|
|
182
|
+
* 任何强制 flush 路径必须经过这里,保证统计与日志一致。
|
|
183
|
+
*/
|
|
184
|
+
const flushCoalesceBuffer = (trigger) => {
|
|
185
|
+
clearIdleTimer();
|
|
186
|
+
if (!coalesceBuf)
|
|
187
|
+
return;
|
|
188
|
+
const payload = coalesceBuf;
|
|
189
|
+
coalesceBuf = "";
|
|
190
|
+
emitChunk(payload, trigger);
|
|
191
|
+
};
|
|
59
192
|
// ── Token 用量 ──
|
|
60
193
|
// 把每一轮的 usage 写入 transcript jsonl(与 history 模块同源),
|
|
61
194
|
// 因此在 markComplete 时从 transcript 末尾读最近一条 assistant 消息的 usage,
|
|
@@ -71,17 +204,44 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
71
204
|
if (typingStartSent)
|
|
72
205
|
return;
|
|
73
206
|
typingStartSent = true;
|
|
74
|
-
emitSignal(signalCtx, "typing_start");
|
|
207
|
+
emitSignal(signalCtx, "typing_start", "", groupSignalExtra, buildGroupDataExtra('delta'));
|
|
75
208
|
};
|
|
76
|
-
const sendTypingStop = () => {
|
|
209
|
+
const sendTypingStop = (streamStatus, finalContent) => {
|
|
77
210
|
if (typingStopSent)
|
|
78
211
|
return;
|
|
79
212
|
typingStopSent = true;
|
|
80
|
-
emitSignal(signalCtx, "typing_stop");
|
|
213
|
+
emitSignal(signalCtx, "typing_stop", finalContent ?? "", groupSignalExtra, buildGroupDataExtra(streamStatus ?? 'done'));
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* 群聊场景:发送最终完整文本帧
|
|
217
|
+
* 通过 emitter.emit 直接推送完整消息(非信号),前端据此渲染最终气泡内容。
|
|
218
|
+
*/
|
|
219
|
+
const emitFinalText = (text, streamStatus) => {
|
|
220
|
+
if (!isGroupMode)
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
emitter.emit({
|
|
224
|
+
msgId: signalCtx.replyMsgId,
|
|
225
|
+
from: emitter.botClientId,
|
|
226
|
+
to: targetId,
|
|
227
|
+
content: text,
|
|
228
|
+
timestamp: Date.now(),
|
|
229
|
+
replyToMsgId: originalMsgId,
|
|
230
|
+
agentId,
|
|
231
|
+
chatKind: 'group',
|
|
232
|
+
extra: buildGroupDataExtra(streamStatus),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
log?.warn(`[${CHANNEL_KEY}] [stream] emitFinalText failed: ${err}`);
|
|
237
|
+
}
|
|
81
238
|
};
|
|
82
239
|
/**
|
|
83
|
-
* 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 →
|
|
240
|
+
* 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 → 推送给用户。
|
|
84
241
|
* 仅在 mediaList 非空时调用。
|
|
242
|
+
*
|
|
243
|
+
* 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。
|
|
244
|
+
* 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。
|
|
85
245
|
*/
|
|
86
246
|
const handleMediaFinal = async (replyText, mediaList) => {
|
|
87
247
|
const files = await mediaUrlsToFiles(mediaList, log);
|
|
@@ -105,16 +265,32 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
105
265
|
}
|
|
106
266
|
let enrichedText = replyText;
|
|
107
267
|
if (publicUrls.length > 0) {
|
|
268
|
+
// 判断是否为图片类型(根据 URL 中的文件扩展名)
|
|
108
269
|
const urlSection = publicUrls
|
|
109
270
|
.map((url, i) => {
|
|
110
271
|
const match = url.match(/filePath=([^&]+)/);
|
|
111
272
|
const filePath = match ? decodeURIComponent(match[1]) : "";
|
|
112
273
|
const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`;
|
|
113
|
-
|
|
274
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
275
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
|
|
276
|
+
// 图片使用 Markdown 图片语法,非图片使用链接语法
|
|
277
|
+
return isImage ? `` : `📎 [${fileName}](${url})`;
|
|
114
278
|
})
|
|
115
279
|
.join("\n");
|
|
116
280
|
enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
|
|
117
281
|
}
|
|
282
|
+
// ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ──
|
|
283
|
+
if (isGroupMode) {
|
|
284
|
+
if (enrichedText.trim()) {
|
|
285
|
+
const delta = `\n\n${enrichedText}`;
|
|
286
|
+
streamedText += delta;
|
|
287
|
+
emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta'));
|
|
288
|
+
emittedUserVisible = true;
|
|
289
|
+
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// ── 私聊模式:通过 sendFiles / sendReply 推送 ──
|
|
118
294
|
if (files.length > 0) {
|
|
119
295
|
emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
|
|
120
296
|
emittedUserVisible = true;
|
|
@@ -138,9 +314,11 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
138
314
|
if (completed)
|
|
139
315
|
return false;
|
|
140
316
|
counts.tool++;
|
|
317
|
+
// 强制 flush:tool_end 之后 LLM 通常会续写新文本,必须把上一段 buffer 先发出去,避免错位。
|
|
318
|
+
flushCoalesceBuffer("tool");
|
|
141
319
|
const isError = !!payload.isError;
|
|
142
320
|
log?.info(`[${CHANNEL_KEY}] [stream] sendToolResult: textLen=${payload.text?.length ?? 0} isError=${isError}`);
|
|
143
|
-
emitSignal(signalCtx, "tool_end", "", { toolIsError: isError });
|
|
321
|
+
emitSignal(signalCtx, "tool_end", "", { toolIsError: isError, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
144
322
|
return true;
|
|
145
323
|
},
|
|
146
324
|
/** 块回复:文本已由 onPartialReply 推送,这里只处理媒体。 */
|
|
@@ -153,6 +331,8 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
153
331
|
: payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
154
332
|
if (mediaList.length > 0) {
|
|
155
333
|
log?.info(`[${CHANNEL_KEY}] [stream] sendBlockReply: processing ${mediaList.length} media files`);
|
|
334
|
+
// 强制 flush:媒体附件要走在文本"已渲染"之后,否则前端会先看到媒体卡再补文字,渲染错乱。
|
|
335
|
+
flushCoalesceBuffer("block");
|
|
156
336
|
enqueue(() => handleMediaFinal(payload.text ?? "", mediaList).catch((err) => {
|
|
157
337
|
log?.error(`[${CHANNEL_KEY}] [stream] sendBlockReply media error: ${err}`);
|
|
158
338
|
}));
|
|
@@ -182,6 +362,8 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
182
362
|
? payload.mediaUrls
|
|
183
363
|
: payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
184
364
|
log?.info(`[${CHANNEL_KEY}] [stream] sendFinalReply: textLen=${replyText.length} mediaCount=${mediaList.length} streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
365
|
+
// 强制 flush:尾部增量必须在 buffer 已堆积内容"之后"发,不能让 finalDelta 插队。
|
|
366
|
+
flushCoalesceBuffer("final");
|
|
185
367
|
if (mediaList.length > 0) {
|
|
186
368
|
enqueue(() => handleMediaFinal(replyText, mediaList).catch((err) => {
|
|
187
369
|
log?.error(`[${CHANNEL_KEY}] [stream] sendFinalReply media error: ${err}`);
|
|
@@ -216,8 +398,8 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
216
398
|
partialReplyState.latest = replyText;
|
|
217
399
|
}
|
|
218
400
|
if (delta) {
|
|
219
|
-
|
|
220
|
-
|
|
401
|
+
// 直接走 emitChunk 而非进 buffer:finalDelta 是"本轮兜底尾巴",必须立即可见。
|
|
402
|
+
emitChunk(delta, "final");
|
|
221
403
|
log?.debug?.(`[${CHANNEL_KEY}] [stream] Final delta: ${delta.length} chars`);
|
|
222
404
|
}
|
|
223
405
|
return true;
|
|
@@ -244,10 +426,36 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
244
426
|
*/
|
|
245
427
|
markComplete: (markOpts) => {
|
|
246
428
|
completed = true;
|
|
429
|
+
// 强制 flush:buffer 残留必须在 typing_stop 之前发出去;同时也清掉 idle timer 防泄漏。
|
|
430
|
+
flushCoalesceBuffer("complete");
|
|
247
431
|
const hadStreamedText = streamedText.length > 0 || streamedHistory.length > 0;
|
|
248
432
|
const hadDispatch = counts.tool > 0 || counts.block > 0 || counts.final > 0;
|
|
249
433
|
const hadLLMActivity = toolStartCount > 0 || assistantMessageCount > 0;
|
|
250
434
|
const isAborted = markOpts?.aborted === true || abortDetected;
|
|
435
|
+
// ── 群聊模式:简化终态处理(不发 usage / NO_REPLY,完整文本合并到 typing_stop 的 content 中) ──
|
|
436
|
+
if (isGroupMode) {
|
|
437
|
+
const aggregatedText = streamedHistory.length > 0
|
|
438
|
+
? [...streamedHistory, streamedText].filter(Boolean).join('\n')
|
|
439
|
+
: streamedText;
|
|
440
|
+
if (isAborted) {
|
|
441
|
+
log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): aborted`);
|
|
442
|
+
// 将完整文本合并到 typing_stop 的 content 中,前端据此渲染最终气泡
|
|
443
|
+
sendTypingStop('aborted', aggregatedText || '已停止');
|
|
444
|
+
}
|
|
445
|
+
else if (hadStreamedText || hadDispatch) {
|
|
446
|
+
log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): done ` +
|
|
447
|
+
`streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
448
|
+
// 将完整文本合并到 typing_stop 的 content 中,避免额外发送独立消息导致前端重复渲染
|
|
449
|
+
sendTypingStop('done', aggregatedText);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// 群聊无内容输出时也发 done(不发 NO_REPLY)
|
|
453
|
+
log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): done (no content)`);
|
|
454
|
+
sendTypingStop('failed', '调用 Agent 失败,请稍后重试');
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// ── 私聊模式:原有逻辑 ──
|
|
251
459
|
if (isAborted) {
|
|
252
460
|
log?.info(`[${CHANNEL_KEY}] [stream] markComplete: aborted`);
|
|
253
461
|
}
|
|
@@ -274,13 +482,38 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
274
482
|
`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount} ` +
|
|
275
483
|
`streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
276
484
|
}
|
|
485
|
+
// 会话级聚合统计 summary —— 调参依据(详见 ADR-017)
|
|
486
|
+
if (coalesceStats.frames > 0) {
|
|
487
|
+
const durationMs = sinkStartAt > 0 ? Date.now() - sinkStartAt : 0;
|
|
488
|
+
const firstByteMs = sinkStartAt > 0 && firstByteAt > 0 ? firstByteAt - sinkStartAt : 0;
|
|
489
|
+
const avgFrameLen = (coalesceStats.totalChars / coalesceStats.frames).toFixed(1);
|
|
490
|
+
// 平均合并比:每个 emit 帧承载了多少个模型 token 增量
|
|
491
|
+
const avgTokensPerFrame = coalesceStats.frames > 0
|
|
492
|
+
? (coalesceStats.totalTokenDeltas / coalesceStats.frames).toFixed(1)
|
|
493
|
+
: "0";
|
|
494
|
+
log?.info(`[${CHANNEL_KEY}] [stream] [coalesce-summary] ` +
|
|
495
|
+
`frames=${coalesceStats.frames} totalChars=${coalesceStats.totalChars} ` +
|
|
496
|
+
`totalTokens=${coalesceStats.totalTokenDeltas} avgTokensPerFrame=${avgTokensPerFrame} ` +
|
|
497
|
+
`triggerStats=${JSON.stringify(coalesceStats.triggerStats)} ` +
|
|
498
|
+
`avgFrameLen=${avgFrameLen} maxIdleGap=${coalesceStats.maxIdleGap}ms ` +
|
|
499
|
+
`firstByteMs=${firstByteMs} durationMs=${durationMs} ` +
|
|
500
|
+
`cfg={minChars:${coalesceCfg.minChars},idleMs:${coalesceCfg.idleMs}}`);
|
|
501
|
+
}
|
|
277
502
|
// 在 typing_stop 之前 emit 一帧 usage(如有)。
|
|
278
503
|
// 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
|
|
279
|
-
//
|
|
280
|
-
//
|
|
504
|
+
//
|
|
505
|
+
// 守卫条件(缺一不可):
|
|
506
|
+
// 1. 非 abort
|
|
507
|
+
// 2. sessionKey 已知(否则无 transcript 可读)
|
|
508
|
+
// 3. 本 sink 真正承载了一次完整的 LLM 输出:hadLLMActivity && (hadStreamedText || hadDispatch)
|
|
509
|
+
//
|
|
510
|
+
// 第 3 条尤为关键:当用户连发消息被 openclaw 合并为 followup 时,被合并那条的 sink
|
|
511
|
+
// 不会有任何 LLM 活动(hadLLMActivity=false),但 transcript 里有上一轮的 usage —— 若
|
|
512
|
+
// 不加守卫就会把上一轮的 usage 误透给这条消息,前端把它当作"该消息已结束"的终态信号,
|
|
513
|
+
// 导致后续真正的回复内容不再绑定到这条消息上。
|
|
281
514
|
let usageToEmit;
|
|
282
|
-
|
|
283
|
-
if (!isAborted && sessionKey) {
|
|
515
|
+
const sinkProducedOutput = hadLLMActivity && (hadStreamedText || hadDispatch);
|
|
516
|
+
if (!isAborted && sessionKey && sinkProducedOutput) {
|
|
284
517
|
try {
|
|
285
518
|
// 找到本轮最后一条 assistant
|
|
286
519
|
const tailMessages = readSessionHistoryTail(sessionKey, {
|
|
@@ -323,16 +556,43 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
323
556
|
// ────────────────────────────────────────
|
|
324
557
|
const replyOptions = {
|
|
325
558
|
...prefixOptions,
|
|
326
|
-
/**
|
|
559
|
+
/**
|
|
560
|
+
* AI 每产 token 回调,计算增量后进 Coalescer:
|
|
561
|
+
* - 首帧(flushFirstFrame=true)立即 emit,保证首字延迟 < 100ms
|
|
562
|
+
* - 后续追加到 buffer,达到 minChars 即 flush;否则 idle 超过 idleMs 兜底 flush
|
|
563
|
+
* - 详见 ADR-017
|
|
564
|
+
*/
|
|
327
565
|
onPartialReply: (payload) => {
|
|
328
566
|
if (completed || !payload.text)
|
|
329
567
|
return;
|
|
330
568
|
ensureTypingStart();
|
|
569
|
+
if (sinkStartAt === 0)
|
|
570
|
+
sinkStartAt = Date.now();
|
|
331
571
|
const delta = toStreamDeltaText(partialReplyState, payload.text);
|
|
332
|
-
if (delta)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
572
|
+
if (!delta)
|
|
573
|
+
return;
|
|
574
|
+
// 当前合并周期内的模型 token 增量计数 +1(emit 时清零并打入日志)
|
|
575
|
+
pendingTokenCount++;
|
|
576
|
+
streamedText += delta;
|
|
577
|
+
// 不聚合模式(兜底回滚开关):minChars/idleMs 任一为 0 时退化为每帧立即 emit
|
|
578
|
+
if (coalesceCfg.minChars <= 0 || coalesceCfg.idleMs <= 0) {
|
|
579
|
+
emitChunk(delta, firstFrameEmitted ? "size" : "first");
|
|
580
|
+
firstFrameEmitted = true;
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// 首帧立即 flush(含 buffer 中可能已有的内容,但通常为空)
|
|
584
|
+
if (!firstFrameEmitted && coalesceCfg.flushFirstFrame !== false) {
|
|
585
|
+
firstFrameEmitted = true;
|
|
586
|
+
coalesceBuf += delta;
|
|
587
|
+
flushCoalesceBuffer("first");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
coalesceBuf += delta;
|
|
591
|
+
if (coalesceBuf.length >= coalesceCfg.minChars) {
|
|
592
|
+
flushCoalesceBuffer("size");
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
armIdleTimer();
|
|
336
596
|
}
|
|
337
597
|
},
|
|
338
598
|
/**
|
|
@@ -343,13 +603,43 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
343
603
|
onToolStart: (payload) => {
|
|
344
604
|
if (completed)
|
|
345
605
|
return;
|
|
606
|
+
// SDK 在 phase=start 和 phase=update 都会回调;只在 start 时认为是"新一次工具调用",
|
|
607
|
+
// update 仅作为同一 stepId 的中间态,不重新计 seq、不发 running 帧(避免重复)。
|
|
608
|
+
const toolPhase = payload.phase ?? "";
|
|
609
|
+
if (toolPhase !== "start") {
|
|
610
|
+
// 仍然透传 tool_start 帧(向下兼容旧前端的 update 渲染),但不动 thinking_step。
|
|
611
|
+
const toolNameForUpdate = payload.name ?? "unknown";
|
|
612
|
+
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolNameForUpdate} phase=${toolPhase} (skip thinking_step)`);
|
|
613
|
+
emitSignal(signalCtx, "tool_start", "\n\n", { toolName: toolNameForUpdate, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
346
616
|
toolStartCount++;
|
|
617
|
+
// 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。
|
|
618
|
+
flushCoalesceBuffer("tool");
|
|
347
619
|
const toolName = payload.name ?? "unknown";
|
|
348
|
-
const
|
|
620
|
+
const args = payload.args;
|
|
349
621
|
const isSubagentSpawn = SUBAGENT_TOOL_NAMES.has(toolName);
|
|
350
|
-
|
|
622
|
+
const argsKeys = args ? Object.keys(args).join(",") : "(none)";
|
|
623
|
+
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase} argsKeys=${argsKeys}` +
|
|
351
624
|
(isSubagentSpawn ? " (subagent-spawn)" : ""));
|
|
352
|
-
emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase });
|
|
625
|
+
emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
626
|
+
// 追加 thinking_step running 帧:和 tool_start 同步发出,前端可二选一渲染。
|
|
627
|
+
// stepId 缓存到 lastToolStepId,onItemEvent(completed) 没拿到 itemId 时回退使用。
|
|
628
|
+
const stepId = genStepId(toolStartCount, toolName);
|
|
629
|
+
lastToolStepId = stepId;
|
|
630
|
+
lastToolName = toolName;
|
|
631
|
+
// 分配并记录 seq;done 帧复用此 seq 保证合并行的时间线位置稳定。
|
|
632
|
+
thinkingSeqCounter += 1;
|
|
633
|
+
const seq = thinkingSeqCounter;
|
|
634
|
+
thinkingStepSeqMap.set(stepId, seq);
|
|
635
|
+
// 取走上一次 onItemEvent(start) 预热的 meta/title 作为 running 文案兜底,
|
|
636
|
+
// 取后立即清空,避免被下一次调用误用。
|
|
637
|
+
const fallbackMeta = pendingItemMeta ?? undefined;
|
|
638
|
+
const fallbackTitle = pendingItemTitle ?? undefined;
|
|
639
|
+
pendingItemMeta = null;
|
|
640
|
+
pendingItemTitle = null;
|
|
641
|
+
const runningStep = buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle);
|
|
642
|
+
emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: runningStep });
|
|
353
643
|
},
|
|
354
644
|
onReplyStart: () => {
|
|
355
645
|
if (completed)
|
|
@@ -359,6 +649,10 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
359
649
|
onAgentRunStart: (runId) => {
|
|
360
650
|
if (completed)
|
|
361
651
|
return;
|
|
652
|
+
// 群聊场景:若外部未预设 runId,用 SDK 返回的 runId 回填
|
|
653
|
+
if (isGroupMode && !optRunId && runId) {
|
|
654
|
+
knownRunId = runId;
|
|
655
|
+
}
|
|
362
656
|
log?.info(`[${CHANNEL_KEY}] [stream] Agent run started: runId=${runId}`);
|
|
363
657
|
},
|
|
364
658
|
/**
|
|
@@ -396,6 +690,10 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
396
690
|
if (completed)
|
|
397
691
|
return;
|
|
398
692
|
assistantMessageCount++;
|
|
693
|
+
// 强制 flush:切轮前若不发出 buffer 残留,旋转 streamedText 时会丢失这段未发的字符。
|
|
694
|
+
flushCoalesceBuffer("round");
|
|
695
|
+
// 切轮意味着"上一段语义已结束",重置首帧标记,让下一段开头的首字也能立即可见。
|
|
696
|
+
firstFrameEmitted = false;
|
|
399
697
|
if (streamedText)
|
|
400
698
|
streamedHistory.push(streamedText);
|
|
401
699
|
partialReplyState = createDeltaTrackerState();
|
|
@@ -421,6 +719,45 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
421
719
|
if (completed)
|
|
422
720
|
return;
|
|
423
721
|
log?.info(`[${CHANNEL_KEY}] [stream] onItemEvent: ${JSON.stringify(payload)}`);
|
|
722
|
+
// SDK 在 exec 工具上会同时发 kind=tool 和 kind=command 两份事件,载荷高度重复。
|
|
723
|
+
// 只采 kind=tool(缺省视为 tool),过滤掉 kind=command 避免时间线重复行。
|
|
724
|
+
const kind = payload.kind ?? "tool";
|
|
725
|
+
if (kind === "command")
|
|
726
|
+
return;
|
|
727
|
+
// start 帧:预热缓存 meta/title,供紧随其后的 onToolStart 拼 running 文案。
|
|
728
|
+
// (实测时序:onItemEvent(start) → onToolStart,间隔毫秒级;工具调用串行不会交叉。)
|
|
729
|
+
const phase = payload.phase ?? "";
|
|
730
|
+
if (phase === "start") {
|
|
731
|
+
pendingItemMeta = payload.meta?.trim() || null;
|
|
732
|
+
pendingItemTitle = payload.title?.trim() || null;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// 仅终态事件触发 thinking_step done/error 帧;中间态(running / progress)暂不映射,
|
|
736
|
+
// 避免协议面被 SDK 内部 phase 噪音污染。
|
|
737
|
+
const status = payload.status ?? "";
|
|
738
|
+
const isTerminal = status === "completed" ||
|
|
739
|
+
status === "done" ||
|
|
740
|
+
status === "failed" ||
|
|
741
|
+
status === "error" ||
|
|
742
|
+
status === "cancelled";
|
|
743
|
+
if (!isTerminal)
|
|
744
|
+
return;
|
|
745
|
+
// stepId 关联策略(重要):
|
|
746
|
+
// 实测 openclaw `onItemEvent` 里的 `payload.itemId` 与 `onToolStart` 时生成的
|
|
747
|
+
// stepId 完全对不上(SDK 内部随机分配),如果直接把 itemId 当 stepId 透传出去,
|
|
748
|
+
// 前端按 stepId 合并行就永远落空,结果就是"running 一行 + done 又起一行"。
|
|
749
|
+
// 因此优先复用 running 时缓存的 lastToolStepId,保证 done 帧能就地覆盖到 running 那一行。
|
|
750
|
+
// 仅当不存在 lastToolStepId(极端兜底)时才退回到 itemId / "item-unknown"。
|
|
751
|
+
const fallbackStepId = `item-${payload.itemId ?? "unknown"}`;
|
|
752
|
+
const stepId = lastToolStepId ?? payload.itemId ?? fallbackStepId;
|
|
753
|
+
// 复用 running 时分配的 seq,保证合并行不漂移。
|
|
754
|
+
let seq = thinkingStepSeqMap.get(stepId);
|
|
755
|
+
if (seq === undefined) {
|
|
756
|
+
thinkingSeqCounter += 1;
|
|
757
|
+
seq = thinkingSeqCounter;
|
|
758
|
+
}
|
|
759
|
+
const doneStep = buildDoneStep(payload, stepId, seq, lastToolName ?? undefined);
|
|
760
|
+
emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: doneStep });
|
|
424
761
|
},
|
|
425
762
|
onTypingCleanup: () => {
|
|
426
763
|
log?.info(`[${CHANNEL_KEY}] [stream] onTypingCleanup: counts=${JSON.stringify(counts)} ` +
|
|
@@ -435,5 +772,15 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
435
772
|
replyOptions,
|
|
436
773
|
// 业务辅助:独立于 openclaw ReplyDispatcher 契约暴露,避免污染 dispatcher
|
|
437
774
|
hasEmittedContent: () => emittedUserVisible,
|
|
775
|
+
// 群聊场景下暴露 emitStart / getRunId,私聊场景为 no-op
|
|
776
|
+
emitStart: ensureTypingStart,
|
|
777
|
+
getRunId: () => knownRunId,
|
|
778
|
+
// 获取本次 run 的最终完整文本(聚合所有轮次),供 AgentRunner 返回给上层写入账本
|
|
779
|
+
getFinalText: () => {
|
|
780
|
+
if (streamedHistory.length > 0) {
|
|
781
|
+
return [...streamedHistory, streamedText].filter(Boolean).join('\n');
|
|
782
|
+
}
|
|
783
|
+
return streamedText;
|
|
784
|
+
},
|
|
438
785
|
};
|
|
439
786
|
}
|