dsh-liuhen 0.0.1 → 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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 xiaoman000
3
+ Copyright (c) 2026 dsh-liuhen contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,25 +1,128 @@
1
1
  # 留痕 · dsh-liuhen
2
2
 
3
- **可核对的 AI 记忆。** 它记得你说过什么,而且你能核对它记对了没有。
3
+ **能核对的 AI 记忆。** 它记得你说过什么,而且你能核对它记对了没有。
4
4
 
5
- > ⚠️ **当前是占位版本 0.0.1。** 只锁包名,功能尚未实现。
6
- > 现在安装它:能装、能卸、不写任何文件、不发任何请求(空实现)。
7
- > 正式版开发中,请先不要依赖它。
5
+ - 索引短(每条 200 字)用来翻,仓库存完整原文用来查
6
+ - 每条记忆都能凭 `session:seq` 回到原话,不是"大概记住了"
7
+ - 自动注入**默认关闭**;要开也得先看预览
8
+ - 不删:保留期到点只把索引归档,字节一个不少
9
+ - 不联网:插件自身不发起任何网络请求
8
10
 
9
- ## 它最终会做什么
11
+ ## 面板八块
10
12
 
11
- - **记得**:每轮对话自动记账,跨会话不丢
12
- - **能核**:每条记录都能跳回原文,不信可以自己查
13
- - **能搬**:一键导出纯文本,换设备、换模型都在
14
- - **不联网**:零网络请求,数据只在你机器上
13
+ 1. **我是谁** — 你交代过的、希望它一直记得的事
14
+ 2. **日记** — 公开版 / 私密版,可选,默认关
15
+ 3. **今天的账本** — 只记今天,从新到旧;助手的话下面可以挂批注
16
+ 4. **自省** — 由模型手写,没有自动生成、没有定时任务
17
+ 5. **承诺清单** — 本机一份清单
18
+ 6. **仓库(完整原文)** — 按天点开,能翻到原话
19
+ 7. **索引保留** — 到期只归档,不删除;没有「遗忘」功能
20
+ 8. **备份包** — 一键打出可搬家的 zip(放最底下)
15
21
 
16
- ## 现在能做的
22
+ ## 安装
23
+
24
+ ### 一句话安装(推荐)
25
+
26
+ 把压缩包放在电脑上(比如 `~/Downloads`),然后对 DSH 说一句:
27
+
28
+ > 帮我装一下 ~/Downloads/dsh-liuhen-0.1.0.zip 这个插件
29
+
30
+ 助手要做的就三步(自己敲也一样):
31
+
32
+ ```bash
33
+ mkdir -p ~/liuhen-install && cd ~/liuhen-install
34
+ unzip -q ~/Downloads/dsh-liuhen-0.1.0.zip # 解出 dsh-liuhen/ 目录
35
+ cd dsh-liuhen && node install.js # 拷进 profile + 在 bundles 里登记
36
+ # 然后重启 DSH
37
+ ```
38
+
39
+ 助手如果不知道包在哪:先在 `~/Downloads`、`~/Desktop`、下载目录里找 `dsh-liuhen*.zip` 或 `*.tgz`。
40
+
41
+ `install.js` 会:把自己拷进 `~/.dsh/profiles/web/node_modules/dsh-liuhen`(已有就自动备份)、
42
+ 在 profile 的 `bundles` 里登记一行、告诉你重启。它不联网,也不碰你的数据。
43
+
44
+ ### 从 npm 装
17
45
 
18
46
  ```bash
19
47
  dsh plugin --profile web add dsh-liuhen
20
- dsh plugin --profile web remove dsh-liuhen
21
48
  ```
22
49
 
