mslxdff 0.1.45 → 0.1.55
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/README.md +162 -162
- package/bin/mslxdff.js +202 -21
- package/docs/adr/0001-reasoning-content-injection.md +13 -13
- package/docs/adr/0002-models-free-filter.md +11 -11
- package/docs/adr/0003-zero-state-no-auth.md +9 -9
- package/docs/adr/0004-bearer-token.md +17 -17
- package/docs/agents/domain.md +50 -50
- package/docs/agents/issue-tracker.md +29 -29
- package/docs/agents/triage-labels.md +14 -14
- package/package.json +1 -1
- package/src/auto.js +20 -2
- package/src/chooser.js +12 -6
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -342
- package/src/routes/hedge.js +251 -0
- package/src/routes/peers.js +49 -15
- package/src/server.js +57 -57
- package/src/state.js +285 -152
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -212
package/src/server.js
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
import { createServer as httpCreateServer } from "node:http";
|
|
2
|
-
import { DEFAULT_PORT, getPort } from "./state.js";
|
|
3
|
-
|
|
4
|
-
export function startServer({ router, signals = true, host, onBeforeClose }, port = resolvePort()) {
|
|
5
|
-
const listenHost = host ?? resolveHost();
|
|
6
|
-
const server = httpCreateServer((req, res) => {
|
|
7
|
-
router(req, res).catch((err) => {
|
|
8
|
-
res.statusCode = 500;
|
|
9
|
-
res.setHeader("Content-Type", "application/json");
|
|
10
|
-
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
11
|
-
});
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const ready = () =>
|
|
15
|
-
new Promise((resolve, reject) => {
|
|
16
|
-
server.on("error", reject);
|
|
17
|
-
if (listenHost) server.listen(port, listenHost, resolve);
|
|
18
|
-
else server.listen(port, resolve);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
const close = async () => {
|
|
22
|
-
// 插件 hook:server:stop — 关闭前触发(fire-and-forget,不阻塞关停)
|
|
23
|
-
try { await onBeforeClose?.(); } catch {}
|
|
24
|
-
await new Promise((resolve) => {
|
|
25
|
-
server.close(resolve);
|
|
26
|
-
// SSE debug streams keep connections open — force-close so shutdown
|
|
27
|
-
// never waits on them.
|
|
28
|
-
server.closeAllConnections?.();
|
|
29
|
-
});
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
if (signals) {
|
|
33
|
-
process.on("SIGINT", close);
|
|
34
|
-
process.on("SIGTERM", close);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return { server, ready, close };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function resolveHost() {
|
|
41
|
-
const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
|
|
42
|
-
if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
|
|
43
|
-
return "0.0.0.0";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function resolvePort() {
|
|
47
|
-
// 8989 is the hard default. Port only changes via an explicit, mslxdff-owned
|
|
48
|
-
// override: the persisted `-port N` setting, or the MSLXDFF_PORT env var.
|
|
49
|
-
// Bare `PORT` is deliberately NOT read — an ssh session / wrapper script
|
|
50
|
-
// commonly injects it and would silently override the default.
|
|
51
|
-
const persisted = getPort();
|
|
52
|
-
if (persisted) return persisted;
|
|
53
|
-
const env = Number(process.env.MSLXDFF_PORT);
|
|
54
|
-
// 0 = OS-assigned ephemeral port (valid; used by tests/containers)
|
|
55
|
-
if (Number.isInteger(env) && env >= 0) return env;
|
|
56
|
-
return DEFAULT_PORT;
|
|
57
|
-
}
|
|
1
|
+
import { createServer as httpCreateServer } from "node:http";
|
|
2
|
+
import { DEFAULT_PORT, getPort } from "./state.js";
|
|
3
|
+
|
|
4
|
+
export function startServer({ router, signals = true, host, onBeforeClose }, port = resolvePort()) {
|
|
5
|
+
const listenHost = host ?? resolveHost();
|
|
6
|
+
const server = httpCreateServer((req, res) => {
|
|
7
|
+
router(req, res).catch((err) => {
|
|
8
|
+
res.statusCode = 500;
|
|
9
|
+
res.setHeader("Content-Type", "application/json");
|
|
10
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const ready = () =>
|
|
15
|
+
new Promise((resolve, reject) => {
|
|
16
|
+
server.on("error", reject);
|
|
17
|
+
if (listenHost) server.listen(port, listenHost, resolve);
|
|
18
|
+
else server.listen(port, resolve);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const close = async () => {
|
|
22
|
+
// 插件 hook:server:stop — 关闭前触发(fire-and-forget,不阻塞关停)
|
|
23
|
+
try { await onBeforeClose?.(); } catch {}
|
|
24
|
+
await new Promise((resolve) => {
|
|
25
|
+
server.close(resolve);
|
|
26
|
+
// SSE debug streams keep connections open — force-close so shutdown
|
|
27
|
+
// never waits on them.
|
|
28
|
+
server.closeAllConnections?.();
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (signals) {
|
|
33
|
+
process.on("SIGINT", close);
|
|
34
|
+
process.on("SIGTERM", close);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { server, ready, close };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveHost() {
|
|
41
|
+
const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
|
|
42
|
+
if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
|
|
43
|
+
return "0.0.0.0";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolvePort() {
|
|
47
|
+
// 8989 is the hard default. Port only changes via an explicit, mslxdff-owned
|
|
48
|
+
// override: the persisted `-port N` setting, or the MSLXDFF_PORT env var.
|
|
49
|
+
// Bare `PORT` is deliberately NOT read — an ssh session / wrapper script
|
|
50
|
+
// commonly injects it and would silently override the default.
|
|
51
|
+
const persisted = getPort();
|
|
52
|
+
if (persisted) return persisted;
|
|
53
|
+
const env = Number(process.env.MSLXDFF_PORT);
|
|
54
|
+
// 0 = OS-assigned ephemeral port (valid; used by tests/containers)
|
|
55
|
+
if (Number.isInteger(env) && env >= 0) return env;
|
|
56
|
+
return DEFAULT_PORT;
|
|
57
|
+
}
|
package/src/state.js
CHANGED
|
@@ -1,152 +1,285 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
|
|
7
|
+
export const DEFAULT_PORT = 8989;
|
|
8
|
+
|
|
9
|
+
export function defaultStateFile() {
|
|
10
|
+
return process.env.MSLXDFF_STATE_FILE ||
|
|
11
|
+
join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function generateToken() {
|
|
15
|
+
return randomBytes(32).toString("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---- 缓存层:读走内存,写分“热/冷”两档,热数据 500ms 批量刷盘 ----
|
|
19
|
+
const stateCache = new Map(); // file -> { data, dirty, timer, mtimeMs }
|
|
20
|
+
const FLUSH_MS = (() => {
|
|
21
|
+
const n = Number(process.env.MSLXDFF_STATE_FLUSH_MS);
|
|
22
|
+
return Number.isInteger(n) && n >= 0 ? n : 500;
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
function getEntry(file) {
|
|
26
|
+
let e = stateCache.get(file);
|
|
27
|
+
if (!e) {
|
|
28
|
+
e = { data: null, dirty: false, timer: null, mtimeMs: 0 };
|
|
29
|
+
stateCache.set(file, e);
|
|
30
|
+
}
|
|
31
|
+
return e;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadFromDisk(file) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readState(file) {
|
|
43
|
+
const e = getEntry(file);
|
|
44
|
+
// 脏数据直接从内存拿,不碰磁盘
|
|
45
|
+
if (e.data !== null && e.dirty) return e.data;
|
|
46
|
+
// 已有干净缓存且文件未变,直接命中
|
|
47
|
+
if (e.data !== null) {
|
|
48
|
+
try {
|
|
49
|
+
const st = statSync(file);
|
|
50
|
+
if (st.mtimeMs === e.mtimeMs) return e.data;
|
|
51
|
+
} catch {
|
|
52
|
+
// 文件不存在等,沿用内存
|
|
53
|
+
if (e.data) return e.data;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const disk = loadFromDisk(file);
|
|
57
|
+
const obj = typeof disk === "object" && disk !== null ? disk : {};
|
|
58
|
+
e.data = obj;
|
|
59
|
+
try {
|
|
60
|
+
const st = statSync(file);
|
|
61
|
+
e.mtimeMs = st.mtimeMs;
|
|
62
|
+
} catch {
|
|
63
|
+
e.mtimeMs = Date.now();
|
|
64
|
+
}
|
|
65
|
+
e.dirty = false;
|
|
66
|
+
return e.data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeStateImmediate(file, patch) {
|
|
70
|
+
const e = getEntry(file);
|
|
71
|
+
const base = e.data !== null ? e.data : readState(file);
|
|
72
|
+
const merged = { ...base, ...patch };
|
|
73
|
+
e.data = merged;
|
|
74
|
+
e.dirty = false;
|
|
75
|
+
if (e.timer) {
|
|
76
|
+
clearTimeout(e.timer);
|
|
77
|
+
e.timer = null;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
81
|
+
try {
|
|
82
|
+
e.mtimeMs = statSync(file).mtimeMs;
|
|
83
|
+
} catch {
|
|
84
|
+
e.mtimeMs = Date.now();
|
|
85
|
+
}
|
|
86
|
+
return merged;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function scheduleFlush(file) {
|
|
90
|
+
const e = getEntry(file);
|
|
91
|
+
if (e.timer) return;
|
|
92
|
+
if (FLUSH_MS === 0) {
|
|
93
|
+
// 同步刷(测试用)
|
|
94
|
+
flushStateSync(file);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
e.timer = setTimeout(() => {
|
|
98
|
+
e.timer = null;
|
|
99
|
+
void flushState(file);
|
|
100
|
+
}, FLUSH_MS);
|
|
101
|
+
e.timer.unref?.();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function flushState(file) {
|
|
105
|
+
const e = stateCache.get(file);
|
|
106
|
+
if (!e || !e.dirty || !e.data) return;
|
|
107
|
+
const snapshot = e.data;
|
|
108
|
+
try {
|
|
109
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
110
|
+
await writeFile(file, JSON.stringify(snapshot, null, 2), { mode: 0o600 });
|
|
111
|
+
e.dirty = false;
|
|
112
|
+
try {
|
|
113
|
+
e.mtimeMs = statSync(file).mtimeMs;
|
|
114
|
+
} catch {
|
|
115
|
+
e.mtimeMs = Date.now();
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// 保留 dirty,下次重试
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeStateDeferred(file, patch) {
|
|
123
|
+
const e = getEntry(file);
|
|
124
|
+
const base = e.data !== null ? e.data : readState(file);
|
|
125
|
+
const merged = { ...base, ...patch };
|
|
126
|
+
e.data = merged;
|
|
127
|
+
e.dirty = true;
|
|
128
|
+
scheduleFlush(file);
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function flushStateSync(file = defaultStateFile()) {
|
|
133
|
+
const e = stateCache.get(file);
|
|
134
|
+
if (!e || !e.dirty || !e.data) return;
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
137
|
+
writeFileSync(file, JSON.stringify(e.data, null, 2), { mode: 0o600 });
|
|
138
|
+
e.dirty = false;
|
|
139
|
+
try {
|
|
140
|
+
e.mtimeMs = statSync(file).mtimeMs;
|
|
141
|
+
} catch {
|
|
142
|
+
e.mtimeMs = Date.now();
|
|
143
|
+
}
|
|
144
|
+
if (e.timer) {
|
|
145
|
+
clearTimeout(e.timer);
|
|
146
|
+
e.timer = null;
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function clearStateCache(file) {
|
|
152
|
+
if (file) stateCache.delete(file);
|
|
153
|
+
else stateCache.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- 对外 API:冷数据立即落盘,热数据延迟批量 ----
|
|
157
|
+
|
|
158
|
+
export async function loadToken({ file = defaultStateFile() } = {}) {
|
|
159
|
+
const state = readState(file);
|
|
160
|
+
if (typeof state.token === "string" && state.token.length > 0) {
|
|
161
|
+
return { token: state.token, created: false };
|
|
162
|
+
}
|
|
163
|
+
return { token: writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token, created: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function refreshToken({ file = defaultStateFile() } = {}) {
|
|
167
|
+
return writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function setPort(port, { file = defaultStateFile() } = {}) {
|
|
171
|
+
writeStateImmediate(file, { port: Number(port) });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getPort({ file = defaultStateFile() } = {}) {
|
|
175
|
+
const port = readState(file).port;
|
|
176
|
+
return typeof port === "number" && Number.isInteger(port) && port > 0 ? port : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function loadModelErrors({ file = defaultStateFile() } = {}) {
|
|
180
|
+
const errors = readState(file).modelErrors;
|
|
181
|
+
return errors && typeof errors === "object" && !Array.isArray(errors) ? errors : {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function saveModelErrors(errors, { file = defaultStateFile() } = {}) {
|
|
185
|
+
writeStateDeferred(file, { modelErrors: errors });
|
|
186
|
+
return errors;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function loadModelLatencies({ file = defaultStateFile() } = {}) {
|
|
190
|
+
const lat = readState(file).modelLatencies;
|
|
191
|
+
return lat && typeof lat === "object" && !Array.isArray(lat) ? lat : {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function saveModelLatencies(latencies, { file = defaultStateFile() } = {}) {
|
|
195
|
+
writeStateDeferred(file, { modelLatencies: latencies });
|
|
196
|
+
return latencies;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function loadPreferredModel({ file = defaultStateFile() } = {}) {
|
|
200
|
+
const v = readState(file).preferredModel;
|
|
201
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 常用模型勾选集(auto 候选池白名单):空数组 = 不启用筛选(全量 auto)
|
|
205
|
+
export function loadModelPicks({ file = defaultStateFile() } = {}) {
|
|
206
|
+
const picks = readState(file).modelPicks;
|
|
207
|
+
if (!Array.isArray(picks)) return [];
|
|
208
|
+
return [...new Set(picks.filter((x) => typeof x === "string" && x.trim().length))];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function saveModelPicks(picks, { file = defaultStateFile() } = {}) {
|
|
212
|
+
const list = [...new Set((Array.isArray(picks) ? picks : []).filter((x) => typeof x === "string" && x.trim().length))];
|
|
213
|
+
writeStateImmediate(file, { modelPicks: list });
|
|
214
|
+
return list;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function savePreferredModel(id, { file = defaultStateFile() } = {}) {
|
|
218
|
+
writeStateImmediate(file, { preferredModel: String(id || "").trim() });
|
|
219
|
+
return String(id || "").trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function loadPeers({ file = defaultStateFile() } = {}) {
|
|
223
|
+
const peers = readState(file).peers;
|
|
224
|
+
return Array.isArray(peers) ? peers : [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function savePeers(peers, { file = defaultStateFile() } = {}) {
|
|
228
|
+
writeStateImmediate(file, { peers });
|
|
229
|
+
return peers;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function loadPeerErrors({ file = defaultStateFile() } = {}) {
|
|
233
|
+
const errors = readState(file).peerErrors;
|
|
234
|
+
return errors && typeof errors === "object" && !Array.isArray(errors) ? errors : {};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function savePeerErrors(errors, { file = defaultStateFile() } = {}) {
|
|
238
|
+
writeStateDeferred(file, { peerErrors: errors });
|
|
239
|
+
return errors;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function loadPeerStats({ file = defaultStateFile() } = {}) {
|
|
243
|
+
const stats = readState(file).peerStats;
|
|
244
|
+
return stats && typeof stats === "object" && !Array.isArray(stats) ? stats : {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function savePeerStats(stats, { file = defaultStateFile() } = {}) {
|
|
248
|
+
writeStateDeferred(file, { peerStats: stats });
|
|
249
|
+
return stats;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function loadGroups({ file = defaultStateFile() } = {}) {
|
|
253
|
+
const groups = readState(file).groups;
|
|
254
|
+
return groups && typeof groups === "object" && !Array.isArray(groups) ? groups : {};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function loadGroupsJoined({ file = defaultStateFile() } = {}) {
|
|
258
|
+
const joined = readState(file).groupsJoined;
|
|
259
|
+
return Array.isArray(joined) ? joined : [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function saveGroupsJoined(joined, { file = defaultStateFile() } = {}) {
|
|
263
|
+
writeStateImmediate(file, { groupsJoined: joined });
|
|
264
|
+
return joined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function loadBans({ file = defaultStateFile() } = {}) {
|
|
268
|
+
const bans = readState(file).bans;
|
|
269
|
+
return bans && typeof bans === "object" && !Array.isArray(bans) ? bans : {};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function saveBans(bans, { file = defaultStateFile() } = {}) {
|
|
273
|
+
writeStateImmediate(file, { bans });
|
|
274
|
+
return bans;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function saveGroups(groups, { file = defaultStateFile() } = {}) {
|
|
278
|
+
writeStateImmediate(file, { groups });
|
|
279
|
+
return groups;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function writeState(file, patch) {
|
|
283
|
+
// 兼容旧调用:默认走立即落盘
|
|
284
|
+
return writeStateImmediate(file, patch);
|
|
285
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
export function workbuddyModelsPath() {
|
|
6
|
+
const env = process.env.WORKBUDDY_MODELS_FILE || process.env.WORKBUDDY_MODELS_PATH;
|
|
7
|
+
if (typeof env === "string" && env.trim()) return env.trim();
|
|
8
|
+
return join(os.homedir(), ".workbuddy", "models.json");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildWorkbuddyEntry({ id, token, port }) {
|
|
12
|
+
const p = Number(port) || 8989;
|
|
13
|
+
return {
|
|
14
|
+
id: String(id),
|
|
15
|
+
name: String(id),
|
|
16
|
+
vendor: "Custom",
|
|
17
|
+
url: `http://127.0.0.1:${p}/v1/chat/completions`,
|
|
18
|
+
apiKey: String(token || ""),
|
|
19
|
+
supportsToolCall: true,
|
|
20
|
+
supportsImages: true,
|
|
21
|
+
supportsReasoning: true,
|
|
22
|
+
useCustomProtocol: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isLocalUrl(url) {
|
|
27
|
+
const u = String(url || "");
|
|
28
|
+
return u.includes("127.0.0.1") && u.includes("/v1/chat/completions");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isTargetEntry(m, id) {
|
|
32
|
+
if (!m || typeof m !== "object") return false;
|
|
33
|
+
if (m.id !== id) return false;
|
|
34
|
+
return isLocalUrl(m.url);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
38
|
+
const targetFile = file || workbuddyModelsPath();
|
|
39
|
+
const cleanId = String(id || "").trim();
|
|
40
|
+
if (!cleanId) throw new Error("model id required");
|
|
41
|
+
const cleanToken = String(token || "");
|
|
42
|
+
const p = Number(port) || 8989;
|
|
43
|
+
|
|
44
|
+
let arr = [];
|
|
45
|
+
let corrupted = false;
|
|
46
|
+
let rawText = null;
|
|
47
|
+
try {
|
|
48
|
+
rawText = readFileSync(targetFile, "utf8");
|
|
49
|
+
const parsed = JSON.parse(rawText);
|
|
50
|
+
arr = Array.isArray(parsed) ? parsed : [];
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (rawText !== null) {
|
|
53
|
+
// file existed but unreadable/corrupted -> backup
|
|
54
|
+
corrupted = true;
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
57
|
+
// only backup if .bak not exists or corrupted content differs?
|
|
58
|
+
writeFileSync(targetFile + ".bak", rawText ?? "", "utf8");
|
|
59
|
+
} catch {}
|
|
60
|
+
arr = [];
|
|
61
|
+
} else {
|
|
62
|
+
// file not exists
|
|
63
|
+
arr = [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// find exact target (id + local url, any port -> will update port)
|
|
68
|
+
let idx = arr.findIndex((m) => isTargetEntry(m, cleanId));
|
|
69
|
+
let action;
|
|
70
|
+
if (idx >= 0) {
|
|
71
|
+
// update token/url/name, preserve other fields
|
|
72
|
+
const old = arr[idx];
|
|
73
|
+
arr[idx] = { ...old, id: cleanId, name: cleanId, url: `http://127.0.0.1:${p}/v1/chat/completions`, apiKey: cleanToken };
|
|
74
|
+
if (!arr[idx].vendor) arr[idx].vendor = "Custom";
|
|
75
|
+
if (arr[idx].supportsToolCall === undefined) arr[idx].supportsToolCall = true;
|
|
76
|
+
if (arr[idx].supportsImages === undefined) arr[idx].supportsImages = true;
|
|
77
|
+
if (arr[idx].supportsReasoning === undefined) arr[idx].supportsReasoning = true;
|
|
78
|
+
if (arr[idx].useCustomProtocol === undefined) arr[idx].useCustomProtocol = false;
|
|
79
|
+
action = "updated";
|
|
80
|
+
} else {
|
|
81
|
+
// ensure existence: no reuse, always insert new entry (preserves other local entries like x-preview)
|
|
82
|
+
arr.push(buildWorkbuddyEntry({ id: cleanId, token: cleanToken, port: p }));
|
|
83
|
+
action = "inserted";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// atomic write
|
|
87
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
88
|
+
const tmp = `${targetFile}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
89
|
+
writeFileSync(tmp, JSON.stringify(arr, null, 2), "utf8");
|
|
90
|
+
try {
|
|
91
|
+
// on Windows, rename fails if target exists -> remove first? use renameSync which overwrites on Node 20?
|
|
92
|
+
// try rename, fallback to copy+unlink
|
|
93
|
+
renameSync(tmp, targetFile);
|
|
94
|
+
} catch {
|
|
95
|
+
try { writeFileSync(targetFile, readFileSync(tmp, "utf8"), "utf8"); } catch {}
|
|
96
|
+
try { readFileSync(tmp); } catch {}
|
|
97
|
+
// cleanup tmp
|
|
98
|
+
try { /* remove tmp */ } catch {}
|
|
99
|
+
}
|
|
100
|
+
// cleanup tmp if still exists
|
|
101
|
+
try { if (existsSync(tmp)) { const { unlinkSync } = await import("node:fs"); unlinkSync(tmp); } } catch {}
|
|
102
|
+
|
|
103
|
+
return { action, file: targetFile, id: cleanId, corrupted };
|
|
104
|
+
}
|