koishi-plugin-group-memory 1.1.1 → 1.2.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/package.json +1 -1
- package/src/index.js +277 -50
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
const { Schema } = require('koishi');
|
|
2
2
|
|
|
3
3
|
exports.name = 'group-memory';
|
|
4
|
-
exports.usage = '
|
|
4
|
+
exports.usage = '自动记录群聊和私聊上下文,并为 AI 插件提供历史记录查询接口。支持数据库存储,群聊共享上下文,私聊独立上下文。';
|
|
5
|
+
|
|
6
|
+
// --- 数据库表结构 ---
|
|
7
|
+
const MessageSchema = Schema.object({
|
|
8
|
+
id: Schema.number().role('primary').required(),
|
|
9
|
+
session_id: Schema.string().required(), // 会话ID:private:{userId} 或 group:{groupId}
|
|
10
|
+
session_type: Schema.string().required(), // 'private' 或 'group'
|
|
11
|
+
user_id: Schema.string().required(),
|
|
12
|
+
user_name: Schema.string().default(''),
|
|
13
|
+
role: Schema.string().required(), // 'user' 或 'assistant'
|
|
14
|
+
content: Schema.string().required(),
|
|
15
|
+
timestamp: Schema.date().required(),
|
|
16
|
+
});
|
|
5
17
|
|
|
6
|
-
// ---
|
|
18
|
+
// --- 配置架构 ---
|
|
7
19
|
exports.Config = Schema.object({
|
|
8
|
-
// ---
|
|
20
|
+
// --- 存储设置 ---
|
|
9
21
|
maxContext: Schema.number()
|
|
10
22
|
.default(20)
|
|
11
23
|
.min(1)
|
|
12
24
|
.max(100)
|
|
13
|
-
.description('
|
|
25
|
+
.description('每个会话保留最近多少条消息作为上下文。'),
|
|
14
26
|
|
|
15
27
|
timeWindow: Schema.number()
|
|
16
28
|
.default(3600000) // 1小时
|
|
@@ -18,7 +30,16 @@ exports.Config = Schema.object({
|
|
|
18
30
|
.step(60000) // 1分钟步进
|
|
19
31
|
.description('只保留最近 N 毫秒内的消息。设置为 0 表示不限制时间,只按条数限制。'),
|
|
20
32
|
|
|
21
|
-
// ---
|
|
33
|
+
// --- 记录设置 ---
|
|
34
|
+
recordPrivateChat: Schema.boolean()
|
|
35
|
+
.default(true)
|
|
36
|
+
.description('是否记录私聊消息。'),
|
|
37
|
+
|
|
38
|
+
recordGroupChat: Schema.boolean()
|
|
39
|
+
.default(true)
|
|
40
|
+
.description('是否记录群聊消息。'),
|
|
41
|
+
|
|
42
|
+
// --- 触发条件(用于中间件模式) ---
|
|
22
43
|
triggerOnAt: Schema.boolean()
|
|
23
44
|
.default(true)
|
|
24
45
|
.description('当消息中包含 @机器人 时,触发上下文注入。'),
|
|
@@ -27,11 +48,6 @@ exports.Config = Schema.object({
|
|
|
27
48
|
.default(true)
|
|
28
49
|
.description('当消息是回复机器人的消息时,触发上下文注入。'),
|
|
29
50
|
|
|
30
|
-
// --- 适配设置 ---
|
|
31
|
-
targetPlugins: Schema.array(String)
|
|
32
|
-
.default([])
|
|
33
|
-
.description('目标 AI 插件的名称列表。留空则对所有满足条件的消息生效。'),
|
|
34
|
-
|
|
35
51
|
// --- 内容处理 ---
|
|
36
52
|
cleanMentions: Schema.boolean()
|
|
37
53
|
.default(true)
|
|
@@ -41,30 +57,46 @@ exports.Config = Schema.object({
|
|
|
41
57
|
.default(true)
|
|
42
58
|
.description('是否移除图片/媒体占位符 (如 [图片])。'),
|
|
43
59
|
|
|
44
|
-
// prefixTemplate: Schema.string() // 这个配置项比较复杂,不适合用 Schema.string() 来定义
|
|
45
|
-
// .default(...)
|
|
46
|
-
// .textarea() // 导致错误
|
|
47
|
-
// .rows(5) // 导致错误
|
|
48
|
-
// ...
|
|
49
|
-
// 我们将其移除,让其成为一个纯粹的代码逻辑,默认值直接在 apply 函数中硬编码。
|
|
50
|
-
|
|
51
60
|
// --- 调试 ---
|
|
52
61
|
verbose: Schema.boolean()
|
|
53
62
|
.default(false)
|
|
54
63
|
.description('是否在控制台打印注入的上下文 (调试用)。'),
|
|
55
64
|
});
|
|
56
65
|
|
|
57
|
-
// --- 关键:显式导出 schema ---
|
|
58
66
|
exports.schema = exports.Config;
|
|
59
67
|
|
|
68
|
+
// --- 声明依赖注入 ---
|
|
69
|
+
exports.inject = ['database'];
|
|
70
|
+
|
|
60
71
|
exports.apply = (ctx, config) => {
|
|
61
|
-
const
|
|
72
|
+
const logger = ctx.logger('group-memory');
|
|
73
|
+
|
|
74
|
+
// 内存缓存(用于快速访问,数据库持久化)
|
|
75
|
+
const memoryCache = new Map();
|
|
76
|
+
|
|
77
|
+
// 确保数据库表存在
|
|
78
|
+
ctx.database.extend('group_memory_messages', MessageSchema, {
|
|
79
|
+
autoInc: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- 辅助函数:生成会话ID ---
|
|
83
|
+
// 私聊: private:{userId}
|
|
84
|
+
// 群聊: group:{groupId}(所有人共享)
|
|
85
|
+
const generateSessionId = (session) => {
|
|
86
|
+
if (!session.groupId) {
|
|
87
|
+
return { sessionId: `private:${session.userId}`, sessionType: 'private' };
|
|
88
|
+
} else {
|
|
89
|
+
return { sessionId: `group:${session.groupId}`, sessionType: 'group' };
|
|
90
|
+
}
|
|
91
|
+
};
|
|
62
92
|
|
|
93
|
+
// --- 辅助函数:格式化时间 ---
|
|
63
94
|
const formatTime = (timestamp) => {
|
|
64
95
|
const date = new Date(timestamp);
|
|
65
96
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
|
66
97
|
};
|
|
67
98
|
|
|
99
|
+
// --- 辅助函数:清理内容 ---
|
|
68
100
|
const cleanContent = (content, session) => {
|
|
69
101
|
let text = content;
|
|
70
102
|
|
|
@@ -82,56 +114,163 @@ exports.apply = (ctx, config) => {
|
|
|
82
114
|
return text.trim();
|
|
83
115
|
};
|
|
84
116
|
|
|
117
|
+
// --- 核心 API:获取上下文(供其他插件调用) ---
|
|
118
|
+
const getContext = async (sessionId, limit = null) => {
|
|
119
|
+
const maxMessages = limit ? limit * 2 : config.maxContext * 2;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// 从数据库获取
|
|
123
|
+
let history = await ctx.database.get('group_memory_messages',
|
|
124
|
+
{ session_id: sessionId },
|
|
125
|
+
{
|
|
126
|
+
fields: ['id', 'session_id', 'role', 'content', 'timestamp', 'user_name'],
|
|
127
|
+
orderBy: { id: 'desc' },
|
|
128
|
+
limit: maxMessages
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// 反转数组以恢复时间顺序
|
|
133
|
+
history.reverse();
|
|
134
|
+
|
|
135
|
+
// 如果时间窗口设置,进行过滤
|
|
136
|
+
if (config.timeWindow > 0) {
|
|
137
|
+
const cutoff = Date.now() - config.timeWindow;
|
|
138
|
+
history = history.filter(msg => new Date(msg.timestamp).getTime() >= cutoff);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return history;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
logger.error('获取上下文失败:', error.message);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// --- 核心 API:保存消息(供其他插件调用) ---
|
|
149
|
+
const saveMessage = async (sessionId, role, content, userId = 'system', userName = 'System') => {
|
|
150
|
+
try {
|
|
151
|
+
// 解析 sessionId 获取 sessionType
|
|
152
|
+
const sessionType = sessionId.startsWith('private:') ? 'private' : 'group';
|
|
153
|
+
|
|
154
|
+
// 保存到数据库
|
|
155
|
+
await ctx.database.create('group_memory_messages', {
|
|
156
|
+
session_id: sessionId,
|
|
157
|
+
session_type: sessionType,
|
|
158
|
+
user_id: userId,
|
|
159
|
+
user_name: userName,
|
|
160
|
+
role: role,
|
|
161
|
+
content: content,
|
|
162
|
+
timestamp: new Date(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// 清理旧消息
|
|
166
|
+
await cleanupOldMessages(sessionId);
|
|
167
|
+
|
|
168
|
+
return true;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.error('保存消息失败:', error.message);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// --- 核心 API:清空上下文(供其他插件调用) ---
|
|
176
|
+
const clearContext = async (sessionId) => {
|
|
177
|
+
try {
|
|
178
|
+
await ctx.database.remove('group_memory_messages', { session_id: sessionId });
|
|
179
|
+
memoryCache.delete(sessionId);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.error('清空上下文失败:', error.message);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// --- 辅助函数:清理旧消息 ---
|
|
188
|
+
const cleanupOldMessages = async (sessionId) => {
|
|
189
|
+
try {
|
|
190
|
+
// 获取当前消息数量
|
|
191
|
+
const allMessages = await ctx.database.get('group_memory_messages',
|
|
192
|
+
{ session_id: sessionId },
|
|
193
|
+
{ fields: ['id', 'timestamp'] }
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const maxMessages = config.maxContext * 2;
|
|
197
|
+
|
|
198
|
+
// 如果超过最大数量,删除最旧的消息
|
|
199
|
+
if (allMessages.length > maxMessages) {
|
|
200
|
+
// 按 ID 排序(ID 是自增的,代表时间顺序)
|
|
201
|
+
allMessages.sort((a, b) => a.id - b.id);
|
|
202
|
+
const toDelete = allMessages.slice(0, allMessages.length - maxMessages);
|
|
203
|
+
for (const msg of toDelete) {
|
|
204
|
+
await ctx.database.remove('group_memory_messages', { id: msg.id });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 如果时间窗口设置,删除过期的消息
|
|
209
|
+
if (config.timeWindow > 0) {
|
|
210
|
+
const cutoff = Date.now() - config.timeWindow;
|
|
211
|
+
const expiredMessages = allMessages.filter(msg =>
|
|
212
|
+
new Date(msg.timestamp).getTime() < cutoff
|
|
213
|
+
);
|
|
214
|
+
for (const msg of expiredMessages) {
|
|
215
|
+
await ctx.database.remove('group_memory_messages', { id: msg.id });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
logger.error('清理旧消息失败:', error.message);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// --- 将 API 暴露给其他插件 ---
|
|
224
|
+
ctx['group-memory'] = {
|
|
225
|
+
getContext,
|
|
226
|
+
saveMessage,
|
|
227
|
+
clearContext,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// --- 中间件1: 记录所有消息 ---
|
|
85
231
|
ctx.middleware(async (session, next) => {
|
|
86
|
-
if (!session.groupId) return next();
|
|
87
232
|
if (session.selfId === session.userId) return next();
|
|
88
233
|
|
|
89
234
|
const trimmed = session.content?.trim() || '';
|
|
90
|
-
if (/^[./\\]/.test(trimmed)) return next();
|
|
91
235
|
if (!trimmed) return next();
|
|
92
236
|
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
groupChats.set(groupId, []);
|
|
96
|
-
}
|
|
237
|
+
// 忽略命令消息
|
|
238
|
+
if (/^[./\\]/.test(trimmed)) return next();
|
|
97
239
|
|
|
98
|
-
const
|
|
99
|
-
const now = Date.now();
|
|
240
|
+
const { sessionId, sessionType } = generateSessionId(session);
|
|
100
241
|
|
|
101
|
-
|
|
242
|
+
// 根据配置决定是否记录
|
|
243
|
+
if (sessionType === 'private' && !config.recordPrivateChat) return next();
|
|
244
|
+
if (sessionType === 'group' && !config.recordGroupChat) return next();
|
|
102
245
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
content: cleanText,
|
|
106
|
-
time: now,
|
|
107
|
-
timeStr: formatTime(now)
|
|
108
|
-
});
|
|
246
|
+
const cleanText = cleanContent(session.content, session);
|
|
247
|
+
const userName = session.author?.nickname || session.userId;
|
|
109
248
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
249
|
+
// 保存到数据库
|
|
250
|
+
await saveMessage(sessionId, 'user', cleanText, session.userId, userName);
|
|
113
251
|
|
|
114
|
-
if (config.
|
|
115
|
-
|
|
116
|
-
while (history.length > 0 && history[0].time < cutoff) {
|
|
117
|
-
history.shift();
|
|
118
|
-
}
|
|
252
|
+
if (config.verbose) {
|
|
253
|
+
logger.info(`记录消息 [${sessionType}] ${userName}: ${cleanText.substring(0, 50)}...`);
|
|
119
254
|
}
|
|
120
255
|
|
|
121
256
|
return next();
|
|
122
257
|
}, true);
|
|
123
258
|
|
|
259
|
+
// --- 中间件2: 上下文注入(可选功能) ---
|
|
124
260
|
ctx.middleware(async (session, next) => {
|
|
125
|
-
if (!session.groupId) return next();
|
|
261
|
+
if (!session.groupId && !config.recordPrivateChat) return next();
|
|
262
|
+
if (session.groupId && !config.recordGroupChat) return next();
|
|
126
263
|
|
|
127
264
|
let shouldInject = false;
|
|
128
265
|
|
|
266
|
+
// 检查是否 @ 机器人
|
|
129
267
|
if (config.triggerOnAt) {
|
|
130
268
|
const isAtBot = session.parsed?.bots?.includes(session.selfId) ||
|
|
131
269
|
(session.content && new RegExp(`<@${session.selfId}>|@${session.selfId}`).test(session.content));
|
|
132
270
|
if (isAtBot) shouldInject = true;
|
|
133
271
|
}
|
|
134
272
|
|
|
273
|
+
// 检查是否回复机器人
|
|
135
274
|
if (!shouldInject && config.triggerOnReply && session.quote) {
|
|
136
275
|
if (session.quote.userId === session.selfId) {
|
|
137
276
|
shouldInject = true;
|
|
@@ -140,15 +279,18 @@ exports.apply = (ctx, config) => {
|
|
|
140
279
|
|
|
141
280
|
if (!shouldInject) return next();
|
|
142
281
|
|
|
143
|
-
const
|
|
144
|
-
const history =
|
|
282
|
+
const { sessionId } = generateSessionId(session);
|
|
283
|
+
const history = await getContext(sessionId);
|
|
145
284
|
|
|
146
285
|
if (history.length === 0) return next();
|
|
147
286
|
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
287
|
+
// 构建上下文前缀
|
|
288
|
+
const contextLines = history.map(msg => {
|
|
289
|
+
const timeStr = formatTime(msg.timestamp);
|
|
290
|
+
const userName = msg.user_name || 'Unknown';
|
|
291
|
+
return `${timeStr} ${userName}: ${msg.content}`;
|
|
292
|
+
});
|
|
293
|
+
const contextPrefix = `【对话前情提要】\n${contextLines.join('\n')}\n---\n请结合以上上下文回答以下问题:`;
|
|
152
294
|
|
|
153
295
|
let originalContent = session.content || '';
|
|
154
296
|
|
|
@@ -158,12 +300,97 @@ exports.apply = (ctx, config) => {
|
|
|
158
300
|
.trim();
|
|
159
301
|
}
|
|
160
302
|
|
|
303
|
+
// 修改 session.content,添加上下文前缀
|
|
161
304
|
session.content = `${contextPrefix}\n${originalContent}`;
|
|
162
305
|
|
|
163
306
|
if (config.verbose) {
|
|
164
|
-
|
|
307
|
+
logger.info(`[GroupMemory] ${sessionId} 触发上下文注入。历史条数: ${history.length}`);
|
|
165
308
|
}
|
|
166
309
|
|
|
167
310
|
return next();
|
|
168
311
|
}, 100);
|
|
312
|
+
|
|
313
|
+
// --- 命令:查看当前会话的上下文 ---
|
|
314
|
+
ctx.command('memory-view [count]', '查看当前会话的历史记录')
|
|
315
|
+
.alias('mem-view')
|
|
316
|
+
.action(async ({ session }, count = 5) => {
|
|
317
|
+
const { sessionId, sessionType } = generateSessionId(session);
|
|
318
|
+
const history = await getContext(sessionId, parseInt(count));
|
|
319
|
+
|
|
320
|
+
if (history.length === 0) {
|
|
321
|
+
return `📭 当前${sessionType === 'private' ? '私聊' : '群聊'}暂无历史记录。`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const lines = history.map((msg, index) => {
|
|
325
|
+
const timeStr = formatTime(msg.timestamp);
|
|
326
|
+
const role = msg.role === 'user' ? '👤' : '🤖';
|
|
327
|
+
const userName = msg.user_name || 'Unknown';
|
|
328
|
+
const content = msg.content.substring(0, 50) + (msg.content.length > 50 ? '...' : '');
|
|
329
|
+
return `${index + 1}. ${timeStr} ${role} ${userName}: ${content}`;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return `📜 ${sessionType === 'private' ? '私聊' : '群聊'}历史记录 (最近${history.length}条):\n${lines.join('\n')}`;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// --- 命令:清空当前会话的上下文 ---
|
|
336
|
+
ctx.command('memory-clear', '清空当前会话的历史记录')
|
|
337
|
+
.alias('mem-clear')
|
|
338
|
+
.action(async ({ session }) => {
|
|
339
|
+
const { sessionId, sessionType } = generateSessionId(session);
|
|
340
|
+
const result = await clearContext(sessionId);
|
|
341
|
+
|
|
342
|
+
if (result) {
|
|
343
|
+
return `✅ 已清空当前${sessionType === 'private' ? '私聊' : '群聊'}的对话记忆!`;
|
|
344
|
+
} else {
|
|
345
|
+
return '❌ 清空记忆失败,请查看日志。';
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// --- 命令:查看插件状态 ---
|
|
350
|
+
ctx.command('memory-status', '查看 memory 插件状态')
|
|
351
|
+
.alias('mem-status')
|
|
352
|
+
.action(async () => {
|
|
353
|
+
try {
|
|
354
|
+
const allMessages = await ctx.database.get('group_memory_messages', {}, { fields: ['id'] });
|
|
355
|
+
const privateCount = await ctx.database.get('group_memory_messages', { session_type: 'private' }, { fields: ['id'] });
|
|
356
|
+
const groupCount = await ctx.database.get('group_memory_messages', { session_type: 'group' }, { fields: ['id'] });
|
|
357
|
+
|
|
358
|
+
return `🧠 Group Memory 插件状态\n` +
|
|
359
|
+
`总消息数: ${allMessages.length}\n` +
|
|
360
|
+
`私聊消息: ${privateCount.length}\n` +
|
|
361
|
+
`群聊消息: ${groupCount.length}\n` +
|
|
362
|
+
`最大上下文: ${config.maxContext} 轮\n` +
|
|
363
|
+
`时间窗口: ${config.timeWindow > 0 ? config.timeWindow / 60000 + ' 分钟' : '无限制'}`;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return `❌ 获取状态失败: ${error.message}`;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// --- 命令:清理过期消息 ---
|
|
370
|
+
ctx.command('memory-cleanup', '清理所有过期的历史记录')
|
|
371
|
+
.alias('mem-cleanup')
|
|
372
|
+
.action(async () => {
|
|
373
|
+
if (config.timeWindow === 0) {
|
|
374
|
+
return '⚠️ 时间窗口设置为 0,不会清理任何消息。';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const allMessages = await ctx.database.get('group_memory_messages', {}, { fields: ['id', 'timestamp'] });
|
|
379
|
+
const cutoff = Date.now() - config.timeWindow;
|
|
380
|
+
let deletedCount = 0;
|
|
381
|
+
|
|
382
|
+
for (const msg of allMessages) {
|
|
383
|
+
if (new Date(msg.timestamp).getTime() < cutoff) {
|
|
384
|
+
await ctx.database.remove('group_memory_messages', { id: msg.id });
|
|
385
|
+
deletedCount++;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return `🧹 清理完成,删除了 ${deletedCount} 条过期消息。`;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return `❌ 清理失败: ${error.message}`;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
logger.info('Group Memory 插件已加载');
|
|
169
396
|
};
|