50
+ 装好后在设置页会多一块「留痕」。数据全部在 `$DSH_HOME/liuhen/` 与 `$DSH_HOME/vault/`
51
+ (文件 600、目录 700)。
52
+
53
+ **本地安装(还没发布到 npm 时)**:
54
+
55
+ ```bash
56
+ # 1) 解压后把目录放到 profile 的 node_modules 里
57
+ cp -r dsh-liuhen "$DSH_HOME/profiles/web/node_modules/dsh-liuhen"
58
+ # 2) 在 "$DSH_HOME/profiles/web/package.json" 的 dsh.profile.bundles 里加一行 "dsh-liuhen"
59
+ # 3) 重启 DSH,设置页就会出现「留痕」
60
+ ```
61
+
62
+ **环境要求**:Node ≥ 22.15(读会话文件要用 `node:zlib` 的 zstd);备份包与「看一眼包里有什么」
63
+ 用到系统的 `zip` / `unzip` 命令(Linux、macOS 自带;Windows 需要自己装,缺了只会打包失败,不影响其它功能)。
64
+
65
+ ## 它到底存什么、发什么
66
+
67
+ | 项目 | 存在哪 | 会不会外发 |
68
+ |---|---|---|
69
+ | 索引(200 字/条) | `liuhen/index.jsonl` | 开了注入才会,且只发摘要 |
70
+ | 完整原文 | `vault/<日期>.jsonl` | 不会(除非你自己打包带走) |
71
+ | 日记 / 自省 / 承诺 | `liuhen/diary`、`notes`、`promises.md` | 开了注入只发日记有没有写 |
72
+ | 设置 | `liuhen/config.json` | 不会 |
73
+ | 批注(感受/犹豫/权衡/没说出口) | `vault/` 里同一行旁边 | 不会 |
74
+
75
+ 自动注入分两档:**简洁档**默认(身份 + 关于你 + 日记状态 + 最近 12 条),**完整档**按天全量。
76
+ 两档都可以预览;`maxChars` / `maxLines` 是兜底闸,写 0 表示不限。
77
+
78
+ ## 三个防呆设计
79
+
80
+ - **注入剥引用**:助手旧回复里的代码块、`>` 引用行、长引号、以及「原话是这么说的:…」这类带引导语的抄写,都不进注入,只留它自己的话;用户说的话原样不动。
81
+ - **写接口只收本机面板**:POST 必须是 `application/json` 且同源,请求体上限 1MB。
82
+ - **跨进程锁 + 写前核对**:多个实例同时回填不会写重复行;仓库读取时也会按 `session:seq` 去重。
83
+
84
+ ## 隐私边界(说清楚,不糊弄)
85
+
86
+ - **脱敏挡不住自然语言里的隐私**:住址、关系、习惯这类写在句子里的话,规则认不出来。
87
+ - 打包脱敏只改**包里的副本**:姓名保留,生日、学校、籍贯/住址换成 `[已隐去]`;磁盘上的原件一个字不动。
88
+ - 备份包是**明文**。放到共享目录或发给别人之前先想清楚。
89
+ - 要额外屏蔽的词,写进设置里的 `privacy.denyWords`:命中的行在注入时整行跳过,打包时也会替换。
90
+
91
+ ## 验证
92
+
93
+ ```bash
94
+ # 自带自检:只在自己造的沙箱里跑,不读、不拷任何真实数据
95
+ node node_modules/dsh-liuhen/scripts/selfcheck.js
96
+ ```
97
+
98
+ 自检覆盖:模块加载、工具注册、全部 HTTP 端点、配置读写、日记/自省/承诺、索引与仓库、
99
+ 注入分档与剥引用、脱敏、打包、锁、目录权限、坏数据容错。跑完自动清理沙箱。
100
+
101
+ 仓库文件里的重复行可以这样清(先预演,加 `--write` 才真写):
102
+
103
+ ```bash
104
+ node node_modules/dsh-liuhen/scripts/dedupe-vault.js
105
+ node node_modules/dsh-liuhen/scripts/dedupe-vault.js --write
106
+ ```
107
+
108
+ ## 卸载与删除数据
109
+
110
+ 把插件从 profile 里去掉就卸载了;它不会自己删数据。要彻底清掉:
111
+
112
+ ```bash
113
+ rm -rf "$DSH_HOME/liuhen" "$DSH_HOME/vault"
114
+ ```
115
+
116
+ ## 已知的依赖与边界
117
+
118
+ - 读会话依赖 DSH 的会话文件格式(`sessions/<bucket>/<id>/session.v4.jsonl.zstd`);DSH 换格式就要改 `lib/store.js`。
119
+ - 备份包的脱敏是**规则式**的:生日、学校:`…`、籍贯:`…` 这类写法能隐,写成普通句子的隐私认不出来。
120
+ - 注入的剥引用是**启发式**:带引号、代码块、`原话是…:` 这类引导语能剥;把别人的话当自己的话讲,剥不出来。
121
+ - `scripts/dedupe-vault.js --write` 会重写仓库文件;请在 DSH 停着的时候跑,避免和回填撞上。
122
+ - **磁盘占用会一直长**:索引按保留期归档(只换地方不删),但仓库 `vault/` 是按设计不删的。
123
+ 一天几十到几百 KB,一年几十到几百 MB;这是"留痕不留删"的代价,先知道再装。
124
+ - 自检也要用到 `zip` / `unzip` 才能跑完打包那几条。
125
+
23
126
  ## 许可
24
127
 
25
128
  MIT
