lightclawbot 1.2.6-beta.0 → 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 -517
- 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/chat.js +0 -257
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file group-history-tool.ts
|
|
3
|
+
* @description 群聊历史消息查询工具
|
|
4
|
+
* 提供群聊场景下的历史消息检索能力,支持按时间分页和关键词过滤。
|
|
5
|
+
* 注册为 OpenClaw 插件工具,仅在 lightclawbot 频道的群聊会话中生效。
|
|
6
|
+
*/
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { resolveOpenClawHome } from '../history/session-store.js';
|
|
9
|
+
import { MessageReader } from '../group/storage/message-reader.js';
|
|
10
|
+
import { CHANNEL_KEY } from '../config.js';
|
|
11
|
+
/** 工具注册名称 */
|
|
12
|
+
export const GROUP_HISTORY_TOOL_NAME = 'group_chat_history';
|
|
13
|
+
/** 单条消息内容的最大展示长度,超出部分将被截断 */
|
|
14
|
+
const MAX_CONTENT_LENGTH = 500;
|
|
15
|
+
/** 默认返回的消息条数 */
|
|
16
|
+
const DEFAULT_LIMIT = 20;
|
|
17
|
+
/** 允许返回的最大消息条数 */
|
|
18
|
+
const MAX_LIMIT = 50;
|
|
19
|
+
/** 关键词过滤时的读取倍率,多读取数据以保证过滤后仍有足够结果 */
|
|
20
|
+
const KEYWORD_READ_MULTIPLIER = 3;
|
|
21
|
+
/** 从 sessionKey 中提取 groupId 的正则表达式 */
|
|
22
|
+
const GROUP_ID_REGEX = /:group:(.+)$/;
|
|
23
|
+
/**
|
|
24
|
+
* 群聊历史工具的参数 JSON Schema 定义
|
|
25
|
+
* 用于 Agent 调用时的参数校验和提示
|
|
26
|
+
*/
|
|
27
|
+
export const groupHistoryToolSchema = {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
limit: {
|
|
31
|
+
type: 'number',
|
|
32
|
+
description: '返回的历史消息条数,默认 20,最大 50',
|
|
33
|
+
},
|
|
34
|
+
before: {
|
|
35
|
+
type: 'number',
|
|
36
|
+
description: '时间戳上限(毫秒),只返回此时间之前的消息。不传则返回最新消息',
|
|
37
|
+
},
|
|
38
|
+
keyword: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: '关键词过滤,只返回包含该关键词的消息(大小写不敏感)',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: [],
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* 从 sessionKey 中解析出 groupId
|
|
47
|
+
* @param sessionKey - 会话标识,格式如 `xxx:group:groupId`
|
|
48
|
+
* @returns 解析出的 groupId,无法解析时返回 undefined
|
|
49
|
+
*/
|
|
50
|
+
function parseGroupIdFromSessionKey(sessionKey) {
|
|
51
|
+
if (!sessionKey)
|
|
52
|
+
return undefined;
|
|
53
|
+
return sessionKey.match(GROUP_ID_REGEX)?.[1];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 根据 groupId 解析群聊数据存储目录的绝对路径
|
|
57
|
+
* @param groupId - 群组唯一标识
|
|
58
|
+
* @returns 群聊数据目录路径
|
|
59
|
+
*/
|
|
60
|
+
function resolveGroupDir(groupId) {
|
|
61
|
+
return path.join(resolveOpenClawHome(), 'groups', groupId);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 规范化 limit 参数,确保在 [1, MAX_LIMIT] 范围内
|
|
65
|
+
* @param raw - 原始 limit 值
|
|
66
|
+
* @returns 规范化后的 limit 值
|
|
67
|
+
*/
|
|
68
|
+
function normalizeLimit(raw) {
|
|
69
|
+
return Math.min(Math.max(raw ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 将单条消息记录格式化为可读字符串
|
|
73
|
+
* 格式:[时间] 角色(名称): 内容
|
|
74
|
+
* @param entry - 消息记录条目
|
|
75
|
+
* @returns 格式化后的字符串
|
|
76
|
+
*/
|
|
77
|
+
function formatEntry(entry) {
|
|
78
|
+
const time = new Date(entry.ts).toLocaleString('zh-CN', {
|
|
79
|
+
month: '2-digit',
|
|
80
|
+
day: '2-digit',
|
|
81
|
+
hour: '2-digit',
|
|
82
|
+
minute: '2-digit',
|
|
83
|
+
});
|
|
84
|
+
const role = entry.role === 'user' ? `用户(${entry.userId ?? '未知'})` : `Agent(${entry.agentId ?? '未知'})`;
|
|
85
|
+
let content = (entry.content ?? '').trim();
|
|
86
|
+
if (content.length > MAX_CONTENT_LENGTH) {
|
|
87
|
+
content = content.slice(0, MAX_CONTENT_LENGTH) + '…(已截断)';
|
|
88
|
+
}
|
|
89
|
+
return `[${time}] ${role}: ${content}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 批量格式化消息记录列表
|
|
93
|
+
* @param entries - 消息记录数组
|
|
94
|
+
* @returns 以换行符连接的格式化字符串
|
|
95
|
+
*/
|
|
96
|
+
function formatEntries(entries) {
|
|
97
|
+
return entries.map(formatEntry).join('\n');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 构建查询成功时的返回结果
|
|
101
|
+
* @param entries - 匹配到的消息记录
|
|
102
|
+
* @param hasMore - 是否还有更早的记录可供翻页
|
|
103
|
+
* @returns 标准工具返回结果
|
|
104
|
+
*/
|
|
105
|
+
function buildSuccessResult(entries, hasMore) {
|
|
106
|
+
const formatted = formatEntries(entries);
|
|
107
|
+
const hasMoreHint = hasMore ? '(还有更早的记录,可通过 before 参数翻页)' : '';
|
|
108
|
+
const summary = `共找到 ${entries.length} 条消息${hasMoreHint}:\n\n${formatted}`;
|
|
109
|
+
return { content: [{ type: 'text', text: summary }] };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 构建纯文本的工具返回结果
|
|
113
|
+
* @param text - 要返回的文本内容
|
|
114
|
+
* @returns 标准工具返回结果
|
|
115
|
+
*/
|
|
116
|
+
function buildTextResult(text) {
|
|
117
|
+
return { content: [{ type: 'text', text }] };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 注册群聊历史消息查询工具到 OpenClaw 插件系统
|
|
121
|
+
* 该工具仅在 lightclawbot 频道的群聊会话中可用,
|
|
122
|
+
* 支持按时间分页查询和关键词过滤。
|
|
123
|
+
* @param api - OpenClaw 插件 API 实例
|
|
124
|
+
*/
|
|
125
|
+
export function registerGroupHistoryTool(api) {
|
|
126
|
+
const log = api.logger;
|
|
127
|
+
const reader = new MessageReader();
|
|
128
|
+
api.registerTool((ctx) => {
|
|
129
|
+
// 仅在 lightclawbot 频道生效
|
|
130
|
+
if (ctx.messageChannel !== 'lightclawbot')
|
|
131
|
+
return null;
|
|
132
|
+
// 从 sessionKey 中提取 groupId,非群聊会话则不注册此工具
|
|
133
|
+
const groupId = parseGroupIdFromSessionKey(ctx.sessionKey);
|
|
134
|
+
if (!groupId)
|
|
135
|
+
return null;
|
|
136
|
+
return {
|
|
137
|
+
name: GROUP_HISTORY_TOOL_NAME,
|
|
138
|
+
description: `查询当前群聊的历史消息记录。当你需要了解之前的对话内容、其他 Agent 的回复、用户的历史诉求时,调用此工具。返回按时间排序的对话记录,包含发言者角色、名称和内容。`,
|
|
139
|
+
parameters: groupHistoryToolSchema,
|
|
140
|
+
async execute(_toolCallId, params) {
|
|
141
|
+
const limit = normalizeLimit(params.limit);
|
|
142
|
+
const before = params.before ?? Date.now();
|
|
143
|
+
const keyword = params.keyword?.trim().toLowerCase();
|
|
144
|
+
log?.info?.(`[$${CHANNEL_KEY}:${GROUP_HISTORY_TOOL_NAME}] 开始查询群${groupId}的历史记录`);
|
|
145
|
+
try {
|
|
146
|
+
const groupDir = resolveGroupDir(groupId);
|
|
147
|
+
// 关键词过滤时多读取数据,确保过滤后仍有足够结果
|
|
148
|
+
const readLimit = keyword ? limit * KEYWORD_READ_MULTIPLIER : limit;
|
|
149
|
+
const result = await reader.readTranscriptEntries(groupDir, {
|
|
150
|
+
limit: readLimit,
|
|
151
|
+
before,
|
|
152
|
+
chatOnly: true,
|
|
153
|
+
});
|
|
154
|
+
let entries = result.entries;
|
|
155
|
+
// 按关键词过滤并截取指定条数
|
|
156
|
+
if (keyword) {
|
|
157
|
+
entries = entries.filter((e) => e.content?.toLowerCase().includes(keyword)).slice(0, limit);
|
|
158
|
+
}
|
|
159
|
+
if (entries.length === 0) {
|
|
160
|
+
return buildTextResult('未找到匹配的历史消息。');
|
|
161
|
+
}
|
|
162
|
+
return buildSuccessResult(entries, result.hasMore);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
166
|
+
log?.error?.(`[$${CHANNEL_KEY}: ${GROUP_HISTORY_TOOL_NAME}] 查询失败: ${msg}`);
|
|
167
|
+
return buildTextResult(`查询群聊历史失败: ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}, { name: GROUP_HISTORY_TOOL_NAME });
|
|
172
|
+
}
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fsp from "node:fs/promises";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import { formatFileSize } from "
|
|
17
|
-
import { LOCALFILE_SCHEME } from "
|
|
16
|
+
import { formatFileSize } from "../media.js";
|
|
17
|
+
import { LOCALFILE_SCHEME } from "../config.js";
|
|
18
18
|
// ============================================================
|
|
19
19
|
// 工具参数 schema(JSON Schema 格式)
|
|
20
20
|
// ============================================================
|
package/dist/src/tools.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* 在 defineChannelPluginEntry 的 registerFull 回调中注册所有 Agent Tools。
|
|
5
5
|
* 此模块仅在插件完全激活后加载,避免 setup-only 模式拉起不必要的依赖。
|
|
6
6
|
*/
|
|
7
|
-
import { registerUploadTool } from './upload-tool.js';
|
|
8
|
-
|
|
7
|
+
import { registerUploadTool } from './tools/upload-tool.js';
|
|
8
|
+
import { registerGroupHistoryTool } from './tools/group-history-tool.js';
|
|
9
9
|
export function registerLightclawTools(api) {
|
|
10
10
|
// 注册文件存储工具(飞书工厂函数模式:execute 时动态从 api.config 解析 apiKey)
|
|
11
11
|
registerUploadTool(api);
|
|
12
|
-
//
|
|
12
|
+
// 注册群聊历史查询工具(群聊场景下 Agent 可主动查询历史记录)
|
|
13
|
+
registerGroupHistoryTool(api);
|
|
13
14
|
}
|
package/dist/src/utils/index.js
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getLightclawRuntime } from '../runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* 获取模块专属日志器(惰性代理)。
|
|
4
|
+
*
|
|
5
|
+
* 返回一个 Proxy 对象,仅在实际调用日志方法(info/warn/error/debug 等)时
|
|
6
|
+
* 才通过 getLightclawRuntime() 获取真实 logger 实例并转发调用。
|
|
7
|
+
* 这样即使在模块顶层 `const log = getModuleLogger('xxx')` 也不会触发
|
|
8
|
+
* runtime 未初始化的异常。
|
|
9
|
+
*
|
|
10
|
+
* @param module 模块名称(如 'group.inbound'、'outbound')
|
|
11
|
+
* @returns 带模块标签的惰性日志器代理
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* // 可安全地在模块顶层调用
|
|
16
|
+
* const log = getModuleLogger('group.orchestrator');
|
|
17
|
+
* // 实际使用时才会初始化 runtime
|
|
18
|
+
* log.info('调度开始');
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function getModuleLogger(module) {
|
|
22
|
+
let cached = null;
|
|
23
|
+
/** 获取或缓存真实 logger 实例 */
|
|
24
|
+
const resolve = () => {
|
|
25
|
+
if (!cached) {
|
|
26
|
+
cached = getLightclawRuntime().logging.getChildLogger({ module });
|
|
27
|
+
}
|
|
28
|
+
return cached;
|
|
29
|
+
};
|
|
30
|
+
return new Proxy({}, {
|
|
31
|
+
get(_target, prop, receiver) {
|
|
32
|
+
const real = resolve();
|
|
33
|
+
const value = Reflect.get(real, prop, receiver);
|
|
34
|
+
// 如果是函数则绑定到真实 logger 上下文
|
|
35
|
+
return typeof value === 'function' ? value.bind(real) : value;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/dist/src/socket/chat.js
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
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
|
-
}
|