luckytri 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +7 -3
  3. package/README.md +7 -3
  4. package/docs/en/CHANGELOG.md +35 -16
  5. package/docs/en/chat-style.md +28 -9
  6. package/docs/en/guide.md +5 -5
  7. package/docs/en/her-life.md +2 -2
  8. package/docs/en/webui.md +1 -1
  9. package/docs/zh/CHANGELOG.md +35 -16
  10. package/docs/zh/chat-style.md +30 -9
  11. package/docs/zh/guide.md +5 -5
  12. package/docs/zh/her-life.md +2 -2
  13. package/docs/zh/webui.md +1 -1
  14. package/package.json +1 -1
  15. package/public/app/assets/{ChatsPage-DCYMkJ91.js → ChatsPage-CshdtTzV.js} +2 -2
  16. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  17. package/public/app/assets/{Empty-CLyZkxR5.js → Empty-C7yzOBzy.js} +1 -1
  18. package/public/app/assets/{Empty-DIobYG3x.css → Empty-DmUB2-Bk.css} +1 -1
  19. package/public/app/assets/{Field-BpaRwLI_.js → Field-BjFv4Rfn.js} +1 -1
  20. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  21. package/public/app/assets/{HeartPage-BIOMTiwr.js → HeartPage-eqo6ru16.js} +1 -1
  22. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  23. package/public/app/assets/{LifePage-BCgM4JkR.js → LifePage-CN8GE5NK.js} +1 -1
  24. package/public/app/assets/{MemoryPage-vShvRqMt.js → MemoryPage-CCpMN4Wg.js} +1 -1
  25. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  26. package/public/app/assets/{NaturePage-Cy9ptr4G.js → NaturePage-C0JqdBTR.js} +2 -2
  27. package/public/app/assets/NaturePage-CHkpb5NK.css +1 -0
  28. package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
  29. package/public/app/assets/{PeoplePage-CUZekB2E.js → PeoplePage-BXGBbO4u.js} +1 -1
  30. package/public/app/assets/{PluginsPage-1RM4rmJP.js → PluginsPage-Dksp5AV5.js} +1 -1
  31. package/public/app/assets/{SystemPage-BI2CB5Ig.css → SystemPage-BenIyTvk.css} +1 -1
  32. package/public/app/assets/{SystemPage-BeT5TmIV.js → SystemPage-Cd7aa8eE.js} +1 -1
  33. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  34. package/public/app/assets/{TimePage-taTmPMM8.js → TimePage-Bs_zImb4.js} +2 -2
  35. package/public/app/assets/{index-DY5AM0Hz.css → index-BHZcHUMh.css} +1 -1
  36. package/public/app/assets/{index-DdYwC5ZU.js → index-CyRtYxP3.js} +3 -3
  37. package/public/app/assets/{knowledge-DJmfu2KN.js → knowledge-BJOIyuJo.js} +1 -1
  38. package/public/app/assets/{media-BEtnE0Gv.js → media-Bwtx8blz.js} +1 -1
  39. package/public/app/assets/{mind-Dtiw0-QK.js → mind-DtTVrW6Z.js} +1 -1
  40. package/public/app/index.html +5 -6
  41. package/public/guide.en.html +3 -3
  42. package/public/guide.html +3 -3
  43. package/scripts/import-data.js +128 -128
  44. package/scripts/proxy-config.js +167 -167
  45. package/scripts/update-instance.js +201 -201
  46. package/server/channels/adapters.js +27 -27
  47. package/server/channels/onebot/adapter.js +288 -288
  48. package/server/channels/qqbot/adapter.js +253 -253
  49. package/server/channels/qqbot/api.js +199 -199
  50. package/server/channels/qqbot/credentials.js +11 -11
  51. package/server/channels/qqbot/gateway.js +274 -274
  52. package/server/channels/qqbot/index.js +389 -389
  53. package/server/channels/segments.js +40 -40
  54. package/server/channels/session-key.js +153 -153
  55. package/server/channels/state.js +42 -42
  56. package/server/core/api.js +564 -564
  57. package/server/core/context-builder.js +288 -288
  58. package/server/core/context-compactor.js +454 -454
  59. package/server/core/conversation-cues.js +385 -325
  60. package/server/core/conversation-grounding.js +304 -0
  61. package/server/core/conversation-manager.js +87 -87
  62. package/server/core/dialogue-context.js +244 -0
  63. package/server/core/dialogue-contract.js +35 -0
  64. package/server/core/feedback.js +8 -8
  65. package/server/core/group-style.js +72 -72
  66. package/server/core/initiative-context.js +68 -66
  67. package/server/core/input-budget.js +32 -32
  68. package/server/core/local-demo.js +98 -98
  69. package/server/core/message-manager.js +118 -118
  70. package/server/core/message-scheduler.js +78 -78
  71. package/server/core/model-json.js +190 -190
  72. package/server/core/model-manager.js +5 -8
  73. package/server/core/model-presets.js +900 -900
  74. package/server/core/name-call.js +29 -29
  75. package/server/core/network.js +157 -157
  76. package/server/core/person-name.js +22 -22
  77. package/server/core/persona-manager.js +359 -269
  78. package/server/core/reader-check.js +62 -0
  79. package/server/core/reply-delivery.js +537 -404
  80. package/server/core/reply-target-resolver.js +50 -50
  81. package/server/core/response-generator.js +92 -50
  82. package/server/core/response-validator.js +53 -32
  83. package/server/core/retired-prompts.js +51 -51
  84. package/server/core/sessions.js +109 -109
  85. package/server/core/speaker-names.js +92 -92
  86. package/server/core/topic-tracker.js +65 -65
  87. package/server/core/turn.js +82 -15
  88. package/server/http.js +18 -18
  89. package/server/i18n/decisions.js +209 -209
  90. package/server/i18n/errors.js +2 -0
  91. package/server/i18n/index.js +144 -144
  92. package/server/i18n/labels.js +6 -10
  93. package/server/i18n/models.js +111 -111
  94. package/server/knowledge/api.js +342 -342
  95. package/server/knowledge/embedding-profile.js +114 -114
  96. package/server/knowledge/index.js +13 -13
  97. package/server/knowledge/retrieval.js +93 -93
  98. package/server/knowledge/schema.js +158 -158
  99. package/server/knowledge/vector-index.js +90 -90
  100. package/server/mind/affect.js +265 -265
  101. package/server/mind/attention.js +200 -200
  102. package/server/mind/bonds.js +565 -565
  103. package/server/mind/clock.js +83 -83
  104. package/server/mind/context-selection.js +16 -4
  105. package/server/mind/continuity.js +562 -562
  106. package/server/mind/conversation-origin.js +175 -175
  107. package/server/mind/days.js +210 -210
  108. package/server/mind/evidence-read.js +45 -45
  109. package/server/mind/expression-grounding.js +21 -0
  110. package/server/mind/faces.js +373 -373
  111. package/server/mind/guard.js +68 -60
  112. package/server/mind/initiative.js +338 -338
  113. package/server/mind/life/activities.js +21 -21
  114. package/server/mind/life/context.js +330 -330
  115. package/server/mind/life/growth.js +261 -261
  116. package/server/mind/life/presence.js +305 -305
  117. package/server/mind/meetings.js +1069 -1069
  118. package/server/mind/memory-policy.js +48 -48
  119. package/server/mind/memory.js +888 -888
  120. package/server/mind/nature.js +358 -358
  121. package/server/mind/novelty.js +19 -19
  122. package/server/mind/own-voice.js +35 -0
  123. package/server/mind/periods.js +118 -118
  124. package/server/mind/reading.js +132 -120
  125. package/server/mind/relationship-context.js +71 -71
  126. package/server/mind/relationships.js +390 -390
  127. package/server/mind/salience.js +201 -201
  128. package/server/mind/schema.js +567 -567
  129. package/server/mind/self.js +496 -484
  130. package/server/mind/thoughts.js +274 -274
  131. package/server/mind/time/agenda.js +242 -242
  132. package/server/mind/time/api.js +200 -200
  133. package/server/mind/time/attention.js +34 -34
  134. package/server/mind/time/availability.js +23 -1
  135. package/server/mind/time/executor.js +4 -2
  136. package/server/mind/time/games.js +20 -11
  137. package/server/mind/time/index.js +9 -2
  138. package/server/mind/time/kinds.js +29 -2
  139. package/server/mind/time/material-relevance.js +19 -0
  140. package/server/mind/time/model-material.js +77 -77
  141. package/server/mind/time/presentation.js +35 -51
  142. package/server/mind/time/reading-input.js +31 -0
  143. package/server/mind/time/schedule.js +83 -83
  144. package/server/mind/time/schema.js +84 -84
  145. package/server/mind/time/search.js +383 -383
  146. package/server/mind/time/sharing.js +482 -472
  147. package/server/mind/time/task-links.js +311 -311
  148. package/server/mind/time/tasks.js +41 -3
  149. package/server/mind/time/works.js +527 -432
  150. package/server/mind/traits.js +191 -191
  151. package/server/mind/view.js +62 -12
  152. package/server/runtime/installation.js +116 -116
  153. package/server/runtime/update-manager.js +140 -140
  154. package/server/storage/data-package.js +298 -298
  155. package/server/storage/data-transfer-api.js +50 -50
  156. package/server/storage/data-transfer-worker.js +18 -18
  157. package/server/storage/data-transfer.js +416 -416
  158. package/server/studio/display-names.js +139 -139
  159. package/server/studio/management.js +102 -102
  160. package/server/studio/updates.js +144 -144
  161. package/server/version.js +10 -10
  162. package/public/app/assets/ChatsPage-CFMVozoG.css +0 -1
  163. package/public/app/assets/HeartPage-C-2jvgzF.css +0 -1
  164. package/public/app/assets/LifePage-CG1OHbRK.css +0 -1
  165. package/public/app/assets/MemoryPage-C_Bu4rfb.css +0 -1
  166. package/public/app/assets/NaturePage-CPZlImT0.css +0 -1
  167. package/public/app/assets/PeoplePage-3RRUITDz.css +0 -1
  168. package/public/app/assets/TimePage-C-guwPk2.css +0 -1
