@ppagent/memory 0.1.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/dist/index.js ADDED
@@ -0,0 +1,2876 @@
1
+ // src/memory.manager.ts
2
+ import { v4 as uuidv44 } from "uuid";
3
+
4
+ // src/config.ts
5
+ function resolveConfig(config) {
6
+ let embeddingBaseUrl = config.embeddingBaseUrl;
7
+ let embeddingApiKey = config.embeddingApiKey;
8
+ if (!embeddingBaseUrl) {
9
+ console.warn(
10
+ "[MemoryConfig] embeddingBaseUrl \u672A\u914D\u7F6E\uFF0C\u81EA\u52A8\u56DE\u9000\u4F7F\u7528 llmBaseUrl\uFF1A" + config.llmBaseUrl
11
+ );
12
+ embeddingBaseUrl = config.llmBaseUrl;
13
+ }
14
+ if (!embeddingApiKey) {
15
+ console.warn("[MemoryConfig] embeddingApiKey \u672A\u914D\u7F6E\uFF0C\u81EA\u52A8\u56DE\u9000\u4F7F\u7528 llmApiKey\u3002");
16
+ embeddingApiKey = config.llmApiKey;
17
+ }
18
+ return {
19
+ ...config,
20
+ embeddingBaseUrl,
21
+ embeddingApiKey,
22
+ sessionTokenLimit: config.sessionTokenLimit ?? 16386,
23
+ historyWindowTokenLimit: config.historyWindowTokenLimit ?? 10240,
24
+ topicRatio: config.topicRatio ?? [1, 5, 20],
25
+ detailMaxTokens: config.detailMaxTokens ?? 2048,
26
+ summaryMaxTokens: config.summaryMaxTokens ?? 512,
27
+ conciseMaxTokens: config.conciseMaxTokens ?? 128,
28
+ httpTimeoutMs: config.httpTimeoutMs ?? 6e4,
29
+ httpMaxRetries: config.httpMaxRetries ?? 2,
30
+ embeddingBatchSize: config.embeddingBatchSize ?? 20,
31
+ embeddingConcurrency: config.embeddingConcurrency ?? 2,
32
+ maxConcurrentCompressions: config.maxConcurrentCompressions ?? 3,
33
+ entitySimilarityThreshold: config.entitySimilarityThreshold ?? 0.92,
34
+ defaultSearchLimit: config.defaultSearchLimit ?? 10,
35
+ recallBoostMs: config.recallBoostMs ?? 36e5,
36
+ chunkStrategy: config.chunkStrategy ?? "markdown-heading",
37
+ chunkMaxTokens: config.chunkMaxTokens ?? 800,
38
+ chunkOverlap: config.chunkOverlap ?? 0,
39
+ knowledgeTopK: config.knowledgeTopK ?? 8,
40
+ docCoarseTopK: config.docCoarseTopK ?? 5,
41
+ buildGraphDefault: config.buildGraphDefault ?? "auto",
42
+ chunkRedundantIds: config.chunkRedundantIds ?? true,
43
+ knowledgeGraphTriggerScore: config.knowledgeGraphTriggerScore ?? 0.78,
44
+ knowledgeGraphEntityTopK: config.knowledgeGraphEntityTopK ?? 10,
45
+ knowledgeGraphAnchorTopK: config.knowledgeGraphAnchorTopK ?? 3,
46
+ knowledgeGraphHopLimit: config.knowledgeGraphHopLimit ?? 10,
47
+ graphExtractConcurrency: config.graphExtractConcurrency ?? 2,
48
+ graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4
49
+ };
50
+ }
51
+
52
+ // src/constants.ts
53
+ var DEFAULT_NODE_TYPES = [
54
+ "Person",
55
+ "Group",
56
+ "Organization",
57
+ "Project",
58
+ "Task",
59
+ "Decision",
60
+ "Plan",
61
+ "Event",
62
+ "Product",
63
+ "Technology",
64
+ "Data",
65
+ "Document",
66
+ "Topic",
67
+ "Concept",
68
+ "Preference",
69
+ "Habit",
70
+ "Goal",
71
+ "Skill",
72
+ "Attribute",
73
+ "Value",
74
+ "Status",
75
+ "Time",
76
+ "Location",
77
+ "Resource",
78
+ "Relationship"
79
+ ];
80
+ var DEFAULT_RELATION_TYPES = [
81
+ // 结构类
82
+ "is_a",
83
+ "part_of",
84
+ "belongs_to",
85
+ "contains",
86
+ "has_member",
87
+ // 引用类
88
+ "mentioned_in",
89
+ "refers_to",
90
+ "same_as",
91
+ "alias_of",
92
+ // 通用
93
+ "related_to",
94
+ "associated_with",
95
+ // 行为
96
+ "uses",
97
+ "creates",
98
+ "updates",
99
+ "buys",
100
+ "owns",
101
+ "consumes",
102
+ // 用户
103
+ "works_on",
104
+ "prefers",
105
+ "likes",
106
+ "dislikes",
107
+ "interested_in",
108
+ "favorite",
109
+ "plans",
110
+ "decides",
111
+ "habit_of",
112
+ "tends_to",
113
+ "avoids",
114
+ "skilled_in",
115
+ "learning",
116
+ // 人际关系
117
+ "knows",
118
+ "friends_with",
119
+ "married_to",
120
+ "parent_of",
121
+ "child_of",
122
+ "lives_with",
123
+ // 项目/技术
124
+ "depends_on",
125
+ "built_with",
126
+ "integrates_with",
127
+ "deployed_on",
128
+ // 数据/AI
129
+ "inputs",
130
+ "outputs",
131
+ "trained_on",
132
+ "predicts",
133
+ // 时间
134
+ "happens_at",
135
+ "started_at",
136
+ "ended_at",
137
+ // 因果
138
+ "affects",
139
+ "causes",
140
+ "leads_to",
141
+ "improves",
142
+ "reduces",
143
+ // 任务
144
+ "assigned_to",
145
+ "executed_by",
146
+ "blocks",
147
+ "completes",
148
+ // 知识
149
+ "describes",
150
+ "explains",
151
+ "references"
152
+ ];
153
+ var NODE_TYPES_LIST = DEFAULT_NODE_TYPES.join(", ");
154
+ var RELATION_TYPES_LIST = DEFAULT_RELATION_TYPES.join(", ");
155
+ var DEFAULT_USER_ID = "default";
156
+ var DEFAULT_CHAT_ID = "default";
157
+ var DEFAULT_SESSION_ID = "default";
158
+ var KIND_CONVERSATION = "conversation";
159
+ var KIND_KNOWLEDGE = "knowledge";
160
+
161
+ // src/db/grafeo.service.ts
162
+ import { GrafeoDB } from "@grafeo-db/js";
163
+
164
+ // src/llm/embed.service.ts
165
+ import { get_encoding } from "@dqbd/tiktoken";
166
+
167
+ // src/llm/http.ts
168
+ function backoffDelay(attempt) {
169
+ return Math.min(1e3 * 2 ** attempt, 8e3);
170
+ }
171
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
172
+ async function postJsonWithRetry(url, headers, body, opts, errorLabel = "HTTP") {
173
+ let lastErr;
174
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
177
+ let res;
178
+ try {
179
+ res = await fetch(url, {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json", ...headers },
182
+ body: JSON.stringify(body),
183
+ signal: controller.signal
184
+ });
185
+ } catch (err) {
186
+ clearTimeout(timer);
187
+ lastErr = err;
188
+ if (attempt < opts.maxRetries) {
189
+ await sleep(backoffDelay(attempt));
190
+ continue;
191
+ }
192
+ throw err;
193
+ }
194
+ clearTimeout(timer);
195
+ if (res.ok) {
196
+ return await res.json();
197
+ }
198
+ const text = await res.text().catch(() => "");
199
+ lastErr = new Error(`${errorLabel} error ${res.status}: ${text}`);
200
+ const retryable = res.status >= 500 || res.status === 429;
201
+ if (retryable && attempt < opts.maxRetries) {
202
+ await sleep(backoffDelay(attempt));
203
+ continue;
204
+ }
205
+ throw lastErr;
206
+ }
207
+ throw lastErr;
208
+ }
209
+
210
+ // src/llm/embed.service.ts
211
+ var encoder = null;
212
+ function initEncoder() {
213
+ if (!encoder) {
214
+ encoder = get_encoding("cl100k_base");
215
+ }
216
+ }
217
+ function countTokens(text) {
218
+ if (!encoder) {
219
+ encoder = get_encoding("cl100k_base");
220
+ }
221
+ return encoder.encode(text).length;
222
+ }
223
+ var EmbedService = class {
224
+ config;
225
+ constructor(config) {
226
+ this.config = config;
227
+ }
228
+ async embed(texts) {
229
+ const inputs = Array.isArray(texts) ? texts : [texts];
230
+ const url = `${this.config.embeddingBaseUrl.replace(/\/$/, "")}/embeddings`;
231
+ const batchSize = Math.max(1, this.config.embeddingBatchSize);
232
+ const concurrency = Math.max(1, this.config.embeddingConcurrency);
233
+ const retryOpts = { timeoutMs: this.config.httpTimeoutMs, maxRetries: this.config.httpMaxRetries };
234
+ const batches = [];
235
+ for (let i = 0; i < inputs.length; i += batchSize) {
236
+ batches.push({ index: batches.length, texts: inputs.slice(i, i + batchSize) });
237
+ }
238
+ const batchResults = await mapLimit(batches, concurrency, async (batch) => {
239
+ const data = await postJsonWithRetry(
240
+ url,
241
+ { Authorization: `Bearer ${this.config.embeddingApiKey}` },
242
+ { model: this.config.embeddingModel, input: batch.texts },
243
+ retryOpts,
244
+ "Embedding API"
245
+ );
246
+ return {
247
+ index: batch.index,
248
+ vectors: data.data.sort((a, b) => a.index - b.index).map((d) => d.embedding)
249
+ };
250
+ });
251
+ return batchResults.sort((a, b) => a.index - b.index).flatMap((b) => b.vectors);
252
+ }
253
+ async embedOne(text) {
254
+ const result = await this.embed([text]);
255
+ return result[0];
256
+ }
257
+ };
258
+ async function mapLimit(items, limit, mapper) {
259
+ const results = new Array(items.length);
260
+ let next = 0;
261
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
262
+ while (true) {
263
+ const index = next++;
264
+ if (index >= items.length) return;
265
+ results[index] = await mapper(items[index], index);
266
+ }
267
+ });
268
+ await Promise.all(workers);
269
+ return results;
270
+ }
271
+ function cosineSimilarity(a, b) {
272
+ let dot = 0;
273
+ let normA = 0;
274
+ let normB = 0;
275
+ for (let i = 0; i < a.length; i++) {
276
+ dot += a[i] * b[i];
277
+ normA += a[i] * a[i];
278
+ normB += b[i] * b[i];
279
+ }
280
+ if (normA === 0 || normB === 0) return 0;
281
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
282
+ }
283
+
284
+ // src/db/grafeo.service.ts
285
+ var GrafeoService = class {
286
+ config;
287
+ db;
288
+ constructor(config) {
289
+ this.config = config;
290
+ }
291
+ async init() {
292
+ try {
293
+ this.db = GrafeoDB.open(this.config.grafeoPath);
294
+ } catch {
295
+ this.db = GrafeoDB.create(this.config.grafeoPath);
296
+ }
297
+ try {
298
+ await this.db.createVectorIndex(
299
+ "Entity",
300
+ "embedding",
301
+ this.config.embeddingDimension,
302
+ "cosine"
303
+ );
304
+ } catch {
305
+ }
306
+ try {
307
+ await this.db.createTextIndex("Entity", "name");
308
+ } catch {
309
+ }
310
+ }
311
+ buildDomainFilter(userId, chatId, kind) {
312
+ return { userId, chatId, kind };
313
+ }
314
+ async findExactEntity(name, userId, chatId, kind) {
315
+ try {
316
+ const result = await this.db.execute(
317
+ `MATCH (n:Entity {name: $name, userId: $userId, chatId: $chatId, kind: $kind}) RETURN n LIMIT 1`,
318
+ { name, userId, chatId, kind }
319
+ );
320
+ const nodes = result.nodes();
321
+ return nodes.length > 0 ? nodes[0] : null;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+ async findSimilarEntity(embedding, userId, chatId, kind, threshold) {
327
+ try {
328
+ const results = await this.db.vectorSearch(
329
+ "Entity",
330
+ "embedding",
331
+ embedding,
332
+ 5,
333
+ void 0,
334
+ this.buildDomainFilter(userId, chatId, kind)
335
+ );
336
+ for (const [nodeId, distance] of results) {
337
+ const similarity = 1 - distance;
338
+ if (similarity >= threshold) {
339
+ const node = this.db.getNode(nodeId);
340
+ if (node) return node;
341
+ }
342
+ }
343
+ } catch {
344
+ }
345
+ return null;
346
+ }
347
+ async findOrCreateEntity(entity, embedding, kind) {
348
+ const { name, type, meta } = entity;
349
+ const { userId, chatId } = meta;
350
+ const threshold = this.config.entitySimilarityThreshold;
351
+ const exact = await this.findExactEntity(name, userId, chatId, kind);
352
+ if (exact) return exact.id;
353
+ const similar = await this.findSimilarEntity(embedding, userId, chatId, kind, threshold);
354
+ if (similar) return similar.id;
355
+ const extraProps = Object.fromEntries(
356
+ Object.entries(meta).filter(
357
+ ([k]) => ![
358
+ "userId",
359
+ "chatId",
360
+ "sessionId",
361
+ "kind",
362
+ "messageId",
363
+ "messageTime",
364
+ "docId",
365
+ "chunkId"
366
+ ].includes(k)
367
+ )
368
+ );
369
+ if (embedding.length > 0) {
370
+ const [nodeId] = await this.db.batchCreateNodes("Entity", "embedding", [embedding]);
371
+ this.db.addNodeLabel(nodeId, type);
372
+ this.db.setNodeProperty(nodeId, "name", name);
373
+ this.db.setNodeProperty(nodeId, "userId", userId);
374
+ this.db.setNodeProperty(nodeId, "chatId", chatId);
375
+ this.db.setNodeProperty(nodeId, "kind", kind);
376
+ this.db.setNodeProperty(nodeId, "sessionId", meta.sessionId);
377
+ for (const [k, v] of Object.entries(extraProps)) {
378
+ this.db.setNodeProperty(nodeId, k, v);
379
+ }
380
+ return nodeId;
381
+ }
382
+ const node = this.db.createNode(["Entity", type], {
383
+ name,
384
+ userId,
385
+ chatId,
386
+ kind,
387
+ sessionId: meta.sessionId,
388
+ ...extraProps
389
+ });
390
+ return node.id;
391
+ }
392
+ async upsertEntitiesAndRelations(entities, relations, embeddings, kind = KIND_CONVERSATION) {
393
+ const nodeIdMap = /* @__PURE__ */ new Map();
394
+ for (const entity of entities) {
395
+ const embedding = embeddings.get(entity.name) ?? [];
396
+ const nodeId = await this.findOrCreateEntity(entity, embedding, kind);
397
+ nodeIdMap.set(entity.name, nodeId);
398
+ }
399
+ for (const relation of relations) {
400
+ const fromId = nodeIdMap.get(relation.from);
401
+ const toId = nodeIdMap.get(relation.to);
402
+ if (fromId == null || toId == null) continue;
403
+ const existing = await this.db.execute(
404
+ `MATCH (a)-[r:${relation.type}]->(b) WHERE id(a) = $fromId AND id(b) = $toId RETURN r LIMIT 1`,
405
+ { fromId, toId }
406
+ );
407
+ if (existing.length === 0) {
408
+ const props = {
409
+ userId: relation.meta.userId,
410
+ chatId: relation.meta.chatId,
411
+ sessionId: relation.meta.sessionId,
412
+ kind
413
+ };
414
+ if (relation.happenedAt) props.happenedAt = relation.happenedAt;
415
+ if (relation.meta.messageId) props.messageId = relation.meta.messageId;
416
+ if (relation.meta.messageTime) props.messageTime = relation.meta.messageTime;
417
+ if (relation.meta.docId) props.docId = relation.meta.docId;
418
+ if (relation.meta.chunkId) props.chunkId = relation.meta.chunkId;
419
+ this.db.createEdge(fromId, toId, relation.type, props);
420
+ }
421
+ }
422
+ }
423
+ async searchEntities(vector, filter, limit = 10) {
424
+ try {
425
+ const domainFilter = {};
426
+ if (filter.userId) domainFilter.userId = filter.userId;
427
+ if (filter.chatId) domainFilter.chatId = filter.chatId;
428
+ if (filter.sessionId) domainFilter.sessionId = filter.sessionId;
429
+ if (filter.kind) domainFilter.kind = filter.kind;
430
+ const results = await this.db.vectorSearch(
431
+ "Entity",
432
+ "embedding",
433
+ vector,
434
+ limit,
435
+ void 0,
436
+ Object.keys(domainFilter).length > 0 ? domainFilter : void 0
437
+ );
438
+ const searchResults = [];
439
+ for (const [nodeId, distance] of results) {
440
+ const node = this.db.getNode(nodeId);
441
+ if (!node) continue;
442
+ const props = node.properties();
443
+ const embedding = props.embedding;
444
+ const similarity = embedding ? cosineSimilarity(vector, embedding) : 1 - distance;
445
+ searchResults.push({
446
+ type: "entity",
447
+ content: `\u5B9E\u4F53\uFF1A${props.name}\uFF08\u7C7B\u578B\uFF1A${node.labels.filter((l) => l !== "Entity").join(", ")}\uFF09`,
448
+ score: similarity,
449
+ meta: { ...props, nodeId }
450
+ });
451
+ }
452
+ return searchResults;
453
+ } catch {
454
+ return [];
455
+ }
456
+ }
457
+ async getRelatedEntities(entityName, userId, chatId, limit = 20, kind = KIND_CONVERSATION) {
458
+ const mapRows = (rows) => rows.map((row) => {
459
+ const r = row;
460
+ return {
461
+ type: "relation",
462
+ content: `${r["a.name"]} -[${r["type(r)"]}]-> ${r["b.name"]}`,
463
+ score: 1,
464
+ meta: { happenedAt: r["r.happenedAt"] }
465
+ };
466
+ });
467
+ try {
468
+ const result = await this.db.execute(
469
+ `MATCH (a:Entity {name: $name, userId: $userId, chatId: $chatId, kind: $kind})-[r]->(b)
470
+ WHERE r.chatId = $chatId AND r.userId = $userId
471
+ RETURN a.name, type(r), b.name, r.happenedAt LIMIT $limit`,
472
+ { name: entityName, userId, chatId, kind, limit }
473
+ );
474
+ return mapRows(result.toArray());
475
+ } catch {
476
+ }
477
+ try {
478
+ const result = await this.db.execute(
479
+ `MATCH (a:Entity {name: $name, userId: $userId, chatId: $chatId, kind: $kind})-[r]->(b)
480
+ RETURN a.name, type(r), b.name, r.happenedAt LIMIT $limit`,
481
+ { name: entityName, userId, chatId, kind, limit }
482
+ );
483
+ return mapRows(result.toArray());
484
+ } catch {
485
+ return [];
486
+ }
487
+ }
488
+ /** 删除某文档的知识图谱痕迹(best-effort:删边按 docId,再清理孤立知识节点)*/
489
+ async deleteKnowledgeByDoc(docId) {
490
+ try {
491
+ await this.db.execute(`MATCH ()-[r]->() WHERE r.docId = $docId DELETE r`, { docId });
492
+ } catch {
493
+ }
494
+ try {
495
+ await this.db.execute(
496
+ `MATCH (n:Entity {kind: $kind}) WHERE NOT (n)--() DETACH DELETE n`,
497
+ { kind: KIND_KNOWLEDGE }
498
+ );
499
+ } catch {
500
+ }
501
+ }
502
+ // ── 管理面板用:全量读取实体 / 关系 ────────────────────────────────────────────────
503
+ /** 全量读取实体节点(管理面板/知识图谱用,剔除体积大的 embedding 字段)*/
504
+ async getAllEntities(limit = 1e3) {
505
+ try {
506
+ const result = await this.db.execute(`MATCH (n:Entity) RETURN n LIMIT $limit`, { limit });
507
+ const nodes = result.nodes();
508
+ return nodes.map((node) => {
509
+ const props = node.properties();
510
+ const { embedding: _embedding, name, userId, chatId, sessionId, ...rest } = props;
511
+ const type = (node.labels ?? []).filter((l) => l !== "Entity").join(", ") || "\u672A\u77E5";
512
+ return {
513
+ name: String(name ?? ""),
514
+ type,
515
+ meta: {
516
+ userId: String(userId ?? ""),
517
+ chatId: String(chatId ?? ""),
518
+ sessionId: String(sessionId ?? ""),
519
+ ...rest
520
+ }
521
+ };
522
+ });
523
+ } catch {
524
+ return [];
525
+ }
526
+ }
527
+ /** 全量读取关系边(管理面板/知识图谱用)*/
528
+ async getAllRelations(limit = 2e3) {
529
+ try {
530
+ const result = await this.db.execute(
531
+ `MATCH (a:Entity)-[r]->(b:Entity)
532
+ RETURN a.name, type(r), b.name, r.happenedAt, r.userId, r.chatId, r.sessionId LIMIT $limit`,
533
+ { limit }
534
+ );
535
+ return result.toArray().map((row) => {
536
+ const r = row;
537
+ return {
538
+ from: String(r["a.name"] ?? ""),
539
+ to: String(r["b.name"] ?? ""),
540
+ type: String(r["type(r)"] ?? ""),
541
+ happenedAt: r["r.happenedAt"] != null ? Number(r["r.happenedAt"]) : void 0,
542
+ meta: {
543
+ userId: String(r["r.userId"] ?? ""),
544
+ chatId: String(r["r.chatId"] ?? ""),
545
+ sessionId: String(r["r.sessionId"] ?? "")
546
+ }
547
+ };
548
+ });
549
+ } catch {
550
+ return [];
551
+ }
552
+ }
553
+ close() {
554
+ try {
555
+ this.db.close();
556
+ } catch {
557
+ }
558
+ }
559
+ };
560
+
561
+ // src/db/lance.service.ts
562
+ import * as lancedb from "@lancedb/lancedb";
563
+ import { Field, FixedSizeList, Float32, Int32, Int64, Schema, Utf8 } from "apache-arrow";
564
+
565
+ // src/db/sql.util.ts
566
+ function escLance(value) {
567
+ return String(value).replace(/'/g, "''");
568
+ }
569
+ function eqFilter(field, value) {
570
+ return `${field} = '${escLance(value)}'`;
571
+ }
572
+
573
+ // src/db/lance.service.ts
574
+ var MESSAGES_TABLE = "messages";
575
+ var TOPICS_TABLE = "topics";
576
+ var FACTS_TABLE = "facts";
577
+ var SESSIONS_TABLE = "sessions";
578
+ var DOCUMENTS_TABLE = "documents";
579
+ var CHUNKS_TABLE = "chunks";
580
+ function messagesSchema(dim) {
581
+ return new Schema([
582
+ new Field("message_id", new Utf8(), false),
583
+ new Field("talker_id", new Utf8(), false),
584
+ new Field("chat_id", new Utf8(), false),
585
+ new Field("user_id", new Utf8(), false),
586
+ new Field("session_id", new Utf8(), false),
587
+ new Field("type", new Utf8(), false),
588
+ new Field("content", new Utf8(), false),
589
+ new Field("parts", new Utf8(), true),
590
+ // nullable,兼容存量数据
591
+ new Field("vector", new FixedSizeList(dim, new Field("item", new Float32(), false)), false),
592
+ new Field("usage", new Int32(), false),
593
+ new Field("metadata", new Utf8(), false),
594
+ new Field("created_at", new Int64(), false)
595
+ ]);
596
+ }
597
+ function topicsSchema(dim) {
598
+ return new Schema([
599
+ new Field("summary_id", new Utf8(), false),
600
+ new Field("session_id", new Utf8(), false),
601
+ new Field("user_id", new Utf8(), false),
602
+ new Field("chat_id", new Utf8(), false),
603
+ new Field("title", new Utf8(), true),
604
+ // nullable,兼容存量数据
605
+ new Field("detail", new Utf8(), false),
606
+ new Field("summary", new Utf8(), false),
607
+ new Field("concise", new Utf8(), false),
608
+ new Field("vector", new FixedSizeList(dim, new Field("item", new Float32(), false)), false),
609
+ new Field("start_time", new Int64(), false),
610
+ new Field("end_time", new Int64(), false),
611
+ new Field("created_at", new Int64(), false),
612
+ new Field("updated_at", new Int64(), false),
613
+ new Field("recall_count", new Int32(), false)
614
+ ]);
615
+ }
616
+ function factsSchema() {
617
+ return new Schema([
618
+ new Field("fact_id", new Utf8(), false),
619
+ new Field("level", new Utf8(), false),
620
+ new Field("chat_id", new Utf8(), false),
621
+ new Field("session_id", new Utf8(), false),
622
+ new Field("user_id", new Utf8(), false),
623
+ new Field("content", new Utf8(), false),
624
+ new Field("created_at", new Int64(), false)
625
+ ]);
626
+ }
627
+ function sessionsSchema() {
628
+ return new Schema([
629
+ new Field("session_id", new Utf8(), false),
630
+ new Field("chat_id", new Utf8(), false),
631
+ new Field("user_id", new Utf8(), false),
632
+ new Field("title", new Utf8(), false),
633
+ new Field("metadata", new Utf8(), false),
634
+ new Field("created_at", new Int64(), false),
635
+ new Field("updated_at", new Int64(), false)
636
+ ]);
637
+ }
638
+ function documentsSchema(dim) {
639
+ return new Schema([
640
+ new Field("doc_id", new Utf8(), false),
641
+ new Field("user_id", new Utf8(), false),
642
+ new Field("chat_id", new Utf8(), false),
643
+ new Field("session_id", new Utf8(), false),
644
+ new Field("title", new Utf8(), false),
645
+ new Field("source_name", new Utf8(), false),
646
+ new Field("full_content", new Utf8(), false),
647
+ new Field("content_hash", new Utf8(), false),
648
+ new Field("summary", new Utf8(), false),
649
+ // vector 列存储文档摘要向量(summaryVector),用于文档级粗召回
650
+ new Field("vector", new FixedSizeList(dim, new Field("item", new Float32(), false)), false),
651
+ new Field("chunk_count", new Int32(), false),
652
+ new Field("has_graph", new Int32(), false),
653
+ // 0/1 充当布尔
654
+ new Field("metadata", new Utf8(), false),
655
+ new Field("created_at", new Int64(), false),
656
+ new Field("updated_at", new Int64(), false)
657
+ ]);
658
+ }
659
+ function chunksSchema(dim) {
660
+ return new Schema([
661
+ new Field("chunk_id", new Utf8(), false),
662
+ new Field("doc_id", new Utf8(), false),
663
+ // user_id/chat_id/session_id:chunkRedundantIds=false 时存空串,仅经 documents join 过滤
664
+ new Field("user_id", new Utf8(), false),
665
+ new Field("chat_id", new Utf8(), false),
666
+ new Field("session_id", new Utf8(), false),
667
+ new Field("content", new Utf8(), false),
668
+ new Field("vector", new FixedSizeList(dim, new Field("item", new Float32(), false)), false),
669
+ new Field("heading_path", new Utf8(), false),
670
+ new Field("ordinal", new Int32(), false),
671
+ new Field("tokens", new Int32(), false),
672
+ new Field("metadata", new Utf8(), false),
673
+ new Field("created_at", new Int64(), false)
674
+ ]);
675
+ }
676
+ function messageToRow(m) {
677
+ return {
678
+ message_id: m.messageId,
679
+ talker_id: m.talkerId,
680
+ chat_id: m.chatId,
681
+ user_id: m.userId,
682
+ session_id: m.sessionId,
683
+ type: m.type,
684
+ content: m.content,
685
+ parts: m.parts ?? "[]",
686
+ vector: m.vector,
687
+ usage: m.usage,
688
+ metadata: m.metadata,
689
+ created_at: m.createdAt
690
+ };
691
+ }
692
+ function rowToMessage(r) {
693
+ return {
694
+ messageId: r.message_id,
695
+ talkerId: r.talker_id,
696
+ chatId: r.chat_id,
697
+ userId: r.user_id,
698
+ sessionId: r.session_id,
699
+ type: r.type,
700
+ content: r.content,
701
+ parts: r.parts ?? "[]",
702
+ // 旧数据无此列时安全降级
703
+ vector: Array.from(r.vector),
704
+ usage: Number(r.usage),
705
+ metadata: r.metadata,
706
+ createdAt: Number(r.created_at)
707
+ };
708
+ }
709
+ function topicToRow(t) {
710
+ return {
711
+ summary_id: t.summaryId,
712
+ session_id: t.sessionId,
713
+ user_id: t.userId,
714
+ chat_id: t.chatId,
715
+ title: t.title ?? "",
716
+ detail: t.detail,
717
+ summary: t.summary,
718
+ concise: t.concise,
719
+ vector: t.vector,
720
+ start_time: t.startTime,
721
+ end_time: t.endTime,
722
+ created_at: t.createdAt,
723
+ updated_at: t.updatedAt,
724
+ recall_count: t.recallCount
725
+ };
726
+ }
727
+ function rowToTopic(r) {
728
+ return {
729
+ summaryId: r.summary_id,
730
+ sessionId: r.session_id,
731
+ userId: r.user_id,
732
+ chatId: r.chat_id,
733
+ title: r.title ?? "",
734
+ detail: r.detail,
735
+ summary: r.summary,
736
+ concise: r.concise,
737
+ vector: Array.from(r.vector),
738
+ startTime: Number(r.start_time),
739
+ endTime: Number(r.end_time),
740
+ createdAt: Number(r.created_at),
741
+ updatedAt: Number(r.updated_at),
742
+ recallCount: Number(r.recall_count)
743
+ };
744
+ }
745
+ function factToRow(f) {
746
+ return {
747
+ fact_id: f.factId,
748
+ level: f.level,
749
+ chat_id: f.chatId,
750
+ session_id: f.sessionId,
751
+ user_id: f.userId,
752
+ content: f.content,
753
+ created_at: f.createdAt
754
+ };
755
+ }
756
+ function rowToFact(r) {
757
+ return {
758
+ factId: r.fact_id,
759
+ level: r.level,
760
+ chatId: r.chat_id,
761
+ sessionId: r.session_id,
762
+ userId: r.user_id,
763
+ content: r.content,
764
+ createdAt: Number(r.created_at)
765
+ };
766
+ }
767
+ function sessionToRow(s) {
768
+ return {
769
+ session_id: s.sessionId,
770
+ chat_id: s.chatId,
771
+ user_id: s.userId,
772
+ title: s.title,
773
+ metadata: s.metadata,
774
+ created_at: s.createdAt,
775
+ updated_at: s.updatedAt
776
+ };
777
+ }
778
+ function rowToSession(r) {
779
+ return {
780
+ sessionId: r.session_id,
781
+ chatId: r.chat_id,
782
+ userId: r.user_id,
783
+ title: r.title,
784
+ metadata: r.metadata,
785
+ createdAt: Number(r.created_at),
786
+ updatedAt: Number(r.updated_at)
787
+ };
788
+ }
789
+ function documentToRow(d) {
790
+ return {
791
+ doc_id: d.docId,
792
+ user_id: d.userId,
793
+ chat_id: d.chatId,
794
+ session_id: d.sessionId,
795
+ title: d.title,
796
+ source_name: d.sourceName,
797
+ full_content: d.fullContent,
798
+ content_hash: d.contentHash,
799
+ summary: d.summary,
800
+ vector: d.summaryVector,
801
+ chunk_count: d.chunkCount,
802
+ has_graph: d.hasGraph ? 1 : 0,
803
+ metadata: JSON.stringify(d.metadata ?? {}),
804
+ created_at: d.createdAt,
805
+ updated_at: d.updatedAt
806
+ };
807
+ }
808
+ function rowToDocument(r) {
809
+ return {
810
+ docId: r.doc_id,
811
+ userId: r.user_id,
812
+ chatId: r.chat_id,
813
+ sessionId: r.session_id,
814
+ title: r.title,
815
+ sourceName: r.source_name,
816
+ fullContent: r.full_content,
817
+ contentHash: r.content_hash,
818
+ summary: r.summary,
819
+ summaryVector: Array.from(r.vector),
820
+ chunkCount: Number(r.chunk_count),
821
+ hasGraph: Number(r.has_graph) === 1,
822
+ metadata: safeParseObject(r.metadata),
823
+ createdAt: Number(r.created_at),
824
+ updatedAt: Number(r.updated_at)
825
+ };
826
+ }
827
+ function chunkToRow(c) {
828
+ return {
829
+ chunk_id: c.chunkId,
830
+ doc_id: c.docId,
831
+ user_id: c.userId,
832
+ chat_id: c.chatId,
833
+ session_id: c.sessionId,
834
+ content: c.content,
835
+ vector: c.vector,
836
+ heading_path: c.headingPath,
837
+ ordinal: c.ordinal,
838
+ tokens: c.tokens,
839
+ metadata: JSON.stringify(c.metadata ?? {}),
840
+ created_at: c.createdAt
841
+ };
842
+ }
843
+ function rowToChunk(r) {
844
+ return {
845
+ chunkId: r.chunk_id,
846
+ docId: r.doc_id,
847
+ userId: r.user_id,
848
+ chatId: r.chat_id,
849
+ sessionId: r.session_id,
850
+ content: r.content,
851
+ vector: Array.from(r.vector),
852
+ headingPath: r.heading_path,
853
+ ordinal: Number(r.ordinal),
854
+ tokens: Number(r.tokens),
855
+ metadata: safeParseObject(r.metadata),
856
+ createdAt: Number(r.created_at)
857
+ };
858
+ }
859
+ function safeParseObject(s) {
860
+ try {
861
+ return JSON.parse(s);
862
+ } catch {
863
+ return {};
864
+ }
865
+ }
866
+ var LanceService = class {
867
+ config;
868
+ conn;
869
+ messagesTable;
870
+ topicsTable;
871
+ factsTable;
872
+ sessionsTable;
873
+ documentsTable;
874
+ chunksTable;
875
+ // Scalar indexes cannot be created on empty tables (LanceDB btree limitation).
876
+ // These flags defer creation to the first insert.
877
+ isNewMessagesTable = false;
878
+ isNewSessionsTable = false;
879
+ isNewDocumentsTable = false;
880
+ isNewChunksTable = false;
881
+ constructor(config) {
882
+ this.config = config;
883
+ }
884
+ async init() {
885
+ this.conn = await lancedb.connect(this.config.lancedbPath);
886
+ const dim = this.config.embeddingDimension;
887
+ const existingTables = await this.conn.tableNames();
888
+ if (existingTables.includes(MESSAGES_TABLE)) {
889
+ this.messagesTable = await this.conn.openTable(MESSAGES_TABLE);
890
+ await this._ensurePartsColumn();
891
+ await this.ensureScalarIndex(this.messagesTable, "message_id");
892
+ } else {
893
+ this.messagesTable = await this.conn.createEmptyTable(
894
+ MESSAGES_TABLE,
895
+ messagesSchema(dim)
896
+ );
897
+ this.isNewMessagesTable = true;
898
+ }
899
+ if (existingTables.includes(TOPICS_TABLE)) {
900
+ this.topicsTable = await this.conn.openTable(TOPICS_TABLE);
901
+ await this._ensureTopicTitleColumn();
902
+ } else {
903
+ this.topicsTable = await this.conn.createEmptyTable(
904
+ TOPICS_TABLE,
905
+ topicsSchema(dim)
906
+ );
907
+ }
908
+ if (existingTables.includes(FACTS_TABLE)) {
909
+ this.factsTable = await this.conn.openTable(FACTS_TABLE);
910
+ } else {
911
+ this.factsTable = await this.conn.createEmptyTable(FACTS_TABLE, factsSchema());
912
+ }
913
+ if (existingTables.includes(SESSIONS_TABLE)) {
914
+ this.sessionsTable = await this.conn.openTable(SESSIONS_TABLE);
915
+ await this.ensureScalarIndex(this.sessionsTable, "session_id");
916
+ } else {
917
+ this.sessionsTable = await this.conn.createEmptyTable(SESSIONS_TABLE, sessionsSchema());
918
+ this.isNewSessionsTable = true;
919
+ }
920
+ if (existingTables.includes(DOCUMENTS_TABLE)) {
921
+ this.documentsTable = await this.conn.openTable(DOCUMENTS_TABLE);
922
+ await this.ensureScalarIndex(this.documentsTable, "doc_id");
923
+ } else {
924
+ this.documentsTable = await this.conn.createEmptyTable(DOCUMENTS_TABLE, documentsSchema(dim));
925
+ this.isNewDocumentsTable = true;
926
+ }
927
+ if (existingTables.includes(CHUNKS_TABLE)) {
928
+ this.chunksTable = await this.conn.openTable(CHUNKS_TABLE);
929
+ await this.ensureScalarIndex(this.chunksTable, "doc_id");
930
+ } else {
931
+ this.chunksTable = await this.conn.createEmptyTable(CHUNKS_TABLE, chunksSchema(dim));
932
+ this.isNewChunksTable = true;
933
+ }
934
+ await this.ensureFtsIndexes();
935
+ }
936
+ async ensureFtsIndexes() {
937
+ const ftsTargets = [
938
+ { table: this.messagesTable, column: "content" },
939
+ { table: this.messagesTable, column: "metadata" },
940
+ // 支持 metadata 内容全文检索
941
+ { table: this.topicsTable, column: "detail" },
942
+ { table: this.chunksTable, column: "content" }
943
+ // 知识片段混合检索
944
+ ];
945
+ for (const { table, column } of ftsTargets) {
946
+ try {
947
+ await table.createIndex(column, { config: lancedb.Index.fts() });
948
+ } catch {
949
+ }
950
+ }
951
+ }
952
+ /**
953
+ * 为存量 messages 表添加 parts 列(如果缺失)。
954
+ * LanceDB 0.14+ 支持 addColumns;旧版本会 throw,此时 rowToMessage 的 ?? "[]" 兜底。
955
+ */
956
+ async _ensurePartsColumn() {
957
+ try {
958
+ const schema = await this.messagesTable.schema();
959
+ const hasPartsCol = schema.fields?.some(
960
+ (f) => f.name === "parts"
961
+ );
962
+ if (!hasPartsCol) {
963
+ await this.messagesTable.addColumns([
964
+ { name: "parts", valueSql: "CAST(NULL AS STRING)" }
965
+ ]);
966
+ }
967
+ } catch {
968
+ }
969
+ }
970
+ /**
971
+ * 为存量 topics 表添加 title 列(如果缺失)。
972
+ * 旧版本不支持 addColumns 时忽略,读取时 rowToTopic 的 ?? "" 兜底。
973
+ */
974
+ async _ensureTopicTitleColumn() {
975
+ try {
976
+ const schema = await this.topicsTable.schema();
977
+ const hasTitleCol = schema.fields?.some(
978
+ (f) => f.name === "title"
979
+ );
980
+ if (!hasTitleCol) {
981
+ await this.topicsTable.addColumns([
982
+ { name: "title", valueSql: "CAST(NULL AS STRING)" }
983
+ ]);
984
+ }
985
+ } catch {
986
+ }
987
+ }
988
+ // LanceDB btree scalar indexes require at least one row.
989
+ // For existing tables (opened in init) this is safe; for new tables we defer via isNew*Table flags.
990
+ async ensureScalarIndex(table, column) {
991
+ try {
992
+ await table.createIndex(column);
993
+ } catch {
994
+ }
995
+ }
996
+ async addMessages(messages) {
997
+ if (messages.length === 0) return;
998
+ await this.messagesTable.add(messages.map(messageToRow));
999
+ if (this.isNewMessagesTable) {
1000
+ await this.ensureScalarIndex(this.messagesTable, "message_id");
1001
+ this.isNewMessagesTable = false;
1002
+ }
1003
+ }
1004
+ async addTopic(topic) {
1005
+ await this.topicsTable.add([topicToRow(topic)]);
1006
+ }
1007
+ async updateTopicRecallCount(summaryId, count) {
1008
+ await this.topicsTable.update({
1009
+ values: { recall_count: count, updated_at: Date.now() },
1010
+ where: eqFilter("summary_id", summaryId)
1011
+ });
1012
+ }
1013
+ async getRecentTopics(chatId, userId, n1, n2, n3) {
1014
+ const filter = `${eqFilter("chat_id", chatId)} AND ${eqFilter("user_id", userId)}`;
1015
+ const recallBoostMs = this.config.recallBoostMs;
1016
+ const fetchTopics = async (count) => {
1017
+ if (count <= 0) return [];
1018
+ try {
1019
+ const rows = await this.topicsTable.query().where(filter).limit(count * 5).toArray();
1020
+ return rows.sort((a, b) => {
1021
+ const scoreA = Number(a.end_time) + Number(a.recall_count) * recallBoostMs;
1022
+ const scoreB = Number(b.end_time) + Number(b.recall_count) * recallBoostMs;
1023
+ return scoreB - scoreA;
1024
+ }).slice(0, count).map(rowToTopic);
1025
+ } catch {
1026
+ return [];
1027
+ }
1028
+ };
1029
+ const allTopics = await fetchTopics(n1 + n2 + n3);
1030
+ return {
1031
+ detail: allTopics.slice(0, n1),
1032
+ summary: allTopics.slice(n1, n1 + n2),
1033
+ concise: allTopics.slice(n1 + n2, n1 + n2 + n3)
1034
+ };
1035
+ }
1036
+ async getMessagesSince(sessionId, since, limit) {
1037
+ try {
1038
+ const q = this.messagesTable.query().where(`${eqFilter("session_id", sessionId)} AND created_at > ${since}`);
1039
+ if (limit) q.limit(limit);
1040
+ const rows = await q.toArray();
1041
+ return rows.sort((a, b) => Number(a.created_at) - Number(b.created_at)).map(rowToMessage);
1042
+ } catch {
1043
+ return [];
1044
+ }
1045
+ }
1046
+ async getLatestMessages(sessionId, limit) {
1047
+ try {
1048
+ const rows = await this.messagesTable.query().where(eqFilter("session_id", sessionId)).limit(limit * 3).toArray();
1049
+ return rows.sort((a, b) => Number(b.created_at) - Number(a.created_at)).slice(0, limit).reverse().map(rowToMessage);
1050
+ } catch {
1051
+ return [];
1052
+ }
1053
+ }
1054
+ async searchMessages(vector, filter, limit = 10) {
1055
+ const q = this.messagesTable.vectorSearch(vector).limit(limit);
1056
+ if (filter) q.where(filter);
1057
+ const rows = await q.toArray();
1058
+ return rows.map((r) => ({
1059
+ ...rowToMessage(r),
1060
+ _distance: r._distance
1061
+ }));
1062
+ }
1063
+ // 对 messages 表执行混合搜索(BM25 + 向量),用于 topics 搜索无结果时的回退
1064
+ async hybridSearchMessages(query, vector, filter, limit = 10) {
1065
+ try {
1066
+ const q = this.messagesTable.query().fullTextSearch(query).nearestTo(vector).limit(limit);
1067
+ if (filter) q.where(filter);
1068
+ const rows = await q.toArray();
1069
+ return rows.map(rowToMessage);
1070
+ } catch {
1071
+ const rows = await this.searchMessages(vector, filter, limit);
1072
+ return rows.map(({ _distance: _d, ...r }) => r);
1073
+ }
1074
+ }
1075
+ async hybridSearchTopics(query, vector, filter, limit = 10) {
1076
+ const recallBoostMs = this.config.recallBoostMs;
1077
+ try {
1078
+ const q = this.topicsTable.query().fullTextSearch(query).nearestTo(vector).limit(limit);
1079
+ if (filter) q.where(filter);
1080
+ const rows = await q.toArray();
1081
+ if (rows.length > 0) {
1082
+ const total = rows.length;
1083
+ return rows.map((r, idx) => ({
1084
+ row: r,
1085
+ // 位置分(越前越高)+ recall_count 加成
1086
+ score: 1 - idx / total + Math.log1p(Number(r.recall_count)) * 0.1
1087
+ })).sort((a, b) => b.score - a.score).map((x) => rowToTopic(x.row));
1088
+ }
1089
+ } catch {
1090
+ }
1091
+ const msgRows = await this.hybridSearchMessages(query, vector, filter, limit);
1092
+ return msgRows.map((r) => ({
1093
+ summaryId: r.messageId,
1094
+ sessionId: r.sessionId,
1095
+ userId: r.userId,
1096
+ chatId: r.chatId,
1097
+ detail: r.content,
1098
+ summary: r.content,
1099
+ concise: r.content,
1100
+ startTime: r.createdAt,
1101
+ endTime: r.createdAt,
1102
+ createdAt: r.createdAt,
1103
+ updatedAt: r.createdAt,
1104
+ recallCount: 0,
1105
+ vector: r.vector
1106
+ }));
1107
+ }
1108
+ async saveFact(fact) {
1109
+ await this.factsTable.add([factToRow(fact)]);
1110
+ }
1111
+ async getAllFacts() {
1112
+ try {
1113
+ const rows = await this.factsTable.query().toArray();
1114
+ return rows.map(rowToFact);
1115
+ } catch {
1116
+ return [];
1117
+ }
1118
+ }
1119
+ async getAllSessionIds() {
1120
+ try {
1121
+ const rows = await this.messagesTable.query().select(["session_id"]).limit(1e4).toArray();
1122
+ return [...new Set(rows.map((r) => r.session_id))];
1123
+ } catch {
1124
+ return [];
1125
+ }
1126
+ }
1127
+ async insertSession(session) {
1128
+ await this.sessionsTable.add([sessionToRow(session)]);
1129
+ if (this.isNewSessionsTable) {
1130
+ await this.ensureScalarIndex(this.sessionsTable, "session_id");
1131
+ this.isNewSessionsTable = false;
1132
+ }
1133
+ }
1134
+ async upsertSession(session) {
1135
+ await this.sessionsTable.delete(eqFilter("session_id", session.sessionId));
1136
+ await this.sessionsTable.add([sessionToRow(session)]);
1137
+ }
1138
+ async getAllSessions() {
1139
+ try {
1140
+ const rows = await this.sessionsTable.query().toArray();
1141
+ return rows.map(rowToSession);
1142
+ } catch {
1143
+ return [];
1144
+ }
1145
+ }
1146
+ async deleteSession(sessionId) {
1147
+ await this.sessionsTable.delete(eqFilter("session_id", sessionId));
1148
+ }
1149
+ // ── 管理面板用:全量读取 / 删除 / 计数 ──────────────────────────────────────────────
1150
+ /** 全量读取 topics(管理面板列表用,不做 recall 加权排序,按 endTime 倒序)*/
1151
+ async getAllTopics() {
1152
+ try {
1153
+ const rows = await this.topicsTable.query().toArray();
1154
+ return rows.sort((a, b) => Number(b.end_time) - Number(a.end_time)).map(rowToTopic);
1155
+ } catch {
1156
+ return [];
1157
+ }
1158
+ }
1159
+ /** 删除单条 fact */
1160
+ async deleteFact(factId) {
1161
+ await this.factsTable.delete(eqFilter("fact_id", factId));
1162
+ }
1163
+ /** 删除某会话下的全部消息(级联删除会话时使用)*/
1164
+ async deleteMessagesBySession(sessionId) {
1165
+ await this.messagesTable.delete(eqFilter("session_id", sessionId));
1166
+ }
1167
+ /** 删除某会话下的全部 topics(级联删除会话时使用)*/
1168
+ async deleteTopicsBySession(sessionId) {
1169
+ await this.topicsTable.delete(eqFilter("session_id", sessionId));
1170
+ }
1171
+ /** 各表行数统计(概览卡片用)*/
1172
+ async countAll() {
1173
+ const safeCount = async (table) => {
1174
+ try {
1175
+ return await table.countRows();
1176
+ } catch {
1177
+ return 0;
1178
+ }
1179
+ };
1180
+ const [sessions, messages, topics, facts] = await Promise.all([
1181
+ safeCount(this.sessionsTable),
1182
+ safeCount(this.messagesTable),
1183
+ safeCount(this.topicsTable),
1184
+ safeCount(this.factsTable)
1185
+ ]);
1186
+ return { sessions, messages, topics, facts };
1187
+ }
1188
+ /**
1189
+ * 按天聚合最近 days 天的活跃趋势(概览图表用)。
1190
+ * 返回连续日期序列(含无数据的零值天),按本地日期分桶。
1191
+ */
1192
+ async trendDaily(days) {
1193
+ const dayMs = 864e5;
1194
+ const now = /* @__PURE__ */ new Date();
1195
+ const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
1196
+ const startMs = todayMidnight - (days - 1) * dayMs;
1197
+ const fmt = (ts) => {
1198
+ const d = new Date(ts);
1199
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
1200
+ const dd = String(d.getDate()).padStart(2, "0");
1201
+ return `${d.getFullYear()}-${mm}-${dd}`;
1202
+ };
1203
+ const readCreatedAt = async (table) => {
1204
+ try {
1205
+ const rows = await table.query().select(["created_at"]).where(`created_at >= ${startMs}`).limit(1e6).toArray();
1206
+ return rows.map((r) => Number(r.created_at)).filter((n) => Number.isFinite(n));
1207
+ } catch {
1208
+ return [];
1209
+ }
1210
+ };
1211
+ const [sessionTs, messageTs, factTs] = await Promise.all([
1212
+ readCreatedAt(this.sessionsTable),
1213
+ readCreatedAt(this.messagesTable),
1214
+ readCreatedAt(this.factsTable)
1215
+ ]);
1216
+ const buckets = /* @__PURE__ */ new Map();
1217
+ for (let i = 0; i < days; i++) {
1218
+ const key = fmt(startMs + i * dayMs);
1219
+ buckets.set(key, { date: key, sessions: 0, messages: 0, facts: 0 });
1220
+ }
1221
+ const tally = (list, field) => {
1222
+ for (const ts of list) {
1223
+ const b = buckets.get(fmt(ts));
1224
+ if (b) b[field]++;
1225
+ }
1226
+ };
1227
+ tally(sessionTs, "sessions");
1228
+ tally(messageTs, "messages");
1229
+ tally(factTs, "facts");
1230
+ return [...buckets.values()];
1231
+ }
1232
+ // ── 知识库:documents / chunks ─────────────────────────────────────────────
1233
+ async addDocument(doc) {
1234
+ await this.documentsTable.add([documentToRow(doc)]);
1235
+ if (this.isNewDocumentsTable) {
1236
+ await this.ensureScalarIndex(this.documentsTable, "doc_id");
1237
+ this.isNewDocumentsTable = false;
1238
+ }
1239
+ }
1240
+ async addChunks(chunks) {
1241
+ if (chunks.length === 0) return;
1242
+ await this.chunksTable.add(chunks.map(chunkToRow));
1243
+ if (this.isNewChunksTable) {
1244
+ await this.ensureScalarIndex(this.chunksTable, "doc_id");
1245
+ this.isNewChunksTable = false;
1246
+ }
1247
+ }
1248
+ async updateDocumentGraphFlag(docId, hasGraph) {
1249
+ await this.documentsTable.update({
1250
+ values: { has_graph: hasGraph ? 1 : 0, updated_at: Date.now() },
1251
+ where: eqFilter("doc_id", docId)
1252
+ });
1253
+ }
1254
+ async getDocument(docId) {
1255
+ try {
1256
+ const rows = await this.documentsTable.query().where(eqFilter("doc_id", docId)).limit(1).toArray();
1257
+ if (rows.length === 0) return null;
1258
+ return rowToDocument(rows[0]);
1259
+ } catch {
1260
+ return null;
1261
+ }
1262
+ }
1263
+ /** 查找同域同 hash 的文档(去重用)*/
1264
+ async findDocumentByHash(contentHash, filter) {
1265
+ try {
1266
+ const where = filter ? `${eqFilter("content_hash", contentHash)} AND ${filter}` : eqFilter("content_hash", contentHash);
1267
+ const rows = await this.documentsTable.query().where(where).limit(1).toArray();
1268
+ if (rows.length === 0) return null;
1269
+ return rowToDocument(rows[0]);
1270
+ } catch {
1271
+ return null;
1272
+ }
1273
+ }
1274
+ /** 按域过滤返回 docId 列表(chunkRedundantIds=false 时用于 chunk 过滤)*/
1275
+ async getDocIdsByDomain(filter) {
1276
+ try {
1277
+ const q = this.documentsTable.query().select(["doc_id"]).limit(1e5);
1278
+ if (filter) q.where(filter);
1279
+ const rows = await q.toArray();
1280
+ return [...new Set(rows.map((r) => r.doc_id))];
1281
+ } catch {
1282
+ return [];
1283
+ }
1284
+ }
1285
+ /** 文档级粗召回:对摘要向量做向量搜索,定位候选文档 */
1286
+ async searchDocuments(vector, filter, limit = 5) {
1287
+ try {
1288
+ const q = this.documentsTable.vectorSearch(vector).limit(limit);
1289
+ if (filter) q.where(filter);
1290
+ const rows = await q.toArray();
1291
+ return rows.map((r) => ({
1292
+ ...rowToDocument(r),
1293
+ _distance: r._distance
1294
+ }));
1295
+ } catch {
1296
+ return [];
1297
+ }
1298
+ }
1299
+ /** 知识片段混合检索(BM25 + 向量),失败回退纯向量 */
1300
+ async searchChunks(query, vector, filter, limit = 8) {
1301
+ try {
1302
+ const q = this.chunksTable.query().fullTextSearch(query).nearestTo(vector).limit(limit);
1303
+ if (filter) q.where(filter);
1304
+ const rows = await q.toArray();
1305
+ return rows.map(rowToChunk);
1306
+ } catch {
1307
+ try {
1308
+ const q = this.chunksTable.vectorSearch(vector).limit(limit);
1309
+ if (filter) q.where(filter);
1310
+ const rows = await q.toArray();
1311
+ return rows.map(rowToChunk);
1312
+ } catch {
1313
+ return [];
1314
+ }
1315
+ }
1316
+ }
1317
+ async deleteDocument(docId) {
1318
+ await this.documentsTable.delete(eqFilter("doc_id", docId));
1319
+ }
1320
+ async deleteChunksByDoc(docId) {
1321
+ await this.chunksTable.delete(eqFilter("doc_id", docId));
1322
+ }
1323
+ /** 全量读取文档(管理面板用),按更新时间倒序 */
1324
+ async getAllDocuments() {
1325
+ try {
1326
+ const rows = await this.documentsTable.query().toArray();
1327
+ return rows.sort((a, b) => Number(b.updated_at) - Number(a.updated_at)).map(rowToDocument);
1328
+ } catch {
1329
+ return [];
1330
+ }
1331
+ }
1332
+ /** 知识库行数统计 */
1333
+ async countKnowledge() {
1334
+ const safeCount = async (table) => {
1335
+ try {
1336
+ return await table.countRows();
1337
+ } catch {
1338
+ return 0;
1339
+ }
1340
+ };
1341
+ const [documents, chunks] = await Promise.all([
1342
+ safeCount(this.documentsTable),
1343
+ safeCount(this.chunksTable)
1344
+ ]);
1345
+ return { documents, chunks };
1346
+ }
1347
+ /**
1348
+ * 根据 metadata 字段内容构建 SQL LIKE 过滤条件。
1349
+ *
1350
+ * metadata 以 JSON 字符串存储,此方法将键值对转换为 SQL LIKE 表达式,
1351
+ * 可直接传给 searchMessages / hybridSearchTopics 的 filter 参数。
1352
+ *
1353
+ * 示例:buildMetadataFilter({ env: "prod", version: 2 })
1354
+ * → `metadata LIKE '%"env":"prod"%' AND metadata LIKE '%"version":2%'`
1355
+ *
1356
+ * 注意:仅适合简单标量值(字符串、数字、布尔)的精确匹配。
1357
+ * 复杂嵌套对象或含空格的 JSON 值可能无法可靠匹配。
1358
+ */
1359
+ static buildMetadataFilter(conditions) {
1360
+ return Object.entries(conditions).map(([key, value]) => {
1361
+ const jsonValue = typeof value === "string" ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : String(value);
1362
+ return `metadata LIKE '%"${key}":${jsonValue}%'`;
1363
+ }).join(" AND ");
1364
+ }
1365
+ };
1366
+
1367
+ // src/llm/llm.service.ts
1368
+ var ENTITY_EXTRACTOR_TOOL = {
1369
+ type: "function",
1370
+ function: {
1371
+ name: "extract_entities",
1372
+ description: "\u4ECE\u5BF9\u8BDD\u5185\u5BB9\u4E2D\u63D0\u53D6\u547D\u540D\u5B9E\u4F53\u548C\u5173\u7CFB\u3002\u4EC5\u5728\u5BF9\u8BDD\u4E2D\u660E\u786E\u51FA\u73B0\u6709\u610F\u4E49\u7684\u5B9E\u4F53\uFF08\u4EBA\u7269\u3001\u7EC4\u7EC7\u3001\u9879\u76EE\u3001\u5730\u70B9\u3001\u6280\u672F\u7B49\uFF09\u65F6\u8C03\u7528\u6B64\u5DE5\u5177\uFF0C\u5426\u5219\u4E0D\u8C03\u7528\u3002",
1373
+ parameters: {
1374
+ type: "object",
1375
+ properties: {
1376
+ entities: {
1377
+ type: "array",
1378
+ description: "\u5BF9\u8BDD\u4E2D\u51FA\u73B0\u7684\u5B9E\u4F53\u5217\u8868",
1379
+ items: {
1380
+ type: "object",
1381
+ properties: {
1382
+ name: { type: "string", description: "\u5B9E\u4F53\u540D\u79F0" },
1383
+ type: {
1384
+ type: "string",
1385
+ enum: [...DEFAULT_NODE_TYPES],
1386
+ description: "\u5B9E\u4F53\u7C7B\u578B"
1387
+ },
1388
+ meta: {
1389
+ type: "object",
1390
+ description: "\u5B9E\u4F53\u9644\u52A0\u5C5E\u6027\uFF08\u5E74\u9F84\u3001\u6027\u522B\u3001\u804C\u4F4D\u7B49\uFF09\uFF0C\u65E0\u5219\u7701\u7565",
1391
+ additionalProperties: true
1392
+ }
1393
+ },
1394
+ required: ["name", "type"]
1395
+ }
1396
+ },
1397
+ relations: {
1398
+ type: "array",
1399
+ description: "\u5BF9\u8BDD\u4E2D\u51FA\u73B0\u7684\u5173\u7CFB\u5217\u8868",
1400
+ items: {
1401
+ type: "object",
1402
+ properties: {
1403
+ from: { type: "string", description: "\u5173\u7CFB\u8D77\u70B9\u5B9E\u4F53\u540D\u79F0" },
1404
+ to: { type: "string", description: "\u5173\u7CFB\u7EC8\u70B9\u5B9E\u4F53\u540D\u79F0" },
1405
+ type: {
1406
+ type: "string",
1407
+ enum: [...DEFAULT_RELATION_TYPES],
1408
+ description: "\u5173\u7CFB\u7C7B\u578B"
1409
+ },
1410
+ happenedAt: {
1411
+ type: "number",
1412
+ description: "\u5173\u7CFB\u53D1\u751F\u65F6\u95F4\uFF08Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF09\u3002\u5982\u6709\u65F6\u95F4\u63CF\u8FF0\uFF08\u5982\u300C\u53BB\u5E74\u300D\uFF09\u8BF7\u6839\u636E\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\u63A8\u7B97\uFF0C\u65E0\u6CD5\u786E\u5B9A\u5219\u7701\u7565\u6B64\u5B57\u6BB5"
1413
+ },
1414
+ meta: {
1415
+ type: "object",
1416
+ description: "\u5173\u7CFB\u9644\u52A0\u5C5E\u6027",
1417
+ additionalProperties: true
1418
+ }
1419
+ },
1420
+ required: ["from", "to", "type"]
1421
+ }
1422
+ }
1423
+ },
1424
+ required: ["entities", "relations"]
1425
+ }
1426
+ }
1427
+ };
1428
+ var LlmService = class {
1429
+ config;
1430
+ constructor(config) {
1431
+ this.config = config;
1432
+ }
1433
+ // ── 基础请求(不使用工具)──────────────────────────────────────────────────────
1434
+ /**
1435
+ * @param jsonMode 是否要求 JSON 输出(response_format: json_object)。
1436
+ * 仅在提示词明确要求 JSON 时启用;返回自然语言的调用(如 summarizeSearchResults)
1437
+ * 必须传 false,否则部分端点会因 "messages 未含 json 字样" 而 400。
1438
+ */
1439
+ async chatCompletion(systemPrompt, userPrompt, jsonMode = true) {
1440
+ const url = `${this.config.llmBaseUrl.replace(/\/$/, "")}/chat/completions`;
1441
+ const body = {
1442
+ model: this.config.llmModel,
1443
+ stream: false,
1444
+ messages: [
1445
+ { role: "system", content: systemPrompt },
1446
+ { role: "user", content: userPrompt }
1447
+ ]
1448
+ };
1449
+ if (jsonMode) body.response_format = { type: "json_object" };
1450
+ const data = await postJsonWithRetry(
1451
+ url,
1452
+ { Authorization: `Bearer ${this.config.llmApiKey}` },
1453
+ body,
1454
+ { timeoutMs: this.config.httpTimeoutMs, maxRetries: this.config.httpMaxRetries },
1455
+ "LLM API"
1456
+ );
1457
+ return data.choices[0].message.content ?? "";
1458
+ }
1459
+ // ── 带工具调用的请求 ──────────────────────────────────────────────────────────
1460
+ async chatWithTools(systemPrompt, userPrompt) {
1461
+ const url = `${this.config.llmBaseUrl.replace(/\/$/, "")}/chat/completions`;
1462
+ const data = await postJsonWithRetry(
1463
+ url,
1464
+ { Authorization: `Bearer ${this.config.llmApiKey}` },
1465
+ {
1466
+ model: this.config.llmModel,
1467
+ stream: false,
1468
+ messages: [
1469
+ { role: "system", content: systemPrompt },
1470
+ { role: "user", content: userPrompt }
1471
+ ],
1472
+ tools: [ENTITY_EXTRACTOR_TOOL],
1473
+ tool_choice: "auto"
1474
+ },
1475
+ { timeoutMs: this.config.httpTimeoutMs, maxRetries: this.config.httpMaxRetries },
1476
+ "LLM API"
1477
+ );
1478
+ return data.choices[0].message;
1479
+ }
1480
+ // ── 第一步:生成三级摘要(JSON 输出)──────────────────────────────────────────
1481
+ async summarizeMessages(messages) {
1482
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1483
+ const { detailMaxTokens, summaryMaxTokens, conciseMaxTokens } = this.config;
1484
+ const systemPrompt = `\u4F60\u662F\u4E00\u4E2A\u5BF9\u8BDD\u8BB0\u5FC6\u7BA1\u7406\u52A9\u624B\u3002\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\uFF1A${now}\u3002
1485
+ \u8BF7\u5BF9\u7ED9\u5B9A\u7684\u5BF9\u8BDD\u5185\u5BB9\u751F\u6210\u4E00\u4E2A\u4E3B\u9898\u6807\u9898\u548C\u4E09\u7EA7\u6458\u8981\u3002
1486
+
1487
+ \u8981\u6C42\uFF1A
1488
+ - title\uFF1A\u4E3B\u9898\u6807\u9898\uFF0C\u4E00\u53E5\u8BDD\uFF08\u5EFA\u8BAE\u4E0D\u8D85\u8FC7 20 \u5B57\uFF09\uFF0C\u9AD8\u5EA6\u6982\u62EC\u8FD9\u6BB5\u5BF9\u8BDD\u7684\u4E3B\u9898\uFF0C\u7528\u4E8E\u5217\u8868\u5C55\u793A
1489
+ - detail\uFF1A\u8BE6\u7EC6\u6458\u8981\uFF0C\u6700\u591A ${detailMaxTokens} tokens\uFF0C\u4FDD\u7559\u5173\u952E\u4E8B\u5B9E\u3001\u4EBA\u7269\u548C\u65F6\u95F4
1490
+ - summary\uFF1A\u4E2D\u7B49\u6458\u8981\uFF0C\u6700\u591A ${summaryMaxTokens} tokens
1491
+ - concise\uFF1A\u4E00\u4E24\u53E5\u8BDD\u7684\u7B80\u6D01\u6458\u8981\uFF0C\u6700\u591A ${conciseMaxTokens} tokens
1492
+
1493
+ \u8F93\u51FA\u5FC5\u987B\u662F\u5408\u6CD5\u7684 JSON \u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u4EFB\u4F55 markdown \u6807\u8BB0\uFF0C\u683C\u5F0F\u5982\u4E0B\uFF1A
1494
+ {"title": "...", "detail": "...", "summary": "...", "concise": "..."}`;
1495
+ const formatted = messages.map((m) => `[${new Date(m.createdAt).toISOString()}] ${m.talkerId || "user"}: ${m.content}`).join("\n");
1496
+ const raw = await this.chatCompletion(systemPrompt, `\u8BF7\u5BF9\u4EE5\u4E0B\u5BF9\u8BDD\u751F\u6210\u4E3B\u9898\u6807\u9898\u548C\u4E09\u7EA7\u6458\u8981\uFF1A
1497
+
1498
+ ${formatted}`);
1499
+ const parsed = JSON.parse(raw);
1500
+ return { title: parsed.title ?? "", detail: parsed.detail, summary: parsed.summary, concise: parsed.concise };
1501
+ }
1502
+ // ── 第二步:通过工具调用提取实体和关系 ──────────────────────────────────────
1503
+ async extractEntitiesFromMessages(messages) {
1504
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1505
+ const systemPrompt = `\u4F60\u662F\u4E00\u4E2A\u5B9E\u4F53\u5173\u7CFB\u62BD\u53D6\u52A9\u624B\u3002\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\uFF1A${now}\u3002
1506
+ \u68C0\u67E5\u7ED9\u5B9A\u7684\u5BF9\u8BDD\u5185\u5BB9\uFF0C\u5224\u65AD\u5176\u4E2D\u662F\u5426\u5B58\u5728\u503C\u5F97\u8BB0\u5F55\u7684\u547D\u540D\u5B9E\u4F53\uFF08\u4EBA\u7269\u3001\u7EC4\u7EC7\u3001\u9879\u76EE\u3001\u5730\u70B9\u3001\u6280\u672F\u7B49\uFF09\u548C\u5B83\u4EEC\u4E4B\u95F4\u7684\u5173\u7CFB\u3002
1507
+ - \u5982\u679C\u5B58\u5728\u6709\u610F\u4E49\u7684\u5B9E\u4F53\u548C\u5173\u7CFB\uFF0C\u8C03\u7528 extract_entities \u5DE5\u5177\u8FDB\u884C\u62BD\u53D6
1508
+ - \u5982\u679C\u5BF9\u8BDD\u5185\u5BB9\u4E2D\u6CA1\u6709\u503C\u5F97\u62BD\u53D6\u7684\u5B9E\u4F53\uFF08\u5982\u53EA\u662F\u65E5\u5E38\u95F2\u804A\uFF09\uFF0C\u5219\u4E0D\u8C03\u7528\u5DE5\u5177\uFF0C\u76F4\u63A5\u56DE\u590D"\u65E0\u9700\u62BD\u53D6"
1509
+ - \u8282\u70B9\u7C7B\u578B\u9650\u4E8E\uFF1A${NODE_TYPES_LIST}
1510
+ - \u5173\u7CFB\u7C7B\u578B\u9650\u4E8E\uFF1A${RELATION_TYPES_LIST}
1511
+ - \u5173\u7CFB\u5982\u6709\u65F6\u95F4\u4FE1\u606F\uFF08\u5982"\u53BB\u5E74"\u3001"\u4E0A\u5468"\uFF09\uFF0C\u8BF7\u6839\u636E\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\u63A8\u7B97\u4E3A Unix \u6BEB\u79D2\u65F6\u95F4\u6233`;
1512
+ const formatted = messages.map((m) => `[${new Date(m.createdAt).toISOString()}] ${m.talkerId || "user"}: ${m.content}`).join("\n");
1513
+ try {
1514
+ const message = await this.chatWithTools(
1515
+ systemPrompt,
1516
+ `\u8BF7\u5206\u6790\u4EE5\u4E0B\u5BF9\u8BDD\u5185\u5BB9\uFF0C\u63D0\u53D6\u5176\u4E2D\u7684\u5B9E\u4F53\u548C\u5173\u7CFB\uFF1A
1517
+
1518
+ ${formatted}`
1519
+ );
1520
+ if (message.tool_calls && message.tool_calls.length > 0) {
1521
+ const toolCall = message.tool_calls[0];
1522
+ if (toolCall.function.name === "extract_entities") {
1523
+ const args = JSON.parse(toolCall.function.arguments);
1524
+ return {
1525
+ entities: args.entities ?? [],
1526
+ relations: args.relations ?? []
1527
+ };
1528
+ }
1529
+ }
1530
+ } catch (err) {
1531
+ console.error("[LlmService] extractEntitiesFromMessages failed:", err);
1532
+ }
1533
+ return { entities: [], relations: [] };
1534
+ }
1535
+ // ── 对外主接口:压缩 + 实体抽取(并发执行两步)────────────────────────────────
1536
+ async compress(messages) {
1537
+ const [summary, extraction] = await Promise.all([
1538
+ this.summarizeMessages(messages),
1539
+ this.extractEntitiesFromMessages(messages)
1540
+ ]);
1541
+ return {
1542
+ title: summary.title,
1543
+ detail: summary.detail,
1544
+ summary: summary.summary,
1545
+ concise: summary.concise,
1546
+ entities: extraction.entities,
1547
+ relations: extraction.relations
1548
+ };
1549
+ }
1550
+ // ── 搜索判断 ──────────────────────────────────────────────────────────────────
1551
+ async judgeNeedsGraphSearch(query) {
1552
+ const systemPrompt = `\u4F60\u662F\u4E00\u4E2A\u610F\u56FE\u5224\u65AD\u52A9\u624B\u3002\u5224\u65AD\u7528\u6237\u7684\u67E5\u8BE2\u662F\u5426\u6D89\u53CA\u5B9E\u4F53\u5173\u7CFB\u67E5\u8BE2\uFF08\u5982\uFF1A\u67D0\u4EBA\u8BA4\u8BC6\u8C01\u3001\u67D0\u9879\u76EE\u7528\u4E86\u4EC0\u4E48\u6280\u672F\u3001\u8C01\u8D1F\u8D23\u4EC0\u4E48\u4EFB\u52A1\u7B49\uFF09\u3002
1553
+ \u53EA\u8F93\u51FA\u5408\u6CD5 JSON\uFF1A{"needsGraph": true} \u6216 {"needsGraph": false}`;
1554
+ try {
1555
+ const raw = await this.chatCompletion(systemPrompt, `\u7528\u6237\u67E5\u8BE2\uFF1A${query}`);
1556
+ const result = JSON.parse(raw);
1557
+ return result.needsGraph === true;
1558
+ } catch {
1559
+ return false;
1560
+ }
1561
+ }
1562
+ // ── 知识库:文档摘要 + 是否值得建图判定 ────────────────────────────────────────
1563
+ /**
1564
+ * 为文档生成摘要,并判定是否值得构建知识图谱。
1565
+ * 一次 LLM 调用同时产出两者,供 addDocument 在 buildGraph="auto" 时使用。
1566
+ */
1567
+ async summarizeDocument(content) {
1568
+ const { summaryMaxTokens } = this.config;
1569
+ const input = content.length > 12e3 ? content.slice(0, 12e3) : content;
1570
+ const systemPrompt = `\u4F60\u662F\u77E5\u8BC6\u5E93\u6587\u6863\u52A9\u624B\u3002\u8BF7\u5BF9\u7ED9\u5B9A\u7684\u6587\u6863\u5185\u5BB9\u5B8C\u6210\u4E24\u4EF6\u4E8B\uFF1A
1571
+ 1. \u751F\u6210\u4E00\u6BB5\u6458\u8981\uFF08summary\uFF09\uFF0C\u6700\u591A ${summaryMaxTokens} tokens\uFF0C\u6982\u62EC\u6587\u6863\u4E3B\u65E8\u4E0E\u5173\u952E\u4FE1\u606F\uFF0C\u4F9B\u540E\u7EED\u7C97\u53EC\u56DE\u5B9A\u4F4D\u3002
1572
+ 2. \u5224\u65AD\u8BE5\u6587\u6863\u662F\u5426\u503C\u5F97\u6784\u5EFA\u77E5\u8BC6\u56FE\u8C31\uFF08worthGraph\uFF09\uFF1A\u82E5\u6587\u6863\u5305\u542B\u6709\u4EF7\u503C\u7684\u547D\u540D\u5B9E\u4F53\u53CA\u5176\u5173\u7CFB\uFF08\u4EBA\u7269\u3001\u7EC4\u7EC7\u3001\u9879\u76EE\u3001\u4E8B\u4EF6\u3001\u6280\u672F\u3001\u4EA7\u54C1\u3001\u5730\u70B9\u7B49\u7ED3\u6784\u5316\u77E5\u8BC6\uFF09\u5219\u4E3A true\uFF1B\u82E5\u4E3A\u7EAF\u4EE3\u7801\u3001\u7EAF\u6570\u636E\u8868\u3001\u65E0\u660E\u663E\u5B9E\u4F53\u7684\u6D41\u6C34\u6587\u672C\u6216\u683C\u5F0F\u5316\u65E5\u5FD7\u5219\u4E3A false\u3002
1573
+
1574
+ \u53EA\u8F93\u51FA\u5408\u6CD5 JSON\uFF0C\u4E0D\u8981\u4EFB\u4F55 markdown \u6807\u8BB0\uFF1A{"summary": "...", "worthGraph": true}`;
1575
+ try {
1576
+ const raw = await this.chatCompletion(systemPrompt, `\u6587\u6863\u5185\u5BB9\uFF1A
1577
+
1578
+ ${input}`);
1579
+ const parsed = JSON.parse(raw);
1580
+ return {
1581
+ summary: parsed.summary ?? "",
1582
+ worthGraph: parsed.worthGraph === true
1583
+ };
1584
+ } catch (err) {
1585
+ console.error("[LlmService] summarizeDocument failed:", err);
1586
+ return { summary: "", worthGraph: false };
1587
+ }
1588
+ }
1589
+ /**
1590
+ * 从任意文本(知识片段)中抽取实体和关系,复用 extract_entities 工具。
1591
+ */
1592
+ async extractEntitiesFromText(text) {
1593
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1594
+ const systemPrompt = `\u4F60\u662F\u4E00\u4E2A\u5B9E\u4F53\u5173\u7CFB\u62BD\u53D6\u52A9\u624B\u3002\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\uFF1A${now}\u3002
1595
+ \u68C0\u67E5\u7ED9\u5B9A\u7684\u77E5\u8BC6\u6587\u6863\u7247\u6BB5\uFF0C\u5224\u65AD\u5176\u4E2D\u662F\u5426\u5B58\u5728\u503C\u5F97\u8BB0\u5F55\u7684\u547D\u540D\u5B9E\u4F53\uFF08\u4EBA\u7269\u3001\u7EC4\u7EC7\u3001\u9879\u76EE\u3001\u5730\u70B9\u3001\u6280\u672F\u3001\u4EA7\u54C1\u3001\u4E8B\u4EF6\u7B49\uFF09\u548C\u5B83\u4EEC\u4E4B\u95F4\u7684\u5173\u7CFB\u3002
1596
+ - \u5982\u679C\u5B58\u5728\u6709\u610F\u4E49\u7684\u5B9E\u4F53\u548C\u5173\u7CFB\uFF0C\u8C03\u7528 extract_entities \u5DE5\u5177\u8FDB\u884C\u62BD\u53D6
1597
+ - \u5982\u679C\u7247\u6BB5\u4E2D\u6CA1\u6709\u503C\u5F97\u62BD\u53D6\u7684\u5B9E\u4F53\uFF0C\u5219\u4E0D\u8C03\u7528\u5DE5\u5177\uFF0C\u76F4\u63A5\u56DE\u590D"\u65E0\u9700\u62BD\u53D6"
1598
+ - \u8282\u70B9\u7C7B\u578B\u9650\u4E8E\uFF1A${NODE_TYPES_LIST}
1599
+ - \u5173\u7CFB\u7C7B\u578B\u9650\u4E8E\uFF1A${RELATION_TYPES_LIST}
1600
+ - \u5173\u7CFB\u5982\u6709\u65F6\u95F4\u4FE1\u606F\uFF0C\u8BF7\u6839\u636E\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\u63A8\u7B97\u4E3A Unix \u6BEB\u79D2\u65F6\u95F4\u6233`;
1601
+ try {
1602
+ const message = await this.chatWithTools(
1603
+ systemPrompt,
1604
+ `\u8BF7\u5206\u6790\u4EE5\u4E0B\u77E5\u8BC6\u7247\u6BB5\uFF0C\u63D0\u53D6\u5176\u4E2D\u7684\u5B9E\u4F53\u548C\u5173\u7CFB\uFF1A
1605
+
1606
+ ${text}`
1607
+ );
1608
+ if (message.tool_calls && message.tool_calls.length > 0) {
1609
+ const toolCall = message.tool_calls[0];
1610
+ if (toolCall.function.name === "extract_entities") {
1611
+ const args = JSON.parse(toolCall.function.arguments);
1612
+ return {
1613
+ entities: args.entities ?? [],
1614
+ relations: args.relations ?? []
1615
+ };
1616
+ }
1617
+ }
1618
+ } catch (err) {
1619
+ console.error("[LlmService] extractEntitiesFromText failed:", err);
1620
+ }
1621
+ return { entities: [], relations: [] };
1622
+ }
1623
+ // ── ask 结果总结 ──────────────────────────────────────────────────────────────
1624
+ async summarizeSearchResults(query, results, maxChars) {
1625
+ const limitHint = maxChars ? `\u56DE\u7B54\u4E0D\u8D85\u8FC7 ${maxChars} \u5B57\u3002` : "";
1626
+ const systemPrompt = `\u4F60\u662F\u4E00\u4E2A\u8BB0\u5FC6\u95EE\u7B54\u52A9\u624B\u3002\u6839\u636E\u63D0\u4F9B\u7684\u8BB0\u5FC6\u7247\u6BB5\uFF0C\u7B80\u6D01\u51C6\u786E\u5730\u56DE\u7B54\u7528\u6237\u95EE\u9898\u3002${limitHint}`;
1627
+ const context = results.map((r, i) => `[${i + 1}] ${r.content}`).join("\n");
1628
+ return this.chatCompletion(systemPrompt, `\u95EE\u9898\uFF1A${query}
1629
+
1630
+ \u76F8\u5173\u8BB0\u5FC6\uFF1A
1631
+ ${context}`, false);
1632
+ }
1633
+ };
1634
+
1635
+ // src/manager/compress.manager.ts
1636
+ import { v4 as uuidv4 } from "uuid";
1637
+
1638
+ // src/manager/semaphore.ts
1639
+ var Semaphore = class {
1640
+ count;
1641
+ queue = [];
1642
+ constructor(max) {
1643
+ this.count = max;
1644
+ }
1645
+ async run(fn) {
1646
+ await this.acquire();
1647
+ try {
1648
+ return await fn();
1649
+ } finally {
1650
+ this.release();
1651
+ }
1652
+ }
1653
+ acquire() {
1654
+ if (this.count > 0) {
1655
+ this.count--;
1656
+ return Promise.resolve();
1657
+ }
1658
+ return new Promise((resolve) => {
1659
+ this.queue.push(resolve);
1660
+ });
1661
+ }
1662
+ release() {
1663
+ const next = this.queue.shift();
1664
+ if (next) {
1665
+ next();
1666
+ } else {
1667
+ this.count++;
1668
+ }
1669
+ }
1670
+ };
1671
+
1672
+ // src/manager/compress.manager.ts
1673
+ var CompressManager = class {
1674
+ config;
1675
+ lance;
1676
+ grafeo;
1677
+ llm;
1678
+ embed;
1679
+ sessionCache;
1680
+ semaphore;
1681
+ sessionChain = /* @__PURE__ */ new Map();
1682
+ constructor(config, lance, grafeo, llm, embed, sessionCache) {
1683
+ this.config = config;
1684
+ this.lance = lance;
1685
+ this.grafeo = grafeo;
1686
+ this.llm = llm;
1687
+ this.embed = embed;
1688
+ this.sessionCache = sessionCache;
1689
+ this.semaphore = new Semaphore(config.maxConcurrentCompressions);
1690
+ }
1691
+ triggerCompress(sessionId, force = false, waitGraph = false) {
1692
+ const chain = this.sessionChain.get(sessionId) ?? Promise.resolve();
1693
+ const next = chain.then(
1694
+ () => this.semaphore.run(() => this.doCompress(sessionId, force, waitGraph))
1695
+ );
1696
+ this.sessionChain.set(sessionId, next.catch(() => {
1697
+ }));
1698
+ return next;
1699
+ }
1700
+ async doCompress(sessionId, force, waitGraph) {
1701
+ const messages = this.sessionCache.getSessionMessages(sessionId);
1702
+ if (messages.length === 0) return;
1703
+ const entry = this.sessionCache.getEntry(sessionId);
1704
+ if (!entry) return;
1705
+ if (!force && entry.totalTokens < this.config.sessionTokenLimit) return;
1706
+ const { chatId, userId } = entry.ids;
1707
+ const startTime = messages[0].createdAt;
1708
+ const endTime = messages[messages.length - 1].createdAt;
1709
+ let summary;
1710
+ try {
1711
+ summary = await this.llm.summarizeMessages(messages);
1712
+ } catch (err) {
1713
+ console.error(`[CompressManager] LLM compress failed for session ${sessionId}:`, err);
1714
+ return;
1715
+ }
1716
+ const { title, detail, summary: summaryText, concise } = summary;
1717
+ const topicTextForEmbed = [detail, summaryText, concise].find((t) => t.length > 0) ?? "";
1718
+ let topicVector = [];
1719
+ try {
1720
+ topicVector = await this.embed.embedOne(topicTextForEmbed);
1721
+ } catch (err) {
1722
+ console.error(`[CompressManager] Embed topic failed:`, err);
1723
+ }
1724
+ const topic = {
1725
+ summaryId: uuidv4(),
1726
+ sessionId,
1727
+ userId,
1728
+ chatId,
1729
+ title,
1730
+ detail,
1731
+ summary: summaryText,
1732
+ concise,
1733
+ startTime,
1734
+ endTime,
1735
+ createdAt: Date.now(),
1736
+ updatedAt: Date.now(),
1737
+ recallCount: 0,
1738
+ vector: topicVector
1739
+ };
1740
+ try {
1741
+ await this.lance.addTopic(topic);
1742
+ } catch (err) {
1743
+ console.error(`[CompressManager] Save topic failed:`, err);
1744
+ }
1745
+ this.sessionCache.clearMessages(sessionId, endTime);
1746
+ const [n1, n2, n3] = this.config.topicRatio;
1747
+ try {
1748
+ const topicGroups = await this.lance.getRecentTopics(chatId, userId, n1, n2, n3);
1749
+ this.sessionCache.buildHistoryWindow(sessionId, topicGroups);
1750
+ } catch (err) {
1751
+ console.error(`[CompressManager] Rebuild history window failed:`, err);
1752
+ }
1753
+ const graphTask = this.extractAndPersistGraph(messages, sessionId, chatId, userId, endTime);
1754
+ if (waitGraph) {
1755
+ await withTimeout(graphTask, this.config.graphBuildTimeoutMs, "flushChat graph build");
1756
+ } else {
1757
+ graphTask.catch((err) => {
1758
+ console.error(`[CompressManager] Background graph persist failed:`, err);
1759
+ });
1760
+ }
1761
+ }
1762
+ async extractAndPersistGraph(messages, sessionId, chatId, userId, endTime) {
1763
+ const { entities, relations } = await this.llm.extractEntitiesFromMessages(messages);
1764
+ if (entities.length === 0 && relations.length === 0) return;
1765
+ await this.persistGraph(entities, relations, sessionId, chatId, userId, endTime);
1766
+ }
1767
+ async persistGraph(rawEntities, rawRelations, sessionId, chatId, userId, messageTime) {
1768
+ const entities = rawEntities.map((e) => ({
1769
+ name: e.name,
1770
+ type: e.type,
1771
+ meta: {
1772
+ sessionId,
1773
+ chatId,
1774
+ userId,
1775
+ messageTime,
1776
+ ...e.meta
1777
+ }
1778
+ }));
1779
+ const relations = rawRelations.map((r) => ({
1780
+ from: r.from,
1781
+ to: r.to,
1782
+ type: r.type,
1783
+ happenedAt: r.happenedAt ?? void 0,
1784
+ meta: {
1785
+ sessionId,
1786
+ chatId,
1787
+ userId,
1788
+ messageTime,
1789
+ ...r.meta
1790
+ }
1791
+ }));
1792
+ const allNames = [.../* @__PURE__ */ new Set([...entities.map((e) => e.name), ...relations.flatMap((r) => [r.from, r.to])])];
1793
+ const embeddings = /* @__PURE__ */ new Map();
1794
+ try {
1795
+ const vecs = await this.embed.embed(allNames);
1796
+ allNames.forEach((name, i) => embeddings.set(name, vecs[i]));
1797
+ } catch (err) {
1798
+ console.error(`[CompressManager] Embed entity names failed:`, err);
1799
+ return;
1800
+ }
1801
+ try {
1802
+ await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
1803
+ } catch (err) {
1804
+ console.error(`[CompressManager] Upsert entities/relations failed:`, err);
1805
+ }
1806
+ }
1807
+ };
1808
+ function withTimeout(promise, timeoutMs, label) {
1809
+ let timer;
1810
+ const timeout = new Promise((_, reject) => {
1811
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
1812
+ });
1813
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
1814
+ }
1815
+
1816
+ // src/manager/fact.cache.ts
1817
+ import { v4 as uuidv42 } from "uuid";
1818
+ var FactCache = class {
1819
+ lance;
1820
+ cache = /* @__PURE__ */ new Map();
1821
+ constructor(lance) {
1822
+ this.lance = lance;
1823
+ }
1824
+ async init() {
1825
+ const facts = await this.lance.getAllFacts();
1826
+ for (const fact of facts) {
1827
+ const key = this.key(fact.level, fact.level === "user" ? fact.userId : fact.chatId);
1828
+ const existing = this.cache.get(key) ?? [];
1829
+ existing.push(fact);
1830
+ this.cache.set(key, existing);
1831
+ }
1832
+ }
1833
+ key(level, id) {
1834
+ return `${level}:${id}`;
1835
+ }
1836
+ async add(content, level, userId, chatId, sessionId = "default") {
1837
+ const fact = {
1838
+ factId: uuidv42(),
1839
+ level,
1840
+ chatId,
1841
+ sessionId,
1842
+ userId,
1843
+ content,
1844
+ createdAt: Date.now()
1845
+ };
1846
+ await this.lance.saveFact(fact);
1847
+ const id = level === "user" ? userId : chatId;
1848
+ const k = this.key(level, id);
1849
+ const existing = this.cache.get(k) ?? [];
1850
+ existing.push(fact);
1851
+ this.cache.set(k, existing);
1852
+ }
1853
+ get(level, id) {
1854
+ return this.cache.get(this.key(level, id)) ?? [];
1855
+ }
1856
+ /** 全部 fact(管理面板列表用)*/
1857
+ all() {
1858
+ return [...this.cache.values()].flat();
1859
+ }
1860
+ /** 删除单条 fact:同步从 LanceDB 与内存缓存中移除。返回是否命中。*/
1861
+ async remove(factId) {
1862
+ let hit = false;
1863
+ for (const [k, facts] of this.cache) {
1864
+ const idx = facts.findIndex((f) => f.factId === factId);
1865
+ if (idx >= 0) {
1866
+ facts.splice(idx, 1);
1867
+ if (facts.length === 0) this.cache.delete(k);
1868
+ else this.cache.set(k, facts);
1869
+ hit = true;
1870
+ break;
1871
+ }
1872
+ }
1873
+ await this.lance.deleteFact(factId);
1874
+ return hit;
1875
+ }
1876
+ toString(level, id) {
1877
+ const facts = this.get(level, id);
1878
+ if (facts.length === 0) return "";
1879
+ return facts.sort((a, b) => a.createdAt - b.createdAt).map((f) => `${new Date(f.createdAt).toISOString()}\uFF1A${f.content}`).join("\n");
1880
+ }
1881
+ };
1882
+
1883
+ // src/manager/knowledge.manager.ts
1884
+ import { createHash } from "node:crypto";
1885
+ import { v4 as uuidv43 } from "uuid";
1886
+
1887
+ // src/manager/chunker.ts
1888
+ var HEADING_RE = /^(#{1,6})\s+(.*)$/;
1889
+ function chunkMarkdown(markdown, opts) {
1890
+ const lines = markdown.split(/\r?\n/);
1891
+ const sections = [];
1892
+ const headingStack = [];
1893
+ let current = null;
1894
+ const pathFromStack = () => headingStack.map((h) => h.title).join(" / ");
1895
+ for (const line of lines) {
1896
+ const m = HEADING_RE.exec(line);
1897
+ if (m) {
1898
+ const level = m[1].length;
1899
+ const title = m[2].trim();
1900
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) {
1901
+ headingStack.pop();
1902
+ }
1903
+ headingStack.push({ level, title });
1904
+ current = { headingPath: pathFromStack(), bodyLines: [] };
1905
+ sections.push(current);
1906
+ } else {
1907
+ if (!current) {
1908
+ current = { headingPath: "", bodyLines: [] };
1909
+ sections.push(current);
1910
+ }
1911
+ current.bodyLines.push(line);
1912
+ }
1913
+ }
1914
+ const pieces = [];
1915
+ let ordinal = 0;
1916
+ for (const section of sections) {
1917
+ const body = section.bodyLines.join("\n").trim();
1918
+ const baseText = body || section.headingPath;
1919
+ if (!baseText) continue;
1920
+ for (const text of splitToMaxTokens(baseText, opts.maxTokens, opts.overlap)) {
1921
+ const content = text.trim();
1922
+ if (!content) continue;
1923
+ pieces.push({
1924
+ content,
1925
+ headingPath: section.headingPath,
1926
+ ordinal: ordinal++,
1927
+ tokens: countTokens(content)
1928
+ });
1929
+ }
1930
+ }
1931
+ if (pieces.length === 0 && markdown.trim()) {
1932
+ const content = markdown.trim();
1933
+ return [{ content, headingPath: "", ordinal: 0, tokens: countTokens(content) }];
1934
+ }
1935
+ return pieces;
1936
+ }
1937
+ function splitToMaxTokens(text, maxTokens, overlap) {
1938
+ if (!text) return [];
1939
+ if (countTokens(text) <= maxTokens) return [text];
1940
+ const paragraphs = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
1941
+ const units = [];
1942
+ for (const p of paragraphs) {
1943
+ if (countTokens(p) <= maxTokens) units.push(p);
1944
+ else units.push(...splitLongUnit(p, maxTokens));
1945
+ }
1946
+ const chunks = [];
1947
+ let buf = [];
1948
+ let bufTokens = 0;
1949
+ const flush = () => {
1950
+ if (buf.length === 0) return;
1951
+ const joined = buf.join("\n\n");
1952
+ chunks.push(joined);
1953
+ if (overlap > 0) {
1954
+ const tail = takeTailTokens(joined, overlap);
1955
+ buf = tail ? [tail] : [];
1956
+ bufTokens = tail ? countTokens(tail) : 0;
1957
+ } else {
1958
+ buf = [];
1959
+ bufTokens = 0;
1960
+ }
1961
+ };
1962
+ for (const u of units) {
1963
+ const ut = countTokens(u);
1964
+ if (bufTokens + ut > maxTokens && buf.length > 0) flush();
1965
+ buf.push(u);
1966
+ bufTokens += ut;
1967
+ }
1968
+ if (buf.length > 0) chunks.push(buf.join("\n\n"));
1969
+ return chunks;
1970
+ }
1971
+ function splitLongUnit(text, maxTokens) {
1972
+ const sentences = text.split(/(?<=[。!?!?\n])/).filter((s) => s.length > 0);
1973
+ const out = [];
1974
+ let buf = "";
1975
+ for (const s of sentences) {
1976
+ if (buf && countTokens(buf + s) > maxTokens) {
1977
+ out.push(buf);
1978
+ buf = "";
1979
+ }
1980
+ if (countTokens(s) > maxTokens) {
1981
+ out.push(...hardSplit(s, maxTokens));
1982
+ buf = "";
1983
+ } else {
1984
+ buf += s;
1985
+ }
1986
+ }
1987
+ if (buf) out.push(buf);
1988
+ return out;
1989
+ }
1990
+ function hardSplit(text, maxTokens) {
1991
+ const out = [];
1992
+ let buf = "";
1993
+ for (const ch of text) {
1994
+ if (buf && countTokens(buf + ch) > maxTokens) {
1995
+ out.push(buf);
1996
+ buf = "";
1997
+ }
1998
+ buf += ch;
1999
+ }
2000
+ if (buf) out.push(buf);
2001
+ return out;
2002
+ }
2003
+ function takeTailTokens(text, overlapTokens) {
2004
+ if (overlapTokens <= 0) return "";
2005
+ const chars = [...text];
2006
+ let tail = "";
2007
+ for (let i = chars.length - 1; i >= 0; i--) {
2008
+ tail = chars[i] + tail;
2009
+ if (countTokens(tail) >= overlapTokens) break;
2010
+ }
2011
+ return tail;
2012
+ }
2013
+
2014
+ // src/manager/knowledge.manager.ts
2015
+ var KnowledgeManager = class {
2016
+ config;
2017
+ lance;
2018
+ grafeo;
2019
+ llm;
2020
+ embed;
2021
+ semaphore;
2022
+ constructor(config, lance, grafeo, llm, embed) {
2023
+ this.config = config;
2024
+ this.lance = lance;
2025
+ this.grafeo = grafeo;
2026
+ this.llm = llm;
2027
+ this.embed = embed;
2028
+ this.semaphore = new Semaphore(config.maxConcurrentCompressions);
2029
+ }
2030
+ // ── 摄入 ───────────────────────────────────────────────────────────────────────
2031
+ async addDocument(opts) {
2032
+ const userId = opts.userId ?? DEFAULT_USER_ID;
2033
+ const chatId = opts.chatId ?? DEFAULT_CHAT_ID;
2034
+ const sessionId = opts.sessionId ?? DEFAULT_SESSION_ID;
2035
+ const buildGraph = opts.buildGraph ?? this.config.buildGraphDefault;
2036
+ const now = Date.now();
2037
+ const content = opts.content;
2038
+ const contentHash = createHash("sha256").update(content).digest("hex");
2039
+ const domainFilter = `user_id = '${esc(userId)}' AND chat_id = '${esc(chatId)}' AND session_id = '${esc(sessionId)}'`;
2040
+ const existing = await this.lance.findDocumentByHash(contentHash, domainFilter);
2041
+ if (existing) return { docId: existing.docId };
2042
+ const pieces = chunkMarkdown(content, {
2043
+ maxTokens: this.config.chunkMaxTokens,
2044
+ overlap: this.config.chunkOverlap
2045
+ });
2046
+ const { summary, worthGraph } = await this.llm.summarizeDocument(content);
2047
+ const shouldBuildGraph = buildGraph === "auto" ? worthGraph : buildGraph === true;
2048
+ let summaryVector = [];
2049
+ try {
2050
+ summaryVector = await this.embed.embedOne(summary || content.slice(0, 2e3));
2051
+ } catch (err) {
2052
+ console.error("[KnowledgeManager] embed summary failed:", err);
2053
+ }
2054
+ const docId = uuidv43();
2055
+ const title = opts.title ?? inferTitle(content) ?? opts.sourceName ?? "\u672A\u547D\u540D\u6587\u6863";
2056
+ const doc = {
2057
+ docId,
2058
+ userId,
2059
+ chatId,
2060
+ sessionId,
2061
+ title,
2062
+ sourceName: opts.sourceName ?? "",
2063
+ fullContent: content,
2064
+ contentHash,
2065
+ summary,
2066
+ summaryVector,
2067
+ chunkCount: pieces.length,
2068
+ hasGraph: false,
2069
+ metadata: opts.metadata ?? {},
2070
+ createdAt: now,
2071
+ updatedAt: now
2072
+ };
2073
+ await this.lance.addDocument(doc);
2074
+ const chunkIngest = this.semaphore.run(() => this.ingestChunks(doc, pieces));
2075
+ const graphBuild = chunkIngest.then((chunks) => {
2076
+ if (!shouldBuildGraph) return;
2077
+ return this.buildDocumentGraph(doc, chunks);
2078
+ });
2079
+ if (opts.wait || opts.waitGraph) {
2080
+ await chunkIngest;
2081
+ } else {
2082
+ chunkIngest.catch((err) => {
2083
+ console.error("[KnowledgeManager] background chunk ingest failed:", err);
2084
+ });
2085
+ }
2086
+ if (opts.waitGraph) {
2087
+ await withTimeout2(graphBuild, this.config.graphBuildTimeoutMs, "document graph build");
2088
+ } else {
2089
+ graphBuild.catch((err) => {
2090
+ console.error("[KnowledgeManager] background graph build failed:", err);
2091
+ });
2092
+ }
2093
+ return { docId };
2094
+ }
2095
+ async ingestChunks(doc, pieces) {
2096
+ if (pieces.length === 0) return [];
2097
+ const redundant = this.config.chunkRedundantIds;
2098
+ const embedInputs = pieces.map(
2099
+ (p) => p.headingPath ? `${p.headingPath}
2100
+ ${p.content}` : p.content
2101
+ );
2102
+ let vectors;
2103
+ try {
2104
+ vectors = await this.embed.embed(embedInputs);
2105
+ } catch (err) {
2106
+ console.error("[KnowledgeManager] embed chunks failed:", err);
2107
+ return [];
2108
+ }
2109
+ const chunks = pieces.map((p, i) => ({
2110
+ chunkId: uuidv43(),
2111
+ docId: doc.docId,
2112
+ userId: redundant ? doc.userId : "",
2113
+ chatId: redundant ? doc.chatId : "",
2114
+ sessionId: redundant ? doc.sessionId : "",
2115
+ content: p.content,
2116
+ vector: vectors[i],
2117
+ headingPath: p.headingPath,
2118
+ ordinal: p.ordinal,
2119
+ tokens: p.tokens,
2120
+ metadata: {},
2121
+ createdAt: doc.createdAt
2122
+ }));
2123
+ await this.lance.addChunks(chunks);
2124
+ return chunks;
2125
+ }
2126
+ async buildDocumentGraph(doc, chunks) {
2127
+ const extracted = await mapLimit2(chunks, this.config.graphExtractConcurrency, async (chunk) => {
2128
+ const { entities: rawEntities, relations: rawRelations } = await this.llm.extractEntitiesFromText(chunk.content);
2129
+ if (rawEntities.length === 0 && rawRelations.length === 0) return null;
2130
+ const meta = {
2131
+ userId: doc.userId,
2132
+ chatId: doc.chatId,
2133
+ sessionId: doc.sessionId,
2134
+ docId: doc.docId,
2135
+ chunkId: chunk.chunkId,
2136
+ messageTime: doc.createdAt
2137
+ };
2138
+ const entities2 = rawEntities.map((e) => ({
2139
+ name: e.name,
2140
+ type: e.type,
2141
+ meta: { ...meta, ...e.meta }
2142
+ }));
2143
+ const relations2 = rawRelations.map((r) => ({
2144
+ from: r.from,
2145
+ to: r.to,
2146
+ type: r.type,
2147
+ happenedAt: r.happenedAt ?? void 0,
2148
+ meta: { ...meta, ...r.meta }
2149
+ }));
2150
+ return { entities: entities2, relations: relations2 };
2151
+ });
2152
+ const entities = extracted.flatMap((item) => item?.entities ?? []);
2153
+ const relations = extracted.flatMap((item) => item?.relations ?? []);
2154
+ if (entities.length === 0 && relations.length === 0) return;
2155
+ const allNames = [
2156
+ .../* @__PURE__ */ new Set([
2157
+ ...entities.map((e) => e.name),
2158
+ ...relations.flatMap((r) => [r.from, r.to])
2159
+ ])
2160
+ ];
2161
+ if (allNames.length === 0) return;
2162
+ const embeddings = /* @__PURE__ */ new Map();
2163
+ try {
2164
+ const vecs = await this.embed.embed(allNames);
2165
+ allNames.forEach((name, i) => embeddings.set(name, vecs[i]));
2166
+ } catch (err) {
2167
+ console.error("[KnowledgeManager] embed entity names failed:", err);
2168
+ return;
2169
+ }
2170
+ await this.grafeo.upsertEntitiesAndRelations(
2171
+ entities,
2172
+ relations,
2173
+ embeddings,
2174
+ KIND_KNOWLEDGE
2175
+ );
2176
+ await this.lance.updateDocumentGraphFlag(doc.docId, true).catch((err) => {
2177
+ console.error(`[KnowledgeManager] Failed to update graph flag for doc ${doc.docId}:`, err);
2178
+ });
2179
+ }
2180
+ // ── 检索 ───────────────────────────────────────────────────────────────────────
2181
+ async searchKnowledge(opts) {
2182
+ const empty = { chunks: [], documents: [], graphHits: [] };
2183
+ if (!opts.query.trim()) return empty;
2184
+ const scope = opts.scope ?? "all";
2185
+ const scopeId = opts.scopeId;
2186
+ const mode = opts.mode ?? "auto";
2187
+ const limit = opts.limit ?? this.config.knowledgeTopK;
2188
+ const vector = await this.embed.embedOne(opts.query);
2189
+ const docFilter = buildDomainSql(scope, scopeId);
2190
+ let candidateDocIds;
2191
+ const docTitleMap = /* @__PURE__ */ new Map();
2192
+ if (this.config.docCoarseTopK > 0) {
2193
+ const coarse = await this.lance.searchDocuments(vector, docFilter, this.config.docCoarseTopK);
2194
+ if (coarse.length > 0) {
2195
+ candidateDocIds = coarse.map((d) => d.docId);
2196
+ for (const d of coarse) docTitleMap.set(d.docId, d.title);
2197
+ }
2198
+ }
2199
+ const chunkFilter = await this.buildChunkFilter(scope, scopeId, candidateDocIds);
2200
+ if (chunkFilter === NO_MATCH) return empty;
2201
+ const rawChunks = await this.lance.searchChunks(opts.query, vector, chunkFilter, limit);
2202
+ const scored = rawChunks.map((c) => ({
2203
+ chunkId: c.chunkId,
2204
+ docId: c.docId,
2205
+ content: c.content,
2206
+ headingPath: c.headingPath,
2207
+ score: cosineSimilarity(vector, c.vector)
2208
+ })).sort((a, b) => b.score - a.score).slice(0, limit);
2209
+ const docCount = /* @__PURE__ */ new Map();
2210
+ for (const c of scored) docCount.set(c.docId, (docCount.get(c.docId) ?? 0) + 1);
2211
+ const documents = await Promise.all(
2212
+ [...docCount.entries()].map(async ([docId, matchedChunkCount]) => {
2213
+ let title = docTitleMap.get(docId);
2214
+ if (title === void 0) {
2215
+ const d = await this.lance.getDocument(docId);
2216
+ title = d?.title ?? "";
2217
+ }
2218
+ return { docId, title, matchedChunkCount };
2219
+ })
2220
+ );
2221
+ const graphHits = await this.searchGraph(mode, vector, scope, scopeId, scored);
2222
+ return { chunks: scored, documents, graphHits };
2223
+ }
2224
+ async searchGraph(mode, vector, scope, scopeId, topChunks) {
2225
+ if (mode === "fast") return [];
2226
+ if (mode === "auto") {
2227
+ const top = topChunks[0];
2228
+ if (!top || top.score < this.config.knowledgeGraphTriggerScore) return [];
2229
+ }
2230
+ const grafeoFilter = {
2231
+ kind: KIND_KNOWLEDGE
2232
+ };
2233
+ if (scope === "user" && scopeId) grafeoFilter.userId = scopeId;
2234
+ if (scope === "chat" && scopeId) grafeoFilter.chatId = scopeId;
2235
+ if (scope === "session" && scopeId) grafeoFilter.sessionId = scopeId;
2236
+ const entities = await this.grafeo.searchEntities(vector, grafeoFilter, this.config.knowledgeGraphEntityTopK).catch(() => []);
2237
+ if (entities.length === 0) return entities;
2238
+ const anchorCount = Math.min(this.config.knowledgeGraphAnchorTopK, entities.length);
2239
+ const relatedArrays = await Promise.all(
2240
+ entities.slice(0, anchorCount).map((e) => {
2241
+ const name = e.meta?.name ?? "";
2242
+ if (!name) return Promise.resolve([]);
2243
+ return this.grafeo.getRelatedEntities(
2244
+ name,
2245
+ grafeoFilter.userId ?? e.meta?.userId ?? "",
2246
+ grafeoFilter.chatId ?? e.meta?.chatId ?? "",
2247
+ this.config.knowledgeGraphHopLimit,
2248
+ KIND_KNOWLEDGE
2249
+ ).catch(() => []);
2250
+ })
2251
+ );
2252
+ return dedupByContent([...entities, ...relatedArrays.flat()]);
2253
+ }
2254
+ /**
2255
+ * 构建片段表的过滤条件。
2256
+ * - chunkRedundantIds=true:直接在 chunks 行的 user_id/chat_id/session_id 上过滤
2257
+ * - false:先按域查 documents 得到 docId,再用 doc_id IN(...) 过滤
2258
+ * 返回 undefined 表示不过滤;返回 NO_MATCH 表示无候选(应直接返回空结果)。
2259
+ */
2260
+ async buildChunkFilter(scope, scopeId, candidateDocIds) {
2261
+ const parts = [];
2262
+ if (this.config.chunkRedundantIds) {
2263
+ const domainSql = buildDomainSql(scope, scopeId);
2264
+ if (domainSql) parts.push(domainSql);
2265
+ if (candidateDocIds) parts.push(inSql("doc_id", candidateDocIds));
2266
+ } else {
2267
+ let docIds = candidateDocIds;
2268
+ if (scope !== "all") {
2269
+ const domainSql = buildDomainSql(scope, scopeId);
2270
+ const domainDocIds = await this.lance.getDocIdsByDomain(domainSql);
2271
+ docIds = docIds ? domainDocIds.filter((id) => docIds.includes(id)) : domainDocIds;
2272
+ }
2273
+ if (docIds) {
2274
+ if (docIds.length === 0) return NO_MATCH;
2275
+ parts.push(inSql("doc_id", docIds));
2276
+ }
2277
+ }
2278
+ return parts.length > 0 ? parts.join(" AND ") : void 0;
2279
+ }
2280
+ // ── 读取 / 删除 ─────────────────────────────────────────────────────────────────
2281
+ async getDocument(docId) {
2282
+ return this.lance.getDocument(docId);
2283
+ }
2284
+ async deleteDocument(docId) {
2285
+ const existing = await this.lance.getDocument(docId);
2286
+ if (!existing) return false;
2287
+ await this.lance.deleteDocument(docId);
2288
+ await this.lance.deleteChunksByDoc(docId).catch((err) => {
2289
+ console.error(`[KnowledgeManager] Failed to delete chunks for doc ${docId}:`, err);
2290
+ });
2291
+ await this.grafeo.deleteKnowledgeByDoc(docId).catch((err) => {
2292
+ console.error(`[KnowledgeManager] Failed to delete knowledge graph for doc ${docId}:`, err);
2293
+ });
2294
+ return true;
2295
+ }
2296
+ async listDocuments(filter) {
2297
+ let docs = await this.lance.getAllDocuments();
2298
+ if (filter?.userId) docs = docs.filter((d) => d.userId === filter.userId);
2299
+ if (filter?.chatId) docs = docs.filter((d) => d.chatId === filter.chatId);
2300
+ if (filter?.sessionId) docs = docs.filter((d) => d.sessionId === filter.sessionId);
2301
+ return docs;
2302
+ }
2303
+ };
2304
+ var NO_MATCH = Symbol("no-match");
2305
+ function dedupByContent(items) {
2306
+ const seen = /* @__PURE__ */ new Set();
2307
+ const out = [];
2308
+ for (const item of items) {
2309
+ if (seen.has(item.content)) continue;
2310
+ seen.add(item.content);
2311
+ out.push(item);
2312
+ }
2313
+ return out;
2314
+ }
2315
+ function esc(value) {
2316
+ return value.replace(/'/g, "''");
2317
+ }
2318
+ function buildDomainSql(scope, scopeId) {
2319
+ if (scope === "session" && scopeId) return `session_id = '${esc(scopeId)}'`;
2320
+ if (scope === "chat" && scopeId) return `chat_id = '${esc(scopeId)}'`;
2321
+ if (scope === "user" && scopeId) return `user_id = '${esc(scopeId)}'`;
2322
+ return void 0;
2323
+ }
2324
+ function inSql(column, values) {
2325
+ const list = values.map((v) => `'${esc(v)}'`).join(", ");
2326
+ return `${column} IN (${list})`;
2327
+ }
2328
+ function inferTitle(markdown) {
2329
+ const lines = markdown.split(/\r?\n/);
2330
+ for (const line of lines) {
2331
+ const m = /^(#{1,6})\s+(.*)$/.exec(line);
2332
+ if (m && m[2].trim()) return m[2].trim().slice(0, 120);
2333
+ }
2334
+ for (const line of lines) {
2335
+ const t = line.trim();
2336
+ if (t) return t.slice(0, 120);
2337
+ }
2338
+ return void 0;
2339
+ }
2340
+ async function mapLimit2(items, limit, mapper) {
2341
+ const results = new Array(items.length);
2342
+ let next = 0;
2343
+ const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
2344
+ while (true) {
2345
+ const index = next++;
2346
+ if (index >= items.length) return;
2347
+ results[index] = await mapper(items[index], index);
2348
+ }
2349
+ });
2350
+ await Promise.all(workers);
2351
+ return results;
2352
+ }
2353
+ function withTimeout2(promise, timeoutMs, label) {
2354
+ let timer;
2355
+ const timeout = new Promise((_, reject) => {
2356
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
2357
+ });
2358
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
2359
+ }
2360
+
2361
+ // src/manager/session.cache.ts
2362
+ var SessionCache = class {
2363
+ config;
2364
+ sessions = /* @__PURE__ */ new Map();
2365
+ historyWindows = /* @__PURE__ */ new Map();
2366
+ constructor(config) {
2367
+ this.config = config;
2368
+ }
2369
+ getEntry(sessionId) {
2370
+ return this.sessions.get(sessionId);
2371
+ }
2372
+ getOrCreateEntry(sessionId, chatId, userId) {
2373
+ let entry = this.sessions.get(sessionId);
2374
+ if (!entry) {
2375
+ entry = { messages: [], totalTokens: 0, ids: { chatId, userId } };
2376
+ this.sessions.set(sessionId, entry);
2377
+ }
2378
+ return entry;
2379
+ }
2380
+ addMessages(sessionId, messages, chatId, userId) {
2381
+ const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2382
+ entry.messages.push(...messages);
2383
+ entry.totalTokens += messages.reduce((sum, m) => sum + m.usage, 0);
2384
+ return entry.totalTokens >= this.config.sessionTokenLimit;
2385
+ }
2386
+ clearMessages(sessionId, keepAfter) {
2387
+ const entry = this.sessions.get(sessionId);
2388
+ if (!entry) return;
2389
+ if (keepAfter != null) {
2390
+ entry.messages = entry.messages.filter((m) => m.createdAt > keepAfter);
2391
+ entry.totalTokens = entry.messages.reduce((sum, m) => sum + m.usage, 0);
2392
+ } else {
2393
+ entry.messages = [];
2394
+ entry.totalTokens = 0;
2395
+ }
2396
+ }
2397
+ getSessionMessages(sessionId) {
2398
+ return this.sessions.get(sessionId)?.messages ?? [];
2399
+ }
2400
+ getAllSessionIds() {
2401
+ return [...this.sessions.keys()];
2402
+ }
2403
+ buildHistoryWindow(sessionId, topicGroups) {
2404
+ const { topicRatio, historyWindowTokenLimit } = this.config;
2405
+ const [n1, n2, n3] = topicRatio;
2406
+ let remaining = historyWindowTokenLimit;
2407
+ const parts = [];
2408
+ const addTopics = (topics, field, max) => {
2409
+ let count = 0;
2410
+ for (const t of topics) {
2411
+ if (count >= max || remaining <= 0) break;
2412
+ const text = t[field];
2413
+ const tokens = countTokens(text);
2414
+ if (tokens > remaining) break;
2415
+ parts.push(text);
2416
+ remaining -= tokens;
2417
+ count++;
2418
+ }
2419
+ };
2420
+ addTopics(topicGroups.detail, "detail", n1);
2421
+ addTopics(topicGroups.summary, "summary", n2);
2422
+ addTopics(topicGroups.concise, "concise", n3);
2423
+ this.historyWindows.set(sessionId, parts.join("\n\n"));
2424
+ }
2425
+ getHistoryWindow(sessionId) {
2426
+ return this.historyWindows.get(sessionId) ?? "";
2427
+ }
2428
+ setHistoryWindow(sessionId, content) {
2429
+ this.historyWindows.set(sessionId, content);
2430
+ }
2431
+ };
2432
+
2433
+ // src/memory.manager.ts
2434
+ var MemoryManager = class {
2435
+ config;
2436
+ lance;
2437
+ grafeo;
2438
+ embed;
2439
+ llm;
2440
+ sessionCache;
2441
+ factCache;
2442
+ compressManager;
2443
+ knowledgeManager;
2444
+ sessionMap = /* @__PURE__ */ new Map();
2445
+ constructor(config) {
2446
+ this.config = resolveConfig(config);
2447
+ this.lance = new LanceService(this.config);
2448
+ this.grafeo = new GrafeoService(this.config);
2449
+ this.embed = new EmbedService(this.config);
2450
+ this.llm = new LlmService(this.config);
2451
+ this.sessionCache = new SessionCache(this.config);
2452
+ this.factCache = new FactCache(this.lance);
2453
+ this.compressManager = new CompressManager(
2454
+ this.config,
2455
+ this.lance,
2456
+ this.grafeo,
2457
+ this.llm,
2458
+ this.embed,
2459
+ this.sessionCache
2460
+ );
2461
+ this.knowledgeManager = new KnowledgeManager(
2462
+ this.config,
2463
+ this.lance,
2464
+ this.grafeo,
2465
+ this.llm,
2466
+ this.embed
2467
+ );
2468
+ }
2469
+ async init() {
2470
+ initEncoder();
2471
+ await this.lance.init();
2472
+ await this.grafeo.init();
2473
+ await this.factCache.init();
2474
+ const allSessions = await this.lance.getAllSessions();
2475
+ for (const s of allSessions) {
2476
+ this.sessionMap.set(s.sessionId, this.deserializeSession(s));
2477
+ }
2478
+ await this.restoreFromStorage();
2479
+ }
2480
+ async restoreFromStorage() {
2481
+ const [n1, n2, n3] = this.config.topicRatio;
2482
+ const sessionIds = await this.lance.getAllSessionIds();
2483
+ if (sessionIds.length === 0) return;
2484
+ for (const sessionId of sessionIds) {
2485
+ const recentMessages = await this.lance.getLatestMessages(sessionId, 1);
2486
+ if (recentMessages.length === 0) continue;
2487
+ const { chatId, userId } = recentMessages[0];
2488
+ const topicGroups = await this.lance.getRecentTopics(chatId, userId, n1, n2, n3);
2489
+ const n1EndTime = topicGroups.detail.length > 0 ? topicGroups.detail[topicGroups.detail.length - 1].endTime : 0;
2490
+ const rawMessages = n1EndTime > 0 ? await this.lance.getMessagesSince(sessionId, n1EndTime) : await this.lance.getLatestMessages(sessionId, 100);
2491
+ if (rawMessages.length > 0) {
2492
+ this.sessionCache.addMessages(sessionId, rawMessages, chatId, userId);
2493
+ }
2494
+ if (topicGroups.detail.length + topicGroups.summary.length + topicGroups.concise.length > 0) {
2495
+ this.sessionCache.buildHistoryWindow(sessionId, topicGroups);
2496
+ } else {
2497
+ this.sessionCache.setHistoryWindow(sessionId, "");
2498
+ }
2499
+ }
2500
+ }
2501
+ async updateChat(messages, opts) {
2502
+ if (messages.length === 0) return;
2503
+ const userId = opts?.userId ?? DEFAULT_USER_ID;
2504
+ const chatId = opts?.chatId ?? DEFAULT_CHAT_ID;
2505
+ const sessionId = opts?.sessionId ?? DEFAULT_SESSION_ID;
2506
+ const now = Date.now();
2507
+ const withUsage = messages.map((m) => ({
2508
+ messageId: m.messageId ?? uuidv44(),
2509
+ talkerId: m.talkerId ?? "user",
2510
+ chatId: m.chatId ?? chatId,
2511
+ userId: m.userId ?? userId,
2512
+ sessionId: m.sessionId ?? sessionId,
2513
+ type: m.type ?? "text",
2514
+ content: m.content,
2515
+ parts: JSON.stringify(m.parts ?? []),
2516
+ usage: m.usage ?? countTokens(m.content),
2517
+ metadata: JSON.stringify(m.metadata ?? {}),
2518
+ createdAt: m.createdAt ?? now
2519
+ }));
2520
+ let vectors;
2521
+ try {
2522
+ vectors = await this.embed.embed(withUsage.map((m) => m.content));
2523
+ } catch (err) {
2524
+ console.error("[MemoryManager] Embedding failed:", err);
2525
+ throw err;
2526
+ }
2527
+ const stored = withUsage.map((m, i) => ({
2528
+ messageId: m.messageId,
2529
+ talkerId: m.talkerId,
2530
+ chatId: m.chatId,
2531
+ userId: m.userId,
2532
+ sessionId: m.sessionId,
2533
+ type: m.type,
2534
+ content: m.content,
2535
+ parts: m.parts,
2536
+ usage: m.usage,
2537
+ metadata: m.metadata,
2538
+ vector: vectors[i],
2539
+ createdAt: m.createdAt
2540
+ }));
2541
+ await this.lance.addMessages(stored);
2542
+ if (!this.sessionMap.has(sessionId)) {
2543
+ const session = {
2544
+ sessionId,
2545
+ chatId,
2546
+ userId,
2547
+ title: opts?.sessionTitle ?? "",
2548
+ metadata: JSON.stringify(opts?.sessionMetadata ?? {}),
2549
+ createdAt: now,
2550
+ updatedAt: now
2551
+ };
2552
+ this.sessionMap.set(sessionId, this.deserializeSession(session));
2553
+ this.lance.insertSession(session).catch((err) => {
2554
+ console.error("[MemoryManager] Failed to insert session:", err);
2555
+ });
2556
+ } else {
2557
+ const view = this.sessionMap.get(sessionId);
2558
+ view.updatedAt = now;
2559
+ this.lance.upsertSession(this.serializeSession(view)).catch((err) => {
2560
+ console.error(`[MemoryManager] Failed to upsert session ${sessionId}:`, err);
2561
+ });
2562
+ }
2563
+ (async () => {
2564
+ const overLimit = this.sessionCache.addMessages(sessionId, stored, chatId, userId);
2565
+ if (overLimit) {
2566
+ await this.compressManager.triggerCompress(sessionId).catch((err) => {
2567
+ console.error("[MemoryManager] Background compress failed:", err);
2568
+ });
2569
+ }
2570
+ })();
2571
+ }
2572
+ async flushChat(sessionId, opts) {
2573
+ const sid = sessionId ?? DEFAULT_SESSION_ID;
2574
+ const promise = this.compressManager.triggerCompress(sid, true, opts?.waitGraph === true);
2575
+ if (opts?.wait) await promise;
2576
+ }
2577
+ async updateFacts(content, level, userId, chatId, sessionId) {
2578
+ await this.factCache.add(content, level, userId, chatId, sessionId);
2579
+ }
2580
+ async updateEntity(entities, relations, context) {
2581
+ if (entities.length === 0 && relations.length === 0) return;
2582
+ const sessionId = context?.sessionId ?? DEFAULT_SESSION_ID;
2583
+ const chatId = context?.chatId ?? DEFAULT_CHAT_ID;
2584
+ const userId = context?.userId ?? DEFAULT_USER_ID;
2585
+ const allNames = [
2586
+ .../* @__PURE__ */ new Set([
2587
+ ...entities.map((e) => e.name),
2588
+ ...relations.flatMap((r) => [r.from, r.to])
2589
+ ])
2590
+ ];
2591
+ const vecs = await this.embed.embed(allNames);
2592
+ const embeddings = /* @__PURE__ */ new Map();
2593
+ allNames.forEach((name, i) => embeddings.set(name, vecs[i]));
2594
+ const enrichedEntities = entities.map((e) => ({
2595
+ ...e,
2596
+ meta: { sessionId, chatId, userId, ...e.meta }
2597
+ }));
2598
+ const enrichedRelations = relations.map((r) => ({
2599
+ ...r,
2600
+ meta: { sessionId, chatId, userId, ...r.meta }
2601
+ }));
2602
+ await this.grafeo.upsertEntitiesAndRelations(enrichedEntities, enrichedRelations, embeddings);
2603
+ }
2604
+ async search(opts) {
2605
+ const {
2606
+ query,
2607
+ scope = "all",
2608
+ scopeId,
2609
+ mode = "auto",
2610
+ limit = this.config.defaultSearchLimit
2611
+ } = opts;
2612
+ if (!query.trim()) return [];
2613
+ const vector = await this.embed.embedOne(query);
2614
+ const filter = this.buildLanceFilter(scope, scopeId);
2615
+ let useGraph = false;
2616
+ if (mode === "all") {
2617
+ useGraph = true;
2618
+ } else if (mode === "auto") {
2619
+ useGraph = await this.llm.judgeNeedsGraphSearch(query).catch(() => false);
2620
+ }
2621
+ const tasks = [
2622
+ this.lance.hybridSearchTopics(query, vector, filter, limit).then(
2623
+ (topics) => topics.map((t) => ({
2624
+ type: "topic",
2625
+ content: t.detail,
2626
+ score: 1,
2627
+ meta: {
2628
+ summaryId: t.summaryId,
2629
+ sessionId: t.sessionId,
2630
+ chatId: t.chatId,
2631
+ userId: t.userId
2632
+ }
2633
+ }))
2634
+ ).catch(() => [])
2635
+ ];
2636
+ if (useGraph) {
2637
+ const grafeoFilter = { kind: KIND_CONVERSATION };
2638
+ if (scope === "user" && scopeId) grafeoFilter.userId = scopeId;
2639
+ if (scope === "chat" && scopeId) grafeoFilter.chatId = scopeId;
2640
+ if (scope === "session" && scopeId) grafeoFilter.sessionId = scopeId;
2641
+ tasks.push(
2642
+ this.grafeo.searchEntities(vector, grafeoFilter, limit).catch(() => [])
2643
+ );
2644
+ }
2645
+ const allResults = (await Promise.all(tasks)).flat();
2646
+ const seen = /* @__PURE__ */ new Set();
2647
+ const deduped = allResults.filter((r) => {
2648
+ if (seen.has(r.content)) return false;
2649
+ seen.add(r.content);
2650
+ return true;
2651
+ });
2652
+ return deduped.sort((a, b) => b.score - a.score).slice(0, limit);
2653
+ }
2654
+ async ask(opts) {
2655
+ const { maxChars, includeKnowledge, ...searchOpts } = opts;
2656
+ const results = await this.search(searchOpts);
2657
+ if (includeKnowledge) {
2658
+ const kb = await this.knowledgeManager.searchKnowledge({
2659
+ query: searchOpts.query,
2660
+ scope: searchOpts.scope,
2661
+ scopeId: searchOpts.scopeId,
2662
+ mode: searchOpts.mode
2663
+ }).catch(() => null);
2664
+ if (kb) {
2665
+ for (const c of kb.chunks) {
2666
+ results.push({ type: "topic", content: c.content, score: c.score, meta: { docId: c.docId } });
2667
+ }
2668
+ results.push(...kb.graphHits);
2669
+ }
2670
+ }
2671
+ if (results.length === 0) return "\u672A\u627E\u5230\u76F8\u5173\u8BB0\u5FC6\u3002";
2672
+ return this.llm.summarizeSearchResults(opts.query, results, maxChars);
2673
+ }
2674
+ async getFacts(level, id) {
2675
+ return this.factCache.toString(level, id);
2676
+ }
2677
+ /**
2678
+ * 按层级语义取「上下文相关」的 facts 拼接字符串。
2679
+ *
2680
+ * scope 是层级包含的:user ⊃ chat ⊃ session。因此在某个 chat/session 上下文中,
2681
+ * 既要看到该 chat 级的 facts,也要看到所属 user 级的 facts。
2682
+ * 返回 user 级(userId) ∪ chat 级(chatId),按时间正序拼接。
2683
+ */
2684
+ async getFactsForContext(userId, chatId) {
2685
+ const merged = [
2686
+ ...this.factCache.get("user", userId),
2687
+ ...this.factCache.get("chat", chatId)
2688
+ ].sort((a, b) => a.createdAt - b.createdAt);
2689
+ if (merged.length === 0) return "";
2690
+ return merged.map((f) => `${new Date(f.createdAt).toISOString()}\uFF1A${f.content}`).join("\n");
2691
+ }
2692
+ getHistoryWindow(sessionId) {
2693
+ return this.sessionCache.getHistoryWindow(sessionId);
2694
+ }
2695
+ buildLanceFilter(scope, scopeId) {
2696
+ if (scope === "session" && scopeId) return `session_id = '${scopeId}'`;
2697
+ if (scope === "chat" && scopeId) return `chat_id = '${scopeId}'`;
2698
+ if (scope === "user" && scopeId) return `user_id = '${scopeId}'`;
2699
+ return void 0;
2700
+ }
2701
+ deserializeSession(s) {
2702
+ return { ...s, metadata: JSON.parse(s.metadata) };
2703
+ }
2704
+ serializeSession(v) {
2705
+ return { ...v, metadata: JSON.stringify(v.metadata) };
2706
+ }
2707
+ // ── Message queries ──────────────────────────────────────────────────────────
2708
+ async getRecentMessages(sessionId, limit) {
2709
+ return this.lance.getLatestMessages(sessionId, limit);
2710
+ }
2711
+ // ── Session queries (in-memory) ──────────────────────────────────────────────
2712
+ getSession(sessionId) {
2713
+ return this.sessionMap.get(sessionId) ?? null;
2714
+ }
2715
+ getSessionsByUserId(userId, opts) {
2716
+ const results = [...this.sessionMap.values()].filter((s) => s.userId === userId).sort((a, b) => b.updatedAt - a.updatedAt);
2717
+ return opts?.limit ? results.slice(0, opts.limit) : results;
2718
+ }
2719
+ getSessionsByChatId(chatId, opts) {
2720
+ const results = [...this.sessionMap.values()].filter((s) => s.chatId === chatId).sort((a, b) => b.updatedAt - a.updatedAt);
2721
+ return opts?.limit ? results.slice(0, opts.limit) : results;
2722
+ }
2723
+ searchSessions(opts) {
2724
+ let results = [...this.sessionMap.values()];
2725
+ if (opts.userId) results = results.filter((s) => s.userId === opts.userId);
2726
+ if (opts.chatId) results = results.filter((s) => s.chatId === opts.chatId);
2727
+ if (opts.title) {
2728
+ const q = opts.title.toLowerCase();
2729
+ results = results.filter((s) => s.title.toLowerCase().includes(q));
2730
+ }
2731
+ results.sort((a, b) => b.updatedAt - a.updatedAt);
2732
+ return opts.limit ? results.slice(0, opts.limit) : results;
2733
+ }
2734
+ // ── Session mutations ────────────────────────────────────────────────────────
2735
+ async updateSession(sessionId, opts) {
2736
+ const existing = this.sessionMap.get(sessionId);
2737
+ if (!existing) return null;
2738
+ const updated = {
2739
+ ...existing,
2740
+ ...opts.title !== void 0 && { title: opts.title },
2741
+ ...opts.metadata !== void 0 && { metadata: opts.metadata },
2742
+ updatedAt: Date.now()
2743
+ };
2744
+ this.sessionMap.set(sessionId, updated);
2745
+ await this.lance.upsertSession(this.serializeSession(updated));
2746
+ return updated;
2747
+ }
2748
+ async deleteSession(sessionId) {
2749
+ if (!this.sessionMap.has(sessionId)) return false;
2750
+ this.sessionMap.delete(sessionId);
2751
+ await this.lance.deleteSession(sessionId);
2752
+ await this.lance.deleteMessagesBySession(sessionId).catch((err) => {
2753
+ console.error(`[MemoryManager] Failed to delete messages for session ${sessionId}:`, err);
2754
+ });
2755
+ await this.lance.deleteTopicsBySession(sessionId).catch((err) => {
2756
+ console.error(`[MemoryManager] Failed to delete topics for session ${sessionId}:`, err);
2757
+ });
2758
+ return true;
2759
+ }
2760
+ // ── 管理面板门面 API ───────────────────────────────────────────────────────────
2761
+ /** 概览统计:各类记忆数据的总量 */
2762
+ async stats() {
2763
+ const [counts, knowledge, entities, relations] = await Promise.all([
2764
+ this.lance.countAll(),
2765
+ this.lance.countKnowledge(),
2766
+ this.grafeo.getAllEntities(),
2767
+ this.grafeo.getAllRelations()
2768
+ ]);
2769
+ return {
2770
+ ...counts,
2771
+ ...knowledge,
2772
+ entities: entities.length,
2773
+ relations: relations.length
2774
+ };
2775
+ }
2776
+ /** 按天聚合最近 days 天的活跃趋势(概览图表用,含零值天)。days 默认 30,范围 1~365。 */
2777
+ async trend(days = 30) {
2778
+ const n = Math.min(365, Math.max(1, Math.floor(days)));
2779
+ return this.lance.trendDaily(n);
2780
+ }
2781
+ /** 列出会话,可选按 userId / chatId 过滤,按更新时间倒序 */
2782
+ listSessions(filter) {
2783
+ let results = [...this.sessionMap.values()];
2784
+ if (filter?.userId) results = results.filter((s) => s.userId === filter.userId);
2785
+ if (filter?.chatId) results = results.filter((s) => s.chatId === filter.chatId);
2786
+ return results.sort((a, b) => b.updatedAt - a.updatedAt);
2787
+ }
2788
+ /** 列出会话内的消息(按时间正序)*/
2789
+ async listMessages(sessionId, limit = 100) {
2790
+ return this.lance.getLatestMessages(sessionId, limit);
2791
+ }
2792
+ /** 列出主题/摘要,可选按 sessionId / userId / chatId 过滤 */
2793
+ async listTopics(filter) {
2794
+ let topics = await this.lance.getAllTopics();
2795
+ if (filter?.sessionId) topics = topics.filter((t) => t.sessionId === filter.sessionId);
2796
+ if (filter?.userId) topics = topics.filter((t) => t.userId === filter.userId);
2797
+ if (filter?.chatId) topics = topics.filter((t) => t.chatId === filter.chatId);
2798
+ return topics;
2799
+ }
2800
+ /**
2801
+ * 列出事实,按创建时间倒序。过滤遵循 scope 层级包含语义:
2802
+ * - 按 chatId 过滤时,除该 chat 级 facts 外,还包含该 chat 所属 user 的 user 级 facts
2803
+ * (user 级对其下所有 chat/session 生效)。
2804
+ * - 按 userId 过滤时,返回该 user 名下全部 facts(两级都带 userId)。
2805
+ * - level 过滤在层级过滤之后再叠加。
2806
+ */
2807
+ listFacts(filter) {
2808
+ let facts = this.factCache.all();
2809
+ if (filter?.chatId) {
2810
+ const chatId = filter.chatId;
2811
+ const ownerUserId = filter.userId ?? this.resolveChatOwner(chatId);
2812
+ facts = facts.filter(
2813
+ (f) => f.level === "chat" && f.chatId === chatId || f.level === "user" && ownerUserId != null && f.userId === ownerUserId
2814
+ );
2815
+ } else if (filter?.userId) {
2816
+ facts = facts.filter((f) => f.userId === filter.userId);
2817
+ }
2818
+ if (filter?.level) facts = facts.filter((f) => f.level === filter.level);
2819
+ return facts.sort((a, b) => b.createdAt - a.createdAt);
2820
+ }
2821
+ /** 由 chatId 反查所属 userId:优先用会话表映射,兜底用 chat 级 fact 自身。 */
2822
+ resolveChatOwner(chatId) {
2823
+ for (const s of this.sessionMap.values()) {
2824
+ if (s.chatId === chatId) return s.userId;
2825
+ }
2826
+ return this.factCache.all().find((f) => f.chatId === chatId)?.userId;
2827
+ }
2828
+ /** 手动新增一条事实 */
2829
+ async addFact(content, level, userId, chatId, sessionId) {
2830
+ await this.factCache.add(content, level, userId, chatId, sessionId);
2831
+ }
2832
+ /** 删除单条事实,返回是否命中 */
2833
+ async deleteFact(factId) {
2834
+ return this.factCache.remove(factId);
2835
+ }
2836
+ /** 列出全部实体(知识图谱节点)*/
2837
+ async listEntities() {
2838
+ return this.grafeo.getAllEntities();
2839
+ }
2840
+ /** 列出全部关系(知识图谱边)*/
2841
+ async listRelations() {
2842
+ return this.grafeo.getAllRelations();
2843
+ }
2844
+ // ── 知识库 API ────────────────────────────────────────────────────────────────
2845
+ /** 摄入一个文档(markdown)。document 落库即返回,切块/embedding/图谱后台执行(wait=true 可等待)。*/
2846
+ async addDocument(opts) {
2847
+ return this.knowledgeManager.addDocument(opts);
2848
+ }
2849
+ /** 检索知识库片段,返回命中片段、涉及文档与图谱命中。*/
2850
+ async searchKnowledge(opts) {
2851
+ return this.knowledgeManager.searchKnowledge(opts);
2852
+ }
2853
+ /** 获取文档(含原文)。*/
2854
+ async getDocument(docId) {
2855
+ return this.knowledgeManager.getDocument(docId);
2856
+ }
2857
+ /** 删除文档(级联删除其片段与知识图谱痕迹),返回是否命中。*/
2858
+ async deleteDocument(docId) {
2859
+ return this.knowledgeManager.deleteDocument(docId);
2860
+ }
2861
+ /** 列出文档,可选按 userId / chatId / sessionId 过滤,按更新时间倒序。*/
2862
+ async listDocuments(filter) {
2863
+ return this.knowledgeManager.listDocuments(filter);
2864
+ }
2865
+ destroy() {
2866
+ this.grafeo.close();
2867
+ }
2868
+ };
2869
+ export {
2870
+ DEFAULT_NODE_TYPES,
2871
+ DEFAULT_RELATION_TYPES,
2872
+ KIND_CONVERSATION,
2873
+ KIND_KNOWLEDGE,
2874
+ LanceService,
2875
+ MemoryManager
2876
+ };