dsh-memory-tiers 0.8.2 → 0.8.3
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/ARCHITECTURE.md +420 -2460
- package/README.md +140 -268
- package/client.mjs +701 -608
- package/index.mjs +939 -826
- package/{memory5.mjs → memory.mjs} +5280 -3403
- package/package.json +61 -63
- package/settings.mjs +335 -327
- package/silicon.mjs +24 -22
- package/memory6.mjs +0 -1553
- package/reindex.mjs +0 -162
package/settings.mjs
CHANGED
|
@@ -1,327 +1,335 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 设置页的服务端半边:读写 config.json + 暴露运行状态。
|
|
3
|
-
*
|
|
4
|
-
* 为什么要自己写 HTTP 接口而不是用 schemastery 声明 schema:
|
|
5
|
-
* link 安装的插件 import 不到 @deepseek-ai/*,拿不到 dsh 的 schema 包(见 ARCHITECTURE 19.5)。
|
|
6
|
-
* 走 webServer + 自己的 React 组件反而更可控——字段说明、分组、只读状态都能自己排版。
|
|
7
|
-
*/
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
9
|
-
import { homedir } from 'node:os'
|
|
10
|
-
import { join } from 'node:path'
|
|
11
|
-
import { isIP } from 'node:net'
|
|
12
|
-
|
|
13
|
-
/** 可写字段白名单:只管运行参数,dir 和 _说明 不让前端改 */
|
|
14
|
-
const WRITABLE = {
|
|
15
|
-
apiKey: 'string',
|
|
16
|
-
apiBase: 'string',
|
|
17
|
-
bootMaxChars: 'number',
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (typeof
|
|
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
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
ignored.push(`models.${st}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 设置页的服务端半边:读写 config.json + 暴露运行状态。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要自己写 HTTP 接口而不是用 schemastery 声明 schema:
|
|
5
|
+
* link 安装的插件 import 不到 @deepseek-ai/*,拿不到 dsh 的 schema 包(见 ARCHITECTURE 19.5)。
|
|
6
|
+
* 走 webServer + 自己的 React 组件反而更可控——字段说明、分组、只读状态都能自己排版。
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { isIP } from 'node:net'
|
|
12
|
+
|
|
13
|
+
/** 可写字段白名单:只管运行参数,dir 和 _说明 不让前端改 */
|
|
14
|
+
const WRITABLE = {
|
|
15
|
+
apiKey: 'string',
|
|
16
|
+
apiBase: 'string',
|
|
17
|
+
bootMaxChars: 'number',
|
|
18
|
+
inject: 'boolean', // 注入总开关:false = 只写不读
|
|
19
|
+
refreshMin: 'number', // L3 视图(DL3 排序)刷新间隔(分钟);L1/L2 不节流
|
|
20
|
+
semanticTtlMin: 'number', // 语义检索结果的有效期(分钟)
|
|
21
|
+
autoCompact: 'boolean',
|
|
22
|
+
compactKeepTurns: 'number',
|
|
23
|
+
compactArchiveChars: 'number',
|
|
24
|
+
userCaptureChars: 'number',
|
|
25
|
+
toolCaptureChars: 'number',
|
|
26
|
+
botCaptureChars: 'number', // 一轮 assistant 正文拼起来后截多少(L1 当最近历史用)
|
|
27
|
+
quietMin: 'number', // 安静多少分钟才允许整理 L1
|
|
28
|
+
organizeEveryMin: 'number', // 整理定时器(分钟)
|
|
29
|
+
budgetTokens: 'number',
|
|
30
|
+
flushEveryMs: 'number',
|
|
31
|
+
sweepEveryMs: 'number',
|
|
32
|
+
// 'siliconflow' = 嵌入/重排用那两个免费模型;'custom' = 用 models 里自己填的
|
|
33
|
+
preset: 'string',
|
|
34
|
+
// ★ 布局:'split' = 六文件(默认);'legacy' = 老的单文件分片(回退用)
|
|
35
|
+
layout: 'string',
|
|
36
|
+
// ★ 多写者标识:共用同一个数据目录时每个程序必须不同
|
|
37
|
+
writer: 'string',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 数据目录:不写 config.json(它在目录里面,鸡生蛋),写固定位置的指针文件 */
|
|
41
|
+
function pointerFile() {
|
|
42
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
43
|
+
return join(home, 'memory-tiers.json')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 关思考:chat(融合判关系)默认 true,summary 默认 false */
|
|
47
|
+
const NOTHINK_WRITABLE = { chat: 'boolean', summary: 'boolean' }
|
|
48
|
+
|
|
49
|
+
/** 三层容量在 memoryCfg 里,单独处理 */
|
|
50
|
+
const MEMORY_CFG_WRITABLE = {
|
|
51
|
+
hotMaxChars: 'number',
|
|
52
|
+
consolidateMaxChars: 'number',
|
|
53
|
+
longMaxChars: 'number',
|
|
54
|
+
dl2MaxChars: 'number',
|
|
55
|
+
// 下面几项和 astrbot 端的配置项一一对应(hot_quota_own / l2_keep_max / summary_chunk / forget_threshold)
|
|
56
|
+
hotQuotaOwn: 'number',
|
|
57
|
+
l2KeepMax: 'number',
|
|
58
|
+
summaryChunk: 'number',
|
|
59
|
+
forgetThreshold: 'number',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 每个模型阶段可单独覆盖的连接参数;留空字符串 = 清除覆盖、回落到顶层 */
|
|
63
|
+
const STAGES = ['embed', 'rerank', 'chat', 'summary']
|
|
64
|
+
// source: 'session' 跟当前会话模型走;'api' 或留空则走下面的连接参数
|
|
65
|
+
const STAGE_FIELDS = { base: 'string', apiKey: 'string', model: 'string', source: 'string' }
|
|
66
|
+
|
|
67
|
+
function json(res, code, obj) {
|
|
68
|
+
const body = JSON.stringify(obj)
|
|
69
|
+
res.statusCode = code
|
|
70
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
71
|
+
res.setHeader('cache-control', 'no-store')
|
|
72
|
+
res.end(body)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readBody(req) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let buf = ''
|
|
78
|
+
req.on('data', (c) => {
|
|
79
|
+
buf += c
|
|
80
|
+
if (buf.length > 1 << 20) reject(new Error('body too large'))
|
|
81
|
+
})
|
|
82
|
+
req.on('end', () => resolve(buf))
|
|
83
|
+
req.on('error', reject)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 只信任本机回环;远程连接交给 dsh 的 connection 服务判定 */
|
|
88
|
+
function isTrustedReq(ctx, req) {
|
|
89
|
+
const addr = req.socket?.remoteAddress || ''
|
|
90
|
+
const bare = addr.replace(/^::ffff:/, '')
|
|
91
|
+
if (isIP(bare) && (bare === '127.0.0.1' || bare === '::1')) return true
|
|
92
|
+
try {
|
|
93
|
+
const conn = ctx.get?.('connection') || ctx.connection
|
|
94
|
+
if (conn && typeof conn.isTrusted === 'function') return conn.isTrusted(req) === true
|
|
95
|
+
} catch { /* 栅栏不可用按不信任处理 */ }
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function coerce(kind, value) {
|
|
100
|
+
if (kind === 'boolean') {
|
|
101
|
+
if (typeof value === 'boolean') return value
|
|
102
|
+
if (value === 'true') return true
|
|
103
|
+
if (value === 'false') return false
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
if (kind === 'number') {
|
|
107
|
+
const n = Number(value)
|
|
108
|
+
return Number.isFinite(n) ? n : undefined
|
|
109
|
+
}
|
|
110
|
+
if (kind === 'string') return typeof value === 'string' ? value : undefined
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 挂上 /dsh-memory/api/*。
|
|
116
|
+
* @returns {() => void} disposer;webServer 不可用时返回 null
|
|
117
|
+
*/
|
|
118
|
+
export function installSettingsApi(ctx, opts) {
|
|
119
|
+
const { dir, getCfg, log, mem, getModels, getSession } = opts
|
|
120
|
+
const webServer = ctx.webServer
|
|
121
|
+
if (!webServer?.register) return null
|
|
122
|
+
|
|
123
|
+
const cfgPath = join(dir, 'config.json')
|
|
124
|
+
const disposers = []
|
|
125
|
+
|
|
126
|
+
const readCfgFile = () => {
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(cfgPath)) return {}
|
|
129
|
+
return JSON.parse(readFileSync(cfgPath, 'utf8'))
|
|
130
|
+
} catch (err) {
|
|
131
|
+
log?.warn?.('[memory-tiers] 读 config.json 失败:%s', err?.message ?? err)
|
|
132
|
+
return {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function route(path, handler) {
|
|
137
|
+
const off = webServer.register({
|
|
138
|
+
kind: 'exact',
|
|
139
|
+
path,
|
|
140
|
+
handler: async (req, res) => {
|
|
141
|
+
if (!isTrustedReq(ctx, req)) { json(res, 403, { ok: false, error: '不受信任的访问来源' }); return }
|
|
142
|
+
try { await handler(req, res) } catch (err) {
|
|
143
|
+
log?.warn?.('[memory-tiers] 设置接口出错:%s', err?.message ?? err)
|
|
144
|
+
json(res, 500, { ok: false, error: String(err?.message ?? err) })
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
if (typeof off === 'function') disposers.push(off)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── settings:读和写必须**合成一个 handler** ──
|
|
152
|
+
// 踩过的坑:同一路径注册两次,若宿主「首个匹配即终止」,后注册的永远收不到请求。
|
|
153
|
+
// 实测表现是 GET 正常、POST 405(或反之),排查半天。
|
|
154
|
+
route('/dsh-memory/api/settings', async (req, res) => {
|
|
155
|
+
if (req.method === 'GET') {
|
|
156
|
+
const file = readCfgFile()
|
|
157
|
+
const live = getCfg() || {}
|
|
158
|
+
const stats = (() => {
|
|
159
|
+
try { return mem?.stats?.() ?? null } catch { return null }
|
|
160
|
+
})()
|
|
161
|
+
json(res, 200, {
|
|
162
|
+
ok: true,
|
|
163
|
+
config: {
|
|
164
|
+
...file,
|
|
165
|
+
// dir 不在 config.json 里(它在数据目录里面,鸡生蛋),从指针文件回填给界面
|
|
166
|
+
dir: live.dir || dir,
|
|
167
|
+
writer: file.writer || 'default',
|
|
168
|
+
memoryCfg: { ...(file.memoryCfg || {}) },
|
|
169
|
+
},
|
|
170
|
+
live: {
|
|
171
|
+
dir,
|
|
172
|
+
layout: live.layout || 'split',
|
|
173
|
+
// 多写者:我自己是谁、还有谁在往这个目录写
|
|
174
|
+
// split 布局看 L1.tmp/*.jsonl,legacy 看 store-*.json
|
|
175
|
+
writer: live.writer || 'default',
|
|
176
|
+
peers: (() => {
|
|
177
|
+
try {
|
|
178
|
+
const files = readdirSync(dir)
|
|
179
|
+
if (files.includes('L1.tmp') || files.includes('L1.json')) {
|
|
180
|
+
try {
|
|
181
|
+
return readdirSync(join(dir, 'L1.tmp'))
|
|
182
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
183
|
+
.map((f) => f.replace(/\.jsonl$/, '').split('.')[0])
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
} catch { return [] }
|
|
186
|
+
}
|
|
187
|
+
return files
|
|
188
|
+
.map((f) => f.match(/^store-([A-Za-z0-9_-]+)\.json$/)?.[1])
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
} catch { return [] }
|
|
191
|
+
})(),
|
|
192
|
+
pointerFile: pointerFile(),
|
|
193
|
+
counts: stats?.count ?? null,
|
|
194
|
+
chars: stats?.chars ?? null, // 每层字数(设置页要写清楚"存了多少字")
|
|
195
|
+
vectors: stats?.vectors ?? null,
|
|
196
|
+
total: stats?.total ?? null,
|
|
197
|
+
byChannel: stats?.byChannel ?? null,
|
|
198
|
+
temp: stats?.temp ?? null,
|
|
199
|
+
lock: stats?.lock ?? null,
|
|
200
|
+
last: stats?.last ?? null,
|
|
201
|
+
semantic: !!(live.apiKey || process.env.SILICONFLOW_API_KEY ||
|
|
202
|
+
Object.values(live.models || {}).some((m) => m?.apiKey)),
|
|
203
|
+
inject: live.inject !== false,
|
|
204
|
+
autoCompact: live.autoCompact !== false,
|
|
205
|
+
preset: live.preset || 'siliconflow',
|
|
206
|
+
noThink: { chat: true, summary: false, ...(file.noThink || {}) },
|
|
207
|
+
// 会话模型通道:能不能用、当前是哪个模型
|
|
208
|
+
session: (() => {
|
|
209
|
+
try { return getSession?.() ?? null } catch { return null }
|
|
210
|
+
})(),
|
|
211
|
+
// 各阶段最终解析成什么(覆盖已合并),设置页拿来显示"实际生效"
|
|
212
|
+
models: (() => {
|
|
213
|
+
try { return getModels?.() ?? null } catch { return null }
|
|
214
|
+
})(),
|
|
215
|
+
},
|
|
216
|
+
configPath: cfgPath,
|
|
217
|
+
})
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: '只接受 GET / POST' }); return }
|
|
222
|
+
|
|
223
|
+
const raw = await readBody(req)
|
|
224
|
+
let body
|
|
225
|
+
try { body = JSON.parse(raw || '{}') } catch { json(res, 400, { ok: false, error: 'JSON 解析失败' }); return }
|
|
226
|
+
|
|
227
|
+
// 兼容 { config: {...} } 和直接 {...} 两种写法(踩过:只认一种会「保存成功但没变」)
|
|
228
|
+
const incoming = (body && typeof body.config === 'object' && body.config) || body || {}
|
|
229
|
+
|
|
230
|
+
const cur = readCfgFile()
|
|
231
|
+
const next = { ...cur }
|
|
232
|
+
const applied = []
|
|
233
|
+
const ignored = []
|
|
234
|
+
|
|
235
|
+
// 数据目录单独处理:写指针文件,不写 config.json
|
|
236
|
+
if (typeof incoming.dir === 'string') {
|
|
237
|
+
const v = incoming.dir.trim()
|
|
238
|
+
if (v) {
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync(pointerFile(), JSON.stringify({ dir: v }, null, 2), 'utf8')
|
|
241
|
+
applied.push('dir(指针文件)')
|
|
242
|
+
} catch {
|
|
243
|
+
ignored.push('dir')
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const [k, kind] of Object.entries(WRITABLE)) {
|
|
249
|
+
if (!(k in incoming)) continue
|
|
250
|
+
const v = coerce(kind, incoming[k])
|
|
251
|
+
if (v === undefined) { ignored.push(k); continue }
|
|
252
|
+
next[k] = v
|
|
253
|
+
applied.push(k)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (incoming.memoryCfg && typeof incoming.memoryCfg === 'object') {
|
|
257
|
+
const mc = { ...(cur.memoryCfg || {}) }
|
|
258
|
+
for (const [k, kind] of Object.entries(MEMORY_CFG_WRITABLE)) {
|
|
259
|
+
if (!(k in incoming.memoryCfg)) continue
|
|
260
|
+
const v = coerce(kind, incoming.memoryCfg[k])
|
|
261
|
+
if (v === undefined) { ignored.push('memoryCfg.' + k); continue }
|
|
262
|
+
mc[k] = v
|
|
263
|
+
applied.push('memoryCfg.' + k)
|
|
264
|
+
}
|
|
265
|
+
next.memoryCfg = mc
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── 每阶段模型覆盖 ──
|
|
269
|
+
// 语义:填了就覆盖,留空(空字符串)= 删掉这条覆盖,回落到顶层的 apiKey/apiBase 和默认模型名。
|
|
270
|
+
// 这样「全走一家」和「摘要单独挂别家」都能表达,不用区分"没填过"和"清空了"。
|
|
271
|
+
if (incoming.models && typeof incoming.models === 'object') {
|
|
272
|
+
const mm = { ...(cur.models || {}) }
|
|
273
|
+
for (const st of STAGES) {
|
|
274
|
+
const src = incoming.models[st]
|
|
275
|
+
if (!src || typeof src !== 'object') continue
|
|
276
|
+
const dst = { ...(mm[st] || {}) }
|
|
277
|
+
for (const [k, kind] of Object.entries(STAGE_FIELDS)) {
|
|
278
|
+
if (!(k in src)) continue
|
|
279
|
+
const v = coerce(kind, src[k])
|
|
280
|
+
if (v === undefined) { ignored.push(`models.${st}.${k}`); continue }
|
|
281
|
+
if (v === '') delete dst[k]
|
|
282
|
+
else dst[k] = v
|
|
283
|
+
applied.push(`models.${st}.${k}`)
|
|
284
|
+
}
|
|
285
|
+
// source 只接受 session / api,别的值(含乱写)一律当"没配"
|
|
286
|
+
if (typeof dst.source === 'string' && dst.source !== 'session' && dst.source !== 'api') {
|
|
287
|
+
delete dst.source
|
|
288
|
+
ignored.push(`models.${st}.source`)
|
|
289
|
+
}
|
|
290
|
+
if (Object.keys(dst).length) mm[st] = dst
|
|
291
|
+
else delete mm[st]
|
|
292
|
+
}
|
|
293
|
+
next.models = mm
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── 关思考 ──
|
|
297
|
+
if (incoming.noThink && typeof incoming.noThink === 'object') {
|
|
298
|
+
const nt = { ...(cur.noThink || {}) }
|
|
299
|
+
for (const [k, kind] of Object.entries(NOTHINK_WRITABLE)) {
|
|
300
|
+
if (!(k in incoming.noThink)) continue
|
|
301
|
+
const v = coerce(kind, incoming.noThink[k])
|
|
302
|
+
if (v === undefined) { ignored.push('noThink.' + k); continue }
|
|
303
|
+
nt[k] = v
|
|
304
|
+
applied.push('noThink.' + k)
|
|
305
|
+
}
|
|
306
|
+
next.noThink = nt
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
mkdirSync(dir, { recursive: true })
|
|
311
|
+
writeFileSync(cfgPath, JSON.stringify(next, null, 2), 'utf8')
|
|
312
|
+
} catch (err) {
|
|
313
|
+
json(res, 500, { ok: false, error: '写入失败:' + String(err?.message ?? err) })
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
log?.info?.('[memory-tiers] 设置已更新:%s 项,重启 dsh 生效', applied.length)
|
|
317
|
+
json(res, 200, { ok: true, applied, ignored, needRestart: true })
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// ── 最近归档/压缩审计(设置页底部展示) ──
|
|
321
|
+
route('/dsh-memory/api/log', async (req, res) => {
|
|
322
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false }); return }
|
|
323
|
+
let lines = []
|
|
324
|
+
try {
|
|
325
|
+
const p = join(dir, 'capture.log')
|
|
326
|
+
if (existsSync(p)) {
|
|
327
|
+
const all = readFileSync(p, 'utf8').trim().split('\n')
|
|
328
|
+
lines = all.slice(-15)
|
|
329
|
+
}
|
|
330
|
+
} catch { /* 读不到就当空 */ }
|
|
331
|
+
json(res, 200, { ok: true, lines })
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
return () => { for (const off of disposers) { try { off() } catch { /* 已卸 */ } } }
|
|
335
|
+
}
|