dsh-memory-tiers 0.5.1 → 0.8.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/client.mjs CHANGED
@@ -1,425 +1,608 @@
1
- /**
2
- * dsh-memory-tiers —— 客户端半边:DSH「设置」里的 **记忆** section。
3
- *
4
- * 宿主提供的运行时:window.__ModuleLoader__.load({ id, factory }) + require('react')。
5
- * 挂载点:ctx.slots.register('settings.section', { id, order, label }, Component)。
6
- * 数据来源:本插件服务端的 /dsh-memory/api/*(settings.mjs)。
7
- *
8
- * 为什么不用 schemastery 声明 schema 让 dsh 自动生成表单:
9
- * link 安装的插件 import 不到 @deepseek-ai/*(见 ARCHITECTURE 19.5),拿不到那个包。
10
- * 手写反而能把「只读运行状态」「三层容量对照」「重启提示」排在一起。
11
- */
12
- window.__ModuleLoader__.load({
13
- id: 'dsh-memory-tiers',
14
- factory: (require) => {
15
- const module = { exports: {} }
16
- const exports = module.exports
17
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
-
19
- const React = require('react')
20
- const { createElement: h, useCallback, useEffect, useState } = React
21
-
22
- const API = '/dsh-memory/api'
23
- const CSS_TAG = 'dsh-memory-tiers/section.css'
24
- const CSS = [
25
- '.dmt{max-width:760px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
26
- '.dmt-title{margin:0;font-size:16px;font-weight:500;line-height:24px}',
27
- '.dmt-intro{color:var(--dsw-alias-label-tertiary);margin:0 0 4px;font-size:13px;line-height:20px}',
28
- '.dmt-group{margin-top:16px;font-size:13px;font-weight:500;line-height:20px}',
29
- '.dmt-rows{display:flex;flex-direction:column}',
30
- '.dmt-row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px 20px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap}',
31
- '.dmt-row:last-child{border-bottom:none}',
32
- '.dmt-row>div:first-child{flex:1;min-width:190px}',
33
- '.dmt-label{font-size:13px;line-height:19px}',
34
- '.dmt-hint{color:var(--dsw-alias-label-tertiary);margin-top:3px;font-size:11.5px;line-height:17px}',
35
- '.dmt-input,.dmt-select{background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;color:var(--dsw-alias-label-primary);font-size:12.5px;height:28px;padding:0 8px;min-width:180px;outline:none}',
36
- '.dmt-input:focus-visible{border-color:var(--dsw-alias-brand-primary)}',
37
- '.dmt-input.dmt-wide{min-width:280px}',
38
- '.dmt-check{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary)}',
39
- '.dmt-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:16px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
40
- '.dmt-btn{height:28px;padding:0 14px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-size:12.5px;cursor:pointer}',
41
- '.dmt-btn:hover{border-color:var(--dsw-alias-border-l1)}',
42
- '.dmt-btn.dmt-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff)}',
43
- '.dmt-btn:disabled{opacity:.5;cursor:default}',
44
- '.dmt-msg{font-size:12.5px;line-height:18px}',
45
- '.dmt-msg.dmt-ok{color:var(--dsw-alias-label-success,#3b6d11)}',
46
- '.dmt-msg.dmt-err{color:var(--dsw-alias-label-danger,#a32d2d)}',
47
- '.dmt-stats{display:flex;gap:18px;flex-wrap:wrap;margin-top:8px}',
48
- '.dmt-stat{min-width:92px}',
49
- '.dmt-stat b{display:block;font-size:15px;font-weight:500;line-height:22px}',
50
- '.dmt-stat span{color:var(--dsw-alias-label-tertiary);font-size:11.5px}',
51
- '.dmt-log{margin-top:8px;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-secondary,rgba(127,127,127,.06));font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;line-height:16px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow:auto}',
52
- '.dmt-mono{font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11.5px;word-break:break-all}',
53
- ].join('\n')
54
-
55
- function ensureCss() {
56
- if (typeof document === 'undefined') return
57
- if (document.querySelector('style[data-dmt="' + CSS_TAG + '"]')) return
58
- const el = document.createElement('style')
59
- el.dataset.dmt = CSS_TAG
60
- el.textContent = CSS
61
- document.head.appendChild(el)
62
- }
63
-
64
- const getPath = (obj, path) => path.reduce((o, k) => (o == null ? undefined : o[k]), obj)
65
- const setPath = (obj, path, value) => {
66
- const next = { ...obj }
67
- let cur = next
68
- for (let i = 0; i < path.length - 1; i++) {
69
- const k = path[i]
70
- cur[k] = { ...(cur[k] || {}) }
71
- cur = cur[k]
72
- }
73
- cur[path[path.length - 1]] = value
74
- return next
75
- }
76
-
77
- // ── 字段定义:改这里就能增减配置项(服务端白名单见 settings.mjs 的 WRITABLE) ──
78
- const FIELDS = [
79
- {
80
- group: '模型连接',
81
- path: ['apiKey'],
82
- label: 'API Key',
83
- type: 'password',
84
- wide: true,
85
- hint: '硅基流动的 key。留空则用本地哈希向量——能跑,但语义检索会弱很多。改完需重启。',
86
- },
87
- {
88
- group: '模型连接',
89
- path: ['apiBase'],
90
- label: 'API 地址',
91
- type: 'text',
92
- wide: true,
93
- hint: '默认 https://api.siliconflow.cn/v1。换别家兼容 OpenAI 接口的服务时改这里。',
94
- },
95
-
96
- {
97
- group: '模型(每个阶段可分开指定,留空走默认)',
98
- path: ['models', 'summary', 'model'],
99
- label: '摘要模型',
100
- type: 'text',
101
- wide: true,
102
- hint: '把一批对话压成摘要用哪个。留空 = deepseek-ai/DeepSeek-V4.1-Flash。摘要量最大,想单独走一家便宜的就填这里,并配上下面两项地址和 key。',
103
- },
104
- {
105
- group: '模型(每个阶段可分开指定,留空走默认)',
106
- path: ['models', 'summary', 'base'],
107
- label: '摘要 API 地址',
108
- type: 'text',
109
- wide: true,
110
- hint: '留空 = 用上面「模型连接」里的 API 地址。例:https://api.deepseek.com',
111
- },
112
- {
113
- group: '模型(每个阶段可分开指定,留空走默认)',
114
- path: ['models', 'summary', 'apiKey'],
115
- label: '摘要 API Key',
116
- type: 'password',
117
- wide: true,
118
- hint: '留空 = 用上面的 API Key。填了就表示摘要单独走另一家。',
119
- },
120
- {
121
- group: '模型(每个阶段可分开指定,留空走默认)',
122
- path: ['models', 'chat', 'model'],
123
- label: '轻任务模型',
124
- type: 'text',
125
- wide: true,
126
- hint: '判重、判矛盾、分类这些高频小任务,选快而便宜的。留空 = inclusionAI/Ling-mini-2.0。',
127
- },
128
- {
129
- group: '模型(每个阶段可分开指定,留空走默认)',
130
- path: ['models', 'embed', 'model'],
131
- label: '向量模型',
132
- type: 'text',
133
- wide: true,
134
- hint: '检索和入库判重都靠它。留空 = Qwen/Qwen3-Embedding-0.6B。换模型后向量维度不同,旧库要重建。',
135
- },
136
- {
137
- group: '模型(每个阶段可分开指定,留空走默认)',
138
- path: ['models', 'rerank', 'model'],
139
- label: '重排模型',
140
- type: 'text',
141
- wide: true,
142
- hint: '检索结果精排。留空 = Qwen/Qwen3-Reranker-0.6B。',
143
- },
144
-
145
- {
146
- group: '三层容量(决定每轮注入多少字)',
147
- path: ['memoryCfg', 'hotMaxChars'],
148
- label: 'L1 即时层',
149
- type: 'number',
150
- hint: '按时间保存的原文。满了取最老一半分流沉淀。默认 2000。',
151
- },
152
- {
153
- group: '三层容量(决定每轮注入多少字)',
154
- path: ['memoryCfg', 'consolidateMaxChars'],
155
- label: 'L2 巩固层',
156
- type: 'number',
157
- hint: '较重要的原话。默认 2500。',
158
- },
159
- {
160
- group: '三层容量(决定每轮注入多少字)',
161
- path: ['memoryCfg', 'longMaxChars'],
162
- label: 'L3 长期层',
163
- type: 'number',
164
- hint: '摘要。这个值直接决定每轮注入块的大小,默认 2000,调大更全但也更贵。',
165
- },
166
-
167
- {
168
- group: '上下文注入',
169
- path: ['injectHot'],
170
- label: '把 L1 也注入上下文',
171
- type: 'boolean',
172
- hint: '默认关。当前会话的对话本来就在历史里,重复注入会让注入块每轮变化、打掉 prompt cache。',
173
- },
174
- {
175
- group: '上下文注入',
176
- path: ['refreshMinMs'],
177
- label: '记忆块刷新间隔(毫秒)',
178
- type: 'number',
179
- hint: '默认 1800000(30 分钟)。内容变了也要等过节流窗口才刷新,保护缓存。',
180
- },
181
-
182
- {
183
- group: '历史压缩',
184
- path: ['autoCompact'],
185
- label: '自动压缩历史',
186
- type: 'boolean',
187
- hint: '每轮结束把宿主的会话历史压成一条占位,原文归档进记忆,不调用模型。',
188
- },
189
- {
190
- group: '历史压缩',
191
- path: ['compactKeepTurns'],
192
- label: '保留最近几轮原文',
193
- type: 'number',
194
- hint: '默认 1。设 0 会让模型完全看不见上一句问了什么,不建议。',
195
- },
196
- {
197
- group: '历史压缩',
198
- path: ['compactArchiveChars'],
199
- label: '单次归档字数上限',
200
- type: 'number',
201
- hint: '默认 1200。超出的部分截断收录,不会整段丢。',
202
- },
203
-
204
- {
205
- group: '写入捕获',
206
- path: ['userCaptureChars'],
207
- label: '单条用户消息上限',
208
- type: 'number',
209
- hint: '默认 2000 字。超长消息只记前这么多。',
210
- },
211
- {
212
- group: '写入捕获',
213
- path: ['toolCaptureChars'],
214
- label: '工具输出上限',
215
- type: 'number',
216
- hint: '默认 500 字。bash 一次能吐几万字,L1 才 2000 字。',
217
- },
218
- ]
219
-
220
- const GROUPS = []
221
- for (const f of FIELDS) if (!GROUPS.includes(f.group)) GROUPS.push(f.group)
222
-
223
- async function api(path, init) {
224
- const res = await fetch(API + path, {
225
- credentials: 'same-origin',
226
- headers: { 'content-type': 'application/json' },
227
- ...init,
228
- })
229
- const text = await res.text()
230
- let data
231
- try { data = JSON.parse(text) } catch { data = { ok: false, error: text.slice(0, 200) } }
232
- if (!res.ok || data.ok === false) throw new Error(data.error || 'HTTP ' + res.status)
233
- return data
234
- }
235
-
236
- function Field({ field, value, onChange }) {
237
- const kind = field.type
238
- const id = 'dmt-' + field.path.join('-')
239
- let control
240
- if (kind === 'boolean') {
241
- control = h('input', {
242
- id,
243
- className: 'dmt-check',
244
- type: 'checkbox',
245
- checked: value === true,
246
- onChange: (e) => onChange(e.target.checked),
247
- })
248
- } else {
249
- control = h('input', {
250
- id,
251
- className: 'dmt-input' + (field.wide ? ' dmt-wide' : ''),
252
- type: kind === 'number' ? 'number' : kind === 'password' ? 'password' : 'text',
253
- value: value === undefined || value === null ? '' : String(value),
254
- placeholder: kind === 'password' ? '留空则用本地哈希向量' : '',
255
- onChange: (e) => onChange(kind === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value),
256
- })
257
- }
258
- return h('div', { className: 'dmt-row' },
259
- h('div', null,
260
- h('label', { className: 'dmt-label', htmlFor: id }, field.label),
261
- field.hint ? h('div', { className: 'dmt-hint' }, field.hint) : null,
262
- ),
263
- control,
264
- )
265
- }
266
-
267
- function MemoryTiersSection() {
268
- const [cfg, setCfg] = useState(null)
269
- const [live, setLive] = useState(null)
270
- const [logs, setLogs] = useState([])
271
- const [busy, setBusy] = useState(false)
272
- const [msg, setMsg] = useState(null)
273
- const [err, setErr] = useState(null)
274
-
275
- const load = useCallback(async () => {
276
- try {
277
- const d = await api('/settings')
278
- setCfg(d.config || {})
279
- setLive(d.live || null)
280
- const l = await api('/log').catch(() => ({ lines: [] }))
281
- setLogs(l.lines || [])
282
- setErr(null)
283
- } catch (e) {
284
- setErr(String(e.message || e))
285
- }
286
- }, [])
287
-
288
- useEffect(() => { ensureCss(); load() }, [load])
289
-
290
- const setField = (path, value) => {
291
- setCfg((cur) => setPath(cur || {}, path, value))
292
- setMsg(null)
293
- }
294
-
295
- const save = async () => {
296
- setBusy(true)
297
- setMsg(null)
298
- setErr(null)
299
- try {
300
- const r = await api('/settings', { method: 'POST', body: JSON.stringify({ config: cfg }) })
301
- const applied = r.applied?.length ?? 0
302
- setMsg(applied > 0
303
- ? '已保存 ' + applied + ' 项。重启 dsh 后生效。'
304
- : '没有可保存的改动。')
305
- await load()
306
- } catch (e) {
307
- setErr('保存失败:' + String(e.message || e))
308
- } finally {
309
- setBusy(false)
310
- }
311
- }
312
-
313
- if (err && !cfg) {
314
- return h('div', { className: 'dmt' },
315
- h('h3', { className: 'dmt-title' }, '记忆'),
316
- h('div', { className: 'dmt-msg dmt-err' }, '读取配置失败:' + err),
317
- h('div', { className: 'dmt-actions' }, h('button', { className: 'dmt-btn', onClick: load }, '重试')),
318
- )
319
- }
320
-
321
- if (!cfg) return h('div', { className: 'dmt' }, h('div', { className: 'dmt-hint' }, '加载中…'))
322
-
323
- const c = live?.counts || {}
324
-
325
- return h('div', { className: 'dmt' },
326
- h('h3', { className: 'dmt-title' }, '记忆'),
327
- h('p', { className: 'dmt-intro' },
328
- '六层记忆 + 零 LLM 历史压缩。同一会话里记忆负责「记得住」,压缩负责「上下文不涨」。改完需要重启 dsh 生效。'),
329
-
330
- // ── 运行状态(只读) ──
331
- h('div', { className: 'dmt-group' }, '运行状态'),
332
- h('div', { className: 'dmt-stats' },
333
- h('div', { className: 'dmt-stat' }, h('b', null, String(c.L1 ?? '-')), h('span', null, 'L1 即时')),
334
- h('div', { className: 'dmt-stat' }, h('b', null, String(c.L2 ?? '-')), h('span', null, 'L2 巩固')),
335
- h('div', { className: 'dmt-stat' }, h('b', null, String(c.L3 ?? '-')), h('span', null, 'L3 长期')),
336
- h('div', { className: 'dmt-stat' }, h('b', null, String(c.DL3 ?? '-')), h('span', null, 'DL3 摘要库')),
337
- h('div', { className: 'dmt-stat' }, h('b', null, String(live?.vectors ?? '-')), h('span', null, '向量')),
338
- ),
339
- h('div', { className: 'dmt-row' },
340
- h('div', null,
341
- h('div', { className: 'dmt-label' }, '数据目录'),
342
- h('div', { className: 'dmt-hint' }, '整个目录是自包含的,拷走即可备份或迁移;用环境变量 MEMORY_TIERS_DIR 可换位置。'),
343
- ),
344
- h('div', { className: 'dmt-mono' }, live?.dir || '-'),
345
- ),
346
- h('div', { className: 'dmt-row' },
347
- h('div', null,
348
- h('div', { className: 'dmt-label' }, '语义检索'),
349
- h('div', { className: 'dmt-hint' }, live?.semantic ? '已接真模型' : '未配置 key,当前用本地哈希向量,关键词匹配为主'),
350
- ),
351
- h('div', { className: 'dmt-msg ' + (live?.semantic ? 'dmt-ok' : '') }, live?.semantic ? '开' : '关'),
352
- ),
353
- live?.models
354
- ? h('div', { className: 'dmt-row' },
355
- h('div', null,
356
- h('div', { className: 'dmt-label' }, '实际生效的模型'),
357
- h('div', { className: 'dmt-hint' }, '留空的项已回落到顶层配置或默认值。改完需重启才会重新解析。'),
358
- ),
359
- h('div', {
360
- className: 'dmt-mono',
361
- style: { whiteSpace: 'pre-line', textAlign: 'right' },
362
- }, Object.entries(live.models)
363
- .map(([k, v]) => k + ': ' + (v.model || '?') + ' @ ' + String(v.base || '').replace(/^https?:\/\//, ''))
364
- .join('\n')),
365
- )
366
- : null,
367
-
368
- // ── 配置字段 ──
369
- ...GROUPS.map((g) =>
370
- h('div', { key: g },
371
- h('div', { className: 'dmt-group' }, g),
372
- h('div', { className: 'dmt-rows' },
373
- FIELDS.filter((f) => f.group === g).map((f) =>
374
- h(Field, {
375
- key: f.path.join('.'),
376
- field: f,
377
- value: getPath(cfg, f.path),
378
- onChange: (v) => setField(f.path, v),
379
- }),
380
- ),
381
- ),
382
- ),
383
- ),
384
-
385
- // ── 操作 ──
386
- h('div', { className: 'dmt-actions' },
387
- h('button', { className: 'dmt-btn dmt-primary', onClick: save, disabled: busy }, busy ? '保存中…' : '保存'),
388
- h('button', { className: 'dmt-btn', onClick: load, disabled: busy }, '重新读取'),
389
- msg ? h('span', { className: 'dmt-msg dmt-ok' }, msg) : null,
390
- err ? h('span', { className: 'dmt-msg dmt-err' }, err) : null,
391
- ),
392
-
393
- // ── 最近审计 ──
394
- h('div', { className: 'dmt-group' }, '最近归档 / 压缩(capture.log 末尾)'),
395
- logs.length
396
- ? h('div', { className: 'dmt-log' }, logs.join('\n'))
397
- : h('div', { className: 'dmt-hint' }, '还没有记录。聊几轮后这里会出现每轮的归档与压缩行。'),
398
- )
399
- }
400
-
401
- const inject = ['slots']
402
- const NS = 'memory-tiers'
403
- const NAMES = { nav: '记忆' }
404
-
405
- function apply(ctx) {
406
- ctx.effect(
407
- () =>
408
- ctx.slots.inject('settings.section', () =>
409
- ctx.slots.register(
410
- { name: 'settings.section', id: NS, order: 7, label: () => NAMES.nav },
411
- MemoryTiersSection,
412
- ),
413
- 'memory-tiers: settings section',
414
- ),
415
- 'memory-tiers: settings section',
416
- )
417
- }
418
-
419
- exports.apply = apply
420
- exports.inject = inject
421
- exports.MemoryTiersSection = MemoryTiersSection
422
- exports.FIELDS = FIELDS
423
- return module.exports
424
- },
425
- })
1
+ /**
2
+ * dsh-memory-tiers —— 客户端半边:DSH「设置」里的 **记忆** section。
3
+ *
4
+ * 宿主提供的运行时:window.__ModuleLoader__.load({ id, factory }) + require('react')。
5
+ * 挂载点:ctx.slots.register('settings.section', { id, order, label }, Component)。
6
+ * 数据来源:本插件服务端的 /dsh-memory/api/*(settings.mjs)。
7
+ *
8
+ * 为什么不用 schemastery 声明 schema 让 dsh 自动生成表单:
9
+ * link 安装的插件 import 不到 @deepseek-ai/*(见 ARCHITECTURE 19.5),拿不到那个包。
10
+ * 手写反而能把「只读运行状态」「三层容量对照」「重启提示」排在一起。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-memory-tiers',
14
+ factory: (require) => {
15
+ const module = { exports: {} }
16
+ const exports = module.exports
17
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
+
19
+ const React = require('react')
20
+ const { createElement: h, useCallback, useEffect, useState } = React
21
+
22
+ const API = '/dsh-memory/api'
23
+ const CSS_TAG = 'dsh-memory-tiers/section.css'
24
+ const CSS = [
25
+ '.dmt{max-width:760px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
26
+ '.dmt-title{margin:0;font-size:16px;font-weight:500;line-height:24px}',
27
+ '.dmt-intro{color:var(--dsw-alias-label-tertiary);margin:0 0 4px;font-size:13px;line-height:20px}',
28
+ '.dmt-group{margin-top:16px;font-size:13px;font-weight:500;line-height:20px}',
29
+ '.dmt-rows{display:flex;flex-direction:column}',
30
+ '.dmt-row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px 20px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap}',
31
+ '.dmt-row:last-child{border-bottom:none}',
32
+ '.dmt-row>div:first-child{flex:1;min-width:190px}',
33
+ '.dmt-label{font-size:13px;line-height:19px}',
34
+ '.dmt-hint{color:var(--dsw-alias-label-tertiary);margin-top:3px;font-size:11.5px;line-height:17px}',
35
+ '.dmt-input,.dmt-select{background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;color:var(--dsw-alias-label-primary);font-size:12.5px;height:28px;padding:0 8px;min-width:180px;outline:none}',
36
+ '.dmt-input:focus-visible{border-color:var(--dsw-alias-brand-primary)}',
37
+ '.dmt-input.dmt-wide{min-width:280px}',
38
+ '.dmt-check{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary)}',
39
+ '.dmt-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:16px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
40
+ '.dmt-btn{height:28px;padding:0 14px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-size:12.5px;cursor:pointer}',
41
+ '.dmt-btn:hover{border-color:var(--dsw-alias-border-l1)}',
42
+ '.dmt-btn.dmt-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff)}',
43
+ '.dmt-btn:disabled{opacity:.5;cursor:default}',
44
+ '.dmt-msg{font-size:12.5px;line-height:18px}',
45
+ '.dmt-msg.dmt-ok{color:var(--dsw-alias-label-success,#3b6d11)}',
46
+ '.dmt-msg.dmt-warn{color:var(--dsw-alias-label-warning,#854f0b)}',
47
+ '.dmt-msg.dmt-err{color:var(--dsw-alias-label-danger,#a32d2d)}',
48
+ '.dmt-stats{display:flex;gap:18px;flex-wrap:wrap;margin-top:8px}',
49
+ '.dmt-stat{min-width:92px}',
50
+ '.dmt-stat b{display:block;font-size:15px;font-weight:500;line-height:22px}',
51
+ '.dmt-stat span{color:var(--dsw-alias-label-tertiary);font-size:11.5px}',
52
+ '.dmt-log{margin-top:8px;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-secondary,rgba(127,127,127,.06));font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;line-height:16px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow:auto}',
53
+ '.dmt-mono{font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11.5px;word-break:break-all}',
54
+ '.dmt-table{width:100%;border-collapse:collapse;margin-top:8px;font-size:12.5px}',
55
+ '.dmt-table th{text-align:left;font-weight:500;color:var(--dsw-alias-label-tertiary);font-size:11.5px;padding:4px 8px 4px 0;border-bottom:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.2))}',
56
+ '.dmt-table td{padding:5px 8px 5px 0;border-bottom:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.12));vertical-align:top}',
57
+ '.dmt-num{text-align:right;font-family:var(--dsw-font-mono,ui-monospace,monospace);white-space:nowrap}',
58
+ '.dmt-tag{margin-left:6px;padding:0 5px;border-radius:4px;font-size:10.5px;background:var(--dsw-alias-bg-tertiary,rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary)}',
59
+ '.dmt-preset{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}',
60
+ '.dmt-card{flex:1 1 220px;min-width:200px;padding:10px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.25));cursor:pointer}',
61
+ '.dmt-card.dmt-on{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-secondary,rgba(127,127,127,.06))}',
62
+ '.dmt-card b{display:block;font-size:12.5px;font-weight:500}',
63
+ '.dmt-card span{display:block;margin-top:3px;font-size:11.5px;color:var(--dsw-alias-label-tertiary);line-height:1.5}',
64
+ ].join('\n')
65
+
66
+ function ensureCss() {
67
+ if (typeof document === 'undefined') return
68
+ if (document.querySelector('style[data-dmt="' + CSS_TAG + '"]')) return
69
+ const el = document.createElement('style')
70
+ el.dataset.dmt = CSS_TAG
71
+ el.textContent = CSS
72
+ document.head.appendChild(el)
73
+ }
74
+
75
+ const getPath = (obj, path) => path.reduce((o, k) => (o == null ? undefined : o[k]), obj)
76
+ const setPath = (obj, path, value) => {
77
+ const next = { ...obj }
78
+ let cur = next
79
+ for (let i = 0; i < path.length - 1; i++) {
80
+ const k = path[i]
81
+ cur[k] = { ...(cur[k] || {}) }
82
+ cur = cur[k]
83
+ }
84
+ cur[path[path.length - 1]] = value
85
+ return next
86
+ }
87
+
88
+ // ── 六层分别是什么,设置页顶部照这个表画 ──
89
+ const LAYERS = [
90
+ { key: 'L1', name: 'L1 即时', inCtx: true, desc: '当前会话的对话原话与事件,按时间保存;满了取最老一半往下分流' },
91
+ { key: 'L2', name: 'L2 巩固', inCtx: true, desc: '较重要的原话,留得住细节;溢出时按是否被摘要引用决定去留' },
92
+ { key: 'L3', name: 'L3 长期', inCtx: true, desc: '摘要视图(不落盘,从 DL3 排序截出),直接决定注入上下文多少字' },
93
+ { key: 'DL1', name: 'DL1 遗忘中', inCtx: false, desc: '被判不重要的原文,慢慢遗忘;被再次关注会升回 L2' },
94
+ { key: 'DL2', name: 'DL2 原文库', inCtx: false, desc: '被摘要引用过的重要原文,保细节,按需检索' },
95
+ { key: 'DL3', name: 'DL3 摘要库', inCtx: false, desc: 'L3 溢出的摘要,长期沉淀;融合去重在这里做,按需检索' },
96
+ ]
97
+
98
+ // ── 字段定义:改这里就能增减配置项(服务端白名单见 settings.mjs 的 WRITABLE) ──
99
+ const FIELDS = [
100
+ {
101
+ group: '数据共享(多个程序共用一个库)',
102
+ path: ['dir'],
103
+ label: '数据目录',
104
+ type: 'text',
105
+ wide: true,
106
+ hint: '记忆存在哪。改成 dsh 和 astrbot 都能访问的路径就能共享同一份记忆。'
107
+ + '它存在固定位置的指针文件里(不在 config.json 里——config.json 就在数据目录里面,'
108
+ + '得先知道目录才能读它)。换目录要把旧目录里的文件一起搬过去。改完需重启。',
109
+ },
110
+ {
111
+ group: '数据共享(多个程序共用一个库)',
112
+ path: ['writer'],
113
+ label: '写者标识',
114
+ type: 'text',
115
+ wide: true,
116
+ hint: '共用同一个目录时,**每个程序必须填得不一样**(如 dsh / astrbot)。'
117
+ + '它决定这个程序写哪份文件(store-<写者>.json)。填成一样的两个程序会互相覆盖。'
118
+ + '只读字母、数字、下划线和短横线。改完需重启。',
119
+ },
120
+ {
121
+ group: '嵌入与重排',
122
+ path: ['apiKey'],
123
+ label: 'API Key',
124
+ type: 'password',
125
+ wide: true,
126
+ hint: '硅基流动的免费模型也要 key(去硅基流动官网领)。不填就没有语义检索,只走关键词。融合与摘要不受影响——它们默认跟当前会话模型走。改完需重启。',
127
+ },
128
+ {
129
+ group: '嵌入与重排',
130
+ path: ['apiBase'],
131
+ label: 'API 地址(两者共用)',
132
+ type: 'text',
133
+ wide: true,
134
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
135
+ hint: '嵌入与重排的默认地址;下面各自还能单独覆盖。留空 = https://api.siliconflow.cn/v1。',
136
+ },
137
+
138
+ {
139
+ group: '嵌入与重排',
140
+ path: ['models', 'embed', 'model'],
141
+ label: '嵌入模型',
142
+ type: 'text',
143
+ wide: true,
144
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
145
+ hint: '把记忆变成向量,检索和判重都靠它。硅基流动默认用 BAAI/bge-m3(免费、1024 维)。换模型必须跑一次 reindex,否则旧向量全是废的。',
146
+ },
147
+ {
148
+ group: '嵌入与重排',
149
+ path: ['models', 'embed', 'base'],
150
+ label: '嵌入 API 地址',
151
+ type: 'text',
152
+ wide: true,
153
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
154
+ hint: '留空 = 用上面「模型连接」里的地址。例:https://api.siliconflow.cn/v1',
155
+ },
156
+ {
157
+ group: '嵌入与重排',
158
+ path: ['models', 'embed', 'apiKey'],
159
+ label: '嵌入 API Key',
160
+ type: 'password',
161
+ wide: true,
162
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
163
+ hint: '留空 = 用上面的 API Key。',
164
+ },
165
+ {
166
+ group: '嵌入与重排',
167
+ path: ['models', 'rerank', 'model'],
168
+ label: '重排模型',
169
+ type: 'text',
170
+ wide: true,
171
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
172
+ hint: '检索结果的精排(判断"这条到底相不相关")。硅基流动默认 BAAI/bge-reranker-v2-m3,和 bge-m3 同门。',
173
+ },
174
+ {
175
+ group: '嵌入与重排',
176
+ path: ['models', 'rerank', 'base'],
177
+ label: '重排 API 地址',
178
+ type: 'text',
179
+ wide: true,
180
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
181
+ hint: '留空 = 用上面「模型连接」里的地址。',
182
+ },
183
+ {
184
+ group: '嵌入与重排',
185
+ path: ['models', 'rerank', 'apiKey'],
186
+ label: '重排 API Key',
187
+ type: 'password',
188
+ wide: true,
189
+ showIf: (c) => (c?.preset || 'siliconflow') === 'custom',
190
+ hint: '留空 = 用上面的 API Key。',
191
+ },
192
+
193
+ {
194
+ group: '融合与摘要',
195
+ path: ['models', 'chat', 'model'],
196
+ label: '融合模型(自己指定时)',
197
+ type: 'text',
198
+ wide: true,
199
+ showIf: (c) => c?.models?.chat?.source === 'api',
200
+ hint: '判重 / 判矛盾 / 判进展 / 分类。这活只要一个词,用便宜快的小模型就够,别上大模型。',
201
+ },
202
+ {
203
+ group: '融合与摘要',
204
+ path: ['models', 'chat', 'base'],
205
+ label: '融合 API 地址',
206
+ type: 'text',
207
+ wide: true,
208
+ showIf: (c) => c?.models?.chat?.source === 'api',
209
+ hint: '留空 = 用上面「模型连接」里的地址。',
210
+ },
211
+ {
212
+ group: '融合与摘要',
213
+ path: ['models', 'chat', 'apiKey'],
214
+ label: '融合 API Key',
215
+ type: 'password',
216
+ wide: true,
217
+ showIf: (c) => c?.models?.chat?.source === 'api',
218
+ hint: '留空 = 用上面的 API Key。',
219
+ },
220
+ {
221
+ group: '融合与摘要',
222
+ path: ['noThink', 'chat'],
223
+ label: '融合时关思考',
224
+ type: 'boolean',
225
+ hint: '推荐开。判关系只要一个词,开思考既慢又费 token,还可能把词藏进推理里。',
226
+ },
227
+ {
228
+ group: '融合与摘要',
229
+ path: ['models', 'summary', 'model'],
230
+ label: '摘要模型(自己指定时)',
231
+ type: 'text',
232
+ wide: true,
233
+ showIf: (c) => c?.models?.summary?.source === 'api',
234
+ hint: '把一批对话压成摘要。要它读得多、写得准,建议用比融合模型大一号或多一号的。',
235
+ },
236
+ {
237
+ group: '融合与摘要',
238
+ path: ['models', 'summary', 'base'],
239
+ label: '摘要 API 地址',
240
+ type: 'text',
241
+ wide: true,
242
+ showIf: (c) => c?.models?.summary?.source === 'api',
243
+ hint: '留空 = 用上面「模型连接」里的地址。',
244
+ },
245
+ {
246
+ group: '融合与摘要',
247
+ path: ['models', 'summary', 'apiKey'],
248
+ label: '摘要 API Key',
249
+ type: 'password',
250
+ wide: true,
251
+ showIf: (c) => c?.models?.summary?.source === 'api',
252
+ hint: '留空 = 用上面的 API Key。',
253
+ },
254
+ {
255
+ group: '融合与摘要',
256
+ path: ['noThink', 'summary'],
257
+ label: '摘要时关思考',
258
+ type: 'boolean',
259
+ hint: '默认不关。摘要要想清楚再压,压错了信息就真丢了。',
260
+ },
261
+
262
+ {
263
+ group: '三层容量(决定每轮注入多少字)',
264
+ path: ['memoryCfg', 'hotMaxChars'],
265
+ label: 'L1 即时层',
266
+ type: 'number',
267
+ hint: '按时间保存的原文。满了取最老一半分流沉淀。默认 2000。',
268
+ },
269
+ {
270
+ group: '三层容量(决定每轮注入多少字)',
271
+ path: ['memoryCfg', 'consolidateMaxChars'],
272
+ label: 'L2 巩固层',
273
+ type: 'number',
274
+ hint: '较重要的原话。默认 2500。',
275
+ },
276
+ {
277
+ group: '三层容量(决定每轮注入多少字)',
278
+ path: ['memoryCfg', 'longMaxChars'],
279
+ label: 'L3 长期层',
280
+ type: 'number',
281
+ hint: '摘要。这个值直接决定每轮注入块的大小,默认 2000,调大更全但也更贵。',
282
+ },
283
+
284
+ {
285
+ group: '三层容量(决定每轮注入多少字)',
286
+ path: ['refreshMinMs'],
287
+ label: 'L3 视图刷新间隔(毫秒)',
288
+ type: 'number',
289
+ hint: '默认 1800000(30 分钟)。只有 L3 需要节流:它排在注入块最前,一变后面 L2/L1 的缓存全断。L1/L2 不节流,写了就带上下一轮。',
290
+ },
291
+
292
+ {
293
+ group: '写入捕获',
294
+ path: ['userCaptureChars'],
295
+ label: '单条用户消息上限',
296
+ type: 'number',
297
+ hint: '默认 2000 字。超长消息只记前这么多。',
298
+ },
299
+ {
300
+ group: '写入捕获',
301
+ path: ['toolCaptureChars'],
302
+ label: '工具输出上限',
303
+ type: 'number',
304
+ hint: '默认 500 字。bash 一次能吐几万字,L1 才 2000 字。',
305
+ },
306
+ ]
307
+
308
+ const GROUPS = []
309
+ for (const f of FIELDS) if (!GROUPS.includes(f.group)) GROUPS.push(f.group)
310
+
311
+ async function api(path, init) {
312
+ const res = await fetch(API + path, {
313
+ credentials: 'same-origin',
314
+ headers: { 'content-type': 'application/json' },
315
+ ...init,
316
+ })
317
+ const text = await res.text()
318
+ let data
319
+ try { data = JSON.parse(text) } catch { data = { ok: false, error: text.slice(0, 200) } }
320
+ if (!res.ok || data.ok === false) throw new Error(data.error || 'HTTP ' + res.status)
321
+ return data
322
+ }
323
+
324
+ function Field({ field, value, onChange }) {
325
+ const kind = field.type
326
+ const id = 'dmt-' + field.path.join('-')
327
+ let control
328
+ if (kind === 'boolean') {
329
+ control = h('input', {
330
+ id,
331
+ className: 'dmt-check',
332
+ type: 'checkbox',
333
+ checked: value === true,
334
+ onChange: (e) => onChange(e.target.checked),
335
+ })
336
+ } else {
337
+ control = h('input', {
338
+ id,
339
+ className: 'dmt-input' + (field.wide ? ' dmt-wide' : ''),
340
+ type: kind === 'number' ? 'number' : kind === 'password' ? 'password' : 'text',
341
+ value: value === undefined || value === null ? '' : String(value),
342
+ placeholder: kind === 'password' ? '不填就不启用语义检索' : '',
343
+ onChange: (e) => onChange(kind === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value),
344
+ })
345
+ }
346
+ return h('div', { className: 'dmt-row' },
347
+ h('div', null,
348
+ h('label', { className: 'dmt-label', htmlFor: id }, field.label),
349
+ field.hint ? h('div', { className: 'dmt-hint' }, field.hint) : null,
350
+ ),
351
+ control,
352
+ )
353
+ }
354
+
355
+ function MemoryTiersSection() {
356
+ const [cfg, setCfg] = useState(null)
357
+ const [live, setLive] = useState(null)
358
+ const [logs, setLogs] = useState([])
359
+ const [busy, setBusy] = useState(false)
360
+ const [msg, setMsg] = useState(null)
361
+ const [err, setErr] = useState(null)
362
+
363
+ const load = useCallback(async () => {
364
+ try {
365
+ const d = await api('/settings')
366
+ setCfg(d.config || {})
367
+ setLive(d.live || null)
368
+ const l = await api('/log').catch(() => ({ lines: [] }))
369
+ setLogs(l.lines || [])
370
+ setErr(null)
371
+ } catch (e) {
372
+ setErr(String(e.message || e))
373
+ }
374
+ }, [])
375
+
376
+ useEffect(() => { ensureCss(); load() }, [load])
377
+
378
+ const setField = (path, value) => {
379
+ setCfg((cur) => setPath(cur || {}, path, value))
380
+ setMsg(null)
381
+ }
382
+
383
+ const save = async () => {
384
+ setBusy(true)
385
+ setMsg(null)
386
+ setErr(null)
387
+ try {
388
+ const r = await api('/settings', { method: 'POST', body: JSON.stringify({ config: cfg }) })
389
+ const applied = r.applied?.length ?? 0
390
+ setMsg(applied > 0
391
+ ? '已保存 ' + applied + ' 项。重启 dsh 后生效。'
392
+ : '没有可保存的改动。')
393
+ await load()
394
+ } catch (e) {
395
+ setErr('保存失败:' + String(e.message || e))
396
+ } finally {
397
+ setBusy(false)
398
+ }
399
+ }
400
+
401
+ if (err && !cfg) {
402
+ return h('div', { className: 'dmt' },
403
+ h('h3', { className: 'dmt-title' }, '记忆'),
404
+ h('div', { className: 'dmt-msg dmt-err' }, '读取配置失败:' + err),
405
+ h('div', { className: 'dmt-actions' }, h('button', { className: 'dmt-btn', onClick: load }, '重试')),
406
+ )
407
+ }
408
+
409
+ if (!cfg) return h('div', { className: 'dmt' }, h('div', { className: 'dmt-hint' }, '加载中…'))
410
+
411
+ const c = live?.counts || {}
412
+ const ch = live?.chars || {}
413
+
414
+ // ── 嵌入/重排:硅基流动免费模型 还是 自己指定 ──
415
+ const presetNow = cfg?.preset || 'siliconflow'
416
+ const presetCards = h('div', { className: 'dmt-preset' },
417
+ h('div', {
418
+ className: 'dmt-card' + (presetNow === 'siliconflow' ? ' dmt-on' : ''),
419
+ onClick: () => setField(['preset'], 'siliconflow'),
420
+ },
421
+ h('b', null, '硅基流动 · 免费模型'),
422
+ h('span', null, '嵌入 BAAI/bge-m3 + 重排 BAAI/bge-reranker-v2-m3。中文够用,日常首选,只要一个 key。'),
423
+ ),
424
+ h('div', {
425
+ className: 'dmt-card' + (presetNow === 'custom' ? ' dmt-on' : ''),
426
+ onClick: () => setField(['preset'], 'custom'),
427
+ },
428
+ h('b', null, '自己指定来源'),
429
+ h('span', null, '填下面的地址 / Key / 模型名,可以换更高级的或自建服务。'),
430
+ ),
431
+ )
432
+
433
+ // ── 融合/摘要:跟当前会话模型走,还是自己指定 ──
434
+ const sess = live?.session
435
+ const sourceRow = (stage, title, tip) => {
436
+ const cur = cfg?.models?.[stage]?.source || 'session'
437
+ return h('div', { className: 'dmt-row', key: stage },
438
+ h('div', null,
439
+ h('div', { className: 'dmt-label' }, title),
440
+ h('div', { className: 'dmt-hint' }, tip),
441
+ ),
442
+ h('div', { className: 'dmt-preset', style: { marginTop: 0, gap: 6 } },
443
+ h('div', {
444
+ className: 'dmt-card' + (cur === 'session' ? ' dmt-on' : ''),
445
+ style: { flex: '0 0 auto', minWidth: 132 },
446
+ onClick: () => setField(['models', stage, 'source'], 'session'),
447
+ },
448
+ h('b', null, '跟随当前会话模型'),
449
+ h('span', null, sess?.available ? `现在:${sess.model}` : '(通道不可用)'),
450
+ ),
451
+ h('div', {
452
+ className: 'dmt-card' + (cur === 'api' ? ' dmt-on' : ''),
453
+ style: { flex: '0 0 auto', minWidth: 100 },
454
+ onClick: () => setField(['models', stage, 'source'], 'api'),
455
+ },
456
+ h('b', null, '自己指定'),
457
+ h('span', null, '填下面的模型 / 地址 / Key'),
458
+ ),
459
+ ),
460
+ )
461
+ }
462
+ const sessionBlock = h('div', null,
463
+ h('div', { className: 'dmt-hint', style: { marginBottom: 6 } },
464
+ sess?.available
465
+ ? `当前会话模型:${sess.model}(${sess.provider})。选「跟随」就不用额外配 key,对话换模型它也跟着换。`
466
+ : `没检测到会话模型通道(${sess?.reason || '未知'}):这两个阶段只能自己指定外部来源。`),
467
+ sourceRow('chat', '融合模型(判重 / 判矛盾 / 分类)', '只输出一个词,建议便宜快的小模型'),
468
+ sourceRow('summary', '摘要模型', '要把几十条压成一段,建议用比融合模型大一号的'),
469
+ )
470
+
471
+ return h('div', { className: 'dmt' },
472
+ h('h3', { className: 'dmt-title' }, '记忆'),
473
+ h('p', { className: 'dmt-intro' },
474
+ '六层记忆 + 零 LLM 历史压缩。同一会话里记忆负责「记得住」,压缩负责「上下文不涨」。改完需要重启 dsh 生效。'),
475
+
476
+ // ── 每层存量(只读):条数 + 字数 + 每层干什么 ──
477
+ h('div', { className: 'dmt-group' }, '每层现在存了多少'),
478
+ h('table', { className: 'dmt-table' },
479
+ h('thead', null,
480
+ h('tr', null,
481
+ h('th', null, '层'),
482
+ h('th', null, '条数'),
483
+ h('th', null, '字数'),
484
+ h('th', null, '存的是什么 / 怎么用'),
485
+ ),
486
+ ),
487
+ h('tbody', null,
488
+ LAYERS.map((l) => {
489
+ const n = c[l.key] ?? 0
490
+ const s = ch[l.key] ?? 0
491
+ return h('tr', { key: l.key },
492
+ h('td', null, h('b', null, l.name), l.inCtx ? h('span', { className: 'dmt-tag' }, '进上下文') : null),
493
+ h('td', { className: 'dmt-num' }, String(n)),
494
+ h('td', { className: 'dmt-num' }, s ? s.toLocaleString() : '0'),
495
+ h('td', { className: 'dmt-hint' }, l.desc),
496
+ )
497
+ }),
498
+ h('tr', { key: 'vec' },
499
+ h('td', null, h('b', null, '向量')),
500
+ h('td', { className: 'dmt-num' }, String(live?.vectors ?? 0)),
501
+ h('td', { className: 'dmt-num' }, '-'),
502
+ h('td', { className: 'dmt-hint' }, '有向量的条目才能语义检索;0 = 没接模型或还没重建'),
503
+ ),
504
+ ),
505
+ ),
506
+ h('div', { className: 'dmt-hint', style: { marginTop: 4 } },
507
+ `合计 ${live?.total ?? 0} 条。` + (live?.semantic
508
+ ? '语义检索已开启。'
509
+ : '没接模型:检索只走关键词。')),
510
+ h('div', { className: 'dmt-row' },
511
+ h('div', null,
512
+ h('div', { className: 'dmt-label' }, '数据目录'),
513
+ h('div', { className: 'dmt-hint' }, '整个目录是自包含的,拷走即可备份或迁移;用环境变量 MEMORY_TIERS_DIR 可换位置。'),
514
+ ),
515
+ h('div', { className: 'dmt-mono' }, live?.dir || '-'),
516
+ ),
517
+ h('div', { className: 'dmt-row' },
518
+ h('div', null,
519
+ h('div', { className: 'dmt-label' }, '语义检索'),
520
+ h('div', { className: 'dmt-hint' }, live?.semantic
521
+ ? '已接真模型'
522
+ : '未连接模型:语义检索关闭,只按关键词命中(没有假向量)'),
523
+ ),
524
+ h('div', { className: 'dmt-msg ' + (live?.semantic ? 'dmt-ok' : 'dmt-warn') }, live?.semantic ? '开' : '关'),
525
+ ),
526
+ live?.semantic ? null : h('div', { className: 'dmt-row', style: { borderLeft: '2px solid var(--dsw-alias-border-warning, #BA7517)', paddingLeft: 12 } },
527
+ h('div', null,
528
+ h('div', { className: 'dmt-label' }, '需要先接模型'),
529
+ h('div', { className: 'dmt-hint' }, '在上面「API Key」填硅基流动的 key 并保存,重启 dsh 后语义检索才会启用。已经有的旧向量不会被读取,也不会被删。'),
530
+ ),
531
+ ),
532
+ live?.models
533
+ ? h('div', { className: 'dmt-row' },
534
+ h('div', null,
535
+ h('div', { className: 'dmt-label' }, '实际生效的模型'),
536
+ h('div', { className: 'dmt-hint' }, '留空的项已回落到顶层配置或默认值。改完需重启才会重新解析。'),
537
+ ),
538
+ h('div', {
539
+ className: 'dmt-mono',
540
+ style: { whiteSpace: 'pre-line', textAlign: 'right' },
541
+ }, Object.entries(live.models)
542
+ .map(([k, v]) => k + ': ' + (v.source === 'session'
543
+ ? '跟随会话模型 ' + (v.sessionModel || '?')
544
+ : (v.model || '?') + ' @ ' + String(v.base || '').replace(/^https?:\/\//, '')))
545
+ .join('\n')),
546
+ )
547
+ : null,
548
+
549
+ // ── 配置字段 ──
550
+ ...GROUPS.map((g) =>
551
+ h('div', { key: g },
552
+ h('div', { className: 'dmt-group' }, g),
553
+ g === '嵌入与重排' ? presetCards : null,
554
+ g === '融合与摘要' ? sessionBlock : null,
555
+ h('div', { className: 'dmt-rows' },
556
+ FIELDS.filter((f) => f.group === g && (!f.showIf || f.showIf(cfg, live))).map((f) =>
557
+ h(Field, {
558
+ key: f.path.join('.'),
559
+ field: f,
560
+ value: getPath(cfg, f.path),
561
+ onChange: (v) => setField(f.path, v),
562
+ }),
563
+ ),
564
+ ),
565
+ ),
566
+ ),
567
+
568
+ // ── 操作 ──
569
+ h('div', { className: 'dmt-actions' },
570
+ h('button', { className: 'dmt-btn dmt-primary', onClick: save, disabled: busy }, busy ? '保存中…' : '保存'),
571
+ h('button', { className: 'dmt-btn', onClick: load, disabled: busy }, '重新读取'),
572
+ msg ? h('span', { className: 'dmt-msg dmt-ok' }, msg) : null,
573
+ err ? h('span', { className: 'dmt-msg dmt-err' }, err) : null,
574
+ ),
575
+
576
+ // ── 最近审计 ──
577
+ h('div', { className: 'dmt-group' }, '最近归档 / 压缩(capture.log 末尾)'),
578
+ logs.length
579
+ ? h('div', { className: 'dmt-log' }, logs.join('\n'))
580
+ : h('div', { className: 'dmt-hint' }, '还没有记录。聊几轮后这里会出现每轮的归档与压缩行。'),
581
+ )
582
+ }
583
+
584
+ const inject = ['slots']
585
+ const NS = 'memory-tiers'
586
+ const NAMES = { nav: '记忆' }
587
+
588
+ function apply(ctx) {
589
+ ctx.effect(
590
+ () =>
591
+ ctx.slots.inject('settings.section', () =>
592
+ ctx.slots.register(
593
+ { name: 'settings.section', id: NS, order: 7, label: () => NAMES.nav },
594
+ MemoryTiersSection,
595
+ ),
596
+ 'memory-tiers: settings section',
597
+ ),
598
+ 'memory-tiers: settings section',
599
+ )
600
+ }
601
+
602
+ exports.apply = apply
603
+ exports.inject = inject
604
+ exports.MemoryTiersSection = MemoryTiersSection
605
+ exports.FIELDS = FIELDS
606
+ return module.exports
607
+ },
608
+ })