mslxdff 0.1.64 → 0.1.66
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/mslxdff.js +3 -3122
- package/package.json +1 -1
- package/src/chat/prompt.js +7 -2
- package/src/chat/repl.js +8 -4
- package/src/chat/upstream.js +271 -36
- package/src/cli/bootstrap.js +408 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/status.js +332 -0
- package/src/cli/util.js +24 -0
- package/src/models.js +15 -5
- package/src/providers/base.js +159 -0
- package/src/providers/generic.js +26 -166
- package/src/providers/keyring.js +19 -1
- package/src/providers/openrouter.js +32 -162
- package/src/providers/workbuddy.js +200 -131
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/index.js +1 -239
- package/src/state/facade.js +57 -0
- package/src/state/memory.js +180 -0
- package/src/state/persist.js +42 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +254 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +202 -0
- package/src/state.js +1 -711
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
|
|
2
|
+
|
|
3
|
+
export const WORKBUDDY_DEFAULT_BASE_URL = "https://copilot.tencent.com";
|
|
4
|
+
|
|
5
|
+
export function normalizeBaseUrl(url) {
|
|
6
|
+
const s = String(url || "").trim();
|
|
7
|
+
if (!s) return "";
|
|
8
|
+
return s.replace(/\/+$/, "");
|
|
9
|
+
}
|
|
10
|
+
export function normalizeAuths(list) {
|
|
11
|
+
if (!Array.isArray(list)) return [];
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const a of list) {
|
|
14
|
+
if (!a || typeof a !== "object") continue;
|
|
15
|
+
const uid = String(a.uid || a.userId || "").trim();
|
|
16
|
+
if (!uid) continue;
|
|
17
|
+
out.push({
|
|
18
|
+
uid,
|
|
19
|
+
domain: String(a.domain || "www.codebuddy.cn").trim() || "www.codebuddy.cn",
|
|
20
|
+
enterpriseId: String(a.enterpriseId || a.enterprise_id || "").trim(),
|
|
21
|
+
refreshToken: String(a.refreshToken || a.refresh_token || "").trim(),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function normalizeAllowedModel(model, providerId) {
|
|
27
|
+
let s = String(model || "").trim();
|
|
28
|
+
if (!s) return "";
|
|
29
|
+
const idx = s.indexOf("/");
|
|
30
|
+
if (idx > 0) {
|
|
31
|
+
const head = s.slice(0, idx).toLowerCase();
|
|
32
|
+
if (head === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
export function normalizeEndpointPath(v) {
|
|
37
|
+
const s = String(v || "").trim();
|
|
38
|
+
if (!s) return "";
|
|
39
|
+
return s.startsWith("/") ? s : `/${s}`;
|
|
40
|
+
}
|
|
41
|
+
export function defaultModelsPath(id) {
|
|
42
|
+
if (String(id).toLowerCase() === "workbuddy") return "/console/enterprises/personal/models";
|
|
43
|
+
return "/models";
|
|
44
|
+
}
|
|
45
|
+
export function defaultChatPath(id) {
|
|
46
|
+
if (String(id).toLowerCase() === "workbuddy") return "/v2/chat/completions";
|
|
47
|
+
return "/chat/completions";
|
|
48
|
+
}
|
|
49
|
+
export function loadProviderModelsPath(id, { file = defaultStateFile() } = {}) {
|
|
50
|
+
const cfg = loadProviderConfigs({ file })[id];
|
|
51
|
+
if (cfg && typeof cfg.modelsPath === "string" && cfg.modelsPath.trim()) return normalizeEndpointPath(cfg.modelsPath);
|
|
52
|
+
return defaultModelsPath(id);
|
|
53
|
+
}
|
|
54
|
+
export function loadProviderChatPath(id, { file = defaultStateFile() } = {}) {
|
|
55
|
+
const cfg = loadProviderConfigs({ file })[id];
|
|
56
|
+
if (cfg && typeof cfg.chatPath === "string" && cfg.chatPath.trim()) return normalizeEndpointPath(cfg.chatPath);
|
|
57
|
+
return defaultChatPath(id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
|
|
61
|
+
export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
|
|
62
|
+
export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
|
|
63
|
+
|
|
64
|
+
export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
|
|
65
|
+
const env = (process.env[providerKeyEnv(id)] || "").trim();
|
|
66
|
+
if (env) return [env];
|
|
67
|
+
const configs = readState(file).providerConfigs;
|
|
68
|
+
const cfg = configs && typeof configs === "object" ? configs[id] : undefined;
|
|
69
|
+
if (cfg && typeof cfg === "object" && Array.isArray(cfg.keys)) {
|
|
70
|
+
const list = [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))];
|
|
71
|
+
if (list.length) return list;
|
|
72
|
+
}
|
|
73
|
+
const keys = readState(file).providerKeys;
|
|
74
|
+
const v = keys && typeof keys === "object" ? keys[id] : undefined;
|
|
75
|
+
if (typeof v === "string") return v.trim() ? [v.trim()] : [];
|
|
76
|
+
if (Array.isArray(v)) return [...new Set(v.filter((x) => typeof x === "string" && x.trim().length))];
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadProviderKey(id, opts = {}) {
|
|
81
|
+
return loadProviderKeys(id, opts)[0] || "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
|
|
85
|
+
const keys = { ...(readState(file).providerKeys || {}) };
|
|
86
|
+
const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
87
|
+
if (clean.length) keys[id] = clean;
|
|
88
|
+
else delete keys[id];
|
|
89
|
+
writeStateImmediate(file, { providerKeys: keys });
|
|
90
|
+
return clean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function saveProviderKey(id, key, opts = {}) {
|
|
94
|
+
const list = [...loadProviderKeys(id, opts)];
|
|
95
|
+
const clean = String(key || "").trim();
|
|
96
|
+
if (clean && !list.includes(clean)) list.push(clean);
|
|
97
|
+
return saveProviderKeys(id, clean ? list : [], opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function addProviderKey(id, key, opts = {}) {
|
|
101
|
+
return saveProviderKey(id, key, opts);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function removeProviderKey(id, key, opts = {}) {
|
|
105
|
+
return removeProviderKeys(id, [key], opts);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function removeProviderKeys(id, targets = [], opts = {}) {
|
|
109
|
+
const set = new Set((Array.isArray(targets) ? targets : [targets]).map((k) => String(k || "").trim()).filter(Boolean));
|
|
110
|
+
const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
|
|
111
|
+
return saveProviderKeys(id, list, opts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
|
|
115
|
+
const env = process.env[providerShareEnv(id)] || "";
|
|
116
|
+
if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
|
|
117
|
+
const map = readState(file).providerShareKeys;
|
|
118
|
+
return !!(map && typeof map === "object" && map[id]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
|
|
122
|
+
const map = { ...(readState(file).providerShareKeys || {}) };
|
|
123
|
+
if (on) map[id] = true;
|
|
124
|
+
else delete map[id];
|
|
125
|
+
writeStateImmediate(file, { providerShareKeys: map });
|
|
126
|
+
return !!on;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
|
|
130
|
+
const v = readState(file).providerConfigs;
|
|
131
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
|
|
136
|
+
const envUrl = (process.env[providerBaseUrlEnv(id)] || "").trim();
|
|
137
|
+
const envKeys = loadProviderKeys(id, { file });
|
|
138
|
+
if (envUrl || (process.env[providerKeyEnv(id)] || "").trim()) {
|
|
139
|
+
const baseUrl = envUrl || loadProviderConfigs({ file })[id]?.baseUrl || (String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "");
|
|
140
|
+
const cfg = loadProviderConfigs({ file })[id];
|
|
141
|
+
const allowedModels = cfg && Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
142
|
+
const auths = cfg && Array.isArray(cfg.auths) ? normalizeAuths(cfg.auths) : [];
|
|
143
|
+
const modelsPath = cfg && typeof cfg.modelsPath === "string" && cfg.modelsPath.trim() ? normalizeEndpointPath(cfg.modelsPath) : defaultModelsPath(id);
|
|
144
|
+
const chatPath = cfg && typeof cfg.chatPath === "string" && cfg.chatPath.trim() ? normalizeEndpointPath(cfg.chatPath) : defaultChatPath(id);
|
|
145
|
+
if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels, modelsPath, chatPath };
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const configs = loadProviderConfigs({ file });
|
|
149
|
+
const cfg = configs[id];
|
|
150
|
+
if (cfg && typeof cfg === "object") {
|
|
151
|
+
return {
|
|
152
|
+
baseUrl: normalizeBaseUrl(cfg.baseUrl || (String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "")),
|
|
153
|
+
keys: Array.isArray(cfg.keys) ? [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))] : [],
|
|
154
|
+
auths: normalizeAuths(cfg.auths),
|
|
155
|
+
allowedModels: Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [],
|
|
156
|
+
modelsPath: typeof cfg.modelsPath === "string" && cfg.modelsPath.trim() ? normalizeEndpointPath(cfg.modelsPath) : defaultModelsPath(id),
|
|
157
|
+
chatPath: typeof cfg.chatPath === "string" && cfg.chatPath.trim() ? normalizeEndpointPath(cfg.chatPath) : defaultChatPath(id),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const keys = loadProviderKeys(id, { file });
|
|
161
|
+
if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [], modelsPath: defaultModelsPath(id), chatPath: defaultChatPath(id) };
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function loadProviderBaseUrl(id, opts = {}) {
|
|
166
|
+
const env = (process.env[providerBaseUrlEnv(id)] || "").trim();
|
|
167
|
+
if (env) return normalizeBaseUrl(env);
|
|
168
|
+
const cfg = loadProviderConfigs(opts)[id];
|
|
169
|
+
if (cfg && typeof cfg.baseUrl === "string" && normalizeBaseUrl(cfg.baseUrl)) return normalizeBaseUrl(cfg.baseUrl);
|
|
170
|
+
if (String(id || "").toLowerCase() === "workbuddy") return WORKBUDDY_DEFAULT_BASE_URL;
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function loadProviderAuths(id, { file = defaultStateFile() } = {}) {
|
|
175
|
+
const cfg = loadProviderConfigs({ file })[id];
|
|
176
|
+
if (cfg && Array.isArray(cfg.auths)) return normalizeAuths(cfg.auths);
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
export function saveProviderAuths(id, list, { file = defaultStateFile() } = {}) {
|
|
180
|
+
const clean = normalizeAuths(list);
|
|
181
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
182
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
183
|
+
const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
|
|
184
|
+
const keys = Array.isArray(cur.keys) ? [...new Set(cur.keys.filter((x) => typeof x === "string" && x.trim().length))] : loadProviderKeys(id, { file });
|
|
185
|
+
const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
186
|
+
const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
|
|
187
|
+
const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
|
|
188
|
+
if (!baseUrl && !keys.length && !clean.length && !allowedModels.length && !modelsPath && !chatPath) {
|
|
189
|
+
delete configs[id];
|
|
190
|
+
} else {
|
|
191
|
+
configs[id] = { baseUrl, keys };
|
|
192
|
+
if (clean.length) configs[id].auths = clean;
|
|
193
|
+
if (allowedModels.length) configs[id].allowedModels = allowedModels;
|
|
194
|
+
if (modelsPath) configs[id].modelsPath = modelsPath;
|
|
195
|
+
if (chatPath) configs[id].chatPath = chatPath;
|
|
196
|
+
}
|
|
197
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
198
|
+
return clean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function saveProviderBaseUrl(id, baseUrl, { file = defaultStateFile() } = {}) {
|
|
202
|
+
const clean = normalizeBaseUrl(baseUrl);
|
|
203
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
204
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
205
|
+
const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
|
|
206
|
+
const auths = normalizeAuths(cur.auths);
|
|
207
|
+
const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
208
|
+
const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
|
|
209
|
+
const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
|
|
210
|
+
if (!clean && !keys.length && !auths.length && !allowedModels.length && !modelsPath && !chatPath) {
|
|
211
|
+
delete configs[id];
|
|
212
|
+
} else {
|
|
213
|
+
configs[id] = { baseUrl: clean, keys };
|
|
214
|
+
if (auths.length) configs[id].auths = auths;
|
|
215
|
+
if (allowedModels.length) configs[id].allowedModels = allowedModels;
|
|
216
|
+
if (modelsPath) configs[id].modelsPath = modelsPath;
|
|
217
|
+
if (chatPath) configs[id].chatPath = chatPath;
|
|
218
|
+
}
|
|
219
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
220
|
+
return clean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels, modelsPath, chatPath }, { file = defaultStateFile() } = {}) {
|
|
224
|
+
const cleanUrl = normalizeBaseUrl(baseUrl);
|
|
225
|
+
const cleanKeys = [...new Set((Array.isArray(keys) ? keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
226
|
+
const cleanAuths = auths === undefined ? undefined : normalizeAuths(auths);
|
|
227
|
+
const cleanAllowed = [...new Set((Array.isArray(allowedModels) ? allowedModels : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
|
|
228
|
+
const cleanModelsPath = modelsPath === undefined ? undefined : (String(modelsPath).trim() ? normalizeEndpointPath(modelsPath) : "");
|
|
229
|
+
const cleanChatPath = chatPath === undefined ? undefined : (String(chatPath).trim() ? normalizeEndpointPath(chatPath) : "");
|
|
230
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
231
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
232
|
+
const finalAllowed = allowedModels === undefined ? (Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : []) : cleanAllowed;
|
|
233
|
+
const finalAuths = cleanAuths === undefined ? normalizeAuths(cur.auths) : cleanAuths;
|
|
234
|
+
const finalModelsPath = cleanModelsPath === undefined ? (typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "") : cleanModelsPath;
|
|
235
|
+
const finalChatPath = cleanChatPath === undefined ? (typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "") : cleanChatPath;
|
|
236
|
+
if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length && !finalModelsPath && !finalChatPath) {
|
|
237
|
+
delete configs[id];
|
|
238
|
+
} else {
|
|
239
|
+
configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
|
|
240
|
+
if (finalAuths.length) configs[id].auths = finalAuths;
|
|
241
|
+
if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
|
|
242
|
+
if (finalModelsPath) configs[id].modelsPath = finalModelsPath;
|
|
243
|
+
if (finalChatPath) configs[id].chatPath = finalChatPath;
|
|
244
|
+
}
|
|
245
|
+
const oldKeys = readState(file).providerKeys;
|
|
246
|
+
if (oldKeys && typeof oldKeys === "object" && oldKeys[id] !== undefined) {
|
|
247
|
+
const nextKeys = { ...oldKeys };
|
|
248
|
+
delete nextKeys[id];
|
|
249
|
+
writeStateImmediate(file, { providerKeys: nextKeys, providerConfigs: configs });
|
|
250
|
+
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
|
|
251
|
+
}
|
|
252
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
253
|
+
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
|
|
254
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { defaultStateFile, tokenFile, generateToken, readState, writeStateImmediate, getEntry } from "../store.js";
|
|
4
|
+
|
|
5
|
+
function syncTokenFile(token, file) {
|
|
6
|
+
try {
|
|
7
|
+
const tf = tokenFile(file);
|
|
8
|
+
mkdirSync(dirname(tf), { recursive: true });
|
|
9
|
+
writeFileSync(tf, String(token || "").trim() + "\n", "utf8");
|
|
10
|
+
} catch {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function loadToken({ file = defaultStateFile() } = {}) {
|
|
14
|
+
const state = readState(file);
|
|
15
|
+
if (typeof state.token === "string" && state.token.length > 0) {
|
|
16
|
+
syncTokenFile(state.token, file);
|
|
17
|
+
return { token: state.token, created: false };
|
|
18
|
+
}
|
|
19
|
+
if (existsSync(file)) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
if (typeof raw.token === "string" && raw.token.length > 0) {
|
|
23
|
+
syncTokenFile(raw.token, file);
|
|
24
|
+
const e = getEntry(file);
|
|
25
|
+
e.data = raw;
|
|
26
|
+
try { e.mtimeMs = statSync(file).mtimeMs; } catch {}
|
|
27
|
+
return { token: raw.token, created: false };
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
const tok = generateToken();
|
|
32
|
+
const saved = writeStateImmediate(file, { token: tok, createdAt: new Date().toISOString() }).token;
|
|
33
|
+
syncTokenFile(saved, file);
|
|
34
|
+
return { token: saved, created: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function refreshToken({ file = defaultStateFile() } = {}) {
|
|
38
|
+
const tok = writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
|
|
39
|
+
syncTokenFile(tok, file);
|
|
40
|
+
return tok;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { generateToken, tokenFile };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { loadFromDisk, atomicWriteSync, getMtimeMs, ensureDir } from "./persist.js";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_PORT = 8989;
|
|
9
|
+
|
|
10
|
+
function isTestEnv() {
|
|
11
|
+
if (process.env.NODE_ENV === "test") return true;
|
|
12
|
+
if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
|
|
13
|
+
if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
|
|
14
|
+
if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
|
|
15
|
+
if (process.env.NODE_TEST_CONTEXT) return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defaultStateFile() {
|
|
20
|
+
if (process.env.MSLXDFF_STATE_FILE) return process.env.MSLXDFF_STATE_FILE;
|
|
21
|
+
if (isTestEnv()) return join(os.tmpdir(), "mslxdff-test-state.json");
|
|
22
|
+
return join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function tokenFile(file) {
|
|
26
|
+
if (process.env.MSLXDFF_TOKEN_FILE) return process.env.MSLXDFF_TOKEN_FILE;
|
|
27
|
+
const sf = file || defaultStateFile();
|
|
28
|
+
const realDefault = join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
29
|
+
if (sf !== realDefault) return join(dirname(sf), "token");
|
|
30
|
+
if (isTestEnv()) return join(os.tmpdir(), "mslxdff-test-token");
|
|
31
|
+
return join(os.homedir(), ".config", "mslxdff", "token");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function generateToken() {
|
|
35
|
+
return randomBytes(32).toString("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---- 缓存层:读走内存,写分“热/冷”两档,热数据 500ms 批量刷盘 ----
|
|
39
|
+
const stateCache = new Map(); // file -> { data, dirty, timer, mtimeMs }
|
|
40
|
+
const FLUSH_MS = (() => {
|
|
41
|
+
const n = Number(process.env.MSLXDFF_STATE_FLUSH_MS);
|
|
42
|
+
return Number.isInteger(n) && n >= 0 ? n : 500;
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
function getEntry(file) {
|
|
46
|
+
let e = stateCache.get(file);
|
|
47
|
+
if (!e) {
|
|
48
|
+
e = { data: null, dirty: false, timer: null, mtimeMs: 0 };
|
|
49
|
+
stateCache.set(file, e);
|
|
50
|
+
}
|
|
51
|
+
return e;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- 合并规则:coldWins 以 disk 为准,hotWins 以 mem 为准 ----
|
|
55
|
+
const COLD_WINS = new Set([
|
|
56
|
+
"providerConfigs",
|
|
57
|
+
"providerKeys",
|
|
58
|
+
"providerShareKeys",
|
|
59
|
+
"port",
|
|
60
|
+
"token",
|
|
61
|
+
"preferredModel",
|
|
62
|
+
"modelPicks",
|
|
63
|
+
"peers",
|
|
64
|
+
"groups",
|
|
65
|
+
"groupsJoined",
|
|
66
|
+
"bans",
|
|
67
|
+
"createdAt",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function mergeState(disk, mem) {
|
|
71
|
+
const merged = { ...disk, ...mem };
|
|
72
|
+
for (const k of COLD_WINS) {
|
|
73
|
+
if (disk[k] !== undefined) merged[k] = disk[k];
|
|
74
|
+
else if (!(k in disk) && k in mem) {
|
|
75
|
+
// deletion on disk: respect deletion by removing from merged if disk intentionally lacks key?
|
|
76
|
+
// Preserve original behavior: keep mem's stale value (do not delete).
|
|
77
|
+
// Uncomment to support true deletion: delete merged[k];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return merged;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readState(file) {
|
|
84
|
+
const e = getEntry(file);
|
|
85
|
+
if (e.data !== null) {
|
|
86
|
+
try {
|
|
87
|
+
const st = statSync(file);
|
|
88
|
+
if (st.mtimeMs === e.mtimeMs) return e.data;
|
|
89
|
+
if (e.dirty) {
|
|
90
|
+
const disk = loadFromDisk(file);
|
|
91
|
+
const diskObj = typeof disk === "object" && disk !== null ? disk : {};
|
|
92
|
+
const merged = mergeState(diskObj, e.data);
|
|
93
|
+
e.data = merged;
|
|
94
|
+
e.mtimeMs = st.mtimeMs;
|
|
95
|
+
return e.data;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
if (e.data) return e.data;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const disk = loadFromDisk(file);
|
|
102
|
+
const obj = typeof disk === "object" && disk !== null ? disk : {};
|
|
103
|
+
e.data = obj;
|
|
104
|
+
try {
|
|
105
|
+
const st = statSync(file);
|
|
106
|
+
e.mtimeMs = st.mtimeMs;
|
|
107
|
+
} catch {
|
|
108
|
+
e.mtimeMs = Date.now();
|
|
109
|
+
}
|
|
110
|
+
e.dirty = false;
|
|
111
|
+
return e.data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writeStateImmediate(file, patch) {
|
|
115
|
+
const e = getEntry(file);
|
|
116
|
+
const base = e.data !== null ? e.data : readState(file);
|
|
117
|
+
const merged = { ...base, ...patch };
|
|
118
|
+
e.data = merged;
|
|
119
|
+
e.dirty = false;
|
|
120
|
+
if (e.timer) {
|
|
121
|
+
clearTimeout(e.timer);
|
|
122
|
+
e.timer = null;
|
|
123
|
+
}
|
|
124
|
+
ensureDir(file);
|
|
125
|
+
const mtime = atomicWriteSync(file, merged);
|
|
126
|
+
e.mtimeMs = mtime;
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scheduleFlush(file) {
|
|
131
|
+
const e = getEntry(file);
|
|
132
|
+
if (e.timer) return;
|
|
133
|
+
if (FLUSH_MS === 0) {
|
|
134
|
+
flushStateSync(file);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
e.timer = setTimeout(() => {
|
|
138
|
+
e.timer = null;
|
|
139
|
+
void flushState(file);
|
|
140
|
+
}, FLUSH_MS);
|
|
141
|
+
e.timer.unref?.();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function flushState(file) {
|
|
145
|
+
const e = stateCache.get(file);
|
|
146
|
+
if (!e || !e.dirty || !e.data) return;
|
|
147
|
+
const snapshot = e.data;
|
|
148
|
+
try {
|
|
149
|
+
ensureDir(file);
|
|
150
|
+
const mtime = await atomicWriteAsync(file, snapshot);
|
|
151
|
+
e.dirty = false;
|
|
152
|
+
e.mtimeMs = mtime;
|
|
153
|
+
} catch {
|
|
154
|
+
// retain dirty
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function writeStateDeferred(file, patch) {
|
|
159
|
+
const e = getEntry(file);
|
|
160
|
+
const base = e.data !== null ? e.data : readState(file);
|
|
161
|
+
const merged = { ...base, ...patch };
|
|
162
|
+
e.data = merged;
|
|
163
|
+
e.dirty = true;
|
|
164
|
+
scheduleFlush(file);
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function flushStateSync(file = defaultStateFile()) {
|
|
169
|
+
const e = stateCache.get(file);
|
|
170
|
+
if (!e || !e.dirty || !e.data) return;
|
|
171
|
+
try {
|
|
172
|
+
ensureDir(file);
|
|
173
|
+
const mtime = atomicWriteSync(file, e.data);
|
|
174
|
+
e.dirty = false;
|
|
175
|
+
e.mtimeMs = mtime;
|
|
176
|
+
if (e.timer) {
|
|
177
|
+
clearTimeout(e.timer);
|
|
178
|
+
e.timer = null;
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function clearStateCache(file) {
|
|
184
|
+
if (file) stateCache.delete(file);
|
|
185
|
+
else stateCache.clear();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// internal helper for facade compat
|
|
189
|
+
function writeState(file, patch) {
|
|
190
|
+
return writeStateImmediate(file, patch);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export {
|
|
194
|
+
readState,
|
|
195
|
+
writeStateImmediate,
|
|
196
|
+
writeStateDeferred,
|
|
197
|
+
scheduleFlush,
|
|
198
|
+
flushState,
|
|
199
|
+
writeState,
|
|
200
|
+
getEntry,
|
|
201
|
+
stateCache,
|
|
202
|
+
};
|