lightclawbot 1.2.6 → 1.2.7
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/gateway.js +50 -6
- 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/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 +270 -14
- 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,7 @@ 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 { DEFAULT_PARTIAL_COALESCE } from "./types.js";
|
|
20
21
|
import { emitSignal } from "../utils/common.js";
|
|
21
22
|
import { readSessionHistoryTail } from "../history/index.js";
|
|
22
23
|
/** 派生/管理 subagent 的工具名;前端可据此特化渲染 tool_start。 */
|
|
@@ -33,6 +34,39 @@ function localizeAbortReplyText(text) {
|
|
|
33
34
|
}
|
|
34
35
|
export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
35
36
|
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
|
|
37
|
+
// ── 群聊扩展字段 ──
|
|
38
|
+
const { groupId, runId: optRunId, parentRunId, conversationId, isSubtask, parentMsgId } = opts;
|
|
39
|
+
/** 是否为群聊模式 */
|
|
40
|
+
const isGroupMode = !!groupId;
|
|
41
|
+
/** 群聊场景下可变的 runId(可能由 SDK 回填) */
|
|
42
|
+
let knownRunId = optRunId;
|
|
43
|
+
/**
|
|
44
|
+
* 构建群聊 extra 数据载荷(仅群聊模式使用)
|
|
45
|
+
* 协议字段与前端 useGroupChat.ts 对齐
|
|
46
|
+
*/
|
|
47
|
+
const buildGroupDataExtra = (streamStatus) => {
|
|
48
|
+
if (!isGroupMode)
|
|
49
|
+
return undefined;
|
|
50
|
+
const extra = { groupId, agentId, streamStatus };
|
|
51
|
+
if (knownRunId)
|
|
52
|
+
extra.runId = knownRunId;
|
|
53
|
+
if (parentRunId !== undefined && parentRunId !== null)
|
|
54
|
+
extra.parentRunId = parentRunId;
|
|
55
|
+
if (conversationId)
|
|
56
|
+
extra.conversationId = conversationId;
|
|
57
|
+
if (isSubtask)
|
|
58
|
+
extra.isSubtask = true;
|
|
59
|
+
if (parentMsgId)
|
|
60
|
+
extra.parentMsgId = parentMsgId;
|
|
61
|
+
return extra;
|
|
62
|
+
};
|
|
63
|
+
/** 群聊场景下所有信号帧的顶层 extra(注入 chatKind: 'group') */
|
|
64
|
+
const groupSignalExtra = isGroupMode ? { chatKind: 'group' } : undefined;
|
|
65
|
+
// ── partial 通道二次聚合参数(详见 ADR-017) ──
|
|
66
|
+
const coalesceCfg = {
|
|
67
|
+
...DEFAULT_PARTIAL_COALESCE,
|
|
68
|
+
...(opts.partialCoalesce ?? {}),
|
|
69
|
+
};
|
|
36
70
|
// ── 增量追踪 & 已推送文本 ──
|
|
37
71
|
let partialReplyState = createDeltaTrackerState();
|
|
38
72
|
let streamedText = "";
|
|
@@ -56,6 +90,89 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
56
90
|
let completed = false;
|
|
57
91
|
/** openclaw abort 检测:sendFinalReply 收到 abort 文案时置 true,markComplete 据此跳过 NO_REPLY。 */
|
|
58
92
|
let abortDetected = false;
|
|
93
|
+
// ── Coalescer 状态:缓冲区 + idle 定时器 + 统计 ──
|
|
94
|
+
let coalesceBuf = "";
|
|
95
|
+
let idleTimer = null;
|
|
96
|
+
let firstFrameEmitted = false;
|
|
97
|
+
/** 自然 flush(size/idle)的最近一次时间戳,用于统计 maxIdleGap */
|
|
98
|
+
let lastFlushAt = 0;
|
|
99
|
+
/** 首字(首帧)emit 时刻,用于 firstByteMs 统计 */
|
|
100
|
+
let firstByteAt = 0;
|
|
101
|
+
/** 本次会话的 sink 启动时间(首次 onPartialReply 进入时戳一次) */
|
|
102
|
+
let sinkStartAt = 0;
|
|
103
|
+
const coalesceStats = {
|
|
104
|
+
frames: 0,
|
|
105
|
+
totalChars: 0,
|
|
106
|
+
triggerStats: { first: 0, size: 0, idle: 0, tool: 0, final: 0, round: 0, block: 0, complete: 0 },
|
|
107
|
+
maxIdleGap: 0,
|
|
108
|
+
/** 模型 onPartialReply 累计的 token 增量条数(用于评估 "模型给了多少条" → 合并比) */
|
|
109
|
+
totalTokenDeltas: 0,
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* 当前合并周期内累计的模型 token 增量条数。
|
|
113
|
+
*
|
|
114
|
+
* - onPartialReply 每收到一个非空 delta 就 +1
|
|
115
|
+
* - emitChunk 时打入日志后清零
|
|
116
|
+
* - finalDelta 路径不计入(不是模型流式 token)
|
|
117
|
+
*/
|
|
118
|
+
let pendingTokenCount = 0;
|
|
119
|
+
/** 清掉 idle 定时器(不 flush) */
|
|
120
|
+
const clearIdleTimer = () => {
|
|
121
|
+
if (idleTimer !== null) {
|
|
122
|
+
clearTimeout(idleTimer);
|
|
123
|
+
idleTimer = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
/** 重置 idle 定时器:到期触发 idle flush */
|
|
127
|
+
const armIdleTimer = () => {
|
|
128
|
+
clearIdleTimer();
|
|
129
|
+
if (coalesceCfg.idleMs > 0) {
|
|
130
|
+
idleTimer = setTimeout(() => {
|
|
131
|
+
idleTimer = null;
|
|
132
|
+
flushCoalesceBuffer("idle");
|
|
133
|
+
}, coalesceCfg.idleMs);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* 真正向 socket 发出 stream_chunk 帧 + 维护 coalesceStats。
|
|
138
|
+
* trigger 仅用于日志归因,行为完全一致。
|
|
139
|
+
*/
|
|
140
|
+
const emitChunk = (text, trigger) => {
|
|
141
|
+
if (!text)
|
|
142
|
+
return;
|
|
143
|
+
emitSignal(signalCtx, "stream_chunk", text, groupSignalExtra, buildGroupDataExtra('delta'));
|
|
144
|
+
emittedUserVisible = true;
|
|
145
|
+
coalesceStats.frames++;
|
|
146
|
+
coalesceStats.totalChars += text.length;
|
|
147
|
+
coalesceStats.triggerStats[trigger]++;
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (firstByteAt === 0)
|
|
150
|
+
firstByteAt = now;
|
|
151
|
+
if (lastFlushAt > 0) {
|
|
152
|
+
const gap = now - lastFlushAt;
|
|
153
|
+
if (gap > coalesceStats.maxIdleGap)
|
|
154
|
+
coalesceStats.maxIdleGap = gap;
|
|
155
|
+
}
|
|
156
|
+
lastFlushAt = now;
|
|
157
|
+
// 当前周期内累计的模型 token 增量条数,emit 后清零;final 路径未累加,会显示为 0
|
|
158
|
+
const tokensInCycle = pendingTokenCount;
|
|
159
|
+
coalesceStats.totalTokenDeltas += tokensInCycle;
|
|
160
|
+
pendingTokenCount = 0;
|
|
161
|
+
log?.info(`[${CHANNEL_KEY}] [stream] [coalesce] flush: trigger=${trigger} ` +
|
|
162
|
+
`bufLen=${coalesceBuf.length} deltaLen=${text.length} tokens=${tokensInCycle}`);
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* 把 buffer 中堆积的字符整体 emit 出去并复位 buffer/timer。
|
|
166
|
+
* 任何强制 flush 路径必须经过这里,保证统计与日志一致。
|
|
167
|
+
*/
|
|
168
|
+
const flushCoalesceBuffer = (trigger) => {
|
|
169
|
+
clearIdleTimer();
|
|
170
|
+
if (!coalesceBuf)
|
|
171
|
+
return;
|
|
172
|
+
const payload = coalesceBuf;
|
|
173
|
+
coalesceBuf = "";
|
|
174
|
+
emitChunk(payload, trigger);
|
|
175
|
+
};
|
|
59
176
|
// ── Token 用量 ──
|
|
60
177
|
// 把每一轮的 usage 写入 transcript jsonl(与 history 模块同源),
|
|
61
178
|
// 因此在 markComplete 时从 transcript 末尾读最近一条 assistant 消息的 usage,
|
|
@@ -71,17 +188,44 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
71
188
|
if (typingStartSent)
|
|
72
189
|
return;
|
|
73
190
|
typingStartSent = true;
|
|
74
|
-
emitSignal(signalCtx, "typing_start");
|
|
191
|
+
emitSignal(signalCtx, "typing_start", "", groupSignalExtra, buildGroupDataExtra('delta'));
|
|
75
192
|
};
|
|
76
|
-
const sendTypingStop = () => {
|
|
193
|
+
const sendTypingStop = (streamStatus, finalContent) => {
|
|
77
194
|
if (typingStopSent)
|
|
78
195
|
return;
|
|
79
196
|
typingStopSent = true;
|
|
80
|
-
emitSignal(signalCtx, "typing_stop");
|
|
197
|
+
emitSignal(signalCtx, "typing_stop", finalContent ?? "", groupSignalExtra, buildGroupDataExtra(streamStatus ?? 'done'));
|
|
81
198
|
};
|
|
82
199
|
/**
|
|
83
|
-
*
|
|
200
|
+
* 群聊场景:发送最终完整文本帧
|
|
201
|
+
* 通过 emitter.emit 直接推送完整消息(非信号),前端据此渲染最终气泡内容。
|
|
202
|
+
*/
|
|
203
|
+
const emitFinalText = (text, streamStatus) => {
|
|
204
|
+
if (!isGroupMode)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
emitter.emit({
|
|
208
|
+
msgId: signalCtx.replyMsgId,
|
|
209
|
+
from: emitter.botClientId,
|
|
210
|
+
to: targetId,
|
|
211
|
+
content: text,
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
replyToMsgId: originalMsgId,
|
|
214
|
+
agentId,
|
|
215
|
+
chatKind: 'group',
|
|
216
|
+
extra: buildGroupDataExtra(streamStatus),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
log?.warn(`[${CHANNEL_KEY}] [stream] emitFinalText failed: ${err}`);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 → 推送给用户。
|
|
84
225
|
* 仅在 mediaList 非空时调用。
|
|
226
|
+
*
|
|
227
|
+
* 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。
|
|
228
|
+
* 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。
|
|
85
229
|
*/
|
|
86
230
|
const handleMediaFinal = async (replyText, mediaList) => {
|
|
87
231
|
const files = await mediaUrlsToFiles(mediaList, log);
|
|
@@ -105,16 +249,32 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
105
249
|
}
|
|
106
250
|
let enrichedText = replyText;
|
|
107
251
|
if (publicUrls.length > 0) {
|
|
252
|
+
// 判断是否为图片类型(根据 URL 中的文件扩展名)
|
|
108
253
|
const urlSection = publicUrls
|
|
109
254
|
.map((url, i) => {
|
|
110
255
|
const match = url.match(/filePath=([^&]+)/);
|
|
111
256
|
const filePath = match ? decodeURIComponent(match[1]) : "";
|
|
112
257
|
const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`;
|
|
113
|
-
|
|
258
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
259
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
|
|
260
|
+
// 图片使用 Markdown 图片语法,非图片使用链接语法
|
|
261
|
+
return isImage ? `` : `📎 [${fileName}](${url})`;
|
|
114
262
|
})
|
|
115
263
|
.join("\n");
|
|
116
264
|
enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
|
|
117
265
|
}
|
|
266
|
+
// ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ──
|
|
267
|
+
if (isGroupMode) {
|
|
268
|
+
if (enrichedText.trim()) {
|
|
269
|
+
const delta = `\n\n${enrichedText}`;
|
|
270
|
+
streamedText += delta;
|
|
271
|
+
emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta'));
|
|
272
|
+
emittedUserVisible = true;
|
|
273
|
+
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// ── 私聊模式:通过 sendFiles / sendReply 推送 ──
|
|
118
278
|
if (files.length > 0) {
|
|
119
279
|
emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
|
|
120
280
|
emittedUserVisible = true;
|
|
@@ -138,9 +298,11 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
138
298
|
if (completed)
|
|
139
299
|
return false;
|
|
140
300
|
counts.tool++;
|
|
301
|
+
// 强制 flush:tool_end 之后 LLM 通常会续写新文本,必须把上一段 buffer 先发出去,避免错位。
|
|
302
|
+
flushCoalesceBuffer("tool");
|
|
141
303
|
const isError = !!payload.isError;
|
|
142
304
|
log?.info(`[${CHANNEL_KEY}] [stream] sendToolResult: textLen=${payload.text?.length ?? 0} isError=${isError}`);
|
|
143
|
-
emitSignal(signalCtx, "tool_end", "", { toolIsError: isError });
|
|
305
|
+
emitSignal(signalCtx, "tool_end", "", { toolIsError: isError, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
144
306
|
return true;
|
|
145
307
|
},
|
|
146
308
|
/** 块回复:文本已由 onPartialReply 推送,这里只处理媒体。 */
|
|
@@ -153,6 +315,8 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
153
315
|
: payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
154
316
|
if (mediaList.length > 0) {
|
|
155
317
|
log?.info(`[${CHANNEL_KEY}] [stream] sendBlockReply: processing ${mediaList.length} media files`);
|
|
318
|
+
// 强制 flush:媒体附件要走在文本"已渲染"之后,否则前端会先看到媒体卡再补文字,渲染错乱。
|
|
319
|
+
flushCoalesceBuffer("block");
|
|
156
320
|
enqueue(() => handleMediaFinal(payload.text ?? "", mediaList).catch((err) => {
|
|
157
321
|
log?.error(`[${CHANNEL_KEY}] [stream] sendBlockReply media error: ${err}`);
|
|
158
322
|
}));
|
|
@@ -182,6 +346,8 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
182
346
|
? payload.mediaUrls
|
|
183
347
|
: payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
184
348
|
log?.info(`[${CHANNEL_KEY}] [stream] sendFinalReply: textLen=${replyText.length} mediaCount=${mediaList.length} streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
349
|
+
// 强制 flush:尾部增量必须在 buffer 已堆积内容"之后"发,不能让 finalDelta 插队。
|
|
350
|
+
flushCoalesceBuffer("final");
|
|
185
351
|
if (mediaList.length > 0) {
|
|
186
352
|
enqueue(() => handleMediaFinal(replyText, mediaList).catch((err) => {
|
|
187
353
|
log?.error(`[${CHANNEL_KEY}] [stream] sendFinalReply media error: ${err}`);
|
|
@@ -216,8 +382,8 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
216
382
|
partialReplyState.latest = replyText;
|
|
217
383
|
}
|
|
218
384
|
if (delta) {
|
|
219
|
-
|
|
220
|
-
|
|
385
|
+
// 直接走 emitChunk 而非进 buffer:finalDelta 是"本轮兜底尾巴",必须立即可见。
|
|
386
|
+
emitChunk(delta, "final");
|
|
221
387
|
log?.debug?.(`[${CHANNEL_KEY}] [stream] Final delta: ${delta.length} chars`);
|
|
222
388
|
}
|
|
223
389
|
return true;
|
|
@@ -244,10 +410,36 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
244
410
|
*/
|
|
245
411
|
markComplete: (markOpts) => {
|
|
246
412
|
completed = true;
|
|
413
|
+
// 强制 flush:buffer 残留必须在 typing_stop 之前发出去;同时也清掉 idle timer 防泄漏。
|
|
414
|
+
flushCoalesceBuffer("complete");
|
|
247
415
|
const hadStreamedText = streamedText.length > 0 || streamedHistory.length > 0;
|
|
248
416
|
const hadDispatch = counts.tool > 0 || counts.block > 0 || counts.final > 0;
|
|
249
417
|
const hadLLMActivity = toolStartCount > 0 || assistantMessageCount > 0;
|
|
250
418
|
const isAborted = markOpts?.aborted === true || abortDetected;
|
|
419
|
+
// ── 群聊模式:简化终态处理(不发 usage / NO_REPLY,完整文本合并到 typing_stop 的 content 中) ──
|
|
420
|
+
if (isGroupMode) {
|
|
421
|
+
const aggregatedText = streamedHistory.length > 0
|
|
422
|
+
? [...streamedHistory, streamedText].filter(Boolean).join('\n')
|
|
423
|
+
: streamedText;
|
|
424
|
+
if (isAborted) {
|
|
425
|
+
log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): aborted`);
|
|
426
|
+
// 将完整文本合并到 typing_stop 的 content 中,前端据此渲染最终气泡
|
|
427
|
+
sendTypingStop('aborted', aggregatedText || '已停止');
|
|
428
|
+
}
|
|
429
|
+
else if (hadStreamedText || hadDispatch) {
|
|
430
|
+
log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): done ` +
|
|
431
|
+
`streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
432
|
+
// 将完整文本合并到 typing_stop 的 content 中,避免额外发送独立消息导致前端重复渲染
|
|
433
|
+
sendTypingStop('done', aggregatedText);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
// 群聊无内容输出时也发 done(不发 NO_REPLY)
|
|
437
|
+
log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): done (no content)`);
|
|
438
|
+
sendTypingStop('failed', '调用 Agent 失败,请稍后重试');
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// ── 私聊模式:原有逻辑 ──
|
|
251
443
|
if (isAborted) {
|
|
252
444
|
log?.info(`[${CHANNEL_KEY}] [stream] markComplete: aborted`);
|
|
253
445
|
}
|
|
@@ -274,6 +466,23 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
274
466
|
`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount} ` +
|
|
275
467
|
`streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
276
468
|
}
|
|
469
|
+
// 会话级聚合统计 summary —— 调参依据(详见 ADR-017)
|
|
470
|
+
if (coalesceStats.frames > 0) {
|
|
471
|
+
const durationMs = sinkStartAt > 0 ? Date.now() - sinkStartAt : 0;
|
|
472
|
+
const firstByteMs = sinkStartAt > 0 && firstByteAt > 0 ? firstByteAt - sinkStartAt : 0;
|
|
473
|
+
const avgFrameLen = (coalesceStats.totalChars / coalesceStats.frames).toFixed(1);
|
|
474
|
+
// 平均合并比:每个 emit 帧承载了多少个模型 token 增量
|
|
475
|
+
const avgTokensPerFrame = coalesceStats.frames > 0
|
|
476
|
+
? (coalesceStats.totalTokenDeltas / coalesceStats.frames).toFixed(1)
|
|
477
|
+
: "0";
|
|
478
|
+
log?.info(`[${CHANNEL_KEY}] [stream] [coalesce-summary] ` +
|
|
479
|
+
`frames=${coalesceStats.frames} totalChars=${coalesceStats.totalChars} ` +
|
|
480
|
+
`totalTokens=${coalesceStats.totalTokenDeltas} avgTokensPerFrame=${avgTokensPerFrame} ` +
|
|
481
|
+
`triggerStats=${JSON.stringify(coalesceStats.triggerStats)} ` +
|
|
482
|
+
`avgFrameLen=${avgFrameLen} maxIdleGap=${coalesceStats.maxIdleGap}ms ` +
|
|
483
|
+
`firstByteMs=${firstByteMs} durationMs=${durationMs} ` +
|
|
484
|
+
`cfg={minChars:${coalesceCfg.minChars},idleMs:${coalesceCfg.idleMs}}`);
|
|
485
|
+
}
|
|
277
486
|
// 在 typing_stop 之前 emit 一帧 usage(如有)。
|
|
278
487
|
// 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
|
|
279
488
|
// abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage,
|
|
@@ -323,16 +532,43 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
323
532
|
// ────────────────────────────────────────
|
|
324
533
|
const replyOptions = {
|
|
325
534
|
...prefixOptions,
|
|
326
|
-
/**
|
|
535
|
+
/**
|
|
536
|
+
* AI 每产 token 回调,计算增量后进 Coalescer:
|
|
537
|
+
* - 首帧(flushFirstFrame=true)立即 emit,保证首字延迟 < 100ms
|
|
538
|
+
* - 后续追加到 buffer,达到 minChars 即 flush;否则 idle 超过 idleMs 兜底 flush
|
|
539
|
+
* - 详见 ADR-017
|
|
540
|
+
*/
|
|
327
541
|
onPartialReply: (payload) => {
|
|
328
542
|
if (completed || !payload.text)
|
|
329
543
|
return;
|
|
330
544
|
ensureTypingStart();
|
|
545
|
+
if (sinkStartAt === 0)
|
|
546
|
+
sinkStartAt = Date.now();
|
|
331
547
|
const delta = toStreamDeltaText(partialReplyState, payload.text);
|
|
332
|
-
if (delta)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
548
|
+
if (!delta)
|
|
549
|
+
return;
|
|
550
|
+
// 当前合并周期内的模型 token 增量计数 +1(emit 时清零并打入日志)
|
|
551
|
+
pendingTokenCount++;
|
|
552
|
+
streamedText += delta;
|
|
553
|
+
// 不聚合模式(兜底回滚开关):minChars/idleMs 任一为 0 时退化为每帧立即 emit
|
|
554
|
+
if (coalesceCfg.minChars <= 0 || coalesceCfg.idleMs <= 0) {
|
|
555
|
+
emitChunk(delta, firstFrameEmitted ? "size" : "first");
|
|
556
|
+
firstFrameEmitted = true;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// 首帧立即 flush(含 buffer 中可能已有的内容,但通常为空)
|
|
560
|
+
if (!firstFrameEmitted && coalesceCfg.flushFirstFrame !== false) {
|
|
561
|
+
firstFrameEmitted = true;
|
|
562
|
+
coalesceBuf += delta;
|
|
563
|
+
flushCoalesceBuffer("first");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
coalesceBuf += delta;
|
|
567
|
+
if (coalesceBuf.length >= coalesceCfg.minChars) {
|
|
568
|
+
flushCoalesceBuffer("size");
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
armIdleTimer();
|
|
336
572
|
}
|
|
337
573
|
},
|
|
338
574
|
/**
|
|
@@ -344,12 +580,14 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
344
580
|
if (completed)
|
|
345
581
|
return;
|
|
346
582
|
toolStartCount++;
|
|
583
|
+
// 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。
|
|
584
|
+
flushCoalesceBuffer("tool");
|
|
347
585
|
const toolName = payload.name ?? "unknown";
|
|
348
586
|
const toolPhase = payload.phase ?? "";
|
|
349
587
|
const isSubagentSpawn = SUBAGENT_TOOL_NAMES.has(toolName);
|
|
350
588
|
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase}` +
|
|
351
589
|
(isSubagentSpawn ? " (subagent-spawn)" : ""));
|
|
352
|
-
emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase });
|
|
590
|
+
emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
353
591
|
},
|
|
354
592
|
onReplyStart: () => {
|
|
355
593
|
if (completed)
|
|
@@ -359,6 +597,10 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
359
597
|
onAgentRunStart: (runId) => {
|
|
360
598
|
if (completed)
|
|
361
599
|
return;
|
|
600
|
+
// 群聊场景:若外部未预设 runId,用 SDK 返回的 runId 回填
|
|
601
|
+
if (isGroupMode && !optRunId && runId) {
|
|
602
|
+
knownRunId = runId;
|
|
603
|
+
}
|
|
362
604
|
log?.info(`[${CHANNEL_KEY}] [stream] Agent run started: runId=${runId}`);
|
|
363
605
|
},
|
|
364
606
|
/**
|
|
@@ -396,6 +638,10 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
396
638
|
if (completed)
|
|
397
639
|
return;
|
|
398
640
|
assistantMessageCount++;
|
|
641
|
+
// 强制 flush:切轮前若不发出 buffer 残留,旋转 streamedText 时会丢失这段未发的字符。
|
|
642
|
+
flushCoalesceBuffer("round");
|
|
643
|
+
// 切轮意味着"上一段语义已结束",重置首帧标记,让下一段开头的首字也能立即可见。
|
|
644
|
+
firstFrameEmitted = false;
|
|
399
645
|
if (streamedText)
|
|
400
646
|
streamedHistory.push(streamedText);
|
|
401
647
|
partialReplyState = createDeltaTrackerState();
|
|
@@ -435,5 +681,15 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
435
681
|
replyOptions,
|
|
436
682
|
// 业务辅助:独立于 openclaw ReplyDispatcher 契约暴露,避免污染 dispatcher
|
|
437
683
|
hasEmittedContent: () => emittedUserVisible,
|
|
684
|
+
// 群聊场景下暴露 emitStart / getRunId,私聊场景为 no-op
|
|
685
|
+
emitStart: ensureTypingStart,
|
|
686
|
+
getRunId: () => knownRunId,
|
|
687
|
+
// 获取本次 run 的最终完整文本(聚合所有轮次),供 AgentRunner 返回给上层写入账本
|
|
688
|
+
getFinalText: () => {
|
|
689
|
+
if (streamedHistory.length > 0) {
|
|
690
|
+
return [...streamedHistory, streamedText].filter(Boolean).join('\n');
|
|
691
|
+
}
|
|
692
|
+
return streamedText;
|
|
693
|
+
},
|
|
438
694
|
};
|
|
439
695
|
}
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LightClaw — 流式输出类型定义
|
|
3
3
|
*/
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* 默认聚合参数(详见 ADR-017 数据评估)。
|
|
6
|
+
*
|
|
7
|
+
* 调优记录:
|
|
8
|
+
* - v1(minChars=24, idleMs=120):合并率 ~95%(500 字 200+ 帧 → 约 8-9 帧),
|
|
9
|
+
* 但视觉上"打字一卡一卡",每 1.2-2 秒才更新一次,且尾部有 ~350ms 停顿感。
|
|
10
|
+
* - v2(当前,minChars=24, idleMs=120):兼顾流畅度与节流效率
|
|
11
|
+
* · minChars=24 ≈ 1 句中文/半行英文,承载 ~10 个模型 token / 帧
|
|
12
|
+
* · idleMs=120 远低于人眼感知卡顿阈值(约 400-500ms),尾部无停顿感
|
|
13
|
+
* · 预估合并率仍 >80%(200+ token → 约 25-30 帧),更新频率 ~1.3-2 帧/秒,
|
|
14
|
+
* 与主流 Chat 产品(ChatGPT / Claude)网页端体感一致
|
|
15
|
+
*
|
|
16
|
+
* 实际合并比可通过 `[coalesce-summary]` 日志中的 `avgTokensPerFrame` 字段观测,
|
|
17
|
+
* 据此进一步调参。
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_PARTIAL_COALESCE = {
|
|
20
|
+
minChars: 24,
|
|
21
|
+
idleMs: 120,
|
|
22
|
+
flushFirstFrame: true,
|
|
23
|
+
};
|
|
@@ -7,38 +7,38 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 工具名: lightclaw_get_file_url
|
|
9
9
|
*/
|
|
10
|
-
import * as fs from
|
|
11
|
-
import * as path from
|
|
12
|
-
import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer
|
|
13
|
-
import { formatFileSize } from
|
|
14
|
-
import { resolveEffectiveApiKey } from
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer } from '../file-storage.js';
|
|
13
|
+
import { formatFileSize } from '../media.js';
|
|
14
|
+
import { resolveEffectiveApiKey } from '../config.js';
|
|
15
15
|
// ============================================================
|
|
16
16
|
// 工具参数 schema
|
|
17
17
|
// ============================================================
|
|
18
|
-
export const DOWNLOAD_TOOL_NAME =
|
|
18
|
+
export const DOWNLOAD_TOOL_NAME = 'lightclaw_get_file_url';
|
|
19
19
|
export const downloadToolSchema = {
|
|
20
|
-
type:
|
|
20
|
+
type: 'object',
|
|
21
21
|
properties: {
|
|
22
22
|
action: {
|
|
23
|
-
type:
|
|
24
|
-
enum: [
|
|
25
|
-
description:
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['get_url', 'download_to_local', 'upload_and_get_url'],
|
|
25
|
+
description: 'Action to perform: ' +
|
|
26
26
|
"'get_url' — get the public download URL for a previously uploaded file (by filePath); " +
|
|
27
27
|
"'download_to_local' — download a cloud file to local directory; " +
|
|
28
28
|
"'upload_and_get_url' — upload a local file and return its public download URL.",
|
|
29
29
|
},
|
|
30
30
|
filePath: {
|
|
31
|
-
type:
|
|
31
|
+
type: 'string',
|
|
32
32
|
description: "For 'get_url' and 'download_to_local': the cloud file path (e.g. '2026-03-06/report.pdf'). " +
|
|
33
33
|
"For 'upload_and_get_url': the local file path to upload.",
|
|
34
34
|
},
|
|
35
35
|
localDir: {
|
|
36
|
-
type:
|
|
36
|
+
type: 'string',
|
|
37
37
|
description: "For 'download_to_local' only: the local directory to save the downloaded file. " +
|
|
38
|
-
|
|
38
|
+
'Defaults to the current working directory if not specified.',
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
|
-
required: [
|
|
41
|
+
required: ['action', 'filePath'],
|
|
42
42
|
};
|
|
43
43
|
// ============================================================
|
|
44
44
|
// 工具注册(飞书工厂函数模式)
|
|
@@ -51,34 +51,34 @@ export function registerDownloadTool(api) {
|
|
|
51
51
|
const sessionKey = ctx.sessionKey;
|
|
52
52
|
return {
|
|
53
53
|
name: DOWNLOAD_TOOL_NAME,
|
|
54
|
-
description:
|
|
55
|
-
|
|
54
|
+
description: 'Manage files on cloud storage. ' +
|
|
55
|
+
'Actions: ' +
|
|
56
56
|
"(1) 'get_url' — get a public download URL for a previously uploaded file (by its filePath); " +
|
|
57
57
|
"(2) 'download_to_local' — download a cloud file back to the local filesystem for further processing; " +
|
|
58
58
|
"(3) 'upload_and_get_url' — upload a single local file and return its public download URL. " +
|
|
59
|
-
|
|
59
|
+
'Note: For batch uploading files to share with users, prefer lightclaw_upload_file instead.',
|
|
60
60
|
parameters: downloadToolSchema,
|
|
61
61
|
async execute(_toolCallId, params) {
|
|
62
62
|
// 每次 execute 时动态解析 apiKey(多 key 模式下通过 sessionKey 直接获取)
|
|
63
63
|
const apiKey = resolveEffectiveApiKey({ sessionKey });
|
|
64
64
|
// log.warn(`[lightclaw_get_file_url] resolved apiKey="${apiKey?.slice(0, 8)}..."`);
|
|
65
65
|
const { action, filePath, localDir } = params;
|
|
66
|
-
if (!filePath || typeof filePath !==
|
|
66
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
67
67
|
return {
|
|
68
|
-
content: [{ type:
|
|
68
|
+
content: [{ type: 'text', text: 'Error: filePath is required.' }],
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
try {
|
|
72
72
|
switch (action) {
|
|
73
|
-
case
|
|
73
|
+
case 'get_url': {
|
|
74
74
|
const url = getFileDownloadUrl(filePath);
|
|
75
|
-
const fileName = filePath.split(
|
|
75
|
+
const fileName = filePath.split('/').pop() || filePath;
|
|
76
76
|
return {
|
|
77
|
-
content: [{ type:
|
|
77
|
+
content: [{ type: 'text', text: `[${fileName}](${url})` }],
|
|
78
78
|
details: { action, filePath, url },
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
-
case
|
|
81
|
+
case 'download_to_local': {
|
|
82
82
|
const result = await downloadFileFromServer(filePath, { apiKey });
|
|
83
83
|
const targetDir = localDir || process.cwd();
|
|
84
84
|
// 确保目录存在
|
|
@@ -88,10 +88,12 @@ export function registerDownloadTool(api) {
|
|
|
88
88
|
const targetPath = path.join(targetDir, result.fileName);
|
|
89
89
|
fs.writeFileSync(targetPath, result.buffer);
|
|
90
90
|
return {
|
|
91
|
-
content: [
|
|
92
|
-
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: 'text',
|
|
93
94
|
text: `File downloaded to: ${targetPath} (${formatFileSize(result.buffer.length)}, ${result.contentType})`,
|
|
94
|
-
}
|
|
95
|
+
},
|
|
96
|
+
],
|
|
95
97
|
details: {
|
|
96
98
|
action,
|
|
97
99
|
filePath,
|
|
@@ -101,19 +103,21 @@ export function registerDownloadTool(api) {
|
|
|
101
103
|
},
|
|
102
104
|
};
|
|
103
105
|
}
|
|
104
|
-
case
|
|
106
|
+
case 'upload_and_get_url': {
|
|
105
107
|
if (!fs.existsSync(filePath)) {
|
|
106
108
|
return {
|
|
107
|
-
content: [{ type:
|
|
109
|
+
content: [{ type: 'text', text: `Error: local file not found: ${filePath}` }],
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
const uploadResult = await uploadFileToServer(filePath, { apiKey });
|
|
111
113
|
const fileName = path.basename(filePath);
|
|
112
114
|
return {
|
|
113
|
-
content: [
|
|
114
|
-
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: 'text',
|
|
115
118
|
text: `File uploaded.\n\n[${fileName}](${uploadResult.url})`,
|
|
116
|
-
}
|
|
119
|
+
},
|
|
120
|
+
],
|
|
117
121
|
details: {
|
|
118
122
|
action,
|
|
119
123
|
localPath: filePath,
|
|
@@ -124,17 +128,19 @@ export function registerDownloadTool(api) {
|
|
|
124
128
|
}
|
|
125
129
|
default:
|
|
126
130
|
return {
|
|
127
|
-
content: [
|
|
128
|
-
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: 'text',
|
|
129
134
|
text: `Error: unknown action '${action}'. Use 'get_url', 'download_to_local', or 'upload_and_get_url'.`,
|
|
130
|
-
}
|
|
135
|
+
},
|
|
136
|
+
],
|
|
131
137
|
};
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
catch (err) {
|
|
135
141
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
136
142
|
return {
|
|
137
|
-
content: [{ type:
|
|
143
|
+
content: [{ type: 'text', text: `Error: ${errMsg}` }],
|
|
138
144
|
};
|
|
139
145
|
}
|
|
140
146
|
},
|