@dafantong01/dsh-memory-palace 0.0.0-stage → 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,637 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-memory-palace',
3
+ factory: (require) => {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const API = '/memory-palace-api'
7
+
8
+ const s = {
9
+ root: { padding: '24px 20px 8px', fontSize: '13px', lineHeight: 1.65, color: 'var(--dsh-text, #222)' },
10
+ page: { maxWidth: '820px', margin: '0 auto', width: '100%', boxSizing: 'border-box' },
11
+ header: { margin: '0 0 14px' },
12
+ title: { fontSize: '18px', fontWeight: 600, margin: 0, letterSpacing: '0.01em' },
13
+ subtitle: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px', marginTop: '4px', fontSize: '12px', opacity: 0.72, wordBreak: 'break-all' },
14
+ row: { margin: '2px 0' },
15
+ muted: { opacity: 0.65 },
16
+ bar: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', margin: 0 },
17
+ btn: { padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(128,128,128,.45)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: '13px', fontWeight: 500 },
18
+ btnPrimary: { padding: '5px 16px', borderRadius: '6px', border: '1px solid transparent', background: 'var(--dsh-accent, #3b6ef5)', color: '#fff', cursor: 'pointer', fontSize: '13px', fontWeight: 600 },
19
+ btnSecondary: { padding: '5px 14px', borderRadius: '6px', border: '1px solid rgba(128,128,128,.45)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: '13px', fontWeight: 500 },
20
+ input: { width: '84px', padding: '4px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,.45)', background: 'transparent', color: 'inherit', fontSize: '13px', boxSizing: 'border-box' },
21
+ field: { display: 'flex', flexDirection: 'column', gap: '2px', margin: 0, minWidth: 0 },
22
+ label: { fontSize: '12px', opacity: 0.75, lineHeight: 1.5 },
23
+ ctrlRow: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', minWidth: 0 },
24
+ hint: { fontSize: '12px', opacity: 0.6, lineHeight: 1.55, wordBreak: 'break-word' },
25
+ list: { marginTop: '10px', borderTop: '1px solid rgba(128,128,128,.25)', paddingTop: '8px' },
26
+ item: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', opacity: 0.85, wordBreak: 'break-all' },
27
+ err: { color: '#b4534e' },
28
+ ok: { color: '#2f8f4e' },
29
+ switchRow: { display: 'flex', alignItems: 'center', gap: '10px', margin: '2px 0 8px', flexWrap: 'wrap' },
30
+ derived: { margin: '8px 0 2px', padding: '6px 10px', borderRadius: '6px', background: 'rgba(128,128,128,.10)', fontSize: '12px', lineHeight: 1.6 },
31
+ card: { border: '1px solid rgba(128,128,128,.22)', borderRadius: '10px', padding: '12px 14px', marginBottom: '12px', background: 'var(--dsh-bg-elevated, transparent)', boxSizing: 'border-box' },
32
+ cardHead: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '8px', marginBottom: '8px' },
33
+ cardTitle: { fontSize: '12px', fontWeight: 600, opacity: 0.8, letterSpacing: '0.02em', margin: 0, lineHeight: 1.5 },
34
+ cardAside: { fontSize: '12px', opacity: 0.6, lineHeight: 1.5, textAlign: 'right' },
35
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '8px 16px' },
36
+ checks: { display: 'flex', flexDirection: 'column', gap: '2px' },
37
+ checkRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '3px 0', minWidth: 0 },
38
+ checkLabel: { flex: 1, minWidth: 0, lineHeight: 1.55 },
39
+ gateRow: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px 10px', margin: '2px 0 6px', fontSize: '12px' },
40
+ gateItem: { display: 'inline-flex', alignItems: 'center', gap: '4px', opacity: 0.9 },
41
+ gateLabel: { opacity: 0.7 },
42
+ stats: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: '8px', margin: '10px 0 4px', padding: '8px 10px', borderRadius: '8px', background: 'rgba(128,128,128,.07)' },
43
+ stat: { minWidth: 0, display: 'flex', flexDirection: 'column', gap: '1px' },
44
+ statLabel: { fontSize: '11px', opacity: 0.6, lineHeight: 1.5 },
45
+ statValue: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4, fontVariantNumeric: 'tabular-nums' },
46
+ lastCheck: { fontSize: '12px', opacity: 0.6, lineHeight: 1.6, marginTop: '2px' },
47
+ naming: { marginTop: '8px' },
48
+ env: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '12px', opacity: 0.72 },
49
+ code: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', wordBreak: 'break-all' },
50
+ strengthHead: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '2px' },
51
+ range: { flex: '1 1 90px', minWidth: '80px', maxWidth: '180px', verticalAlign: 'middle', accentColor: 'var(--dsh-accent, #3b6ef5)' },
52
+ subHead: { fontSize: '12px', fontWeight: 600, opacity: 0.7, letterSpacing: '0.02em', margin: '10px 0 4px' },
53
+ details: { marginTop: '10px', borderTop: '1px solid rgba(128,128,128,.15)', paddingTop: '8px' },
54
+ summary: { cursor: 'pointer', fontSize: '12px', opacity: 0.7, userSelect: 'none', marginBottom: '2px' },
55
+ sticky: { position: 'sticky', bottom: 0, zIndex: 5, marginBottom: '4px' },
56
+ stickyTop: { position: 'sticky', top: 0, zIndex: 5, marginBottom: '10px', paddingTop: '2px', background: 'var(--dsh-bg, #fff)' },
57
+ actionCard: { border: '1px solid rgba(128,128,128,.22)', borderRadius: '10px', padding: '10px 14px', background: 'var(--dsh-bg-elevated, rgba(128,128,128,.08))', backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', boxShadow: '0 -4px 16px rgba(128,128,128,.12)', boxSizing: 'border-box' },
58
+ actionHead: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '8px', marginBottom: '6px' },
59
+ events: { display: 'flex', flexDirection: 'column', gap: '3px' },
60
+ eventLine: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: 1.6 },
61
+ empty: { fontSize: '12px', opacity: 0.55, padding: '2px 0' },
62
+ tableHead: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 110px', gap: '4px 12px', padding: '0 0 5px', borderBottom: '1px solid rgba(128,128,128,.18)', fontSize: '11px', opacity: 0.6, letterSpacing: '0.02em' },
63
+ archiveRow: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 110px', gap: '2px 12px', padding: '7px 0', alignItems: 'start' },
64
+ archiveMain: { minWidth: 0 },
65
+ archiveMeta: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px', fontSize: '11px', opacity: 0.6, marginBottom: '1px' },
66
+ archiveFile: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', lineHeight: 1.55, wordBreak: 'break-all', opacity: 0.9 },
67
+ archiveRight: { textAlign: 'right', fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', lineHeight: 1.55, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' },
68
+ }
69
+
70
+ const CHIP_TONES = {
71
+ ok: { background: 'rgba(47,143,78,.12)', color: '#2f8f4e' },
72
+ bad: { background: 'rgba(180,83,78,.12)', color: '#b4534e' },
73
+ warn: { background: 'rgba(180,140,60,.15)', color: '#a06a1f' },
74
+ neutral: { background: 'rgba(128,128,128,.12)', color: 'inherit' },
75
+ }
76
+
77
+ function Switch(props) {
78
+ const on = Boolean(props.value)
79
+ return h('button', {
80
+ type: 'button',
81
+ role: 'switch',
82
+ 'aria-checked': on,
83
+ disabled: props.disabled,
84
+ onClick: () => props.onChange(!on),
85
+ title: on ? '点击关闭' : '点击开启',
86
+ style: {
87
+ width: '46px', height: '24px', borderRadius: '12px', border: 'none', padding: 0,
88
+ cursor: props.disabled ? 'default' : 'pointer', position: 'relative',
89
+ background: on ? '#2f8f4e' : '#9a9a9a', opacity: props.disabled ? 0.6 : 1,
90
+ transition: 'background .15s', flexShrink: 0,
91
+ },
92
+ }, h('span', {
93
+ style: {
94
+ position: 'absolute', top: '3px', left: on ? '25px' : '3px',
95
+ width: '18px', height: '18px', borderRadius: '50%', background: '#fff',
96
+ transition: 'left .15s',
97
+ },
98
+ }))
99
+ }
100
+
101
+ /** 卡片容器:统一边框/圆角/内边距,可带标题与右上角统计。 */
102
+ function Card(props) {
103
+ return h('section', { style: s.card },
104
+ props.title
105
+ ? h('div', { style: s.cardHead },
106
+ h('div', { style: s.cardTitle }, props.title),
107
+ props.aside ? h('div', { style: s.cardAside }, props.aside) : null)
108
+ : null,
109
+ props.children)
110
+ }
111
+
112
+ /** 状态胶囊:启用绿 / 停用红 / 中性灰,背景 12% alpha。 */
113
+ function Chip(props) {
114
+ const tone = CHIP_TONES[props.kind] || CHIP_TONES.neutral
115
+ return h('span', {
116
+ style: {
117
+ display: 'inline-flex', alignItems: 'center', padding: '0 7px',
118
+ borderRadius: '999px', fontSize: '12px', lineHeight: '1.7',
119
+ fontWeight: 500, whiteSpace: 'nowrap', ...tone,
120
+ },
121
+ }, props.children)
122
+ }
123
+
124
+ /** 统计小格:上方 11px 灰标签,下方数值。 */
125
+ function Stat(props) {
126
+ return h('div', { style: s.stat },
127
+ h('div', { style: s.statLabel }, props.label),
128
+ h('div', { style: s.statValue }, props.value))
129
+ }
130
+
131
+ /** 单行文本输入:标签在上、输入框在下,长中文标签不会挤压输入。 */
132
+ function Field(props) {
133
+ return h('div', { style: s.field },
134
+ h('span', { style: s.label }, props.label),
135
+ h('div', { style: s.ctrlRow },
136
+ h('input', {
137
+ style: props.wide ? { ...s.input, width: '100%' } : s.input,
138
+ value: props.value, disabled: props.disabled,
139
+ onChange: (e) => props.onChange(e.target.value),
140
+ })),
141
+ props.suffix ? h('div', { style: s.hint }, props.suffix) : null)
142
+ }
143
+
144
+ /** 只读展示字段:窗口、保留量、前缀等由后端自动计算/探测的值。 */
145
+ function ROField(props) {
146
+ return h('div', { style: s.field },
147
+ h('span', { style: s.label }, props.label),
148
+ h('div', { style: s.ctrlRow },
149
+ h('input', {
150
+ style: { ...s.input, width: props.width || '150px', opacity: 0.6, cursor: 'not-allowed' },
151
+ value: props.value, readOnly: true, tabIndex: -1, title: props.title,
152
+ })),
153
+ props.hint ? h('div', { style: s.hint }, props.hint) : null)
154
+ }
155
+
156
+ function useStatus(sessionId) {
157
+ const [data, setData] = React.useState(null)
158
+ const [err, setErr] = React.useState('')
159
+ const [statsLoading, setStatsLoading] = React.useState(false)
160
+ const load = React.useCallback(async () => {
161
+ if (!sessionId) return
162
+ // 第一段:轻量视图,秒开(不含统计,后端不解码日志)。
163
+ try {
164
+ const res = await fetch(`${API}?sessionId=${encodeURIComponent(sessionId)}`)
165
+ const body = await res.json()
166
+ if (!res.ok) throw new Error(body.error || String(res.status))
167
+ setData(body)
168
+ setErr('')
169
+ } catch (e) { setErr(String(e && e.message ? e.message : e)); return }
170
+ // 第二段:后台补统计与真实窗口;只合并这几个字段,不覆盖用户正在编辑的表单。
171
+ setStatsLoading(true)
172
+ try {
173
+ const res2 = await fetch(`${API}?sessionId=${encodeURIComponent(sessionId)}&stats=1`)
174
+ const full = await res2.json()
175
+ if (res2.ok) {
176
+ setData((prev) => (prev ? {
177
+ ...prev,
178
+ stats: full.stats,
179
+ contextWindowTokens: full.contextWindowTokens,
180
+ retentionCapBytes: full.retentionCapBytes,
181
+ retentionCapTokens: full.retentionCapTokens,
182
+ partial: false,
183
+ } : full))
184
+ }
185
+ } catch { /* 统计失败不影响配置页使用 */ } finally { setStatsLoading(false) }
186
+ }, [sessionId])
187
+ React.useEffect(() => { load() }, [load])
188
+ return { data, err, load, statsLoading }
189
+ }
190
+
191
+ async function post(sessionId, action, values) {
192
+ const res = await fetch(API, {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json' },
195
+ body: JSON.stringify({ sessionId, action, values }),
196
+ })
197
+ const body = await res.json()
198
+ if (!res.ok) throw new Error(body.error || String(res.status))
199
+ return body
200
+ }
201
+
202
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : undefined }
203
+
204
+ /** 回忆强度档位:滑条索引 0–4 对应后端枚举值。 */
205
+ const RECALL_KEYS = ['off', 'low', 'medium', 'high', 'forced']
206
+ const RECALL_LABELS = ['关', '低', '中', '高', '强制']
207
+ const RECALL_HINT = '关=只归档不读;低=优先读摘要;中=走摘要→检索→原文;高=允许递归;强制=每条消息后必须检索'
208
+
209
+ /** 百分比字段:滑块 + 输入框联动,拖动时输入框跟着变。 */
210
+ function PctField(props) {
211
+ const value = Number(props.value)
212
+ const safe = Number.isFinite(value) ? value : props.min
213
+ return h('div', { style: s.field },
214
+ h('span', { style: s.label }, props.label),
215
+ h('div', { style: s.ctrlRow },
216
+ h('input', {
217
+ type: 'range', min: props.min, max: props.max, step: 1,
218
+ value: safe, disabled: props.disabled,
219
+ style: s.range,
220
+ onChange: (e) => props.onChange(e.target.value),
221
+ }),
222
+ h('input', {
223
+ style: { ...s.input, width: '68px' }, value: props.value, disabled: props.disabled,
224
+ onChange: (e) => props.onChange(e.target.value),
225
+ }),
226
+ h('span', { style: s.muted }, '%')),
227
+ props.suffix ? h('div', { style: s.hint }, props.suffix) : null)
228
+ }
229
+
230
+ /** 整数滑块字段:数字框 + 滑块联动,用于 1–30 这类小整数。disabled 时不可编辑并整体变淡。 */
231
+ function IntSlider(props) {
232
+ const raw = Number(props.value)
233
+ const safe = Number.isFinite(raw) ? Math.min(Math.max(raw, props.min), props.max) : props.min
234
+ return h('div', { style: { ...s.field, opacity: props.disabled ? 0.5 : 1 } },
235
+ h('span', { style: s.label }, props.label),
236
+ h('div', { style: s.ctrlRow },
237
+ h('input', {
238
+ type: 'number', min: props.min, max: props.max, step: 1,
239
+ style: { ...s.input, width: '68px' }, value: props.value, disabled: props.disabled,
240
+ onChange: (e) => props.onChange(e.target.value),
241
+ }),
242
+ h('input', {
243
+ type: 'range', min: props.min, max: props.max, step: 1,
244
+ value: safe, disabled: props.disabled,
245
+ style: s.range,
246
+ onChange: (e) => props.onChange(e.target.value),
247
+ })),
248
+ props.suffix ? h('div', { style: s.hint }, props.suffix) : null)
249
+ }
250
+
251
+ function Check(props) {
252
+ return h('div', { style: { ...s.checkRow, opacity: props.disabled ? 0.6 : 1 } },
253
+ h('span', { style: s.checkLabel }, props.label),
254
+ h('input', {
255
+ type: 'checkbox', checked: props.value, disabled: props.disabled,
256
+ style: { marginTop: '3px', flexShrink: 0, accentColor: 'var(--dsh-accent, #3b6ef5)' },
257
+ onChange: (e) => props.onChange(e.target.checked),
258
+ }))
259
+ }
260
+
261
+ function MemoryPalaceView(props) {
262
+ const sessionId = props.sessionId || ''
263
+ const { data, err, load, statsLoading } = useStatus(sessionId)
264
+ const [busy, setBusy] = React.useState(false)
265
+ const [msg, setMsg] = React.useState('')
266
+ const [form, setForm] = React.useState(null)
267
+ const baseline = React.useRef(null)
268
+ const initedFor = React.useRef(null)
269
+
270
+ React.useEffect(() => {
271
+ if (!data) return
272
+ // 只在首次拿到配置时初始化表单;异步补统计会触发 data 更新,
273
+ // 不能因此把用户正在编辑的内容重置掉。
274
+ if (initedFor.current === data.sessionId && form !== null) return
275
+ initedFor.current = data.sessionId
276
+ const next = {
277
+ triggerPressurePct: String(Math.round(data.triggerPressure * 100)),
278
+ checkEveryTurns: String(data.checkEveryTurns),
279
+ minBatchTurns: String(data.minBatchTurns),
280
+ batchByteFractionPct: String(Math.round((data.batchByteFraction ?? 0.5) * 100)),
281
+ retainFractionPct: String(Math.round((data.retainFraction || 0.12) * 100)),
282
+ maxFileBytesMB: (Math.round(data.maxFileBytes / 100000) / 10).toFixed(1),
283
+ name: String(data.name || ''),
284
+ enableSteer: data.enableSteer,
285
+ includeReasoning: data.includeReasoning,
286
+ compactAfterArchive: data.compactAfterArchive === true,
287
+ searchTurnLimit: String(data.searchTurnLimit ?? 12),
288
+ groupByTurn: data.groupByTurn !== false,
289
+ recallStrength: data.recallStrength || 'low',
290
+ recursionMaxDepth: String(data.recursionMaxDepth ?? 1),
291
+ recursionMaxWidth: String(data.recursionMaxWidth ?? 1),
292
+ }
293
+ setForm(next)
294
+ baseline.current = JSON.stringify(next)
295
+ }, [data])
296
+ const dirty = form !== null && baseline.current !== null && JSON.stringify(form) !== baseline.current
297
+
298
+ const run = async (fn, okText) => {
299
+ setBusy(true); setMsg('')
300
+ try {
301
+ const out = await fn()
302
+ await load()
303
+ setMsg(okText || (typeof out === 'string' && out ? out : '已保存'))
304
+ } catch (e) { setMsg(String(e.message || e)) } finally { setBusy(false) }
305
+ }
306
+
307
+ const save = () => {
308
+ // 先本地校验检索轮数:不合法就不发请求,也不让 run() 的“已保存”盖掉提示。
309
+ const limit = Number(form.searchTurnLimit)
310
+ if (!Number.isInteger(limit) || limit < 1 || limit > 30) {
311
+ setMsg('检索轮数必须是 1–30 的整数')
312
+ return
313
+ }
314
+ // 递归轮次/宽度同样在 run() 外面校验,避免提示被“已保存”盖掉。
315
+ const depth = Number(form.recursionMaxDepth)
316
+ const width = Number(form.recursionMaxWidth)
317
+ const depthOk = Number.isInteger(depth) && depth >= 1 && depth <= 5
318
+ const widthOk = Number.isInteger(width) && width >= 1 && width <= 5
319
+ if (!depthOk || !widthOk) {
320
+ setMsg('递归轮次/宽度必须是 1–5 的整数')
321
+ return
322
+ }
323
+ return run(async () => {
324
+ const values = {}
325
+ const pct = num(form.triggerPressurePct)
326
+ if (pct !== undefined) values.triggerPressure = pct / 100
327
+ const every = num(form.checkEveryTurns)
328
+ if (every !== undefined) values.checkEveryTurns = every
329
+ const batch = num(form.minBatchTurns)
330
+ if (batch !== undefined) values.minBatchTurns = batch
331
+ const bf = num(form.batchByteFractionPct)
332
+ if (bf !== undefined) values.batchByteFraction = bf / 100
333
+ const mb = num(form.maxFileBytesMB)
334
+ if (mb !== undefined) values.maxFileBytes = Math.round(mb * 1000000)
335
+ values.name = String(form.name ?? '').trim()
336
+ const rf = num(form.retainFractionPct)
337
+ if (rf !== undefined) values.retainFraction = rf / 100
338
+ values.enableSteer = Boolean(form.enableSteer)
339
+ values.includeReasoning = Boolean(form.includeReasoning)
340
+ values.compactAfterArchive = Boolean(form.compactAfterArchive)
341
+ values.searchTurnLimit = limit
342
+ values.groupByTurn = Boolean(form.groupByTurn)
343
+ values.recallStrength = form.recallStrength
344
+ values.recursionMaxDepth = depth
345
+ values.recursionMaxWidth = width
346
+ await post(sessionId, 'set', values)
347
+ baseline.current = JSON.stringify(form) // 保存成功:当前表单成为新的基线
348
+ })
349
+ }
350
+
351
+ if (!sessionId) {
352
+ return h('div', { style: s.root },
353
+ h('div', { style: s.page }, '没有会话上下文'))
354
+ }
355
+ if (!data || !form) {
356
+ return h('div', { style: s.root },
357
+ h('div', { style: s.page },
358
+ h('div', { style: s.header }, h('div', { style: s.title }, '记忆回廊')),
359
+ err ? h('p', { style: s.err }, err) : h('p', { style: s.muted }, '读取中…')))
360
+ }
361
+
362
+ const kb = (v) => `${Math.round(v / 1024)} KB`
363
+ const pctNow = num(form.triggerPressurePct) ?? 0
364
+ const triggerTokens = Math.round((pctNow / 100) * (data.contextWindowTokens || 0))
365
+ const retainBytesNow = data.retentionCapBytes || 0
366
+ const onOff = (v) => (v === undefined || v === null ? '未知' : (v ? '开' : '关'))
367
+ const gateChip = (v) => h(Chip, { kind: v === true ? 'ok' : (v === false ? 'bad' : 'neutral') }, onOff(v))
368
+ const gateWhy = () => {
369
+ if (!data.enabled) return '全局总闸关闭'
370
+ if (!data.sessionEnabled) return '本会话开关关闭'
371
+ if (data.name === '') return '未命名'
372
+ if (!data.injectNotice) return '自动提示关闭(不会主动 steer,仅工具可用)'
373
+ return '未知原因'
374
+ }
375
+ const strengthIdx = Math.max(0, RECALL_KEYS.indexOf(form.recallStrength))
376
+ const strengthNow = RECALL_KEYS[strengthIdx] || 'low'
377
+ const recursionOn = strengthNow === 'high' || strengthNow === 'forced'
378
+
379
+ return h('div', { style: s.root },
380
+ h('div', { style: s.page },
381
+
382
+ // ── 页头 ─────────────────────────────────────────────
383
+ h('div', { style: s.header },
384
+ h('div', { style: s.title }, '记忆回廊'),
385
+ h('div', { style: s.subtitle },
386
+ h('span', null, `当前会话:${data.name || data.sessionId || '未命名'}`),
387
+ h('span', { style: s.muted }, '·'),
388
+ h(Chip, { kind: data.sessionEnabled ? 'ok' : 'bad' }, data.sessionEnabled ? '已启用' : '未启用'),
389
+ data.stats
390
+ ? h('span', null, `· 归档 ${data.stats.archivedTurns} / ${data.stats.totalTurns} 轮`)
391
+ : null,
392
+ data.active ? h(Chip, { kind: 'ok' }, '生效中') : null)),
393
+
394
+ // ── 状态卡片 ─────────────────────────────────────────
395
+ h(Card, { title: '状态' },
396
+ h('div', { style: s.switchRow },
397
+ h(Switch, {
398
+ value: data.sessionEnabled,
399
+ disabled: busy,
400
+ onChange: (on) => run(() => post(sessionId, on ? 'on' : 'off'), on ? '已开启本会话' : '已关闭本会话'),
401
+ }),
402
+ h(Chip, { kind: data.sessionEnabled ? 'ok' : 'bad' }, data.sessionEnabled ? '已启用' : '未启用'),
403
+ h('span', { style: s.muted }, data.active ? '(生效中)' : (data.sessionEnabled ? (data.name ? '(全局被关)' : '(未命名,暂不归档)') : '(不会归档)'))),
404
+
405
+ h('div', { style: s.gateRow },
406
+ h('span', { style: s.gateLabel }, '生效门'),
407
+ h('span', { style: s.gateItem }, '全局 enabled', gateChip(data.enabled)),
408
+ h('span', { style: s.gateItem }, '本会话 sessionEnabled', gateChip(data.sessionEnabled)),
409
+ h('span', { style: s.gateItem }, '自动提示 injectNotice', gateChip(data.injectNotice)),
410
+ data.active
411
+ ? h(Chip, { kind: 'ok' }, '当前生效')
412
+ : h(Chip, { kind: 'bad' }, `未生效:${gateWhy()}`)),
413
+
414
+ data.stats
415
+ ? h('div', { style: s.stats },
416
+ h(Stat, { label: '总轮次', value: data.stats.totalTurns }),
417
+ h(Stat, { label: '已归档', value: data.stats.archivedTurns }),
418
+ h(Stat, { label: '待归档', value: data.stats.pendingTurns }),
419
+ h(Stat, { label: '水位', value: data.stats.watermark }))
420
+ : h('div', { style: s.stats },
421
+ h(Stat, { label: '总轮次', value: statsLoading ? '计算中…' : '—' }),
422
+ h(Stat, { label: '已归档', value: statsLoading ? '计算中…' : '—' }),
423
+ h(Stat, { label: '待归档', value: statsLoading ? '计算中…' : '—' }),
424
+ h(Stat, { label: '水位', value: statsLoading ? '计算中…' : '—' })),
425
+
426
+ h('div', { style: s.lastCheck },
427
+ data.lastCheckTurn
428
+ ? `上次检查 turn ${data.lastCheckTurn}(${data.lastCheckAt}),实测占用 ${Math.round((data.lastPressure || 0) * 100)}%${data.lastPressureSource === 'log-sum' ? '(日志估算)' : ''}|已归档到 turn ${data.archivedThroughTurn || 0}`
429
+ : '还没有检查记录'),
430
+
431
+ h('div', { style: s.naming },
432
+ h(Field, {
433
+ label: '记忆命名', value: form.name, wide: true, disabled: busy,
434
+ suffix: '英文/数字/空格/下划线/连字符,首尾不能空格;留空 = 用会话ID 且不归档',
435
+ onChange: (v) => setForm({ ...form, name: v }),
436
+ })),
437
+
438
+ h('div', { style: s.env },
439
+ h('div', null, '工作区:', h('code', { style: s.code }, data.workspace)),
440
+ h('div', null,
441
+ '本会话文件夹:', h('code', { style: s.code }, data.nsLabel || ''),
442
+ data.name ? null : h('span', { style: s.muted }, '(未命名,不会归档)')))),
443
+
444
+ // ── 操作卡片:粘性顶部(保存按钮永远可见) ─────────────
445
+ h('div', { style: s.stickyTop },
446
+ h('div', { style: s.actionCard },
447
+ h('div', { style: s.actionHead },
448
+ h('div', { style: s.cardTitle }, '操作'),
449
+ dirty ? h(Chip, { kind: 'warn' }, '有未保存修改') : h(Chip, { kind: 'ok' }, '已保存')),
450
+ h('div', { style: s.bar },
451
+ h('button', {
452
+ style: { ...s.btnPrimary, opacity: busy || !dirty ? 0.5 : 1, cursor: busy || !dirty ? 'default' : 'pointer' },
453
+ disabled: busy || !dirty, onClick: save,
454
+ }, dirty ? '保存参数' : '已保存'),
455
+ h('button', {
456
+ style: { ...s.btnSecondary, opacity: busy ? 0.6 : 1, cursor: busy ? 'default' : 'pointer' },
457
+ disabled: busy,
458
+ onClick: () => run(async () => {
459
+ const r = await post(sessionId, 'archive', {})
460
+ return r.message || '已发送归档指令'
461
+ }),
462
+ }, '现在归档'),
463
+ h('span', { style: { ...s.hint, flex: '1 1 240px' } },
464
+ '改完参数点「保存参数」;「现在归档」只是给模型发一条指令')),
465
+ msg ? h('p', { style: { ...(msg.startsWith('已') ? s.ok : s.err), margin: '4px 0 0' } }, msg) : null,
466
+ err ? h('p', { style: { ...s.err, margin: '4px 0 0' } }, err) : null)),
467
+
468
+ // ── 检索与回忆卡片 ───────────────────────────────────
469
+ h(Card, { title: '检索与回忆' },
470
+ h('div', { style: s.grid },
471
+ h(IntSlider, {
472
+ label: '检索轮数 limitTurns', value: form.searchTurnLimit, min: 1, max: 30,
473
+ suffix: '按轮聚合时返回多少轮(1–30 整数)', disabled: busy,
474
+ onChange: (v) => setForm({ ...form, searchTurnLimit: v }),
475
+ }),
476
+ h(Check, {
477
+ label: '检索按轮聚合', value: form.groupByTurn, disabled: busy,
478
+ onChange: (v) => setForm({ ...form, groupByTurn: v }),
479
+ })),
480
+
481
+ h('div', { style: { ...s.field, marginTop: '8px' } },
482
+ h('div', { style: s.strengthHead },
483
+ h('span', { style: s.label }, '回忆强度'),
484
+ h(Chip, { kind: 'neutral' }, RECALL_LABELS[strengthIdx])),
485
+ h('div', { style: s.ctrlRow },
486
+ h('input', {
487
+ type: 'range', min: 0, max: 4, step: 1,
488
+ value: strengthIdx, disabled: busy,
489
+ style: { ...s.range, minWidth: '100px', maxWidth: '220px' },
490
+ onChange: (e) => setForm({ ...form, recallStrength: RECALL_KEYS[Number(e.target.value)] || 'low' }),
491
+ })),
492
+ h('div', { style: s.hint }, RECALL_HINT)),
493
+
494
+ h('div', { style: { ...s.grid, marginTop: '8px' } },
495
+ h(IntSlider, {
496
+ label: '回忆预算(轮次)', value: form.recursionMaxDepth, min: 1, max: 5,
497
+ suffix: 'recallBudgetRounds · 1–5 整数', disabled: busy || !recursionOn,
498
+ onChange: (v) => setForm({ ...form, recursionMaxDepth: v }),
499
+ }),
500
+ h(IntSlider, {
501
+ label: '回忆预算(每轮关键词)', value: form.recursionMaxWidth, min: 1, max: 5,
502
+ suffix: 'recallBudgetKeywords · 1–5 整数', disabled: busy || !recursionOn,
503
+ onChange: (v) => setForm({ ...form, recursionMaxWidth: v }),
504
+ })),
505
+
506
+ h('div', { style: { ...s.hint, marginTop: '6px' } },
507
+ '预算是给模型的建议上限(插件不强制执行),只在「高/强制」档出现')),
508
+
509
+ // ── 归档参数卡片 ─────────────────────────────────────
510
+ h(Card, { title: '归档参数' },
511
+ h('div', { style: s.grid },
512
+ h(PctField, {
513
+ label: '触发阈值', value: form.triggerPressurePct, min: 20, max: 95,
514
+ suffix: '20–95', disabled: busy,
515
+ onChange: (v) => setForm({ ...form, triggerPressurePct: v }),
516
+ }),
517
+ h(PctField, {
518
+ label: '无需归档比例', value: form.retainFractionPct, min: 2, max: 50,
519
+ suffix: '2–50,建议 < 16(DSH 压缩尾部)', disabled: busy,
520
+ onChange: (v) => setForm({ ...form, retainFractionPct: v }),
521
+ }),
522
+ h(PctField, {
523
+ label: '批量兜底阈值', value: form.batchByteFractionPct, min: 5, max: 100,
524
+ suffix: '可归档量达到无需归档量的该比例即触发(不看轮数)', disabled: busy,
525
+ onChange: (v) => setForm({ ...form, batchByteFractionPct: v }),
526
+ }),
527
+ h(Field, {
528
+ label: '检查频率(每 N 轮)', value: form.checkEveryTurns, disabled: busy,
529
+ onChange: (v) => setForm({ ...form, checkEveryTurns: v }),
530
+ }),
531
+ h(Field, {
532
+ label: '最小归档批量(轮)', value: form.minBatchTurns, disabled: busy,
533
+ onChange: (v) => setForm({ ...form, minBatchTurns: v }),
534
+ })),
535
+
536
+ h('div', { style: s.derived },
537
+ '按当前输入实时计算:会话估算到 ',
538
+ h('b', null, triggerTokens.toLocaleString()),
539
+ ` tokens 时触发归档(${pctNow}% × ${(data.contextWindowTokens || 0).toLocaleString()})`),
540
+
541
+ h('div', { style: s.subHead }, '行为开关'),
542
+ h('div', { style: s.checks },
543
+ h(Check, {
544
+ label: '自动提示(steer)', value: form.enableSteer, disabled: busy,
545
+ onChange: (v) => setForm({ ...form, enableSteer: v }),
546
+ }),
547
+ h(Check, {
548
+ label: '收录深度思考', value: form.includeReasoning, disabled: busy,
549
+ onChange: (v) => setForm({ ...form, includeReasoning: v }),
550
+ }),
551
+ h(Check, {
552
+ label: '归档后压缩上下文(保留多少由 DSH 自己决定,可能压掉未归档的近期轮次)',
553
+ value: form.compactAfterArchive, disabled: busy,
554
+ onChange: (v) => setForm({ ...form, compactAfterArchive: v }),
555
+ })),
556
+
557
+ h('details', { style: s.details },
558
+ h('summary', { style: s.summary }, '高级设置'),
559
+ h('div', { style: { ...s.grid, marginTop: '8px' } },
560
+ h(ROField, {
561
+ label: '保留字节(自动计算)',
562
+ value: `${Math.round(retainBytesNow / 1024).toLocaleString()} KB`,
563
+ hint: '窗口 × 保留比例 × 3 字节/token · 自动计算 · 只读',
564
+ title: '窗口 × 已填比例估算出的保留区字节数;这段不归档,其余较旧轮次才归档',
565
+ }),
566
+ h(ROField, {
567
+ label: '无需归档的近期文本量',
568
+ value: (data.retentionCapTokens || 0).toLocaleString(),
569
+ hint: `tokens · 窗口 × ${form.retainFractionPct}% · 自动计算 · 只读`,
570
+ title: '窗口 × 已填比例;这段不归档,其余较旧轮次才归档',
571
+ }),
572
+ h(Field, {
573
+ label: '单文件上限 MB', value: form.maxFileBytesMB, disabled: busy,
574
+ onChange: (v) => setForm({ ...form, maxFileBytesMB: v }),
575
+ }),
576
+ h(ROField, {
577
+ label: '估算窗口 tokens',
578
+ value: (data.contextWindowTokens || 0).toLocaleString(),
579
+ hint: '自动探测 · 只读',
580
+ title: '由插件从会话日志自动探测,不可修改;每次启动 DSH 后刷新',
581
+ }),
582
+ h(ROField, {
583
+ label: '文件名前缀',
584
+ value: data.selfNameSlug || '',
585
+ hint: '(跟随记忆名;要单独固定用 palace_config 设 selfNameSlug)',
586
+ title: '归档文件名前缀,跟随记忆名',
587
+ })))),
588
+
589
+ // ── 最近事件卡片 ─────────────────────────────────────
590
+ h(Card, { title: '最近事件' },
591
+ Array.isArray(data.events) && data.events.length > 0
592
+ ? h('div', { style: s.events },
593
+ data.events.slice(-5).reverse().map((e, i) => h('div', {
594
+ key: i,
595
+ style: s.eventLine,
596
+ }, `${e.t} [${e.kind}] ${e.detail}`)))
597
+ : h('div', { style: s.empty }, '暂无事件')),
598
+
599
+ // ── 归档列表卡片 ─────────────────────────────────────
600
+ h(Card, {
601
+ title: '最近归档',
602
+ aside: `显示 ${data.archives.length} 条` + (data.totals ? ` · 共 ${data.totals.count} 份 ${(data.totals.bytes / 1024 / 1024).toFixed(2)} MB` : ''),
603
+ },
604
+ h('div', { style: s.tableHead },
605
+ h('span', null, '文件'),
606
+ h('span', { style: { textAlign: 'right' } }, '大小 / 轮次')),
607
+ data.archives.length === 0
608
+ ? h('div', { style: s.empty }, '还没有归档。')
609
+ : data.archives.map((a, i) => h('div', {
610
+ key: i,
611
+ style: { ...s.archiveRow, borderTop: i === 0 ? 'none' : '1px solid rgba(128,128,128,.12)' },
612
+ },
613
+ h('div', { style: s.archiveMain },
614
+ h('div', { style: s.archiveMeta },
615
+ h('span', null, a.archivedAt),
616
+ h(Chip, { kind: 'neutral' }, a.category)),
617
+ h('div', { style: s.archiveFile }, a.file)),
618
+ h('div', { style: s.archiveRight },
619
+ h('div', null, `${(a.bytes / 1024).toFixed(1)} KB`),
620
+ h('div', { style: s.muted }, `turn ${a.fromTurn}–${a.toTurn}`)))))
621
+ )
622
+ )
623
+ }
624
+
625
+ function apply(ctx) {
626
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
627
+ name: 'conversation.view',
628
+ id: 'memory-palace',
629
+ order: 40,
630
+ label: () => '记忆回廊',
631
+ inject: (sessionId) => ({ sessionId }),
632
+ }, MemoryPalaceView))
633
+ }
634
+
635
+ return { apply, inject: ['slots'] }
636
+ },
637
+ })