lightclawbot 1.2.3 → 1.2.6-beta.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/dist/src/channel.js +160 -39
- package/dist/src/config.js +4 -0
- package/dist/src/gateway.js +17 -5
- package/dist/src/history/index.js +1 -1
- package/dist/src/history/message-parser.js +20 -0
- package/dist/src/history/session-reader.js +74 -4
- package/dist/src/history/session-store.js +43 -0
- package/dist/src/history/text-processing.js +7 -0
- package/dist/src/history/usage-aggregator.js +53 -0
- package/dist/src/inbound.js +37 -20
- package/dist/src/socket/chat.js +257 -0
- package/dist/src/socket/handlers.js +271 -29
- package/dist/src/streaming/stream-reply-sink.js +58 -6
- package/dist/src/usage/index.js +4 -0
- package/dist/src/usage/normalize.js +47 -0
- package/dist/src/usage/types.js +1 -0
- package/dist/src/utils/common.js +160 -5
- package/node_modules/ws/lib/receiver.js +54 -0
- package/node_modules/ws/lib/sender.js +6 -1
- package/node_modules/ws/lib/websocket-server.js +8 -0
- package/node_modules/ws/lib/websocket.js +14 -0
- package/node_modules/ws/package.json +1 -1
- package/package.json +1 -1
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* 所有出站 socket.emit 通过 ReliableEmitter 实现 ACK 确认 + 自动重试,
|
|
13
13
|
* 保证消息在网络抖动时不丢失。
|
|
14
14
|
*/
|
|
15
|
-
import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID,
|
|
15
|
+
import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_AGENTS_REQUEST, EVENT_AGENTS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS, EVENT_CHAT_REQUEST, } from '../config.js';
|
|
16
16
|
import { isDuplicate, debounceHistoryRequest, generateMsgId } from '../dedup.js';
|
|
17
17
|
import { getLightclawRuntime } from '../runtime.js';
|
|
18
|
-
import { readSessionHistoryWithCron,
|
|
18
|
+
import { readSessionHistoryWithCron, readSessionHistoriesByIds, loadSessionStore } from '../history/index.js';
|
|
19
|
+
import { handleChatList, handleChatCreate, handleChatUpdate, handleChatDelete } from './chat.js';
|
|
19
20
|
import { uploadFileToServer } from '../file-storage.js';
|
|
20
21
|
import { guessMimeByExt } from '../media.js';
|
|
22
|
+
import { ensureSessionInHistory, readChatsFile, resolveChatsFilePath } from '../utils/common.js';
|
|
21
23
|
import * as fs from 'node:fs';
|
|
22
24
|
import * as path from 'node:path';
|
|
23
25
|
/**
|
|
@@ -72,6 +74,7 @@ export function bindSocketHandlers(socket, deps) {
|
|
|
72
74
|
onEvent?.();
|
|
73
75
|
log?.info(`[${CHANNEL_KEY}] Message from Agent: ${data.agentId}, ${data.from}: "${(data.content || '').slice(0, 60)}" files=${data.files?.length ?? 0}`);
|
|
74
76
|
// /stop 及自然语言 abort 不做旁路,统一交给 openclaw fast-abort。
|
|
77
|
+
const chatId = extractChatId(data);
|
|
75
78
|
handleMessage({
|
|
76
79
|
senderId: data.from,
|
|
77
80
|
text: data.content || '',
|
|
@@ -79,6 +82,32 @@ export function bindSocketHandlers(socket, deps) {
|
|
|
79
82
|
files: data.files ?? [],
|
|
80
83
|
timestamp: data.timestamp,
|
|
81
84
|
agentId: data.agentId, // 透传前端指定的 agentId
|
|
85
|
+
chatId, // 透传前端指定的 chatId(多会话分桶用)
|
|
86
|
+
});
|
|
87
|
+
// ④ 异步登记当前生效的 sessionId 到 chats.json[chatId].sessionIdHistory。
|
|
88
|
+
//
|
|
89
|
+
// 为什么用 setImmediate?
|
|
90
|
+
// - 首条消息到达时,框架还未创建 session,sessions.json 查不到该 sessionKey;
|
|
91
|
+
// - /reset 等场景下,框架会在派发消息前 rotate sessionId,本回合的当前 sessionId
|
|
92
|
+
// 是 rotate 之后的新 ID;
|
|
93
|
+
// - 把登记放到下一个事件循环 tick,给框架留出时序窗口(创建/rotate session);
|
|
94
|
+
// - 与主消息流解耦,登记失败不影响 handleMessage 主流程。
|
|
95
|
+
//
|
|
96
|
+
// 即使本次未登记成功(首条消息时 sessions.json 还没条目),下一条消息进来时
|
|
97
|
+
// 仍会再次触发本逻辑,幂等的 ensureSessionInHistory 会补登记,最终一致。
|
|
98
|
+
setImmediate(() => {
|
|
99
|
+
try {
|
|
100
|
+
recordSessionInHistory({
|
|
101
|
+
userId: data.from,
|
|
102
|
+
agentId: data.agentId,
|
|
103
|
+
chatId,
|
|
104
|
+
accountId: account.accountId,
|
|
105
|
+
log,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
log?.warn(`[${CHANNEL_KEY}] recordSessionInHistory(message:private) error: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
|
+
}
|
|
82
111
|
});
|
|
83
112
|
});
|
|
84
113
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -124,23 +153,91 @@ export function bindSocketHandlers(socket, deps) {
|
|
|
124
153
|
log?.info(`[${CHANNEL_KEY}], 当前agentId为:${resolvedAgentId}`);
|
|
125
154
|
// 始终用 effectiveAgentId 调用 buildAgentSessionKey 生成 sessionKey:
|
|
126
155
|
// - 前端传了合法 agentId → 用它;否则降级到 baseRoute.agentId(框架路由解析出的默认值)
|
|
127
|
-
|
|
156
|
+
//
|
|
157
|
+
// 框架生成的基础 sessionKey 形如:
|
|
158
|
+
// agent:<agentId>:<channel>:direct:<userId>
|
|
159
|
+
// 当前端带上 chatId(多会话场景)时,需要在基础 sessionKey 末尾追加 `:<chatId>`,
|
|
160
|
+
// 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>,
|
|
161
|
+
// 以区分同一用户下的不同会话历史文件。框架本身不感知 chatId,
|
|
162
|
+
// 故在此处显式拼接,保持与写入端(chat 维度的 sessionId)一致。
|
|
163
|
+
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
|
|
128
164
|
agentId: resolvedAgentId,
|
|
129
165
|
channel: CHANNEL_KEY,
|
|
130
166
|
accountId: baseRoute.accountId,
|
|
131
167
|
peer: { kind: 'direct', id: data.from },
|
|
132
168
|
dmScope: 'per-channel-peer',
|
|
133
169
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
170
|
+
const chatIdSuffix = extractChatId(data);
|
|
171
|
+
const sessionKey = chatIdSuffix ? `${baseSessionKey}:${chatIdSuffix}` : baseSessionKey;
|
|
172
|
+
log?.info(`[${CHANNEL_KEY}] userId=${data.from},chatId=${chatIdSuffix || '-'},当前的sessionKey=${sessionKey}`);
|
|
173
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
174
|
+
// ① 兜底登记当前 sessionId 到 sessionIdHistory
|
|
175
|
+
//
|
|
176
|
+
// 为什么在拉历史时也做登记?—— 兜底覆盖一个边界场景:用户 reset 后没再
|
|
177
|
+
// 发过消息,只是重新打开聊天界面拉历史。此时 message:private 的登记
|
|
178
|
+
// 路径不会被触发,仅靠这里能补登记,确保历史不丢。
|
|
179
|
+
//
|
|
180
|
+
// 必须先于读历史,因为读历史依赖 sessionIdHistory 来合并多份 jsonl,
|
|
181
|
+
// 当前 sessionId 也要一起读到。
|
|
182
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
183
|
+
try {
|
|
184
|
+
const store = loadSessionStore(resolvedAgentId);
|
|
185
|
+
const currentSessionId = store[sessionKey]?.sessionId;
|
|
186
|
+
if (currentSessionId) {
|
|
187
|
+
ensureSessionInHistory(resolvedAgentId, data.from, chatIdSuffix, currentSessionId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
log?.warn(`[${CHANNEL_KEY}] ensureSessionInHistory(history:request) error: ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
+
}
|
|
193
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
194
|
+
// ② 读取历史消息:优先按 sessionIdHistory 合并多份 jsonl(跨 reset 历史回看)
|
|
195
|
+
//
|
|
196
|
+
// - chats.json 命中(含 chatId === '' 的默认对话兜底条目):
|
|
197
|
+
// 把该 chat 用过的所有 sessionId(含归档的旧 sessionId)的 jsonl
|
|
198
|
+
// 全部读出来按 timestamp 合并;
|
|
199
|
+
// - 否则:退化到原来的"按 sessionKey 读当前 jsonl + cron 合并"路径,
|
|
200
|
+
// 保持向后兼容(chats.json 不存在 / 解析异常等极端场景)。
|
|
201
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
202
|
+
const limit = data.limit ?? DEFAULT_HISTORY_LIMIT;
|
|
203
|
+
const chatOnly = data.chatOnly ?? true;
|
|
204
|
+
let messages = [];
|
|
205
|
+
let readMode = 'single-session-with-cron';
|
|
206
|
+
try {
|
|
207
|
+
// 查询侧使用与登记侧同一个 chatId(已在 extractChatId 统一归一)。
|
|
208
|
+
const targetChatId = chatIdSuffix;
|
|
209
|
+
const chatsPath = resolveChatsFilePath(resolvedAgentId, data.from);
|
|
210
|
+
// 这里使用**纯只读**的 readChatsFile:
|
|
211
|
+
// - 拉历史是只读接口,不应该在用户还没发任何消息前就把
|
|
212
|
+
// chats.json 写到磁盘(避免凭空出现一个默认对话);
|
|
213
|
+
// - 如果是默认对话首次消息后拉历史,前面的 ensureSessionInHistory
|
|
214
|
+
// 已经走 readChatsFileOrInitDefault 打点过 chats.json,这里能读到。
|
|
215
|
+
const matched = readChatsFile(chatsPath).find((c) => c.chatId === targetChatId);
|
|
216
|
+
const historyIds = matched?.sessionIdHistory ?? [];
|
|
217
|
+
if (historyIds.length > 0) {
|
|
218
|
+
messages = readSessionHistoriesByIds(historyIds, {
|
|
219
|
+
limit,
|
|
220
|
+
chatOnly,
|
|
221
|
+
agentId: resolvedAgentId,
|
|
222
|
+
});
|
|
223
|
+
readMode = 'multi-session';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
log?.warn(`[${CHANNEL_KEY}] readSessionHistoriesByIds fallback to single-session: ${err instanceof Error ? err.message : String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
if (messages.length === 0 && readMode !== 'multi-session') {
|
|
230
|
+
// 退路 / 兜底:仅当未走多 sessionId 合并路径时,退化到按当前 sessionKey 读(含 cron 合并)。
|
|
231
|
+
// 已走 multi-session 但消息为空,意味着 chats.json 里 sessionIdHistory 全部 jsonl 都没内容,
|
|
232
|
+
// 这是合理的"空历史"状态,不应再退回 single-session 路径,避免重复 cron 拼接的副作用。
|
|
233
|
+
messages = readSessionHistoryWithCron(sessionKey, {
|
|
234
|
+
limit,
|
|
235
|
+
chatOnly,
|
|
236
|
+
includeCron: true,
|
|
237
|
+
agentId: resolvedAgentId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
log?.info(`[${CHANNEL_KEY}] History request: userId=${data.from} sessionKey=${sessionKey} mode=${readMode} found=${messages.length}`);
|
|
144
241
|
// 过滤掉内容为空的消息(既无文字也无附件),避免客户端渲染空气泡
|
|
145
242
|
const historyMsgId = generateMsgId();
|
|
146
243
|
reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, {
|
|
@@ -168,31 +265,171 @@ export function bindSocketHandlers(socket, deps) {
|
|
|
168
265
|
}
|
|
169
266
|
});
|
|
170
267
|
});
|
|
171
|
-
|
|
268
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
269
|
+
// 事件:Agents 列表请求(agents:request)
|
|
270
|
+
// 职责:读取 openclaw.json 中 agents.list 并返回给客户端(支持热更新,不缓存)
|
|
271
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
272
|
+
socket.on(EVENT_AGENTS_REQUEST, (data, ack) => {
|
|
273
|
+
// 立即回复 ACK,告知服务端请求已收到
|
|
274
|
+
ack?.();
|
|
275
|
+
if (data.from === botClientId)
|
|
276
|
+
return;
|
|
172
277
|
// 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判)
|
|
173
278
|
onEvent?.();
|
|
174
279
|
try {
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
280
|
+
// 获取插件运行时,读取最新配置
|
|
281
|
+
const pluginRuntime = getLightclawRuntime();
|
|
282
|
+
const currentCfg = pluginRuntime.config.loadConfig();
|
|
283
|
+
const agentsList = currentCfg.agents?.list ?? [];
|
|
284
|
+
log?.info(`[${CHANNEL_KEY}] Agents request: count=${agentsList.length}`);
|
|
285
|
+
const agentsMsgId = generateMsgId();
|
|
286
|
+
reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
|
|
287
|
+
msgId: agentsMsgId,
|
|
288
|
+
to: data.from,
|
|
289
|
+
from: botClientId,
|
|
290
|
+
agents: agentsList,
|
|
291
|
+
timestamp: Date.now(),
|
|
292
|
+
}, agentsMsgId);
|
|
182
293
|
}
|
|
183
294
|
catch (err) {
|
|
184
|
-
log?.error(`[${CHANNEL_KEY}]
|
|
185
|
-
const
|
|
186
|
-
reliableEmitter.emitWithAck(
|
|
187
|
-
|
|
188
|
-
|
|
295
|
+
log?.error(`[${CHANNEL_KEY}] Agents request error: ${err}`);
|
|
296
|
+
const agentsErrMsgId = generateMsgId();
|
|
297
|
+
reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
|
|
298
|
+
msgId: agentsErrMsgId,
|
|
299
|
+
from: botClientId,
|
|
300
|
+
to: data.from,
|
|
301
|
+
agents: [],
|
|
189
302
|
error: err instanceof Error ? err.message : String(err),
|
|
190
|
-
|
|
191
|
-
},
|
|
303
|
+
timestamp: Date.now(),
|
|
304
|
+
}, agentsErrMsgId);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
socket.on(EVENT_CHAT_REQUEST, (data, ack) => {
|
|
308
|
+
ack?.();
|
|
309
|
+
log?.info(`[${CHANNEL_KEY}] Received chat request from ${data.from}: ${data.type}`);
|
|
310
|
+
if (!data?.from) {
|
|
311
|
+
log?.warn(`[${CHANNEL_KEY}] Chat request missing userId, ignoring`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// 回环防御
|
|
315
|
+
if (data.from === botClientId)
|
|
316
|
+
return;
|
|
317
|
+
onEvent?.();
|
|
318
|
+
switch (data.type) {
|
|
319
|
+
case 'list':
|
|
320
|
+
handleChatList({
|
|
321
|
+
userId: data.from,
|
|
322
|
+
agentId: data.agentId,
|
|
323
|
+
botClientId,
|
|
324
|
+
reliableEmitter,
|
|
325
|
+
log,
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
case 'create':
|
|
329
|
+
handleChatCreate({
|
|
330
|
+
userId: data.from,
|
|
331
|
+
agentId: data.agentId,
|
|
332
|
+
botClientId,
|
|
333
|
+
reliableEmitter,
|
|
334
|
+
log,
|
|
335
|
+
});
|
|
336
|
+
break;
|
|
337
|
+
case 'update':
|
|
338
|
+
handleChatUpdate({
|
|
339
|
+
userId: data.from,
|
|
340
|
+
agentId: data.agentId,
|
|
341
|
+
chatId: data.chatId,
|
|
342
|
+
title: data.title,
|
|
343
|
+
botClientId,
|
|
344
|
+
reliableEmitter,
|
|
345
|
+
log,
|
|
346
|
+
});
|
|
347
|
+
break;
|
|
348
|
+
case 'delete':
|
|
349
|
+
handleChatDelete({
|
|
350
|
+
userId: data.from,
|
|
351
|
+
agentId: data.agentId,
|
|
352
|
+
chatId: data.chatId,
|
|
353
|
+
botClientId,
|
|
354
|
+
reliableEmitter,
|
|
355
|
+
log,
|
|
356
|
+
});
|
|
357
|
+
break;
|
|
192
358
|
}
|
|
193
359
|
});
|
|
194
360
|
}
|
|
195
361
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
362
|
+
// 内部工具:从 PrivateMessageData / HistoryRequestData 中统一提取 chatId
|
|
363
|
+
//
|
|
364
|
+
// 抽离原因:原代码同一字段在 message:private(||)、setImmediate(??)、
|
|
365
|
+
// history:request(?.trim())三处使用了三种不同写法,虽然在协议正常时结果一致,
|
|
366
|
+
// 但语义有偏差且日后极易踩坑。统一为 extractChatId 后:
|
|
367
|
+
// - 入参可能是 undefined / null / 非字符串 / 含空白字符串 / 合法字符串;
|
|
368
|
+
// - 一律 trim 后归一为字符串;undefined / null / 非字符串 → '';
|
|
369
|
+
// - 返回值要么是 ''(默认对话标识),要么是非空合法 chatId。
|
|
370
|
+
// 这样写入侧(chats.json[chatId].sessionIdHistory)与读取侧(按 chatId 查询)
|
|
371
|
+
// 始终拿到同一个值,杜绝因写法差异引起的"找不到 chat 条目"问题。
|
|
372
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
373
|
+
function extractChatId(data) {
|
|
374
|
+
const raw = data?.extra?.chatId;
|
|
375
|
+
if (typeof raw !== 'string')
|
|
376
|
+
return '';
|
|
377
|
+
return raw.trim();
|
|
378
|
+
}
|
|
379
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
380
|
+
// 内部工具:把当前 chat 在用的 sessionId 登记到 chats.json[chatId].sessionIdHistory
|
|
381
|
+
//
|
|
382
|
+
// 设计要点:
|
|
383
|
+
// 1. 解析 sessionKey —— 与 history:request 分支保持一致:
|
|
384
|
+
// agent:<agentId>:<channel>:direct:<userId>[:<chatId>]
|
|
385
|
+
// 若无 chatId 直接早返(无 chatId 的会话不在 chats.json 中登记)。
|
|
386
|
+
// 2. 通过 agentId 合法性校验,避免前端伪造 agentId 污染他人 chats.json。
|
|
387
|
+
// 3. 从 sessions.json 索引读出当前 sessionKey 对应的 sessionId
|
|
388
|
+
// —— 框架在派发消息前完成 mint/rotate,本时刻读到的就是"当前生效"的 sessionId。
|
|
389
|
+
// 4. 调用幂等的 ensureSessionInHistory:已存在则跳过;新出现则末尾追加并落盘。
|
|
390
|
+
//
|
|
391
|
+
// 调用时机:
|
|
392
|
+
// - EVENT_MESSAGE_PRIVATE:在 setImmediate 中异步调用,确保框架已建好/rotate 好 session;
|
|
393
|
+
// - EVENT_HISTORY_REQUEST:在 sessionKey 解析后同步调用,作为兜底登记入口。
|
|
394
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
395
|
+
function recordSessionInHistory(params) {
|
|
396
|
+
const { userId, agentId, chatId, accountId, log } = params;
|
|
397
|
+
// 取最新配置,复用 history 分支同款 agentId 校验逻辑(防止伪造 agentId)
|
|
398
|
+
const pluginRuntime = getLightclawRuntime();
|
|
399
|
+
const currentCfg = pluginRuntime.config.loadConfig();
|
|
400
|
+
const currentCfgTyped = currentCfg;
|
|
401
|
+
const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID];
|
|
402
|
+
const resolvedAgentId = agentId && validAgentIds.includes(agentId) ? agentId : DEFAULT_AGENT_ID;
|
|
403
|
+
// 计算 sessionKey(必须与 history:request 一致,否则查不到 sessions.json 条目)
|
|
404
|
+
const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({
|
|
405
|
+
cfg: currentCfg,
|
|
406
|
+
channel: CHANNEL_KEY,
|
|
407
|
+
accountId,
|
|
408
|
+
peer: { kind: 'direct', id: userId },
|
|
409
|
+
});
|
|
410
|
+
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
|
|
411
|
+
agentId: resolvedAgentId,
|
|
412
|
+
channel: CHANNEL_KEY,
|
|
413
|
+
accountId: baseRoute.accountId,
|
|
414
|
+
peer: { kind: 'direct', id: userId },
|
|
415
|
+
dmScope: 'per-channel-peer',
|
|
416
|
+
});
|
|
417
|
+
// 与 history:request 保持完全一致的 sessionKey 拼接规则:
|
|
418
|
+
// - 空 chatId → 不追加后缀,直接用 baseSessionKey(默认对话场景)
|
|
419
|
+
// - 非空 chatId → 追加 `:<chatId>` 后缀(多会话场景)
|
|
420
|
+
// 这是写入侧已建立的约定,框架对无 chatId 会话写到 baseSessionKey 上。
|
|
421
|
+
const sessionKey = chatId ? `${baseSessionKey}:${chatId}` : baseSessionKey;
|
|
422
|
+
// 查 sessions.json 拿当前生效 sessionId;首条消息时可能查不到 → 跳过,下一条消息会补登记
|
|
423
|
+
const store = loadSessionStore(resolvedAgentId);
|
|
424
|
+
const currentSessionId = store[sessionKey]?.sessionId;
|
|
425
|
+
if (!currentSessionId) {
|
|
426
|
+
log?.info(`[${CHANNEL_KEY}] recordSessionInHistory: sessionId not found yet, skip. sessionKey=${sessionKey}`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// 登记 sessionId 到 chats.json,确保历史记录能被查到;已存在则跳过,新增则末尾追加并落盘
|
|
430
|
+
ensureSessionInHistory(resolvedAgentId, userId, chatId, currentSessionId);
|
|
431
|
+
}
|
|
432
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
196
433
|
// 文件下载处理器(kind=file:download, status=download_req)
|
|
197
434
|
//
|
|
198
435
|
// 流程:
|
|
@@ -220,7 +457,10 @@ async function handleFileDownloadReq(data, botClientId, reliableEmitter, account
|
|
|
220
457
|
content: '',
|
|
221
458
|
timestamp: Date.now(),
|
|
222
459
|
kind: KIND_FILE_DOWNLOAD,
|
|
223
|
-
extra: {
|
|
460
|
+
extra: {
|
|
461
|
+
chatId: extractChatId(data),
|
|
462
|
+
transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error },
|
|
463
|
+
},
|
|
224
464
|
}, msgId);
|
|
225
465
|
log?.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`);
|
|
226
466
|
};
|
|
@@ -255,6 +495,7 @@ async function handleFileDownloadReq(data, botClientId, reliableEmitter, account
|
|
|
255
495
|
timestamp: Date.now(),
|
|
256
496
|
kind: KIND_FILE_DOWNLOAD,
|
|
257
497
|
extra: {
|
|
498
|
+
chatId: extractChatId(data),
|
|
258
499
|
transferData: {
|
|
259
500
|
transferId,
|
|
260
501
|
status: FILE_DOWNLOAD_STATUS.READY,
|
|
@@ -283,6 +524,7 @@ async function handleFileDownloadReq(data, botClientId, reliableEmitter, account
|
|
|
283
524
|
timestamp: Date.now(),
|
|
284
525
|
kind: KIND_FILE_DOWNLOAD,
|
|
285
526
|
extra: {
|
|
527
|
+
chatId: extractChatId(data),
|
|
286
528
|
transferData: {
|
|
287
529
|
transferId,
|
|
288
530
|
status: FILE_DOWNLOAD_STATUS.URL,
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - onPartialReply → 计算增量 → emit stream_chunk
|
|
6
6
|
* - onToolStart → emit tool_start(前端决定如何渲染)
|
|
7
7
|
* - sendFinalReply → 跨轮次去重后发送剩余增量(或丢弃)
|
|
8
|
-
* - markComplete → 发 typing_stop;
|
|
8
|
+
* - markComplete → 从 transcript 读 usage 并 emit usage 帧 → 发 typing_stop;
|
|
9
|
+
* NO_REPLY 场景兜底一条提示
|
|
9
10
|
*
|
|
10
11
|
* 关键状态:
|
|
11
12
|
* streamedText 当前 assistant 轮已流式推送的文本
|
|
@@ -17,6 +18,7 @@ import { uploadFileToServer } from "../file-storage.js";
|
|
|
17
18
|
import { mediaUrlsToFiles } from "../media.js";
|
|
18
19
|
import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js";
|
|
19
20
|
import { emitSignal } from "../utils/common.js";
|
|
21
|
+
import { readSessionHistoryTail } from "../history/index.js";
|
|
20
22
|
/** 派生/管理 subagent 的工具名;前端可据此特化渲染 tool_start。 */
|
|
21
23
|
const SUBAGENT_TOOL_NAMES = new Set([
|
|
22
24
|
"sessions_spawn",
|
|
@@ -30,7 +32,7 @@ function localizeAbortReplyText(text) {
|
|
|
30
32
|
return OPENCLAW_ABORT_REPLY_RE.test(text.trim()) ? LOCALIZED_ABORT_REPLY : text;
|
|
31
33
|
}
|
|
32
34
|
export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
33
|
-
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted } = opts;
|
|
35
|
+
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
|
|
34
36
|
// ── 增量追踪 & 已推送文本 ──
|
|
35
37
|
let partialReplyState = createDeltaTrackerState();
|
|
36
38
|
let streamedText = "";
|
|
@@ -54,6 +56,10 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
54
56
|
let completed = false;
|
|
55
57
|
/** openclaw abort 检测:sendFinalReply 收到 abort 文案时置 true,markComplete 据此跳过 NO_REPLY。 */
|
|
56
58
|
let abortDetected = false;
|
|
59
|
+
// ── Token 用量 ──
|
|
60
|
+
// 把每一轮的 usage 写入 transcript jsonl(与 history 模块同源),
|
|
61
|
+
// 因此在 markComplete 时从 transcript 末尾读最近一条 assistant 消息的 usage,
|
|
62
|
+
// 然后 emit `kind='usage'` 帧给前端。
|
|
57
63
|
// ── 串行化回调队列:保证 COS 上传等异步操作按顺序执行 ──
|
|
58
64
|
let queuePromise = Promise.resolve();
|
|
59
65
|
const enqueue = (fn) => {
|
|
@@ -110,11 +116,11 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
110
116
|
enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
|
|
111
117
|
}
|
|
112
118
|
if (files.length > 0) {
|
|
113
|
-
emitter.sendFiles(targetId, enrichedText, files, originalMsgId);
|
|
119
|
+
emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
|
|
114
120
|
emittedUserVisible = true;
|
|
115
121
|
}
|
|
116
122
|
else if (enrichedText.trim()) {
|
|
117
|
-
emitter.sendReply(targetId, enrichedText, originalMsgId);
|
|
123
|
+
emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId);
|
|
118
124
|
emittedUserVisible = true;
|
|
119
125
|
}
|
|
120
126
|
};
|
|
@@ -223,13 +229,18 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
223
229
|
getQueuedCounts: () => ({ ...counts }),
|
|
224
230
|
getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
225
231
|
/**
|
|
226
|
-
* 标记本次 dispatch 完成 → 发 typing_stop。
|
|
232
|
+
* 标记本次 dispatch 完成 → 发 usage 帧(如有)→ 发 typing_stop。
|
|
227
233
|
*
|
|
228
234
|
* NO_REPLY 仅在"LLM 正常完成一次完整 run 但没产出可见文本"时才触发。
|
|
229
235
|
* 以下场景均不发:
|
|
230
236
|
* - isAborted / abortDetected:/stop 或 abort 触发
|
|
231
237
|
* - hadLLMActivity 但 hadDispatch=false:LLM 启动但被中途打断(abort 没来得及 sendFinalReply)
|
|
232
238
|
* - !hadLLMActivity:followup 被 collect 合并 / plugin 拦截
|
|
239
|
+
*
|
|
240
|
+
* Token 用量帧的 emit 时机:
|
|
241
|
+
* 在 typing_stop **之前** 从 transcript 读 usage 并 emit 一次。前端依次收到:
|
|
242
|
+
* ...stream_chunk → tool_start/end → ... → usage(可选) → typing_stop
|
|
243
|
+
* 这样保证"一次问答一次消耗",且消耗信息在结束态前到达。
|
|
233
244
|
*/
|
|
234
245
|
markComplete: (markOpts) => {
|
|
235
246
|
completed = true;
|
|
@@ -256,13 +267,54 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
256
267
|
: "LLM only produced thinking, no visible text";
|
|
257
268
|
log?.warn(`[${CHANNEL_KEY}] [stream] NO_REPLY: counts=${JSON.stringify(counts)} ` +
|
|
258
269
|
`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount}. Cause: ${cause}`);
|
|
259
|
-
emitter.sendReply(targetId, "NO_REPLY", originalMsgId);
|
|
270
|
+
emitter.sendReply(targetId, "NO_REPLY", originalMsgId, signalCtx.chatId);
|
|
260
271
|
}
|
|
261
272
|
else {
|
|
262
273
|
log?.info(`[${CHANNEL_KEY}] [stream] markComplete: counts=${JSON.stringify(counts)} ` +
|
|
263
274
|
`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount} ` +
|
|
264
275
|
`streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`);
|
|
265
276
|
}
|
|
277
|
+
// 在 typing_stop 之前 emit 一帧 usage(如有)。
|
|
278
|
+
// 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
|
|
279
|
+
// abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage,
|
|
280
|
+
// 此时不 emit,前端按"无用量数据"渲染。
|
|
281
|
+
let usageToEmit;
|
|
282
|
+
// 非 abort + sessionKey 已知 → 从 transcript 读 usage
|
|
283
|
+
if (!isAborted && sessionKey) {
|
|
284
|
+
try {
|
|
285
|
+
// 找到本轮最后一条 assistant
|
|
286
|
+
const tailMessages = readSessionHistoryTail(sessionKey, {
|
|
287
|
+
limit: 10,
|
|
288
|
+
chatOnly: true,
|
|
289
|
+
agentId,
|
|
290
|
+
});
|
|
291
|
+
// 找到最后一条 assistant 消息(一轮的最后一条 = 本次问答的累计 usage)
|
|
292
|
+
let foundUsage;
|
|
293
|
+
for (let i = tailMessages.length - 1; i >= 0; i -= 1) {
|
|
294
|
+
const m = tailMessages[i];
|
|
295
|
+
if (m.role === "assistant" && m.usage) {
|
|
296
|
+
foundUsage = m.usage;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (foundUsage) {
|
|
301
|
+
usageToEmit = foundUsage;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
log?.warn(`[${CHANNEL_KEY}] [stream] no assistant.usage found in transcript tail ` +
|
|
305
|
+
`(messages=${tailMessages.length})`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
log?.warn(`[${CHANNEL_KEY}] [stream] read transcript failed: ` +
|
|
310
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (usageToEmit) {
|
|
314
|
+
log?.info(`[${CHANNEL_KEY}] [stream] emit final usage: ` +
|
|
315
|
+
`input=${usageToEmit.inputTokens} output=${usageToEmit.outputTokens} total=${usageToEmit.totalTokens}`);
|
|
316
|
+
emitSignal(signalCtx, "usage", "", undefined, { usage: usageToEmit });
|
|
317
|
+
}
|
|
266
318
|
sendTypingStop();
|
|
267
319
|
},
|
|
268
320
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LightClaw — Usage 归一函数
|
|
3
|
+
*
|
|
4
|
+
* 当前阶段直接读取 OpenClaw 的字段(input/output/...),
|
|
5
|
+
* 未来如果遇到和当前 OpenClaw 不一致的agent或者其他模型需要兼容
|
|
6
|
+
* 可以把本函数升级为「多适配器」,对外 API保持不变,调用方 0 改动。
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeUsage(raw, ctx = {}) {
|
|
9
|
+
if (!raw || typeof raw !== 'object')
|
|
10
|
+
return null;
|
|
11
|
+
const r = raw;
|
|
12
|
+
// OpenClaw 落盘格式的字段直接取(v1:input/output;v2:inputTokens/outputTokens 双兼容)
|
|
13
|
+
const inputTokens = pickNumber(r, ['inputTokens', 'input']);
|
|
14
|
+
const outputTokens = pickNumber(r, ['outputTokens', 'output']);
|
|
15
|
+
// 三者全无则视为无效数据
|
|
16
|
+
if (inputTokens === undefined && outputTokens === undefined) {
|
|
17
|
+
const totalOnly = pickNumber(r, ['totalTokens', 'total']);
|
|
18
|
+
if (totalOnly === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const totalTokensRaw = pickNumber(r, ['totalTokens', 'total']);
|
|
22
|
+
const totalTokens = totalTokensRaw ?? (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
23
|
+
return {
|
|
24
|
+
inputTokens,
|
|
25
|
+
outputTokens,
|
|
26
|
+
totalTokens,
|
|
27
|
+
cachedInputTokens: pickNumber(r, ['cachedInputTokens', 'cacheRead']),
|
|
28
|
+
cacheWriteTokens: pickNumber(r, ['cacheWriteTokens', 'cacheWrite']),
|
|
29
|
+
reasoningTokens: pickNumber(r, ['reasoningTokens']),
|
|
30
|
+
model: ctx.model,
|
|
31
|
+
provider: ctx.provider,
|
|
32
|
+
raw: r,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 在对象中按候选键名顺序查找第一个 finite number 字段。
|
|
37
|
+
* 抽出此函数是为了让字段命名兼容(v1 短名 / v2 长名)逻辑保持一处,
|
|
38
|
+
* 后续如果 OpenClaw 又新增字段命名(比如 inputTokenCount),只需在候选列表里追加。
|
|
39
|
+
*/
|
|
40
|
+
function pickNumber(obj, keys) {
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const v = obj[key];
|
|
43
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|