luckytri 1.0.6 → 1.0.7

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 (155) hide show
  1. package/LICENSE +21 -21
  2. package/docs/en/CHANGELOG.md +25 -16
  3. package/docs/en/chat-style.md +28 -9
  4. package/docs/en/guide.md +5 -5
  5. package/docs/en/her-life.md +2 -2
  6. package/docs/en/webui.md +1 -1
  7. package/docs/zh/CHANGELOG.md +25 -16
  8. package/docs/zh/chat-style.md +30 -9
  9. package/docs/zh/guide.md +5 -5
  10. package/docs/zh/her-life.md +2 -2
  11. package/docs/zh/webui.md +1 -1
  12. package/package.json +1 -1
  13. package/public/app/assets/{ChatsPage-DCYMkJ91.js → ChatsPage-CshdtTzV.js} +2 -2
  14. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  15. package/public/app/assets/{Empty-CLyZkxR5.js → Empty-C7yzOBzy.js} +1 -1
  16. package/public/app/assets/{Empty-DIobYG3x.css → Empty-DmUB2-Bk.css} +1 -1
  17. package/public/app/assets/{Field-BpaRwLI_.js → Field-BjFv4Rfn.js} +1 -1
  18. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  19. package/public/app/assets/{HeartPage-BIOMTiwr.js → HeartPage-eqo6ru16.js} +1 -1
  20. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  21. package/public/app/assets/{LifePage-BCgM4JkR.js → LifePage-CN8GE5NK.js} +1 -1
  22. package/public/app/assets/{MemoryPage-vShvRqMt.js → MemoryPage-CCpMN4Wg.js} +1 -1
  23. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  24. package/public/app/assets/{NaturePage-Cy9ptr4G.js → NaturePage-C0JqdBTR.js} +2 -2
  25. package/public/app/assets/NaturePage-CHkpb5NK.css +1 -0
  26. package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
  27. package/public/app/assets/{PeoplePage-CUZekB2E.js → PeoplePage-BXGBbO4u.js} +1 -1
  28. package/public/app/assets/{PluginsPage-1RM4rmJP.js → PluginsPage-Dksp5AV5.js} +1 -1
  29. package/public/app/assets/{SystemPage-BI2CB5Ig.css → SystemPage-BenIyTvk.css} +1 -1
  30. package/public/app/assets/{SystemPage-BeT5TmIV.js → SystemPage-Cd7aa8eE.js} +1 -1
  31. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  32. package/public/app/assets/{TimePage-taTmPMM8.js → TimePage-Bs_zImb4.js} +2 -2
  33. package/public/app/assets/{index-DY5AM0Hz.css → index-BHZcHUMh.css} +1 -1
  34. package/public/app/assets/{index-DdYwC5ZU.js → index-CyRtYxP3.js} +3 -3
  35. package/public/app/assets/{knowledge-DJmfu2KN.js → knowledge-BJOIyuJo.js} +1 -1
  36. package/public/app/assets/{media-BEtnE0Gv.js → media-Bwtx8blz.js} +1 -1
  37. package/public/app/assets/{mind-Dtiw0-QK.js → mind-DtTVrW6Z.js} +1 -1
  38. package/public/app/index.html +5 -6
  39. package/public/guide.en.html +3 -3
  40. package/public/guide.html +3 -3
  41. package/scripts/import-data.js +128 -128
  42. package/scripts/proxy-config.js +167 -167
  43. package/scripts/update-instance.js +201 -201
  44. package/server/channels/adapters.js +27 -27
  45. package/server/channels/onebot/adapter.js +288 -288
  46. package/server/channels/qqbot/adapter.js +253 -253
  47. package/server/channels/qqbot/api.js +199 -199
  48. package/server/channels/qqbot/credentials.js +11 -11
  49. package/server/channels/qqbot/gateway.js +274 -274
  50. package/server/channels/qqbot/index.js +389 -389
  51. package/server/channels/segments.js +40 -40
  52. package/server/channels/session-key.js +153 -153
  53. package/server/channels/state.js +42 -42
  54. package/server/core/api.js +564 -564
  55. package/server/core/context-builder.js +288 -288
  56. package/server/core/context-compactor.js +454 -454
  57. package/server/core/conversation-cues.js +365 -325
  58. package/server/core/conversation-grounding.js +178 -0
  59. package/server/core/conversation-manager.js +87 -87
  60. package/server/core/dialogue-context.js +52 -0
  61. package/server/core/feedback.js +8 -8
  62. package/server/core/group-style.js +72 -72
  63. package/server/core/initiative-context.js +66 -66
  64. package/server/core/input-budget.js +32 -32
  65. package/server/core/local-demo.js +98 -98
  66. package/server/core/message-manager.js +118 -118
  67. package/server/core/message-scheduler.js +78 -78
  68. package/server/core/model-json.js +190 -190
  69. package/server/core/model-presets.js +900 -900
  70. package/server/core/name-call.js +29 -29
  71. package/server/core/network.js +157 -157
  72. package/server/core/person-name.js +22 -22
  73. package/server/core/persona-manager.js +324 -269
  74. package/server/core/reply-delivery.js +472 -404
  75. package/server/core/reply-target-resolver.js +50 -50
  76. package/server/core/response-generator.js +58 -50
  77. package/server/core/response-validator.js +30 -27
  78. package/server/core/retired-prompts.js +51 -51
  79. package/server/core/sessions.js +109 -109
  80. package/server/core/speaker-names.js +92 -92
  81. package/server/core/topic-tracker.js +65 -65
  82. package/server/core/turn.js +42 -8
  83. package/server/http.js +18 -18
  84. package/server/i18n/decisions.js +209 -209
  85. package/server/i18n/index.js +144 -144
  86. package/server/i18n/labels.js +0 -5
  87. package/server/i18n/models.js +111 -111
  88. package/server/knowledge/api.js +342 -342
  89. package/server/knowledge/embedding-profile.js +114 -114
  90. package/server/knowledge/index.js +13 -13
  91. package/server/knowledge/retrieval.js +93 -93
  92. package/server/knowledge/schema.js +158 -158
  93. package/server/knowledge/vector-index.js +90 -90
  94. package/server/mind/affect.js +265 -265
  95. package/server/mind/attention.js +200 -200
  96. package/server/mind/bonds.js +565 -565
  97. package/server/mind/clock.js +83 -83
  98. package/server/mind/context-selection.js +16 -4
  99. package/server/mind/continuity.js +562 -562
  100. package/server/mind/conversation-origin.js +175 -175
  101. package/server/mind/days.js +210 -210
  102. package/server/mind/evidence-read.js +45 -45
  103. package/server/mind/faces.js +373 -373
  104. package/server/mind/guard.js +68 -60
  105. package/server/mind/initiative.js +338 -338
  106. package/server/mind/life/activities.js +21 -21
  107. package/server/mind/life/context.js +330 -330
  108. package/server/mind/life/growth.js +261 -261
  109. package/server/mind/life/presence.js +305 -305
  110. package/server/mind/meetings.js +1069 -1069
  111. package/server/mind/memory-policy.js +48 -48
  112. package/server/mind/memory.js +888 -888
  113. package/server/mind/nature.js +358 -358
  114. package/server/mind/novelty.js +19 -19
  115. package/server/mind/periods.js +118 -118
  116. package/server/mind/reading.js +120 -120
  117. package/server/mind/relationship-context.js +71 -71
  118. package/server/mind/relationships.js +390 -390
  119. package/server/mind/salience.js +201 -201
  120. package/server/mind/schema.js +567 -567
  121. package/server/mind/self.js +496 -484
  122. package/server/mind/thoughts.js +274 -274
  123. package/server/mind/time/agenda.js +242 -242
  124. package/server/mind/time/api.js +200 -200
  125. package/server/mind/time/attention.js +34 -34
  126. package/server/mind/time/delivery-links.js +130 -130
  127. package/server/mind/time/games.js +7 -3
  128. package/server/mind/time/index.js +3 -0
  129. package/server/mind/time/model-material.js +77 -77
  130. package/server/mind/time/presentation.js +35 -51
  131. package/server/mind/time/schedule.js +83 -83
  132. package/server/mind/time/schema.js +84 -84
  133. package/server/mind/time/search.js +383 -383
  134. package/server/mind/time/sharing.js +472 -472
  135. package/server/mind/time/task-links.js +311 -311
  136. package/server/mind/time/works.js +438 -432
  137. package/server/mind/traits.js +191 -191
  138. package/server/mind/view.js +43 -6
  139. package/server/runtime/installation.js +116 -116
  140. package/server/runtime/update-manager.js +140 -140
  141. package/server/storage/data-package.js +298 -298
  142. package/server/storage/data-transfer-api.js +50 -50
  143. package/server/storage/data-transfer-worker.js +18 -18
  144. package/server/storage/data-transfer.js +416 -416
  145. package/server/studio/display-names.js +139 -139
  146. package/server/studio/management.js +102 -102
  147. package/server/studio/updates.js +144 -144
  148. package/server/version.js +10 -10
  149. package/public/app/assets/ChatsPage-CFMVozoG.css +0 -1
  150. package/public/app/assets/HeartPage-C-2jvgzF.css +0 -1
  151. package/public/app/assets/LifePage-CG1OHbRK.css +0 -1
  152. package/public/app/assets/MemoryPage-C_Bu4rfb.css +0 -1
  153. package/public/app/assets/NaturePage-CPZlImT0.css +0 -1
  154. package/public/app/assets/PeoplePage-3RRUITDz.css +0 -1
  155. 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
+ }