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
package/dist/src/channel.js
CHANGED
|
@@ -1,39 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* channel.ts —— Lightclaw Channel Plugin 主入口
|
|
4
|
+
* ============================================================================
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* 本文件通过 OpenClaw 的 Plugin SDK 创建并导出一个「聊天类 Channel 插件」
|
|
7
|
+
* (`lightclawPlugin`),用于把 Lightclaw(AI 助手 Server)接入到 OpenClaw
|
|
8
|
+
* 网关体系中,作为一个标准的消息通道(Channel)使用。
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* 该插件主要负责以下几件事:
|
|
11
|
+
* 1. messaging:目标字符串(user:xxx / channel:xxx)的规范化与识别;
|
|
12
|
+
* 2. status :暴露插件/账户运行时状态,供 OpenClaw 状态面板展示;
|
|
13
|
+
* 3. gateway :账户级别的长连接生命周期管理(启动/停止/登出);
|
|
14
|
+
* 4. outbound :出站消息(文本 / 媒体)的投递策略与发送实现。
|
|
15
|
+
*
|
|
16
|
+
* 架构概览:
|
|
17
|
+
* OpenClaw ──启动账户──▶ startAccount() ──▶ startGateway() ──▶ Socket.IO WS
|
|
18
|
+
* ◀──状态回写── ctx.setStatus() ◀── onReady/onEvent/onDisconnect/onError
|
|
19
|
+
*
|
|
20
|
+
* ============================================================================
|
|
11
21
|
*/
|
|
12
22
|
import { createChatChannelPlugin } from 'openclaw/plugin-sdk/core';
|
|
23
|
+
// ---- 通用工具:文本分片、默认账户 ID 解析 ------------------------------------
|
|
13
24
|
import { chunkText, defaultAccountId } from './utils/index.js';
|
|
25
|
+
// ---- 常量:通道 Key、默认账户 ID、文本分片上限 -------------------------------
|
|
14
26
|
import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, TEXT_CHUNK_LIMIT } from './config.js';
|
|
27
|
+
// ---- 出站发送:文本 / 媒体两类消息的实际发送实现 -----------------------------
|
|
15
28
|
import { sendText, sendMedia } from './outbound.js';
|
|
29
|
+
// ---- Gateway:账户级别的 Socket.IO 长连接控制器 ------------------------------
|
|
16
30
|
import { startGateway } from './gateway.js';
|
|
31
|
+
// ---- 共享 base:抽取出的通用插件基础配置(避免与其他插件重复) ---------------
|
|
17
32
|
import { createLightclawPluginBase } from './shared.js';
|
|
33
|
+
// ---- Setup 适配器:首次配置 / 登录流程 ---------------------------------------
|
|
18
34
|
import { lightclawSetupAdapter } from './setup-core.js';
|
|
35
|
+
// ---- 目标解析:对 target 字符串做 normalize 和 "是否像 ID" 的预判 -------------
|
|
19
36
|
import { normalizeTarget, looksLikeId } from './messaging.js';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Lightclaw 聊天通道插件。
|
|
39
|
+
*
|
|
40
|
+
* 通过 `createChatChannelPlugin` 工厂创建;泛型 `ResolvedAssistantAccount`
|
|
41
|
+
* 指定该通道使用的账户类型(OpenClaw 会据此在各回调中注入强类型 account)。
|
|
42
|
+
*
|
|
43
|
+
* 导出后由 OpenClaw 主程序在启动阶段加载并注册到 Channel 管理器中。
|
|
44
|
+
*/
|
|
23
45
|
export const lightclawPlugin = createChatChannelPlugin({
|
|
46
|
+
// ========================================================================
|
|
47
|
+
// base:插件的核心能力声明(messaging / status / gateway ...)
|
|
48
|
+
// ========================================================================
|
|
24
49
|
base: {
|
|
25
|
-
//
|
|
50
|
+
// 展开共享的 base:包含 setup、channelKey、日志前缀等公用配置
|
|
26
51
|
...createLightclawPluginBase({
|
|
27
52
|
setup: lightclawSetupAdapter,
|
|
28
53
|
}),
|
|
29
|
-
//
|
|
54
|
+
// ----------------------------------------------------------------------
|
|
55
|
+
// messaging:消息目标(target)的解析与规范化
|
|
56
|
+
// ----------------------------------------------------------------------
|
|
30
57
|
messaging: {
|
|
31
|
-
|
|
58
|
+
/**
|
|
59
|
+
* 对原始目标字符串进行标准化处理。
|
|
60
|
+
*
|
|
61
|
+
* 例如:去除前后空格、统一大小写、把 `@xxx` 转为 `user:xxx` 等,
|
|
62
|
+
* 返回规范化后的字符串,供后续路由匹配使用。
|
|
63
|
+
*/
|
|
32
64
|
normalizeTarget: (target) => {
|
|
33
65
|
return normalizeTarget(target);
|
|
34
66
|
},
|
|
67
|
+
/**
|
|
68
|
+
* 目标解析器:在消息路由阶段帮助框架判断 target 的"形态"。
|
|
69
|
+
*/
|
|
35
70
|
targetResolver: {
|
|
36
|
-
|
|
71
|
+
/**
|
|
72
|
+
* 判断一个原始字符串是否看起来像一个 ID(而不是昵称/关键词等),
|
|
73
|
+
* 用于在解析前快速分流处理逻辑,避免不必要的远端查询。
|
|
74
|
+
*
|
|
75
|
+
* @param id 用户输入的原始字符串
|
|
76
|
+
* @param normalized 经过 normalizeTarget 规范化后的字符串(可选)
|
|
77
|
+
*/
|
|
37
78
|
looksLikeId: (id, normalized) => {
|
|
38
79
|
return looksLikeId(id, normalized);
|
|
39
80
|
},
|
|
@@ -41,8 +82,14 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
41
82
|
hint: `user:<userId> or channel:<groupId>`,
|
|
42
83
|
},
|
|
43
84
|
},
|
|
44
|
-
//
|
|
85
|
+
// ----------------------------------------------------------------------
|
|
86
|
+
// status:运行时状态快照的构建与默认值
|
|
87
|
+
// - defaultRuntime :账户 runtime 的初始值(未启动时回显)
|
|
88
|
+
// - buildChannelSummary:整个通道层级的概要状态(所有账户汇总之上的顶层)
|
|
89
|
+
// - buildAccountSnapshot:单个账户的详细快照(CLI/面板展示的主体数据)
|
|
90
|
+
// ----------------------------------------------------------------------
|
|
45
91
|
status: {
|
|
92
|
+
/** 账户 runtime 的初始状态:未连接、未运行、无错误 */
|
|
46
93
|
defaultRuntime: {
|
|
47
94
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
48
95
|
running: false,
|
|
@@ -50,6 +97,10 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
50
97
|
lastConnectedAt: null,
|
|
51
98
|
lastError: null,
|
|
52
99
|
},
|
|
100
|
+
/**
|
|
101
|
+
* 构建通道级别的汇总状态。
|
|
102
|
+
* 由 OpenClaw 在查询 `status` 命令时调用。
|
|
103
|
+
*/
|
|
53
104
|
buildChannelSummary: ({ snapshot }) => ({
|
|
54
105
|
configured: snapshot.configured ?? false,
|
|
55
106
|
running: snapshot.running ?? false,
|
|
@@ -57,10 +108,18 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
57
108
|
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
|
58
109
|
lastError: snapshot.lastError ?? null,
|
|
59
110
|
}),
|
|
111
|
+
/**
|
|
112
|
+
* 构建单个账户的快照。
|
|
113
|
+
*
|
|
114
|
+
* @param account 账户元数据(accountId / name / apiKey / enabled 等)
|
|
115
|
+
* @param runtime 账户运行时状态(由 gateway 回调实时更新)
|
|
116
|
+
* @param cfg 当前 OpenClaw 完整配置,用于在 account 缺失时回退取默认
|
|
117
|
+
*/
|
|
60
118
|
buildAccountSnapshot: ({ account, runtime, cfg }) => ({
|
|
61
119
|
accountId: account?.accountId ?? defaultAccountId(cfg),
|
|
62
120
|
name: account?.name,
|
|
63
121
|
enabled: account?.enabled ?? false,
|
|
122
|
+
// configured 表示"是否已配置 apiKey",是前端判断"是否可启动"的依据
|
|
64
123
|
configured: Boolean(account?.apiKey),
|
|
65
124
|
running: runtime?.running ?? false,
|
|
66
125
|
connected: runtime?.connected ?? false,
|
|
@@ -68,24 +127,41 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
68
127
|
lastError: runtime?.lastError ?? null,
|
|
69
128
|
}),
|
|
70
129
|
},
|
|
71
|
-
//
|
|
130
|
+
// ----------------------------------------------------------------------
|
|
131
|
+
// gateway:账户级生命周期(启动 WS 长连接 / 登出清理凭证)
|
|
132
|
+
// ----------------------------------------------------------------------
|
|
72
133
|
gateway: {
|
|
73
134
|
/**
|
|
74
|
-
* 启动账户:建立 WS 长连接到 AI 助手 Server
|
|
75
|
-
*
|
|
135
|
+
* 启动账户:建立 WS 长连接到 AI 助手 Server。
|
|
136
|
+
*
|
|
137
|
+
* OpenClaw 会在加载配置后,为每个 `enabled` 的账户调用此方法。
|
|
138
|
+
* 方法内部通过 `startGateway` 维持一个带自动重连的 Socket.IO 实例,
|
|
139
|
+
* 并通过一组回调把连接状态反向写回 OpenClaw 的 runtime 状态。
|
|
140
|
+
*
|
|
141
|
+
* @param ctx 插件上下文:
|
|
142
|
+
* - account 当前账户解析结果
|
|
143
|
+
* - abortSignal 外部取消信号(用户执行 stop 时触发)
|
|
144
|
+
* - log 插件作用域的 logger
|
|
145
|
+
* - cfg OpenClaw 完整配置
|
|
146
|
+
* - getStatus/setStatus 读写当前 runtime 快照
|
|
76
147
|
*/
|
|
77
148
|
startAccount: async (ctx) => {
|
|
78
149
|
const { account, abortSignal, log, cfg } = ctx;
|
|
79
|
-
|
|
150
|
+
const preLogFix = `[${CHANNEL_KEY}:${account.accountId}]`;
|
|
151
|
+
log?.info(`${preLogFix} Starting gateway...`);
|
|
80
152
|
// 启动并维持一个 Socket.IO Gateway 实例
|
|
81
153
|
await startGateway({
|
|
82
154
|
account,
|
|
83
155
|
abortSignal,
|
|
84
156
|
cfg,
|
|
85
157
|
log,
|
|
86
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* WS 连接就绪回调:
|
|
160
|
+
* 标记 running=true / connected=true,并用当前时间戳初始化
|
|
161
|
+
* lastConnectedAt 与 lastEventAt(后者是 health-monitor 的检测基准)。
|
|
162
|
+
*/
|
|
87
163
|
onReady: () => {
|
|
88
|
-
log?.info(
|
|
164
|
+
log?.info(`${preLogFix} Gateway ready: WS connected`);
|
|
89
165
|
const now = Date.now();
|
|
90
166
|
ctx.setStatus({
|
|
91
167
|
...ctx.getStatus(),
|
|
@@ -96,17 +172,24 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
96
172
|
lastEventAt: now,
|
|
97
173
|
});
|
|
98
174
|
},
|
|
99
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* WS 连接断开回调:
|
|
177
|
+
* 仅把 connected 置为 false;running 保持 true,表示 gateway
|
|
178
|
+
* 仍在尝试重连。真正的停止由 abortSignal 触发。
|
|
179
|
+
*/
|
|
100
180
|
onDisconnect: () => {
|
|
101
|
-
log?.info(
|
|
181
|
+
log?.info(`${preLogFix} Gateway ready: WS disconnect`);
|
|
102
182
|
ctx.setStatus({
|
|
103
183
|
...ctx.getStatus(),
|
|
104
184
|
connected: false,
|
|
105
185
|
});
|
|
106
186
|
},
|
|
107
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* 错误回调:记录错误信息到 runtime.lastError。
|
|
189
|
+
* 不中断重连逻辑(重连由 gateway 内部自愈机制处理)。
|
|
190
|
+
*/
|
|
108
191
|
onError: (error) => {
|
|
109
|
-
log?.error(
|
|
192
|
+
log?.error(`${preLogFix} Gateway error: ${error.message}`);
|
|
110
193
|
ctx.setStatus({
|
|
111
194
|
...ctx.getStatus(),
|
|
112
195
|
lastError: error.message,
|
|
@@ -114,11 +197,12 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
114
197
|
},
|
|
115
198
|
/**
|
|
116
199
|
* 入站事件回调:每次收到消息时调用。
|
|
117
|
-
*
|
|
118
|
-
*
|
|
200
|
+
*
|
|
201
|
+
* 通过刷新 `lastEventAt` 和 `lastInboundAt`,通知框架
|
|
202
|
+
* health-monitor 该连接仍处于活跃状态,避免被判定为 stale 而重连。
|
|
119
203
|
*/
|
|
120
204
|
onEvent: () => {
|
|
121
|
-
log?.info(
|
|
205
|
+
log?.info(`${preLogFix} Gateway Ready: WS message enter`);
|
|
122
206
|
ctx.setStatus({
|
|
123
207
|
...ctx.getStatus(),
|
|
124
208
|
lastEventAt: Date.now(),
|
|
@@ -127,19 +211,24 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
127
211
|
},
|
|
128
212
|
});
|
|
129
213
|
},
|
|
130
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* 登出账户:从配置中清除该账户的凭证(apiKey)。
|
|
216
|
+
* 清理 `lightclawbot.accounts[accountId].apiKey`
|
|
217
|
+
*
|
|
218
|
+
* 注意:本方法只负责清除凭证字段,不停止 gateway(停止由框架调度)。
|
|
219
|
+
*
|
|
220
|
+
* @returns { ok, cleared } cleared=true 表示确实清理了字段
|
|
221
|
+
*/
|
|
131
222
|
logoutAccount: async ({ accountId, cfg }) => {
|
|
223
|
+
// 拷贝 cfg,避免直接改引用导致上游感知不到变化
|
|
132
224
|
const nextCfg = { ...cfg };
|
|
133
|
-
const
|
|
225
|
+
const lightclawbotConfig = nextCfg?.channels?.[CHANNEL_KEY];
|
|
134
226
|
let cleared = false;
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const accounts = section.accounts;
|
|
141
|
-
if (accounts?.[accountId]?.apiKeys) {
|
|
142
|
-
delete accounts[accountId].apiKeys;
|
|
227
|
+
if (lightclawbotConfig) {
|
|
228
|
+
// 命名账户:凭证挂在 accounts[accountId] 下
|
|
229
|
+
const accounts = lightclawbotConfig.accounts;
|
|
230
|
+
if (accounts?.[accountId]?.apiKey) {
|
|
231
|
+
delete accounts[accountId].apiKey;
|
|
143
232
|
cleared = true;
|
|
144
233
|
}
|
|
145
234
|
}
|
|
@@ -147,13 +236,30 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
147
236
|
},
|
|
148
237
|
},
|
|
149
238
|
},
|
|
150
|
-
//
|
|
239
|
+
// ========================================================================
|
|
240
|
+
// outbound:出站消息投递配置
|
|
241
|
+
// - base :投递策略(模式、分片器、目标解析)
|
|
242
|
+
// - attachedResults :实际发送函数(sendText / sendMedia)
|
|
243
|
+
// ========================================================================
|
|
151
244
|
outbound: {
|
|
152
245
|
base: {
|
|
246
|
+
/** 投递模式:direct = 直接发送,不走队列/合并 */
|
|
153
247
|
deliveryMode: 'direct',
|
|
248
|
+
/** 文本分片器:按 Markdown 语义边界做智能切分 */
|
|
154
249
|
chunker: chunkText,
|
|
250
|
+
/** 分片模式:markdown 模式会保留代码块、列表等结构完整性 */
|
|
155
251
|
chunkerMode: 'markdown',
|
|
252
|
+
/** 单条消息文本长度上限(超过会被 chunker 切成多条发送) */
|
|
156
253
|
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
254
|
+
/**
|
|
255
|
+
* 解析投递目标(resolveTarget):
|
|
256
|
+
*
|
|
257
|
+
* 决定一次 outbound 消息最终要发送给谁(to)。
|
|
258
|
+
* 优先级:
|
|
259
|
+
* 1. 显式 `to` 参数(用户 / 上层明确指定);
|
|
260
|
+
* 2. implicit 模式下,从 `allowFrom` 列表中挑第一个非通配符条目;
|
|
261
|
+
* 3. 都没有则返回错误,提示用户用 `--to` 指定或配置 allowFrom。
|
|
262
|
+
*/
|
|
157
263
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
|
158
264
|
// 优先使用显式 to;implicit 模式下回退到 allowFrom 第一个非通配符条目
|
|
159
265
|
const effectiveTo = to?.trim();
|
|
@@ -172,11 +278,26 @@ export const lightclawPlugin = createChatChannelPlugin({
|
|
|
172
278
|
};
|
|
173
279
|
},
|
|
174
280
|
},
|
|
281
|
+
/**
|
|
282
|
+
* attachedResults:绑定到"工具调用结果投递"这条链路的发送实现。
|
|
283
|
+
*
|
|
284
|
+
* OpenClaw 在 assistant 产出工具结果需要推送给外部时,会根据
|
|
285
|
+
* `channel` 字段路由到这里,调用 sendText / sendMedia 完成实际投递。
|
|
286
|
+
*/
|
|
175
287
|
attachedResults: {
|
|
288
|
+
/** 通道标识,需与 CHANNEL_KEY 一致,供 OpenClaw 路由匹配 */
|
|
176
289
|
channel: CHANNEL_KEY,
|
|
290
|
+
/**
|
|
291
|
+
* 发送纯文本消息。
|
|
292
|
+
* 参数由框架透传:to 目标、text 内容、accountId 账户、replyToId 回复锚点、cfg 完整配置。
|
|
293
|
+
*/
|
|
177
294
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
178
295
|
return sendText({ to, text, accountId, replyToId, cfg });
|
|
179
296
|
},
|
|
297
|
+
/**
|
|
298
|
+
* 发送带媒体附件的消息(图片 / 文件等)。
|
|
299
|
+
* text 为附带的文本说明,mediaUrl 为媒体资源 URL。
|
|
300
|
+
*/
|
|
180
301
|
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
|
|
181
302
|
return sendMedia({ to, text, mediaUrl, accountId, replyToId, cfg });
|
|
182
303
|
},
|
package/dist/src/config.js
CHANGED
|
@@ -147,6 +147,10 @@ export const EVENT_HISTORY_REQUEST = 'message:history:request';
|
|
|
147
147
|
export const EVENT_HISTORY_RESPONSE = 'message:history:response';
|
|
148
148
|
export const EVENT_SESSIONS_REQUEST = 'sessions:request';
|
|
149
149
|
export const EVENT_SESSIONS_RESPONSE = 'sessions:response';
|
|
150
|
+
export const EVENT_AGENTS_REQUEST = 'agents:request';
|
|
151
|
+
export const EVENT_AGENTS_RESPONSE = 'agents:response';
|
|
152
|
+
export const EVENT_CHAT_REQUEST = 'chat:request';
|
|
153
|
+
export const EVENT_CHAT_RESPONSE = 'chat:response';
|
|
150
154
|
// ============================================================
|
|
151
155
|
// 文件下载信令(kind 字段值)
|
|
152
156
|
// ============================================================
|
package/dist/src/gateway.js
CHANGED
|
@@ -133,10 +133,19 @@ export async function startGateway(ctx) {
|
|
|
133
133
|
* - 所有消息都走可靠发送队列,入队即视为"发送成功",实际 ACK 由 ReliableEmitter 保证
|
|
134
134
|
*/
|
|
135
135
|
const emit = (data) => {
|
|
136
|
+
// 统一保证 chatId 字段存在:全部 EVENT_MESSAGE_PRIVATE 出站消息都通过
|
|
137
|
+
// `extra.chatId` 携带,调用方未传入时默认为 ''(默认会话)。
|
|
138
|
+
// 同时保留调用方传入的其他 extra 字段(如 transferData)。
|
|
139
|
+
const existingExtra = data.extra ?? {};
|
|
140
|
+
const chatId = typeof existingExtra.chatId === 'string' ? existingExtra.chatId : '';
|
|
141
|
+
const payload = {
|
|
142
|
+
...data,
|
|
143
|
+
extra: { ...existingExtra, chatId },
|
|
144
|
+
};
|
|
136
145
|
// 完整发送日志由 ReliableEmitter 打印(含 idempotencyKey)
|
|
137
|
-
reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE,
|
|
146
|
+
reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, payload, payload.msgId).then((ok) => {
|
|
138
147
|
if (!ok)
|
|
139
|
-
log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${
|
|
148
|
+
log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${payload.msgId}`);
|
|
140
149
|
});
|
|
141
150
|
return true;
|
|
142
151
|
};
|
|
@@ -149,7 +158,7 @@ export async function startGateway(ctx) {
|
|
|
149
158
|
* @param replyToMsgId - 可选,回复对应的原始消息 ID(用于前端展示引用关系)
|
|
150
159
|
* @param agentId - 可选,目标 Agent ID
|
|
151
160
|
*/
|
|
152
|
-
const sendReply = (targetId, text, replyToMsgId, agentId) => {
|
|
161
|
+
const sendReply = (targetId, text, replyToMsgId, chatId, agentId) => {
|
|
153
162
|
log?.info(`[${CHANNEL_KEY}] sendReply: ${text} to ${targetId} (replyTo: ${replyToMsgId || 'none'})`);
|
|
154
163
|
return emit({
|
|
155
164
|
msgId: generateMsgId(),
|
|
@@ -159,6 +168,7 @@ export async function startGateway(ctx) {
|
|
|
159
168
|
timestamp: Date.now(),
|
|
160
169
|
replyToMsgId,
|
|
161
170
|
agentId,
|
|
171
|
+
extra: { chatId: chatId ?? '' },
|
|
162
172
|
});
|
|
163
173
|
};
|
|
164
174
|
/**
|
|
@@ -171,7 +181,7 @@ export async function startGateway(ctx) {
|
|
|
171
181
|
* @param replyToMsgId - 可选,回复对应的原始消息 ID
|
|
172
182
|
* @param agentId - 可选,目标 Agent ID
|
|
173
183
|
*/
|
|
174
|
-
const sendFiles = (targetId, text, files, replyToMsgId, agentId) => {
|
|
184
|
+
const sendFiles = (targetId, text, files, replyToMsgId, chatId, agentId) => {
|
|
175
185
|
return emit({
|
|
176
186
|
msgId: generateMsgId(),
|
|
177
187
|
from: botClientId,
|
|
@@ -181,6 +191,7 @@ export async function startGateway(ctx) {
|
|
|
181
191
|
files,
|
|
182
192
|
replyToMsgId,
|
|
183
193
|
agentId,
|
|
194
|
+
extra: { chatId: chatId ?? '' },
|
|
184
195
|
});
|
|
185
196
|
};
|
|
186
197
|
/** SocketEmitter 抽象:将 emit / sendReply / sendFiles 和 botClientId 打包传给 inbound 处理器 */
|
|
@@ -201,7 +212,7 @@ export async function startGateway(ctx) {
|
|
|
201
212
|
catch (err) {
|
|
202
213
|
log?.error(`[${CHANNEL_KEY}] Message handler error: ${err}`);
|
|
203
214
|
try {
|
|
204
|
-
emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId);
|
|
215
|
+
emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId, msg.chatId);
|
|
205
216
|
emitter.emit({
|
|
206
217
|
msgId: generateMsgId(),
|
|
207
218
|
from: emitter.botClientId,
|
|
@@ -211,6 +222,7 @@ export async function startGateway(ctx) {
|
|
|
211
222
|
kind: "typing_stop",
|
|
212
223
|
replyToMsgId: msg.messageId,
|
|
213
224
|
agentId: msg.agentId,
|
|
225
|
+
extra: { chatId: msg.chatId ?? '' },
|
|
214
226
|
});
|
|
215
227
|
}
|
|
216
228
|
catch (notifyErr) {
|
|
@@ -20,4 +20,4 @@ export { isSystemInjectedUserMessage, extractText, extractRawText, extractThinki
|
|
|
20
20
|
// ── Cron Utilities ──
|
|
21
21
|
export { isCronSessionKey, isCronRunKey, isCronBaseKey, extractCronJobId, extractCronRunId, classifySessionKey, listCronSessions, groupCronSessionsByJob, extractCronInfoFromText, cleanCronUserMessage, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js";
|
|
22
22
|
// ── Session Reader (核心 API) ──
|
|
23
|
-
export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, listSessions, } from "./session-reader.js";
|
|
23
|
+
export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, readSessionHistoriesByIds, listSessions, } from "./session-reader.js";
|
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
* - listSessions() — 列出所有 session(含 cron 类型标识)
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
-
import { resolveTranscriptPath } from "./session-store.js";
|
|
12
|
+
import { resolveTranscriptPath, loadSessionStore, resolveTranscriptPathBySessionId } from "./session-store.js";
|
|
13
13
|
import { isSystemInjectedUserMessage, normalizeMessage } from "./message-parser.js";
|
|
14
14
|
import { listCronSessions, classifySessionKey, extractCronJobId, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js";
|
|
15
|
-
import { loadSessionStore } from "./session-store.js";
|
|
16
15
|
// ============================================================
|
|
17
16
|
// 核心读取:单个 Session
|
|
18
17
|
// ============================================================
|
|
@@ -226,6 +225,62 @@ export function listSessions(agentId) {
|
|
|
226
225
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
227
226
|
}
|
|
228
227
|
// ============================================================
|
|
228
|
+
// 多 SessionId 合并读取(跨 reset 历史回看)
|
|
229
|
+
// ============================================================
|
|
230
|
+
/**
|
|
231
|
+
* 给定一组 sessionId(按时间序),合并读取它们各自 jsonl 中的消息。
|
|
232
|
+
*
|
|
233
|
+
* 适用场景:
|
|
234
|
+
* chats.json 的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId
|
|
235
|
+
* (含已被 reset 归档的旧 ID 和当前在用的 ID)。本函数据此把多份 jsonl
|
|
236
|
+
* 合并为一份按时间正序的消息流,让前端能跨 reset 看到完整对话历史。
|
|
237
|
+
*
|
|
238
|
+
* 实现细节:
|
|
239
|
+
* - 通过 resolveTranscriptPathBySessionId 直接按 sessionId 定位文件,
|
|
240
|
+
* 既能命中活跃文件 <sessionId>.jsonl,也能命中归档文件
|
|
241
|
+
* <sessionId>.jsonl.reset.* / .deleted.*;
|
|
242
|
+
* - 单份 jsonl 内已按写入顺序自然有序,不同 sessionId 之间通过 timestamp
|
|
243
|
+
* 做最终归并排序;
|
|
244
|
+
* - 仅返回最后 limit 条(保持与 readSessionHistory 一致的行为)。
|
|
245
|
+
*
|
|
246
|
+
* @param sessionIds - 该 chat 用过的所有 sessionId(顺序由调用方保证:
|
|
247
|
+
* 通常 = chats.json[chatId].sessionIdHistory)
|
|
248
|
+
* @param opts - 与 readSessionHistory 一致;agentId 用于路径解析
|
|
249
|
+
* @returns 按 timestamp 升序合并的消息列表(最多 limit 条)
|
|
250
|
+
*/
|
|
251
|
+
export function readSessionHistoriesByIds(sessionIds, opts) {
|
|
252
|
+
const limit = opts?.limit ?? 200;
|
|
253
|
+
const chatOnly = opts?.chatOnly ?? false;
|
|
254
|
+
if (!Array.isArray(sessionIds) || sessionIds.length === 0)
|
|
255
|
+
return [];
|
|
256
|
+
const all = [];
|
|
257
|
+
// 去重 + 保持顺序:同一 sessionId 出现多次只读一次
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
for (const sid of sessionIds) {
|
|
260
|
+
if (!sid || seen.has(sid))
|
|
261
|
+
continue;
|
|
262
|
+
seen.add(sid);
|
|
263
|
+
const filePath = resolveTranscriptPathBySessionId(sid, opts?.agentId);
|
|
264
|
+
if (!filePath)
|
|
265
|
+
continue;
|
|
266
|
+
try {
|
|
267
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
268
|
+
// 单文件先读到上限:避免单段过大撑爆内存;最终还要按 limit 截断
|
|
269
|
+
const msgs = parseTranscriptLines(raw, { limit, chatOnly });
|
|
270
|
+
for (const m of msgs)
|
|
271
|
+
all.push(m);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// 单段读取失败不阻断其他段
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (all.length === 0)
|
|
278
|
+
return [];
|
|
279
|
+
// 多段合并:按 timestamp 升序;无 timestamp 的消息按相对顺序兜底
|
|
280
|
+
all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
|
281
|
+
return all.length > limit ? all.slice(-limit) : all;
|
|
282
|
+
}
|
|
283
|
+
// ============================================================
|
|
229
284
|
// 内部工具函数
|
|
230
285
|
// ============================================================
|
|
231
286
|
/**
|
|
@@ -29,6 +29,7 @@ export function resolveOpenClawHome() {
|
|
|
29
29
|
export function resolveSessionsDir(agentId) {
|
|
30
30
|
const home = resolveOpenClawHome();
|
|
31
31
|
const id = agentId?.trim() || "main";
|
|
32
|
+
// 例如:~/.openclaw/agents/main/sessions
|
|
32
33
|
return path.join(home, "agents", id, "sessions");
|
|
33
34
|
}
|
|
34
35
|
// ============================================================
|
|
@@ -45,6 +46,7 @@ let _lastStoreFilePath = null;
|
|
|
45
46
|
* 加载 sessions.json 索引,获取 sessionKey → sessionEntry 映射
|
|
46
47
|
*/
|
|
47
48
|
export function loadSessionStore(agentId) {
|
|
49
|
+
// ~/.openclaw/agents/main/sessions/sessions.json
|
|
48
50
|
const storePath = path.join(resolveSessionsDir(agentId), "sessions.json");
|
|
49
51
|
// const storePath = path.join(currentDir, "../../sessions/sessions.json").replace("file:", "");
|
|
50
52
|
const loaded = _tryLoadStore(storePath);
|
|
@@ -169,3 +171,44 @@ function _findArchivedTranscript(dir, jsonlFileName) {
|
|
|
169
171
|
}
|
|
170
172
|
return null;
|
|
171
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* 直接根据 sessionId 解析 transcript 文件路径(不依赖 sessions.json 索引)。
|
|
176
|
+
*
|
|
177
|
+
* 适用场景:
|
|
178
|
+
* chats.json 中的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId,
|
|
179
|
+
* 这些 sessionId 在 reset 之后会被框架从 sessions.json 索引中移除(旧条目失效),
|
|
180
|
+
* 因此无法再通过 sessionKey 反查;只能直接按 sessionId 在磁盘上定位 jsonl。
|
|
181
|
+
*
|
|
182
|
+
* 解析顺序:
|
|
183
|
+
* 1. <sessionsDir>/<sessionId>.jsonl —— 当前在用的 transcript
|
|
184
|
+
* 2. <sessionsDir>/<sessionId>.jsonl.reset.* / .deleted.* —— 归档 transcript
|
|
185
|
+
* 3. storeDir 兜底(同 resolveTranscriptPath,离线分析场景)
|
|
186
|
+
*
|
|
187
|
+
* 找不到则返回 null。
|
|
188
|
+
*/
|
|
189
|
+
export function resolveTranscriptPathBySessionId(sessionId, agentId) {
|
|
190
|
+
if (!sessionId || !sessionId.trim())
|
|
191
|
+
return null;
|
|
192
|
+
const sessionsDir = resolveSessionsDir(agentId);
|
|
193
|
+
const jsonlFileName = `${sessionId}.jsonl`;
|
|
194
|
+
// 1. 标准目录下的活跃 transcript
|
|
195
|
+
const defaultPath = path.join(sessionsDir, jsonlFileName);
|
|
196
|
+
if (fs.existsSync(defaultPath))
|
|
197
|
+
return defaultPath;
|
|
198
|
+
// 2. 同目录归档文件
|
|
199
|
+
const archivedPath = _findArchivedTranscript(sessionsDir, jsonlFileName);
|
|
200
|
+
if (archivedPath)
|
|
201
|
+
return archivedPath;
|
|
202
|
+
// 3. storeDir 兜底(离线分析场景)
|
|
203
|
+
// 注意:getStoreDir() 仅在 loadSessionStore() 成功后才有值,调用方需确保前置条件
|
|
204
|
+
const storeDir = getStoreDir();
|
|
205
|
+
if (storeDir && storeDir !== sessionsDir) {
|
|
206
|
+
const siblingPath = path.join(storeDir, jsonlFileName);
|
|
207
|
+
if (fs.existsSync(siblingPath))
|
|
208
|
+
return siblingPath;
|
|
209
|
+
const archivedInStore = _findArchivedTranscript(storeDir, jsonlFileName);
|
|
210
|
+
if (archivedInStore)
|
|
211
|
+
return archivedInStore;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
@@ -71,6 +71,13 @@ export function stripResidualMetadata(text) {
|
|
|
71
71
|
result = result.replace(/<file\b[^>]*>.*?<\/file>/gs, "");
|
|
72
72
|
// 移除 "用户发送了文件: filename (size)" 描述文本
|
|
73
73
|
result = result.replace(/用户发送了文件:\s*.+?\s*\([^)]+\)\s*/g, "");
|
|
74
|
+
// 移除 OpenClaw 媒体消息的结构化包装块:
|
|
75
|
+
// [Image] / [Video] / [Audio] / [Document] / [Image 1] 等媒体类型标记行
|
|
76
|
+
result = result.replace(/^\[[A-Za-z][\w ]*\]\s*$/gm, "");
|
|
77
|
+
// "User text:" 单独一行的 label(其后紧跟用户真实输入,需保留输入本身)
|
|
78
|
+
result = result.replace(/^User text:\s*$/gm, "");
|
|
79
|
+
// "Description:\n<AI 对媒体的自动描述>" — 位于消息尾部,直接吃到文本末尾
|
|
80
|
+
result = result.replace(/^Description:[\s\S]*$/m, "");
|
|
74
81
|
return result.trim();
|
|
75
82
|
}
|
|
76
83
|
// ============================================================
|
package/dist/src/inbound.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - /stop 及自然语言 abort 由 tryFastAbortFromMessage 统一处理并递归 kill subagent。
|
|
9
9
|
*/
|
|
10
10
|
import { emitSignal } from './utils/common.js';
|
|
11
|
-
import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME } from './config.js';
|
|
11
|
+
import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME, } from './config.js';
|
|
12
12
|
import { getLightclawRuntime } from './runtime.js';
|
|
13
13
|
import { createChannelReplyPipeline } from 'openclaw/plugin-sdk/channel-reply-pipeline';
|
|
14
14
|
import { generateMsgId } from './dedup.js';
|
|
@@ -45,23 +45,29 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
45
45
|
peer: { kind: 'direct', id: msg.senderId },
|
|
46
46
|
});
|
|
47
47
|
// route: {"agentId":"main","channel":"lightclawbot","accountId":"default","sessionKey":"agent:main:lightclawbot:direct:100013456706","mainSessionKey":"agent:main:main","lastRoutePolicy":"session","matchedBy":"default"}
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
48
|
+
// 框架生成的基础 sessionKey 形如:
|
|
49
|
+
// agent:<agentId>:<channel>:direct:<userId>
|
|
50
|
+
// 当前端带上 chatId(多会话场景)时,需要在末尾追加 `:<chatId>`,
|
|
51
|
+
// 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>,
|
|
52
|
+
const chatIdSuffix = msg?.chatId?.trim();
|
|
53
|
+
const appendChatId = (key) => (chatIdSuffix ? `${key}:${chatIdSuffix}` : key);
|
|
54
|
+
// 用 buildAgentSessionKey 基于 resolvedAgentId 重建 sessionKey 和 agentId,
|
|
55
|
+
// 实现 Agent 间隔离(sessionKey 含 agentId,不同 Agent 的会话历史完全独立)。
|
|
56
|
+
// resolvedAgentId 由 DEFAULT_AGENT_ID 兜底,必定有值,故无需再判空。
|
|
57
|
+
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
|
|
58
|
+
agentId: resolvedAgentId,
|
|
59
|
+
channel: CHANNEL_KEY,
|
|
60
|
+
accountId: baseRoute.accountId,
|
|
61
|
+
peer: { kind: 'direct', id: msg.senderId },
|
|
62
|
+
dmScope: 'per-channel-peer',
|
|
63
|
+
});
|
|
64
|
+
const route = {
|
|
65
|
+
...baseRoute,
|
|
66
|
+
agentId: resolvedAgentId,
|
|
67
|
+
sessionKey: appendChatId(baseSessionKey),
|
|
68
|
+
mainSessionKey: `agent:${resolvedAgentId}:main`,
|
|
69
|
+
};
|
|
70
|
+
log?.info(`[CHANNEL_KEY]: route= ${JSON.stringify(route)}, chatId=${chatIdSuffix ?? '-'}`);
|
|
65
71
|
// ---- 步骤 3:权限检查 ----
|
|
66
72
|
// 根据 allowFrom 白名单和 dmPolicy 策略决定是否允许此用户发送命令
|
|
67
73
|
const commandAuthorized = checkAuth(account.allowFrom, account.dmPolicy, msg.senderId);
|
|
@@ -77,8 +83,16 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
77
83
|
const targetId = msg.senderId;
|
|
78
84
|
// 2. 提前发 typing_start(作为"已读回执",减少 COS 下载/LLM TTFT 期间的无反馈感)
|
|
79
85
|
const replyMsgId = generateMsgId();
|
|
80
|
-
const signalCtx = {
|
|
86
|
+
const signalCtx = {
|
|
87
|
+
emitter,
|
|
88
|
+
targetId,
|
|
89
|
+
replyMsgId,
|
|
90
|
+
originalMsgId: msg.messageId,
|
|
91
|
+
agentId: resolvedAgentId,
|
|
92
|
+
chatId: msg.chatId ?? '',
|
|
93
|
+
};
|
|
81
94
|
emitSignal(signalCtx, 'typing_start');
|
|
95
|
+
// 注:SignalContext.chatId 是上下文字段,由 emitSignal 内部规整到 extra.chatId 输出
|
|
82
96
|
// 3. 处理文件附件(files[] → 本地存储 + 公网 URL)
|
|
83
97
|
const localMediaPaths = [];
|
|
84
98
|
/** 本地文件 MIME 类型列表,与 localMediaPaths 一一对应 */
|
|
@@ -251,7 +265,7 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
251
265
|
log?.error(`[${CHANNEL_KEY}] Dispatch error: ${errMsg}`);
|
|
252
266
|
// 已推过可见内容时不再追加错误文案,避免打断阅读。
|
|
253
267
|
if (!hasEmittedContent()) {
|
|
254
|
-
emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId);
|
|
268
|
+
emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId, msg.chatId);
|
|
255
269
|
}
|
|
256
270
|
}
|
|
257
271
|
};
|