lightclawbot 1.2.0-beta.3 → 1.2.5
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/session-reader.js +57 -2
- package/dist/src/history/session-store.js +43 -0
- package/dist/src/history/text-processing.js +7 -0
- package/dist/src/inbound.js +34 -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 +3 -3
- package/dist/src/utils/common.js +153 -1
- package/node_modules/ws/lib/sender.js +6 -1
- package/node_modules/ws/package.json +1 -1
- package/openclaw.plugin.json +77 -3
- package/package.json +1 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LightClaw — Chat(会话元信息)处理器
|
|
3
|
+
*
|
|
4
|
+
* 收敛 EVENT_CHAT_REQUEST 各子类型的处理逻辑,与 handlers.ts 中的
|
|
5
|
+
* 消息 / 历史 / Agents 等事件处理保持平级解耦,避免 handlers.ts 膨胀。
|
|
6
|
+
*
|
|
7
|
+
* 涉及的持久化文件:
|
|
8
|
+
* `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
|
|
9
|
+
* └─ 以 ChatMeta[] 形式存储该用户在该 Agent 下的会话元信息列表
|
|
10
|
+
* (不包含消息正文,正文以 jsonl 形式另外存储)
|
|
11
|
+
*
|
|
12
|
+
* 四个处理器(对应 data.type):
|
|
13
|
+
* - list: 读取 chats.json 返回 { chats };文件不存在 / 解析失败
|
|
14
|
+
* 时返回空数组,不抛错,避免阻塞前端。
|
|
15
|
+
* - create: 新建一条会话元数据,chatId = randomUUID(),插入列表最前。
|
|
16
|
+
* 文件或目录不存在时由 writeChatsFile 递归创建。
|
|
17
|
+
* - update: 根据 chatId 修改 title(并置 titleLocked=true + 刷新 updatedAt);
|
|
18
|
+
* 文件或条目不存在时回 error,不隐式创建。
|
|
19
|
+
* - delete: 根据 chatId 从列表剔除,原子写回;文件或条目不存在时回 error。
|
|
20
|
+
*
|
|
21
|
+
* 全部响应均通过 EVENT_CHAT_RESPONSE + ReliableEmitter 发出(带 ACK + 重试)。
|
|
22
|
+
* 错误处理统一走 createChatErrorSender 生成的回调,保证日志 / 响应结构一致。
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../config.js';
|
|
27
|
+
import { generateMsgId } from '../dedup.js';
|
|
28
|
+
import { readChatsFile, readChatsFileOrInitDefault, resolveChatsFilePath, writeChatsFile } from '../utils/common.js';
|
|
29
|
+
/**
|
|
30
|
+
* 生成统一的错误响应发送器。
|
|
31
|
+
*
|
|
32
|
+
* 背景:list / create / update / delete 四个 handler 的错误处理结构完全一致:
|
|
33
|
+
* 1. 打同一格式日志:`[CHANNEL_KEY] Chat <type> error: <msg>`
|
|
34
|
+
* 2. 通过 EVENT_CHAT_RESPONSE 回同一组基础字段(msgId/from/to/type/agentId/error)
|
|
35
|
+
* 仅差异项:
|
|
36
|
+
* - `type` 不同
|
|
37
|
+
* - `chatId` 是否需要回显(update/delete 需要)
|
|
38
|
+
* - list 出错时前端期望一个兜底的空列表 `chats: []`,避免 UI 处理 undefined
|
|
39
|
+
*
|
|
40
|
+
* 因此将其封装为模块级工厂,handler 开头调用一次即可,避免重复闭包。
|
|
41
|
+
*
|
|
42
|
+
* @param ctx 共享上下文(见 ChatErrorSenderContext)
|
|
43
|
+
* @param extra 额外字段(如 `chatId`、兜底 `chats: []`),会与 error 一并合并到响应体
|
|
44
|
+
* @returns ChatErrorSender — 调用时传入错误信息即可
|
|
45
|
+
*/
|
|
46
|
+
function createChatErrorSender(ctx, extra = {}) {
|
|
47
|
+
const { responseMsgId, botClientId, userId, agentId, type, reliableEmitter, log } = ctx;
|
|
48
|
+
return (error) => {
|
|
49
|
+
log?.error(`[${CHANNEL_KEY}] Chat ${type} error: ${error}`);
|
|
50
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
51
|
+
msgId: responseMsgId,
|
|
52
|
+
from: botClientId,
|
|
53
|
+
to: userId,
|
|
54
|
+
type,
|
|
55
|
+
agentId,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
...extra,
|
|
58
|
+
error,
|
|
59
|
+
}, responseMsgId);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 处理 EVENT_CHAT_REQUEST(type=list):返回该用户在指定 Agent 下的会话列表。
|
|
64
|
+
*
|
|
65
|
+
* 行为:
|
|
66
|
+
* 1. 解析当前用户 + Agent 的 chats.json 绝对路径
|
|
67
|
+
* 2. 调用 readChatsFileOrInitDefault 读取:文件不存在时**主动落盘一条 chatId=''
|
|
68
|
+
* 的默认会话兜底条目**,保证前端拉列表永远拿到至少一项;格式异常时返回 []
|
|
69
|
+
* 3. 通过 EVENT_CHAT_RESPONSE 回 { chats };若意外抛异常则回 { chats: [], error }
|
|
70
|
+
*
|
|
71
|
+
* 为什么 list 路径要主动兜底?
|
|
72
|
+
* - 用户首次连接拉列表 → 拿到默认对话 → UI 展示一致,不会出现
|
|
73
|
+
* “先空态、首条消息后又凭空多出一项”的诡异闪烁;
|
|
74
|
+
* - 兜底是幂等的:若 chats.json 已存在则等价于纯读取,无副作用;
|
|
75
|
+
* - 与 ensureSessionInHistory 的被动兜底形成双保险,覆盖“前端跳过 list
|
|
76
|
+
* 直接发消息”的极端场景。
|
|
77
|
+
*
|
|
78
|
+
* 为什么 create/update/delete/history:request 仍走纯只读 readChatsFile?
|
|
79
|
+
* - 它们要么是用户主动操作(隐式创建语义已在 create 中处理),
|
|
80
|
+
* 要么是只读查询(不应有写盘副作用),由 list 路径统一负责兜底即可。
|
|
81
|
+
*/
|
|
82
|
+
export function handleChatList(params) {
|
|
83
|
+
const { userId, agentId, botClientId, reliableEmitter, log } = params;
|
|
84
|
+
// 当前用户在当前 Agent 下的会话记录保存路径,即 chats.json 的绝对路径
|
|
85
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
86
|
+
// 响应 msgId:同时做 ReliableEmitter 的 ack key,成功 / 失败分支共用同一枚
|
|
87
|
+
const responseMsgId = generateMsgId();
|
|
88
|
+
// list 出错时,用一个空数组兑底,避免 UI 处理 undefined
|
|
89
|
+
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'list', reliableEmitter, log }, { chats: [] });
|
|
90
|
+
try {
|
|
91
|
+
// 主动兜底读取:文件不存在则落盘一条 chatId='' 的默认会话;存在则等价于纯只读
|
|
92
|
+
const chats = readChatsFileOrInitDefault(chatsPath);
|
|
93
|
+
log?.info(`[${CHANNEL_KEY}] Chat list: userId=${userId} agentId=${agentId} count=${chats.length}`);
|
|
94
|
+
// 正常分支:统一经 ReliableEmitter 发送,依赖其 ACK + 重试保障送达
|
|
95
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
96
|
+
msgId: responseMsgId,
|
|
97
|
+
from: botClientId,
|
|
98
|
+
to: userId,
|
|
99
|
+
type: 'list',
|
|
100
|
+
agentId,
|
|
101
|
+
chats,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
}, responseMsgId);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
// 兼容 Error / 非 Error 投掷(如字符串 throw),统一拼接为可读消息
|
|
107
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 处理 EVENT_CHAT_REQUEST(type=create):新建一条会话元数据。
|
|
112
|
+
*
|
|
113
|
+
* 行为:
|
|
114
|
+
* 1. 解析 `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
|
|
115
|
+
* 2. 文件不存在 → 以空列表起步(writeChatsFile 会递归建目录 + 原子落盘)
|
|
116
|
+
* 3. 文件存在 → 读取并在头部插入新会话(新建的默认置顶于列表最前)
|
|
117
|
+
* 4. 新会话默认 `title = '新会话'`,`chatId = randomUUID()`,createdAt = updatedAt = now
|
|
118
|
+
* 5. 通过 EVENT_CHAT_RESPONSE 返回 { chat: 新建项 }
|
|
119
|
+
*/
|
|
120
|
+
export function handleChatCreate(params) {
|
|
121
|
+
const { userId, agentId, botClientId, reliableEmitter, log } = params;
|
|
122
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
123
|
+
// 响应 msgId:成功 / 失败分支共用,侜助前端根据 msgId 反查原请求
|
|
124
|
+
const responseMsgId = generateMsgId();
|
|
125
|
+
const sendError = createChatErrorSender({
|
|
126
|
+
responseMsgId,
|
|
127
|
+
botClientId,
|
|
128
|
+
userId,
|
|
129
|
+
agentId,
|
|
130
|
+
type: 'create',
|
|
131
|
+
reliableEmitter,
|
|
132
|
+
log,
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
// 读取已有列表:文件不存在则返回 [] —— create 是隐式创建语义
|
|
136
|
+
const existing = readChatsFile(chatsPath);
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const newChat = {
|
|
139
|
+
chatId: randomUUID(),
|
|
140
|
+
title: '新会话',
|
|
141
|
+
// 新建时 titleLocked=false:首条消息后可被自动标题生成策略改写
|
|
142
|
+
titleLocked: false,
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
pinned: false,
|
|
146
|
+
// sessionIdHistory:同一 chat 历史上使用过的 sessionId(支持会话分支 / 重置)
|
|
147
|
+
sessionIdHistory: [],
|
|
148
|
+
};
|
|
149
|
+
// 新会话插入到最前,writeChatsFile 会在目录不存在时逐级创建(tmp → rename 原子落盘)
|
|
150
|
+
const nextChats = [newChat, ...existing];
|
|
151
|
+
writeChatsFile(chatsPath, nextChats);
|
|
152
|
+
log?.info(`[${CHANNEL_KEY}] Chat create: userId=${userId} agentId=${agentId} chatId=${newChat.chatId}`);
|
|
153
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
154
|
+
msgId: responseMsgId,
|
|
155
|
+
from: botClientId,
|
|
156
|
+
to: userId,
|
|
157
|
+
type: 'create',
|
|
158
|
+
agentId,
|
|
159
|
+
chats: [newChat],
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
}, responseMsgId);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function handleChatUpdate(params) {
|
|
168
|
+
const { userId, agentId, chatId, title, botClientId, reliableEmitter, log } = params;
|
|
169
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
170
|
+
const responseMsgId = generateMsgId();
|
|
171
|
+
// update 错误响应需要回显 chatId,便于前端关联请求
|
|
172
|
+
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'update', reliableEmitter, log }, { chatId });
|
|
173
|
+
// title 可能为纯空白字符,trim 后再判断,避免“空标题”落盘
|
|
174
|
+
const nextTitle = title?.trim();
|
|
175
|
+
if (!nextTitle) {
|
|
176
|
+
sendError('Missing title');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// 文件必须存在,update 不负责创建(语义上:无源数据则无从更新)
|
|
180
|
+
if (!fs.existsSync(chatsPath)) {
|
|
181
|
+
sendError(`Chats file not found: ${chatsPath}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const chats = readChatsFile(chatsPath);
|
|
186
|
+
// O(n) 线性查找;chats.json 一般单用户单 Agent 规模有限,无需 Map 索引
|
|
187
|
+
const idx = chats.findIndex((c) => c.chatId === chatId);
|
|
188
|
+
if (idx < 0) {
|
|
189
|
+
sendError(`Chat not found: chatId=${chatId}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// 更新:title + updatedAt + titleLocked
|
|
193
|
+
// titleLocked=true:用户主动改名后锁定,防止被自动标题生成策略覆盖
|
|
194
|
+
const updated = {
|
|
195
|
+
...chats[idx],
|
|
196
|
+
title: nextTitle,
|
|
197
|
+
titleLocked: true,
|
|
198
|
+
updatedAt: Date.now(),
|
|
199
|
+
};
|
|
200
|
+
// 浅拷贝后原位替换,保留其他项的顺序(列表排序给前端决定,后端不托管)
|
|
201
|
+
const nextChats = [...chats];
|
|
202
|
+
nextChats[idx] = updated;
|
|
203
|
+
// 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件
|
|
204
|
+
writeChatsFile(chatsPath, nextChats);
|
|
205
|
+
log?.info(`[${CHANNEL_KEY}] Chat update: userId=${userId} agentId=${agentId} chatId=${chatId} title="${nextTitle}"`);
|
|
206
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
207
|
+
msgId: responseMsgId,
|
|
208
|
+
from: botClientId,
|
|
209
|
+
to: userId,
|
|
210
|
+
type: 'update',
|
|
211
|
+
agentId,
|
|
212
|
+
chats: [updated],
|
|
213
|
+
timestamp: Date.now(),
|
|
214
|
+
}, responseMsgId);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function handleChatDelete(params) {
|
|
221
|
+
const { userId, agentId, chatId, botClientId, reliableEmitter, log } = params;
|
|
222
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
223
|
+
const responseMsgId = generateMsgId();
|
|
224
|
+
// delete 错误响应需要回显 chatId,便于前端关联请求
|
|
225
|
+
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'delete', reliableEmitter, log }, { chatId });
|
|
226
|
+
// 文件必须存在,delete 不负责创建(无文件 == 无可删的项,返回明确错误)
|
|
227
|
+
if (!fs.existsSync(chatsPath)) {
|
|
228
|
+
sendError(`Chats file not found: ${chatsPath}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const chats = readChatsFile(chatsPath);
|
|
233
|
+
// 先确认条目存在再写盘,避免“无效删除”仍触发一次磁盘 IO
|
|
234
|
+
const idx = chats.findIndex((c) => c.chatId === chatId);
|
|
235
|
+
if (idx < 0) {
|
|
236
|
+
sendError(`Chat not found: chatId=${chatId}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// 剔除目标项,保留其余顺序(不重排,不影响前端缓存的列表索引)
|
|
240
|
+
const nextChats = chats.filter((c) => c.chatId !== chatId);
|
|
241
|
+
// 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件
|
|
242
|
+
writeChatsFile(chatsPath, nextChats);
|
|
243
|
+
log?.info(`[${CHANNEL_KEY}] Chat delete: userId=${userId} agentId=${agentId} chatId=${chatId} remaining=${nextChats.length}`);
|
|
244
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
245
|
+
msgId: responseMsgId,
|
|
246
|
+
from: botClientId,
|
|
247
|
+
to: userId,
|
|
248
|
+
type: 'delete',
|
|
249
|
+
agentId,
|
|
250
|
+
chats: chats.filter((c) => c.chatId === chatId),
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
}, responseMsgId);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -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,
|
|
@@ -110,11 +110,11 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
110
110
|
enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
|
|
111
111
|
}
|
|
112
112
|
if (files.length > 0) {
|
|
113
|
-
emitter.sendFiles(targetId, enrichedText, files, originalMsgId);
|
|
113
|
+
emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
|
|
114
114
|
emittedUserVisible = true;
|
|
115
115
|
}
|
|
116
116
|
else if (enrichedText.trim()) {
|
|
117
|
-
emitter.sendReply(targetId, enrichedText, originalMsgId);
|
|
117
|
+
emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId);
|
|
118
118
|
emittedUserVisible = true;
|
|
119
119
|
}
|
|
120
120
|
};
|
|
@@ -256,7 +256,7 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
256
256
|
: "LLM only produced thinking, no visible text";
|
|
257
257
|
log?.warn(`[${CHANNEL_KEY}] [stream] NO_REPLY: counts=${JSON.stringify(counts)} ` +
|
|
258
258
|
`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount}. Cause: ${cause}`);
|
|
259
|
-
emitter.sendReply(targetId, "NO_REPLY", originalMsgId);
|
|
259
|
+
emitter.sendReply(targetId, "NO_REPLY", originalMsgId, signalCtx.chatId);
|
|
260
260
|
}
|
|
261
261
|
else {
|
|
262
262
|
log?.info(`[${CHANNEL_KEY}] [stream] markComplete: counts=${JSON.stringify(counts)} ` +
|