package/client.js ADDED
@@ -0,0 +1,493 @@
1
+ /**
2
+ * 留痕的设置面板。八块,从上到下的顺序:
3
+ * 我是谁 / 日记 / 今天的账本 / 自省 / 承诺清单
4
+ * / 仓库 / 索引保留 / 备份包(搬家才用,所以放最底下)
5
+ *
6
+ * 没有「遗忘」:哪怕是想忘掉的记忆,也是痕迹,不该被删。
7
+ * 索引保留只做归档(换个地方躺着),不做删除。
8
+ */
9
+ (function () {
10
+ const PREFIX = '/.dsh-liuhen'
11
+
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-liuhen',
14
+ factory: (require) => {
15
+ const React = require('react')
16
+ const h = React.createElement
17
+ const name = 'dsh-liuhen'
18
+ const inject = ['slots']
19
+
20
+ const S = {
21
+ wrap: { display: 'flex', flexDirection: 'column', gap: '12px', padding: '4px 2px 24px' },
22
+ card: { border: '1px solid var(--dsh-border, rgba(127,127,127,.25))', borderRadius: '10px', padding: '12px 14px' },
23
+ title: { fontSize: '13px', opacity: 0.65, marginBottom: '10px' },
24
+ row: { display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap', fontSize: '13px' },
25
+ muted: { fontSize: '12px', opacity: 0.62, lineHeight: '1.7' },
26
+ pre: { margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12.5px', lineHeight: '1.7', fontFamily: 'inherit' },
27
+ input: { width: '100%', boxSizing: 'border-box', fontSize: '13px', padding: '6px 8px', borderRadius: '7px', border: '1px solid var(--dsh-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', fontFamily: 'inherit' },
28
+ area: { width: '100%', boxSizing: 'border-box', minHeight: '64px', fontSize: '12.5px', padding: '6px 8px', borderRadius: '7px', border: '1px solid var(--dsh-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', fontFamily: 'inherit', resize: 'vertical' },
29
+ btn: { fontSize: '12.5px', padding: '5px 12px', borderRadius: '7px', border: '1px solid var(--dsh-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', cursor: 'pointer' },
30
+ entry: { fontSize: '12.5px', lineHeight: '1.65', padding: '6px 8px', borderRadius: '7px', background: 'rgba(127,127,127,.07)' },
31
+ scroll: { display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '340px', overflowY: 'auto' },
32
+ err: { fontSize: '12.5px', color: '#d9534f' },
33
+ ok: { fontSize: '12px', color: '#3c9e6e' },
34
+ who: { fontSize: '12px', opacity: 0.55, marginRight: '6px' }
35
+ }
36
+
37
+ function Card(title, ...children) {
38
+ return h('div', { style: S.card }, h('div', { style: S.title }, title), ...children)
39
+ }
40
+
41
+ function Line(props) {
42
+ return h('div', { style: S.entry },
43
+ h('span', { style: S.who }, `${props.t} ${props.r === 'u' ? '你' : '助手'}:`),
44
+ h('span', { style: { whiteSpace: 'pre-wrap' } }, props.text))
45
+ }
46
+
47
+ /* 渠道:消息尾巴上会带 [渠道:微信] 这样的标记,抠出来做个小牌子。 */
48
+ const CHANNEL_RE = /\[渠道:([^\]]+)\]/
49
+
50
+ function channelOf(text) {
51
+ const m = CHANNEL_RE.exec(String(text || ''))
52
+ return m ? m[1].trim() : ''
53
+ }
54
+
55
+ /*
56
+ * 展示时把渠道标记和它后面那串框架说明整段切掉,只留人话。
57
+ * 「[渠道:微信] 你在和微信上的用户对话,对方在手机上……」这种注脚只留一个「微信」徽章。
58
+ * 仓库里存的仍是原文,这里只管显示。
59
+ */
60
+ function cleanText(text) {
61
+ return String(text || '')
62
+ .replace(/\s*\[渠道:[^\]]+\][\s\S]*$/, '')
63
+ .replace(/\s+$/, '')
64
+ }
65
+
66
+ /* 一问一答拼成块:遇到用户的话开新块,后面助手的话贴进同一块。 */
67
+ /*
68
+ * 一问一答成块。
69
+ *
70
+ * 关键:先把同一个会话的挑到一起,再按序号排。
71
+ * 以前只按时间排,两个会话同时开着就会交叉插队——看起来像"我问了 A,他答了 B"。
72
+ * 跨会话按各自第一条的时间排,切换处打一条细线。
73
+ */
74
+ /**
75
+ * 先把会话切成一块块「问 + 答」,顺序由调用方定:
76
+ * 账本要新在上,所以从上往下从新到旧;跨会话时保留「起」的标头,
77
+ * 标头永远跟着那个会话的第一块走(倒过来也一样)。
78
+ */
79
+ function buildSessions(rows) {
80
+ const groups = new Map()
81
+ for (const m of rows) {
82
+ const key = m.s || 'unknown'
83
+ if (!groups.has(key)) groups.set(key, [])
84
+ groups.get(key).push(m)
85
+ }
86
+ return [...groups.entries()].map(([id, list]) => {
87
+ const sorted = list.slice().sort((a, b) => {
88
+ const an = Number(a.n) || 0
89
+ const bn = Number(b.n) || 0
90
+ if (an !== bn) return an - bn
91
+ return String(a.t) < String(b.t) ? -1 : 1
92
+ })
93
+ const blocks = []
94
+ for (const m of sorted) {
95
+ const text = cleanText(m.text)
96
+ const marks = marksOfMessage(m)
97
+ const last = blocks[blocks.length - 1]
98
+ if (m.r === 'a' && last && last._open === true) {
99
+ last.answer = text
100
+ last.marks = marks
101
+ last._open = false
102
+ continue
103
+ }
104
+ if (m.r === 'u') blocks.push({ t: m.t, channel: channelOf(m.text), ask: text, answer: null, marks: [], _open: true })
105
+ else blocks.push({ t: m.t, channel: '', ask: null, answer: text, marks, _open: false })
106
+ }
107
+ return { id, blocks, first: sorted[0] }
108
+ }).sort((a, b) => (a.first.t < b.first.t ? -1 : 1))
109
+ }
110
+
111
+ function pairRows(rows, options) {
112
+ const desc = Boolean(options && options.desc)
113
+ const sessions = buildSessions(rows)
114
+ if (desc) sessions.reverse()
115
+ const multi = sessions.length > 1
116
+ const out = []
117
+ for (const sess of sessions) {
118
+ const blocks = desc ? sess.blocks.slice().reverse() : sess.blocks
119
+ blocks.forEach((block, index) => {
120
+ /* 标头用会话开始的那一刻,不是这一块的时刻——倒序时这块可能不是最早的那块 */
121
+ if (multi && index === 0) out.push({ sessionStart: (sess.first && sess.first.t) || block.t, sessionId: sess.id, ...block })
122
+ else out.push(block)
123
+ })
124
+ }
125
+ return out
126
+ }
127
+
128
+ /*
129
+ * 批注:挂在「我说过的那句话」上的痕迹,一条一条对着那条话。
130
+ * 四种颜色的牌子,读一遍就知道这句当时卡在哪、为什么那么说、咽下了什么。
131
+ */
132
+ const MARK_STYLE = {
133
+ feel: { label: '感受', color: '#3c9e6e' },
134
+ hes: { label: '犹豫', color: '#c98b3c' },
135
+ why: { label: '当时的权衡', color: '#5b8dd6' },
136
+ unsaid: { label: '没说出口', color: '#9a6bbf' }
137
+ }
138
+
139
+ function marksOfMessage(m) {
140
+ if (m && Array.isArray(m.marks)) return m.marks
141
+ if (m && m.f) return [{ k: 'feel', x: String(m.f), t: '' }]
142
+ return []
143
+ }
144
+
145
+ function MarkList(props) {
146
+ const marks = Array.isArray(props.marks) ? props.marks : []
147
+ if (marks.length === 0) return null
148
+ return h('div', { style: { marginTop: '7px', display: 'flex', flexDirection: 'column', gap: '5px' } },
149
+ ...marks.map((mark, i) => {
150
+ const def = MARK_STYLE[mark.k] || { label: mark.k, color: '#888' }
151
+ return h('div', {
152
+ key: i,
153
+ style: {
154
+ display: 'flex', gap: '7px', alignItems: 'flex-start',
155
+ borderLeft: `2px solid ${def.color}`, paddingLeft: '7px',
156
+ fontSize: '12.5px', lineHeight: '1.65'
157
+ }
158
+ },
159
+ h('span', {
160
+ style: {
161
+ flex: '0 0 auto', color: def.color, fontSize: '11px',
162
+ padding: '0 5px', borderRadius: '5px', border: `1px solid ${def.color}`,
163
+ opacity: 0.9, whiteSpace: 'nowrap', marginTop: '1px'
164
+ }
165
+ }, def.label),
166
+ h('span', { style: { whiteSpace: 'pre-wrap', opacity: 0.9 } }, mark.x))
167
+ }))
168
+ }
169
+
170
+ const S2 = {
171
+ badge: { fontSize: '10.5px', padding: '1px 6px', borderRadius: '5px', border: '1px solid var(--dsh-border, rgba(127,127,127,.4))', opacity: 0.75, marginRight: '6px', whiteSpace: 'nowrap' },
172
+ answer: { marginTop: '6px', paddingTop: '6px', borderTop: '1px dashed rgba(127,127,127,.25)', whiteSpace: 'pre-wrap' }
173
+ }
174
+
175
+ function PairBlock(props) {
176
+ const line = props.sessionStart
177
+ ? h('div', { style: { fontSize: '11px', opacity: 0.45, margin: '2px 0 6px' } }, `⸺ ${props.sessionStart} 起的另一个会话 ⸺`)
178
+ : null
179
+ return h('div', { style: S.entry },
180
+ line,
181
+ props.ask !== null
182
+ ? h('div', null,
183
+ h('span', { style: S.who }, `${props.t} `),
184
+ props.channel !== '' ? h('span', { style: S2.badge }, props.channel) : null,
185
+ h('span', { style: { whiteSpace: 'pre-wrap' } }, props.ask))
186
+ : null,
187
+ props.answer !== null
188
+ ? h('div', { style: S2.answer },
189
+ h('span', { style: S.who }, '我:'),
190
+ h('span', null, props.answer),
191
+ h(MarkList, { marks: marksOfMessage({ marks: props.marks, f: props.feel }) }))
192
+ : (props.ask !== null ? h('div', { style: { ...S.muted, marginTop: '4px' } }, '(没回)') : null))
193
+ }
194
+
195
+ function Panel() {
196
+ const [state, setState] = React.useState({ loading: true, error: '', data: null })
197
+ const [nameInput, setNameInput] = React.useState('')
198
+ const [textInput, setTextInput] = React.useState('')
199
+ const [preview, setPreview] = React.useState({ text: '', busy: false })
200
+ const [msg, setMsg] = React.useState('')
201
+ const [detail, setDetail] = React.useState({ loading: false, text: '', at: '', role: '', error: '' })
202
+ const [identityPreview, setIdentityPreview] = React.useState({ text: '', busy: false, meta: null })
203
+ const [identityEditing, setIdentityEditing] = React.useState(false)
204
+ const [aboutInput, setAboutInput] = React.useState('')
205
+ const [boxList, setBoxList] = React.useState({ loading: false, entries: [], count: 0, error: '' })
206
+ const [boxSend, setBoxSend] = React.useState({ busy: false, msg: '', error: '' })
207
+ const [vaultTree, setVaultTree] = React.useState({ loading: false, days: [], stats: null, error: '' })
208
+ const [vaultView, setVaultView] = React.useState({ date: '', loading: false, rows: [], total: 0, more: false, error: '' })
209
+
210
+ const load = React.useCallback(() => {
211
+ fetch(`${PREFIX}/summary`, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
212
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
213
+ .then((data) => {
214
+ setState({ loading: false, error: '', data })
215
+ setNameInput((data.identity && data.identity.name) || '')
216
+ setAboutInput((data.identity && data.identity.aboutUser) || '')
217
+ setTextInput((data.identity && data.identity.text) || '')
218
+ })
219
+ .catch((e) => setState({ loading: false, error: String(e && e.message ? e.message : e), data: null }))
220
+ }, [])
221
+
222
+ React.useEffect(() => { load() }, [load])
223
+
224
+ const post = React.useCallback((path, body, done) => {
225
+ fetch(`${PREFIX}${path}`, {
226
+ method: 'POST',
227
+ headers: { 'content-type': 'application/json' },
228
+ credentials: 'same-origin',
229
+ body: JSON.stringify(body || {})
230
+ })
231
+ .then((r) => r.json())
232
+ .then((j) => { setMsg(done ? (j.ok === false ? (j.text || j.error || '失败') : done) : ''); load() })
233
+ .catch((e) => setMsg(String(e && e.message ? e.message : e)))
234
+ }, [load])
235
+
236
+ const showOriginal = (session, seq) => {
237
+ setDetail({ loading: true, text: '', at: '', role: '', error: '' })
238
+ fetch(`${PREFIX}/original?session=${encodeURIComponent(session)}&seq=${seq}`, { credentials: 'same-origin' })
239
+ .then((r) => r.json())
240
+ .then((j) => j.ok ? setDetail({ loading: false, text: j.text, at: j.at, role: j.role, error: '' })
241
+ : setDetail({ loading: false, text: '', at: '', role: '', error: j.error || '找不到原文' }))
242
+ .catch((e) => setDetail({ loading: false, text: '', at: '', role: '', error: String(e && e.message ? e.message : e) }))
243
+ }
244
+
245
+ if (state.loading && !state.data) return h('div', { style: S.muted }, '正在读…')
246
+ if (!state.data) {
247
+ return h('div', null,
248
+ h('div', { style: S.err }, `读不到:${state.error}`),
249
+ h('button', { type: 'button', style: { ...S.btn, marginTop: '10px' }, onClick: load }, '重试'))
250
+ }
251
+
252
+ const d = state.data
253
+ const today = d.ledger.today || []
254
+
255
+ return h('div', { style: S.wrap },
256
+
257
+ // 1. 我是谁
258
+ Card('我是谁',
259
+ d.identity.name ? h('div', { style: { ...S.row, marginBottom: '8px' } }, h('strong', null, d.identity.name)) : null,
260
+ identityEditing ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '8px' } },
261
+ h('textarea', { style: { ...S.area, minHeight: '96px' }, value: textInput, placeholder: '每行一条,以 - 开头。例如:\n- 我的名字\n- 别拿「这样才对」说教我', onChange: (e) => setTextInput(e.target.value) }),
262
+ h('div', { style: S.row },
263
+ h('button', { type: 'button', style: S.btn, onClick: () => { post('/config', { identity: { name: nameInput, text: textInput } }, '已保存'); setIdentityEditing(false) } }, '保存'),
264
+ h('button', { type: 'button', style: S.btn, onClick: () => setIdentityEditing(false) }, '取消')))
265
+ : (() => {
266
+ const bullets = String(d.identity.text || '').split(/^##\s/m)[0].split('\n')
267
+ .map((line) => line.trim())
268
+ .filter((line) => line.startsWith('- '))
269
+ .map((line) => line.slice(2).trim())
270
+ .filter(Boolean)
271
+ .slice(0, 10)
272
+ if (bullets.length === 0 && !identityEditing) return h('div', null,
273
+ h('div', { style: S.muted }, '还没写「我是谁」。每行一条,以 - 开头,会显示在这里。'),
274
+ h('button', { type: 'button', style: { ...S.btn, marginTop: '8px' }, onClick: () => setIdentityEditing(true) }, '写'))
275
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
276
+ bullets.map((b, i) => h('div', { key: i, style: { ...S.entry, padding: '4px 8px' } }, `· ${b}`)))
277
+ })(),
278
+ h('div', { style: { ...S.row, marginTop: '10px' } },
279
+ d.inject.enabled
280
+ ? h('button', { type: 'button', style: S.btn, onClick: () => post('/config', { inject: { enabled: false } }, '注入已关闭') }, '关闭注入')
281
+ : h('button', { type: 'button', style: { ...S.btn, borderColor: '#3c9e6e', color: '#3c9e6e' }, onClick: () => post('/config', { inject: { enabled: true } }, '注入已开启') }, '开启注入'),
282
+ h('button', { type: 'button', style: S.btn, disabled: identityPreview.busy, onClick: () => {
283
+ setIdentityPreview({ text: '', busy: true, meta: null })
284
+ fetch(`${PREFIX}/preview`, { credentials: 'same-origin' }).then((r) => r.json())
285
+ .then((j) => setIdentityPreview({ text: j.text || '(现在没有可注入的内容)', busy: false, meta: j }))
286
+ .catch((e) => setIdentityPreview({ text: String(e && e.message ? e.message : e), busy: false, meta: null }))
287
+ } }, identityPreview.busy ? '生成中…' : '预览注入内容'),
288
+ h('span', { style: S.muted }, d.inject.enabled ? '注入:开' : '注入:关'),
289
+ h('span', { style: { ...S.muted, marginLeft: '4px' } }, `档位:${d.inject.mode === 'full' ? '完整' : '简洁'}`),
290
+ h('button', { type: 'button', style: { ...S.btn, borderColor: d.inject.mode !== 'full' ? '#3c9e6e' : undefined }, onClick: () => post('/config', { inject: { mode: 'brief' } }, '档位:简洁') }, '简洁档'),
291
+ h('button', { type: 'button', style: { ...S.btn, borderColor: d.inject.mode === 'full' ? '#3c9e6e' : undefined }, onClick: () => post('/config', { inject: { mode: 'full' } }, '档位:完整(会挤上下文)') }, '完整档')),
292
+ /* 注入是完整的:给全文,盒子自己滚。 */
293
+ identityPreview.text !== '' ? h('pre', { style: { ...S.pre, background: 'rgba(127,127,127,.08)', padding: '8px', borderRadius: '8px', maxHeight: '45vh', overflowY: 'auto', marginTop: '8px' } }, identityPreview.text) : null,
294
+ identityPreview.text !== '' && identityPreview.meta && identityPreview.meta.chars !== undefined
295
+ ? h('div', { style: { ...S.muted, marginTop: '6px' } },
296
+ `${identityPreview.meta.chars} 字 · 摘要 ${identityPreview.meta.lines || 0} 行 · ` +
297
+ `上限:${identityPreview.meta.maxChars > 0 ? `${identityPreview.meta.maxChars} 字` : '不限'}` +
298
+ (identityPreview.meta.clipped ? '(已截断)' : '(不截断)'))
299
+ : null,
300
+ identityPreview.meta && identityPreview.meta.big === true
301
+ ? h('div', { style: { ...S.err, marginTop: '6px' } },
302
+ `${identityPreview.meta.chars} 字——超过 2 万字了。完整档按设计不裁字,它会随每次请求发出去:上下文会被挤占,费用也跟着涨。`)
303
+ : null,
304
+ h('div', { style: { ...S.muted, marginTop: '8px' } }, '注入会把这段随每次请求发给模型服务商;只发摘要,本地脱敏。'),
305
+ h('div', { style: { borderTop: '1px solid var(--dsh-border, rgba(127,127,127,.2))', marginTop: '12px', paddingTop: '10px' } },
306
+ h('div', { style: { ...S.muted, marginBottom: '6px' } }, '关于你(可选,可留空)· 建议写网名'),
307
+ h('textarea', {
308
+ style: { ...S.area, minHeight: '56px' },
309
+ value: aboutInput,
310
+ placeholder: '建议写网名,不写真名。不想说就留空。',
311
+ onChange: (e) => setAboutInput(e.target.value)
312
+ }),
313
+ h('div', { style: { ...S.row, marginTop: '8px' } },
314
+ h('button', { type: 'button', style: S.btn, onClick: () => post('/config', { identity: { aboutUser: aboutInput } }, aboutInput.trim() === '' ? '已清空' : '记下了') }, '保存'),
315
+ d.identity.ackPending ? h('span', { style: S.ok }, '只存本机,不会外发。') : null),
316
+ h('div', { style: { ...S.muted, marginTop: '6px' } }, '只存本机。默认不进注入;开启后只给这台机器的助手看。'))),
317
+
318
+ // 2. 我要写的日记
319
+ Card('我要写的日记',
320
+ h('label', { style: { ...S.row, fontSize: '12px', opacity: 0.8, cursor: 'pointer', marginBottom: '8px' } },
321
+ h('input', { type: 'checkbox', checked: d.diary.enabled, onChange: (e) => post('/config', { diary: { enabled: e.target.checked } }, e.target.checked ? '日记已开启' : '日记已关闭') }),
322
+ h('span', null, '启用日记模块(不写可以一直关着)')),
323
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
324
+ h('div', null,
325
+ h('div', { style: { ...S.muted, marginBottom: '4px' } }, '公开版:'),
326
+ d.diary.public === null
327
+ ? h('div', { style: S.muted }, '今天还没写。')
328
+ : h('pre', { style: { ...S.pre, maxHeight: '200px', overflowY: 'auto' } }, d.diary.public)),
329
+ h('div', null,
330
+ h('div', { style: { ...S.muted, marginBottom: '4px' } }, '私密版:'),
331
+ d.diary.self === null
332
+ ? h('div', { style: S.muted }, '今天还没写。')
333
+ : h('pre', { style: { ...S.pre, maxHeight: '200px', overflowY: 'auto' } }, d.diary.self)))),
334
+
335
+ // 3. 今天的账本(只今天:messagesFor(today),不跨天;感受挂在我说的话后面)
336
+ Card(`今天的账本 · ${d.date}(${today.length} 条${d.feels && d.feels.all ? ` · ${d.feels.all} 条批注` : ''})`,
337
+ today.length === 0
338
+ ? h('div', { style: S.muted }, '今天还没有记录。')
339
+ : h('div', { style: S.scroll }, pairRows(today, { desc: true }).map((p, i) => h(PairBlock, { key: i, t: p.t, channel: p.channel, ask: p.ask, answer: p.answer, marks: p.marks, feel: p.feel, sessionStart: p.sessionStart }))),
340
+ detail.loading ? h('div', { style: { ...S.muted, marginTop: '8px' } }, '正在取原文…') : null,
341
+ detail.error !== '' ? h('div', { style: { ...S.err, marginTop: '8px' } }, detail.error) : null,
342
+ detail.text !== ''
343
+ ? h('div', { style: { marginTop: '10px' } },
344
+ h('div', { style: S.muted }, `原文(${detail.at} · ${detail.role})`),
345
+ h('pre', { style: { ...S.pre, background: 'rgba(127,127,127,.08)', padding: '8px', borderRadius: '8px', maxHeight: '240px', overflowY: 'auto' } }, detail.text))
346
+ : null),
347
+
348
+ // 4. 我亲手写的自省(只今天:notes/<今天>.md,不跨天)
349
+ Card(`我亲手写的自省 · ${d.date}`,
350
+ d.notes ? h('pre', { style: S.pre }, d.notes) : h('div', { style: S.muted }, '今天还没写。有就写,没有就不写——空着不算欠账。')),
351
+
352
+ // 5. 承诺清单
353
+ Card('承诺清单',
354
+ d.promises ? h('pre', { style: S.pre }, d.promises) : h('div', { style: S.muted }, '(清单是空的)')),
355
+
356
+ // 6. 仓库:点得开、看得见
357
+ Card('仓库(完整原文)',
358
+ (() => {
359
+ const st = (vaultTree.stats || d.vault || {})
360
+ return h('div', { style: S.muted },
361
+ `${st.rows || 0} 条 / ${st.days || 0} 天 · ${st.bytes ? (st.bytes / 1024).toFixed(0) : 0} KB`,
362
+ st.oldest ? `(${st.oldest} → ${st.newest})` : '',
363
+ h('br'),
364
+ '索引里每条只有 200 字摘要;这里是原话全文。点开某一天就能看。')
365
+ })(),
366
+ h('div', { style: { ...S.row, marginTop: '10px' } },
367
+ h('button', { type: 'button', style: S.btn, disabled: vaultTree.loading, onClick: () => {
368
+ if (vaultTree.days.length > 0) { setVaultTree({ loading: false, days: [], stats: vaultTree.stats, error: '' }); return }
369
+ setVaultTree({ loading: true, days: [], stats: null, error: '' })
370
+ fetch(`${PREFIX}/vault/tree`, { credentials: 'same-origin' }).then((r) => r.json())
371
+ .then((j) => setVaultTree({ loading: false, days: j.days || [], stats: j.stats || null, error: j.ok === false ? (j.error || '读不出来') : '' }))
372
+ .catch((e) => setVaultTree({ loading: false, days: [], stats: null, error: String(e && e.message ? e.message : e) }))
373
+ } }, vaultTree.loading ? '读中…' : (vaultTree.days.length > 0 ? '收起' : '打开仓库'))),
374
+ vaultTree.error !== '' ? h('div', { style: { ...S.err, marginTop: '6px' } }, vaultTree.error) : null,
375
+ vaultTree.days.length > 0
376
+ ? h('div', { style: { ...S.row, marginTop: '8px' } },
377
+ ...vaultTree.days.map((day) => h('button', {
378
+ key: day.date,
379
+ type: 'button',
380
+ style: { ...S.btn, borderColor: vaultView.date === day.date ? '#3c9e6e' : undefined },
381
+ onClick: () => {
382
+ setVaultView({ date: day.date, loading: true, rows: [], total: 0, more: false, error: '' })
383
+ fetch(`${PREFIX}/vault/day?date=${day.date}&offset=0&limit=20`, { credentials: 'same-origin' }).then((r) => r.json())
384
+ .then((j) => setVaultView({ date: day.date, loading: false, rows: j.rows || [], total: j.total || 0, more: Boolean(j.more), error: j.ok === false ? (j.error || '读不出来') : '' }))
385
+ .catch((e) => setVaultView({ date: day.date, loading: false, rows: [], total: 0, more: false, error: String(e && e.message ? e.message : e) }))
386
+ }
387
+ }, `${day.date}(${day.rows}${day.marks ? ` · ${day.marks} 批注` : ''})`)))
388
+ : null,
389
+ vaultView.date !== '' ? h('div', { style: { ...S.row, marginTop: '10px' } },
390
+ h('span', { style: S.muted }, `${vaultView.date} · 共 ${vaultView.total} 条,这里 ${vaultView.rows.length} 条`),
391
+ vaultView.more ? h('button', { type: 'button', style: S.btn, disabled: vaultView.loading, onClick: () => {
392
+ const offset = vaultView.rows.length
393
+ setVaultView({ ...vaultView, loading: true })
394
+ fetch(`${PREFIX}/vault/day?date=${vaultView.date}&offset=${offset}&limit=20`, { credentials: 'same-origin' }).then((r) => r.json())
395
+ .then((j) => setVaultView({ date: vaultView.date, loading: false, rows: [...vaultView.rows, ...(j.rows || [])], total: j.total || 0, more: Boolean(j.more), error: '' }))
396
+ .catch((e) => setVaultView({ ...vaultView, loading: false, error: String(e && e.message ? e.message : e) }))
397
+ } }, '更早的') : null) : null,
398
+ vaultView.error !== '' ? h('div', { style: { ...S.err, marginTop: '6px' } }, vaultView.error) : null,
399
+ vaultView.rows.length > 0
400
+ ? h('div', { style: { ...S.scroll, marginTop: '8px' } },
401
+ /* 仓库那天也按时间从上到下、从新到旧(跟账本一个规矩) */
402
+ ...pairRows(vaultView.rows.map((row) => ({
403
+ d: vaultView.date, t: row.t, r: row.r, s: row.s, n: row.n, text: row.x, marks: row.marks, f: row.f
404
+ })), { desc: true }).map((p, i) => h(PairBlock, {
405
+ key: i, t: p.t, channel: p.channel, ask: p.ask, answer: p.answer, marks: p.marks, feel: p.feel, sessionStart: p.sessionStart
406
+ })))
407
+ : null),
408
+
409
+ // 7. 索引保留:只归档,不删除。留痕不留删
410
+ Card('索引保留',
411
+ (() => {
412
+ const r = d.retention || {}
413
+ const kb = r.archiveBytes ? `${(r.archiveBytes / 1024).toFixed(1)} KB` : '0 KB'
414
+ const vv = d.vault || {}
415
+ return h('div', { style: S.muted },
416
+ `索引:${r.rows || 0} 条 / ${r.days || 0} 天(${r.oldest || '—'} → ${r.newest || '—'})`,
417
+ h('br'),
418
+ `仓库:${vv.rows || 0} 条 / ${vv.days || 0} 天 · ${vv.bytes ? (vv.bytes / 1024).toFixed(0) : 0} KB(完整原文)`,
419
+ h('br'),
420
+ `归档:${r.archives || 0} 天 · ${kb} 保留期:${r.keepDays > 0 ? `${r.keepDays} 天` : '永久'}`)
421
+ })(),
422
+ h('div', { style: { ...S.row, marginTop: '8px' } },
423
+ ...[7, 30, 90, 0].map((n) => h('button', {
424
+ key: n,
425
+ type: 'button',
426
+ style: { ...S.btn, borderColor: ((d.retention || {}).keepDays || 0) === n ? '#3c9e6e' : undefined },
427
+ onClick: () => post('/config', { retention: { keepDays: n } }, n === 0 ? '保留期:永久' : `保留期:${n} 天`)
428
+ }, n === 0 ? '永久' : `${n} 天`))),
429
+ h('div', { style: { ...S.muted, marginTop: '6px' } },
430
+ '超过保留期的按天压进 archive/<日期>.jsonl.zst:只是换个地方躺着,不删。会话原文永不动。')),
431
+
432
+ // 8. 备份包(放最底下:搬家才用,不占日常视线)
433
+ Card('备份包',
434
+ h('div', { style: S.muted }, '把留痕的数据打成一个 zip:索引摘要、完整原文(仓库)、日记、自省、承诺、批注和设置。换机器就靠它。箱子里有 00-读我.txt 说明里面有什么。'),
435
+ h('div', { style: { ...S.muted, marginTop: '6px', color: '#d9534f' } },
436
+ '这个包里有完整原文,是明文的;放到共享目录或发给别人之前先想清楚。'),
437
+ d.box
438
+ ? h('div', { style: { marginTop: '10px', padding: '10px', borderRadius: '8px', background: 'rgba(127,127,127,.07)' } },
439
+ h('div', { style: { fontSize: '12.5px', marginBottom: '4px', wordBreak: 'break-all' } }, `${d.box.name} · ${(d.box.bytes / 1024).toFixed(1)} KB`),
440
+ h('div', { style: { ...S.muted, marginBottom: '8px', wordBreak: 'break-all' } },
441
+ d.box.shared ? `已另存一份到:${d.box.shared}` : '还没另存到其他目录,可用下面的「保存」取走。'),
442
+ h('div', { style: S.row },
443
+ h('button', { type: 'button', style: S.btn, disabled: boxList.loading, onClick: () => {
444
+ if (boxList.count > 0 && !boxList.loading) { setBoxList({ loading: false, entries: [], count: 0, error: '' }); return }
445
+ setBoxList({ loading: true, entries: [], count: 0, error: '' })
446
+ fetch(`${PREFIX}/box/list`, { credentials: 'same-origin' })
447
+ .then((r) => r.json())
448
+ .then((j) => setBoxList({ loading: false, entries: j.entries || [], count: j.count || 0, error: j.ok === false ? (j.error || '读不出来') : '' }))
449
+ .catch((e) => setBoxList({ loading: false, entries: [], count: 0, error: String(e && e.message ? e.message : e) }))
450
+ } }, boxList.loading ? '读中…' : (boxList.count > 0 ? '收起' : '打开')),
451
+ h('a', {
452
+ href: `${PREFIX}/box/file`,
453
+ style: { ...S.btn, display: 'inline-block', textDecoration: 'none', color: 'inherit' }
454
+ }, '存到手机'),
455
+ h('button', { type: 'button', style: S.btn, disabled: boxSend.busy, onClick: () => {
456
+ setBoxSend({ busy: true, msg: '', error: '' })
457
+ fetch(`${PREFIX}/box/send`, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: '{}' })
458
+ .then((r) => r.json())
459
+ .then((j) => setBoxSend({ busy: false, msg: j.ok === false ? '' : (j.text || '已排进最新对话。'), error: j.ok === false ? (j.error || '没排进去') : '' }))
460
+ .catch((e) => setBoxSend({ busy: false, msg: '', error: String(e && e.message ? e.message : e) }))
461
+ } }, boxSend.busy ? '打包中…' : '发到最新对话')),
462
+ boxSend.msg !== '' ? h('div', { style: { ...S.ok, marginTop: '6px' } }, boxSend.msg) : null,
463
+ boxSend.error !== '' ? h('div', { style: { ...S.err, marginTop: '6px' } }, boxSend.error) : null,
464
+ boxList.error !== '' ? h('div', { style: { ...S.err, marginTop: '6px' } }, boxList.error) : null,
465
+ boxList.count > 0
466
+ ? h('div', { style: { ...S.scroll, marginTop: '8px', maxHeight: '200px' } },
467
+ h('div', { style: S.muted }, `${d.box.name} 里面 · 共 ${boxList.count} 个文件`),
468
+ ...boxList.entries.map((e, i) => h('div', { key: i, style: { ...S.muted, wordBreak: 'break-all' } },
469
+ `${(e.bytes / 1024).toFixed(1)} KB · ${e.name}`)))
470
+ : null)
471
+ : h('div', { style: { ...S.muted, marginTop: '6px' } }, '还没有备份包。点下面「打一份备份包」,打好就出现在这里。'),
472
+ h('div', { style: { ...S.row, marginTop: '10px' } },
473
+ h('button', { type: 'button', style: S.btn, onClick: () => post('/box', {}, '打好了') }, '打一份备份包'),
474
+ h('button', { type: 'button', style: S.btn, onClick: () => post('/backfill', {}, '回填完成') }, '回填历史'))),
475
+
476
+ msg !== '' ? h('div', { style: S.ok }, msg) : null,
477
+ h('div', null, h('button', { type: 'button', style: S.btn, onClick: load }, '刷新')))
478
+ }
479
+
480
+ function apply(ctx) {
481
+ ctx.effect(() => {
482
+ const dispose = ctx.slots.inject('settings.section', () => ctx.slots.register(
483
+ { name: 'settings.section', id: 'liuhen', order: 42, label: () => '留痕', inject: () => ({}) },
484
+ Panel
485
+ ))
486
+ return () => { try { dispose() } catch {} }
487
+ })
488
+ }
489
+
490
+ return { name, inject, apply }
491
+ }
492
+ })
493
+ })()