luckytri 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/en/guide.md +10 -0
- package/docs/zh/guide.md +10 -0
- package/package.json +3 -2
- package/public/app/assets/{ChatsPage-B9Odvzfz.js → ChatsPage-BhlJHseG.js} +1 -1
- package/public/app/assets/{Empty-SkA8PyMo.js → Empty-DP6WMGHi.js} +1 -1
- package/public/app/assets/{Field-BjLhi8pL.js → Field-CtbdqaiB.js} +1 -1
- package/public/app/assets/{HeartPage-CscKqMsr.js → HeartPage-32BeTPn2.js} +1 -1
- package/public/app/assets/{LifePage-BHCmBHlH.js → LifePage-CxSm0XKI.js} +1 -1
- package/public/app/assets/{MemoryPage-Bhs2xa1k.js → MemoryPage-BeKBcPCY.js} +1 -1
- package/public/app/assets/{NaturePage-CaPIar_5.js → NaturePage-NEPYl2HS.js} +1 -1
- package/public/app/assets/{PeoplePage-CuwSSVeX.js → PeoplePage-DxtpekwM.js} +1 -1
- package/public/app/assets/{PluginsPage-BhVm1SUi.js → PluginsPage-DksEZbUU.js} +1 -1
- package/public/app/assets/{SystemPage-5yYqyUgR.css → SystemPage-BkRu0Rvx.css} +1 -1
- package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
- package/public/app/assets/{TimePage-BUm4eUzB.js → TimePage-7-awH5XL.js} +1 -1
- package/public/app/assets/{index-BIAdfObg.js → index-4rItIkD-.js} +3 -3
- package/public/app/assets/{knowledge-DNDLyN9m.js → knowledge-C6_HvoyB.js} +1 -1
- package/public/app/assets/{media-CKVAvML2.js → media-SMfTT-yc.js} +1 -1
- package/public/app/index.html +2 -2
- package/public/guide.en.html +43 -3
- package/public/guide.html +26 -3
- package/public/tutorial.en.md +10 -0
- package/public/tutorial.md +10 -0
- package/scripts/import-data.js +128 -0
- package/server/app.js +3 -1
- package/server/i18n/errors.js +36 -0
- package/server/index.js +46 -2
- package/server/knowledge/schema.js +12 -8
- package/server/storage/archive.js +5 -4
- package/server/storage/backup-cleanup.js +1 -0
- package/server/storage/data-package.js +298 -0
- package/server/storage/data-transfer-api.js +50 -0
- package/server/storage/data-transfer-worker.js +18 -0
- package/server/storage/data-transfer.js +415 -0
- package/server/storage/store.js +5 -2
- package/public/app/assets/SystemPage-Chm5fiGP.js +0 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { wrap } from "../http.js";
|
|
2
|
+
export function mountDataTransfer(app, transfer) {
|
|
3
|
+
app.get("/api/storage/transfer", (_req, res) =>
|
|
4
|
+
res.json(transfer ? transfer.info() : { available: false }),
|
|
5
|
+
);
|
|
6
|
+
if (!transfer) return;
|
|
7
|
+
app.post(
|
|
8
|
+
"/api/storage/transfer/export",
|
|
9
|
+
wrap((req, res) => res.status(202).json(transfer.export())),
|
|
10
|
+
);
|
|
11
|
+
app.get(
|
|
12
|
+
"/api/storage/transfer/jobs/:id",
|
|
13
|
+
wrap((req, res) => res.json(transfer.job(req.params.id))),
|
|
14
|
+
);
|
|
15
|
+
app.get("/api/storage/transfer/download/:id", async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
await transfer.download(req.params.id, res);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (!res.headersSent) res.status(400).json({ error: error.message });
|
|
20
|
+
else res.destroy();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
app.post(
|
|
24
|
+
"/api/storage/transfer/upload",
|
|
25
|
+
wrap(async (req, res) => {
|
|
26
|
+
if (!req.is("application/octet-stream"))
|
|
27
|
+
return res.status(415).json({ error: "请上传数据包或数据库文件" });
|
|
28
|
+
let name = "";
|
|
29
|
+
try {
|
|
30
|
+
name = decodeURIComponent(req.headers["x-luckytri-filename"] || "");
|
|
31
|
+
} catch {}
|
|
32
|
+
res.status(202).json(
|
|
33
|
+
await transfer.upload(req, {
|
|
34
|
+
bytes: Number(req.headers["content-length"]) || 0,
|
|
35
|
+
name,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
app.post(
|
|
41
|
+
"/api/storage/transfer/import",
|
|
42
|
+
wrap(async (req, res) =>
|
|
43
|
+
res.status(202).json(await transfer.apply(req.body.id)),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
app.delete(
|
|
47
|
+
"/api/storage/transfer/jobs/:id",
|
|
48
|
+
wrap((req, res) => res.json(transfer.discard(req.params.id))),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { makeDataExport, prepareDataImport } from "./data-package.js";
|
|
3
|
+
try {
|
|
4
|
+
const result =
|
|
5
|
+
workerData.kind === "export"
|
|
6
|
+
? makeDataExport(workerData.source, workerData.target, workerData.backups)
|
|
7
|
+
: await prepareDataImport(workerData.upload, workerData.target);
|
|
8
|
+
parentPort.postMessage({ result });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
parentPort.postMessage({
|
|
11
|
+
error:
|
|
12
|
+
error.code === "ENOSPC"
|
|
13
|
+
? "磁盘空间不足"
|
|
14
|
+
: /数据|数据库|LuckyTri|备份|清单/.test(error.message)
|
|
15
|
+
? error.message
|
|
16
|
+
: "数据文件无法校验",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
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
|
+
} = {}) {
|
|
81
|
+
const root = resolve(directory);
|
|
82
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
83
|
+
let active = false;
|
|
84
|
+
const maxBytes =
|
|
85
|
+
maxUploadBytes ??
|
|
86
|
+
Math.floor(
|
|
87
|
+
Math.min(
|
|
88
|
+
100,
|
|
89
|
+
Math.max(1, Number(process.env.DATA_TRANSFER_MAX_GB) || 10),
|
|
90
|
+
) * 1073741824,
|
|
91
|
+
);
|
|
92
|
+
const get = (id) => {
|
|
93
|
+
try {
|
|
94
|
+
return readTransferJob(root, id);
|
|
95
|
+
} catch {
|
|
96
|
+
throw Error("数据任务不存在");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const save = (job) => {
|
|
100
|
+
writeTransferJob(root, job);
|
|
101
|
+
return job;
|
|
102
|
+
};
|
|
103
|
+
const publicJob = (job) => {
|
|
104
|
+
const { manifest, ownerPid, ...rest } = job;
|
|
105
|
+
return rest;
|
|
106
|
+
};
|
|
107
|
+
const check = () => {
|
|
108
|
+
if (active || backup?.running || cleanup?.running)
|
|
109
|
+
throw Error("数据处理或备份正在进行,请稍后再试");
|
|
110
|
+
if (source === ":memory:") throw Error("当前实例不支持文件导入导出");
|
|
111
|
+
};
|
|
112
|
+
const prune = () => {
|
|
113
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
114
|
+
if (!entry.isDirectory() || !ID.test(entry.name)) continue;
|
|
115
|
+
const path = join(root, entry.name);
|
|
116
|
+
const part = relative(root, resolve(path));
|
|
117
|
+
if (
|
|
118
|
+
!part ||
|
|
119
|
+
part.startsWith("..") ||
|
|
120
|
+
isAbsolute(part) ||
|
|
121
|
+
lstatSync(path).isSymbolicLink()
|
|
122
|
+
)
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
const job = get(entry.name);
|
|
126
|
+
if (
|
|
127
|
+
now() - (job.applied || job.readyAt || job.created) > TTL &&
|
|
128
|
+
!["processing", "uploading", "queued"].includes(job.status)
|
|
129
|
+
)
|
|
130
|
+
rmSync(path, { recursive: true, force: true });
|
|
131
|
+
} catch {
|
|
132
|
+
/* Ignore incomplete metadata while a job is publishing it. */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const clearMigrationCopies = (job) => {
|
|
137
|
+
const dir = join(root, job.id, "backups");
|
|
138
|
+
const part = relative(root, resolve(dir));
|
|
139
|
+
if (
|
|
140
|
+
part &&
|
|
141
|
+
!part.startsWith("..") &&
|
|
142
|
+
!isAbsolute(part) &&
|
|
143
|
+
existsSync(dir) &&
|
|
144
|
+
lstatSync(dir).isDirectory() &&
|
|
145
|
+
!lstatSync(dir).isSymbolicLink()
|
|
146
|
+
)
|
|
147
|
+
try {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
const clearFiles = (job) => {
|
|
155
|
+
let cleared = true;
|
|
156
|
+
for (const file of [
|
|
157
|
+
"upload",
|
|
158
|
+
"data.db",
|
|
159
|
+
"data.db.json",
|
|
160
|
+
"data.db-wal",
|
|
161
|
+
"data.db-shm",
|
|
162
|
+
])
|
|
163
|
+
try {
|
|
164
|
+
rmSync(join(root, job.id, file), { force: true });
|
|
165
|
+
} catch {
|
|
166
|
+
cleared = false;
|
|
167
|
+
}
|
|
168
|
+
return clearMigrationCopies(job) && cleared;
|
|
169
|
+
};
|
|
170
|
+
const create = (kind, name = "") => {
|
|
171
|
+
prune();
|
|
172
|
+
// Keep at most one unused file of each kind, rather than accumulating
|
|
173
|
+
// multi-GiB previews every time the user picks another file.
|
|
174
|
+
for (const id of readdirSync(root).filter((id) => ID.test(id))) {
|
|
175
|
+
const previous = get(id);
|
|
176
|
+
if (previous.kind === kind && previous.status === "ready") {
|
|
177
|
+
if (!clearFiles(previous))
|
|
178
|
+
throw Error("临时数据文件正在使用,请稍后再试");
|
|
179
|
+
save({ ...previous, status: "expired" });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const id = randomUUID();
|
|
183
|
+
mkdirSync(join(root, id), { mode: 0o700 });
|
|
184
|
+
return save({
|
|
185
|
+
id,
|
|
186
|
+
kind,
|
|
187
|
+
name,
|
|
188
|
+
created: now(),
|
|
189
|
+
ownerPid: process.pid,
|
|
190
|
+
status: kind === "export" ? "processing" : "uploading",
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
const processJob = async (job) => {
|
|
194
|
+
try {
|
|
195
|
+
const result = await run({
|
|
196
|
+
kind: job.kind,
|
|
197
|
+
source,
|
|
198
|
+
backups,
|
|
199
|
+
target: join(root, job.id, "data.db"),
|
|
200
|
+
upload: join(root, job.id, "upload"),
|
|
201
|
+
});
|
|
202
|
+
rmSync(join(root, job.id, "upload"), { force: true });
|
|
203
|
+
clearMigrationCopies(job);
|
|
204
|
+
save({ ...job, status: "ready", readyAt: now(), ...result });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
clearFiles(job);
|
|
207
|
+
try {
|
|
208
|
+
save({
|
|
209
|
+
...job,
|
|
210
|
+
status: "error",
|
|
211
|
+
error: error.message || "数据处理失败",
|
|
212
|
+
});
|
|
213
|
+
} catch {
|
|
214
|
+
console.error("数据任务状态保存失败");
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
active = false;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const alive = (pid) => {
|
|
221
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
222
|
+
try {
|
|
223
|
+
process.kill(pid, 0);
|
|
224
|
+
return true;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return error.code !== "ESRCH";
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
for (const id of readdirSync(root).filter((id) => ID.test(id))) {
|
|
230
|
+
try {
|
|
231
|
+
const job = get(id);
|
|
232
|
+
if (
|
|
233
|
+
job.kind === "import" &&
|
|
234
|
+
job.status === "queued" &&
|
|
235
|
+
source !== ":memory:" &&
|
|
236
|
+
existsSync(source)
|
|
237
|
+
) {
|
|
238
|
+
const db = new DatabaseSync(source, { readOnly: true });
|
|
239
|
+
let marker;
|
|
240
|
+
try {
|
|
241
|
+
marker = JSON.parse(
|
|
242
|
+
db.prepare("SELECT value FROM settings WHERE id=1").get().value,
|
|
243
|
+
).dataImportId;
|
|
244
|
+
} finally {
|
|
245
|
+
db.close();
|
|
246
|
+
}
|
|
247
|
+
if (marker === id) {
|
|
248
|
+
save({ ...job, status: "done", applied: now() });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (
|
|
253
|
+
["processing", "uploading", "queued"].includes(job.status) &&
|
|
254
|
+
!alive(job.ownerPid)
|
|
255
|
+
) {
|
|
256
|
+
clearFiles(job);
|
|
257
|
+
save({
|
|
258
|
+
...job,
|
|
259
|
+
status: "error",
|
|
260
|
+
readyAt: now(),
|
|
261
|
+
error: "数据处理被中断,请重新操作",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
get busy() {
|
|
268
|
+
return active;
|
|
269
|
+
},
|
|
270
|
+
info() {
|
|
271
|
+
prune();
|
|
272
|
+
const imports = readdirSync(root)
|
|
273
|
+
.filter((id) => ID.test(id))
|
|
274
|
+
.map((id) => {
|
|
275
|
+
try {
|
|
276
|
+
return get(id);
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
.filter(
|
|
282
|
+
(job) =>
|
|
283
|
+
job?.kind === "import" && ["done", "error"].includes(job.status),
|
|
284
|
+
);
|
|
285
|
+
imports.sort((a, b) => b.created - a.created);
|
|
286
|
+
return {
|
|
287
|
+
available: source !== ":memory:",
|
|
288
|
+
maxBytes,
|
|
289
|
+
busy: active,
|
|
290
|
+
lastImport: imports[0] ? publicJob(imports[0]) : null,
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
job(id) {
|
|
294
|
+
return publicJob(get(id));
|
|
295
|
+
},
|
|
296
|
+
export() {
|
|
297
|
+
check();
|
|
298
|
+
active = true;
|
|
299
|
+
let job;
|
|
300
|
+
try {
|
|
301
|
+
job = create("export");
|
|
302
|
+
} catch (error) {
|
|
303
|
+
active = false;
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
void processJob(job);
|
|
307
|
+
return publicJob(job);
|
|
308
|
+
},
|
|
309
|
+
async upload(stream, { bytes = 0, name = "" } = {}) {
|
|
310
|
+
check();
|
|
311
|
+
if (bytes > maxBytes) throw Error("数据文件超过导入大小限制");
|
|
312
|
+
active = true;
|
|
313
|
+
let job;
|
|
314
|
+
try {
|
|
315
|
+
job = create(
|
|
316
|
+
"import",
|
|
317
|
+
String(name).replace(/[\\/]/g, "_").slice(0, 180),
|
|
318
|
+
);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
active = false;
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
let total = 0;
|
|
324
|
+
const limit = new Transform({
|
|
325
|
+
transform(chunk, _encoding, done) {
|
|
326
|
+
total += chunk.length;
|
|
327
|
+
done(
|
|
328
|
+
total > maxBytes ? Error("数据文件超过导入大小限制") : null,
|
|
329
|
+
chunk,
|
|
330
|
+
);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
try {
|
|
334
|
+
await pipeline(
|
|
335
|
+
stream,
|
|
336
|
+
limit,
|
|
337
|
+
createWriteStream(join(root, job.id, "upload"), {
|
|
338
|
+
flags: "wx",
|
|
339
|
+
mode: 0o600,
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
if (!total) throw Error("数据文件为空");
|
|
343
|
+
save({ ...job, status: "processing" });
|
|
344
|
+
void processJob(job);
|
|
345
|
+
return publicJob({ ...job, status: "processing" });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
active = false;
|
|
348
|
+
rmSync(join(root, job.id, "upload"), { force: true });
|
|
349
|
+
save({
|
|
350
|
+
...job,
|
|
351
|
+
status: "error",
|
|
352
|
+
error: error.code === "ENOSPC" ? "磁盘空间不足" : "数据上传失败",
|
|
353
|
+
});
|
|
354
|
+
throw Error("数据上传失败");
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
async download(id, res) {
|
|
358
|
+
if (active) throw Error("数据处理或备份正在进行,请稍后再试");
|
|
359
|
+
const job = get(id);
|
|
360
|
+
if (
|
|
361
|
+
job.kind !== "export" ||
|
|
362
|
+
job.status !== "ready" ||
|
|
363
|
+
now() - (job.readyAt || job.created) > TTL
|
|
364
|
+
)
|
|
365
|
+
throw Error("导出文件尚未就绪或已过期");
|
|
366
|
+
const header = packageHeader(job.manifest);
|
|
367
|
+
res.setHeader("Content-Type", "application/octet-stream");
|
|
368
|
+
res.setHeader(
|
|
369
|
+
"Content-Disposition",
|
|
370
|
+
`attachment; filename="luckytri-data-${new Date(job.created).toISOString().slice(0, 10)}.luckytri"`,
|
|
371
|
+
);
|
|
372
|
+
res.setHeader("Content-Length", header.length + job.bytes);
|
|
373
|
+
active = true;
|
|
374
|
+
try {
|
|
375
|
+
res.write(header);
|
|
376
|
+
await pipeline(createReadStream(join(root, id, "data.db")), res);
|
|
377
|
+
clearFiles(job);
|
|
378
|
+
save({ ...job, status: "done" });
|
|
379
|
+
} finally {
|
|
380
|
+
active = false;
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
async apply(id) {
|
|
384
|
+
check();
|
|
385
|
+
const job = get(id);
|
|
386
|
+
if (!onApply) throw Error("当前实例不支持自动导入,请使用恢复命令");
|
|
387
|
+
if (
|
|
388
|
+
job.kind !== "import" ||
|
|
389
|
+
job.status !== "ready" ||
|
|
390
|
+
now() - (job.readyAt || job.created) > TTL
|
|
391
|
+
)
|
|
392
|
+
throw Error("导入数据尚未就绪或已过期");
|
|
393
|
+
active = true;
|
|
394
|
+
save({ ...job, status: "queued" });
|
|
395
|
+
try {
|
|
396
|
+
const owner = await onApply(id);
|
|
397
|
+
if (Number.isInteger(owner) && owner > 0)
|
|
398
|
+
save({ ...job, status: "queued", ownerPid: owner });
|
|
399
|
+
} catch (error) {
|
|
400
|
+
active = false;
|
|
401
|
+
save(job);
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
return { queued: true, id };
|
|
405
|
+
},
|
|
406
|
+
discard(id) {
|
|
407
|
+
const job = get(id);
|
|
408
|
+
if (["processing", "uploading", "queued"].includes(job.status))
|
|
409
|
+
throw Error("数据处理正在进行,请稍后再试");
|
|
410
|
+
if (!clearFiles(job)) throw Error("临时数据文件正在使用,请稍后再试");
|
|
411
|
+
save({ ...job, status: "expired" });
|
|
412
|
+
return { ok: true };
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
package/server/storage/store.js
CHANGED
|
@@ -6,8 +6,11 @@ import { summarizeGroupStyle } from "../core/group-style.js";
|
|
|
6
6
|
import { isGroupSession } from "../channels/session-key.js";
|
|
7
7
|
import { NATURE_DEFAULTS } from "../mind/nature.js";
|
|
8
8
|
import { prepareDatabaseMigration } from "./archive.js";
|
|
9
|
-
export function createStore(
|
|
10
|
-
|
|
9
|
+
export function createStore(
|
|
10
|
+
path = runtimePaths().database,
|
|
11
|
+
{ quietMigration = false } = {},
|
|
12
|
+
) {
|
|
13
|
+
prepareDatabaseMigration(path, { quiet: quietMigration });
|
|
11
14
|
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
12
15
|
const db = new DatabaseSync(path);
|
|
13
16
|
const legacyMessages =
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{$ as e,Ct as t,E as n,Et as r,I as i,J as a,M as o,P as s,Tt as c,U as l,V as u,W as d,X as f,Y as p,Z as m,a as h,b as g,bt as _,ct as v,dt as y,et as b,f as x,ft as S,gt as C,ht as w,i as T,j as E,lt as D,m as O,n as k,nt as A,o as j,ot as M,p as N,q as P,rt as F,s as I,t as L,ut as R,v as z,vt as B,w as V,wt as H,xt as U,yt as W,z as G}from"./Empty-SkA8PyMo.js";import{c as K,f as ee,l as q,n as J,o as te}from"./index-BIAdfObg.js";var ne={class:`connect`},Y={class:`card connection`},re={class:`card-head`},ie={class:`eyebrow`},X={class:`pill`},ae={key:0,class:`notice`,role:`note`},oe={class:`muted`},Z={class:`connection-details`},Q={class:`detail`},se={class:`instructions`},ce={class:`muted`},le={class:`muted note`},ue={key:0,class:`row`},de=[`disabled`],fe={class:`muted`},pe=[`disabled`,`placeholder`],me={key:0,class:`faint`},$=[`disabled`,`placeholder`],he={key:0,class:`faint`},ge={class:`row`},_e=[`disabled`],ve={key:0,class:`faint`},ye={key:0,class:`connection-details`},be={class:`detail`},xe={"data-state":``},Se={key:0,class:`detail`},Ce={class:`warn-text`},we={key:1,class:`detail`},Te={class:`detail`},Ee={class:`detail`},De={key:1,class:`notice`,role:`note`},Oe={class:`instructions`},ke={class:`muted notes`},Ae={class:`card checklist`},je={class:`card-head`},Me={class:`eyebrow`},Ne={class:`chip`},Pe={class:`checks`},Fe={class:`mark`,"aria-hidden":`true`},Ie={class:`muted`},Le=[`data-fix`,`onClick`],Re=k(A({__name:`ConnectQQ`,emits:[`open`],setup(o,{emit:c}){let p=c,g=P(()=>I.health.connection||{}),v=P(()=>g.value.channel===`qqbot`?`qqbot`:`onebot`),x=P(()=>!!g.value.locked),S=P(()=>!!g.value.online);P(()=>g.value.onebot||{});let w=P(()=>g.value.qqbot||{}),k=P(()=>String(I.health.settings.qqbotAppId||``)),A=P(()=>!!I.health.settings.hasQqbotSecret),j=U(v.value);C(v,e=>j.value=e);let M=P(()=>`${location.protocol===`https:`?`wss`:`ws`}://${location.host}/onebot/v11/ws`),N=P(()=>[{key:`onebot`,label:i(`OneBot 11`),count:v.value===`onebot`?i(`使用中`):``},{key:`qqbot`,label:i(`QQ 官方机器人`),count:v.value===`qqbot`?i(`使用中`):``}]),F=_({appId:k.value,secret:``}),L=U(!1),B=P(()=>F.appId.trim()!==k.value||!!F.secret.trim());C(B,e=>{I.dirty=e}),C(k,e=>{B.value||(F.appId=e)}),D(()=>{I.dirty=!1});let V=P(()=>!!F.appId.trim()&&(A.value||!!F.secret.trim())),G=s({idle:E(`未启用`),unconfigured:E(`尚未填写 AppID 与 AppSecret`),connecting:E(`正在连接开放平台`),ready:E(`已连接`),closed:E(`连接已断开,正在重连`),error:E(`连接出错`)}),K=s({all:E(`全部群消息`),mentions:E(`只有 @ 她的消息`),unknown:E(`尚未收到群消息`)}),ee=P(()=>G[w.value.state||`idle`]??G.idle),te=P(()=>K[w.value.groupMessages||`unknown`]??K.unknown),Re=P(()=>v.value===`qqbot`&&w.value.groupMessages===`mentions`);async function ze(e){if(L.value||x.value||e===v.value)return;let t=i(e===`qqbot`?`QQ 官方机器人`:`OneBot 11`);if(!S.value||await n(i(`切换后会断开当前连接。原来方式下的会话仍可查看,但不能再向它们发送消息。`),{title:i(`切换连接方式`),confirmText:i(`切换`),cancelText:i(`先不切换`)})){L.value=!0;try{await O(`/settings`,`PATCH`,{channel:e}),await h(),z(i(`已切换为 {name}`,{name:t}))}catch(e){z(e.message,!0)}finally{L.value=!1}}}async function Be(){if(!L.value){if(!V.value){z(i(`请填写 AppID 和 AppSecret`),!0);return}L.value=!0;try{let e={qqbotAppId:F.appId.trim()};F.secret.trim()&&(e.qqbotSecret=F.secret.trim()),v.value!==`qqbot`&&!x.value&&(e.channel=`qqbot`),await O(`/settings`,`PATCH`,e),F.secret=``,await h(),z(i(`已保存`))}catch(e){z(e.message,!0)}finally{L.value=!1}}}let Ve={token:[``,`connect`],credentials:[``,`connect`],qq:[``,`connect`],model:[``,`models`],mode:[``,`runtime`],session:[`chats`,``],persona:[`nature`,``]};function He(e){let[t,n]=Ve[e]||[``,`connect`];t?T(t):p(`open`,n)}return(n,o)=>(R(),m(`div`,ne,[a(`section`,Y,[a(`div`,re,[a(`div`,null,[a(`span`,ie,r(t(i)(`QQ`)),1),a(`h2`,null,r(t(i)(`连接 QQ`)),1),a(`p`,null,r(t(i)(`两种连接方式二选一,同一时间只使用一种。她的记忆、心情和行为完全一致,区别只在消息怎么到达。`)),1)]),a(`span`,X,[a(`span`,{class:H([`dot`,{off:!S.value}])},null,2),e(` `+r(S.value?t(i)(`QQ 已连接`):t(i)(`等待连接`)),1)])]),b(J,{modelValue:j.value,"onUpdate:modelValue":o[0]||=e=>j.value=e,label:t(i)(`连接方式`),items:N.value},null,8,[`modelValue`,`label`,`items`]),x.value?(R(),m(`p`,ae,r(t(i)(`连接方式由环境变量 LUCKYTRI_CHANNEL 指定,在这里不能更改。去掉该变量后即可在此选择。`)),1)):f(``,!0),j.value===`onebot`?(R(),m(d,{key:1},[a(`p`,oe,r(t(i)(`自备接入端,通过反向 WebSocket 连接。能看到全部群消息。`)),1),a(`div`,Z,[a(`div`,Q,[a(`span`,null,r(t(i)(`反向 WebSocket 地址`)),1),a(`code`,null,r(M.value),1)])]),a(`ol`,se,[a(`li`,null,r(t(i)(`同一台电脑上的接入端无需单独配置连接令牌。`)),1),a(`li`,null,r(t(i)(`在接入端启用反向 WebSocket 客户端,填写上方地址,消息格式选择数组。已有的地址配置可以继续使用。`)),1),a(`li`,null,r(t(i)(`在接入端完成 QQ 登录。连接成功后,本页状态会更新;新会话仍需在「对话」中开启参与。`)),1)]),a(`p`,ce,r(t(i)(`远程接入时,连接密码使用管理密码;已有的远程连接令牌仍兼容。`)),1),a(`p`,le,r(t(i)(`LuckyTri 只负责接收,不提供、不安装也不管理接入端。`)),1),v.value===`onebot`?f(``,!0):(R(),m(`div`,ue,[a(`button`,{class:`primary`,"data-use":`onebot`,disabled:L.value||x.value,onClick:o[1]||=e=>ze(`onebot`)},r(t(i)(`改用这种方式`)),9,de)]))],64)):(R(),m(d,{key:2},[a(`p`,fe,r(t(i)(`只需 AppID 与 AppSecret,无需额外软件。群里默认只收到 @ 她的消息。`)),1),a(`form`,{class:`stack tight`,onSubmit:l(Be,[`prevent`])},[a(`label`,null,[e(r(t(i)(`AppID`))+` `,1),W(a(`input`,{"onUpdate:modelValue":o[2]||=e=>F.appId=e,name:`qqbotAppId`,autocomplete:`off`,inputmode:`numeric`,maxlength:`40`,disabled:L.value||w.value.appIdFromEnv,placeholder:t(i)(`机器人的 AppID`)},null,8,pe),[[u,F.appId]]),w.value.appIdFromEnv?(R(),m(`small`,me,r(t(i)(`由环境变量指定,在这里不能修改`)),1)):f(``,!0)]),a(`label`,null,[e(r(t(i)(`AppSecret`))+` `,1),W(a(`input`,{"onUpdate:modelValue":o[3]||=e=>F.secret=e,name:`qqbotSecret`,type:`password`,autocomplete:`new-password`,maxlength:`200`,disabled:L.value||w.value.secretFromEnv,placeholder:A.value||w.value.secretFromEnv?t(i)(`已保存,留空保留`):t(i)(`机器人的 AppSecret`)},null,8,$),[[u,F.secret]]),w.value.secretFromEnv?(R(),m(`small`,he,r(t(i)(`由环境变量指定,在这里不能修改`)),1)):f(``,!0)]),a(`div`,ge,[a(`button`,{class:`primary`,"data-save":`qqbot`,disabled:L.value||w.value.appIdFromEnv&&w.value.secretFromEnv},r(v.value===`qqbot`||x.value?t(i)(`保存`):t(i)(`保存并使用`)),9,_e),B.value?(R(),m(`span`,ve,r(t(i)(`有尚未保存的修改`)),1)):f(``,!0)])],32),v.value===`qqbot`?(R(),m(`div`,ye,[a(`div`,be,[a(`span`,null,r(t(i)(`连接状态`)),1),a(`strong`,xe,r(ee.value),1)]),w.value.error?(R(),m(`div`,Se,[o[4]||=a(`span`,null,null,-1),a(`small`,Ce,r(w.value.error),1)])):f(``,!0),w.value.botName?(R(),m(`div`,we,[a(`span`,null,r(t(i)(`机器人`)),1),a(`strong`,null,r(w.value.botName),1)])):f(``,!0),a(`div`,Te,[a(`span`,null,r(t(i)(`最近收到消息`)),1),a(`strong`,null,r(w.value.lastEventAt?t(q)(w.value.lastEventAt):t(i)(`还没有`)),1)]),a(`div`,Ee,[a(`span`,null,r(t(i)(`群消息范围`)),1),a(`strong`,null,r(te.value),1)])])):f(``,!0),Re.value?(R(),m(`p`,De,r(t(i)(`目前只收到 @ 她的群消息。想让她看到群里的全部消息,请在开放平台的机器人设置里开启「接收所有消息」。`)),1)):f(``,!0),a(`ol`,Oe,[a(`li`,null,r(t(i)(`在 QQ 开放平台创建机器人,取得 AppID 与 AppSecret。`)),1),a(`li`,null,r(t(i)(`把运行 LuckyTri 的这台机器的出口 IP 加入机器人的 IP 白名单。`)),1),a(`li`,null,r(t(i)(`可选:开启「接收所有消息」,她才能看到群里没有 @ 她的话;不开启时只会收到 @ 她的消息。`)),1),a(`li`,null,r(t(i)(`填写上方的 AppID 与 AppSecret 并保存。状态变为「已连接」后,在群里 @ 她或私聊她,新会话会出现在「对话」中。`)),1)]),a(`ul`,ke,[a(`li`,null,r(t(i)(`官方平台不提供群名称:新会话以占位名出现,可以在「对话」中为它改名。`)),1),a(`li`,null,r(t(i)(`官方平台上,同一个人在不同的群里标识不同。像只在不同的房间见过对方一样,平台给出统一身份之前,她不会把两处的人当成同一个人。`)),1),a(`li`,null,r(t(i)(`对方关闭了机器人的主动消息时,她不会去打扰,会等对方来找她。`)),1)])],64))]),a(`section`,Ae,[a(`div`,je,[a(`div`,null,[a(`span`,Me,r(t(i)(`就绪检查`)),1),a(`h2`,null,r(t(i)(`还差哪一步`)),1)]),a(`span`,Ne,r(t(I).health.readiness?.completed??0)+` / `+r(t(I).health.readiness?.total??0),1)]),a(`ol`,Pe,[(R(!0),m(d,null,y(t(I).health.readiness?.checks||[],e=>(R(),m(`li`,{key:e.id,class:H({done:e.done})},[a(`span`,Fe,r(e.done?`✓`:``),1),a(`div`,null,[a(`b`,null,r(e.name),1),a(`small`,Ie,r(e.detail),1)]),a(`button`,{class:`small`,"data-fix":e.id,onClick:t=>He(e.id)},r(e.done?t(i)(`查看`):t(i)(`去处理`)),9,Le)],2))),128))])])]))}}),[[`__scopeId`,`data-v-6f4553ed`]]);async function ze(e){return O(`/core/models`,`PUT`,{models:e})}async function Be(e){return O(`/core/models/`+encodeURIComponent(e),`DELETE`,{})}async function Ve(e){return O(`/model/test`,`POST`,{modelId:e})}var He={class:`card embedding-profile`,"aria-labelledby":`embedding-title`},Ue={id:`embedding-title`},We={class:`muted`},Ge={key:0,class:`faint`},Ke={class:`check`},qe={class:`fields`},Je=[`required`],Ye=[`required`],Xe=[`placeholder`],Ze={class:`save-bar`},Qe=[`disabled`],$e=[`disabled`],et={class:`faint`},tt={key:2,role:`status`},nt=k(A({__name:`EmbeddingProfile`,setup(n){let o=_({enabled:!1,label:i(`知识向量`),baseUrl:``,model:``,apiKey:``,dimensions:0,batchSize:32,timeoutMs:9e4}),s=U(!1),c=U(!1),d=U(!1),p=U(``);async function h(){try{Object.assign(o,await O(`/core/knowledge/embedding`),{apiKey:``}),d.value=!0}catch(e){p.value=e.message}}async function g(){s.value=!0;try{Object.assign(o,await O(`/core/knowledge/embedding`,`PATCH`,o),{apiKey:``}),c.value=!1,z(i(`知识向量模型已保存`))}catch(e){z(e.message,!0)}finally{s.value=!1}}async function y(){s.value=!0;try{let e=await O(`/core/knowledge/embedding/test`,`POST`,{});p.value=i(`连接成功,向量维度 {dimensions}`,{dimensions:e.dimensions})}catch(e){p.value=e.message}finally{s.value=!1}}return v(h),(n,h)=>(R(),m(`section`,He,[a(`h2`,Ue,r(t(i)(`知识向量模型`)),1),a(`p`,We,r(t(i)(`单独配置资料检索使用的模型。更换对话模型后仍使用这里的配置。更换向量模型后,可在资料书架逐份重建向量;原文仍能通过关键词检索。`)),1),d.value&&!o.configured?(R(),m(`p`,Ge,r(t(i)(`当前兼容旧配置,跟随默认模型。保存后改用独立配置。`)),1)):f(``,!0),d.value?(R(),m(`form`,{key:1,onSubmit:l(g,[`prevent`]),onInput:h[6]||=e=>c.value=!0,onChange:h[7]||=e=>c.value=!0},[a(`label`,Ke,[W(a(`input`,{"onUpdate:modelValue":h[0]||=e=>o.enabled=e,type:`checkbox`,name:`embeddingEnabled`},null,512),[[G,o.enabled]]),e(r(t(i)(`启用知识向量`)),1)]),a(`div`,qe,[a(`label`,null,[e(r(t(i)(`API 地址`)),1),W(a(`input`,{"onUpdate:modelValue":h[1]||=e=>o.baseUrl=e,name:`embeddingBaseUrl`,type:`url`,required:o.enabled,placeholder:`https://example.com/v1`},null,8,Je),[[u,o.baseUrl]])]),a(`label`,null,[e(r(t(i)(`模型名称`)),1),W(a(`input`,{"onUpdate:modelValue":h[2]||=e=>o.model=e,name:`embeddingModelName`,required:o.enabled},null,8,Ye),[[u,o.model]])]),a(`label`,null,[e(r(t(i)(`独立 API Key`)),1),W(a(`input`,{"onUpdate:modelValue":h[3]||=e=>o.apiKey=e,name:`embeddingApiKey`,type:`password`,autocomplete:`new-password`,placeholder:o.hasApiKey?t(i)(`已保存,留空保留`):t(i)(`填写向量服务密钥`)},null,8,Xe),[[u,o.apiKey]])]),a(`label`,null,[e(r(t(i)(`向量维度(0 表示自动)`)),1),W(a(`input`,{"onUpdate:modelValue":h[4]||=e=>o.dimensions=e,name:`embeddingDimensions`,type:`number`,min:`0`,max:`65536`},null,512),[[u,o.dimensions,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`每批段落数`)),1),W(a(`input`,{"onUpdate:modelValue":h[5]||=e=>o.batchSize=e,name:`embeddingBatchSize`,type:`number`,min:`1`,max:`128`},null,512),[[u,o.batchSize,void 0,{number:!0}]])])]),a(`div`,Ze,[a(`button`,{class:`primary`,disabled:s.value},r(t(i)(`保存向量模型`)),9,Qe),a(`button`,{type:`button`,disabled:s.value||c.value||!o.enabled||!o.configured,onClick:y},r(t(i)(`测试向量连接`)),9,$e),a(`small`,et,r(c.value?t(i)(`有未保存的修改`):t(i)(`配置已载入`)),1)])],32)):f(``,!0),p.value?(R(),m(`p`,tt,r(p.value),1)):f(``,!0)]))}}),[[`__scopeId`,`data-v-6873efbb`]]),rt={class:`library`},it={class:`model-workspace`},at={class:`card shelf`},ot={class:`shelf-head`},st={class:`faint`},ct={class:`rows`},lt={key:0,class:`muted`},ut=[`open`,`data-saved-vendor`,`onToggle`],dt={class:`vendor-models`},ft=[`onClick`],pt={class:`who`},mt={key:0,class:`card detail`},ht={class:`card-head`},gt={class:`eyebrow`},_t=[`data-tone`],vt={class:`form-grid`},yt={key:0},bt=[`placeholder`],xt={class:`wide`},St=[`placeholder`],Ct={class:`faint`},wt={class:`form-grid`},Tt={key:0},Et={key:1},Dt={class:`advanced`},Ot={class:`form-grid`},kt=[`placeholder`],At={class:`faint`},jt={class:`caps`},Mt=[`onUpdate:modelValue`,`name`],Nt={key:0,id:`modelTestResult`,class:`notice`,role:`status`},Pt={class:`actions`},Ft=[`disabled`],It={class:`faint`},Lt={key:0,class:`check`},Rt=[`checked`],zt=[`disabled`],Bt={key:1,class:`card detail`},Vt={class:`card usage-panel`,"aria-labelledby":`token-usage-title`},Ht={class:`usage-intro`},Ut={class:`eyebrow`},Wt={id:`token-usage-title`},Gt={class:`usage-total`},Kt={class:`usage-detail`},qt={class:`usage-stat`},Jt={class:`usage-stat`},Yt={class:`usage-stat cache-stat`},Xt={class:`usage-stat`},Zt={class:`usage-foot`},Qt={key:0},$t={key:1,class:`usage-error`},en={key:2},tn=[`disabled`],nn={class:`model-picker`},rn={class:`muted`},an={class:`catalog-search`},on=[`placeholder`],sn=[`data-vendor`],cn={class:`vendor-summary`},ln={class:`vendor-name`},un={class:`presets`},dn=[`data-preset`,`onClick`],fn={key:0,class:`muted search-empty`},pn=k(A({__name:`ModelLibrary`,setup(s){let p=P(()=>I.health?.modelCatalog||[]),g=P(()=>I.health?.effortLabels||{none:i(`关闭`),minimal:i(`极低`),low:i(`低`),medium:i(`中`),high:i(`高`),xhigh:i(`极高`),max:i(`最深`)}),x=U(I.core.models.map(e=>({...e}))),S=U(x.value.length?0:-1),w=U(x.value.length?Q(x.value[0]):``),T=U(!1),E=U(!1),D=U(!1),k=U(``),A=U(``),j=U(null),M=U(!1),N=U(``),F=_(Z());x.value[0]&&Object.assign(F,x.value[0],{apiKey:``});let V=P(()=>S.value>=0&&!!F.id),q=P(()=>{let e=[];for(let t of p.value){if(t.id===`custom`)continue;let n=e.find(e=>e.vendor===t.vendor);n||(n={vendor:t.vendor,models:[]},e.push(n)),n.models.push(t)}return e}),J=P(()=>{let e=k.value.trim().toLocaleLowerCase();return e?q.value.map(t=>({...t,models:t.models.filter(n=>[t.vendor,n.label,n.model,n.summary].filter(Boolean).join(` `).toLocaleLowerCase().includes(e))})).filter(e=>e.models.length):q.value}),ne=P(()=>{let e=[];return x.value.forEach((t,n)=>{let r=Q(t),i=e.find(e=>e.vendor===r);i||(i={vendor:r,models:[]},e.push(i)),i.models.push({model:t,index:n})}),e}),Y=P(()=>p.value.find(e=>e.id===`custom`)),re=P(()=>p.value.filter(e=>e.vendor&&e.vendor===F.vendor)),ie=P(()=>p.value.find(e=>e.id===F.presetId)||p.value.find(e=>e.model&&e.model===F.model&&e.baseUrl===F.baseUrl)),X=P(()=>{let e=ie.value?.contextWindows||[];if(e.length<2)return[];let t=Number(F.contextWindow);return e.some(e=>e.contextWindow===t)?e:[...e,{label:i(`已保存 {value}`,{value:fe(t)}),contextWindow:t,maxInputTokens:Number(F.maxInputTokens),maxOutputTokens:Number(F.maxOutputTokens)}]}),ae=P(()=>{let e=Array.isArray(F.reasoningEfforts)&&F.reasoningEfforts.length?[...F.reasoningEfforts]:[`none`,`minimal`,`low`,`medium`,`high`,`xhigh`,`max`];return F.reasoningEffort&&!e.includes(F.reasoningEffort)&&e.unshift(F.reasoningEffort),e}),oe=P(()=>I.core.models.some(e=>e.id===F.id));function Z(){return{id:``,presetId:``,label:``,provider:``,vendor:``,baseUrl:``,model:``,apiKey:``,hasApiKey:!1,contextWindow:128e3,maxInputTokens:119808,maxOutputTokens:8192,vision:!1,system:!0,json:!0,tools:!1,embedding:!1,embeddingModel:``,reasoningEffort:`none`,reasoningEfforts:[`none`,`low`,`medium`,`high`],thinkingStyle:`openai`,tokenField:`max_tokens`,omitSampling:!1,temperature:.85,topP:1,timeoutMs:9e4,isDefault:!1,enabled:!0}}function Q(e){let t={"volcengine-coding-plan":i(`火山方舟 Coding Plan`),"opencode-go":`OpenCode Go`,"opencode-zen":`OpenCode Zen`,deepseek:`DeepSeek`,openai:`OpenAI`,bedrock:i(`亚马逊 Bedrock`),qwen:i(`通义千问`),moonshot:`Kimi`,kimi:`Kimi`,zhipu:i(`智谱 GLM`),glm:i(`智谱 GLM`),mimo:i(`小米 MiMo`),siliconflow:`SiliconFlow`,openrouter:`OpenRouter`};return String(e?.vendor||``).trim()||t[String(e?.provider||``).toLowerCase()]||String(e?.provider||i(`自定义供应商`))}function se(e,t){let n=t.currentTarget;w.value=n.open?e:``}function ce(e){let{summary:t,contextWindows:n,...r}=e;return{...r,presetId:e.id}}function le(e){let{contextWindows:t,...n}=e;return n}function ue(e){let t=Number(e.contextWindow),n=Number(e.maxOutputTokens),r=Number(e.maxInputTokens);return(!Number.isFinite(t)||t<2)&&(t=2),(!Number.isFinite(n)||n<1)&&(n=1),n>=t&&(n=t-1),(!Number.isFinite(r)||r<1)&&(r=t-n),r+n>t&&(r=t-n),{contextWindow:t,maxInputTokens:r,maxOutputTokens:n}}function de(e){let t=Number(e.target.value),n=X.value.find(e=>e.contextWindow===t);n&&(F.contextWindow=n.contextWindow,F.maxInputTokens=n.maxInputTokens,F.maxOutputTokens=n.maxOutputTokens,I.dirty=!0)}function fe(e){let t=Number(e)||0;return t>=1e6?(t%1048576==0?t/1048576:Number((t/1e6).toFixed(2)))+`M`:t>=1e3?(t%1e3==0?t/1e3:t%1024==0?t/1024:Math.round(t/1e3))+`K`:String(t)}function pe(e){if(e.id===`openrouter`)return i(`按所选模型填写上下文和输出参数`);if(!e.model)return i(`填写模型 ID 和对应参数`);let t=e.contextWindows||[],n=t.length?t.map(e=>e.label).join(` / `):fe(e.contextWindow);return i(`上下文 {context} · 输出 {v}`,{context:n,v:fe(e.maxOutputTokens)})}function me(e){return[`mimo`,`qwen`,`kimi-toggle`].includes(F.thinkingStyle)?i(e===`none`?`关闭`:`开启`):`${g.value[e]||e} / ${e}`}function $(e){return new Intl.NumberFormat(o()).format(Number(e)||0)}async function he(){if(!M.value){M.value=!0;try{j.value=await O(`/core/usage`),N.value=``}catch(e){N.value=e.message}finally{M.value=!1}}}v(()=>void he()),C(()=>I.tick,()=>void he());async function ge(){return I.dirty?await n(i(`放弃当前模型草稿?`),{title:i(`草稿还没保存`),confirmText:i(`放弃`),cancelText:i(`留下`)})?(I.dirty=!1,!0):!1:!0}async function _e(e){let t=x.value[e];t&&t.id!==F.id&&await ge()&&(x.value=x.value.filter(e=>e.id===t.id||I.core.models.some(t=>t.id===e.id)),S.value=x.value.findIndex(e=>e.id===t.id),w.value=Q(t),Object.assign(F,Z(),t,{apiKey:``}),A.value=``)}async function ve(){if(!await ge())return;x.value=I.core.models.map(e=>({...e})),x.value.length?x.value.some(e=>e.id===F.id)||(S.value=0,Object.assign(F,Z(),x.value[0],{apiKey:``})):(S.value=-1,Object.assign(F,Z()));let e=x.value[S.value]||x.value[0];w.value=e?Q(e):``,k.value=``,D.value=!0}function ye(e){x.value=I.core.models.map(e=>({...e}));let t={...Z(),...ce(e),id:`model-`+Date.now(),apiKey:``,hasApiKey:!1,isDefault:x.value.length===0};x.value.push(t),S.value=x.value.length-1,w.value=Q(t),Object.assign(F,t),A.value=``,D.value=!1,I.dirty=!0}function be(e){let t=e.target.value,n=p.value.find(e=>e.id===t);if(!n)return;Object.assign(F,Z(),ce(n),{id:F.id,apiKey:F.apiKey,hasApiKey:F.hasApiKey,isDefault:F.isDefault});let r=x.value[S.value];r&&(r.label=F.label),I.dirty=!0}function xe(){for(let e of x.value)e.isDefault=e.id===F.id;F.isDefault=!0,I.dirty=!0}async function Se(){let e=ue(F),t=x.value.map((t,n)=>le(n===S.value?{...F,...e,isDefault:!!F.isDefault,enabled:F.isDefault?!0:F.enabled!==!1,timeoutMs:Number(F.timeoutMs),temperature:Number(F.temperature),topP:Number(F.topP)}:{...t,isDefault:!!t.isDefault&&t.id!==F.id}));if(!t.some(e=>e.isDefault)&&t.length&&(t[0].isDefault=!0),!T.value){T.value=!0;try{await ze(t),I.dirty=!1,await h(),x.value=I.core.models.map(e=>({...e})),Object.assign(F,Z(),x.value[S.value],{apiKey:``}),z(i(`已保存并应用`))}catch(e){z(e.message,!0)}finally{T.value=!1}}}async function Ce(){if(await n(i(`确定删除这个模型?`),{title:i(`删除模型`),confirmText:i(`删除`),danger:!0})){if(I.core.models.some(e=>e.id===F.id)&&await Be(F.id),I.dirty=!1,await h(),x.value=I.core.models.map(e=>({...e})),A.value=``,!x.value.length){S.value=-1,w.value=``,Object.assign(F,Z());return}S.value=0,w.value=Q(x.value[0]),Object.assign(F,Z(),x.value[0],{apiKey:``})}}async function we(){if(!(!oe.value||I.dirty||E.value)){E.value=!0,A.value=i(`正在测试「{v}」…`,{v:F.label||F.model});try{let e=await Ve(F.id);A.value=i(`✓ {v} 连接成功 · {latency} ms`,{v:F.label||e.model,latency:e.latency}),await h()}catch(e){A.value=e.message}finally{E.value=!1}}}return(n,s)=>(R(),m(`div`,rt,[a(`div`,it,[a(`aside`,at,[a(`div`,ot,[a(`h2`,null,[e(r(t(i)(`模型库`))+` `,1),a(`small`,null,r(x.value.length),1)]),a(`button`,{id:`addModel`,class:`small primary`,onClick:ve},r(t(i)(`+ 新增模型`)),1)]),a(`p`,st,r(t(i)(`群聊、私聊和独处都使用默认模型。其余已启用的模型按这里的顺序作为备用。关掉的模型不会被调用。`)),1),a(`div`,ct,[x.value.length?f(``,!0):(R(),m(`p`,lt,r(t(i)(`还没有模型。`)),1)),(R(!0),m(d,null,y(ne.value,e=>(R(),m(`details`,{key:e.vendor,class:`saved-vendor`,open:w.value===e.vendor,"data-saved-vendor":e.vendor,onToggle:t=>se(e.vendor,t)},[a(`summary`,null,[a(`span`,null,r(e.vendor),1),a(`small`,null,r(t(i)(`{length} 个模型`,{length:e.models.length})),1)]),a(`div`,dt,[(R(!0),m(d,null,y(e.models,e=>(R(),m(`button`,{key:e.model.id,class:H([`entity-row`,{selected:S.value===e.index}]),onClick:t=>_e(e.index)},[a(`span`,{class:`badge`,style:c({"--hue":t(ee)(e.model.label||e.model.model||e.model.id)})},r(String(e.model.label||e.model.model||`?`).slice(0,1).toUpperCase()),5),a(`span`,pt,[a(`b`,null,r(e.model.label||e.model.model),1),a(`small`,null,r(e.model.enabled===!1?t(i)(`已关闭`):e.model.isDefault?t(i)(`默认模型`):t(i)(`备用模型`)),1)])],10,ft))),128))])],40,ut))),128))])]),V.value?(R(),m(`section`,mt,[a(`div`,ht,[a(`div`,null,[a(`span`,gt,r(t(i)(`模型档案`)),1),a(`h2`,null,r(F.label||t(i)(`新模型`)),1)]),a(`span`,{class:`chip`,"data-tone":F.hasApiKey?`ok`:`warn`},r((F.isDefault?t(i)(`默认模型 · `):``)+(F.hasApiKey?t(i)(`密钥已保存`):t(i)(`待填写密钥`))),9,_t)]),a(`form`,{id:`modelForm`,class:`stack`,onSubmit:l(Se,[`prevent`]),onInput:s[14]||=e=>t(I).dirty=!0},[a(`fieldset`,null,[a(`legend`,null,r(t(i)(`连接`)),1),a(`div`,vt,[re.value.length>1?(R(),m(`label`,yt,[e(r(t(i)(`同厂商模型`))+` `,1),b(K,{"model-value":F.presetId,"aria-label":t(i)(`同厂商模型`),options:re.value.map(e=>({value:e.id,label:e.label})),onChange:be},null,8,[`model-value`,`aria-label`,`options`])])):f(``,!0),a(`label`,null,[e(r(t(i)(`显示名称`)),1),W(a(`input`,{"onUpdate:modelValue":s[0]||=e=>F.label=e,name:`label`,required:``},null,512),[[u,F.label]])]),a(`label`,null,[e(r(t(i)(`供应商`)),1),W(a(`input`,{"onUpdate:modelValue":s[1]||=e=>F.provider=e,name:`provider`,placeholder:t(i)(`例如 deepseek、openai`)},null,8,bt),[[u,F.provider]])]),a(`label`,null,[e(r(t(i)(`API 地址`)),1),W(a(`input`,{"onUpdate:modelValue":s[2]||=e=>F.baseUrl=e,name:`baseUrl`,type:`url`,required:``},null,512),[[u,F.baseUrl]])]),a(`label`,null,[e(r(t(i)(`模型名称`)),1),W(a(`input`,{"onUpdate:modelValue":s[3]||=e=>F.model=e,name:`model`,required:``},null,512),[[u,F.model]])]),a(`label`,xt,[s[18]||=e(` API Key `,-1),W(a(`input`,{"onUpdate:modelValue":s[4]||=e=>F.apiKey=e,name:`apiKey`,type:`password`,autocomplete:`new-password`,placeholder:F.hasApiKey?t(i)(`已保存,留空则保留`):t(i)(`填写供应商提供的密钥`)},null,8,St),[[u,F.apiKey]])])])]),a(`fieldset`,null,[a(`legend`,null,r(t(i)(`容量与思考`)),1),a(`p`,Ct,r(t(i)(`官方按输入长度分档计价的模型可以在标准和百万之间切换;输出上限默认是官方最大值,思考强度只列出这个模型支持的档位。`)),1),a(`div`,wt,[X.value.length?(R(),m(`label`,Tt,[e(r(t(i)(`上下文容量`))+` `,1),b(K,{name:`contextWindow`,"model-value":F.contextWindow,options:X.value.map(e=>({value:e.contextWindow,label:e.label})),onChange:de},null,8,[`model-value`,`options`])])):(R(),m(`label`,Et,[e(r(t(i)(`上下文容量`)),1),W(a(`input`,{"onUpdate:modelValue":s[5]||=e=>F.contextWindow=e,name:`contextWindow`,type:`number`},null,512),[[u,F.contextWindow,void 0,{number:!0}]])])),a(`label`,null,[e(r(t(i)(`最大输入 Token`)),1),W(a(`input`,{"onUpdate:modelValue":s[6]||=e=>F.maxInputTokens=e,name:`maxInputTokens`,type:`number`},null,512),[[u,F.maxInputTokens,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`最大输出 Token`)),1),W(a(`input`,{"onUpdate:modelValue":s[7]||=e=>F.maxOutputTokens=e,name:`maxOutputTokens`,type:`number`},null,512),[[u,F.maxOutputTokens,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`思考强度`))+` `,1),b(K,{modelValue:F.reasoningEffort,"onUpdate:modelValue":s[8]||=e=>F.reasoningEffort=e,name:`reasoningEffort`,"aria-label":t(i)(`思考强度`),options:ae.value.map(e=>({value:e,label:me(e)}))},null,8,[`modelValue`,`aria-label`,`options`])])])]),a(`details`,Dt,[a(`summary`,null,r(t(i)(`高级参数与模型能力`)),1),a(`div`,Ot,[a(`label`,null,[e(r(t(i)(`随机程度 Temperature`)),1),W(a(`input`,{"onUpdate:modelValue":s[9]||=e=>F.temperature=e,name:`temperature`,type:`number`,step:`0.05`},null,512),[[u,F.temperature,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`采样范围 Top P`)),1),W(a(`input`,{"onUpdate:modelValue":s[10]||=e=>F.topP=e,name:`topP`,type:`number`,step:`0.05`},null,512),[[u,F.topP,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`超时(毫秒)`)),1),W(a(`input`,{"onUpdate:modelValue":s[11]||=e=>F.timeoutMs=e,name:`timeoutMs`,type:`number`},null,512),[[u,F.timeoutMs,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`知识检索模型`)),1),W(a(`input`,{"onUpdate:modelValue":s[12]||=e=>F.embeddingModel=e,name:`embeddingModel`,placeholder:t(i)(`留空跟随对话模型`)},null,8,kt),[[u,F.embeddingModel]])])]),a(`p`,At,r(t(i)(`打开图片理解后,聊天里的图片会先在本机读取,再连同画面交给这个模型。主模型不能看图时,到会话设置另选一个打开了图片理解的视觉兼容模型。`)),1),a(`div`,jt,[(R(!0),m(d,null,y({vision:t(i)(`图片理解`),system:`System Prompt`,json:t(i)(`JSON 输出`),tools:t(i)(`工具调用`),embedding:t(i)(`知识向量`)},(t,n)=>(R(),m(`label`,{key:n,class:`check`},[W(a(`input`,{"onUpdate:modelValue":e=>F[n]=e,name:n,type:`checkbox`},null,8,Mt),[[G,F[n]]]),e(r(t),1)]))),128))])]),A.value?(R(),m(`div`,Nt,r(A.value),1)):f(``,!0),a(`div`,Pt,[a(`button`,{class:`primary`,disabled:T.value},r(T.value?t(i)(`正在保存…`):t(i)(`保存模型`)),9,Ft),a(`small`,It,r(t(I).dirty?t(i)(`有未保存的修改`):t(i)(`已保存的模型才会用来测试和回复`)),1),F.isDefault?f(``,!0):(R(),m(`label`,Lt,[a(`input`,{type:`checkbox`,checked:F.enabled!==!1,onChange:s[13]||=e=>{F.enabled=e.target.checked,t(I).dirty=!0}},null,40,Rt),e(r(t(i)(`启用这个备用模型`)),1)])),F.isDefault?f(``,!0):(R(),m(`button`,{key:1,id:`setDefaultModel`,type:`button`,onClick:xe},r(t(i)(`设为默认模型`)),1)),oe.value?(R(),m(`button`,{key:2,id:`testModel`,type:`button`,disabled:T.value||E.value||t(I).dirty,onClick:we},r(E.value?t(i)(`正在测试…`):t(I).dirty?t(i)(`保存后测试此模型`):t(i)(`测试此模型连接`)),9,zt)):f(``,!0),a(`button`,{id:`deleteModel`,type:`button`,class:`danger push`,onClick:Ce},r(t(i)(`删除模型`)),1)])],32)])):(R(),m(`section`,Bt,[b(L,{title:t(i)(`还没有模型`),text:t(i)(`常见厂商会预填模型参数;OpenRouter 可选 GPT-6 预设,也可用自选模型手动填写模型 ID 和参数。`)},{default:B(()=>[a(`button`,{class:`primary`,onClick:ve},r(t(i)(`+ 新增模型`)),1)]),_:1},8,[`title`,`text`])]))]),b(nt),a(`section`,Vt,[a(`div`,Ht,[a(`div`,null,[a(`span`,Ut,r(t(i)(`TOKEN LEDGER · 累计用量`)),1),a(`h2`,Wt,r(t(i)(`一路聊到现在`)),1),a(`p`,null,r(t(i)(`统计本机账本记录的模型调用;缓存 Token 已包含在输入量里。`)),1)]),a(`div`,Gt,[a(`strong`,null,r($(j.value?.total)),1),a(`span`,null,r(t(i)(`累计 Token`)),1)])]),a(`div`,Kt,[a(`div`,qt,[a(`span`,null,r(t(i)(`输入`)),1),a(`b`,null,r($(j.value?.input)),1)]),a(`div`,Jt,[a(`span`,null,r(t(i)(`输出`)),1),a(`b`,null,r($(j.value?.output)),1)]),a(`div`,Yt,[a(`span`,null,[e(r(t(i)(`缓存命中`))+` `,1),a(`small`,null,r(t(i)(`(输入子项)`)),1)]),a(`b`,null,r($(j.value?.cached)),1)]),a(`div`,Xt,[a(`span`,null,r(t(i)(`调用次数`)),1),a(`b`,null,r($(j.value?.calls)),1)])]),a(`div`,Zt,[j.value?.since?(R(),m(`small`,Qt,[e(r(t(i)(`从 {v} 开始记录 · {v2} 为模型返回用量`,{v:new Date(j.value.since).toLocaleDateString(t(o)()),v2:$(j.value.reportedTokens)}))+` `,1),j.value.estimatedCalls?(R(),m(d,{key:0},[e(r(t(i)(`· {v} 次调用按文本估算`,{v:$(j.value.estimatedCalls)})),1)],64)):f(``,!0)])):N.value?(R(),m(`small`,$t,r(N.value),1)):(R(),m(`small`,en,r(t(i)(`还没有模型调用记录。`)),1)),a(`button`,{type:`button`,class:`usage-refresh`,disabled:M.value,onClick:he},r(M.value?t(i)(`更新中…`):t(i)(`刷新统计 ↻`)),9,tn)])]),b(te,{open:D.value,title:t(i)(`选择模型`),eyebrow:`MODELS`,width:`880px`,onClose:s[17]||=e=>D.value=!1},{default:B(()=>[a(`div`,nn,[a(`p`,rn,r(t(i)(`先选供应商,再挑具体模型;支持搜索。火山方舟 Coding Plan 已预填套餐专用接口与型号参数,请勿换成普通推理 API 地址。`)),1),a(`label`,an,[a(`span`,null,r(t(i)(`搜索模型或供应商`)),1),W(a(`input`,{"onUpdate:modelValue":s[15]||=e=>k.value=e,type:`search`,placeholder:t(i)(`例如:火山方舟、Kimi K3、DeepSeek`)},null,8,on),[[u,k.value]])]),(R(!0),m(d,null,y(J.value,e=>(R(),m(`details`,{key:e.vendor,class:`vendor`,"data-vendor":e.vendor},[a(`summary`,cn,[a(`span`,ln,[a(`b`,null,r(e.vendor),1),a(`small`,null,r(t(i)(`{length} 个预设`,{length:e.models.length})),1)]),s[19]||=a(`span`,{class:`vendor-chevron`,"aria-hidden":`true`},`⌄`,-1)]),a(`div`,un,[(R(!0),m(d,null,y(e.models,e=>(R(),m(`button`,{key:e.id,type:`button`,class:`preset-card`,"data-preset":e.id,onClick:t=>ye(e)},[a(`b`,null,r(e.label),1),a(`small`,null,r(e.model||t(i)(`填写你在 OpenRouter 选择的模型 ID`)),1),a(`small`,null,r(pe(e)),1),a(`small`,null,r(e.summary),1)],8,dn))),128))])],8,sn))),128)),J.value.length?f(``,!0):(R(),m(`p`,fn,r(t(i)(`没有找到匹配的模型预设。`)),1)),Y.value?(R(),m(`button`,{key:1,type:`button`,class:`preset-card custom`,"data-preset":`custom`,onClick:s[16]||=e=>ye(Y.value)},[a(`b`,null,r(Y.value.label),1),a(`small`,null,r(Y.value.summary),1)])):f(``,!0)])]),_:1},8,[`open`,`title`])]))}}),[[`__scopeId`,`data-v-59f18516`]]),mn={class:`switch`},hn=[`name`,`disabled`],gn=A({__name:`Toggle`,props:F({name:{},disabled:{type:Boolean}},{modelValue:{type:Boolean,default:!1},modelModifiers:{}}),emits:[`update:modelValue`],setup(e){let t=w(e,`modelValue`);return(n,r)=>(R(),m(`label`,mn,[W(a(`input`,{"onUpdate:modelValue":r[0]||=e=>t.value=e,type:`checkbox`,name:e.name,disabled:e.disabled},null,8,hn),[[G,t.value]]),a(`span`,null,[S(n.$slots,`default`)])]))}}),_n={class:`runtime-grid`},vn={class:`card-head`},yn={class:`eyebrow`},bn={class:`switches`},xn={class:`save-bar`},Sn={class:`primary`,type:`submit`},Cn={class:`faint`},wn={class:`card numbers`},Tn=[`title`],En=k(A({__name:`Runtime`,setup(e){let n=U(!!I.health.settings.enabled),o=U(!!I.health.settings.demo),s=P(()=>(I.core.sessions||[]).filter(e=>e.enabled&&!e.archived).length),c=P(()=>{let e=I.health.backup;if(!e)return null;if(!e.enabled)return{value:i(`已关闭`),caption:i(`自动备份`),tone:`warn`,title:``};if(e.lastError)return{value:i(`失败`),caption:i(`自动备份出错,稍后重试`),tone:`danger`,title:e.lastError.message};if(!e.latest)return{value:i(`还没有`),caption:i(`自动备份,运行一阵后会做第一份`),tone:``,title:``};let t=Math.max(1,Math.round(e.latest.bytes/1048576));return{value:q(e.latest.at),caption:i(`上次自动备份 · {mb} MB · 已留 {count}/{keep} 份`,{mb:t,count:e.count,keep:e.keep}),tone:``,title:e.latest.name}});async function u(){try{await N({enabled:n.value,demo:o.value}),I.dirty=!1,I.health=await x(),z(i(`已保存并应用`))}catch(e){z(e.message,!0)}}return(e,d)=>(R(),m(`div`,_n,[a(`form`,{id:`runtime`,class:`card`,onSubmit:l(u,[`prevent`]),onChange:d[2]||=e=>t(I).dirty=!0},[a(`div`,vn,[a(`div`,null,[a(`span`,yn,r(t(i)(`运行开关`)),1),a(`h2`,null,r(t(i)(`TA 现在可以说话吗`)),1)])]),a(`div`,bn,[b(gn,{modelValue:n.value,"onUpdate:modelValue":d[0]||=e=>n.value=e,name:`enabled`},{default:B(()=>[a(`b`,null,r(t(i)(`允许参与聊天`)),1),a(`small`,null,r(t(i)(`总开关。关掉后 TA 不看也不回任何会话,心智照常保存。`)),1)]),_:1},8,[`modelValue`]),b(gn,{modelValue:o.value,"onUpdate:modelValue":d[1]||=e=>o.value=e,name:`demo`},{default:B(()=>[a(`b`,null,r(t(i)(`仅模拟运行,不向 QQ 发送`)),1),a(`small`,null,r(t(i)(`模拟模式下,「对话」里可以发模拟消息走完整流程;它们写进模拟会话,不进入真实记忆。`)),1)]),_:1},8,[`modelValue`])]),a(`div`,xn,[a(`button`,Sn,r(t(i)(`保存运行状态`)),1),a(`small`,Cn,r(t(I).dirty?t(i)(`有未保存的修改`):t(i)(`已保存,立刻生效`)),1)])],32),a(`section`,wn,[a(`button`,{onClick:d[3]||=e=>t(T)(`chats`)},[a(`b`,null,r(s.value),1),a(`span`,null,r(t(i)(`参与中的会话`)),1)]),a(`button`,{onClick:d[4]||=e=>t(T)(`system`,`models`)},[a(`b`,null,r(t(I).core.models.length),1),a(`span`,null,r(t(i)(`可用模型`)),1)]),a(`button`,{onClick:d[5]||=e=>t(T)(`system`,`connect`)},[a(`b`,null,r(t(I).health.connection?.online?t(i)(`在线`):t(i)(`离线`)),1),a(`span`,null,r(t(i)(`QQ 连接`)),1)]),c.value?(R(),m(`button`,{key:0,class:H([`stat`,c.value.tone]),title:c.value.title,"data-testid":`backup-status`,type:`button`,onClick:d[6]||=e=>t(T)(`system`,`storage`)},[a(`b`,null,r(c.value.value),1),a(`span`,null,r(c.value.caption),1)],10,Tn)):f(``,!0)])]))}}),[[`__scopeId`,`data-v-1f197523`]]),Dn={class:`card search-profile`},On={class:`eyebrow`},kn={class:`muted`},An={key:0,class:`faint`},jn={class:`check`},Mn=[`disabled`],Nn={class:`search-fields`},Pn=[`disabled`],Fn=[`disabled`,`placeholder`],In=[`disabled`],Ln=[`disabled`],Rn=[`disabled`],zn={class:`faint`},Bn={class:`search-usage`,role:`status`},Vn={class:`chip`},Hn={class:`faint`},Un={class:`faint`},Wn={class:`row`},Gn=[`disabled`],Kn=[`disabled`],qn={key:0,class:`faint`},Jn=[`role`],Yn=k(A({__name:`SearchProfile`,setup(n){let o=[`provider`,`enabled`,`baseUrl`,`timeoutMs`,`maxResults`,`dailyLimit`],s=_({provider:`tavily`,enabled:!0,baseUrl:``,apiKey:``,timeoutMs:2e4,maxResults:5,dailyLimit:12}),c=U(null),d=U(!1),p=U(!1),h=U(``),g=U(!1),y=new Map,x=`tavily`,S=P(()=>p.value&&(!!s.apiKey.trim()||o.some(e=>s[e]!==c.value?.[e]))),w=e=>e===`brave`?`Brave`:`Tavily`,T=P(()=>c.value?.dailyLimit===0?i(`不限`):c.value?.dailyLimit??12);C(S,e=>{I.dirty=e}),C(()=>[I.tick,I.pulse],()=>{p.value&&!d.value&&I.page===`system`&&I.sub===`search`&&E().catch(()=>{})});async function E(){c.value=await O(`/mind/time/search`)}async function k(){try{await E(),Object.assign(s,c.value,{apiKey:``}),x=s.provider,p.value=!0}catch(e){z(e.message,!0)}}async function A(){if(d.value)return;d.value=!0;let e=s.provider;y.set(x,{...s,provider:x});try{Object.assign(s,y.get(e)||{...await O(`/mind/time/search?provider=`+e),apiKey:``}),x=e,h.value=``}catch(e){s.provider=x,z(e.message,!0)}finally{d.value=!1}}function j(){return Object.fromEntries([...o,`apiKey`].map(e=>[e,s[e]]))}async function M(){if(!d.value){d.value=!0;try{let e=await O(`/mind/time/search`,`PATCH`,j());c.value=e,Object.assign(s,e,{apiKey:``}),y.set(s.provider,{...s}),x=s.provider,z(i(`搜索配置已保存并生效`))}catch(e){z(e.message,!0)}finally{d.value=!1}}}async function N(){if(!d.value){d.value=!0,h.value=``,g.value=!1;try{let e=await O(`/mind/time/search/test`,`POST`,j()),t={source:e.profile.mode===`web`?i(`{provider} 联网检索`,{provider:w(e.profile.provider)}):e.profile.materialLabel,count:e.results.length};h.value=S.value?i(`{source}测试通过,返回 {count} 条资料。测试不占用每日查询额度;当前填写的配置尚未保存。`,t):i(`{source}测试通过,返回 {count} 条资料。测试不占用每日查询额度。`,t)}catch(e){g.value=!0,h.value=i(`{error}。本次测试不占用每日查询额度。`,{error:e.message})}finally{try{await E()}catch{}d.value=!1}}}return v(k),D(()=>{I.dirty=!1}),(n,o)=>(R(),m(`section`,Dn,[a(`span`,On,r(t(i)(`SEARCH · 独立搜索`)),1),a(`h2`,null,r(t(i)(`接触世界的资料`)),1),a(`p`,kn,r(t(i)(`配置后优先联网检索。未配置密钥或关闭独立搜索时,使用当前模型整理已有知识。`)),1),p.value?(R(),m(`p`,An,r(t(i)(`当前生效:{v}。表单修改保存后生效。`,{v:c.value.mode===`web`?t(i)(`{provider} · 联网检索资料`,{provider:w(c.value.provider)}):c.value.materialLabel})),1)):f(``,!0),p.value?(R(),m(`form`,{key:1,onSubmit:l(M,[`prevent`])},[a(`label`,jn,[W(a(`input`,{"onUpdate:modelValue":o[0]||=e=>s.enabled=e,type:`checkbox`,disabled:d.value},null,8,Mn),[[G,s.enabled]]),e(r(t(i)(`启用独立搜索`)),1)]),a(`div`,Nn,[a(`label`,null,[e(r(t(i)(`提供方`)),1),b(K,{modelValue:s.provider,"onUpdate:modelValue":o[1]||=e=>s.provider=e,"aria-label":t(i)(`搜索提供方`),disabled:d.value,options:[{value:`tavily`,label:`Tavily`},{value:`brave`,label:`Brave`}],onChange:A},null,8,[`modelValue`,`aria-label`,`disabled`])]),a(`label`,null,[e(r(t(i)(`搜索地址`)),1),W(a(`input`,{"onUpdate:modelValue":o[2]||=e=>s.baseUrl=e,type:`url`,required:``,name:`searchUrl`,disabled:d.value},null,8,Pn),[[u,s.baseUrl]])]),a(`label`,null,[e(r(t(i)(`独立密钥`)),1),W(a(`input`,{"onUpdate:modelValue":o[3]||=e=>s.apiKey=e,type:`password`,autocomplete:`new-password`,name:`searchKey`,disabled:d.value,placeholder:s.hasApiKey?t(i)(`已保存,留空保留`):t(i)(`填写该提供方的搜索密钥`)},null,8,Fn),[[u,s.apiKey]])]),a(`label`,null,[e(r(t(i)(`超时(毫秒)`)),1),W(a(`input`,{"onUpdate:modelValue":o[4]||=e=>s.timeoutMs=e,type:`number`,min:`1000`,max:`120000`,disabled:d.value},null,8,In),[[u,s.timeoutMs,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`每次结果数`)),1),W(a(`input`,{"onUpdate:modelValue":o[5]||=e=>s.maxResults=e,type:`number`,min:`1`,max:`20`,disabled:d.value},null,8,Ln),[[u,s.maxResults,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`每日查询上限`)),1),W(a(`input`,{"onUpdate:modelValue":o[6]||=e=>s.dailyLimit=e,name:`searchDailyLimit`,type:`number`,min:`0`,max:`100000`,disabled:d.value},null,8,Rn),[[u,s.dailyLimit,void 0,{number:!0}]]),a(`small`,zn,r(t(i)(`可手动调整;0 表示不限。保存后立即生效。`)),1)])]),a(`div`,Bn,[a(`span`,Vn,r(t(i)(`今日有效查询 {v} / {limitLabel}`,{v:c.value.used||0,limitLabel:T.value})),1),a(`small`,Hn,r(t(i)(`{v} 次进行中 · {v2} 次失败 · {v3} 次测试`,{v:c.value.usage?.running||0,v2:c.value.usage?.failed||0,v3:c.value.usage?.diagnostics||0})),1)]),a(`p`,Un,r(t(i)(`测试当前填写的配置,不会自动保存。失败与连接测试不占本地每日额度;提供方的账户额度由提供方管理。`)),1),a(`div`,Wn,[a(`button`,{class:`primary`,disabled:d.value},r(t(i)(`保存搜索`)),9,Gn),a(`button`,{type:`button`,disabled:d.value,onClick:N},r(d.value?t(i)(`处理中…`):t(i)(`测试连接`)),9,Kn),S.value?(R(),m(`span`,qn,r(t(i)(`有尚未保存的修改`)),1)):f(``,!0)]),h.value?(R(),m(`p`,{key:0,role:g.value?`alert`:`status`,class:H([`search-result`,{failed:g.value}])},r(h.value),11,Jn)):f(``,!0)],32)):f(``,!0)]))}}),[[`__scopeId`,`data-v-e874d8b6`]]),Xn={class:`storage-page`},Zn={class:`card storage-overview`},Qn={class:`eyebrow`},$n={key:0,class:`storage-metrics`},er={key:0,class:`storage-error`,role:`alert`},tr={key:1,class:`storage-grid`},nr={class:`card-head`},rr={class:`eyebrow`},ir={class:`storage-check`},ar={class:`storage-fields`},or=[`aria-label`],sr=[`aria-label`],cr=[`aria-label`],lr={class:`storage-note`},ur={key:0,class:`storage-note`},dr={key:1,class:`storage-error`},fr=[`disabled`],pr={class:`card storage-list`},mr={class:`card-head`},hr={class:`eyebrow`},gr={class:`storage-note`},_r={class:`storage-actions`},vr=[`disabled`],yr=[`disabled`],br=[`disabled`],xr={class:`storage-scroll`},Sr=[`checked`,`disabled`,`aria-label`,`onChange`],Cr={class:`storage-item`},wr={key:0,class:`storage-error`,role:`alert`},Tr={class:`storage-footer`},Er=[`disabled`],Dr=k(A({__name:`BackupManager`,setup(s){let c=U(null),p=U({enabled:!0,time:`04:30`,retainDays:14,keepFull:2}),h=U([]),g=U(!1),_=U(``),b=U([]),x=!0,S=e=>`${e.scope}:${e.name}`,C=P(()=>c.value?.entries.filter(e=>h.value.includes(S(e)))||[]),w=P(()=>C.value.reduce((e,t)=>e+t.bytes,0)),T=e=>e===0?`0 B`:e>=1073741824?`${(e/1073741824).toFixed(2)} GiB`:`${Math.max(.01,e/1048576).toFixed(2)} MiB`,E=e=>new Date(e).toLocaleString(o()),D=e=>({auto:i(`自动备份`),full:i(`完整备份`),migration:i(`迁移快照`),legacy:i(`旧版本备份`),snapshot:i(`升级前快照`)})[e];async function k(e=!1){try{_.value=``;let t=await O(`/storage/cleanup`);if(!x)return;if(c.value=t,e)p.value={...c.value.policy},h.value=[],I.dirty=!1;else{let e=new Set(c.value.entries.filter(e=>!e.protected).map(S));h.value=h.value.filter(t=>e.has(t))}}catch(e){x&&(_.value=e.message)}}function A(){I.dirty=!0}function j(e){let t=S(e);h.value=h.value.includes(t)?h.value.filter(e=>e!==t):[...h.value,t]}function N(){h.value=(c.value?.plan.items||[]).map(S)}function F(){h.value=(c.value?.entries||[]).filter(e=>e.kind===`full`&&!e.protected).map(S)}async function L(){if(!g.value){g.value=!0;try{let e=await O(`/storage/cleanup`,`PATCH`,{enabled:p.value.enabled,time:p.value.time,retainDays:Number(p.value.retainDays),keepFull:Number(p.value.keepFull)});c.value=e,p.value={...e.policy},I.dirty=!1,z(i(`备份整理规则已保存`))}catch(e){z(e.message,!0)}finally{g.value=!1}}}async function B(){if(!C.value.length||g.value)return;let e=C.value.map(({scope:e,name:t,bytes:n,modified:r})=>({scope:e,name:t,bytes:n,modified:r}));if(await n(i(`将清理选中的 {count} 份备份,释放约 {size}。这些历史恢复点清理后无法从本机找回。`,{count:e.length,size:T(w.value)}),{title:i(`清理所选备份`),confirmText:i(`清理所选`),danger:!0})){g.value=!0;try{let t=await O(`/storage/cleanup/manual`,`POST`,{items:e});b.value=t.failures||[];let n=new Set((t.failures||[]).map(e=>`${e.scope}:${e.name}`));h.value=h.value.filter(e=>n.has(e)),await k(),z(i(`已清理 {count} 份备份,释放 {size}`,{count:t.removed.length,size:T(t.bytes)})+(b.value.length?i(`;部分未清理,详情见列表`):``),b.value.length>0)}catch(e){z(e.message,!0),await k()}finally{g.value=!1}}}return v(()=>k(!0)),M(()=>{x=!1}),(n,o)=>(R(),m(`div`,Xn,[a(`section`,Zn,[a(`span`,Qn,r(t(i)(`DATA · 数据与备份`)),1),a(`h2`,null,r(t(i)(`留住重要的,整理重复的。`)),1),a(`p`,null,r(t(i)(`这里管理历史备份。聊天、记忆和作品仍在正在运行的数据库里,不会被列入清理。`)),1),c.value?(R(),m(`div`,$n,[a(`div`,null,[a(`b`,null,r(T(c.value.totalBytes)),1),a(`span`,null,r(t(i)(`可管理的历史备份`)),1)]),a(`div`,null,[a(`b`,null,r(c.value.entries.length),1),a(`span`,null,r(t(i)(`份数据库副本`)),1)]),a(`div`,null,[a(`b`,null,r(T(c.value.plan.bytes)),1),a(`span`,null,r(t(i)(`按当前规则可定时整理`)),1)])])):f(``,!0)]),_.value?(R(),m(`p`,er,r(_.value),1)):f(``,!0),c.value?(R(),m(`div`,tr,[a(`form`,{class:`card storage-policy`,onSubmit:l(L,[`prevent`])},[a(`div`,nr,[a(`div`,null,[a(`span`,rr,r(t(i)(`定时整理`)),1),a(`h2`,null,r(t(i)(`每天给备份留一点空间`)),1)])]),a(`label`,ir,[W(a(`input`,{"onUpdate:modelValue":o[0]||=e=>p.value.enabled=e,type:`checkbox`,onChange:A},null,544),[[G,p.value.enabled]]),e(r(t(i)(`启用定时整理`)),1)]),a(`div`,ar,[a(`label`,null,[e(r(t(i)(`每天执行时间`)),1),W(a(`input`,{"onUpdate:modelValue":o[1]||=e=>p.value.time=e,type:`text`,inputmode:`numeric`,maxlength:`5`,placeholder:`04:30`,"aria-label":t(i)(`每天执行时间`),onInput:A},null,40,or),[[u,p.value.time]])]),a(`label`,null,[e(r(t(i)(`完整备份保留天数`)),1),W(a(`input`,{"onUpdate:modelValue":o[2]||=e=>p.value.retainDays=e,type:`number`,min:`1`,max:`3650`,"aria-label":t(i)(`完整备份保留天数`),onInput:A},null,40,sr),[[u,p.value.retainDays,void 0,{number:!0}]])]),a(`label`,null,[e(r(t(i)(`完整备份最多保留份数`)),1),W(a(`input`,{"onUpdate:modelValue":o[3]||=e=>p.value.keepFull=e,type:`number`,min:`0`,max:`100`,"aria-label":t(i)(`完整备份最多保留份数`),onInput:A},null,40,cr),[[u,p.value.keepFull,void 0,{number:!0}]])])]),a(`p`,lr,r(t(i)(`时间按服务器本地时区 {zone}。完整备份超过保留天数或数量上限便会定时整理;数量填 0 表示不保留完整备份。手动清理可选择完整备份和迁移快照,执行前会校验同一实例的近期自动恢复点。`,{zone:c.value.serverTimeZone})),1),c.value.last?(R(),m(`p`,ur,r(c.value.last.error&&c.value.last.removed===0?t(i)(`上次整理未执行:{error}`,{error:c.value.last.error}):t(i)(`上次整理:{time} · {count} 份 · {size}`,{time:E(c.value.last.at),count:c.value.last.removed,size:T(c.value.last.bytes)})),1)):f(``,!0),c.value.last?.error&&c.value.last.removed>0?(R(),m(`p`,dr,r(c.value.last.error),1)):f(``,!0),a(`button`,{class:`primary`,type:`submit`,disabled:g.value},r(g.value?t(i)(`处理中…`):t(i)(`保存整理规则`)),9,fr)],32),a(`section`,pr,[a(`div`,mr,[a(`div`,null,[a(`span`,hr,r(t(i)(`手动整理`)),1),a(`h2`,null,r(t(i)(`挑选历史恢复点`)),1)])]),a(`p`,gr,r(t(i)(`自动备份已有自己的轮换规则。迁移、旧版本和升级前快照只由你手动挑选;清理前会再次核对文件和恢复点。`)),1),a(`div`,_r,[a(`button`,{type:`button`,disabled:g.value||!c.value.plan.items.length,onClick:N},r(t(i)(`选中规则建议`)),9,vr),a(`button`,{type:`button`,disabled:g.value,onClick:F},r(t(i)(`选中完整备份`)),9,yr),a(`button`,{type:`button`,disabled:g.value||!h.value.length,onClick:o[4]||=e=>h.value=[]},r(t(i)(`取消选择`)),9,br)]),a(`div`,xr,[(R(!0),m(d,null,y(c.value.entries,e=>(R(),m(`label`,{key:S(e),class:H([`storage-row`,{protected:e.protected}])},[a(`input`,{type:`checkbox`,checked:h.value.includes(S(e)),disabled:g.value||e.protected||c.value.running,"aria-label":e.name,onChange:t=>j(e)},null,40,Sr),a(`span`,Cr,[a(`strong`,null,r(e.name),1),a(`small`,null,r(D(e.kind))+` · `+r(E(e.at))+` · `+r(e.manifest?t(i)(`有校验清单`):t(i)(`旧备份无清单`))+r(e.protected?` · `+(e.protectionReason||t(i)(`保留恢复点`)):``),1)]),a(`b`,null,r(T(e.bytes)),1)],2))),128))]),b.value.length?(R(),m(`div`,wr,[a(`p`,null,r(t(i)(`部分备份未清理,请刷新列表后重试`)),1),(R(!0),m(d,null,y(b.value,e=>(R(),m(`p`,{key:e.name},r(e.name)+` · `+r(e.error),1))),128))])):f(``,!0),a(`div`,Tr,[a(`span`,null,r(t(i)(`已选 {count} 份 · 约 {size}`,{count:C.value.length,size:T(w.value)})),1),a(`button`,{class:`primary`,type:`button`,disabled:g.value||c.value.running||!C.value.length,onClick:B},r(t(i)(`清理所选`)),9,Er)])])])):f(``,!0)]))}}),[[`__scopeId`,`data-v-79fb7512`]]),Or={class:`card password-settings`},kr={class:`row`},Ar=[`disabled`],jr=k(A({__name:`PasswordSettings`,setup(n){let o=U(``),s=U(``),c=U(``),d=U(!1);async function f(){if(!d.value){if(s.value!==c.value){z(i(`两次输入的密码不一致`),!0);return}d.value=!0;try{await g(`password`,{currentPassword:o.value,password:s.value}),o.value=s.value=c.value=``,z(i(`管理密码已更新`))}catch(e){z(e.message,!0)}finally{d.value=!1}}}return(n,p)=>(R(),m(`section`,Or,[a(`h2`,null,r(t(i)(`管理密码`)),1),a(`p`,null,r(t(i)(`一个密码管理这个小世界。修改后,其他浏览器需要重新登录。`)),1),a(`form`,{onSubmit:l(f,[`prevent`])},[a(`label`,null,[e(r(t(i)(`当前密码`)),1),W(a(`input`,{"onUpdate:modelValue":p[0]||=e=>o.value=e,name:`currentPassword`,type:`password`,autocomplete:`current-password`,required:``},null,512),[[u,o.value]])]),a(`label`,null,[e(r(t(i)(`新密码`)),1),W(a(`input`,{"onUpdate:modelValue":p[1]||=e=>s.value=e,name:`newPassword`,type:`password`,autocomplete:`new-password`,minlength:`8`,maxlength:`256`,required:``},null,512),[[u,s.value]])]),a(`label`,null,[e(r(t(i)(`确认密码`)),1),W(a(`input`,{"onUpdate:modelValue":p[2]||=e=>c.value=e,name:`confirmNewPassword`,type:`password`,autocomplete:`new-password`,minlength:`8`,maxlength:`256`,required:``},null,512),[[u,c.value]])]),a(`div`,kr,[a(`button`,{class:`primary`,disabled:d.value},r(t(i)(`修改密码`)),9,Ar),a(`button`,{type:`button`,onClick:p[3]||=(...e)=>t(V)&&t(V)(...e)},r(t(i)(`退出登录`)),1)])],32)]))}}),[[`__scopeId`,`data-v-36cd9f70`]]),Mr={class:`page system`},Nr={class:`system-intro`},Pr={class:`eyebrow`},Fr={class:`system-status`},Ir=k(A({__name:`SystemPage`,setup(o){let s=[`connect`,`models`,`search`,`runtime`,`storage`],c=U(s.includes(I.sub)?I.sub:`connect`);async function l(e){e!==c.value&&(!I.dirty||await n(i(`当前设置还没保存,离开这个分区吗?`),{title:i(`未保存的修改`),confirmText:i(`放弃修改`),cancelText:i(`继续编辑`)}))&&(I.dirty=!1,c.value=e)}return C(c,e=>j(e)),C(()=>I.sub,e=>{s.includes(e)&&(c.value=e)}),(n,o)=>(R(),m(`div`,Mr,[a(`header`,Nr,[a(`div`,null,[a(`span`,Pr,r(t(i)(`THE WORLD AROUND HER · 系统`)),1),a(`h1`,null,[e(r(t(i)(`让她与世界,`)),1),a(`em`,null,r(t(i)(`温柔相连。`)),1)]),a(`p`,null,r(t(i)(`连接、模型与运行方式都在这里。每一处改变,都可以看见结果。`)),1)]),a(`span`,Fr,[a(`i`,{class:H({off:!t(I).health.connection?.online})},null,2),e(` `+r(t(I).health.connection?.online?t(i)(`正在与 QQ 相连`):t(i)(`等待 QQ 连接`)),1)])]),b(J,{"model-value":c.value,"onUpdate:modelValue":l,label:t(i)(`系统的分区`),items:[{key:`connect`,label:t(i)(`连接 QQ`)},{key:`models`,label:t(i)(`模型库`)},{key:`search`,label:t(i)(`独立搜索`)},{key:`runtime`,label:t(i)(`运行开关`)},{key:`storage`,label:t(i)(`数据与备份`)}]},null,8,[`model-value`,`label`,`items`]),c.value===`connect`?(R(),p(Re,{key:0,onOpen:l})):c.value===`models`?(R(),p(pn,{key:1})):c.value===`search`?(R(),p(Yn,{key:2})):c.value===`runtime`?(R(),m(d,{key:3},[b(En),b(jr)],64)):(R(),p(Dr,{key:4}))]))}}),[[`__scopeId`,`data-v-0f629207`]]);export{Ir as default};
|