luckytri 1.0.2 → 1.0.4
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/bin/luckytri.js +5 -1
- package/docs/en/CHANGELOG.md +20 -0
- package/docs/en/guide.md +8 -0
- package/docs/en/her-life.md +2 -0
- package/docs/en/webui.md +4 -0
- package/docs/zh/CHANGELOG.md +20 -0
- package/docs/zh/guide.md +9 -1
- package/docs/zh/her-life.md +4 -2
- package/docs/zh/webui.md +4 -0
- package/package.json +111 -110
- package/public/app/assets/ChatsPage-CshdtTzV.js +3 -0
- package/public/app/assets/{Empty-DP6WMGHi.js → Empty-C7yzOBzy.js} +1 -1
- package/public/app/assets/{Field-CtbdqaiB.js → Field-BjFv4Rfn.js} +1 -1
- package/public/app/assets/HeartPage-eqo6ru16.js +1 -0
- package/public/app/assets/LifePage-CN8GE5NK.js +1 -0
- package/public/app/assets/MemoryPage-CCpMN4Wg.js +1 -0
- package/public/app/assets/NaturePage-C0JqdBTR.js +3 -0
- package/public/app/assets/{NaturePage-CTXHCE2D.css → NaturePage-CHkpb5NK.css} +1 -1
- package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
- package/public/app/assets/PeoplePage-BXGBbO4u.js +1 -0
- package/public/app/assets/{PluginsPage-DksEZbUU.js → PluginsPage-Dksp5AV5.js} +1 -1
- package/public/app/assets/{SystemPage-BkRu0Rvx.css → SystemPage-BenIyTvk.css} +1 -1
- package/public/app/assets/SystemPage-Cd7aa8eE.js +1 -0
- package/public/app/assets/TimePage-Bs_zImb4.js +3 -0
- package/public/app/assets/{index-DY4e15Kh.css → index-BHZcHUMh.css} +1 -1
- package/public/app/assets/index-CyRtYxP3.js +4 -0
- package/public/app/assets/{knowledge-C6_HvoyB.js → knowledge-BJOIyuJo.js} +1 -1
- package/public/app/assets/{media-SMfTT-yc.js → media-Bwtx8blz.js} +1 -1
- package/public/app/assets/mind-DtTVrW6Z.js +1 -0
- package/public/app/index.html +4 -4
- package/public/guide.en.html +32 -3
- package/public/guide.html +19 -4
- package/public/tutorial.en.md +8 -0
- package/public/tutorial.md +9 -1
- package/scripts/launch.js +11 -4
- package/scripts/service.js +57 -7
- package/scripts/start-server.js +4 -1
- package/scripts/update-instance.js +201 -0
- package/server/app.js +3 -0
- package/server/core/context-builder.js +2 -2
- package/server/core/context-compactor.js +2 -2
- package/server/core/orchestrator.js +1 -0
- package/server/core/persona-manager.js +30 -5
- package/server/core/reply-delivery.js +10 -0
- package/server/core/response-validator.js +20 -0
- package/server/i18n/errors.js +38 -2
- package/server/index.js +45 -2
- package/server/mind/api.js +26 -0
- package/server/mind/bonds.js +9 -2
- package/server/mind/faces.js +257 -22
- package/server/mind/index.js +2 -0
- package/server/mind/initiative.js +6 -1
- package/server/mind/life/context.js +23 -9
- package/server/mind/life/growth.js +5 -3
- package/server/mind/nature.js +100 -4
- package/server/mind/relationships.js +279 -0
- package/server/mind/schema.js +5 -0
- package/server/mind/time/tasks.js +2 -1
- package/server/mind/view.js +5 -0
- package/server/runtime/installation.js +116 -0
- package/server/runtime/update-execution.js +127 -0
- package/server/runtime/update-manager.js +140 -0
- package/server/startup-lock.js +12 -12
- package/server/storage/archive.js +10 -2
- package/server/storage/backup-lock.js +23 -5
- package/server/storage/data-transfer.js +2 -1
- package/server/studio/display-names.js +1 -0
- package/server/studio/settings.js +1 -0
- package/server/studio/updates.js +144 -0
- package/public/app/assets/ChatsPage-BhlJHseG.js +0 -3
- package/public/app/assets/HeartPage-32BeTPn2.js +0 -1
- package/public/app/assets/LifePage-CxSm0XKI.js +0 -1
- package/public/app/assets/MemoryPage-BeKBcPCY.js +0 -1
- package/public/app/assets/NaturePage-NEPYl2HS.js +0 -3
- package/public/app/assets/PeoplePage-BmUxkrOU.css +0 -1
- package/public/app/assets/PeoplePage-DxtpekwM.js +0 -1
- package/public/app/assets/SystemPage-BvzJlHuY.js +0 -1
- package/public/app/assets/TimePage-7-awH5XL.js +0 -3
- package/public/app/assets/formatters-ZmW_xFHw.js +0 -1
- package/public/app/assets/index-4rItIkD-.js +0 -4
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseSessionKey } from "../channels/session-key.js";
|
|
3
|
+
import { hasCredential, text } from "./util.js";
|
|
4
|
+
import { normalized } from "./util.js";
|
|
5
|
+
import { leaks } from "./guard.js";
|
|
6
|
+
|
|
7
|
+
export const RELATIONSHIP_ROLES = [
|
|
8
|
+
"妹妹",
|
|
9
|
+
"姐姐",
|
|
10
|
+
"弟弟",
|
|
11
|
+
"哥哥",
|
|
12
|
+
"朋友",
|
|
13
|
+
"伙伴",
|
|
14
|
+
];
|
|
15
|
+
const FAMILY = new Set(RELATIONSHIP_ROLES.slice(0, 4));
|
|
16
|
+
const scope = (room) => {
|
|
17
|
+
try {
|
|
18
|
+
return room ? parseSessionKey(room) : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Account identity and an explicit arrangement, never invented encounters or
|
|
25
|
+
// earned trust. Changes append history; nickname equality does not bind peers.
|
|
26
|
+
export class Relationships {
|
|
27
|
+
constructor(mind) {
|
|
28
|
+
this.mind = mind;
|
|
29
|
+
this.db = mind.db;
|
|
30
|
+
}
|
|
31
|
+
version() {
|
|
32
|
+
return this.db
|
|
33
|
+
.prepare("SELECT COALESCE(MAX(rowid),0) n FROM mind_relationships")
|
|
34
|
+
.get().n;
|
|
35
|
+
}
|
|
36
|
+
current(subject, { now = Date.now(), room = "" } = {}) {
|
|
37
|
+
const place = scope(room);
|
|
38
|
+
const row = this.db
|
|
39
|
+
.prepare(
|
|
40
|
+
"SELECT * FROM mind_relationships WHERE subject_id=? AND created<=? ORDER BY created DESC,rowid DESC",
|
|
41
|
+
)
|
|
42
|
+
.all(String(subject), now)
|
|
43
|
+
.find(
|
|
44
|
+
(r) =>
|
|
45
|
+
!place ||
|
|
46
|
+
(r.channel === place.channel &&
|
|
47
|
+
(r.account_id === "_" || r.account_id === place.accountId)),
|
|
48
|
+
);
|
|
49
|
+
return row?.status === "active" ? row : null;
|
|
50
|
+
}
|
|
51
|
+
visible(row, room) {
|
|
52
|
+
if (!row || !room || row.discretion === "open") return !!row;
|
|
53
|
+
const place = scope(room);
|
|
54
|
+
return (
|
|
55
|
+
place?.kind === "private" &&
|
|
56
|
+
place.nativeId === row.subject_id &&
|
|
57
|
+
place.channel === row.channel &&
|
|
58
|
+
(row.account_id === "_" || place.accountId === row.account_id)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
discloses(bubbles, room, now = Date.now()) {
|
|
62
|
+
const hidden = this.list(now).filter((row) => !this.visible(row, room));
|
|
63
|
+
if (
|
|
64
|
+
leaks(
|
|
65
|
+
bubbles,
|
|
66
|
+
hidden.filter((r) => r.note).map((r) => ({ content: r.note })),
|
|
67
|
+
).length
|
|
68
|
+
)
|
|
69
|
+
return true;
|
|
70
|
+
const said = normalized(bubbles.join(" "));
|
|
71
|
+
const english = {
|
|
72
|
+
妹妹: "youngersister",
|
|
73
|
+
姐姐: "oldersister",
|
|
74
|
+
弟弟: "youngerbrother",
|
|
75
|
+
哥哥: "olderbrother",
|
|
76
|
+
朋友: "friend",
|
|
77
|
+
伙伴: "companion",
|
|
78
|
+
};
|
|
79
|
+
return hidden.some((row) => {
|
|
80
|
+
const names = [
|
|
81
|
+
row.name,
|
|
82
|
+
this.mind.bonds.name(row.subject_id),
|
|
83
|
+
row.subject_id,
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.map(normalized);
|
|
87
|
+
const role = normalized(row.peer_role),
|
|
88
|
+
en = english[row.peer_role] || role;
|
|
89
|
+
return names.some((name) =>
|
|
90
|
+
[
|
|
91
|
+
"我的" + role + name,
|
|
92
|
+
"我" + role + name,
|
|
93
|
+
name + "是我的" + role,
|
|
94
|
+
name + "是我" + role,
|
|
95
|
+
name + role,
|
|
96
|
+
"my" + en + name,
|
|
97
|
+
name + "ismy" + en,
|
|
98
|
+
].some((line) => said.includes(line)),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
weight(row) {
|
|
103
|
+
return row ? (FAMILY.has(row.peer_role) ? 0.15 : 0.05) : 0;
|
|
104
|
+
}
|
|
105
|
+
view(row, room = "") {
|
|
106
|
+
if (!this.visible(row, room)) return null;
|
|
107
|
+
return {
|
|
108
|
+
id: row.id,
|
|
109
|
+
subjectId: row.subject_id,
|
|
110
|
+
channel: row.channel,
|
|
111
|
+
accountId: row.account_id,
|
|
112
|
+
kind: row.kind,
|
|
113
|
+
name: row.name,
|
|
114
|
+
peerRole: row.peer_role,
|
|
115
|
+
selfRole: row.self_role,
|
|
116
|
+
discretion: row.discretion,
|
|
117
|
+
note: row.note,
|
|
118
|
+
created: row.created,
|
|
119
|
+
origin: "owner",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
list(now = Date.now()) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
return this.db
|
|
125
|
+
.prepare(
|
|
126
|
+
"SELECT * FROM mind_relationships WHERE created<=? ORDER BY created DESC,rowid DESC",
|
|
127
|
+
)
|
|
128
|
+
.all(now)
|
|
129
|
+
.filter((r) => {
|
|
130
|
+
const key = JSON.stringify([r.subject_id, r.channel, r.account_id]);
|
|
131
|
+
if (seen.has(key)) return false;
|
|
132
|
+
seen.add(key);
|
|
133
|
+
return r.status === "active";
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
history(subject, limit = 30) {
|
|
137
|
+
return this.db
|
|
138
|
+
.prepare(
|
|
139
|
+
"SELECT * FROM mind_relationships WHERE subject_id=? ORDER BY created DESC,rowid DESC LIMIT ?",
|
|
140
|
+
)
|
|
141
|
+
.all(String(subject), limit);
|
|
142
|
+
}
|
|
143
|
+
save(input, now = Date.now()) {
|
|
144
|
+
const subject = text(input?.subjectId, 128),
|
|
145
|
+
channel = String(input?.channel || "onebot"),
|
|
146
|
+
account = text(input?.accountId || "_", 128);
|
|
147
|
+
if (
|
|
148
|
+
String(input?.subjectId || "").length > 128 ||
|
|
149
|
+
String(input?.accountId || "").length > 128 ||
|
|
150
|
+
!/^[\w.-]{1,128}$/.test(subject) ||
|
|
151
|
+
["bot", "self", "_"].includes(subject) ||
|
|
152
|
+
!["onebot", "qqbot"].includes(channel) ||
|
|
153
|
+
!/^[\w.-]{1,128}$/.test(account) ||
|
|
154
|
+
(channel === "onebot" && !/^\d+$/.test(subject))
|
|
155
|
+
)
|
|
156
|
+
throw Error("关系对象账号无效");
|
|
157
|
+
if (channel === "qqbot" && account === "_")
|
|
158
|
+
throw Error("官方机器人关系需要指定应用账号");
|
|
159
|
+
if (
|
|
160
|
+
this.db
|
|
161
|
+
.prepare("SELECT 1 FROM core_events WHERE account_id=? LIMIT 1")
|
|
162
|
+
.get(subject)
|
|
163
|
+
)
|
|
164
|
+
throw Error("不能把当前实例绑定为自己的关系对象");
|
|
165
|
+
const kind = String(input.kind || "human"),
|
|
166
|
+
role = text(input.peerRole, 24),
|
|
167
|
+
name = text(input.name, 40),
|
|
168
|
+
note = text(input.note, 300),
|
|
169
|
+
discretion = input.discretion || "open";
|
|
170
|
+
if (
|
|
171
|
+
!["human", "bot"].includes(kind) ||
|
|
172
|
+
!role ||
|
|
173
|
+
String(input.peerRole).length > 24 ||
|
|
174
|
+
String(input.name || "").length > 40 ||
|
|
175
|
+
String(input.note || "").length > 300 ||
|
|
176
|
+
!["open", "private", "secret"].includes(discretion)
|
|
177
|
+
)
|
|
178
|
+
throw Error("关系设置无效");
|
|
179
|
+
const gender = this.mind.nature.current(now).gender;
|
|
180
|
+
const older =
|
|
181
|
+
gender === "female" ? "姐姐" : gender === "male" ? "哥哥" : "年长手足";
|
|
182
|
+
const younger =
|
|
183
|
+
gender === "female" ? "妹妹" : gender === "male" ? "弟弟" : "年幼手足";
|
|
184
|
+
const opposite = {
|
|
185
|
+
妹妹: older,
|
|
186
|
+
弟弟: older,
|
|
187
|
+
姐姐: younger,
|
|
188
|
+
哥哥: younger,
|
|
189
|
+
朋友: "朋友",
|
|
190
|
+
伙伴: "伙伴",
|
|
191
|
+
};
|
|
192
|
+
const selfRole = text(input.selfRole || opposite[role] || "", 24);
|
|
193
|
+
if (
|
|
194
|
+
!selfRole ||
|
|
195
|
+
String(input.selfRole || "").length > 24 ||
|
|
196
|
+
hasCredential([role, selfRole, name, note].join(" "))
|
|
197
|
+
)
|
|
198
|
+
throw Error("关系称呼或备注无效");
|
|
199
|
+
const room = `${channel}:${account}:private:${subject}`,
|
|
200
|
+
previous = this.current(subject, { now, room });
|
|
201
|
+
if (
|
|
202
|
+
input.expectedId !== undefined &&
|
|
203
|
+
input.expectedId !== (previous?.id || null)
|
|
204
|
+
)
|
|
205
|
+
throw Error("关系已经变化,请刷新后再修改");
|
|
206
|
+
if (
|
|
207
|
+
previous &&
|
|
208
|
+
previous.kind === kind &&
|
|
209
|
+
previous.peer_role === role &&
|
|
210
|
+
previous.self_role === selfRole &&
|
|
211
|
+
previous.name === name &&
|
|
212
|
+
previous.note === note &&
|
|
213
|
+
previous.discretion === discretion
|
|
214
|
+
)
|
|
215
|
+
return this.view(previous);
|
|
216
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
217
|
+
try {
|
|
218
|
+
this.db
|
|
219
|
+
.prepare(
|
|
220
|
+
"INSERT INTO mind_relationships(id,created,subject_id,channel,account_id,kind,name,peer_role,self_role,discretion,note,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'active')",
|
|
221
|
+
)
|
|
222
|
+
.run(
|
|
223
|
+
randomUUID(),
|
|
224
|
+
now,
|
|
225
|
+
subject,
|
|
226
|
+
channel,
|
|
227
|
+
account,
|
|
228
|
+
kind,
|
|
229
|
+
name,
|
|
230
|
+
role,
|
|
231
|
+
selfRole,
|
|
232
|
+
discretion,
|
|
233
|
+
note,
|
|
234
|
+
);
|
|
235
|
+
this.db
|
|
236
|
+
.prepare(
|
|
237
|
+
"INSERT INTO mind_people(user_id,name,first_seen,last_seen,sessions) VALUES (?,NULL,NULL,NULL,'[]') ON CONFLICT(user_id) DO NOTHING",
|
|
238
|
+
)
|
|
239
|
+
.run(subject);
|
|
240
|
+
this.db.exec("COMMIT");
|
|
241
|
+
this.mind.repo.store.revision++;
|
|
242
|
+
return this.view(this.current(subject, { now, room }));
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this.db.exec("ROLLBACK");
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
end(subject, input = {}, now = Date.now()) {
|
|
249
|
+
if (
|
|
250
|
+
String(input.note || "").length > 300 ||
|
|
251
|
+
hasCredential(String(input.note || ""))
|
|
252
|
+
)
|
|
253
|
+
throw Error("关系称呼或备注无效");
|
|
254
|
+
const room = `${input.channel || "onebot"}:${input.accountId || "_"}:private:${subject}`,
|
|
255
|
+
row = this.current(subject, { now, room });
|
|
256
|
+
if (!row) throw Error("关系不存在或已解除");
|
|
257
|
+
if (input.expectedId !== undefined && input.expectedId !== row.id)
|
|
258
|
+
throw Error("关系已经变化,请刷新后再修改");
|
|
259
|
+
this.db
|
|
260
|
+
.prepare(
|
|
261
|
+
"INSERT INTO mind_relationships(id,created,subject_id,channel,account_id,kind,name,peer_role,self_role,discretion,note,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'ended')",
|
|
262
|
+
)
|
|
263
|
+
.run(
|
|
264
|
+
randomUUID(),
|
|
265
|
+
now,
|
|
266
|
+
row.subject_id,
|
|
267
|
+
row.channel,
|
|
268
|
+
row.account_id,
|
|
269
|
+
row.kind,
|
|
270
|
+
row.name,
|
|
271
|
+
row.peer_role,
|
|
272
|
+
row.self_role,
|
|
273
|
+
row.discretion,
|
|
274
|
+
text(input.note || "在管理台解除关系", 300),
|
|
275
|
+
);
|
|
276
|
+
this.mind.repo.store.revision++;
|
|
277
|
+
return { ended: true };
|
|
278
|
+
}
|
|
279
|
+
}
|
package/server/mind/schema.js
CHANGED
|
@@ -26,6 +26,8 @@ export function migrateMind(db, store) {
|
|
|
26
26
|
CREATE INDEX IF NOT EXISTS mind_self_thread ON mind_self(thread,created);
|
|
27
27
|
CREATE TABLE IF NOT EXISTS mind_faces (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, created INTEGER NOT NULL, role TEXT NOT NULL DEFAULT '', tone TEXT NOT NULL DEFAULT '', aspiration TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', sources TEXT NOT NULL DEFAULT '[]', origin TEXT NOT NULL);
|
|
28
28
|
CREATE INDEX IF NOT EXISTS mind_faces_session ON mind_faces(session_id,created);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS mind_relationships(id TEXT PRIMARY KEY,created INTEGER NOT NULL,subject_id TEXT NOT NULL,channel TEXT NOT NULL,account_id TEXT NOT NULL,kind TEXT NOT NULL,name TEXT NOT NULL DEFAULT '',peer_role TEXT NOT NULL,self_role TEXT NOT NULL,discretion TEXT NOT NULL DEFAULT 'open',note TEXT NOT NULL DEFAULT '',status TEXT NOT NULL);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS mind_relationships_subject ON mind_relationships(subject_id,channel,account_id,created);
|
|
29
31
|
CREATE TABLE IF NOT EXISTS mind_thoughts (id TEXT PRIMARY KEY, created INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, sessions TEXT NOT NULL DEFAULT '[]', sources TEXT NOT NULL DEFAULT '[]', parent_id TEXT, importance REAL NOT NULL DEFAULT 0.5, revisit_at INTEGER, status TEXT NOT NULL DEFAULT 'open', hidden INTEGER NOT NULL DEFAULT 0, outreach TEXT NOT NULL DEFAULT '', outreach_session TEXT, outreach_status TEXT NOT NULL DEFAULT 'none', run_id TEXT);
|
|
30
32
|
CREATE INDEX IF NOT EXISTS mind_thoughts_time ON mind_thoughts(created);
|
|
31
33
|
CREATE TABLE IF NOT EXISTS mind_diary (id TEXT PRIMARY KEY, day TEXT NOT NULL, created INTEGER NOT NULL, content TEXT NOT NULL, mood TEXT, compare TEXT, sources TEXT NOT NULL DEFAULT '[]', run_id TEXT);
|
|
@@ -65,6 +67,9 @@ export function migrateMind(db, store) {
|
|
|
65
67
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
66
68
|
};
|
|
67
69
|
addColumn("core_memories", "discretion", "TEXT NOT NULL DEFAULT 'open'");
|
|
70
|
+
// NULL distinguishes legacy faces from a deliberately emptied note set.
|
|
71
|
+
addColumn("mind_faces", "notes", "TEXT");
|
|
72
|
+
addColumn("mind_faces", "changes", "TEXT NOT NULL DEFAULT '[]'");
|
|
68
73
|
addColumn("core_memories", "superseded_by", "TEXT");
|
|
69
74
|
addColumn("mind_attention", "deferred", "INTEGER NOT NULL DEFAULT 0");
|
|
70
75
|
addColumn("mind_thoughts", "resolved_at", "INTEGER");
|
|
@@ -367,7 +367,8 @@ export class Tasks {
|
|
|
367
367
|
? 2
|
|
368
368
|
: task.priority) *
|
|
369
369
|
100 +
|
|
370
|
-
Math.min(8, Number(person?.closeness || 0) * 8)
|
|
370
|
+
Math.min(8, Number(person?.closeness || 0) * 8) +
|
|
371
|
+
Math.min(2, Number(person?.relationalWeight || 0) * 8)
|
|
371
372
|
);
|
|
372
373
|
}
|
|
373
374
|
arrange(
|
package/server/mind/view.js
CHANGED
|
@@ -136,6 +136,9 @@ export function innerView(
|
|
|
136
136
|
? {
|
|
137
137
|
here: describeFace({
|
|
138
138
|
...face,
|
|
139
|
+
notes: face.notes.filter((note) =>
|
|
140
|
+
spoken(`${note.content} ${note.why}`),
|
|
141
|
+
),
|
|
139
142
|
role: spoken(face.role),
|
|
140
143
|
tone: spoken(face.tone),
|
|
141
144
|
aspiration: spoken(face.aspiration),
|
|
@@ -176,6 +179,7 @@ export function innerView(
|
|
|
176
179
|
(!bond.interactions &&
|
|
177
180
|
!bond.impression &&
|
|
178
181
|
!bond.lastChange &&
|
|
182
|
+
!bond.relationship &&
|
|
179
183
|
bond.tension < 0.15)
|
|
180
184
|
)
|
|
181
185
|
continue;
|
|
@@ -183,6 +187,7 @@ export function innerView(
|
|
|
183
187
|
id,
|
|
184
188
|
name: bond.name,
|
|
185
189
|
feel: bond.feel,
|
|
190
|
+
...(bond.relationship ? { relationship: bond.relationship } : {}),
|
|
186
191
|
...(bond.impression && mind.meetings.sayable(bond.impression, session)
|
|
187
192
|
? { impression: bond.impression }
|
|
188
193
|
: {}),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { compareVersions } from "../studio/updates.js";
|
|
16
|
+
export const UPDATE_ID =
|
|
17
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
18
|
+
export const updateRoot = (home) => join(home, "runtime");
|
|
19
|
+
export function updatePath(home, id) {
|
|
20
|
+
if (!UPDATE_ID.test(String(id))) throw Error("更新任务无效");
|
|
21
|
+
const root = updateRoot(home),
|
|
22
|
+
path = join(root, "releases", id);
|
|
23
|
+
// Managed paths cannot be symlinked into an unrelated installation.
|
|
24
|
+
for (const p of [root, join(root, "releases"), path])
|
|
25
|
+
if (existsSync(p) && lstatSync(p).isSymbolicLink())
|
|
26
|
+
throw Error("更新目录无效");
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
export function readActive(home) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(
|
|
32
|
+
readFileSync(join(updateRoot(home), "active.json"), "utf8"),
|
|
33
|
+
);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function atomicJson(file, value) {
|
|
39
|
+
const tmp = `${file}.${randomUUID()}.partial`;
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(tmp, JSON.stringify(value), {
|
|
42
|
+
mode: 0o600,
|
|
43
|
+
flag: "wx",
|
|
44
|
+
flush: true,
|
|
45
|
+
});
|
|
46
|
+
renameSync(tmp, file);
|
|
47
|
+
} finally {
|
|
48
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function writeActive(home, value) {
|
|
52
|
+
const root = updateRoot(home);
|
|
53
|
+
if (value) updatePath(home, value.id);
|
|
54
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
55
|
+
if (value) atomicJson(join(root, "active.json"), value);
|
|
56
|
+
else if (existsSync(join(root, "active.json")))
|
|
57
|
+
unlinkSync(join(root, "active.json"));
|
|
58
|
+
}
|
|
59
|
+
export function installedRelease(home, id, version) {
|
|
60
|
+
const slot = updatePath(home, id),
|
|
61
|
+
root = join(slot, "node_modules", "luckytri");
|
|
62
|
+
const part = relative(realpathSync(slot), realpathSync(root));
|
|
63
|
+
if (part.startsWith("..") || isAbsolute(part)) throw Error("更新目录无效");
|
|
64
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
65
|
+
if (
|
|
66
|
+
pkg.name !== "luckytri" ||
|
|
67
|
+
pkg.version !== version ||
|
|
68
|
+
!existsSync(join(root, "server", "index.js")) ||
|
|
69
|
+
!existsSync(join(root, "public", "app", "index.html"))
|
|
70
|
+
)
|
|
71
|
+
throw Error("更新程序校验失败");
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
export function resolveInstallation(home, basePackage, baseVersion) {
|
|
75
|
+
const active = readActive(home);
|
|
76
|
+
if (active) {
|
|
77
|
+
try {
|
|
78
|
+
// An explicit newer/equal global npm or source install takes precedence.
|
|
79
|
+
if (compareVersions(active.version, baseVersion) > 0)
|
|
80
|
+
return {
|
|
81
|
+
package: installedRelease(home, active.id, active.version),
|
|
82
|
+
version: active.version,
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
/* Invalid receipts never route the launcher to arbitrary code. */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { package: basePackage, version: baseVersion };
|
|
89
|
+
}
|
|
90
|
+
export function pruneInstallations(home, keep) {
|
|
91
|
+
const root = join(updateRoot(home), "releases");
|
|
92
|
+
if (!existsSync(root)) return;
|
|
93
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
94
|
+
if (
|
|
95
|
+
!entry.isDirectory() ||
|
|
96
|
+
!UPDATE_ID.test(entry.name) ||
|
|
97
|
+
keep.includes(entry.name)
|
|
98
|
+
)
|
|
99
|
+
continue;
|
|
100
|
+
const path = updatePath(home, entry.name),
|
|
101
|
+
part = relative(realpathSync(root), realpathSync(path));
|
|
102
|
+
if (!part || part.startsWith("..") || isAbsolute(part))
|
|
103
|
+
throw Error("更新目录无效");
|
|
104
|
+
// File removal uses unlink, including Unicode/short Windows paths where
|
|
105
|
+
// recursive rm can return without actually removing a lock or directory.
|
|
106
|
+
const remove = (directory) => {
|
|
107
|
+
for (const child of readdirSync(directory, { withFileTypes: true })) {
|
|
108
|
+
const target = join(directory, child.name);
|
|
109
|
+
if (child.isDirectory() && !child.isSymbolicLink()) remove(target);
|
|
110
|
+
else unlinkSync(target);
|
|
111
|
+
}
|
|
112
|
+
rmdirSync(directory);
|
|
113
|
+
};
|
|
114
|
+
remove(path);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
readActive,
|
|
4
|
+
writeActive,
|
|
5
|
+
installedRelease,
|
|
6
|
+
updatePath,
|
|
7
|
+
pruneInstallations,
|
|
8
|
+
} from "./installation.js";
|
|
9
|
+
import {
|
|
10
|
+
readUpdateJob,
|
|
11
|
+
writeUpdateJob,
|
|
12
|
+
UPDATE_FAILED,
|
|
13
|
+
UPDATE_RECOVERY_FAILED,
|
|
14
|
+
} from "./update-manager.js";
|
|
15
|
+
import { backupDatabase } from "../../scripts/backup.js";
|
|
16
|
+
import { verifyArchive } from "../storage/archive.js";
|
|
17
|
+
|
|
18
|
+
// Executed outside the server so a large verified snapshot does not stall the
|
|
19
|
+
// WebUI. Dependencies are explicit for failure, restart and rollback tests.
|
|
20
|
+
export async function performUpdate(
|
|
21
|
+
paths,
|
|
22
|
+
id,
|
|
23
|
+
{
|
|
24
|
+
install,
|
|
25
|
+
check,
|
|
26
|
+
stop,
|
|
27
|
+
claim,
|
|
28
|
+
start,
|
|
29
|
+
health,
|
|
30
|
+
release,
|
|
31
|
+
snapshot = backupDatabase,
|
|
32
|
+
} = {},
|
|
33
|
+
) {
|
|
34
|
+
let job = readUpdateJob(paths.home, id),
|
|
35
|
+
stopped = false,
|
|
36
|
+
changed = false,
|
|
37
|
+
child = null,
|
|
38
|
+
claimed = false,
|
|
39
|
+
backup = null;
|
|
40
|
+
const previous = readActive(paths.home);
|
|
41
|
+
const save = (status, fields = {}) => {
|
|
42
|
+
job = writeUpdateJob(paths.home, { ...job, ...fields, status });
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
if (job.status !== "queued") throw Error("更新任务已经处理");
|
|
46
|
+
save("downloading");
|
|
47
|
+
const slot = updatePath(paths.home, id);
|
|
48
|
+
mkdirSync(slot, { recursive: true, mode: 0o700 });
|
|
49
|
+
await install(slot, job.version);
|
|
50
|
+
const candidate = installedRelease(paths.home, id, job.version);
|
|
51
|
+
save("checking");
|
|
52
|
+
await check(candidate, job.version);
|
|
53
|
+
save("stopping");
|
|
54
|
+
await stop(job.parent);
|
|
55
|
+
stopped = true;
|
|
56
|
+
claimed = !!claim();
|
|
57
|
+
if (!claimed) throw Error("另一次启动正在进行");
|
|
58
|
+
save("backup");
|
|
59
|
+
backup = snapshot(paths.database, paths.backups);
|
|
60
|
+
verifyArchive(backup);
|
|
61
|
+
save("restarting");
|
|
62
|
+
writeActive(paths.home, { id, version: job.version });
|
|
63
|
+
changed = true;
|
|
64
|
+
child = await start(candidate);
|
|
65
|
+
save("verifying");
|
|
66
|
+
await health(child, job.version);
|
|
67
|
+
save("done", { finished: Date.now() });
|
|
68
|
+
try {
|
|
69
|
+
pruneInstallations(paths.home, [id, previous?.id]);
|
|
70
|
+
} catch {
|
|
71
|
+
/* A locked obsolete program can be removed after the next update. */
|
|
72
|
+
}
|
|
73
|
+
return job;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const failedStage = job.status;
|
|
76
|
+
job = { ...job, failedStage };
|
|
77
|
+
console.error(
|
|
78
|
+
"更新步骤未完成:",
|
|
79
|
+
failedStage,
|
|
80
|
+
error.code || error.cause?.code || "STEP_FAILED",
|
|
81
|
+
);
|
|
82
|
+
if (!stopped) {
|
|
83
|
+
save("error", { error: UPDATE_FAILED, finished: Date.now() });
|
|
84
|
+
return job;
|
|
85
|
+
}
|
|
86
|
+
if (!claimed) {
|
|
87
|
+
save("error", { error: UPDATE_RECOVERY_FAILED, finished: Date.now() });
|
|
88
|
+
return job;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
save("restoring");
|
|
92
|
+
if (child) await stop(child);
|
|
93
|
+
// The child may have claimed startup.json. Reacquire only after its exit.
|
|
94
|
+
if (child) {
|
|
95
|
+
release();
|
|
96
|
+
if (!claim()) throw Error("启动锁尚未释放");
|
|
97
|
+
}
|
|
98
|
+
if (changed) {
|
|
99
|
+
verifyArchive(backup);
|
|
100
|
+
const temporary = `${paths.database}.${id}.restore`;
|
|
101
|
+
copyFileSync(backup, temporary);
|
|
102
|
+
verifyArchive(temporary);
|
|
103
|
+
// Retain the attempted database and every sidecar; no life is erased.
|
|
104
|
+
for (const suffix of ["", "-wal", "-shm"])
|
|
105
|
+
if (existsSync(paths.database + suffix))
|
|
106
|
+
renameSync(
|
|
107
|
+
paths.database + suffix,
|
|
108
|
+
`${paths.database}.update-failed-${id}${suffix}`,
|
|
109
|
+
);
|
|
110
|
+
renameSync(temporary, paths.database);
|
|
111
|
+
writeActive(paths.home, previous);
|
|
112
|
+
}
|
|
113
|
+
const restored = await start(paths.package);
|
|
114
|
+
await health(restored, job.previous);
|
|
115
|
+
save("error", {
|
|
116
|
+
error: UPDATE_FAILED,
|
|
117
|
+
restored: true,
|
|
118
|
+
finished: Date.now(),
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
save("error", { error: UPDATE_RECOVERY_FAILED, finished: Date.now() });
|
|
122
|
+
}
|
|
123
|
+
return job;
|
|
124
|
+
} finally {
|
|
125
|
+
if (claimed) release();
|
|
126
|
+
}
|
|
127
|
+
}
|