dsh-selfnote 0.0.0-stage → 0.12.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,1713 @@
1
+ /**
2
+ * dsh-selfnote —— 浏览器半侧(手写 __ModuleLoader__ bundle,不需要构建工具)。
3
+ *
4
+ * 在设置页里加「人格(自述)」一节:看现在生效的自述、改容量上限、手动写一条、改一条、删一条、清空(进归档)。
5
+ * 与宿主只走同源 HTTP(/api/selfnote/*)。
6
+ */
7
+
8
+ window.__ModuleLoader__.load({
9
+ id: 'dsh-selfnote',
10
+ factory: function (require) {
11
+ var module = { exports: {} }
12
+ var exports = module.exports
13
+
14
+ var React = require('react')
15
+ var h = React.createElement
16
+ var useCallback = React.useCallback
17
+ var useEffect = React.useEffect
18
+ var useState = React.useState
19
+
20
+ var API = '/api/selfnote'
21
+
22
+ function readJson(res) {
23
+ return res.text().then(function (text) {
24
+ var data = null
25
+ try {
26
+ data = text === '' ? null : JSON.parse(text)
27
+ } catch (error) {
28
+ data = null
29
+ }
30
+ if (!res.ok) {
31
+ var message = data && data.error ? data.error : 'HTTP ' + res.status
32
+ var failure = new Error(message)
33
+ failure.status = res.status
34
+ throw failure
35
+ }
36
+ return data
37
+ })
38
+ }
39
+
40
+ // 准入 token 由宿主在 index.html 里注入:同源页面才读得到,跨站页面拿不到这段脚本。
41
+ // 每个安装都是随机值 —— 不存在「照抄 README 就能进」的固定口令。
42
+ // 自定义头同时是 CSRF 门槛:跨站请求带它会触发预检,服务端不回 CORS 头就发不出去。
43
+ var authToken =
44
+ typeof window !== 'undefined' && typeof window.__DSH_SELFNOTE_TOKEN__ === 'string'
45
+ ? window.__DSH_SELFNOTE_TOKEN__
46
+ : ''
47
+
48
+ function apiGet(path) {
49
+ return fetch(API + path, { headers: { accept: 'application/json', 'x-selfnote': authToken } }).then(readJson)
50
+ }
51
+
52
+ function apiPost(path, body) {
53
+ return fetch(API + path, {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json', 'x-selfnote': authToken },
56
+ body: JSON.stringify(body || {}),
57
+ }).then(readJson)
58
+ }
59
+
60
+ /**
61
+ * 一条记忆的出处:手动提交的带 citation,自动采集的只有 source。
62
+ * 自动采集的还要标出出现次数、以及跟谁冲突 —— 否则审核时看不见证据。
63
+ */
64
+ function citeOf(fact) {
65
+ const parts = []
66
+ if (fact.citation && fact.citation.sessionId) {
67
+ parts.push('← ' + fact.citation.sessionId + ' 第 ' + fact.citation.index + ' 条 · ' + (fact.citation.date || ''))
68
+ } else if (fact.source) {
69
+ parts.push('← 自动采集 · ' + fact.source)
70
+ } else {
71
+ parts.push('← 出处未知')
72
+ }
73
+ if (Number(fact.occurrences ?? 1) > 1) parts.push('出现 ' + fact.occurrences + ' 次')
74
+ if (fact.conflictWith) parts.push('↯ 与 ' + fact.conflictWith + ' 冲突')
75
+ else if (fact.similarTo && fact.similarTo.length > 0) parts.push('≈ ' + fact.similarTo.join('、'))
76
+ return parts.join(' · ')
77
+ }
78
+
79
+ function Notice(props) {
80
+ if (!props.text) return null
81
+ var tone = props.tone || 'info'
82
+ return h(
83
+ 'div',
84
+ { className: 'sn-notice sn-notice-' + tone },
85
+ h('span', { className: 'sn-notice-mark' }, tone === 'ok' ? '✓' : tone === 'warn' ? '!' : 'i'),
86
+ h('span', null, props.text),
87
+ )
88
+ }
89
+
90
+ function SelfNoteSection() {
91
+ var stateTuple = useState(null)
92
+ var state = stateTuple[0]
93
+ var setState = stateTuple[1]
94
+ var noticeTuple = useState(null)
95
+ var notice = noticeTuple[0]
96
+ var setNotice = noticeTuple[1]
97
+ var busyTuple = useState('')
98
+ var busy = busyTuple[0]
99
+ var setBusy = busyTuple[1]
100
+ var draftTuple = useState('')
101
+ var draft = draftTuple[0]
102
+ var setDraft = draftTuple[1]
103
+ var maxCharsTuple = useState('')
104
+ var maxChars = maxCharsTuple[0]
105
+ var setMaxChars = maxCharsTuple[1]
106
+ var maxEntriesTuple = useState('')
107
+ var maxEntries = maxEntriesTuple[0]
108
+ var setMaxEntries = maxEntriesTuple[1]
109
+ var profileDraftTuple = useState(null)
110
+ var profileDraft = profileDraftTuple[0]
111
+ var setProfileDraft = profileDraftTuple[1]
112
+ var confirmTuple = useState(false)
113
+ var confirming = confirmTuple[0]
114
+ var setConfirming = confirmTuple[1]
115
+ // 标签页:设置页这一节东西多,摊平看太累;按「概览 / 自述 / 记忆 / 画像」分开
116
+ var tabTuple = useState('overview')
117
+ var tab = tabTuple[0]
118
+ var setTab = tabTuple[1]
119
+ // 用户还没亲手点过标签:这时按数据决定先看哪一页 —— 有待审的就开「记忆」,否则停在「概览」。
120
+ var pickedTuple = useState(false)
121
+ var picked = pickedTuple[0]
122
+ var setPicked = pickedTuple[1]
123
+ // 体检结果:手动跑,不自动跑 —— 它读的东西多,不该拖慢每次打开设置页
124
+ var doctorTuple = useState(null)
125
+ var doctor = doctorTuple[0]
126
+ var setDoctor = doctorTuple[1]
127
+
128
+ // 放最后:上面那些 useState 的调用序号被测试依赖,别插在中间
129
+ var guideTuple = useState('')
130
+ var noteGuide = guideTuple[0]
131
+ var setNoteGuide = guideTuple[1]
132
+ // 迁移:导出/导入的账目(加了几条、跳了几条、有没有被降级),null = 还没导过
133
+ var transferTuple = useState(null)
134
+ var transfer = transferTuple[0]
135
+ var setTransfer = transferTuple[1]
136
+ var keepStatusTuple = useState(false)
137
+ var keepStatus = keepStatusTuple[0]
138
+ var setKeepStatus = keepStatusTuple[1]
139
+ // 就地改一条:editing = 正在改的那一条在 notes 里的下标(0 = 最旧),null = 没在改
140
+ var editingTuple = useState(null)
141
+ var editing = editingTuple[0]
142
+ var setEditing = editingTuple[1]
143
+ var editTextTuple = useState('')
144
+ var editText = editTextTuple[0]
145
+ var setEditText = editTextTuple[1]
146
+ // 删一条要两步:只说「删」一个字,误点一下就少一条记忆
147
+ var pendingDeleteTuple = useState(null)
148
+ var pendingDelete = pendingDeleteTuple[0]
149
+ var setPendingDelete = pendingDeleteTuple[1]
150
+ // 记忆正文同理:factEdit = { id, text }
151
+ var factEditTuple = useState(null)
152
+ var factEdit = factEditTuple[0]
153
+ var setFactEdit = factEditTuple[1]
154
+ // 注入抬头(每回合那一段的第一行),可改
155
+ var headingTuple = useState('')
156
+ var heading = headingTuple[0]
157
+ var setHeading = headingTuple[1]
158
+
159
+ function apply(data) {
160
+ setState(data)
161
+ setProfileDraft(function (current) {
162
+ return current === null ? (data.profile ? data.profile.override : '') : current
163
+ })
164
+ setMaxChars(String(data.maxChars))
165
+ setMaxEntries(String(data.maxEntries))
166
+ setNoteGuide(typeof data.noteGuide === 'string' ? data.noteGuide : '')
167
+ setHeading(typeof data.heading === 'string' ? data.heading : '')
168
+ // 每次拿到新状态,列表下标都可能整体挪位:正在改/正等确认的那条已经没意义了
169
+ setEditing(null)
170
+ setPendingDelete(null)
171
+ setFactEdit(null)
172
+ }
173
+
174
+ var refresh = useCallback(function () {
175
+ setBusy('load')
176
+ return apiGet('/state')
177
+ .then(function (data) {
178
+ apply(data)
179
+ setNotice(null)
180
+ })
181
+ .catch(function (error) {
182
+ setNotice({ tone: 'warn', text: '读不到状态:' + error.message })
183
+ })
184
+ .then(function () {
185
+ setBusy('')
186
+ })
187
+ }, [])
188
+
189
+ useEffect(function () {
190
+ refresh()
191
+ }, [refresh])
192
+
193
+ // 「真的清空」不该一直是待触发状态:人点了一次走开,回来再点一下就把东西清了。
194
+ useEffect(function () {
195
+ if (!confirming) return undefined
196
+ var timer = setTimeout(function () {
197
+ setConfirming(false)
198
+ }, 5000)
199
+ return function () {
200
+ clearTimeout(timer)
201
+ }
202
+ }, [confirming])
203
+
204
+ function runDoctor() {
205
+ setBusy('doctor')
206
+ apiGet('/doctor')
207
+ .then(function (data) {
208
+ setDoctor(data)
209
+ setNotice(null)
210
+ })
211
+ .catch(function (error) {
212
+ setNotice({ tone: 'warn', text: '体检没跑起来:' + error.message })
213
+ })
214
+ .then(function () {
215
+ setBusy('')
216
+ })
217
+ }
218
+
219
+ function saveProfile(override) {
220
+ setBusy('profile')
221
+ apiPost('/config', { profileOverride: override })
222
+ .then(function (data) {
223
+ apply(data)
224
+ setProfileDraft(data.profile ? data.profile.override : '')
225
+ setNotice({ tone: 'ok', text: override === '' ? '已改回自动聚合。' : '画像已保存,下一回合生效。' })
226
+ })
227
+ .catch(function (error) {
228
+ setNotice({ tone: 'warn', text: '保存失败:' + error.message })
229
+ })
230
+ .then(function () {
231
+ setBusy('')
232
+ })
233
+ }
234
+
235
+ function toggleProfile() {
236
+ setBusy('profile-toggle')
237
+ apiPost('/config', { profileEnabled: !(state.profile && state.profile.enabled) })
238
+ .then(function (data) {
239
+ apply(data)
240
+ setNotice({ tone: 'ok', text: (data.profile && data.profile.enabled) ? '画像已开启。' : '画像已关闭,不再注入。' })
241
+ })
242
+ .catch(function (error) {
243
+ setNotice({ tone: 'warn', text: '切换失败:' + error.message })
244
+ })
245
+ .then(function () {
246
+ setBusy('')
247
+ })
248
+ }
249
+
250
+ function saveConfig() {
251
+ setBusy('config')
252
+ apiPost('/config', { maxChars: Number(maxChars), maxEntries: Number(maxEntries), noteGuide: noteGuide })
253
+ .then(function (data) {
254
+ apply(data)
255
+ setNotice({ tone: 'ok', text: '已保存,下一回合生效。' })
256
+ })
257
+ .catch(function (error) {
258
+ setNotice({ tone: 'warn', text: '保存失败:' + error.message })
259
+ })
260
+ .then(function () {
261
+ setBusy('')
262
+ })
263
+ }
264
+
265
+ function saveHeading() {
266
+ setBusy('heading')
267
+ apiPost('/config', { heading: heading })
268
+ .then(function (data) {
269
+ apply(data)
270
+ setNotice({ tone: 'ok', text: heading.trim() === '' ? '抬头清空了,注入直接从内容开始。' : '抬头已保存,下一回合生效。' })
271
+ })
272
+ .catch(function (error) {
273
+ setNotice({ tone: 'warn', text: '保存失败:' + error.message })
274
+ })
275
+ .then(function () {
276
+ setBusy('')
277
+ })
278
+ }
279
+
280
+ function toggleEnabled() {
281
+ setBusy('toggle')
282
+ apiPost('/config', { enabled: !state.enabled })
283
+ .then(function (data) {
284
+ apply(data)
285
+ setNotice({ tone: 'ok', text: data.enabled ? '已开启注入。' : '已关闭注入(内容还留着)。' })
286
+ })
287
+ .catch(function (error) {
288
+ setNotice({ tone: 'warn', text: '切换失败:' + error.message })
289
+ })
290
+ .then(function () {
291
+ setBusy('')
292
+ })
293
+ }
294
+
295
+ function addNote() {
296
+ var text = draft.trim()
297
+ if (text === '') {
298
+ setNotice({ tone: 'warn', text: '先写点什么再点。' })
299
+ return
300
+ }
301
+ setBusy('note')
302
+ apiPost('/note', { text: text })
303
+ .then(function (data) {
304
+ apply(data)
305
+ setDraft('')
306
+ setNotice({ tone: 'ok', text: '写好了。' })
307
+ })
308
+ .catch(function (error) {
309
+ setNotice({ tone: 'warn', text: '写入失败:' + error.message })
310
+ })
311
+ .then(function () {
312
+ setBusy('')
313
+ })
314
+ }
315
+
316
+ function startEditNote(index, text) {
317
+ setEditing(index)
318
+ setEditText(text)
319
+ setPendingDelete(null)
320
+ setNotice(null)
321
+ }
322
+
323
+ function cancelEditNote() {
324
+ setEditing(null)
325
+ setEditText('')
326
+ }
327
+
328
+ function saveNote(index) {
329
+ var text = editText.trim()
330
+ if (text === '') {
331
+ setNotice({ tone: 'warn', text: '内容不能为空;不想留这条就点「删」。' })
332
+ return
333
+ }
334
+ setBusy('note-edit')
335
+ apiPost('/note/update', { index: index, text: text })
336
+ .then(function (data) {
337
+ apply(data)
338
+ var update = data.noteUpdate || {}
339
+ setNotice({
340
+ tone: 'ok',
341
+ text: update.unchanged
342
+ ? '没改动。'
343
+ : '改好了,下一回合生效。' + (update.archived > 0 ? '(改长后有 ' + update.archived + ' 条挪进归档)' : ''),
344
+ })
345
+ })
346
+ .catch(function (error) {
347
+ setNotice({ tone: 'warn', text: '没改成:' + error.message })
348
+ })
349
+ .then(function () {
350
+ setBusy('')
351
+ })
352
+ }
353
+
354
+ function removeNote(index) {
355
+ if (pendingDelete !== index) {
356
+ setPendingDelete(index)
357
+ setNotice({ tone: 'info', text: '再点一次「真的删」——这条会先进归档,找得回来。' })
358
+ return
359
+ }
360
+ setBusy('note-delete')
361
+ apiPost('/note/delete', { index: index })
362
+ .then(function (data) {
363
+ apply(data)
364
+ setNotice({ tone: 'ok', text: '已删掉,剩下 ' + ((data.noteDelete && data.noteDelete.kept) || 0) + ' 条;删掉的在归档里。' })
365
+ })
366
+ .catch(function (error) {
367
+ setNotice({ tone: 'warn', text: '没删成:' + error.message })
368
+ })
369
+ .then(function () {
370
+ setBusy('')
371
+ })
372
+ }
373
+
374
+ function saveFactText(id, text) {
375
+ var clean = text.trim()
376
+ if (clean === '') {
377
+ setNotice({ tone: 'warn', text: '内容不能为空。' })
378
+ return
379
+ }
380
+ setBusy('fact-edit')
381
+ apiPost('/fact', { id: id, text: clean })
382
+ .then(function (data) {
383
+ apply(data)
384
+ setNotice({ tone: 'ok', text: '记忆正文改好了,下一回合生效;画像会跟着重算。' })
385
+ })
386
+ .catch(function (error) {
387
+ setNotice({ tone: 'warn', text: '没改成:' + error.message })
388
+ })
389
+ .then(function () {
390
+ setBusy('')
391
+ })
392
+ }
393
+
394
+ function clearAll() {
395
+ if (!confirming) {
396
+ setConfirming(true)
397
+ setNotice({ tone: 'info', text: '再点一次「真的清空」——内容会进归档,不会消失。' })
398
+ return
399
+ }
400
+ setBusy('clear')
401
+ apiPost('/clear', {})
402
+ .then(function (data) {
403
+ apply(data)
404
+ setConfirming(false)
405
+ setNotice({ tone: 'ok', text: '已清空 ' + (data.cleared || 0) + ' 条,内容已归档。' })
406
+ })
407
+ .catch(function (error) {
408
+ setNotice({ tone: 'warn', text: '清空失败:' + error.message })
409
+ })
410
+ .then(function () {
411
+ setBusy('')
412
+ })
413
+ }
414
+
415
+ function setFact(id, status) {
416
+ setBusy('fact')
417
+ apiPost('/fact', { id: id, status: status })
418
+ .then(function (data) {
419
+ apply(data)
420
+ setNotice({ tone: 'ok', text: status === 'approved' ? '已采纳,下一回合进注入。' : '已处理。' })
421
+ })
422
+ .catch(function (error) {
423
+ setNotice({ tone: 'warn', text: '没改成:' + error.message })
424
+ })
425
+ .then(function () {
426
+ setBusy('')
427
+ })
428
+ }
429
+
430
+ function setHabitState(text, status, tag) {
431
+ setBusy('habit')
432
+ apiPost('/habit', { text: text, status: status, tag: tag || '' })
433
+ .then(function (data) {
434
+ apply(data)
435
+ setNotice({ tone: 'ok', text: status === 'active' ? '已升成习惯。' : '已停用这条习惯。' })
436
+ })
437
+ .catch(function (error) {
438
+ setNotice({ tone: 'warn', text: '没改成:' + error.message })
439
+ })
440
+ .then(function () {
441
+ setBusy('')
442
+ })
443
+ }
444
+
445
+ if (state === null) {
446
+ return h(
447
+ 'div',
448
+ { className: 'sn-root' },
449
+ h('div', { className: 'sn-loading' }, busy === 'load' ? '读取中…' : '还没有连上宿主(重启 DSH 试试)'),
450
+ )
451
+ }
452
+
453
+ var notes = state.notes || []
454
+ var reversed = notes.slice().reverse()
455
+ var usage = state.maxChars > 0 ? Math.min(100, Math.round((state.chars / state.maxChars) * 100)) : 0
456
+ var candidates = (state.facts && state.facts.candidates) || []
457
+ var approvedFacts = (state.facts && state.facts.approved) || []
458
+ var retiredFacts = (state.facts && state.facts.retired) || []
459
+ var factMap = {}
460
+ candidates.concat(approvedFacts).concat(retiredFacts).forEach(function (item) {
461
+ if (item && item.id) factMap[item.id] = item
462
+ })
463
+
464
+ function statusLabel(status) {
465
+ if (status === 'approved') return '已采纳'
466
+ if (status === 'retired') return '已退役'
467
+ return '待确认'
468
+ }
469
+
470
+ function triggerLabel(fact) {
471
+ var names = { standing: '长期约定', prohibit: '禁止', prefer: '偏好', correct: '纠正' }
472
+ var list = (fact.triggers || []).map(function (key) { return names[key] || key })
473
+ if (list.length > 0) return list.join(' · ')
474
+ return fact.captureSource === 'auto' ? '自动采集' : '手动提交'
475
+ }
476
+
477
+ /** 一条候选/事实的卡片主体:文本、判定依据、出处、操作。 */
478
+ function factBody(fact, withActions) {
479
+ var isEditing = factEdit !== null && factEdit.id === fact.id
480
+ return [
481
+ isEditing
482
+ ? h(
483
+ 'div',
484
+ { key: 'text', className: 'sn-fact-edit' },
485
+ h('textarea', {
486
+ className: 'sn-input',
487
+ rows: 2,
488
+ // 一条事实硬上限 240 字(服务端同一条)
489
+ maxLength: 240,
490
+ value: factEdit.text,
491
+ onChange: function (e) { setFactEdit({ id: fact.id, text: e.target.value }) },
492
+ }),
493
+ h(
494
+ 'span',
495
+ { className: 'sn-actions' },
496
+ h('button', { className: 'sn-btn', onClick: function () { saveFactText(fact.id, factEdit.text) }, disabled: busy !== '' }, '保存'),
497
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit(null) }, disabled: busy !== '' }, '取消'),
498
+ ),
499
+ )
500
+ : h('div', { key: 'text', className: 'sn-fact-text' }, fact.text),
501
+ h(
502
+ 'div',
503
+ { key: 'meta', className: 'sn-fact-meta' },
504
+ h('span', { className: 'sn-badge' }, triggerLabel(fact)),
505
+ h('span', { className: 'sn-fact-cite' }, citeOf(fact)),
506
+ ),
507
+ withActions
508
+ ? h(
509
+ 'div',
510
+ { key: 'actions', className: 'sn-actions' },
511
+ h('button', { className: 'sn-btn', onClick: function () { setFact(fact.id, 'approved') }, disabled: busy !== '' }, '采纳'),
512
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFact(fact.id, 'retired') }, disabled: busy !== '' }, '退役'),
513
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
514
+ )
515
+ : h('div', { key: 'state', className: 'sn-fact-cite' }, '状态:' + statusLabel(fact.status)),
516
+ ]
517
+ }
518
+
519
+ var suggestions = state.habitSuggestions || []
520
+
521
+ var tabs = [
522
+ { key: 'overview', label: '概览' },
523
+ { key: 'notes', label: '自述', badge: notes.length + ' 条' },
524
+ { key: 'memory', label: '记忆', badge: candidates.length > 0 ? '待审 ' + candidates.length : approvedFacts.length + ' 条' },
525
+ { key: 'profile', label: '画像', badge: (state.profile ? state.profile.chars : 0) + ' 字' },
526
+ { key: 'injection', label: '注入', badge: ((state.injection && state.injection.chars) || 0) + ' 字' },
527
+ ]
528
+
529
+ var activeTab = picked ? tab : candidates.length > 0 ? 'memory' : 'overview'
530
+
531
+ function goTab(event) {
532
+ var keys = tabs.map(function (item) { return item.key })
533
+ var index = keys.indexOf(activeTab)
534
+ var target = null
535
+ if (event.key === 'ArrowRight') target = keys[(index + 1) % keys.length]
536
+ else if (event.key === 'ArrowLeft') target = keys[(index - 1 + keys.length) % keys.length]
537
+ else if (event.key === 'Home') target = keys[0]
538
+ else if (event.key === 'End') target = keys[keys.length - 1]
539
+ if (target === null) return
540
+ event.preventDefault()
541
+ setTab(target)
542
+ setPicked(true)
543
+ var button = document.getElementById('sn-tab-' + target)
544
+ if (button !== null) button.focus()
545
+ }
546
+
547
+ var budget = state.budget || {}
548
+ var limit = budget.limit || 800
549
+ var caps = budget.caps || { notes: 200, profile: 130, env: 320, facts: 110, habits: 40 }
550
+ var segments = [
551
+ { key: 'notes', label: '自述', value: budget.notes || 0, color: '#60a5fa' },
552
+ { key: 'profile', label: '画像', value: budget.profile || 0, color: '#a78bfa' },
553
+ { key: 'facts', label: '结论', value: budget.facts || 0, color: '#34d399' },
554
+ { key: 'habits', label: '习惯', value: budget.habits || 0, color: '#fbbf24' },
555
+ ]
556
+
557
+ var budgetBlock = h(
558
+ 'div',
559
+ { className: 'sn-budget', key: 'budget' },
560
+ h(
561
+ 'div',
562
+ { className: 'sn-budget-bar', title: '本回合注入 ' + (budget.total || 0) + ' / ' + limit + ' 字' },
563
+ segments.map(function (item) {
564
+ var width = limit > 0 ? Math.max(0, Math.min(100, (item.value / limit) * 100)) : 0
565
+ return h('div', { key: item.key, className: 'sn-budget-seg', style: { width: width + '%', background: item.color } })
566
+ }),
567
+ ),
568
+ h(
569
+ 'div',
570
+ { className: 'sn-budget-legend' },
571
+ h('span', { className: 'sn-muted' }, '本回合 ' + (budget.total || 0) + ' / ' + limit + ' 字'),
572
+ segments.map(function (item) {
573
+ return h(
574
+ 'span',
575
+ { key: item.key },
576
+ h('span', { className: 'sn-legend-dot', style: { background: item.color } }),
577
+ item.label + ' ' + item.value,
578
+ )
579
+ }),
580
+ ),
581
+ )
582
+
583
+ var statusBar = h(
584
+ 'div',
585
+ { className: 'sn-statusbar', key: 'status' },
586
+ h('span', { className: 'sn-dot' + (state.enabled ? ' sn-dot-on' : '') }),
587
+ h(
588
+ 'span',
589
+ null,
590
+ state.enabled
591
+ ? '注入已开启 · 每回合实际注入 ' + (budget.total || 0) + ' 字(约 ' + Math.round((budget.total || 0) / 1.6) + ' token)· 上限 ' + limit
592
+ : '注入已关闭 · 内容还在,只是不进上下文',
593
+ ),
594
+ h('span', { className: 'sn-grow' }),
595
+ h('span', { className: 'sn-muted' }, '归档 ' + state.archiveFiles + ' 个文件'),
596
+ )
597
+
598
+ var statsRow = h(
599
+ 'div',
600
+ { className: 'sn-stats', key: 'stats' },
601
+ h('div', { className: 'sn-stat' }, h('div', { className: 'sn-stat-num' }, state.entries), h('div', { className: 'sn-stat-label' }, '条自述')),
602
+ h('div', { className: 'sn-stat' }, h('div', { className: 'sn-stat-num' }, state.chars), h('div', { className: 'sn-stat-label' }, '存储字数')),
603
+ h('div', { className: 'sn-stat' }, h('div', { className: 'sn-stat-num' }, usage + '%'), h('div', { className: 'sn-stat-label' }, '存储占用')),
604
+ )
605
+
606
+ function lastWriteText(iso) {
607
+ if (typeof iso !== 'string' || iso === '') return '还没写过'
608
+ var at = Date.parse(iso)
609
+ if (!Number.isFinite(at)) return '还没写过'
610
+ var mins = Math.max(0, Math.round((Date.now() - at) / 60000))
611
+ if (mins < 1) return '刚刚'
612
+ if (mins < 60) return mins + ' 分钟前'
613
+ var hours = Math.round(mins / 60)
614
+ if (hours < 24) return hours + ' 小时前'
615
+ return Math.round(hours / 24) + ' 天前'
616
+ }
617
+
618
+ var settingsCard = h(
619
+ 'div',
620
+ { className: 'sn-card', key: 'settings' },
621
+ h(
622
+ 'div',
623
+ { className: 'sn-card-title' },
624
+ '注入与容量',
625
+ ),
626
+ h(
627
+ 'div',
628
+ { className: 'sn-row' },
629
+ h('label', { className: 'sn-toggle' }, h('input', { type: 'checkbox', checked: state.enabled, onChange: toggleEnabled }), h('span', null, '每回合注入')),
630
+ h('span', { className: 'sn-grow' }),
631
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: refresh, disabled: busy !== '' }, '刷新'),
632
+ ),
633
+ h(
634
+ 'div',
635
+ { className: 'sn-limits' },
636
+ h('label', { className: 'sn-field' }, h('span', null, '存储上限(字)'), h('input', { type: 'number', min: 100, max: 8000, value: maxChars, onChange: function (e) { setMaxChars(e.target.value) } })),
637
+ h('label', { className: 'sn-field' }, h('span', null, '存储上限(条)'), h('input', { type: 'number', min: 1, max: 200, value: maxEntries, onChange: function (e) { setMaxEntries(e.target.value) } })),
638
+ h('button', { className: 'sn-btn', onClick: saveConfig, disabled: busy !== '' }, '保存上限'),
639
+ ),
640
+ h('div', { className: 'sn-hint' }, '这两个数是 notes.md 能存多少:超出的从最旧开始挪进 archive,不会丢。'),
641
+ h(
642
+ 'div',
643
+ { className: 'sn-hint' },
644
+ '它们不影响每回合注入多少 —— 注入只取最新的、合计 ≤ ' + caps.notes + ' 字(这个数写死在插件里,改不了)。想看真正贴进去的字,去「注入」页。',
645
+ ),
646
+ h(
647
+ 'div',
648
+ { className: 'sn-field sn-field-wide' },
649
+ h('span', null, '注入抬头(每回合那段的第一行)'),
650
+ h('input', {
651
+ type: 'text',
652
+ maxLength: 24,
653
+ value: heading,
654
+ placeholder: '例:记忆(我写的)',
655
+ onChange: function (e) { setHeading(e.target.value) },
656
+ }),
657
+ ),
658
+ h(
659
+ 'div',
660
+ { className: 'sn-row' },
661
+ h('span', { className: 'sn-hint' }, '留空就不加抬头,注入直接从内容开始。'),
662
+ h('span', { className: 'sn-grow' }),
663
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: saveHeading, disabled: busy !== '' }, '保存抬头'),
664
+ ),
665
+ h('div', { className: 'sn-hint' }, '上次写入:' + lastWriteText(state.notesLastWrite)),
666
+ h(
667
+ 'div',
668
+ { className: 'sn-field sn-field-wide' },
669
+ h('span', null, '什么时候该写自述(留空=用默认判据)'),
670
+ h('textarea', {
671
+ rows: 2,
672
+ maxLength: 80,
673
+ value: noteGuide,
674
+ placeholder: '例:每次改完一个插件、跑完验收之后写一条;闲聊不用写。',
675
+ onChange: function (e) { setNoteGuide(e.target.value) },
676
+ }),
677
+ ),
678
+ h(
679
+ 'div',
680
+ { className: 'sn-row' },
681
+ h('span', { className: 'sn-hint' }, '这段会作为「写自述的时机」进注入,下一回合生效;工具说明里的默认判据仍然有效。'),
682
+ h('span', { className: 'sn-grow' }),
683
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: saveConfig, disabled: busy !== '' }, '保存引导'),
684
+ ),
685
+ )
686
+
687
+ var idx = state.index || {}
688
+ var idxStatus = idx.present !== true ? '未建立' : idx.fresh === true ? '正常' : '已过期'
689
+ var idxTone = idx.present !== true ? 'sn-kv-tag-missing' : idx.fresh === true ? 'sn-kv-tag-ok' : 'sn-kv-tag-stale'
690
+
691
+ function kv(label, value) {
692
+ return h(
693
+ 'div',
694
+ { className: 'sn-kv-row', key: label },
695
+ h('span', { className: 'sn-kv-key' }, label),
696
+ h('span', { className: 'sn-kv-val' }, value),
697
+ )
698
+ }
699
+
700
+ // 重建索引:跑完必须用返回的 verified 验收,HTTP 200 不等于覆盖完整
701
+ function rebuildIndex() {
702
+ setBusy('index')
703
+ apiPost('/index', { action: 'build' })
704
+ .then(function (data) {
705
+ apply(data)
706
+ if (data.ok === false) {
707
+ setNotice({ tone: 'warn', text: data.error || '重建跑完了,但覆盖还没补齐' })
708
+ return
709
+ }
710
+ var v = data.verified || {}
711
+ setNotice({
712
+ tone: 'ok',
713
+ text:
714
+ '索引已重建:' + (v.records ?? '?') + ' 条记录 · 覆盖 ' +
715
+ (v.sessions ?? '?') + '/' + (v.disk ?? '?') + ' 会话 · 待同步 ' + (v.missing ?? 0) +
716
+ (v.failed > 0 ? '(' + v.failed + ' 个解不开)' : ''),
717
+ })
718
+ })
719
+ .catch(function (error) {
720
+ setNotice({ tone: 'warn', text: '重建失败:' + error.message })
721
+ })
722
+ .then(function () {
723
+ setBusy('')
724
+ })
725
+ }
726
+
727
+ var indexCard = h(
728
+ 'div',
729
+ { className: 'sn-card', key: 'index' },
730
+ h(
731
+ 'div',
732
+ { className: 'sn-card-head' },
733
+ h('div', { className: 'sn-card-title' }, '历史对话索引'),
734
+ h('div', { className: 'sn-card-note' }, '只存词元与位置,不复制正文'),
735
+ ),
736
+ h(
737
+ 'div',
738
+ { className: 'sn-kv' },
739
+ kv('索引状态', h('span', { className: 'sn-kv-tag ' + idxTone }, idxStatus)),
740
+ kv('已索引会话', String(idx.sessions ?? 0)),
741
+ kv('磁盘会话', String(idx.disk ?? 0)),
742
+ kv('待同步会话', String(idx.missing ?? 0)),
743
+ kv('消息记录', String(idx.records ?? 0)),
744
+ kv('索引大小', idx.present === true ? sizeText(idx.bytes) : '—'),
745
+ kv('最近构建', idx.builtAt ? timeText(idx.builtAt) : '—'),
746
+ ),
747
+ h(
748
+ 'div',
749
+ { className: 'sn-actions' },
750
+ h('button', { className: 'sn-btn', onClick: rebuildIndex, disabled: busy !== '' }, busy === 'index' ? '重建中…' : '重建索引'),
751
+ h('span', { className: 'sn-grow' }),
752
+ h('span', { className: 'sn-hint' }, idx.present === true && idx.fresh === false ? '过期时不会用索引查,会退回完整扫描' : '重建期间会占住几秒'),
753
+ ),
754
+ h('div', { className: 'sn-hint' }, '查旧账用 memory_recall;原文取自 DSH 自己的会话记录,只读、不外发。'),
755
+ )
756
+
757
+ function sizeText(bytes) {
758
+ var n = Number(bytes) || 0
759
+ if (n <= 0) return '0 B'
760
+ if (n < 1024) return n + ' B'
761
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
762
+ return (n / 1024 / 1024).toFixed(1) + ' MB'
763
+ }
764
+
765
+ function timeText(iso) {
766
+ if (!iso) return '—'
767
+ var d = new Date(iso)
768
+ if (isNaN(d.getTime())) return '—'
769
+ function pad(n) {
770
+ return n < 10 ? '0' + n : String(n)
771
+ }
772
+ return pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
773
+ }
774
+
775
+ // 打开文件:宿主侧 spawn 系统文件管理器,浏览器自己做不了这件事
776
+ /** 导出成一份 JSON 文件:浏览器里直接下载,不经过任何服务器。 */
777
+ function exportAll() {
778
+ setBusy('export')
779
+ apiGet('/export')
780
+ .then(function (bundle) {
781
+ if (!bundle || bundle.app !== 'dsh-selfnote') {
782
+ setNotice({ tone: 'warn', text: '导出失败:' + ((bundle && bundle.error) || '服务端没给出可用的包') })
783
+ return
784
+ }
785
+ var name = 'selfnote-' + new Date().toISOString().slice(0, 10) + '.json'
786
+ var blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
787
+ var url = URL.createObjectURL(blob)
788
+ var link = document.createElement('a')
789
+ link.href = url
790
+ link.download = name
791
+ document.body.appendChild(link)
792
+ link.click()
793
+ link.remove()
794
+ setTimeout(function () {
795
+ URL.revokeObjectURL(url)
796
+ }, 1000)
797
+ setNotice({
798
+ tone: 'ok',
799
+ text: '已导出 ' + name + ':' + (bundle.counts.facts || 0) + ' 条记忆 / ' + (bundle.counts.notes || 0) + ' 条自述 / ' + (bundle.counts.habits || 0) + ' 个习惯',
800
+ })
801
+ })
802
+ .catch(function (error) {
803
+ setNotice({ tone: 'warn', text: '导出失败:' + error.message })
804
+ })
805
+ .then(function () {
806
+ setBusy('')
807
+ })
808
+ }
809
+
810
+ /** 导入:读文件 → 交服务端合并 → 把账目摊在卡上。 */
811
+ function importBundle(file) {
812
+ if (!file) return
813
+ setBusy('import')
814
+ setTransfer(null)
815
+ var reader = new FileReader()
816
+ reader.onload = function () {
817
+ var parsed = null
818
+ try {
819
+ parsed = JSON.parse(String(reader.result || ''))
820
+ } catch (error) {
821
+ setNotice({ tone: 'warn', text: '这个文件不是 JSON:' + error.message })
822
+ setBusy('')
823
+ return
824
+ }
825
+ apiPost('/import', { bundle: parsed, keepStatus: keepStatus })
826
+ .then(function (data) {
827
+ if (!data || data.ok === false) {
828
+ setNotice({ tone: 'warn', text: '导入失败:' + ((data && data.error) || '未知原因') })
829
+ return
830
+ }
831
+ setTransfer(data.import || null)
832
+ apply(data)
833
+ setNotice({ tone: 'ok', text: '导入完成:加了 ' + (data.import.added.facts || 0) + ' 条记忆、' + (data.import.added.notes || 0) + ' 条自述' })
834
+ })
835
+ .catch(function (error) {
836
+ setNotice({ tone: 'warn', text: '导入失败:' + error.message })
837
+ })
838
+ .then(function () {
839
+ setBusy('')
840
+ })
841
+ }
842
+ reader.onerror = function () {
843
+ setNotice({ tone: 'warn', text: '读不了这个文件' })
844
+ setBusy('')
845
+ }
846
+ reader.readAsText(file)
847
+ }
848
+
849
+ function openFile(key) {
850
+ setBusy('open')
851
+ apiPost('/open', { key: key })
852
+ .then(function (data) {
853
+ setNotice({
854
+ tone: 'ok',
855
+ text:
856
+ data.exists === false
857
+ ? '这个文件还没生成,已经打开它所在的目录:' + data.opened
858
+ : '已经交给系统打开(' + (data.via || 'shell') + '):' + data.opened + ';窗口没出来就点一下路径复制它',
859
+ })
860
+ })
861
+ .catch(function (error) {
862
+ setNotice({ tone: 'warn', text: '打不开:' + error.message })
863
+ })
864
+ .then(function () {
865
+ setBusy('')
866
+ })
867
+ }
868
+
869
+ function copyPath(path) {
870
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
871
+ setNotice({ tone: 'warn', text: '这个浏览器不让复制,路径可以直接选中。' })
872
+ return
873
+ }
874
+ navigator.clipboard
875
+ .writeText(path)
876
+ .then(function () {
877
+ setNotice({ tone: 'ok', text: '路径已复制。' })
878
+ })
879
+ .catch(function () {
880
+ setNotice({ tone: 'warn', text: '复制失败,路径可以直接选中。' })
881
+ })
882
+ }
883
+
884
+ var files = state.files || []
885
+ var filesBlock = h(
886
+ 'div',
887
+ { className: 'sn-card', key: 'files' },
888
+ h(
889
+ 'div',
890
+ { className: 'sn-card-head' },
891
+ h('div', { className: 'sn-card-title' }, '文件'),
892
+ h('div', { className: 'sn-card-note' }, '只列和 AI 记忆有关的;会话原文不在这里'),
893
+ ),
894
+ files.length === 0
895
+ ? h('div', { className: 'sn-empty' }, '读不到文件列表。')
896
+ : h(
897
+ 'div',
898
+ { className: 'sn-files' },
899
+ files.map(function (file) {
900
+ return h(
901
+ 'div',
902
+ { key: file.key, className: 'sn-file' + (file.exists ? '' : ' sn-file-missing') },
903
+ h(
904
+ 'div',
905
+ { className: 'sn-file-main' },
906
+ h(
907
+ 'div',
908
+ { className: 'sn-file-head' },
909
+ h('span', { className: 'sn-file-label' }, file.label),
910
+ h('span', { className: 'sn-file-count' }, file.count || '—'),
911
+ ),
912
+ h(
913
+ 'div',
914
+ { className: 'sn-file-path', title: '点一下复制路径', onClick: function () { copyPath(file.path) } },
915
+ file.path,
916
+ ),
917
+ h(
918
+ 'div',
919
+ { className: 'sn-file-meta' },
920
+ file.detail + ' · ' + (file.exists ? sizeText(file.bytes) + ' · ' + timeText(file.mtime) : '还没生成'),
921
+ ),
922
+ ),
923
+ h(
924
+ 'button',
925
+ { className: 'sn-btn sn-btn-ghost sn-file-btn', onClick: function () { openFile(file.key) }, disabled: busy !== '' },
926
+ '打开',
927
+ ),
928
+ )
929
+ }),
930
+ ),
931
+ )
932
+
933
+ var transferCard = h(
934
+ 'div',
935
+ { className: 'sn-card', key: 'transfer' },
936
+ h(
937
+ 'div',
938
+ { className: 'sn-card-head' },
939
+ h('div', { className: 'sn-card-title' }, '迁移'),
940
+ h('div', { className: 'sn-card-note' }, '换机器用时导出成一份 JSON,在新机器上导入'),
941
+ ),
942
+ h(
943
+ 'div',
944
+ { className: 'sn-transfer' },
945
+ h(
946
+ 'div',
947
+ { className: 'sn-transfer-row' },
948
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: exportAll, disabled: busy !== '' }, busy === 'export' ? '导出中…' : '导出全部记忆'),
949
+ h(
950
+ 'label',
951
+ { className: 'sn-btn sn-btn-ghost sn-transfer-pick' },
952
+ busy === 'import' ? '导入中…' : '选择文件导入',
953
+ h('input', {
954
+ type: 'file',
955
+ accept: 'application/json,.json',
956
+ style: { display: 'none' },
957
+ onChange: function (event) {
958
+ var picked = event.target.files && event.target.files[0]
959
+ event.target.value = ''
960
+ importBundle(picked)
961
+ },
962
+ }),
963
+ ),
964
+ ),
965
+ h(
966
+ 'label',
967
+ { className: 'sn-check' },
968
+ h('input', { type: 'checkbox', checked: keepStatus, onChange: function (event) { setKeepStatus(event.target.checked) } }),
969
+ h('span', {}, '保留原状态(导入的「已采纳」仍然是已采纳)'),
970
+ ),
971
+ h(
972
+ 'div',
973
+ { className: 'sn-hint' },
974
+ '不勾的话,导进来的结论一律变回候选 —— 采纳要你在这里点一次。同 id、同正文的条目不会被覆盖。索引不跟着搬(它记的是本机路径),导入后去「索引」卡重建一次。',
975
+ ),
976
+ transfer === null
977
+ ? null
978
+ : h(
979
+ 'div',
980
+ { className: 'sn-transfer-report' },
981
+ h('div', null, '加了 ' + transfer.added.facts + ' 条记忆 · ' + transfer.added.notes + ' 条自述 · ' + transfer.added.habits + ' 个习惯'),
982
+ h('div', null, '跳过 ' + (transfer.skipped.facts + transfer.skipped.notes + transfer.skipped.habits) + ' 条重复' + (transfer.skipped.invalid > 0 ? ' · ' + transfer.skipped.invalid + ' 条无效' : '')),
983
+ transfer.demoted > 0 ? h('div', null, '其中 ' + transfer.demoted + ' 条已采纳被降回候选(要保留就勾上面那个选项再导一次)') : null,
984
+ transfer.droppedNotes > 0 ? h('div', null, '自述超出容量,' + transfer.droppedNotes + ' 条没放进(去「自述」页看看要不要清)') : null,
985
+ transfer.indexStale ? h('div', { className: 'sn-transfer-warn' }, '索引已过期,重建一次再检索') : null,
986
+ ),
987
+ ),
988
+ )
989
+
990
+ var doctorCard = h(
991
+ 'div',
992
+ { className: 'sn-card', key: 'doctor' },
993
+ h(
994
+ 'div',
995
+ { className: 'sn-card-head' },
996
+ h('div', { className: 'sn-card-title' }, '体检'),
997
+ h('div', { className: 'sn-card-note' }, '让坏掉的东西自己喊出来,而不是等你去查'),
998
+ ),
999
+ doctor === null
1000
+ ? h('div', { className: 'sn-hint' }, '还没跑过。点一下:查索引是否落后、采集是否停了、文件有没有写坏、设置页是否在被拒。')
1001
+ : h(
1002
+ 'div',
1003
+ { className: 'sn-doc-list' },
1004
+ doctor.next
1005
+ ? h(
1006
+ 'div',
1007
+ { className: 'sn-doc-next', key: 'doctor-next' },
1008
+ '先做这一件:' + doctor.next.label + ' —— ' + doctor.next.detail,
1009
+ )
1010
+ : null,
1011
+ (doctor.checks || []).map(function (check) {
1012
+ return h(
1013
+ 'div',
1014
+ { key: check.key, className: 'sn-doc-row' },
1015
+ h(
1016
+ 'span',
1017
+ { className: 'sn-doc-tag sn-doc-' + check.level },
1018
+ check.level === 'ok' ? '正常' : check.level === 'warn' ? '注意' : '有问题',
1019
+ ),
1020
+ h('span', { className: 'sn-doc-label' }, check.label),
1021
+ h('span', { className: 'sn-doc-detail' }, check.detail + (check.fix ? '(' + check.fix + ')' : '')),
1022
+ )
1023
+ }),
1024
+ ),
1025
+ h(
1026
+ 'div',
1027
+ { className: 'sn-actions' },
1028
+ h('button', { className: 'sn-btn', onClick: runDoctor, disabled: busy !== '' }, busy === 'doctor' ? '检查中…' : '跑一次体检'),
1029
+ h('span', { className: 'sn-grow' }),
1030
+ doctor === null ? null : h('span', { className: 'sn-hint' }, '检查于 ' + timeText(doctor.at)),
1031
+ ),
1032
+ )
1033
+
1034
+ var writeCard = h(
1035
+ 'div',
1036
+ { className: 'sn-card', key: 'write' },
1037
+ h('div', { className: 'sn-card-title' }, '手动写一条'),
1038
+ h('div', { className: 'sn-hint' }, '主语是 agent 自己,第一人称「我」指 agent;一句话到三句话,空话和套话不用写。'),
1039
+ h('textarea', {
1040
+ className: 'sn-input',
1041
+ rows: 2,
1042
+ placeholder: '第一人称,一句话到三句话;空话和套话不用写。',
1043
+ value: draft,
1044
+ onChange: function (e) { setDraft(e.target.value) },
1045
+ }),
1046
+ h(
1047
+ 'div',
1048
+ { className: 'sn-actions' },
1049
+ h('button', { className: 'sn-btn', onClick: addNote, disabled: busy !== '' }, '写进去'),
1050
+ h('span', { className: 'sn-grow' }),
1051
+ h('button', { className: 'sn-btn sn-btn-danger', onClick: clearAll, disabled: busy !== '' }, confirming ? '真的清空' : '清空当前(进归档)'),
1052
+ ),
1053
+ )
1054
+
1055
+ var notesList = h(
1056
+ 'div',
1057
+ { className: 'sn-card', key: 'notes' },
1058
+ h('div', { className: 'sn-card-title' }, '现在生效的内容(agent 的第一人称自述,不是用户的原话)'),
1059
+ h(
1060
+ 'div',
1061
+ { className: 'sn-hint' },
1062
+ '每一行都会进注入:最新的优先,合计 ≤ ' + caps.notes + ' 字,放不下的整条跳过(不截半句)。改完/删完下一回合生效。',
1063
+ ),
1064
+ reversed.length === 0
1065
+ ? h('div', { className: 'sn-empty' }, '还没有任何自述。模型攒到第一条时会出现在这里。')
1066
+ : h(
1067
+ 'ul',
1068
+ { className: 'sn-list' },
1069
+ reversed.map(function (entry, index) {
1070
+ // 显示是最新在前,存储是最旧在前:下标得换回去,否则「改第二条」会改到别人
1071
+ var stored = notes.length - 1 - index
1072
+ var isEditing = editing === stored
1073
+ return h(
1074
+ 'li',
1075
+ { key: 'n' + stored, className: 'sn-item' + (isEditing ? ' sn-item-editing' : '') },
1076
+ h('span', { className: 'sn-date' }, entry.date || '—'),
1077
+ isEditing
1078
+ ? h(
1079
+ 'span',
1080
+ { className: 'sn-edit' },
1081
+ h('textarea', {
1082
+ className: 'sn-input',
1083
+ rows: 2,
1084
+ // 单条硬上限 600 字(服务端同一条)——这里是提示,不是校验
1085
+ maxLength: 600,
1086
+ value: editText,
1087
+ onChange: function (e) { setEditText(e.target.value) },
1088
+ }),
1089
+ h(
1090
+ 'span',
1091
+ { className: 'sn-actions' },
1092
+ h('button', { className: 'sn-btn', onClick: function () { saveNote(stored) }, disabled: busy !== '' }, '保存'),
1093
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: cancelEditNote, disabled: busy !== '' }, '取消'),
1094
+ ),
1095
+ )
1096
+ : h('span', { className: 'sn-text' }, entry.text),
1097
+ isEditing
1098
+ ? null
1099
+ : h(
1100
+ 'span',
1101
+ { className: 'sn-row-actions' },
1102
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { startEditNote(stored, entry.text) }, disabled: busy !== '' }, '改'),
1103
+ h('button', { className: 'sn-btn sn-btn-danger', onClick: function () { removeNote(stored) }, disabled: busy !== '' }, pendingDelete === stored ? '真的删' : '删'),
1104
+ ),
1105
+ )
1106
+ }),
1107
+ ),
1108
+ )
1109
+
1110
+ var profileCard = h(
1111
+ 'div',
1112
+ { className: 'sn-card', key: 'profile' },
1113
+ h('div', { className: 'sn-card-title' }, '关于你(从已采纳记忆聚合,不是新事实)'),
1114
+ h('div', { className: 'sn-sub' }, '只读你采纳过的记忆;每句都能回溯到下面某条,关掉就不注入。'),
1115
+ h('textarea', {
1116
+ className: 'sn-input',
1117
+ rows: 3,
1118
+ placeholder: '留空 = 用自动聚合;写了就按你写的注入',
1119
+ value: profileDraft === null ? '' : profileDraft,
1120
+ onChange: function (e) { setProfileDraft(e.target.value) },
1121
+ }),
1122
+ h(
1123
+ 'div',
1124
+ { className: 'sn-row' },
1125
+ h(
1126
+ 'label',
1127
+ { className: 'sn-toggle' },
1128
+ h('input', { type: 'checkbox', checked: !!(state.profile && state.profile.enabled), onChange: toggleProfile }),
1129
+ h('span', null, '每回合注入画像'),
1130
+ ),
1131
+ h('span', { className: 'sn-grow' }),
1132
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { saveProfile('') }, disabled: busy !== '' }, '用自动聚合'),
1133
+ h('button', { className: 'sn-btn', onClick: function () { saveProfile(profileDraft || '') }, disabled: busy !== '' }, '保存画像'),
1134
+ ),
1135
+ h(
1136
+ 'div',
1137
+ { className: 'sn-hint' },
1138
+ '上限 ' + (state.profile && state.profile.maxChars ? state.profile.maxChars : 110) + ' 字 · 当前 ' + (state.profile ? state.profile.chars : 0) + ' 字' +
1139
+ (state.profile && state.profile.manual ? ' · 手改优先' : ' · 自动聚合'),
1140
+ ),
1141
+ state.profile && state.profile.parts && state.profile.parts.length > 0
1142
+ ? h(
1143
+ 'ul',
1144
+ { className: 'sn-sources' },
1145
+ state.profile.parts.map(function (part, index) {
1146
+ return h(
1147
+ 'li',
1148
+ { key: index, className: 'sn-source' },
1149
+ h('span', { className: 'sn-chip' }, part.tag),
1150
+ h('span', { className: 'sn-muted' }, ' ← ' + part.factId),
1151
+ )
1152
+ }),
1153
+ )
1154
+ : null,
1155
+ state.profile && state.profile.conflicts && state.profile.conflicts.length > 0
1156
+ ? h(
1157
+ 'div',
1158
+ { className: 'sn-hint-warn' },
1159
+ '同标签还有未合并的条目:' + state.profile.conflicts.map(function (item) { return item.tag + ' ×' + item.count }).join('、'),
1160
+ )
1161
+ : null,
1162
+ )
1163
+
1164
+ var memoryCard = h(
1165
+ 'div',
1166
+ { className: 'sn-card', key: 'memory' },
1167
+ h('div', { className: 'sn-card-title' }, '记忆条目(主语是用户:你明确说过、可以引用的结论)'),
1168
+ h(
1169
+ 'div',
1170
+ { className: 'sn-hint' },
1171
+ '本回合注入 ' + (budget.total || 0) + ' / ' + limit +
1172
+ ' 字 · 自述 ' + (budget.notes || 0) +
1173
+ ' 画像 ' + (budget.profile || 0) +
1174
+ ' 结论 ' + (budget.facts || 0) +
1175
+ ' 习惯 ' + (budget.habits || 0),
1176
+ ),
1177
+
1178
+ h('div', { className: 'sn-sub' }, '待确认(' + candidates.length + ')'),
1179
+ candidates.length === 0
1180
+ ? h('div', { className: 'sn-empty' }, '没有待确认的候选。我翻到值得记的东西时会先放这儿。')
1181
+ : candidates.map(function (fact) {
1182
+ var partner = fact.conflictWith ? factMap[fact.conflictWith] : null
1183
+ if (partner === null || partner === undefined) {
1184
+ return h('div', { key: fact.id, className: 'sn-fact' }, factBody(fact, true))
1185
+ }
1186
+ return h(
1187
+ 'div',
1188
+ { key: fact.id, className: 'sn-fact sn-fact-conflict' },
1189
+ h(
1190
+ 'div',
1191
+ { className: 'sn-pair-head' },
1192
+ '两条说法对不上 —— 都留着,你来定哪个算数;采纳一条不会自动处理另一条。',
1193
+ ),
1194
+ h(
1195
+ 'div',
1196
+ { className: 'sn-pair' },
1197
+ h(
1198
+ 'div',
1199
+ { className: 'sn-pair-side' },
1200
+ h('div', { className: 'sn-pair-tag' }, '新候选'),
1201
+ factBody(fact, true),
1202
+ ),
1203
+ h(
1204
+ 'div',
1205
+ { className: 'sn-pair-side' },
1206
+ h('div', { className: 'sn-pair-tag' }, '已有的(' + statusLabel(partner.status) + ')'),
1207
+ factBody(partner, partner.status === 'candidate'),
1208
+ ),
1209
+ ),
1210
+ )
1211
+ }),
1212
+
1213
+ h('div', { className: 'sn-sub' }, '已采纳(' + approvedFacts.length + ')'),
1214
+ approvedFacts.length === 0
1215
+ ? h('div', { className: 'sn-empty' }, '还没有已采纳的结论。')
1216
+ : approvedFacts.map(function (fact) {
1217
+ var editingThis = factEdit !== null && factEdit.id === fact.id
1218
+ return h(
1219
+ 'div',
1220
+ { key: fact.id, className: 'sn-fact' },
1221
+ editingThis
1222
+ ? h(
1223
+ 'div',
1224
+ { className: 'sn-fact-edit' },
1225
+ h('textarea', {
1226
+ className: 'sn-input',
1227
+ rows: 2,
1228
+ maxLength: 240,
1229
+ value: factEdit.text,
1230
+ onChange: function (e) { setFactEdit({ id: fact.id, text: e.target.value }) },
1231
+ }),
1232
+ h(
1233
+ 'span',
1234
+ { className: 'sn-actions' },
1235
+ h('button', { className: 'sn-btn', onClick: function () { saveFactText(fact.id, factEdit.text) }, disabled: busy !== '' }, '保存'),
1236
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit(null) }, disabled: busy !== '' }, '取消'),
1237
+ ),
1238
+ )
1239
+ : h('div', { className: 'sn-fact-text' }, fact.text),
1240
+ h(
1241
+ 'div',
1242
+ { className: 'sn-actions' },
1243
+ h('span', { className: 'sn-heat', title: '热度 = 使用次数 × 时间衰减;只是排序,不改可信度' }, '热度 ' + (fact.heat ?? '—')),
1244
+ h('span', { className: 'sn-grow' }),
1245
+ editingThis
1246
+ ? null
1247
+ : h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
1248
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFact(fact.id, 'retired') }, disabled: busy !== '' }, '退役'),
1249
+ ),
1250
+ )
1251
+ }),
1252
+
1253
+ (state.habits || []).length > 0
1254
+ ? h(
1255
+ 'div',
1256
+ null,
1257
+ h('div', { className: 'sn-sub' }, '已生效的习惯(进注入的「习惯」段)'),
1258
+ h('div', { className: 'sn-hint' }, '不要了就撤销。撤销只是不再是习惯,标签和记忆都还在。'),
1259
+ (state.habits || []).map(function (habit) {
1260
+ return h(
1261
+ 'div',
1262
+ { key: 'h-' + habit.text, className: 'sn-fact' },
1263
+ h('div', { className: 'sn-fact-text' }, habit.text),
1264
+ h(
1265
+ 'div',
1266
+ { className: 'sn-actions' },
1267
+ h(
1268
+ 'button',
1269
+ {
1270
+ className: 'sn-btn sn-btn-ghost',
1271
+ onClick: function () { setHabitState(habit.text, 'retired', habit.tag || '') },
1272
+ disabled: busy !== '',
1273
+ },
1274
+ '撤销',
1275
+ ),
1276
+ ),
1277
+ )
1278
+ }),
1279
+ )
1280
+ : null,
1281
+ suggestions.length > 0
1282
+ ? h(
1283
+ 'div',
1284
+ null,
1285
+ h('div', { className: 'sn-sub' }, '可以升成习惯'),
1286
+ suggestions.map(function (item) {
1287
+ return h(
1288
+ 'div',
1289
+ { key: item.tag, className: 'sn-fact' },
1290
+ h('div', { className: 'sn-fact-text' }, item.tag + ' ×' + item.count),
1291
+ item.active
1292
+ ? h('div', { className: 'sn-hint' }, '已经是习惯了')
1293
+ : h(
1294
+ 'div',
1295
+ { className: 'sn-actions' },
1296
+ h('button', { className: 'sn-btn', onClick: function () { setHabitState(item.tag, 'active', item.tag) }, disabled: busy !== '' }, '升成习惯'),
1297
+ ),
1298
+ )
1299
+ }),
1300
+ )
1301
+ : null,
1302
+ )
1303
+
1304
+ // 注入页:把「这一回合实际会贴进提示词的那段字」原样摊开,外加谁进了、谁被挤掉
1305
+ var inj = state.injection || {}
1306
+ var injSegments = inj.segments || []
1307
+ var skippedItems = []
1308
+ injSegments.forEach(function (seg) {
1309
+ ;(seg.skipped || []).forEach(function (text) {
1310
+ skippedItems.push({ seg: seg.label, text: text })
1311
+ })
1312
+ })
1313
+
1314
+ var injectionCard = h(
1315
+ 'div',
1316
+ { className: 'sn-card', key: 'injection' },
1317
+ h(
1318
+ 'div',
1319
+ { className: 'sn-card-head' },
1320
+ h('div', { className: 'sn-card-title' }, '这一回合注入的内容'),
1321
+ h(
1322
+ 'div',
1323
+ { className: 'sn-card-note' },
1324
+ (inj.chars || 0) + ' / ' + (inj.limit || 800) + ' 字' +
1325
+ (inj.overhead > 0 ? '(含抬头 ' + inj.overhead + ' 字)' : ''),
1326
+ ),
1327
+ ),
1328
+ h('div', { className: 'sn-hint' }, '下面这段每回合都贴在用户消息前面。它只改变「我」看到的背景,不会出现在你的输入框里。'),
1329
+ h('pre', { className: 'sn-pre' }, inj.text || '(现在没有可注入的内容)'),
1330
+ h(
1331
+ 'div',
1332
+ { className: 'sn-seg' },
1333
+ injSegments.map(function (seg) {
1334
+ var skipped = seg.skipped || []
1335
+ var stateText = seg.chars > 0 ? '全部进入' : '暂无内容'
1336
+ if (skipped.length > 0) stateText = '挤掉 ' + skipped.length + ' 条'
1337
+ else if (seg.chars === 0 && seg.absorbed > 0) stateText = '已被画像吸收 ' + seg.absorbed + ' 条'
1338
+ return h(
1339
+ 'div',
1340
+ { className: 'sn-seg-row', key: seg.key },
1341
+ h('span', { className: 'sn-seg-key' }, seg.label),
1342
+ h('span', { className: 'sn-seg-num' }, (seg.chars || 0) + ' / ' + seg.limit + ' 字'),
1343
+ h('span', { className: 'sn-seg-state' + (skipped.length > 0 ? ' sn-seg-state-warn' : '') }, stateText),
1344
+ )
1345
+ }),
1346
+ ),
1347
+ h(
1348
+ 'div',
1349
+ { className: 'sn-hint' },
1350
+ '四段只算记忆本身的字数;标题、边界句、画像抬头这些框架文字另计(' + (inj.overhead || 0) + ' 字),也算在总字数里。',
1351
+ ),
1352
+ skippedItems.length === 0
1353
+ ? null
1354
+ : h(
1355
+ 'div',
1356
+ { className: 'sn-skipped' },
1357
+ h(
1358
+ 'div',
1359
+ { className: 'sn-skipped-head' },
1360
+ '被挤掉的 ' + skippedItems.length + ' 条 —— 超出本段预算就整条跳过,不截半句(半句话会变成错结论)',
1361
+ ),
1362
+ skippedItems.slice(0, 6).map(function (item, i) {
1363
+ var text = item.text.length > 60 ? item.text.slice(0, 60) + '…' : item.text
1364
+ return h(
1365
+ 'div',
1366
+ { className: 'sn-skipped-row', key: 'sk' + i },
1367
+ h('span', { className: 'sn-badge' }, item.seg),
1368
+ h('span', { className: 'sn-skipped-text' }, text),
1369
+ )
1370
+ }),
1371
+ skippedItems.length > 6 ? h('div', { className: 'sn-hint' }, '还有 ' + (skippedItems.length - 6) + ' 条没列出来') : null,
1372
+ ),
1373
+ )
1374
+
1375
+ // 注入归属:这条记忆到底有没有被用上。数据来自 hitCount / lastHit,
1376
+ // 只有「真的进了这句话」才 +1,被检索到不算 —— 所以它衡量的是占用,不是价值。
1377
+ var attr = state.attribution || {}
1378
+ var attrRows = attr.rows || []
1379
+ var attrSegLabel = { profile: '画像', env: '环境', facts: '结论' }
1380
+
1381
+ function attrTime(row) {
1382
+ if (row.state === 'never') return '从没进过'
1383
+ if (row.state === 'searched') {
1384
+ if (row.seenDays === 0) return '今天搜到过'
1385
+ if (row.seenDays === null || row.seenDays === undefined) return '搜到过'
1386
+ return row.seenDays + ' 天前搜到'
1387
+ }
1388
+ if (row.days === 0) return '今天还进过'
1389
+ if (row.days === null) return '时间不明'
1390
+ return row.days + ' 天前'
1391
+ }
1392
+
1393
+ var attributionCard = attrRows.length === 0
1394
+ ? null
1395
+ : h(
1396
+ 'div',
1397
+ { className: 'sn-card', key: 'attribution' },
1398
+ h(
1399
+ 'div',
1400
+ { className: 'sn-card-head' },
1401
+ h('div', { className: 'sn-card-title' }, '注入归属'),
1402
+ h(
1403
+ 'div',
1404
+ { className: 'sn-card-note' },
1405
+ '在用 ' + (attr.live || 0) + ' · 静默 ' + (attr.stale || 0) + ' · 只搜到过 ' + (attr.searched || 0) + ' · 从没进过 ' + (attr.never || 0),
1406
+ ),
1407
+ ),
1408
+ h(
1409
+ 'div',
1410
+ { className: 'sn-hint' },
1411
+ '每条已采纳记忆真的被贴进提示词几次、上次什么时候。只有进了这句话才 +1,被检索到不算;' +
1412
+ '超过 ' + (attr.staleDays || 30) + ' 天没进过的,就是占着位置没在用的。',
1413
+ ),
1414
+ h(
1415
+ 'div',
1416
+ { className: 'sn-attr' },
1417
+ attrRows.slice(0, 14).map(function (row) {
1418
+ var text = row.text.length > 46 ? row.text.slice(0, 46) + '…' : row.text
1419
+ return h(
1420
+ 'div',
1421
+ { className: 'sn-attr-row sn-attr-' + row.state, key: row.id },
1422
+ h('span', { className: 'sn-badge' }, attrSegLabel[row.segment] || (row.state === 'searched' ? '搜到过' : '没进')),
1423
+ h('span', { className: 'sn-attr-text' }, text),
1424
+ h(
1425
+ 'span',
1426
+ { className: 'sn-attr-num' },
1427
+ row.state === 'searched' ? '翻了 ' + (row.seen || 0) + ' 次' : (row.hits || 0) + ' 次',
1428
+ ),
1429
+ h('span', { className: 'sn-attr-when' }, attrTime(row)),
1430
+ )
1431
+ }),
1432
+ attrRows.length > 14
1433
+ ? h('div', { className: 'sn-hint' }, '还有 ' + (attrRows.length - 14) + ' 条没列出来')
1434
+ : null,
1435
+ ),
1436
+ )
1437
+
1438
+ var panels = {
1439
+ overview: [statusBar, budgetBlock, statsRow, settingsCard, doctorCard, indexCard, filesBlock, transferCard],
1440
+ notes: [writeCard, notesList],
1441
+ memory: [memoryCard],
1442
+ profile: [profileCard],
1443
+ injection: [injectionCard, attributionCard],
1444
+ }
1445
+ var panelHints = {
1446
+ overview: '注入开关、容量上限,以及索引与文件位置。',
1447
+ notes: 'agent 写给自己看的话,攒多了从最旧的往 archive 挪。每一行都能改、能删。',
1448
+ memory: '候选、已采纳、可以升成习惯的标签;采纳只能人来点。',
1449
+ profile: '从已采纳记忆聚合出的「关于你」,一句一出处。',
1450
+ injection: '这一回合真的会贴进提示词的那段字,逐段拆开看谁进了、谁被挤掉。',
1451
+ }
1452
+
1453
+ return h(
1454
+ 'div',
1455
+ { className: 'sn-root' },
1456
+ h(
1457
+ 'div',
1458
+ { className: 'sn-head' },
1459
+ h('div', { className: 'sn-title' }, '人格(自述)'),
1460
+ h('div', { className: 'sn-sub' }, 'agent 写给自己看的话(「我」= agent),跨会话保留,每回合注入在对话开头。这里能改、能删,改完下一回合生效。'),
1461
+ ),
1462
+
1463
+ h(
1464
+ 'details',
1465
+ { className: 'sn-intro' },
1466
+ h('summary', null, '它是做什么的'),
1467
+ h(
1468
+ 'ul',
1469
+ { className: 'sn-intro-list' },
1470
+ h('li', null, h('b', null, '自述'), ' 模型写给自己看的第一人称短句,跨会话保留;攒多了从最旧的往 archive 挪。'),
1471
+ h('li', null, h('b', null, '关于你(画像)'), ' 从「已采纳」的事实里聚合,按标签每组取最新一条,每句都能点回原句;可以手改覆盖。'),
1472
+ h('li', null, h('b', null, '常驻结论'), ' 已采纳的事实里,画像没吸收到的那些。'),
1473
+ h('li', null, h('b', null, '习惯'), ' 由人点出来才生效,模型自己升不了。'),
1474
+ h('li', { className: 'sn-intro-note' }, '每回合合计 ≤' + limit + ' 字:自述 ' + caps.notes + ' / 画像 ' + caps.profile + ' / 环境 ' + caps.env + ' / 结论 ' + caps.facts + ' / 习惯 ' + caps.habits + '。放不下的那一条会跳过、不截半句。这几段的上限写死在插件里(不是设置项);检索工具不受这个限制。'),
1475
+ ),
1476
+ ),
1477
+
1478
+ h(
1479
+ 'div',
1480
+ { className: 'sn-tabs', role: 'tablist', onKeyDown: goTab },
1481
+ tabs.map(function (item) {
1482
+ var active = activeTab === item.key
1483
+ return h(
1484
+ 'button',
1485
+ {
1486
+ key: item.key,
1487
+ id: 'sn-tab-' + item.key,
1488
+ type: 'button',
1489
+ role: 'tab',
1490
+ 'aria-selected': active,
1491
+ 'aria-controls': 'sn-panel-' + item.key,
1492
+ tabIndex: active ? 0 : -1,
1493
+ className: 'sn-tab' + (active ? ' sn-tab-on' : ''),
1494
+ onClick: function () { setTab(item.key); setPicked(true) },
1495
+ },
1496
+ item.label,
1497
+ item.badge ? h('span', { className: 'sn-tab-badge' }, item.badge) : null,
1498
+ )
1499
+ }),
1500
+ ),
1501
+
1502
+ h(Notice, { tone: notice && notice.tone, text: notice && notice.text }),
1503
+
1504
+ h(
1505
+ 'div',
1506
+ {
1507
+ className: 'sn-panel',
1508
+ id: 'sn-panel-' + activeTab,
1509
+ role: 'tabpanel',
1510
+ 'aria-labelledby': 'sn-tab-' + activeTab,
1511
+ },
1512
+ h('div', { className: 'sn-panel-head' }, h('div', { className: 'sn-hint' }, panelHints[activeTab])),
1513
+ panels[activeTab],
1514
+ ),
1515
+ )
1516
+ }
1517
+
1518
+ var CSS = [
1519
+ '.sn-transfer{display:flex;flex-direction:column;gap:8px}',
1520
+ '.sn-transfer-row{display:flex;gap:8px;flex-wrap:wrap}',
1521
+ '.sn-transfer-pick{cursor:pointer}',
1522
+ '.sn-transfer-report{margin-top:4px;font-size:12px;line-height:1.7;color:var(--dsh-text-secondary)}',
1523
+ '.sn-transfer-warn{color:#b45309}',
1524
+ '.sn-root{display:flex;flex-direction:column;gap:14px;font-size:13px}',
1525
+ '.sn-head{display:flex;flex-direction:column;gap:4px}',
1526
+ '.sn-title{font-size:15px;font-weight:600;color:var(--dsw-alias-text-primary,#111)}',
1527
+ '.sn-sub{font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280);line-height:1.5}',
1528
+ '.sn-intro{font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280);border:1px dashed var(--dsw-alias-border-subtle,#e5e7eb);border-radius:10px;padding:8px 12px}',
1529
+ '.sn-intro summary{cursor:pointer;user-select:none;color:var(--dsw-alias-text-primary,#374151)}',
1530
+ '.sn-intro-list{margin:8px 0 0;padding-left:18px;display:flex;flex-direction:column;gap:4px;line-height:1.6}',
1531
+ '.sn-intro-list b{color:var(--dsw-alias-text-primary,#374151);font-weight:600}',
1532
+ '.sn-intro-note{list-style:none;margin-left:-18px;padding-top:4px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1533
+ '.sn-statusbar{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280)}',
1534
+ '.sn-dot{width:8px;height:8px;border-radius:50%;background:#c7c9cc;flex:none}',
1535
+ '.sn-dot-on{background:#22c55e}',
1536
+ '.sn-grow{flex:1}',
1537
+ '.sn-muted{color:var(--dsw-alias-text-secondary,#9ca3af);font-size:12px}',
1538
+ '.sn-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}',
1539
+ '.sn-stat{border:1px solid var(--dsw-alias-border-subtle,#e5e7eb);border-radius:10px;padding:10px 12px;background:var(--dsw-alias-bg-secondary,#fafafa)}',
1540
+ '.sn-stat-num{font-size:20px;font-weight:600;line-height:1.2}',
1541
+ '.sn-stat-label{font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280);margin-top:2px}',
1542
+ '.sn-card{border:1px solid var(--dsw-alias-border-subtle,#e5e7eb);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}',
1543
+ '.sn-card-title{font-size:12px;font-weight:600;color:var(--dsw-alias-text-secondary,#374151)}',
1544
+ '.sn-row{display:flex;align-items:center;gap:10px}',
1545
+ '.sn-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}',
1546
+ '.sn-toggle input{accent-color:var(--dsw-alias-accent-default,#2563eb)}',
1547
+ '.sn-limits{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}',
1548
+ '.sn-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280)}',
1549
+ '.sn-field input{width:110px;padding:6px 8px;border:1px solid var(--dsw-alias-border-subtle,#d1d5db);border-radius:8px;background:var(--dsw-alias-bg-primary,#fff);color:inherit}',
1550
+ '.sn-field-wide{flex:1 1 100%}',
1551
+ '.sn-field textarea{width:100%;padding:7px 9px;border:1px solid var(--dsw-alias-border-subtle,#d1d5db);border-radius:8px;background:var(--dsw-alias-bg-primary,#fff);color:inherit;resize:vertical;font:inherit}',
1552
+ '.sn-input{width:100%;padding:8px 10px;border:1px solid var(--dsw-alias-border-subtle,#d1d5db);border-radius:8px;background:var(--dsw-alias-bg-primary,#fff);color:inherit;resize:vertical;font:inherit}',
1553
+ '.sn-input:focus,.sn-field input:focus{outline:none;box-shadow:0 0 0 3px rgba(37,99,235,.15)}',
1554
+ '.sn-actions{display:flex;align-items:center;gap:8px}',
1555
+ '.sn-btn{border:1px solid var(--dsw-alias-border-subtle,#d1d5db);background:var(--dsw-alias-bg-primary,#fff);color:inherit;border-radius:8px;padding:6px 12px;font:inherit;cursor:pointer}',
1556
+ '.sn-btn:hover:not(:disabled){background:var(--dsw-alias-bg-secondary,#f3f4f6)}',
1557
+ '.sn-btn:disabled{opacity:.55;cursor:default}',
1558
+ '.sn-btn-ghost{border-color:transparent;background:transparent;color:var(--dsw-alias-text-secondary,#6b7280)}',
1559
+ '.sn-btn-danger{color:#b91c1c;border-color:#fecaca}',
1560
+ '.sn-hint{font-size:11px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1561
+ '.sn-notice{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.5;background:var(--dsw-alias-bg-secondary,#f3f4f6)}',
1562
+ '.sn-notice-mark{font-weight:700}',
1563
+ '.sn-notice-ok{background:#ecfdf5;color:#065f46}',
1564
+ '.sn-notice-warn{background:#fef2f2;color:#991b1b}',
1565
+ '.sn-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px;max-height:260px;overflow:auto}',
1566
+ '.sn-item{display:flex;gap:10px;line-height:1.5}',
1567
+ '.sn-date{color:var(--dsw-alias-text-secondary,#9ca3af);font-size:11px;flex:none;padding-top:1px;font-variant-numeric:tabular-nums}',
1568
+ '.sn-text{flex:1}',
1569
+ '.sn-item{align-items:flex-start}',
1570
+ '.sn-item-editing{background:var(--dsw-alias-bg-secondary,#f8fafc);border-radius:8px;padding:6px 8px}',
1571
+ '.sn-edit{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}',
1572
+ '.sn-row-actions{display:flex;gap:6px;margin-left:auto;flex:none;opacity:.55;transition:opacity .12s ease}',
1573
+ '.sn-item:hover .sn-row-actions,.sn-item:focus-within .sn-row-actions{opacity:1}',
1574
+ '.sn-fact-edit{display:flex;flex-direction:column;gap:6px}',
1575
+ '.sn-empty{font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1576
+ '.sn-sources{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}',
1577
+ '.sn-source{display:flex;align-items:center;gap:4px;font-size:11px}',
1578
+ '.sn-chip{background:var(--dsw-alias-bg-secondary,#f3f4f6);border-radius:999px;padding:2px 8px}',
1579
+ '.sn-hint-warn{font-size:11px;color:#b45309}',
1580
+ '.sn-sub{font-size:12px;font-weight:600;color:var(--dsw-alias-text-secondary,#374151);margin-top:4px}',
1581
+ '.sn-heat{font-size:11px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1582
+ '.sn-fact{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border:1px solid var(--dsw-alias-border-subtle,#eef0f2);border-radius:8px}',
1583
+ '.sn-fact-text{line-height:1.5}',
1584
+ '.sn-fact-cite{font-size:11px;color:var(--dsw-alias-text-secondary,#9ca3af);font-family:ui-monospace,Menlo,Consolas,monospace}',
1585
+ '.sn-details{font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280)}',
1586
+ '.sn-details summary{cursor:pointer;user-select:none}',
1587
+ '.sn-mono{font-family:ui-monospace,Menlo,Consolas,monospace;word-break:break-all;padding-top:4px}',
1588
+ '.sn-loading{padding:16px;font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1589
+ ".sn-fact-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
1590
+ ".sn-badge{font-size:11px;background:#eef2ff;color:#3730a3;border-radius:999px;padding:1px 8px}",
1591
+ ".sn-fact-conflict{border-color:#fcd34d;background:#fffdf5}",
1592
+ ".sn-pair-head{font-size:12px;line-height:1.5;color:#92400e}",
1593
+ ".sn-pair{display:grid;grid-template-columns:1fr 1fr;gap:10px}",
1594
+ ".sn-pair-side{border:1px dashed var(--dsw-alias-border-subtle,#d1d5db);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:6px}",
1595
+ ".sn-pair-tag{font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280)}",
1596
+ "@media (max-width:560px){.sn-pair{grid-template-columns:1fr}}",
1597
+ ".sn-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dsw-alias-border-subtle,#e5e7eb);flex-wrap:wrap}",
1598
+ ".sn-tab{position:relative;border:none;background:transparent;color:var(--dsw-alias-text-secondary,#6b7280);font:inherit;padding:8px 12px;cursor:pointer;border-radius:8px 8px 0 0;display:inline-flex;align-items:center;gap:6px}",
1599
+ ".sn-tab:hover:not(.sn-tab-on){background:var(--dsw-alias-bg-secondary,#f3f4f6)}",
1600
+ ".sn-tab:focus-visible{outline:2px solid var(--dsw-alias-accent-default,#2563eb);outline-offset:-2px}",
1601
+ ".sn-tab-on{color:var(--dsw-alias-text-primary,#111);font-weight:600}",
1602
+ ".sn-tab-on::after{content:\"\";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;background:var(--dsw-alias-accent-default,#2563eb);border-radius:2px}",
1603
+ ".sn-tab-badge{font-size:10px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--dsw-alias-bg-secondary,#f3f4f6);color:var(--dsw-alias-text-secondary,#6b7280);font-weight:400}",
1604
+ ".sn-tab-on .sn-tab-badge{background:#eef2ff;color:#3730a3}",
1605
+ ".sn-panel{display:flex;flex-direction:column;gap:12px}",
1606
+ ".sn-panel-head{display:flex;justify-content:space-between;align-items:center}",
1607
+ ".sn-budget{display:flex;flex-direction:column;gap:6px}",
1608
+ ".sn-budget-bar{display:flex;height:6px;border-radius:999px;overflow:hidden;background:var(--dsw-alias-bg-secondary,#f3f4f6)}",
1609
+ ".sn-budget-seg{height:100%;transition:width .2s ease}",
1610
+ ".sn-budget-legend{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280)}",
1611
+ ".sn-legend-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;vertical-align:middle}",
1612
+ "@media (max-width:560px){.sn-tabs{overflow-x:auto;flex-wrap:nowrap}.sn-tab{padding:8px 10px}}",
1613
+ '.sn-card-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}',
1614
+ '.sn-card-note{font-size:11px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1615
+ '.sn-files{display:flex;flex-direction:column;gap:2px}',
1616
+ '.sn-file{display:flex;align-items:center;gap:10px;padding:8px 6px;border-radius:8px;border-bottom:1px solid var(--dsw-alias-border-subtle,#f1f5f9)}',
1617
+ '.sn-file:last-child{border-bottom:none}',
1618
+ '.sn-file:hover{background:var(--dsw-alias-bg-secondary,#fafafa)}',
1619
+ '.sn-file-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
1620
+ '.sn-file-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
1621
+ '.sn-file-label{font-size:12px;font-weight:600;color:var(--dsw-alias-text-primary,#111827)}',
1622
+ '.sn-file-count{font-size:11px;color:#3730a3;background:#eef2ff;border-radius:999px;padding:0 7px;line-height:17px}',
1623
+ '.sn-file-path{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}',
1624
+ '.sn-file-path:hover{color:var(--dsw-alias-accent-default,#2563eb);text-decoration:underline}',
1625
+ '.sn-file-meta{font-size:11px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1626
+ '.sn-file-missing .sn-file-path{color:#b45309}',
1627
+ '.sn-file-missing .sn-file-count{background:#fef3c7;color:#92400e}',
1628
+ '.sn-file-btn{flex:none}',
1629
+ '.sn-root{gap:16px}',
1630
+ '.sn-card{border-radius:12px;padding:14px;box-shadow:0 1px 2px rgba(15,23,42,.05);gap:12px}',
1631
+ '.sn-card-title{letter-spacing:.01em}',
1632
+ '.sn-panel{gap:14px}',
1633
+ '.sn-stat-num{font-weight:650;letter-spacing:-.01em}',
1634
+ '.sn-btn{line-height:1.6;border-radius:8px;transition:background .15s ease,border-color .15s ease,transform .1s ease}',
1635
+ '.sn-btn:active:not(:disabled){transform:translateY(1px)}',
1636
+ '.sn-btn-ghost:hover:not(:disabled){background:var(--dsw-alias-bg-secondary,#f3f4f6)}',
1637
+ '@media (max-width:560px){.sn-file{align-items:flex-start;flex-wrap:wrap}.sn-file-btn{width:100%}}',
1638
+ '.sn-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:2px 16px}',
1639
+ '.sn-kv-row{display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px dashed var(--dsw-alias-border-subtle,#f1f5f9)}',
1640
+ '.sn-kv-key{font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280);white-space:nowrap}',
1641
+ '.sn-kv-val{font-size:12px;font-weight:600;color:var(--dsw-alias-text-primary,#111827);font-variant-numeric:tabular-nums}',
1642
+ '.sn-kv-tag{font-size:11px;font-weight:600;border-radius:999px;padding:1px 8px}',
1643
+ '.sn-kv-tag-ok{background:#ecfdf5;color:#047857}',
1644
+ '.sn-kv-tag-stale{background:#fef3c7;color:#92400e}',
1645
+ '.sn-kv-tag-missing{background:var(--dsw-alias-bg-secondary,#f3f4f6);color:var(--dsw-alias-text-secondary,#6b7280)}',
1646
+ '.sn-pre{white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.75;background:var(--dsw-alias-bg-secondary,#f8fafc);border:1px solid var(--dsw-alias-border-subtle,#e5e7eb);border-radius:8px;padding:10px 12px;margin:0;color:var(--dsw-alias-text-primary,#111827)}',
1647
+ ".sn-attr{display:flex;flex-direction:column;gap:6px}",
1648
+ ".sn-attr-row{display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.5}",
1649
+ ".sn-attr-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-text-primary,#111827)}",
1650
+ ".sn-attr-num{color:var(--dsw-alias-text-secondary,#6b7280);font-variant-numeric:tabular-nums;white-space:nowrap}",
1651
+ ".sn-attr-when{min-width:72px;text-align:right;color:var(--dsw-alias-text-secondary,#6b7280);font-variant-numeric:tabular-nums;white-space:nowrap}",
1652
+ ".sn-attr-stale .sn-attr-when{color:#b45309}",
1653
+ ".sn-attr-searched .sn-attr-text{color:var(--dsw-alias-text-secondary,#6b7280)}",
1654
+ ".sn-doc-next{font-size:12px;line-height:1.6;background:#fff7ed;border:1px solid #fed7aa;color:#92400e;border-radius:8px;padding:6px 8px}",
1655
+ ".sn-attr-never .sn-attr-text{color:var(--dsw-alias-text-secondary,#9ca3af)}",
1656
+ '.sn-seg{display:flex;flex-direction:column;gap:4px}',
1657
+ '.sn-seg-row{display:flex;align-items:center;gap:10px;font-size:12px;padding:3px 0}',
1658
+ '.sn-seg-key{width:42px;color:var(--dsw-alias-text-secondary,#6b7280)}',
1659
+ '.sn-seg-num{font-variant-numeric:tabular-nums;color:var(--dsw-alias-text-primary,#111827);font-weight:600}',
1660
+ '.sn-seg-state{margin-left:auto;font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280)}',
1661
+ '.sn-seg-state-warn{color:#b45309}',
1662
+ '.sn-skipped{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:1px dashed var(--dsw-alias-border-subtle,#e5e7eb);border-radius:8px}',
1663
+ '.sn-skipped-head{font-size:11px;color:#92400e;line-height:1.6}',
1664
+ '.sn-skipped-row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6}',
1665
+ '.sn-skipped-text{color:var(--dsw-alias-text-secondary,#4b5563)}',
1666
+ '.sn-doc-list{display:flex;flex-direction:column;gap:8px}',
1667
+ '.sn-doc-row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6;flex-wrap:wrap}',
1668
+ '.sn-doc-tag{font-size:11px;font-weight:600;border-radius:999px;padding:1px 8px;white-space:nowrap}',
1669
+ '.sn-doc-ok{background:#ecfdf5;color:#047857}',
1670
+ '.sn-doc-warn{background:#fffbeb;color:#b45309}',
1671
+ '.sn-doc-error{background:#fef2f2;color:#b91c1c}',
1672
+ '.sn-doc-label{font-weight:600;color:var(--dsw-alias-text-primary,#111827)}',
1673
+ '.sn-doc-detail{flex:1;min-width:200px;color:var(--dsw-alias-text-secondary,#4b5563)}',
1674
+ ].join('\n')
1675
+
1676
+ function apply(ctx) {
1677
+ ctx.effect(function () {
1678
+ var style = document.createElement('style')
1679
+ style.id = 'dsh-selfnote-styles'
1680
+ style.textContent = CSS
1681
+ document.head.appendChild(style)
1682
+ return function () {
1683
+ style.remove()
1684
+ }
1685
+ }, 'dsh-selfnote: styles')
1686
+
1687
+ ctx.slots.inject('settings.section', function () {
1688
+ // register 的返回值(disposer)必须交还给注入器 —— 丢了它热重载时会
1689
+ // 重复注册旧插槽,宿主直接把整个 client 模块判为失联。
1690
+ return ctx.slots.register(
1691
+ {
1692
+ name: 'settings.section',
1693
+ id: 'selfnote',
1694
+ order: 48,
1695
+ label: function () {
1696
+ return '人格(自述)'
1697
+ },
1698
+ },
1699
+ function (props) {
1700
+ return h(SelfNoteSection, props !== null && props !== undefined ? props : {})
1701
+ },
1702
+ )
1703
+ })
1704
+ }
1705
+
1706
+ module.exports = {
1707
+ name: 'selfnote',
1708
+ inject: ['slots'],
1709
+ apply: apply,
1710
+ }
1711
+ return module.exports
1712
+ },
1713
+ })