mioku-plugin-chat 2.0.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/db.ts ADDED
@@ -0,0 +1,849 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import * as path from "path";
5
+ import type {
6
+ SessionMeta,
7
+ ChatMessage,
8
+ TopicRecord,
9
+ ExpressionRecord,
10
+ ImageRecord,
11
+ MediaSummaryRecord,
12
+ } from "./types";
13
+
14
+ /**
15
+ * 聊天数据库接口
16
+ */
17
+ export interface ChatDatabase {
18
+ saveSession(meta: SessionMeta): void;
19
+ getSession(id: string): SessionMeta | null;
20
+ saveMessage(msg: ChatMessage): void;
21
+ getMessages(
22
+ sessionId: string,
23
+ limit?: number,
24
+ before?: number,
25
+ ): ChatMessage[];
26
+ // 获取 bot 发送的消息
27
+ getBotMessages(groupId: number, limit?: number): ChatMessage[];
28
+ getStoredGroupNoticeMessages(groupId: number, limit?: number): ChatMessage[];
29
+ getMessagesByUser(
30
+ userId: number,
31
+ sessionId?: string,
32
+ limit?: number,
33
+ ): ChatMessage[];
34
+ getAllMessagesByUser(userId: number, sessionId?: string): ChatMessage[];
35
+ getMessagesByTimeRange(
36
+ sessionId: string,
37
+ startTimestamp: number,
38
+ endTimestamp: number,
39
+ ): ChatMessage[];
40
+ searchMessages(
41
+ sessionId: string,
42
+ keyword: string,
43
+ limit?: number,
44
+ ): ChatMessage[];
45
+ updateCompressedContext(sessionId: string, context: string): void;
46
+ deleteSessionMessages(sessionId: string): void;
47
+ // 删除 bot 发送的消息
48
+ deleteBotMessages(sessionId: string): void;
49
+ // 话题
50
+ saveTopic(topic: TopicRecord): number;
51
+ getTopics(sessionId: string, limit?: number): TopicRecord[];
52
+ getTopicByWindow(
53
+ sessionId: string,
54
+ windowStartAt: number,
55
+ windowEndAt: number,
56
+ ): TopicRecord | null;
57
+ updateTopic(
58
+ id: number,
59
+ updates: Partial<
60
+ Pick<TopicRecord, "summary" | "keywords" | "messageCount" | "updatedAt">
61
+ >,
62
+ ): void;
63
+ // 表达学习
64
+ saveExpression(expr: ExpressionRecord): void;
65
+ getExpressions(sessionId: string, limit?: number): ExpressionRecord[];
66
+ getExpressionsByUser(userId: number, limit?: number): ExpressionRecord[];
67
+ replaceExpressionsByUser(
68
+ userId: number,
69
+ userName: string,
70
+ expressions: Array<
71
+ Pick<ExpressionRecord, "situation" | "style" | "example">
72
+ >,
73
+ ): void;
74
+ getExpressionCount(sessionId: string): number;
75
+ deleteOldestExpressions(sessionId: string, keepCount: number): void;
76
+ // 图片记录
77
+ saveImage(image: ImageRecord): void;
78
+ getImageByHash(hash: string): ImageRecord | null;
79
+ getImageByUrl(url: string): ImageRecord | null;
80
+ getAllImages(): ImageRecord[];
81
+ // 媒体/卡片摘要缓存
82
+ saveMediaSummary(summary: MediaSummaryRecord): void;
83
+ getMediaSummary(key: string): MediaSummaryRecord | null;
84
+ saveMediaSummarySource(sourceKey: string, summaryKey: string): void;
85
+ getMediaSummaryBySource(sourceKey: string): MediaSummaryRecord | null;
86
+ close(): void;
87
+ }
88
+
89
+ /**
90
+ * SQLite 数据库实现
91
+ */
92
+ export async function initDatabase(): Promise<ChatDatabase> {
93
+ const dbDir = path.join(process.cwd(), "data", "chat");
94
+ if (!existsSync(dbDir)) {
95
+ await fs.mkdir(dbDir, { recursive: true });
96
+ }
97
+
98
+ const dbPath = path.join(dbDir, "chat.db");
99
+ const db = new Database(dbPath);
100
+
101
+ // 开启 WAL 模式提升并发性能
102
+ db.exec("PRAGMA journal_mode = WAL");
103
+
104
+ // 创建表
105
+ db.exec(`
106
+ CREATE TABLE IF NOT EXISTS sessions (
107
+ id TEXT PRIMARY KEY,
108
+ type TEXT NOT NULL,
109
+ target_id INTEGER NOT NULL,
110
+ created_at INTEGER NOT NULL,
111
+ updated_at INTEGER NOT NULL,
112
+ compressed_context TEXT
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS messages (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ session_id TEXT NOT NULL,
118
+ role TEXT NOT NULL,
119
+ content TEXT NOT NULL,
120
+ user_id INTEGER,
121
+ user_name TEXT,
122
+ user_role TEXT,
123
+ user_title TEXT,
124
+ group_id INTEGER,
125
+ group_name TEXT,
126
+ timestamp INTEGER NOT NULL,
127
+ message_id INTEGER
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
131
+ CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id, timestamp);
132
+ CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(session_id, content);
133
+
134
+ CREATE TABLE IF NOT EXISTS topics (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ session_id TEXT NOT NULL,
137
+ title TEXT NOT NULL,
138
+ keywords TEXT NOT NULL DEFAULT '[]',
139
+ summary TEXT NOT NULL,
140
+ message_count INTEGER NOT NULL DEFAULT 0,
141
+ window_start_at INTEGER,
142
+ window_end_at INTEGER,
143
+ created_at INTEGER NOT NULL,
144
+ updated_at INTEGER NOT NULL
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_topics_session ON topics(session_id, updated_at);
147
+
148
+ CREATE TABLE IF NOT EXISTS expressions (
149
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
150
+ session_id TEXT NOT NULL,
151
+ user_id INTEGER NOT NULL,
152
+ user_name TEXT NOT NULL,
153
+ situation TEXT NOT NULL,
154
+ style TEXT NOT NULL,
155
+ example TEXT NOT NULL,
156
+ created_at INTEGER NOT NULL
157
+ );
158
+ CREATE INDEX IF NOT EXISTS idx_expressions_session ON expressions(session_id, created_at);
159
+ CREATE INDEX IF NOT EXISTS idx_expressions_user ON expressions(user_id, created_at);
160
+
161
+ CREATE TABLE IF NOT EXISTS images (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ hash TEXT NOT NULL UNIQUE,
164
+ url TEXT NOT NULL,
165
+ type TEXT NOT NULL,
166
+ description TEXT NOT NULL,
167
+ emotion TEXT,
168
+ character TEXT,
169
+ file_path TEXT,
170
+ created_at INTEGER NOT NULL
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_images_hash ON images(hash);
173
+ CREATE INDEX IF NOT EXISTS idx_images_type ON images(type);
174
+
175
+ CREATE TABLE IF NOT EXISTS media_summaries (
176
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
177
+ key TEXT NOT NULL UNIQUE,
178
+ kind TEXT NOT NULL,
179
+ source TEXT NOT NULL,
180
+ summary TEXT NOT NULL,
181
+ created_at INTEGER NOT NULL
182
+ );
183
+ CREATE INDEX IF NOT EXISTS idx_media_summaries_key ON media_summaries(key);
184
+ CREATE INDEX IF NOT EXISTS idx_media_summaries_kind ON media_summaries(kind);
185
+
186
+ CREATE TABLE IF NOT EXISTS media_summary_sources (
187
+ source_key TEXT PRIMARY KEY,
188
+ summary_key TEXT NOT NULL,
189
+ created_at INTEGER NOT NULL
190
+ );
191
+ CREATE INDEX IF NOT EXISTS idx_media_summary_sources_summary ON media_summary_sources(summary_key);
192
+ `);
193
+
194
+ const topicColumns = db
195
+ .prepare("PRAGMA table_info(topics)")
196
+ .all() as Array<{ name: string }>;
197
+ const hasWindowStartAt = topicColumns.some(
198
+ (column) => column.name === "window_start_at",
199
+ );
200
+ const hasWindowEndAt = topicColumns.some(
201
+ (column) => column.name === "window_end_at",
202
+ );
203
+ if (!hasWindowStartAt) {
204
+ db.exec("ALTER TABLE topics ADD COLUMN window_start_at INTEGER");
205
+ }
206
+ if (!hasWindowEndAt) {
207
+ db.exec("ALTER TABLE topics ADD COLUMN window_end_at INTEGER");
208
+ }
209
+ db.exec(
210
+ "CREATE INDEX IF NOT EXISTS idx_topics_session_window ON topics(session_id, window_end_at)",
211
+ );
212
+
213
+ // 预编译语句
214
+ const stmts = {
215
+ upsertSession: db.prepare(`
216
+ INSERT INTO sessions (id, type, target_id, created_at, updated_at, compressed_context)
217
+ VALUES ($id, $type, $targetId, $createdAt, $updatedAt, $compressedContext)
218
+ ON CONFLICT(id) DO UPDATE SET
219
+ updated_at = $updatedAt,
220
+ compressed_context = COALESCE($compressedContext, compressed_context)
221
+ `),
222
+ getSession: db.prepare(`SELECT * FROM sessions WHERE id = $id`),
223
+ insertMessage: db.prepare(`
224
+ INSERT INTO messages (session_id, role, content, user_id, user_name, user_role, user_title, group_id, group_name, timestamp, message_id)
225
+ VALUES ($sessionId, $role, $content, $userId, $userName, $userRole, $userTitle, $groupId, $groupName, $timestamp, $messageId)
226
+ `),
227
+ getMessages: db.prepare(`
228
+ SELECT * FROM messages WHERE session_id = $sessionId ORDER BY timestamp DESC, id DESC LIMIT $limit
229
+ `),
230
+ getMessagesBefore: db.prepare(`
231
+ SELECT * FROM messages WHERE session_id = $sessionId AND timestamp < $timestamp ORDER BY timestamp DESC, id DESC LIMIT $limit
232
+ `),
233
+ getBotMessages: db.prepare(`
234
+ SELECT * FROM messages WHERE group_id = $groupId AND role = 'assistant' ORDER BY timestamp DESC LIMIT $limit
235
+ `),
236
+ getStoredGroupNoticeMessages: db.prepare(`
237
+ SELECT * FROM messages
238
+ WHERE group_id = $groupId AND role = 'user' AND content LIKE '发布了一条群公告:%'
239
+ ORDER BY timestamp DESC LIMIT $limit
240
+ `),
241
+ getMessagesByUser: db.prepare(`
242
+ SELECT * FROM messages WHERE user_id = $userId ORDER BY timestamp DESC LIMIT $limit
243
+ `),
244
+ getAllMessagesByUser: db.prepare(`
245
+ SELECT * FROM messages WHERE user_id = $userId ORDER BY timestamp DESC, id DESC
246
+ `),
247
+ getMessagesByUserInSession: db.prepare(`
248
+ SELECT * FROM messages WHERE user_id = $userId AND session_id = $sessionId ORDER BY timestamp DESC LIMIT $limit
249
+ `),
250
+ getAllMessagesByUserInSession: db.prepare(`
251
+ SELECT * FROM messages WHERE user_id = $userId AND session_id = $sessionId ORDER BY timestamp DESC, id DESC
252
+ `),
253
+ getMessagesByTimeRange: db.prepare(`
254
+ SELECT * FROM messages
255
+ WHERE session_id = $sessionId AND timestamp >= $startTimestamp AND timestamp < $endTimestamp
256
+ ORDER BY timestamp ASC, id ASC
257
+ `),
258
+ updateCompressedContext: db.prepare(`
259
+ UPDATE sessions SET compressed_context = $compressedContext, updated_at = $updatedAt WHERE id = $sessionId
260
+ `),
261
+ deleteSessionMessages: db.prepare(`
262
+ DELETE FROM messages WHERE session_id = $sessionId
263
+ `),
264
+ deleteBotMessages: db.prepare(`
265
+ DELETE FROM messages WHERE session_id = $sessionId AND role = 'assistant'
266
+ `),
267
+ resetSessionContext: db.prepare(`
268
+ UPDATE sessions SET compressed_context = NULL, updated_at = $updatedAt WHERE id = $sessionId
269
+ `),
270
+ // 消息搜索
271
+ searchMessages: db.prepare(`
272
+ SELECT * FROM messages WHERE session_id = $sessionId AND content LIKE $keyword ORDER BY timestamp DESC LIMIT $limit
273
+ `),
274
+ // 话题
275
+ insertTopic: db.prepare(`
276
+ INSERT INTO topics (session_id, title, keywords, summary, message_count, window_start_at, window_end_at, created_at, updated_at)
277
+ VALUES ($sessionId, $title, $keywords, $summary, $messageCount, $windowStartAt, $windowEndAt, $createdAt, $updatedAt)
278
+ `),
279
+ getTopics: db.prepare(`
280
+ SELECT * FROM topics WHERE session_id = $sessionId ORDER BY updated_at DESC LIMIT $limit
281
+ `),
282
+ getTopicByWindow: db.prepare(`
283
+ SELECT * FROM topics
284
+ WHERE session_id = $sessionId AND window_start_at = $windowStartAt AND window_end_at = $windowEndAt
285
+ ORDER BY id DESC
286
+ LIMIT 1
287
+ `),
288
+ updateTopic: db.prepare(`
289
+ UPDATE topics SET summary = $summary, keywords = $keywords, message_count = $messageCount, updated_at = $updatedAt WHERE id = $id
290
+ `),
291
+ // 表达学习
292
+ insertExpression: db.prepare(`
293
+ INSERT INTO expressions (session_id, user_id, user_name, situation, style, example, created_at)
294
+ VALUES ($sessionId, $userId, $userName, $situation, $style, $example, $createdAt)
295
+ `),
296
+ getExpressions: db.prepare(`
297
+ SELECT * FROM expressions WHERE session_id = $sessionId ORDER BY created_at DESC LIMIT $limit
298
+ `),
299
+ getExpressionsByUser: db.prepare(`
300
+ SELECT * FROM expressions WHERE user_id = $userId ORDER BY created_at DESC LIMIT $limit
301
+ `),
302
+ getExpressionCount: db.prepare(`
303
+ SELECT COUNT(*) as count FROM expressions WHERE session_id = $sessionId
304
+ `),
305
+ deleteExpressionsByUser: db.prepare(`
306
+ DELETE FROM expressions WHERE user_id = $userId
307
+ `),
308
+ deleteOldestExpressions: db.prepare(`
309
+ DELETE FROM expressions WHERE session_id = $sessionId AND id NOT IN (
310
+ SELECT id FROM expressions WHERE session_id = $sessionId ORDER BY created_at DESC LIMIT $keepCount
311
+ )
312
+ `),
313
+ // 图片记录
314
+ insertImage: db.prepare(`
315
+ INSERT OR IGNORE INTO images (hash, url, type, description, emotion, character, file_path, created_at)
316
+ VALUES ($hash, $url, $type, $description, $emotion, $character, $filePath, $createdAt)
317
+ `),
318
+ getImageByHash: db.prepare(`SELECT * FROM images WHERE hash = $hash`),
319
+ getImageByUrl: db.prepare(`SELECT * FROM images WHERE url = $url`),
320
+ getAllImages: db.prepare(`SELECT * FROM images ORDER BY created_at DESC`),
321
+ upsertMediaSummary: db.prepare(`
322
+ INSERT INTO media_summaries (key, kind, source, summary, created_at)
323
+ VALUES ($key, $kind, $source, $summary, $createdAt)
324
+ ON CONFLICT(key) DO UPDATE SET
325
+ summary = $summary,
326
+ source = $source,
327
+ created_at = $createdAt
328
+ `),
329
+ getMediaSummary: db.prepare(`SELECT * FROM media_summaries WHERE key = $key`),
330
+ upsertMediaSummarySource: db.prepare(`
331
+ INSERT INTO media_summary_sources (source_key, summary_key, created_at)
332
+ VALUES ($sourceKey, $summaryKey, $createdAt)
333
+ ON CONFLICT(source_key) DO UPDATE SET
334
+ summary_key = $summaryKey,
335
+ created_at = $createdAt
336
+ `),
337
+ getMediaSummaryBySource: db.prepare(`
338
+ SELECT s.*
339
+ FROM media_summary_sources src
340
+ JOIN media_summaries s ON s.key = src.summary_key
341
+ WHERE src.source_key = $sourceKey
342
+ `),
343
+ };
344
+
345
+ return {
346
+ saveSession(meta: SessionMeta): void {
347
+ stmts.upsertSession.run({
348
+ $id: meta.id,
349
+ $type: meta.type,
350
+ $targetId: meta.targetId,
351
+ $createdAt: meta.createdAt,
352
+ $updatedAt: meta.updatedAt,
353
+ $compressedContext: meta.compressedContext,
354
+ });
355
+ },
356
+
357
+ getSession(id: string): SessionMeta | null {
358
+ const row = stmts.getSession.get({ $id: id }) as any;
359
+ if (!row) return null;
360
+ return {
361
+ id: row.id,
362
+ type: row.type,
363
+ targetId: row.target_id,
364
+ createdAt: row.created_at,
365
+ updatedAt: row.updated_at,
366
+ compressedContext: row.compressed_context,
367
+ };
368
+ },
369
+
370
+ saveMessage(msg: ChatMessage): void {
371
+ stmts.insertMessage.run({
372
+ $sessionId: msg.sessionId,
373
+ $role: msg.role,
374
+ $content: msg.content,
375
+ $userId: msg.userId ?? null,
376
+ $userName: msg.userName ?? null,
377
+ $userRole: msg.userRole ?? null,
378
+ $userTitle: msg.userTitle ?? null,
379
+ $groupId: msg.groupId ?? null,
380
+ $groupName: msg.groupName ?? null,
381
+ $timestamp: msg.timestamp,
382
+ $messageId: msg.messageId ?? null,
383
+ });
384
+ },
385
+
386
+ getMessages(
387
+ sessionId: string,
388
+ limit: number = 30,
389
+ before?: number,
390
+ ): ChatMessage[] {
391
+ const rows = before
392
+ ? (stmts.getMessagesBefore.all({ $sessionId: sessionId, $timestamp: before, $limit: limit }) as any[])
393
+ : (stmts.getMessages.all({ $sessionId: sessionId, $limit: limit }) as any[]);
394
+
395
+ return rows
396
+ .map((row) => ({
397
+ id: row.id,
398
+ sessionId: row.session_id,
399
+ role: row.role,
400
+ content: row.content,
401
+ userId: row.user_id,
402
+ userName: row.user_name,
403
+ userRole: row.user_role,
404
+ userTitle: row.user_title,
405
+ groupId: row.group_id,
406
+ groupName: row.group_name,
407
+ timestamp: row.timestamp,
408
+ messageId: row.message_id,
409
+ }))
410
+ .reverse(); // 按时间正序
411
+ },
412
+
413
+ getBotMessages(groupId: number, limit: number = 50): ChatMessage[] {
414
+ const rows = stmts.getBotMessages.all({ $groupId: groupId, $limit: limit }) as any[];
415
+ return rows
416
+ .map((row) => ({
417
+ id: row.id,
418
+ sessionId: row.session_id,
419
+ role: row.role,
420
+ content: row.content,
421
+ userId: row.user_id,
422
+ userName: row.user_name,
423
+ userRole: row.user_role,
424
+ userTitle: row.user_title,
425
+ groupId: row.group_id,
426
+ groupName: row.group_name,
427
+ timestamp: row.timestamp,
428
+ messageId: row.message_id,
429
+ }))
430
+ .reverse(); // 按时间正序
431
+ },
432
+
433
+ getStoredGroupNoticeMessages(
434
+ groupId: number,
435
+ limit: number = 20,
436
+ ): ChatMessage[] {
437
+ const rows = stmts.getStoredGroupNoticeMessages.all(
438
+ { $groupId: groupId, $limit: limit },
439
+ ) as any[];
440
+ return rows
441
+ .map((row) => ({
442
+ id: row.id,
443
+ sessionId: row.session_id,
444
+ role: row.role,
445
+ content: row.content,
446
+ userId: row.user_id,
447
+ userName: row.user_name,
448
+ userRole: row.user_role,
449
+ userTitle: row.user_title,
450
+ groupId: row.group_id,
451
+ groupName: row.group_name,
452
+ timestamp: row.timestamp,
453
+ messageId: row.message_id,
454
+ }))
455
+ .reverse();
456
+ },
457
+
458
+ getMessagesByUser(
459
+ userId: number,
460
+ sessionId?: string,
461
+ limit: number = 20,
462
+ ): ChatMessage[] {
463
+ const rows = sessionId
464
+ ? (stmts.getMessagesByUserInSession.all({
465
+ $userId: userId,
466
+ $sessionId: sessionId,
467
+ $limit: limit,
468
+ }) as any[])
469
+ : (stmts.getMessagesByUser.all({ $userId: userId, $limit: limit }) as any[]);
470
+
471
+ return rows
472
+ .map((row) => ({
473
+ id: row.id,
474
+ sessionId: row.session_id,
475
+ role: row.role,
476
+ content: row.content,
477
+ userId: row.user_id,
478
+ userName: row.user_name,
479
+ userRole: row.user_role,
480
+ userTitle: row.user_title,
481
+ groupId: row.group_id,
482
+ groupName: row.group_name,
483
+ timestamp: row.timestamp,
484
+ messageId: row.message_id,
485
+ }))
486
+ .reverse();
487
+ },
488
+
489
+ getAllMessagesByUser(userId: number, sessionId?: string): ChatMessage[] {
490
+ const rows = sessionId
491
+ ? (stmts.getAllMessagesByUserInSession.all({ $userId: userId, $sessionId: sessionId }) as any[])
492
+ : (stmts.getAllMessagesByUser.all({ $userId: userId }) as any[]);
493
+
494
+ return rows
495
+ .map((row) => ({
496
+ id: row.id,
497
+ sessionId: row.session_id,
498
+ role: row.role,
499
+ content: row.content,
500
+ userId: row.user_id,
501
+ userName: row.user_name,
502
+ userRole: row.user_role,
503
+ userTitle: row.user_title,
504
+ groupId: row.group_id,
505
+ groupName: row.group_name,
506
+ timestamp: row.timestamp,
507
+ messageId: row.message_id,
508
+ }))
509
+ .reverse();
510
+ },
511
+
512
+ getMessagesByTimeRange(
513
+ sessionId: string,
514
+ startTimestamp: number,
515
+ endTimestamp: number,
516
+ ): ChatMessage[] {
517
+ const rows = stmts.getMessagesByTimeRange.all({
518
+ $sessionId: sessionId,
519
+ $startTimestamp: startTimestamp,
520
+ $endTimestamp: endTimestamp,
521
+ }) as any[];
522
+ return rows.map((row) => ({
523
+ id: row.id,
524
+ sessionId: row.session_id,
525
+ role: row.role,
526
+ content: row.content,
527
+ userId: row.user_id,
528
+ userName: row.user_name,
529
+ userRole: row.user_role,
530
+ userTitle: row.user_title,
531
+ groupId: row.group_id,
532
+ groupName: row.group_name,
533
+ timestamp: row.timestamp,
534
+ messageId: row.message_id,
535
+ }));
536
+ },
537
+
538
+ updateCompressedContext(sessionId: string, context: string): void {
539
+ stmts.updateCompressedContext.run({ $compressedContext: context, $updatedAt: Date.now(), $sessionId: sessionId });
540
+ },
541
+
542
+ deleteSessionMessages(sessionId: string): void {
543
+ stmts.deleteSessionMessages.run({ $sessionId: sessionId });
544
+ stmts.resetSessionContext.run({ $updatedAt: Date.now(), $sessionId: sessionId });
545
+ },
546
+
547
+ deleteBotMessages(sessionId: string): void {
548
+ stmts.deleteBotMessages.run({ $sessionId: sessionId });
549
+ },
550
+
551
+ searchMessages(
552
+ sessionId: string,
553
+ keyword: string,
554
+ limit: number = 20,
555
+ ): ChatMessage[] {
556
+ const rows = stmts.searchMessages.all({
557
+ $sessionId: sessionId,
558
+ $keyword: `%${keyword}%`,
559
+ $limit: limit,
560
+ }) as any[];
561
+ return rows
562
+ .map((row) => ({
563
+ id: row.id,
564
+ sessionId: row.session_id,
565
+ role: row.role,
566
+ content: row.content,
567
+ userId: row.user_id,
568
+ userName: row.user_name,
569
+ userRole: row.user_role,
570
+ userTitle: row.user_title,
571
+ groupId: row.group_id,
572
+ groupName: row.group_name,
573
+ timestamp: row.timestamp,
574
+ messageId: row.message_id,
575
+ }))
576
+ .reverse();
577
+ },
578
+
579
+ saveTopic(topic: TopicRecord): number {
580
+ const result = stmts.insertTopic.run({
581
+ $sessionId: topic.sessionId,
582
+ $title: topic.title,
583
+ $keywords: topic.keywords,
584
+ $summary: topic.summary,
585
+ $messageCount: topic.messageCount,
586
+ $windowStartAt: topic.windowStartAt ?? null,
587
+ $windowEndAt: topic.windowEndAt ?? null,
588
+ $createdAt: topic.createdAt,
589
+ $updatedAt: topic.updatedAt,
590
+ });
591
+ return Number(result.lastInsertRowid);
592
+ },
593
+
594
+ getTopics(sessionId: string, limit: number = 10): TopicRecord[] {
595
+ const rows = stmts.getTopics.all({ $sessionId: sessionId, $limit: limit }) as any[];
596
+ return rows.map((row) => ({
597
+ id: row.id,
598
+ sessionId: row.session_id,
599
+ title: row.title,
600
+ keywords: row.keywords,
601
+ summary: row.summary,
602
+ messageCount: row.message_count,
603
+ windowStartAt: row.window_start_at ?? undefined,
604
+ windowEndAt: row.window_end_at ?? undefined,
605
+ createdAt: row.created_at,
606
+ updatedAt: row.updated_at,
607
+ }));
608
+ },
609
+
610
+ getTopicByWindow(
611
+ sessionId: string,
612
+ windowStartAt: number,
613
+ windowEndAt: number,
614
+ ): TopicRecord | null {
615
+ const row = stmts.getTopicByWindow.get({
616
+ $sessionId: sessionId,
617
+ $windowStartAt: windowStartAt,
618
+ $windowEndAt: windowEndAt,
619
+ }) as any;
620
+ if (!row) return null;
621
+ return {
622
+ id: row.id,
623
+ sessionId: row.session_id,
624
+ title: row.title,
625
+ keywords: row.keywords,
626
+ summary: row.summary,
627
+ messageCount: row.message_count,
628
+ windowStartAt: row.window_start_at ?? undefined,
629
+ windowEndAt: row.window_end_at ?? undefined,
630
+ createdAt: row.created_at,
631
+ updatedAt: row.updated_at,
632
+ };
633
+ },
634
+
635
+ updateTopic(
636
+ id: number,
637
+ updates: Partial<
638
+ Pick<TopicRecord, "summary" | "keywords" | "messageCount" | "updatedAt">
639
+ >,
640
+ ): void {
641
+ // 先获取当前值用于合并
642
+ const current = db
643
+ .prepare("SELECT * FROM topics WHERE id = $id")
644
+ .get({ $id: id }) as any;
645
+ if (!current) return;
646
+ stmts.updateTopic.run({
647
+ $id: id,
648
+ $summary: updates.summary ?? current.summary,
649
+ $keywords: updates.keywords ?? current.keywords,
650
+ $messageCount: updates.messageCount ?? current.message_count,
651
+ $updatedAt: updates.updatedAt ?? Date.now(),
652
+ });
653
+ },
654
+
655
+ saveExpression(expr: ExpressionRecord): void {
656
+ stmts.insertExpression.run({
657
+ $sessionId: expr.sessionId,
658
+ $userId: expr.userId,
659
+ $userName: expr.userName,
660
+ $situation: expr.situation,
661
+ $style: expr.style,
662
+ $example: expr.example,
663
+ $createdAt: expr.createdAt,
664
+ });
665
+ },
666
+
667
+ getExpressions(sessionId: string, limit: number = 50): ExpressionRecord[] {
668
+ const rows = stmts.getExpressions.all({ $sessionId: sessionId, $limit: limit }) as any[];
669
+ return rows.map((row) => ({
670
+ id: row.id,
671
+ sessionId: row.session_id,
672
+ userId: row.user_id,
673
+ userName: row.user_name,
674
+ situation: row.situation,
675
+ style: row.style,
676
+ example: row.example,
677
+ createdAt: row.created_at,
678
+ }));
679
+ },
680
+
681
+ getExpressionsByUser(
682
+ userId: number,
683
+ limit: number = 50,
684
+ ): ExpressionRecord[] {
685
+ const rows = stmts.getExpressionsByUser.all({ $userId: userId, $limit: limit }) as any[];
686
+ return rows.map((row) => ({
687
+ id: row.id,
688
+ sessionId: row.session_id,
689
+ userId: row.user_id,
690
+ userName: row.user_name,
691
+ situation: row.situation,
692
+ style: row.style,
693
+ example: row.example,
694
+ createdAt: row.created_at,
695
+ }));
696
+ },
697
+
698
+ replaceExpressionsByUser(
699
+ userId: number,
700
+ userName: string,
701
+ expressions: Array<
702
+ Pick<ExpressionRecord, "situation" | "style" | "example">
703
+ >,
704
+ ): void {
705
+ const now = Date.now();
706
+ const tx = db.transaction(
707
+ (
708
+ targetUserId: number,
709
+ targetUserName: string,
710
+ rows: Array<
711
+ Pick<ExpressionRecord, "situation" | "style" | "example">
712
+ >,
713
+ ) => {
714
+ stmts.deleteExpressionsByUser.run({ $userId: targetUserId });
715
+ for (const row of rows) {
716
+ stmts.insertExpression.run({
717
+ $sessionId: `user:${targetUserId}`,
718
+ $userId: targetUserId,
719
+ $userName: targetUserName,
720
+ $situation: row.situation,
721
+ $style: row.style,
722
+ $example: row.example,
723
+ $createdAt: now,
724
+ });
725
+ }
726
+ },
727
+ );
728
+
729
+ tx(userId, userName, expressions);
730
+ },
731
+
732
+ getExpressionCount(sessionId: string): number {
733
+ const row = stmts.getExpressionCount.get({ $sessionId: sessionId }) as any;
734
+ return row?.count ?? 0;
735
+ },
736
+
737
+ deleteOldestExpressions(sessionId: string, keepCount: number): void {
738
+ stmts.deleteOldestExpressions.run({ $sessionId: sessionId, $keepCount: keepCount });
739
+ },
740
+
741
+ saveImage(image: ImageRecord): void {
742
+ stmts.insertImage.run({
743
+ $hash: image.hash,
744
+ $url: image.url,
745
+ $type: image.type,
746
+ $description: image.description,
747
+ $emotion: image.emotion ?? null,
748
+ $character: image.character ?? null,
749
+ $filePath: image.filePath ?? null,
750
+ $createdAt: image.createdAt,
751
+ });
752
+ },
753
+
754
+ getImageByHash(hash: string): ImageRecord | null {
755
+ const row = stmts.getImageByHash.get({ $hash: hash }) as any;
756
+ if (!row) return null;
757
+ return {
758
+ id: row.id,
759
+ hash: row.hash,
760
+ url: row.url,
761
+ type: row.type,
762
+ description: row.description,
763
+ emotion: row.emotion,
764
+ character: row.character,
765
+ filePath: row.file_path,
766
+ createdAt: row.created_at,
767
+ };
768
+ },
769
+
770
+ getImageByUrl(url: string): ImageRecord | null {
771
+ const row = stmts.getImageByUrl.get({ $url: url }) as any;
772
+ if (!row) return null;
773
+ return {
774
+ id: row.id,
775
+ hash: row.hash,
776
+ url: row.url,
777
+ type: row.type,
778
+ description: row.description,
779
+ emotion: row.emotion,
780
+ character: row.character,
781
+ filePath: row.file_path,
782
+ createdAt: row.created_at,
783
+ };
784
+ },
785
+
786
+ getAllImages(): ImageRecord[] {
787
+ const rows = stmts.getAllImages.all({}) as any[];
788
+ return rows.map((row) => ({
789
+ id: row.id,
790
+ hash: row.hash,
791
+ url: row.url,
792
+ type: row.type,
793
+ description: row.description,
794
+ emotion: row.emotion,
795
+ character: row.character,
796
+ filePath: row.file_path,
797
+ createdAt: row.created_at,
798
+ }));
799
+ },
800
+
801
+ saveMediaSummary(summary: MediaSummaryRecord): void {
802
+ stmts.upsertMediaSummary.run({
803
+ $key: summary.key,
804
+ $kind: summary.kind,
805
+ $source: summary.source,
806
+ $summary: summary.summary,
807
+ $createdAt: summary.createdAt,
808
+ });
809
+ },
810
+
811
+ getMediaSummary(key: string): MediaSummaryRecord | null {
812
+ const row = stmts.getMediaSummary.get({ $key: key }) as any;
813
+ if (!row) return null;
814
+ return {
815
+ id: row.id,
816
+ key: row.key,
817
+ kind: row.kind,
818
+ source: row.source,
819
+ summary: row.summary,
820
+ createdAt: row.created_at,
821
+ };
822
+ },
823
+
824
+ saveMediaSummarySource(sourceKey: string, summaryKey: string): void {
825
+ stmts.upsertMediaSummarySource.run({
826
+ $sourceKey: sourceKey,
827
+ $summaryKey: summaryKey,
828
+ $createdAt: Date.now(),
829
+ });
830
+ },
831
+
832
+ getMediaSummaryBySource(sourceKey: string): MediaSummaryRecord | null {
833
+ const row = stmts.getMediaSummaryBySource.get({ $sourceKey: sourceKey }) as any;
834
+ if (!row) return null;
835
+ return {
836
+ id: row.id,
837
+ key: row.key,
838
+ kind: row.kind,
839
+ source: row.source,
840
+ summary: row.summary,
841
+ createdAt: row.created_at,
842
+ };
843
+ },
844
+
845
+ close(): void {
846
+ db.close();
847
+ },
848
+ };
849
+ }