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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +277 -50
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-group-memory",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "智能群聊记忆中间件:自动记录上下文,在@或回复时注入前情提要,支持时间窗口过滤与内容清洗",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
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 = '自动记录群聊上下文,并在用户@机器人或回复机器人时,将前情提要注入给 AI 插件';
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
- // --- 修复:移除所有可能导致错误的 UI 方法 ---
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 groupChats = new Map();
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
- const groupId = session.groupId;
94
- if (!groupChats.has(groupId)) {
95
- groupChats.set(groupId, []);
96
- }
237
+ // 忽略命令消息
238
+ if (/^[./\\]/.test(trimmed)) return next();
97
239
 
98
- const history = groupChats.get(groupId);
99
- const now = Date.now();
240
+ const { sessionId, sessionType } = generateSessionId(session);
100
241
 
101
- const cleanText = cleanContent(session.content, session);
242
+ // 根据配置决定是否记录
243
+ if (sessionType === 'private' && !config.recordPrivateChat) return next();
244
+ if (sessionType === 'group' && !config.recordGroupChat) return next();
102
245
 
103
- history.push({
104
- user: session.author?.nickname || session.userId,
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
- while (history.length > config.maxContext) {
111
- history.shift();
112
- }
249
+ // 保存到数据库
250
+ await saveMessage(sessionId, 'user', cleanText, session.userId, userName);
113
251
 
114
- if (config.timeWindow > 0) {
115
- const cutoff = now - config.timeWindow;
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 groupId = session.groupId;
144
- const history = groupChats.get(groupId) || [];
282
+ const { sessionId } = generateSessionId(session);
283
+ const history = await getContext(sessionId);
145
284
 
146
285
  if (history.length === 0) return next();
147
286
 
148
- // --- 修复:将 prefixTemplate 的逻辑移回 apply 函数内部 ---
149
- // 由于 Schema 无法处理函数类型的配置,我们将模板逻辑放在这里
150
- const contextLines = history.map(msg => `${msg.timeStr} ${msg.user}: ${msg.content}`);
151
- const contextPrefix = `【群聊前情提要】\n${contextLines.join('\n')}\n---\n请结合以上上下文回答以下问题:`;
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
- console.log(`[GroupMemory] ${groupId} 触发上下文注入。历史条数: ${history.length}`);
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
  };