@@ -1,416 +1,416 @@
1
- import { Worker } from "node:worker_threads";
2
- import { randomUUID } from "node:crypto";
3
- import {
4
- createReadStream,
5
- createWriteStream,
6
- existsSync,
7
- mkdirSync,
8
- readFileSync,
9
- writeFileSync,
10
- readdirSync,
11
- lstatSync,
12
- rmSync,
13
- renameSync,
14
- } from "node:fs";
15
- import { join, resolve, relative, isAbsolute } from "node:path";
16
- import { pipeline } from "node:stream/promises";
17
- import { Transform } from "node:stream";
18
- import { runtimePaths } from "../paths.js";
19
- import { packageHeader } from "./data-package.js";
20
- import { DatabaseSync } from "node:sqlite";
21
-
22
- const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
23
- const TTL = 3600000;
24
- export const IMPORT_FAILURE =
25
- "导入未完成,原数据已保留。请重新上传或查看运行日志。";
26
- export const IMPORT_START_FAILURE =
27
- "导入启动未完成,未替换运行数据。请重新启动实例。";
28
- export const IMPORT_RESTART_FAILURE =
29
- "数据已导入,但自动重启未完成。请手动重新启动实例。";
30
- export const transferRoot = () => join(runtimePaths().data, "transfers");
31
- export function readTransferJob(root, id) {
32
- if (!ID.test(String(id))) throw Error("数据任务不存在");
33
- const job = JSON.parse(readFileSync(join(root, id, "job.json"), "utf8"));
34
- if (job.id !== id) throw Error("数据任务不存在");
35
- return job;
36
- }
37
- export function writeTransferJob(root, job) {
38
- if (!ID.test(job.id)) throw Error("数据任务不存在");
39
- const temporary = join(root, job.id, `.job-${randomUUID()}.partial`);
40
- try {
41
- writeFileSync(temporary, JSON.stringify(job), {
42
- mode: 0o600,
43
- flush: true,
44
- flag: "wx",
45
- });
46
- renameSync(temporary, join(root, job.id, "job.json"));
47
- } finally {
48
- rmSync(temporary, { force: true });
49
- }
50
- }
51
- function work(payload) {
52
- return new Promise((resolve, reject) => {
53
- const worker = new Worker(
54
- new URL("./data-transfer-worker.js", import.meta.url),
55
- { workerData: payload, stdout: true, stderr: true },
56
- );
57
- // Staging migrations must not interleave their console output with the
58
- // service or a test runner's protocol. Results travel through parentPort.
59
- worker.stdout.resume();
60
- worker.stderr.resume();
61
- worker.once("message", ({ result, error }) =>
62
- error ? reject(Error(error)) : resolve(result),
63
- );
64
- worker.once("error", reject);
65
- worker.once("exit", (code) => {
66
- if (code) reject(Error("数据处理失败"));
67
- });
68
- });
69
- }
70
- export function createDataTransfer({
71
- source = runtimePaths().database,
72
- backups = runtimePaths().backups,
73
- directory = transferRoot(),
74
- backup,
75
- cleanup,
76
- onApply,
77
- run = work,
78
- now = Date.now,
79
- maxUploadBytes,
80
- occupied = () => false,
81
- } = {}) {
82
- const root = resolve(directory);
83
- mkdirSync(root, { recursive: true, mode: 0o700 });
84
- let active = false;
85
- const maxBytes =
86
- maxUploadBytes ??
87
- Math.floor(
88
- Math.min(
89
- 100,
90
- Math.max(1, Number(process.env.DATA_TRANSFER_MAX_GB) || 10),
91
- ) * 1073741824,
92
- );
93
- const get = (id) => {
94
- try {
95
- return readTransferJob(root, id);
96
- } catch {
97
- throw Error("数据任务不存在");
98
- }
99
- };
100
- const save = (job) => {
101
- writeTransferJob(root, job);
102
- return job;
103
- };
104
- const publicJob = (job) => {
105
- const { manifest, ownerPid, ...rest } = job;
106
- return rest;
107
- };
108
- const check = () => {
109
- if (active || backup?.running || cleanup?.running || occupied())
110
- throw Error("数据处理或备份正在进行,请稍后再试");
111
- if (source === ":memory:") throw Error("当前实例不支持文件导入导出");
112
- };
113
- const prune = () => {
114
- for (const entry of readdirSync(root, { withFileTypes: true })) {
115
- if (!entry.isDirectory() || !ID.test(entry.name)) continue;
116
- const path = join(root, entry.name);
117
- const part = relative(root, resolve(path));
118
- if (
119
- !part ||
120
- part.startsWith("..") ||
121
- isAbsolute(part) ||
122
- lstatSync(path).isSymbolicLink()
123
- )
124
- continue;
125
- try {
126
- const job = get(entry.name);
127
- if (
128
- now() - (job.applied || job.readyAt || job.created) > TTL &&
129
- !["processing", "uploading", "queued"].includes(job.status)
130
- )
131
- rmSync(path, { recursive: true, force: true });
132
- } catch {
133
- /* Ignore incomplete metadata while a job is publishing it. */
134
- }
135
- }
136
- };
137
- const clearMigrationCopies = (job) => {
138
- const dir = join(root, job.id, "backups");
139
- const part = relative(root, resolve(dir));
140
- if (
141
- part &&
142
- !part.startsWith("..") &&
143
- !isAbsolute(part) &&
144
- existsSync(dir) &&
145
- lstatSync(dir).isDirectory() &&
146
- !lstatSync(dir).isSymbolicLink()
147
- )
148
- try {
149
- rmSync(dir, { recursive: true, force: true });
150
- } catch {
151
- return false;
152
- }
153
- return true;
154
- };
155
- const clearFiles = (job) => {
156
- let cleared = true;
157
- for (const file of [
158
- "upload",
159
- "data.db",
160
- "data.db.json",
161
- "data.db-wal",
162
- "data.db-shm",
163
- ])
164
- try {
165
- rmSync(join(root, job.id, file), { force: true });
166
- } catch {
167
- cleared = false;
168
- }
169
- return clearMigrationCopies(job) && cleared;
170
- };
171
- const create = (kind, name = "") => {
172
- prune();
173
- // Keep at most one unused file of each kind, rather than accumulating
174
- // multi-GiB previews every time the user picks another file.
175
- for (const id of readdirSync(root).filter((id) => ID.test(id))) {
176
- const previous = get(id);
177
- if (previous.kind === kind && previous.status === "ready") {
178
- if (!clearFiles(previous))
179
- throw Error("临时数据文件正在使用,请稍后再试");
180
- save({ ...previous, status: "expired" });
181
- }
182
- }
183
- const id = randomUUID();
184
- mkdirSync(join(root, id), { mode: 0o700 });
185
- return save({
186
- id,
187
- kind,
188
- name,
189
- created: now(),
190
- ownerPid: process.pid,
191
- status: kind === "export" ? "processing" : "uploading",
192
- });
193
- };
194
- const processJob = async (job) => {
195
- try {
196
- const result = await run({
197
- kind: job.kind,
198
- source,
199
- backups,
200
- target: join(root, job.id, "data.db"),
201
- upload: join(root, job.id, "upload"),
202
- });
203
- rmSync(join(root, job.id, "upload"), { force: true });
204
- clearMigrationCopies(job);
205
- save({ ...job, status: "ready", readyAt: now(), ...result });
206
- } catch (error) {
207
- clearFiles(job);
208
- try {
209
- save({
210
- ...job,
211
- status: "error",
212
- error: error.message || "数据处理失败",
213
- });
214
- } catch {
215
- console.error("数据任务状态保存失败");
216
- }
217
- } finally {
218
- active = false;
219
- }
220
- };
221
- const alive = (pid) => {
222
- if (!Number.isInteger(pid) || pid <= 0) return false;
223
- try {
224
- process.kill(pid, 0);
225
- return true;
226
- } catch (error) {
227
- return error.code !== "ESRCH";
228
- }
229
- };
230
- for (const id of readdirSync(root).filter((id) => ID.test(id))) {
231
- try {
232
- const job = get(id);
233
- if (
234
- job.kind === "import" &&
235
- job.status === "queued" &&
236
- source !== ":memory:" &&
237
- existsSync(source)
238
- ) {
239
- const db = new DatabaseSync(source, { readOnly: true });
240
- let marker;
241
- try {
242
- marker = JSON.parse(
243
- db.prepare("SELECT value FROM settings WHERE id=1").get().value,
244
- ).dataImportId;
245
- } finally {
246
- db.close();
247
- }
248
- if (marker === id) {
249
- save({ ...job, status: "done", applied: now() });
250
- continue;
251
- }
252
- }
253
- if (
254
- ["processing", "uploading", "queued"].includes(job.status) &&
255
- !alive(job.ownerPid)
256
- ) {
257
- clearFiles(job);
258
- save({
259
- ...job,
260
- status: "error",
261
- readyAt: now(),
262
- error: "数据处理被中断,请重新操作",
263
- });
264
- }
265
- } catch {}
266
- }
267
- return {
268
- get busy() {
269
- return active;
270
- },
271
- info() {
272
- prune();
273
- const imports = readdirSync(root)
274
- .filter((id) => ID.test(id))
275
- .map((id) => {
276
- try {
277
- return get(id);
278
- } catch {
279
- return null;
280
- }
281
- })
282
- .filter(
283
- (job) =>
284
- job?.kind === "import" && ["done", "error"].includes(job.status),
285
- );
286
- imports.sort((a, b) => b.created - a.created);
287
- return {
288
- available: source !== ":memory:",
289
- maxBytes,
290
- busy: active,
291
- lastImport: imports[0] ? publicJob(imports[0]) : null,
292
- };
293
- },
294
- job(id) {
295
- return publicJob(get(id));
296
- },
297
- export() {
298
- check();
299
- active = true;
300
- let job;
301
- try {
302
- job = create("export");
303
- } catch (error) {
304
- active = false;
305
- throw error;
306
- }
307
- void processJob(job);
308
- return publicJob(job);
309
- },
310
- async upload(stream, { bytes = 0, name = "" } = {}) {
311
- check();
312
- if (bytes > maxBytes) throw Error("数据文件超过导入大小限制");
313
- active = true;
314
- let job;
315
- try {
316
- job = create(
317
- "import",
318
- String(name).replace(/[\\/]/g, "_").slice(0, 180),
319
- );
320
- } catch (error) {
321
- active = false;
322
- throw error;
323
- }
324
- let total = 0;
325
- const limit = new Transform({
326
- transform(chunk, _encoding, done) {
327
- total += chunk.length;
328
- done(
329
- total > maxBytes ? Error("数据文件超过导入大小限制") : null,
330
- chunk,
331
- );
332
- },
333
- });
334
- try {
335
- await pipeline(
336
- stream,
337
- limit,
338
- createWriteStream(join(root, job.id, "upload"), {
339
- flags: "wx",
340
- mode: 0o600,
341
- }),
342
- );
343
- if (!total) throw Error("数据文件为空");
344
- save({ ...job, status: "processing" });
345
- void processJob(job);
346
- return publicJob({ ...job, status: "processing" });
347
- } catch (error) {
348
- active = false;
349
- rmSync(join(root, job.id, "upload"), { force: true });
350
- save({
351
- ...job,
352
- status: "error",
353
- error: error.code === "ENOSPC" ? "磁盘空间不足" : "数据上传失败",
354
- });
355
- throw Error("数据上传失败");
356
- }
357
- },
358
- async download(id, res) {
359
- if (active) throw Error("数据处理或备份正在进行,请稍后再试");
360
- const job = get(id);
361
- if (
362
- job.kind !== "export" ||
363
- job.status !== "ready" ||
364
- now() - (job.readyAt || job.created) > TTL
365
- )
366
- throw Error("导出文件尚未就绪或已过期");
367
- const header = packageHeader(job.manifest);
368
- res.setHeader("Content-Type", "application/octet-stream");
369
- res.setHeader(
370
- "Content-Disposition",
371
- `attachment; filename="luckytri-data-${new Date(job.created).toISOString().slice(0, 10)}.luckytri"`,
372
- );
373
- res.setHeader("Content-Length", header.length + job.bytes);
374
- active = true;
375
- try {
376
- res.write(header);
377
- await pipeline(createReadStream(join(root, id, "data.db")), res);
378
- clearFiles(job);
379
- save({ ...job, status: "done" });
380
- } finally {
381
- active = false;
382
- }
383
- },
384
- async apply(id) {
385
- check();
386
- const job = get(id);
387
- if (!onApply) throw Error("当前实例不支持自动导入,请使用恢复命令");
388
- if (
389
- job.kind !== "import" ||
390
- job.status !== "ready" ||
391
- now() - (job.readyAt || job.created) > TTL
392
- )
393
- throw Error("导入数据尚未就绪或已过期");
394
- active = true;
395
- save({ ...job, status: "queued" });
396
- try {
397
- const owner = await onApply(id);
398
- if (Number.isInteger(owner) && owner > 0)
399
- save({ ...job, status: "queued", ownerPid: owner });
400
- } catch (error) {
401
- active = false;
402
- save(job);
403
- throw error;
404
- }
405
- return { queued: true, id };
406
- },
407
- discard(id) {
408
- const job = get(id);
409
- if (["processing", "uploading", "queued"].includes(job.status))
410
- throw Error("数据处理正在进行,请稍后再试");
411
- if (!clearFiles(job)) throw Error("临时数据文件正在使用,请稍后再试");
412
- save({ ...job, status: "expired" });
413
- return { ok: true };
414
- },
415
- };
416
- }
1
+ import { Worker } from "node:worker_threads";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ createReadStream,
5
+ createWriteStream,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ writeFileSync,
10
+ readdirSync,
11
+ lstatSync,
12
+ rmSync,
13
+ renameSync,
14
+ } from "node:fs";
15
+ import { join, resolve, relative, isAbsolute } from "node:path";
16
+ import { pipeline } from "node:stream/promises";
17
+ import { Transform } from "node:stream";
18
+ import { runtimePaths } from "../paths.js";
19
+ import { packageHeader } from "./data-package.js";
20
+ import { DatabaseSync } from "node:sqlite";
21
+
22
+ const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
23
+ const TTL = 3600000;
24
+ export const IMPORT_FAILURE =
25
+ "导入未完成,原数据已保留。请重新上传或查看运行日志。";
26
+ export const IMPORT_START_FAILURE =
27
+ "导入启动未完成,未替换运行数据。请重新启动实例。";
28
+ export const IMPORT_RESTART_FAILURE =
29
+ "数据已导入,但自动重启未完成。请手动重新启动实例。";
30
+ export const transferRoot = () => join(runtimePaths().data, "transfers");
31
+ export function readTransferJob(root, id) {
32
+ if (!ID.test(String(id))) throw Error("数据任务不存在");
33
+ const job = JSON.parse(readFileSync(join(root, id, "job.json"), "utf8"));
34
+ if (job.id !== id) throw Error("数据任务不存在");
35
+ return job;
36
+ }
37
+ export function writeTransferJob(root, job) {
38
+ if (!ID.test(job.id)) throw Error("数据任务不存在");
39
+ const temporary = join(root, job.id, `.job-${randomUUID()}.partial`);
40
+ try {
41
+ writeFileSync(temporary, JSON.stringify(job), {
42
+ mode: 0o600,
43
+ flush: true,
44
+ flag: "wx",
45
+ });
46
+ renameSync(temporary, join(root, job.id, "job.json"));
47
+ } finally {
48
+ rmSync(temporary, { force: true });
49
+ }
50
+ }
51
+ function work(payload) {
52
+ return new Promise((resolve, reject) => {
53
+ const worker = new Worker(
54
+ new URL("./data-transfer-worker.js", import.meta.url),
55
+ { workerData: payload, stdout: true, stderr: true },
56
+ );
57
+ // Staging migrations must not interleave their console output with the
58
+ // service or a test runner's protocol. Results travel through parentPort.
59
+ worker.stdout.resume();
60
+ worker.stderr.resume();
61
+ worker.once("message", ({ result, error }) =>
62
+ error ? reject(Error(error)) : resolve(result),
63
+ );
64
+ worker.once("error", reject);
65
+ worker.once("exit", (code) => {
66
+ if (code) reject(Error("数据处理失败"));
67
+ });
68
+ });
69
+ }
70
+ export function createDataTransfer({
71
+ source = runtimePaths().database,
72
+ backups = runtimePaths().backups,
73
+ directory = transferRoot(),
74
+ backup,
75
+ cleanup,
76
+ onApply,
77
+ run = work,
78
+ now = Date.now,
79
+ maxUploadBytes,
80
+ occupied = () => false,
81
+ } = {}) {
82
+ const root = resolve(directory);
83
+ mkdirSync(root, { recursive: true, mode: 0o700 });
84
+ let active = false;
85
+ const maxBytes =
86
+ maxUploadBytes ??
87
+ Math.floor(
88
+ Math.min(
89
+ 100,
90
+ Math.max(1, Number(process.env.DATA_TRANSFER_MAX_GB) || 10),
91
+ ) * 1073741824,
92
+ );
93
+ const get = (id) => {
94
+ try {
95
+ return readTransferJob(root, id);
96
+ } catch {
97
+ throw Error("数据任务不存在");
98
+ }
99
+ };
100
+ const save = (job) => {
101
+ writeTransferJob(root, job);
102
+ return job;
103
+ };
104
+ const publicJob = (job) => {
105
+ const { manifest, ownerPid, ...rest } = job;
106
+ return rest;
107
+ };
108
+ const check = () => {
109
+ if (active || backup?.running || cleanup?.running || occupied())
110
+ throw Error("数据处理或备份正在进行,请稍后再试");
111
+ if (source === ":memory:") throw Error("当前实例不支持文件导入导出");
112
+ };
113
+ const prune = () => {
114
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
115
+ if (!entry.isDirectory() || !ID.test(entry.name)) continue;
116
+ const path = join(root, entry.name);
117
+ const part = relative(root, resolve(path));
118
+ if (
119
+ !part ||
120
+ part.startsWith("..") ||
121
+ isAbsolute(part) ||
122
+ lstatSync(path).isSymbolicLink()
123
+ )
124
+ continue;
125
+ try {
126
+ const job = get(entry.name);
127
+ if (
128
+ now() - (job.applied || job.readyAt || job.created) > TTL &&
129
+ !["processing", "uploading", "queued"].includes(job.status)
130
+ )
131
+ rmSync(path, { recursive: true, force: true });
132
+ } catch {
133
+ /* Ignore incomplete metadata while a job is publishing it. */
134
+ }
135
+ }
136
+ };
137
+ const clearMigrationCopies = (job) => {
138
+ const dir = join(root, job.id, "backups");
139
+ const part = relative(root, resolve(dir));
140
+ if (
141
+ part &&
142
+ !part.startsWith("..") &&
143
+ !isAbsolute(part) &&
144
+ existsSync(dir) &&
145
+ lstatSync(dir).isDirectory() &&
146
+ !lstatSync(dir).isSymbolicLink()
147
+ )
148
+ try {
149
+ rmSync(dir, { recursive: true, force: true });
150
+ } catch {
151
+ return false;
152
+ }
153
+ return true;
154
+ };
155
+ const clearFiles = (job) => {
156
+ let cleared = true;
157
+ for (const file of [
158
+ "upload",
159
+ "data.db",
160
+ "data.db.json",
161
+ "data.db-wal",
162
+ "data.db-shm",
163
+ ])
164
+ try {
165
+ rmSync(join(root, job.id, file), { force: true });
166
+ } catch {
167
+ cleared = false;
168
+ }
169
+ return clearMigrationCopies(job) && cleared;
170
+ };
171
+ const create = (kind, name = "") => {
172
+ prune();
173
+ // Keep at most one unused file of each kind, rather than accumulating
174
+ // multi-GiB previews every time the user picks another file.
175
+ for (const id of readdirSync(root).filter((id) => ID.test(id))) {
176
+ const previous = get(id);
177
+ if (previous.kind === kind && previous.status === "ready") {
178
+ if (!clearFiles(previous))
179
+ throw Error("临时数据文件正在使用,请稍后再试");
180
+ save({ ...previous, status: "expired" });
181
+ }
182
+ }
183
+ const id = randomUUID();
184
+ mkdirSync(join(root, id), { mode: 0o700 });
185
+ return save({
186
+ id,
187
+ kind,
188
+ name,
189
+ created: now(),
190
+ ownerPid: process.pid,
191
+ status: kind === "export" ? "processing" : "uploading",
192
+ });
193
+ };
194
+ const processJob = async (job) => {
195
+ try {
196
+ const result = await run({
197
+ kind: job.kind,
198
+ source,
199
+ backups,
200
+ target: join(root, job.id, "data.db"),
201
+ upload: join(root, job.id, "upload"),
202
+ });
203
+ rmSync(join(root, job.id, "upload"), { force: true });
204
+ clearMigrationCopies(job);
205
+ save({ ...job, status: "ready", readyAt: now(), ...result });
206
+ } catch (error) {
207
+ clearFiles(job);
208
+ try {
209
+ save({
210
+ ...job,
211
+ status: "error",
212
+ error: error.message || "数据处理失败",
213
+ });
214
+ } catch {
215
+ console.error("数据任务状态保存失败");
216
+ }
217
+ } finally {
218
+ active = false;
219
+ }
220
+ };
221
+ const alive = (pid) => {
222
+ if (!Number.isInteger(pid) || pid <= 0) return false;
223
+ try {
224
+ process.kill(pid, 0);
225
+ return true;
226
+ } catch (error) {
227
+ return error.code !== "ESRCH";
228
+ }
229
+ };
230
+ for (const id of readdirSync(root).filter((id) => ID.test(id))) {
231
+ try {
232
+ const job = get(id);
233
+ if (
234
+ job.kind === "import" &&
235
+ job.status === "queued" &&
236
+ source !== ":memory:" &&
237
+ existsSync(source)
238
+ ) {
239
+ const db = new DatabaseSync(source, { readOnly: true });
240
+ let marker;
241
+ try {
242
+ marker = JSON.parse(
243
+ db.prepare("SELECT value FROM settings WHERE id=1").get().value,
244
+ ).dataImportId;
245
+ } finally {
246
+ db.close();
247
+ }
248
+ if (marker === id) {
249
+ save({ ...job, status: "done", applied: now() });
250
+ continue;
251
+ }
252
+ }
253
+ if (
254
+ ["processing", "uploading", "queued"].includes(job.status) &&
255
+ !alive(job.ownerPid)
256
+ ) {
257
+ clearFiles(job);
258
+ save({
259
+ ...job,
260
+ status: "error",
261
+ readyAt: now(),
262
+ error: "数据处理被中断,请重新操作",
263
+ });
264
+ }
265
+ } catch {}
266
+ }
267
+ return {
268
+ get busy() {
269
+ return active;
270
+ },
271
+ info() {
272
+ prune();
273
+ const imports = readdirSync(root)
274
+ .filter((id) => ID.test(id))
275
+ .map((id) => {
276
+ try {
277
+ return get(id);
278
+ } catch {
279
+ return null;
280
+ }
281
+ })
282
+ .filter(
283
+ (job) =>
284
+ job?.kind === "import" && ["done", "error"].includes(job.status),
285
+ );
286
+ imports.sort((a, b) => b.created - a.created);
287
+ return {
288
+ available: source !== ":memory:",
289
+ maxBytes,
290
+ busy: active,
291
+ lastImport: imports[0] ? publicJob(imports[0]) : null,
292
+ };
293
+ },
294
+ job(id) {
295
+ return publicJob(get(id));
296
+ },
297
+ export() {
298
+ check();
299
+ active = true;
300
+ let job;
301
+ try {
302
+ job = create("export");
303
+ } catch (error) {
304
+ active = false;
305
+ throw error;
306
+ }
307
+ void processJob(job);
308
+ return publicJob(job);
309
+ },
310
+ async upload(stream, { bytes = 0, name = "" } = {}) {
311
+ check();
312
+ if (bytes > maxBytes) throw Error("数据文件超过导入大小限制");
313
+ active = true;
314
+ let job;
315
+ try {
316
+ job = create(
317
+ "import",
318
+ String(name).replace(/[\\/]/g, "_").slice(0, 180),
319
+ );
320
+ } catch (error) {
321
+ active = false;
322
+ throw error;
323
+ }
324
+ let total = 0;
325
+ const limit = new Transform({
326
+ transform(chunk, _encoding, done) {
327
+ total += chunk.length;
328
+ done(
329
+ total > maxBytes ? Error("数据文件超过导入大小限制") : null,
330
+ chunk,
331
+ );
332
+ },
333
+ });
334
+ try {
335
+ await pipeline(
336
+ stream,
337
+ limit,
338
+ createWriteStream(join(root, job.id, "upload"), {
339
+ flags: "wx",
340
+ mode: 0o600,
341
+ }),
342
+ );
343
+ if (!total) throw Error("数据文件为空");
344
+ save({ ...job, status: "processing" });
345
+ void processJob(job);
346
+ return publicJob({ ...job, status: "processing" });
347
+ } catch (error) {
348
+ active = false;
349
+ rmSync(join(root, job.id, "upload"), { force: true });
350
+ save({
351
+ ...job,
352
+ status: "error",
353
+ error: error.code === "ENOSPC" ? "磁盘空间不足" : "数据上传失败",
354
+ });
355
+ throw Error("数据上传失败");
356
+ }
357
+ },
358
+ async download(id, res) {
359
+ if (active) throw Error("数据处理或备份正在进行,请稍后再试");
360
+ const job = get(id);
361
+ if (
362
+ job.kind !== "export" ||
363
+ job.status !== "ready" ||
364
+ now() - (job.readyAt || job.created) > TTL
365
+ )
366
+ throw Error("导出文件尚未就绪或已过期");
367
+ const header = packageHeader(job.manifest);
368
+ res.setHeader("Content-Type", "application/octet-stream");
369
+ res.setHeader(
370
+ "Content-Disposition",
371
+ `attachment; filename="luckytri-data-${new Date(job.created).toISOString().slice(0, 10)}.luckytri"`,
372
+ );
373
+ res.setHeader("Content-Length", header.length + job.bytes);
374
+ active = true;
375
+ try {
376
+ res.write(header);
377
+ await pipeline(createReadStream(join(root, id, "data.db")), res);
378
+ clearFiles(job);
379
+ save({ ...job, status: "done" });
380
+ } finally {
381
+ active = false;
382
+ }
383
+ },
384
+ async apply(id) {
385
+ check();
386
+ const job = get(id);
387
+ if (!onApply) throw Error("当前实例不支持自动导入,请使用恢复命令");
388
+ if (
389
+ job.kind !== "import" ||
390
+ job.status !== "ready" ||
391
+ now() - (job.readyAt || job.created) > TTL
392
+ )
393
+ throw Error("导入数据尚未就绪或已过期");
394
+ active = true;
395
+ save({ ...job, status: "queued" });
396
+ try {
397
+ const owner = await onApply(id);
398
+ if (Number.isInteger(owner) && owner > 0)
399
+ save({ ...job, status: "queued", ownerPid: owner });
400
+ } catch (error) {
401
+ active = false;
402
+ save(job);
403
+ throw error;
404
+ }
405
+ return { queued: true, id };
406
+ },
407
+ discard(id) {
408
+ const job = get(id);
409
+ if (["processing", "uploading", "queued"].includes(job.status))
410
+ throw Error("数据处理正在进行,请稍后再试");
411
+ if (!clearFiles(job)) throw Error("临时数据文件正在使用,请稍后再试");
412
+ save({ ...job, status: "expired" });
413
+ return { ok: true };
414
+ },
415
+ };
416
+ }