luckytri 1.0.0 → 1.0.2
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/.github/SECURITY.md +4 -4
- package/README.en.md +252 -250
- package/README.md +252 -250
- package/bin/luckytri.js +5 -1
- package/docs/en/CHANGELOG.md +8 -0
- package/docs/en/connect.md +18 -18
- package/docs/en/guide.md +202 -216
- package/docs/en/npm-release.md +25 -25
- package/docs/zh/CHANGELOG.md +8 -0
- package/docs/zh/connect.md +17 -17
- package/docs/zh/guide.md +202 -216
- package/docs/zh/npm-release.md +33 -33
- package/package.json +110 -108
- package/plugins/sdk/index.d.ts +155 -155
- package/plugins/sdk/testing.js +58 -58
- package/plugins/weather/index.js +82 -82
- package/plugins/weather/luckytri-plugin.json +29 -29
- package/public/app/assets/ChatsPage-BhlJHseG.js +3 -0
- package/public/app/assets/Empty-DP6WMGHi.js +2 -0
- package/public/app/assets/Field-CtbdqaiB.js +1 -0
- package/public/app/assets/HeartPage-32BeTPn2.js +1 -0
- package/public/app/assets/LifePage-CxSm0XKI.js +1 -0
- package/public/app/assets/MemoryPage-BeKBcPCY.js +1 -0
- package/public/app/assets/NaturePage-NEPYl2HS.js +3 -0
- package/public/app/assets/PeoplePage-DxtpekwM.js +1 -0
- package/public/app/assets/PluginsPage-CVVQwEBI.css +1 -0
- package/public/app/assets/PluginsPage-DksEZbUU.js +2 -0
- package/public/app/assets/{SystemPage-CAMPoYqM.css → SystemPage-BkRu0Rvx.css} +1 -1
- package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
- package/public/app/assets/TimePage-7-awH5XL.js +3 -0
- package/public/app/assets/index-4rItIkD-.js +4 -0
- package/public/app/assets/{index-YDz1ree1.css → index-DY4e15Kh.css} +1 -1
- package/public/app/assets/{knowledge-DG0e0uKw.js → knowledge-C6_HvoyB.js} +1 -1
- package/public/app/assets/media-SMfTT-yc.js +1 -0
- package/public/app/index.html +3 -3
- package/public/guide.en.html +72 -99
- package/public/guide.html +44 -86
- package/public/tutorial.en.md +25 -39
- package/public/tutorial.md +25 -39
- package/scripts/backup.js +311 -311
- package/scripts/browser.js +41 -41
- package/scripts/import-data.js +128 -0
- package/scripts/launch.js +101 -94
- package/scripts/plugin.js +150 -150
- package/scripts/reset-password.js +22 -0
- package/scripts/runtime.js +25 -25
- package/scripts/service.js +56 -47
- package/scripts/setup.js +50 -50
- package/scripts/start-server.js +41 -41
- package/scripts/stop.js +36 -36
- package/server/app.js +112 -96
- package/server/auth.js +217 -0
- package/server/channels/contract.js +57 -57
- package/server/channels/hub.js +165 -165
- package/server/channels/index.js +28 -25
- package/server/channels/onebot/index.js +335 -336
- package/server/channels/onebot/token.js +22 -3
- package/server/core/capabilities.js +208 -208
- package/server/core/events.js +64 -60
- package/server/core/image-preprocessor.js +208 -208
- package/server/core/model-manager.js +1213 -1213
- package/server/core/net-guard.js +49 -49
- package/server/core/orchestrator.js +569 -569
- package/server/core/perceivers.js +91 -91
- package/server/core/persona-manager.js +243 -243
- package/server/core/repository.js +293 -293
- package/server/core/response-validator.js +508 -508
- package/server/core/turn-processor.js +630 -630
- package/server/core/turn.js +236 -236
- package/server/core/vision-manager.js +633 -633
- package/server/i18n/errors.js +548 -497
- package/server/i18n/labels.js +649 -653
- package/server/index.js +151 -102
- package/server/knowledge/manager.js +386 -386
- package/server/knowledge/schema.js +12 -8
- package/server/local-access.js +32 -0
- package/server/mind/anticipations.js +507 -507
- package/server/mind/api.js +703 -703
- package/server/mind/budget.js +152 -152
- package/server/mind/context-selection.js +61 -61
- package/server/mind/day-planner.js +324 -324
- package/server/mind/evidence.js +148 -148
- package/server/mind/index.js +445 -445
- package/server/mind/life/diary.js +786 -786
- package/server/mind/life/index.js +1049 -1049
- package/server/mind/observations.js +84 -84
- package/server/mind/own-day.js +324 -324
- package/server/mind/own-voice.js +318 -318
- package/server/mind/senses.js +53 -53
- package/server/mind/time/activity-clock.js +196 -196
- package/server/mind/time/availability.js +69 -69
- package/server/mind/time/commitment-review.js +198 -198
- package/server/mind/time/executor.js +512 -512
- package/server/mind/time/experiences.js +128 -128
- package/server/mind/time/games.js +562 -562
- package/server/mind/time/index.js +739 -739
- package/server/mind/time/intent.js +166 -166
- package/server/mind/time/kinds.js +171 -171
- package/server/mind/time/plugin-activity.js +207 -207
- package/server/mind/time/tasks.js +587 -587
- package/server/mind/util.js +110 -110
- package/server/mind/view.js +273 -273
- package/server/paths.js +102 -102
- package/server/plugins/api.js +204 -204
- package/server/plugins/host.js +967 -967
- package/server/plugins/install.js +295 -295
- package/server/plugins/manifest.js +201 -201
- package/server/plugins/permissions.js +42 -42
- package/server/plugins/runner/guard.mjs +36 -36
- package/server/plugins/runner/worker.mjs +250 -250
- package/server/plugins/schema.js +91 -91
- package/server/plugins/zip.js +112 -112
- package/server/startup-lock.js +118 -118
- package/server/storage/archive.js +242 -241
- package/server/storage/backup-cleanup-worker.js +8 -8
- package/server/storage/backup-cleanup.js +415 -414
- package/server/storage/backup-lock.js +76 -76
- package/server/storage/backup-scheduler.js +131 -131
- package/server/storage/data-package.js +298 -0
- package/server/storage/data-transfer-api.js +50 -0
- package/server/storage/data-transfer-worker.js +18 -0
- package/server/storage/data-transfer.js +415 -0
- package/server/storage/store.js +202 -199
- package/server/studio/readiness.js +181 -190
- package/server/studio/settings.js +236 -236
- package/public/app/assets/ChatsPage-C-FcRapR.js +0 -3
- package/public/app/assets/Empty-CpP5kQHH.js +0 -2
- package/public/app/assets/Field-D4gY-INU.js +0 -1
- package/public/app/assets/HeartPage-CPy_thQd.js +0 -1
- package/public/app/assets/LifePage-CxWvwmSf.js +0 -1
- package/public/app/assets/MemoryPage-DC4rhQ68.js +0 -1
- package/public/app/assets/NaturePage-IvYw04Zq.js +0 -3
- package/public/app/assets/PeoplePage-Cqefm9-q.js +0 -1
- package/public/app/assets/PluginsPage-DKSaKvXb.js +0 -2
- package/public/app/assets/PluginsPage-HyBe5D8w.css +0 -1
- package/public/app/assets/SystemPage-CJmtJkrK.js +0 -1
- package/public/app/assets/TimePage-Cw44inM4.js +0 -3
- package/public/app/assets/index-BheRW-1t.js +0 -4
- package/public/app/assets/media-BzPDBm7e.js +0 -1
|
@@ -1,386 +1,386 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { runtimePaths } from "../paths.js";
|
|
5
|
-
import {
|
|
6
|
-
asPlainDocument,
|
|
7
|
-
chunkText,
|
|
8
|
-
cosine,
|
|
9
|
-
ftsMatchQuery,
|
|
10
|
-
overlapScore,
|
|
11
|
-
lexicalTerms,
|
|
12
|
-
packVector,
|
|
13
|
-
unpackVector,
|
|
14
|
-
} from "./retrieval.js";
|
|
15
|
-
import { indexChunk } from "./schema.js";
|
|
16
|
-
import { ensureVectorIndex, vectorCandidates } from "./vector-index.js";
|
|
17
|
-
import { resolveEmbeddingProfile, embedBatches } from "./embedding-profile.js";
|
|
18
|
-
import {
|
|
19
|
-
parseSessionKey,
|
|
20
|
-
scopedSessionAliases,
|
|
21
|
-
} from "../channels/session-key.js";
|
|
22
|
-
|
|
23
|
-
function scopesFor(db, session) {
|
|
24
|
-
try {
|
|
25
|
-
const parsed = parseSessionKey(session);
|
|
26
|
-
const aliases = scopedSessionAliases(db, session);
|
|
27
|
-
return {
|
|
28
|
-
aliases,
|
|
29
|
-
kind: parsed.kind,
|
|
30
|
-
privateKey: `__private__:${parsed.nativeId}`,
|
|
31
|
-
};
|
|
32
|
-
} catch {
|
|
33
|
-
return { aliases: [session], kind: "group", privateKey: "" };
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function saidBy(rows) {
|
|
38
|
-
const groups = new Map();
|
|
39
|
-
let anon = 0;
|
|
40
|
-
for (const row of rows || []) {
|
|
41
|
-
if (row?.role === "assistant") continue;
|
|
42
|
-
const id = String(row?.userId || "");
|
|
43
|
-
const key = id || `\0${anon++}`;
|
|
44
|
-
const texts = groups.get(key) || [];
|
|
45
|
-
if (row?.text) texts.push(String(row.text));
|
|
46
|
-
groups.set(key, texts);
|
|
47
|
-
}
|
|
48
|
-
return [...groups.values()]
|
|
49
|
-
.map((texts) => texts.join(" "))
|
|
50
|
-
.filter((text) => text.trim());
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function allowedCollection(db, collection, session) {
|
|
54
|
-
if (!session) return true;
|
|
55
|
-
const { aliases, kind, privateKey } = scopesFor(db, session);
|
|
56
|
-
if (collection.scope === "shared") return true;
|
|
57
|
-
if (privateKey && collection.scope === privateKey) return true;
|
|
58
|
-
if (aliases.includes(collection.scope)) return true;
|
|
59
|
-
if (String(collection.scope).startsWith("__private__") && kind !== "private")
|
|
60
|
-
return false;
|
|
61
|
-
if (!aliases.length) return false;
|
|
62
|
-
return !!db
|
|
63
|
-
.prepare(
|
|
64
|
-
`SELECT 1 FROM core_collection_sessions WHERE collection_id=? AND session_id IN (${aliases.map(() => "?").join(",")})`,
|
|
65
|
-
)
|
|
66
|
-
.get(collection.id, ...aliases);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export class KnowledgeManager {
|
|
70
|
-
constructor(repo, models) {
|
|
71
|
-
this.repo = repo;
|
|
72
|
-
this.models = models;
|
|
73
|
-
}
|
|
74
|
-
collections(session = "") {
|
|
75
|
-
return this.repo.db
|
|
76
|
-
.prepare("SELECT * FROM core_collections ORDER BY updated DESC")
|
|
77
|
-
.all()
|
|
78
|
-
.filter((c) => allowedCollection(this.repo.db, c, session));
|
|
79
|
-
}
|
|
80
|
-
createCollection({ name, scope = "shared", sessions = [] }) {
|
|
81
|
-
if (typeof name !== "string" || !name.trim() || name.length > 100)
|
|
82
|
-
throw Error("知识库名称无效");
|
|
83
|
-
const id = randomUUID(),
|
|
84
|
-
now = Date.now();
|
|
85
|
-
this.repo.db
|
|
86
|
-
.prepare(
|
|
87
|
-
"INSERT INTO core_collections(id,name,scope,created,updated) VALUES (?,?,?,?,?)",
|
|
88
|
-
)
|
|
89
|
-
.run(id, name.trim(), scope || "shared", now, now);
|
|
90
|
-
for (const session of sessions)
|
|
91
|
-
this.repo.db
|
|
92
|
-
.prepare("INSERT OR IGNORE INTO core_collection_sessions VALUES (?,?)")
|
|
93
|
-
.run(id, session);
|
|
94
|
-
this.repo.store.revision++;
|
|
95
|
-
return this.repo.db
|
|
96
|
-
.prepare("SELECT * FROM core_collections WHERE id=?")
|
|
97
|
-
.get(id);
|
|
98
|
-
}
|
|
99
|
-
documents(collectionId) {
|
|
100
|
-
return this.repo.db
|
|
101
|
-
.prepare(
|
|
102
|
-
"SELECT * FROM core_documents WHERE collection_id=? ORDER BY updated DESC",
|
|
103
|
-
)
|
|
104
|
-
.all(collectionId);
|
|
105
|
-
}
|
|
106
|
-
async ingest({ collectionId, title, text, source = "paste", embed = true }) {
|
|
107
|
-
const collection = this.repo.db
|
|
108
|
-
.prepare("SELECT * FROM core_collections WHERE id=?")
|
|
109
|
-
.get(collectionId);
|
|
110
|
-
if (!collection) throw Error("知识库不存在");
|
|
111
|
-
if (typeof text !== "string" || !text.trim() || text.length > 400000)
|
|
112
|
-
throw Error("文档内容应为 1–400000 字");
|
|
113
|
-
const safeTitle = String(title || "未命名文档").slice(0, 200);
|
|
114
|
-
const plain = asPlainDocument(text);
|
|
115
|
-
const chunks = chunkText(plain);
|
|
116
|
-
if (!chunks.length) throw Error("文档切分后为空");
|
|
117
|
-
const id = randomUUID(),
|
|
118
|
-
now = Date.now(),
|
|
119
|
-
hash = createHash("sha256").update(text).digest("hex");
|
|
120
|
-
mkdirSync(runtimePaths().knowledge, { recursive: true, mode: 0o700 });
|
|
121
|
-
const path = join(runtimePaths().knowledge, id + ".md");
|
|
122
|
-
writeFileSync(path, text);
|
|
123
|
-
this.repo.db
|
|
124
|
-
.prepare(
|
|
125
|
-
"INSERT INTO core_documents(id,collection_id,title,source,path,status,created,updated,bytes,hash) VALUES (?,?,?,?,?,'processing',?,?,?,?)",
|
|
126
|
-
)
|
|
127
|
-
.run(
|
|
128
|
-
id,
|
|
129
|
-
collectionId,
|
|
130
|
-
safeTitle,
|
|
131
|
-
source,
|
|
132
|
-
path,
|
|
133
|
-
now,
|
|
134
|
-
now,
|
|
135
|
-
Buffer.byteLength(text),
|
|
136
|
-
hash,
|
|
137
|
-
);
|
|
138
|
-
let vectors = [];
|
|
139
|
-
let embeddingProfile = "";
|
|
140
|
-
try {
|
|
141
|
-
if (embed) {
|
|
142
|
-
const resolved = resolveEmbeddingProfile(this.repo, this.models);
|
|
143
|
-
vectors = await embedBatches(
|
|
144
|
-
this.models,
|
|
145
|
-
resolved.profile,
|
|
146
|
-
chunks.map((c) => c.text),
|
|
147
|
-
);
|
|
148
|
-
if (vectors.length) embeddingProfile = resolved.signature;
|
|
149
|
-
}
|
|
150
|
-
} catch (error) {
|
|
151
|
-
this.repo.db
|
|
152
|
-
.prepare(
|
|
153
|
-
"UPDATE core_documents SET status='failed',error=?,updated=? WHERE id=?",
|
|
154
|
-
)
|
|
155
|
-
.run(error.message, Date.now(), id);
|
|
156
|
-
throw error;
|
|
157
|
-
}
|
|
158
|
-
this.repo.db.exec("BEGIN IMMEDIATE");
|
|
159
|
-
try {
|
|
160
|
-
chunks.forEach((chunk, i) => {
|
|
161
|
-
const chunkId = randomUUID();
|
|
162
|
-
this.repo.db
|
|
163
|
-
.prepare(
|
|
164
|
-
"INSERT INTO core_chunks(id,document_id,collection_id,ordinal,heading,text,tokens,embedding,hash,created,embedding_profile) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
165
|
-
)
|
|
166
|
-
.run(
|
|
167
|
-
chunkId,
|
|
168
|
-
id,
|
|
169
|
-
collectionId,
|
|
170
|
-
i,
|
|
171
|
-
chunk.heading,
|
|
172
|
-
chunk.text,
|
|
173
|
-
chunk.text.slice(0, 200),
|
|
174
|
-
vectors[i] ? packVector(vectors[i]) : null,
|
|
175
|
-
createHash("sha256").update(chunk.text).digest("hex"),
|
|
176
|
-
now,
|
|
177
|
-
embeddingProfile,
|
|
178
|
-
);
|
|
179
|
-
indexChunk(this.repo.db, chunkId, chunk.text);
|
|
180
|
-
});
|
|
181
|
-
this.repo.db
|
|
182
|
-
.prepare(
|
|
183
|
-
"UPDATE core_documents SET status='ready',error=NULL,updated=? WHERE id=?",
|
|
184
|
-
)
|
|
185
|
-
.run(Date.now(), id);
|
|
186
|
-
this.repo.db
|
|
187
|
-
.prepare("UPDATE core_collections SET updated=? WHERE id=?")
|
|
188
|
-
.run(Date.now(), collectionId);
|
|
189
|
-
this.repo.db.exec("COMMIT");
|
|
190
|
-
} catch (error) {
|
|
191
|
-
this.repo.db.exec("ROLLBACK");
|
|
192
|
-
throw error;
|
|
193
|
-
}
|
|
194
|
-
this.repo.store.revision++;
|
|
195
|
-
return this.repo.db
|
|
196
|
-
.prepare("SELECT * FROM core_documents WHERE id=?")
|
|
197
|
-
.get(id);
|
|
198
|
-
}
|
|
199
|
-
async embedTexts(texts) {
|
|
200
|
-
const { profile } = resolveEmbeddingProfile(this.repo, this.models);
|
|
201
|
-
return embedBatches(this.models, profile, texts);
|
|
202
|
-
}
|
|
203
|
-
retrieve(
|
|
204
|
-
session,
|
|
205
|
-
rows,
|
|
206
|
-
cutoff = Date.now(),
|
|
207
|
-
{ vectors = [], signature = null, legacy = true } = {},
|
|
208
|
-
) {
|
|
209
|
-
const allowed = new Set(
|
|
210
|
-
this.repo.db
|
|
211
|
-
.prepare("SELECT id,scope FROM core_collections")
|
|
212
|
-
.all()
|
|
213
|
-
.filter((c) => allowedCollection(this.repo.db, c, session))
|
|
214
|
-
.map((c) => c.id),
|
|
215
|
-
);
|
|
216
|
-
if (!allowed.size) return [];
|
|
217
|
-
const queries = saidBy(rows).slice(0, 8);
|
|
218
|
-
if (!queries.length) return [];
|
|
219
|
-
// Each person's own words. Two people are not added together, and a
|
|
220
|
-
// line she already said does not count as the room asking. A prepared
|
|
221
|
-
// vector belongs to that same person, not to the batch mixed together.
|
|
222
|
-
if (vectors.some((vector) => vector?.length))
|
|
223
|
-
ensureVectorIndex(this.repo.db);
|
|
224
|
-
const best = new Map();
|
|
225
|
-
const selected = new Map();
|
|
226
|
-
for (let i = 0; i < queries.length; i++) {
|
|
227
|
-
const query = queries[i];
|
|
228
|
-
const match = ftsMatchQuery(query);
|
|
229
|
-
const fts = new Set();
|
|
230
|
-
if (match)
|
|
231
|
-
try {
|
|
232
|
-
for (const hit of this.repo.db
|
|
233
|
-
.prepare(
|
|
234
|
-
`SELECT f.chunk_id FROM core_chunk_fts f JOIN core_chunks c ON c.id=f.chunk_id JOIN core_documents d ON d.id=c.document_id WHERE f.tokens MATCH ? AND c.collection_id IN (${[...allowed].map(() => "?").join(",")}) AND d.status='ready' AND d.created<=? ORDER BY f.rank LIMIT 128`,
|
|
235
|
-
)
|
|
236
|
-
.all(match, ...allowed, cutoff))
|
|
237
|
-
fts.add(hit.chunk_id);
|
|
238
|
-
} catch {
|
|
239
|
-
/* malformed MATCH */
|
|
240
|
-
}
|
|
241
|
-
const queryTerms = lexicalTerms(query);
|
|
242
|
-
const vec = vectors[i] || null;
|
|
243
|
-
const ids = [
|
|
244
|
-
...new Set([
|
|
245
|
-
...fts,
|
|
246
|
-
...(vec
|
|
247
|
-
? vectorCandidates(this.repo.db, vec, [...allowed], cutoff, {
|
|
248
|
-
signature,
|
|
249
|
-
legacy,
|
|
250
|
-
})
|
|
251
|
-
: []),
|
|
252
|
-
]),
|
|
253
|
-
];
|
|
254
|
-
const chunks = ids.length
|
|
255
|
-
? this.repo.db
|
|
256
|
-
.prepare(
|
|
257
|
-
`SELECT c.*,d.title,d.created AS document_created FROM core_chunks c JOIN core_documents d ON d.id=c.document_id WHERE c.id IN (${ids.map(() => "?").join(",")})`,
|
|
258
|
-
)
|
|
259
|
-
.all(...ids)
|
|
260
|
-
: [];
|
|
261
|
-
for (const chunk of chunks) {
|
|
262
|
-
selected.set(chunk.id, chunk);
|
|
263
|
-
let score =
|
|
264
|
-
(fts.has(chunk.id) ? 2 : 0) +
|
|
265
|
-
overlapScore(chunk.text, queryTerms) * 0.5;
|
|
266
|
-
if (
|
|
267
|
-
vec &&
|
|
268
|
-
chunk.embedding &&
|
|
269
|
-
(!signature ||
|
|
270
|
-
chunk.embedding_profile === signature ||
|
|
271
|
-
(legacy && !chunk.embedding_profile))
|
|
272
|
-
)
|
|
273
|
-
score += cosine(vec, unpackVector(chunk.embedding)) * 4;
|
|
274
|
-
const ageDays = (cutoff - (chunk.document_created || 0)) / 86400000;
|
|
275
|
-
if (ageDays > 30) score *= 0.85;
|
|
276
|
-
if (score > (best.get(chunk.id) || 0)) best.set(chunk.id, score);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return [...best.entries()]
|
|
280
|
-
.sort((a, b) => b[1] - a[1])
|
|
281
|
-
.slice(0, 8)
|
|
282
|
-
.map(([id, score]) => {
|
|
283
|
-
const chunk = selected.get(id);
|
|
284
|
-
if (!chunk) return null;
|
|
285
|
-
return {
|
|
286
|
-
id: chunk.id,
|
|
287
|
-
documentId: chunk.document_id,
|
|
288
|
-
title: chunk.title,
|
|
289
|
-
heading: chunk.heading,
|
|
290
|
-
text: chunk.text.slice(0, 800),
|
|
291
|
-
score,
|
|
292
|
-
whySelected: score >= 4 ? "semantic+lexical" : "lexical",
|
|
293
|
-
};
|
|
294
|
-
})
|
|
295
|
-
.filter((c) => c?.text);
|
|
296
|
-
}
|
|
297
|
-
async retrieveWithEmbed(session, rows, cutoff = Date.now()) {
|
|
298
|
-
const { profile, signature, legacy } = resolveEmbeddingProfile(
|
|
299
|
-
this.repo,
|
|
300
|
-
this.models,
|
|
301
|
-
);
|
|
302
|
-
const queries = saidBy(rows).slice(0, 8);
|
|
303
|
-
let vectors = [];
|
|
304
|
-
if (profile.embedding && queries.length) {
|
|
305
|
-
try {
|
|
306
|
-
vectors = await embedBatches(
|
|
307
|
-
this.models,
|
|
308
|
-
profile,
|
|
309
|
-
queries.map((query) => query.slice(0, 2000)),
|
|
310
|
-
);
|
|
311
|
-
} catch {
|
|
312
|
-
vectors = [];
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
return this.retrieve(session, rows, cutoff, { vectors, signature, legacy });
|
|
316
|
-
}
|
|
317
|
-
async reembedDocument(id) {
|
|
318
|
-
const rows = this.repo.db
|
|
319
|
-
.prepare(
|
|
320
|
-
"SELECT id,text FROM core_chunks WHERE document_id=? ORDER BY ordinal",
|
|
321
|
-
)
|
|
322
|
-
.all(id);
|
|
323
|
-
if (!rows.length) throw Error("文档没有可建立向量的段落");
|
|
324
|
-
const { profile, signature } = resolveEmbeddingProfile(
|
|
325
|
-
this.repo,
|
|
326
|
-
this.models,
|
|
327
|
-
);
|
|
328
|
-
if (!profile.embedding) throw Error("请先启用知识向量模型");
|
|
329
|
-
const vectors = await embedBatches(
|
|
330
|
-
this.models,
|
|
331
|
-
profile,
|
|
332
|
-
rows.map((row) => row.text),
|
|
333
|
-
);
|
|
334
|
-
const db = this.repo.db;
|
|
335
|
-
db.exec("SAVEPOINT reembed_document");
|
|
336
|
-
try {
|
|
337
|
-
const update = db.prepare(
|
|
338
|
-
"UPDATE core_chunks SET embedding=?,embedding_profile=? WHERE id=? AND document_id=? AND text=?",
|
|
339
|
-
);
|
|
340
|
-
rows.forEach((row, index) => {
|
|
341
|
-
if (
|
|
342
|
-
!update.run(
|
|
343
|
-
packVector(vectors[index]),
|
|
344
|
-
signature,
|
|
345
|
-
row.id,
|
|
346
|
-
id,
|
|
347
|
-
row.text,
|
|
348
|
-
).changes
|
|
349
|
-
)
|
|
350
|
-
throw Error("文档已变化,请重新建立向量");
|
|
351
|
-
});
|
|
352
|
-
db.exec("RELEASE reembed_document");
|
|
353
|
-
} catch (error) {
|
|
354
|
-
db.exec("ROLLBACK TO reembed_document");
|
|
355
|
-
db.exec("RELEASE reembed_document");
|
|
356
|
-
throw error;
|
|
357
|
-
}
|
|
358
|
-
this.repo.store.revision++;
|
|
359
|
-
return { chunks: rows.length };
|
|
360
|
-
}
|
|
361
|
-
removeDocument(id) {
|
|
362
|
-
const row = this.repo.db
|
|
363
|
-
.prepare("SELECT * FROM core_documents WHERE id=?")
|
|
364
|
-
.get(id);
|
|
365
|
-
if (!row) throw Error("文档不存在");
|
|
366
|
-
const chunks = this.repo.db
|
|
367
|
-
.prepare("SELECT id FROM core_chunks WHERE document_id=?")
|
|
368
|
-
.all(id);
|
|
369
|
-
this.repo.db.exec("BEGIN IMMEDIATE");
|
|
370
|
-
try {
|
|
371
|
-
for (const chunk of chunks)
|
|
372
|
-
this.repo.db
|
|
373
|
-
.prepare("DELETE FROM core_chunk_fts WHERE chunk_id=?")
|
|
374
|
-
.run(chunk.id);
|
|
375
|
-
this.repo.db
|
|
376
|
-
.prepare("DELETE FROM core_chunks WHERE document_id=?")
|
|
377
|
-
.run(id);
|
|
378
|
-
this.repo.db.prepare("DELETE FROM core_documents WHERE id=?").run(id);
|
|
379
|
-
this.repo.db.exec("COMMIT");
|
|
380
|
-
} catch (e) {
|
|
381
|
-
this.repo.db.exec("ROLLBACK");
|
|
382
|
-
throw e;
|
|
383
|
-
}
|
|
384
|
-
this.repo.store.revision++;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runtimePaths } from "../paths.js";
|
|
5
|
+
import {
|
|
6
|
+
asPlainDocument,
|
|
7
|
+
chunkText,
|
|
8
|
+
cosine,
|
|
9
|
+
ftsMatchQuery,
|
|
10
|
+
overlapScore,
|
|
11
|
+
lexicalTerms,
|
|
12
|
+
packVector,
|
|
13
|
+
unpackVector,
|
|
14
|
+
} from "./retrieval.js";
|
|
15
|
+
import { indexChunk } from "./schema.js";
|
|
16
|
+
import { ensureVectorIndex, vectorCandidates } from "./vector-index.js";
|
|
17
|
+
import { resolveEmbeddingProfile, embedBatches } from "./embedding-profile.js";
|
|
18
|
+
import {
|
|
19
|
+
parseSessionKey,
|
|
20
|
+
scopedSessionAliases,
|
|
21
|
+
} from "../channels/session-key.js";
|
|
22
|
+
|
|
23
|
+
function scopesFor(db, session) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = parseSessionKey(session);
|
|
26
|
+
const aliases = scopedSessionAliases(db, session);
|
|
27
|
+
return {
|
|
28
|
+
aliases,
|
|
29
|
+
kind: parsed.kind,
|
|
30
|
+
privateKey: `__private__:${parsed.nativeId}`,
|
|
31
|
+
};
|
|
32
|
+
} catch {
|
|
33
|
+
return { aliases: [session], kind: "group", privateKey: "" };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saidBy(rows) {
|
|
38
|
+
const groups = new Map();
|
|
39
|
+
let anon = 0;
|
|
40
|
+
for (const row of rows || []) {
|
|
41
|
+
if (row?.role === "assistant") continue;
|
|
42
|
+
const id = String(row?.userId || "");
|
|
43
|
+
const key = id || `\0${anon++}`;
|
|
44
|
+
const texts = groups.get(key) || [];
|
|
45
|
+
if (row?.text) texts.push(String(row.text));
|
|
46
|
+
groups.set(key, texts);
|
|
47
|
+
}
|
|
48
|
+
return [...groups.values()]
|
|
49
|
+
.map((texts) => texts.join(" "))
|
|
50
|
+
.filter((text) => text.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function allowedCollection(db, collection, session) {
|
|
54
|
+
if (!session) return true;
|
|
55
|
+
const { aliases, kind, privateKey } = scopesFor(db, session);
|
|
56
|
+
if (collection.scope === "shared") return true;
|
|
57
|
+
if (privateKey && collection.scope === privateKey) return true;
|
|
58
|
+
if (aliases.includes(collection.scope)) return true;
|
|
59
|
+
if (String(collection.scope).startsWith("__private__") && kind !== "private")
|
|
60
|
+
return false;
|
|
61
|
+
if (!aliases.length) return false;
|
|
62
|
+
return !!db
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT 1 FROM core_collection_sessions WHERE collection_id=? AND session_id IN (${aliases.map(() => "?").join(",")})`,
|
|
65
|
+
)
|
|
66
|
+
.get(collection.id, ...aliases);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class KnowledgeManager {
|
|
70
|
+
constructor(repo, models) {
|
|
71
|
+
this.repo = repo;
|
|
72
|
+
this.models = models;
|
|
73
|
+
}
|
|
74
|
+
collections(session = "") {
|
|
75
|
+
return this.repo.db
|
|
76
|
+
.prepare("SELECT * FROM core_collections ORDER BY updated DESC")
|
|
77
|
+
.all()
|
|
78
|
+
.filter((c) => allowedCollection(this.repo.db, c, session));
|
|
79
|
+
}
|
|
80
|
+
createCollection({ name, scope = "shared", sessions = [] }) {
|
|
81
|
+
if (typeof name !== "string" || !name.trim() || name.length > 100)
|
|
82
|
+
throw Error("知识库名称无效");
|
|
83
|
+
const id = randomUUID(),
|
|
84
|
+
now = Date.now();
|
|
85
|
+
this.repo.db
|
|
86
|
+
.prepare(
|
|
87
|
+
"INSERT INTO core_collections(id,name,scope,created,updated) VALUES (?,?,?,?,?)",
|
|
88
|
+
)
|
|
89
|
+
.run(id, name.trim(), scope || "shared", now, now);
|
|
90
|
+
for (const session of sessions)
|
|
91
|
+
this.repo.db
|
|
92
|
+
.prepare("INSERT OR IGNORE INTO core_collection_sessions VALUES (?,?)")
|
|
93
|
+
.run(id, session);
|
|
94
|
+
this.repo.store.revision++;
|
|
95
|
+
return this.repo.db
|
|
96
|
+
.prepare("SELECT * FROM core_collections WHERE id=?")
|
|
97
|
+
.get(id);
|
|
98
|
+
}
|
|
99
|
+
documents(collectionId) {
|
|
100
|
+
return this.repo.db
|
|
101
|
+
.prepare(
|
|
102
|
+
"SELECT * FROM core_documents WHERE collection_id=? ORDER BY updated DESC",
|
|
103
|
+
)
|
|
104
|
+
.all(collectionId);
|
|
105
|
+
}
|
|
106
|
+
async ingest({ collectionId, title, text, source = "paste", embed = true }) {
|
|
107
|
+
const collection = this.repo.db
|
|
108
|
+
.prepare("SELECT * FROM core_collections WHERE id=?")
|
|
109
|
+
.get(collectionId);
|
|
110
|
+
if (!collection) throw Error("知识库不存在");
|
|
111
|
+
if (typeof text !== "string" || !text.trim() || text.length > 400000)
|
|
112
|
+
throw Error("文档内容应为 1–400000 字");
|
|
113
|
+
const safeTitle = String(title || "未命名文档").slice(0, 200);
|
|
114
|
+
const plain = asPlainDocument(text);
|
|
115
|
+
const chunks = chunkText(plain);
|
|
116
|
+
if (!chunks.length) throw Error("文档切分后为空");
|
|
117
|
+
const id = randomUUID(),
|
|
118
|
+
now = Date.now(),
|
|
119
|
+
hash = createHash("sha256").update(text).digest("hex");
|
|
120
|
+
mkdirSync(runtimePaths().knowledge, { recursive: true, mode: 0o700 });
|
|
121
|
+
const path = join(runtimePaths().knowledge, id + ".md");
|
|
122
|
+
writeFileSync(path, text);
|
|
123
|
+
this.repo.db
|
|
124
|
+
.prepare(
|
|
125
|
+
"INSERT INTO core_documents(id,collection_id,title,source,path,status,created,updated,bytes,hash) VALUES (?,?,?,?,?,'processing',?,?,?,?)",
|
|
126
|
+
)
|
|
127
|
+
.run(
|
|
128
|
+
id,
|
|
129
|
+
collectionId,
|
|
130
|
+
safeTitle,
|
|
131
|
+
source,
|
|
132
|
+
path,
|
|
133
|
+
now,
|
|
134
|
+
now,
|
|
135
|
+
Buffer.byteLength(text),
|
|
136
|
+
hash,
|
|
137
|
+
);
|
|
138
|
+
let vectors = [];
|
|
139
|
+
let embeddingProfile = "";
|
|
140
|
+
try {
|
|
141
|
+
if (embed) {
|
|
142
|
+
const resolved = resolveEmbeddingProfile(this.repo, this.models);
|
|
143
|
+
vectors = await embedBatches(
|
|
144
|
+
this.models,
|
|
145
|
+
resolved.profile,
|
|
146
|
+
chunks.map((c) => c.text),
|
|
147
|
+
);
|
|
148
|
+
if (vectors.length) embeddingProfile = resolved.signature;
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
this.repo.db
|
|
152
|
+
.prepare(
|
|
153
|
+
"UPDATE core_documents SET status='failed',error=?,updated=? WHERE id=?",
|
|
154
|
+
)
|
|
155
|
+
.run(error.message, Date.now(), id);
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
this.repo.db.exec("BEGIN IMMEDIATE");
|
|
159
|
+
try {
|
|
160
|
+
chunks.forEach((chunk, i) => {
|
|
161
|
+
const chunkId = randomUUID();
|
|
162
|
+
this.repo.db
|
|
163
|
+
.prepare(
|
|
164
|
+
"INSERT INTO core_chunks(id,document_id,collection_id,ordinal,heading,text,tokens,embedding,hash,created,embedding_profile) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
165
|
+
)
|
|
166
|
+
.run(
|
|
167
|
+
chunkId,
|
|
168
|
+
id,
|
|
169
|
+
collectionId,
|
|
170
|
+
i,
|
|
171
|
+
chunk.heading,
|
|
172
|
+
chunk.text,
|
|
173
|
+
chunk.text.slice(0, 200),
|
|
174
|
+
vectors[i] ? packVector(vectors[i]) : null,
|
|
175
|
+
createHash("sha256").update(chunk.text).digest("hex"),
|
|
176
|
+
now,
|
|
177
|
+
embeddingProfile,
|
|
178
|
+
);
|
|
179
|
+
indexChunk(this.repo.db, chunkId, chunk.text);
|
|
180
|
+
});
|
|
181
|
+
this.repo.db
|
|
182
|
+
.prepare(
|
|
183
|
+
"UPDATE core_documents SET status='ready',error=NULL,updated=? WHERE id=?",
|
|
184
|
+
)
|
|
185
|
+
.run(Date.now(), id);
|
|
186
|
+
this.repo.db
|
|
187
|
+
.prepare("UPDATE core_collections SET updated=? WHERE id=?")
|
|
188
|
+
.run(Date.now(), collectionId);
|
|
189
|
+
this.repo.db.exec("COMMIT");
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.repo.db.exec("ROLLBACK");
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
this.repo.store.revision++;
|
|
195
|
+
return this.repo.db
|
|
196
|
+
.prepare("SELECT * FROM core_documents WHERE id=?")
|
|
197
|
+
.get(id);
|
|
198
|
+
}
|
|
199
|
+
async embedTexts(texts) {
|
|
200
|
+
const { profile } = resolveEmbeddingProfile(this.repo, this.models);
|
|
201
|
+
return embedBatches(this.models, profile, texts);
|
|
202
|
+
}
|
|
203
|
+
retrieve(
|
|
204
|
+
session,
|
|
205
|
+
rows,
|
|
206
|
+
cutoff = Date.now(),
|
|
207
|
+
{ vectors = [], signature = null, legacy = true } = {},
|
|
208
|
+
) {
|
|
209
|
+
const allowed = new Set(
|
|
210
|
+
this.repo.db
|
|
211
|
+
.prepare("SELECT id,scope FROM core_collections")
|
|
212
|
+
.all()
|
|
213
|
+
.filter((c) => allowedCollection(this.repo.db, c, session))
|
|
214
|
+
.map((c) => c.id),
|
|
215
|
+
);
|
|
216
|
+
if (!allowed.size) return [];
|
|
217
|
+
const queries = saidBy(rows).slice(0, 8);
|
|
218
|
+
if (!queries.length) return [];
|
|
219
|
+
// Each person's own words. Two people are not added together, and a
|
|
220
|
+
// line she already said does not count as the room asking. A prepared
|
|
221
|
+
// vector belongs to that same person, not to the batch mixed together.
|
|
222
|
+
if (vectors.some((vector) => vector?.length))
|
|
223
|
+
ensureVectorIndex(this.repo.db);
|
|
224
|
+
const best = new Map();
|
|
225
|
+
const selected = new Map();
|
|
226
|
+
for (let i = 0; i < queries.length; i++) {
|
|
227
|
+
const query = queries[i];
|
|
228
|
+
const match = ftsMatchQuery(query);
|
|
229
|
+
const fts = new Set();
|
|
230
|
+
if (match)
|
|
231
|
+
try {
|
|
232
|
+
for (const hit of this.repo.db
|
|
233
|
+
.prepare(
|
|
234
|
+
`SELECT f.chunk_id FROM core_chunk_fts f JOIN core_chunks c ON c.id=f.chunk_id JOIN core_documents d ON d.id=c.document_id WHERE f.tokens MATCH ? AND c.collection_id IN (${[...allowed].map(() => "?").join(",")}) AND d.status='ready' AND d.created<=? ORDER BY f.rank LIMIT 128`,
|
|
235
|
+
)
|
|
236
|
+
.all(match, ...allowed, cutoff))
|
|
237
|
+
fts.add(hit.chunk_id);
|
|
238
|
+
} catch {
|
|
239
|
+
/* malformed MATCH */
|
|
240
|
+
}
|
|
241
|
+
const queryTerms = lexicalTerms(query);
|
|
242
|
+
const vec = vectors[i] || null;
|
|
243
|
+
const ids = [
|
|
244
|
+
...new Set([
|
|
245
|
+
...fts,
|
|
246
|
+
...(vec
|
|
247
|
+
? vectorCandidates(this.repo.db, vec, [...allowed], cutoff, {
|
|
248
|
+
signature,
|
|
249
|
+
legacy,
|
|
250
|
+
})
|
|
251
|
+
: []),
|
|
252
|
+
]),
|
|
253
|
+
];
|
|
254
|
+
const chunks = ids.length
|
|
255
|
+
? this.repo.db
|
|
256
|
+
.prepare(
|
|
257
|
+
`SELECT c.*,d.title,d.created AS document_created FROM core_chunks c JOIN core_documents d ON d.id=c.document_id WHERE c.id IN (${ids.map(() => "?").join(",")})`,
|
|
258
|
+
)
|
|
259
|
+
.all(...ids)
|
|
260
|
+
: [];
|
|
261
|
+
for (const chunk of chunks) {
|
|
262
|
+
selected.set(chunk.id, chunk);
|
|
263
|
+
let score =
|
|
264
|
+
(fts.has(chunk.id) ? 2 : 0) +
|
|
265
|
+
overlapScore(chunk.text, queryTerms) * 0.5;
|
|
266
|
+
if (
|
|
267
|
+
vec &&
|
|
268
|
+
chunk.embedding &&
|
|
269
|
+
(!signature ||
|
|
270
|
+
chunk.embedding_profile === signature ||
|
|
271
|
+
(legacy && !chunk.embedding_profile))
|
|
272
|
+
)
|
|
273
|
+
score += cosine(vec, unpackVector(chunk.embedding)) * 4;
|
|
274
|
+
const ageDays = (cutoff - (chunk.document_created || 0)) / 86400000;
|
|
275
|
+
if (ageDays > 30) score *= 0.85;
|
|
276
|
+
if (score > (best.get(chunk.id) || 0)) best.set(chunk.id, score);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return [...best.entries()]
|
|
280
|
+
.sort((a, b) => b[1] - a[1])
|
|
281
|
+
.slice(0, 8)
|
|
282
|
+
.map(([id, score]) => {
|
|
283
|
+
const chunk = selected.get(id);
|
|
284
|
+
if (!chunk) return null;
|
|
285
|
+
return {
|
|
286
|
+
id: chunk.id,
|
|
287
|
+
documentId: chunk.document_id,
|
|
288
|
+
title: chunk.title,
|
|
289
|
+
heading: chunk.heading,
|
|
290
|
+
text: chunk.text.slice(0, 800),
|
|
291
|
+
score,
|
|
292
|
+
whySelected: score >= 4 ? "semantic+lexical" : "lexical",
|
|
293
|
+
};
|
|
294
|
+
})
|
|
295
|
+
.filter((c) => c?.text);
|
|
296
|
+
}
|
|
297
|
+
async retrieveWithEmbed(session, rows, cutoff = Date.now()) {
|
|
298
|
+
const { profile, signature, legacy } = resolveEmbeddingProfile(
|
|
299
|
+
this.repo,
|
|
300
|
+
this.models,
|
|
301
|
+
);
|
|
302
|
+
const queries = saidBy(rows).slice(0, 8);
|
|
303
|
+
let vectors = [];
|
|
304
|
+
if (profile.embedding && queries.length) {
|
|
305
|
+
try {
|
|
306
|
+
vectors = await embedBatches(
|
|
307
|
+
this.models,
|
|
308
|
+
profile,
|
|
309
|
+
queries.map((query) => query.slice(0, 2000)),
|
|
310
|
+
);
|
|
311
|
+
} catch {
|
|
312
|
+
vectors = [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return this.retrieve(session, rows, cutoff, { vectors, signature, legacy });
|
|
316
|
+
}
|
|
317
|
+
async reembedDocument(id) {
|
|
318
|
+
const rows = this.repo.db
|
|
319
|
+
.prepare(
|
|
320
|
+
"SELECT id,text FROM core_chunks WHERE document_id=? ORDER BY ordinal",
|
|
321
|
+
)
|
|
322
|
+
.all(id);
|
|
323
|
+
if (!rows.length) throw Error("文档没有可建立向量的段落");
|
|
324
|
+
const { profile, signature } = resolveEmbeddingProfile(
|
|
325
|
+
this.repo,
|
|
326
|
+
this.models,
|
|
327
|
+
);
|
|
328
|
+
if (!profile.embedding) throw Error("请先启用知识向量模型");
|
|
329
|
+
const vectors = await embedBatches(
|
|
330
|
+
this.models,
|
|
331
|
+
profile,
|
|
332
|
+
rows.map((row) => row.text),
|
|
333
|
+
);
|
|
334
|
+
const db = this.repo.db;
|
|
335
|
+
db.exec("SAVEPOINT reembed_document");
|
|
336
|
+
try {
|
|
337
|
+
const update = db.prepare(
|
|
338
|
+
"UPDATE core_chunks SET embedding=?,embedding_profile=? WHERE id=? AND document_id=? AND text=?",
|
|
339
|
+
);
|
|
340
|
+
rows.forEach((row, index) => {
|
|
341
|
+
if (
|
|
342
|
+
!update.run(
|
|
343
|
+
packVector(vectors[index]),
|
|
344
|
+
signature,
|
|
345
|
+
row.id,
|
|
346
|
+
id,
|
|
347
|
+
row.text,
|
|
348
|
+
).changes
|
|
349
|
+
)
|
|
350
|
+
throw Error("文档已变化,请重新建立向量");
|
|
351
|
+
});
|
|
352
|
+
db.exec("RELEASE reembed_document");
|
|
353
|
+
} catch (error) {
|
|
354
|
+
db.exec("ROLLBACK TO reembed_document");
|
|
355
|
+
db.exec("RELEASE reembed_document");
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
this.repo.store.revision++;
|
|
359
|
+
return { chunks: rows.length };
|
|
360
|
+
}
|
|
361
|
+
removeDocument(id) {
|
|
362
|
+
const row = this.repo.db
|
|
363
|
+
.prepare("SELECT * FROM core_documents WHERE id=?")
|
|
364
|
+
.get(id);
|
|
365
|
+
if (!row) throw Error("文档不存在");
|
|
366
|
+
const chunks = this.repo.db
|
|
367
|
+
.prepare("SELECT id FROM core_chunks WHERE document_id=?")
|
|
368
|
+
.all(id);
|
|
369
|
+
this.repo.db.exec("BEGIN IMMEDIATE");
|
|
370
|
+
try {
|
|
371
|
+
for (const chunk of chunks)
|
|
372
|
+
this.repo.db
|
|
373
|
+
.prepare("DELETE FROM core_chunk_fts WHERE chunk_id=?")
|
|
374
|
+
.run(chunk.id);
|
|
375
|
+
this.repo.db
|
|
376
|
+
.prepare("DELETE FROM core_chunks WHERE document_id=?")
|
|
377
|
+
.run(id);
|
|
378
|
+
this.repo.db.prepare("DELETE FROM core_documents WHERE id=?").run(id);
|
|
379
|
+
this.repo.db.exec("COMMIT");
|
|
380
|
+
} catch (e) {
|
|
381
|
+
this.repo.db.exec("ROLLBACK");
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
this.repo.store.revision++;
|
|
385
|
+
}
|
|
386
|
+
}
|