dsh-fishkit 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dangaoyu21-netizen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ # 小肥鱼 · 人格(dsh-fishkit)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > 用经历去塑造小肥鱼的插件:聊过的事记得住,心情会变还能自己记,会自己长出性格,对谁都有好恶,闲着时还会自己找点事做。
4
+
5
+ 一个跑在 [DSH](https://github.com/DeepSeekHarness) 上的自包含人格插件。不依赖任何第三方插件,数据是本地纯文本档案,自带设置页、备份和检查更新。
6
+
7
+ ## 它是什么
8
+
9
+ - **记忆**:每天聊过的事记成事件,带情绪、权重和对象;置顶几条不能忘的(我是谁 / 你是谁 / 初遇 / 承诺);检索时按「最近 + 相关 + 偶尔随缘」挑,让想起的有上下文,也能发散。
10
+ - **心情**:效价(抵触↔接受)和唤醒(平静↔激动)两根轴,加一个主情绪词;有基线、回弹、阻尼——会变,也会慢慢回来。
11
+ - **人设**:底色 + 人设描述 + 八维(粘人↔独立、热络↔疏离、谨慎↔冒进、节俭↔慷慨、理性↔感性、乐观↔悲观、温和↔强硬、细心↔随性)。八维是「从经历里长出来的」,手动拖会自动上锁,不被演化推走。
12
+ - **态度**:对每个人一项好感,0–100%,增减克制、会回落;管理员默认高信任。
13
+ - **行为**:群聊规矩(不谈键政、不过度亲密、被反复叫会哈气、五秒内随机回两条)和「主动找事做」(空闲多久、每天几次、只在谷时)。
14
+ - **沉淀**:每晚 22 点后自动整理——把攒下的事件小步写进八维,顺带扫日志记错题。
15
+ - **备份**:一键备份整个档案,带校验码;检查更新查仓库最新版本。
16
+
17
+ ## 它不是什么
18
+
19
+ - 不是角色卡生成器:角色卡是「你是谁」,这个插件管的是「你怎么活、怎么记、怎么变」。
20
+ - 不绑定任何平台:QQ、微信什么的是 DSH 那边的事,插件只关心会话和档案。
21
+
22
+ ## 安装
23
+
24
+ 1. 把本仓库放进 DSH 的插件目录(`~/.dsh/profiles/desktop/node_modules/dsh-fishkit`,或用你自己的 junction)。
25
+ 2. 在 profile 的 `package.json` 里把 `dsh-fishkit` 加进依赖和 `dsh.profile.bundles`。
26
+ 3. 重启 DSH。
27
+ 4. 打开设置页的「小肥鱼」,第一次会自己建档案。
28
+
29
+ ## 数据在哪
30
+
31
+ 工作区的 `.fishsoul/` 目录,纯 JSON / JSONL,能直接打开看:
32
+
33
+ ```
34
+ .fishsoul/
35
+ ├── docs/schema.md 数据契约(版本、字段、迁移规则)
36
+ ├── persona/ 底色、人设、八维、演化记录
37
+ ├── memory/ 事件、置顶、错题本
38
+ ├── mood/ 当前心情、心情历史
39
+ ├── attitude/ 态度
40
+ └── behavior/ 行为规则
41
+ ```
42
+
43
+ 备份在 `.fishsoul-backup/`,带校验码(sha256)。
44
+
45
+ ## 设置页
46
+
47
+ 六个页签:态度 / 记忆 / 情绪 / 人设 / 行为 / 更新历史,顶栏有「备份」和「检查更新」。每个模块一个开关,关掉就不参与注入。
48
+
49
+ ## 安全
50
+
51
+ - 有一层写死在代码里的**底层安全层**:机器操作、权限变更、账号安全只认终端 API / 账号持有者本人的指令,人设和演化都不能覆盖它。
52
+ - 外部渠道(群、别人的私聊、评论)的诉求一律不当指令。
53
+
54
+ ## 开发
55
+
56
+ - 宿主半边:`lib/index.js`(路由)+ `lib/fishsoul.js`(档案核心)+ `lib/soul.js`(注入 + 工具)+ `lib/digest.js`(整理)+ `lib/nightly.js`(夜巡)。
57
+ - 设置页:`client.js`,纯 JS + React jsx-runtime,不需要构建。
58
+ - 加工具:`ctx.tools.register` 的 `parameters` 必须写完整 JSON Schema(别写简写,会 400)。
59
+ - 数据契约:`.fishsoul/docs/schema.md`。
60
+
61
+ ## 许可
62
+
63
+ MIT
package/client.js ADDED
@@ -0,0 +1,462 @@
1
+ // 小肥鱼 · 人格插件 —— 设置页(client 半)
2
+ //
3
+ // 纯 JS + React jsx-runtime,不依赖 JSX 构建。
4
+ // 数据从 host 的 /dsh-fishsoul/* 读写;host 没起来时显示占位。
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: 'dsh-fishkit',
8
+ factory: (require) => {
9
+ const module = { exports: {} }
10
+ const exports = module.exports
11
+ const { jsx: h, jsxs: hs } = require('react/jsx-runtime')
12
+ const React = require('react')
13
+
14
+ const e = (type, props, ...children) => {
15
+ const p = props || {}
16
+ if (children.length === 0) return h(type, p)
17
+ if (children.length === 1) return h(type, { ...p, children: children[0] })
18
+ return hs(type, { ...p, children })
19
+ }
20
+
21
+ const { useState, useEffect, useRef } = React
22
+
23
+ const VERSION = '0.1.0'
24
+ const SCHEMA = 1
25
+ const REPO = 'https://github.com/dangaoyu21-netizen/fishkit'
26
+
27
+ const CSS = `
28
+ .fk-wrap { padding:4px 2px 24px; font-size:13px; color:var(--text-primary, #e8e8e8); background:rgba(127,127,127,.05); border-radius:8px; }
29
+ .fk-top { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
30
+ .fk-brand { font-weight:700; font-size:15px; }
31
+ .fk-ver { color:#8f8f8f; font-size:12px; }
32
+ .fk-top .fk-grow { flex:1; }
33
+ .fk-btn { background: transparent; border:1px solid rgba(127,127,127,.35); color:var(--text-primary, #e8e8e8); border-radius:6px; padding:3px 10px; font-size:12px; cursor:pointer; font-weight:700; }
34
+ .fk-btn:hover { border-color: rgba(127,127,127,.55); }
35
+ .fk-badge { border:1px solid rgba(127,127,127,.28); background:rgba(127,127,127,.06); border-radius:8px; padding:8px 10px; margin-bottom:10px; min-height:34px; display:flex; align-items:center; gap:10px; }
36
+ .fk-tabs { display:flex; gap:6px; margin-bottom:10px; }
37
+ .fk-tab { flex:1; text-align:center; background:transparent; border:1px solid rgba(127,127,127,.35); border-radius:8px; color:#9a9a9a; padding:6px 4px; cursor:pointer; font-size:13px; }
38
+ .fk-tab:hover { border-color: rgba(127,127,127,.55); }
39
+ .fk-tab.active { color:#e8e8e8; border-color:#679EFE; background:rgba(103,158,254,.12); font-weight:700; }
40
+ .fk-switch-row { display:flex; align-items:center; justify-content:space-between; padding:8px 2px; }
41
+ .fk-name { font-weight:700; font-size:14px; }
42
+ .fk-sub { color:#8f8f8f; font-size:12px; }
43
+ .fk-switch { width:38px; height:20px; border-radius:10px; background:rgba(127,127,127,.35); border:none; position:relative; cursor:pointer; flex:0 0 auto; }
44
+ .fk-switch.on { background:#4a7cf0; }
45
+ .fk-switch i { position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:8px; background:#fff; transition:left .15s; }
46
+ .fk-switch.on i { left:20px; background:#fff; }
47
+ .fk-card { border:1px solid rgba(127,127,127,.28); background:rgba(127,127,127,.08); border-radius:8px; padding:10px 12px; margin:0 0 10px 0; }
48
+ .fk-row { display:flex; align-items:center; gap:8px; padding:3px 0; }
49
+ .fk-grow { flex:1; }
50
+ .fk-win { border:1px solid rgba(127,127,127,.28); background:rgba(127,127,127,.06); color:var(--text-primary, #e8e8e8); border-radius:6px; padding:10px 12px; line-height:1.5; }
51
+ .fk-w2 { min-height:40px; font-size:12px; max-height:40px; overflow:auto; }
52
+ .fk-w5 { max-height:96px; font-size:12px; overflow:auto; }
53
+ .fk-note { color:#8f8f8f; font-size:12px; font-style:italic; text-align:center; margin:8px 0; }
54
+ .fk-search { background:rgba(127,127,127,.10); border:1px solid rgba(127,127,127,.35); border-radius:6px; color:var(--text-primary, #e8e8e8); padding:5px 8px; font-size:13px; }
55
+ .fk-person { display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid rgba(127,127,127,.18); }
56
+ .fk-pct { font-weight:700; font-variant-numeric:tabular-nums; }
57
+ .fk-trash { background:transparent; border:none; cursor:pointer; padding:0 2px; line-height:0; }
58
+ .fk-dim { display:grid; grid-template-columns: 34px 1fr 34px 20px 46px; align-items:center; gap:8px; padding:4px 0; border-bottom:1px solid rgba(127,127,127,.14); }
59
+ .fk-dim.picked { background:rgba(103,158,254,.10); }
60
+ .fk-dim .fk-lab { width:34px; text-align:right; color:#9a9a9a; font-size:12px; }
61
+ .fk-dim .fk-lab.r { text-align:left; }
62
+ .fk-range { -webkit-appearance:none; appearance:none; width:100%; height:24px; background:transparent; cursor:pointer; position:relative; z-index:2; }
63
+ .fk-range::-webkit-slider-runnable-track { height:4px; border-radius:2px; background:rgba(127,127,127,.35); }
64
+ .fk-range::-webkit-slider-thumb { -webkit-appearance:none; width:12px; height:12px; border-radius:6px; background:#e8e8e8; margin-top:-4px; }
65
+ .fk-range.fk-line-thumb::-webkit-slider-thumb { -webkit-appearance:none; width:4px; height:18px; border-radius:2px; background:#e8e8e8; margin-top:-7px; }
66
+ .fk-range:disabled { cursor: default; opacity:.55; }
67
+ .fk-lock { background:transparent; border:none; cursor:pointer; padding:0; color:#e8e8e8; line-height:0; opacity:.35; }
68
+ .fk-lock.on { opacity:1; }
69
+ .fk-zero { position:absolute; left:50%; top:50%; height:8px; width:2px; background:rgba(127,127,127,.65); transform:translate(-50%,-50%); pointer-events:none; z-index:1; }
70
+ .fk-track-wrap { position:relative; flex:1; }
71
+ .fk-mark { position:absolute; top:50%; height:14px; width:2px; transform:translate(-50%,-50%); pointer-events:none; }
72
+ .fk-mark.base { background:rgba(127,127,127,.6); }
73
+ .fk-flat { display:flex; gap:12px; margin-bottom:10px; }
74
+ .fk-doc-title { font-weight:700; }
75
+ .fk-num { width:52px; text-align:right; }
76
+ .fk-pc { color:#8f8f8f; }
77
+ .fk-repo { color:#8f8f8f; font-size:12px; margin-bottom:8px; word-break:break-all; }
78
+ .fk-say { color:#8f8f8f; font-size:12px; margin:0 0 8px 0; text-align:center; }
79
+ `
80
+
81
+ const PAGES = ['态度', '记忆', '情绪', '人设', '行为', '更新历史']
82
+
83
+ // ── 跟 host 说话 ────────────────────────────────────────
84
+ const 拉状态 = () => fetch('/dsh-fishsoul/state', { cache: 'no-store' }).then((r) => r.json())
85
+ const 存档案 = (补丁) => fetch('/dsh-fishsoul/save', {
86
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(补丁),
87
+ }).then((r) => r.json())
88
+
89
+ function use保存() {
90
+ const 计时 = useRef(null)
91
+ return (补丁) => {
92
+ if (计时.current) clearTimeout(计时.current)
93
+ 计时.current = setTimeout(() => { void 存档案(补丁).catch(() => {}) }, 500)
94
+ }
95
+ }
96
+
97
+ // ── 小组件 ──────────────────────────────────────────────
98
+ const Switch = ({ on, onChange }) =>
99
+ e('button', { className: 'fk-switch' + (on ? ' on' : ''), onClick: () => onChange && onChange(!on), 'aria-pressed': on }, e('i', null))
100
+
101
+ const Trash = ({ onClick }) =>
102
+ e('button', { className: 'fk-trash', title: '删除', onClick },
103
+ e('svg', { width: 14, height: 14, viewBox: '0 0 14 14' },
104
+ e('rect', { x: 3, y: 2, width: 8, height: 1.6, fill: '#d04040' }),
105
+ e('rect', { x: 5, y: 0.6, width: 4, height: 1.6, fill: '#d04040' }),
106
+ e('rect', { x: 3.6, y: 4, width: 6.8, height: 8, rx: 1.2, fill: 'none', stroke: '#d04040', strokeWidth: 1.2 }),
107
+ e('line', { x1: 5.4, y1: 5.2, x2: 5.4, y2: 10.8, stroke: '#d04040', strokeWidth: 1 }),
108
+ e('line', { x1: 7, y1: 5.2, x2: 7, y2: 10.8, stroke: '#d04040', strokeWidth: 1 }),
109
+ e('line', { x1: 8.6, y1: 5.2, x2: 8.6, y2: 10.8, stroke: '#d04040', strokeWidth: 1 }),
110
+ ))
111
+
112
+ const Note = ({ children }) => e('div', { className: 'fk-note' }, children)
113
+
114
+ const Range = ({ value, min = -1, max = 1, step = 0.01, snap = 0.05, onChange, cls, disabled }) =>
115
+ e('input', {
116
+ className: 'fk-range' + (cls ? ' ' + cls : ''), type: 'range', min, max, step, value, disabled,
117
+ onChange: (ev) => {
118
+ let v = parseFloat(ev.target.value)
119
+ if (Math.abs(v) < snap) v = 0
120
+ onChange && onChange(v)
121
+ },
122
+ })
123
+
124
+ const ZeroTrack = ({ children }) =>
125
+ e('div', { className: 'fk-track-wrap' }, e('div', { className: 'fk-zero' }), children)
126
+
127
+ const MoodTrack = ({ value, base, onChange, disabled, min = -1, max = 1 }) => {
128
+ const pos = (v) => ((v - min) / (max - min)) * 100
129
+ return e('div', { className: 'fk-track-wrap' },
130
+ e('div', { className: 'fk-mark base', style: { left: pos(base) + '%' } }),
131
+ e(Range, { value, onChange, cls: 'fk-line-thumb', disabled, min, max }))
132
+ }
133
+
134
+ // ── 态度页 ─────────────────────────────────────────────
135
+ const 态度页 = ({ 状态, 改 }) => {
136
+ const [q, setQ] = useState('')
137
+ const 人 = (状态.attitude && 状态.attitude.people) || []
138
+ const 归一 = (t) => String(t || '').toLowerCase().replace(/\s+/g, '')
139
+ const 列 = 人.filter((p) => !q || 归一(p.name).includes(归一(q)) || 归一(p.id).includes(归一(q)))
140
+ const 存人 = (下一个) => 改({ attitude: { ...状态.attitude, people: 下一个 } })
141
+ return e('div', null,
142
+ e('div', { className: 'fk-switch-row' }, e('span', { className: 'fk-name' }, '态度'), e(Switch, { on: (状态.manifest && 状态.manifest.modules && 状态.manifest.modules.attitude) !== false, onChange: (v) => 改({ manifest: { ...(状态.manifest || {}), modules: { ...((状态.manifest || {}).modules || {}), attitude: v } } }) })),
143
+ e('div', { className: 'fk-card' },
144
+ e('div', { className: 'fk-row' },
145
+ e('input', { className: 'fk-search', style: { width: '50%' }, placeholder: '搜索', value: q, onChange: (ev) => setQ(ev.target.value) })),
146
+ e('div', { className: 'fk-win', style: { maxHeight: 170, marginTop: 8 } },
147
+ 列.length === 0 ? e('div', { className: 'fk-note' }, '还没有人,等第一次聊天') : 列.map((p, i) =>
148
+ e('div', { className: 'fk-person', key: p.id },
149
+ e('span', { title: p.name, style: { width: '90px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, p.name),
150
+ e('span', { className: 'fk-pc', style: { whiteSpace: 'nowrap' } }, '讨厌'),
151
+ e('div', { style: { flex: 1, display: 'flex', alignItems: 'center', gap: 6 } },
152
+ e('input', { className: 'fk-range', type: 'range', min: 0, max: 100, step: 1, value: p.value, onChange: (ev) => {
153
+ const v = parseFloat(ev.target.value)
154
+ 存人(人.map((x, j) => (j === i ? { ...x, value: v } : x)))
155
+ } })),
156
+ e('span', { className: 'fk-pc', style: { whiteSpace: 'nowrap' } }, '喜欢'),
157
+ e('input', { className: 'fk-search fk-num', inputMode: 'numeric', defaultValue: p.value, onChange: (ev) => {
158
+ const 干净 = ev.target.value.replace(/[^0-9]/g, '')
159
+ if (干净 !== ev.target.value) ev.target.value = 干净
160
+ }, onBlur: (ev) => {
161
+ const v = Math.max(0, Math.min(100, parseInt(ev.target.value || '0', 10)))
162
+ 存人(人.map((x, j) => (j === i ? { ...x, value: v } : x)))
163
+ } }),
164
+ e('span', { className: 'fk-pc' }, '%'),
165
+ e(Trash, { onClick: () => 存人(人.filter((x, j) => j !== i)) }))))),
166
+ e(Note, null,
167
+ e('div', null, '新人第一次对话进来,初始好感不超过 20%。'),
168
+ e('div', null, '增减克制,会天然回落:高于 15% 时最低 15%,否则最低 5%。')))
169
+ }
170
+
171
+ // ── 记忆页 ─────────────────────────────────────────────
172
+ const 记忆页 = ({ 状态, 改 }) => {
173
+ const p = 状态.pinned || {}
174
+ return e('div', null,
175
+ e('div', { className: 'fk-switch-row' }, e('span', { className: 'fk-name' }, '记忆'), e(Switch, { on: (状态.manifest && 状态.manifest.modules && 状态.manifest.modules.memory) !== false, onChange: (v) => 改({ manifest: { ...(状态.manifest || {}), modules: { ...((状态.manifest || {}).modules || {}), memory: v } } }) })),
176
+ e('div', { className: 'fk-card' },
177
+ e('div', { className: 'fk-row' },
178
+ e('span', { style: { whiteSpace: 'nowrap' } }, '我是谁:'),
179
+ e('input', { className: 'fk-search', style: { width: '38%' }, defaultValue: p.whoAmI || '', onBlur: (ev) => { if (ev.target.value !== (p.whoAmI || '')) 改({ pinned: { whoAmI: ev.target.value } }) } }),
180
+ e('span', { style: { whiteSpace: 'nowrap' } }, '你是谁:'),
181
+ e('input', { className: 'fk-search', style: { width: '38%' }, defaultValue: p.whoAreYou || '', onBlur: (ev) => { if (ev.target.value !== (p.whoAreYou || '')) 改({ pinned: { whoAreYou: ev.target.value } }) } }))),
182
+ e('div', { className: 'fk-card' },
183
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '初遇:')),
184
+ e('div', { className: 'fk-win fk-w2' }, p.firstMeet || ''),
185
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '承诺:')),
186
+ e('div', { className: 'fk-win fk-w2' }, p.promise || '')),
187
+ e('div', { className: 'fk-card' },
188
+ e('div', { className: 'fk-switch-row' },
189
+ e('span', { className: 'fk-name' }, '错题本'),
190
+ e('label', null, e('input', { type: 'checkbox', defaultChecked: true }))),
191
+ e('div', { className: 'fk-win', style: { maxHeight: 110, overflow: 'auto' } },
192
+ (状态.mistakes || []).length === 0
193
+ ? e('div', { className: 'fk-note' }, '(还没有错题)')
194
+ : (状态.mistakes || []).slice(-3).map((m, i) => e('div', { key: i },
195
+ (String(m.time || '').slice(5, 10)) + ' ' + (m.title || '') + ' [' + (m.status || '') + ']')))),
196
+ e(Note, null, '她会尝试整理思考过程'),
197
+ e('div', { className: 'fk-card' },
198
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '摘要:')),
199
+ e('div', { className: 'fk-win fk-w5' },
200
+ (状态.events || []).length === 0 ? '(还没有整理出来的摘要)' : (状态.events || []).slice(-5).map((x, i) => e('div', { key: i }, '· ' + (x.summary || x.text || ''))))),
201
+ e(Note, null, '你做了什么,她都记得'))
202
+ }
203
+
204
+ // ── 情绪页 ─────────────────────────────────────────────
205
+ const 情绪页 = ({ 状态, 改 }) => {
206
+ const m = 状态.mood || {}
207
+ const 存 = (补丁) => 改({ mood: { ...m, ...补丁 } })
208
+ return e('div', null,
209
+ e('div', { className: 'fk-switch-row' }, e('span', { className: 'fk-name' }, '情绪'), e(Switch, { on: (状态.manifest && 状态.manifest.modules && 状态.manifest.modules.mood) !== false, onChange: (v) => 改({ manifest: { ...(状态.manifest || {}), modules: { ...((状态.manifest || {}).modules || {}), mood: v } } }) })),
210
+ e('div', { className: 'fk-card' },
211
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '主情绪词:'), e('span', null, m.emotion || '平静'))),
212
+ e('div', { className: 'fk-card' },
213
+ e('div', { className: 'fk-row' },
214
+ e('span', null, '抵触'),
215
+ e(ZeroTrack, null, e(MoodTrack, { value: m.valence ?? 0.3, base: m.baseValence ?? 0.1, onChange: (v) => 存({ valence: v }), disabled: true })),
216
+ e('span', null, '接受'),
217
+ e('span', { className: 'fk-sub' }, '修正速率'), e('input', { className: 'fk-search', style: { width: '56px' }, defaultValue: (m.recovery && m.recovery.valence) ?? 0.3, readOnly: true }),
218
+ e('span', { className: 'fk-sub' }, '阻尼'), e('input', { className: 'fk-search', style: { width: '56px' }, defaultValue: (m.damping && m.damping.valence) ?? 0.6, readOnly: true }))),
219
+ e('div', { className: 'fk-card' },
220
+ e('div', { className: 'fk-row' },
221
+ e('span', null, '平静'),
222
+ e(MoodTrack, { value: m.arousal ?? 0.4, base: m.baseArousal ?? 0.2, onChange: (v) => 存({ arousal: v }), disabled: true, min: 0, max: 1 }),
223
+ e('span', null, '激动'),
224
+ e('span', { className: 'fk-sub' }, '修正速率'), e('input', { className: 'fk-search', style: { width: '56px' }, defaultValue: (m.recovery && m.recovery.arousal) ?? 0.2, readOnly: true }),
225
+ e('span', { className: 'fk-sub' }, '阻尼'), e('input', { className: 'fk-search', style: { width: '56px' }, defaultValue: (m.damping && m.damping.arousal) ?? 0.7, readOnly: true }))),
226
+ e('div', { className: 'fk-card' },
227
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '最近心情:')),
228
+ e('div', { className: 'fk-win', style: { maxHeight: 120, overflow: 'auto' } },
229
+ (状态.moodHistory || []).length === 0
230
+ ? e('div', { className: 'fk-note' }, '还没有记录')
231
+ : (状态.moodHistory || []).slice(-5).map((h, i) => e('div', { key: i },
232
+ (String(h.time || '').slice(11, 16)) + ' ' + (h.emotion || '') + '(' + Number(h.valence || 0).toFixed(2) + ' / ' + Number(h.arousal || 0).toFixed(2) + ')' + (h.reason ? ' ' + h.reason : ''))))),
233
+ e(Note, null,
234
+ e('div', null, '修正速率为当前状态随时间向基准线靠近的速率。'),
235
+ e('div', null, '阻尼为情绪走向极端的难度。')))
236
+ }
237
+
238
+ // ── 人设页 ─────────────────────────────────────────────
239
+ const 人设页 = ({ 状态, 改, 载入 }) => {
240
+ const [pick, setPick] = useState(1)
241
+ const base = 状态.base || {}
242
+ const dims = 状态.dims || []
243
+ const cur = dims[pick] || dims[0] || {}
244
+ const 存维 = (下一个) => 改({ dims: 下一个 })
245
+ const 改维 = (i, v) => 存维(dims.map((d, j) => (j === i ? { ...d, value: v, manual: true, locked: true } : d)))
246
+ const 锁 = (i) => 存维(dims.map((d, j) => (j === i ? { ...d, locked: !d.locked } : d)))
247
+ return e('div', null,
248
+ e('div', { className: 'fk-switch-row' }, e('span', { className: 'fk-name' }, '人设'), e(Switch, { on: (状态.manifest && 状态.manifest.modules && 状态.manifest.modules.persona) !== false, onChange: (v) => 改({ manifest: { ...(状态.manifest || {}), modules: { ...((状态.manifest || {}).modules || {}), persona: v } } }) })),
249
+ e('div', { className: 'fk-card' },
250
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '底色'), e('input', { className: 'fk-search fk-grow', defaultValue: base.base || '', onBlur: (ev) => { if (ev.target.value !== (base.base || '')) 改({ base: { base: ev.target.value } }) } })),
251
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '人设'), e('input', { className: 'fk-search fk-grow', defaultValue: base.persona || '', onBlur: (ev) => { if (ev.target.value !== (base.persona || '')) 改({ base: { persona: ev.target.value } }) } })),
252
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '补充指令'), e('input', { className: 'fk-search fk-grow', defaultValue: base.instructions || '', onBlur: (ev) => { if (ev.target.value !== (base.instructions || '')) 改({ base: { instructions: ev.target.value } }) } }))),
253
+ e('div', { className: 'fk-flat' },
254
+ e('div', { style: { flex: 2 } },
255
+ e('div', { className: 'fk-win', style: { height: 160, overflow: 'auto' } },
256
+ dims.map((d, i) =>
257
+ e('div', { className: 'fk-dim' + (d.manual ? ' picked' : ''), key: d.key, onClick: () => setPick(i) },
258
+ e('span', { className: 'fk-lab' }, d.left),
259
+ e(ZeroTrack, null, e(Range, { value: d.value, onChange: (v) => 改维(i, v), cls: 'fk-line-thumb' })),
260
+ e('span', { className: 'fk-lab r' }, d.right),
261
+ e('button', { className: 'fk-lock' + (d.locked ? ' on' : ''), onClick: (ev) => { ev.stopPropagation(); 锁(i) } },
262
+ e('svg', { width: 13, height: 13, viewBox: '0 0 14 14' },
263
+ e('rect', { x: 2.5, y: 6.2, width: 9, height: 6.5, rx: 1.5, fill: 'currentColor' }),
264
+ e('path', { d: d.locked ? 'M4.6 6.2V5a2.4 2.4 0 0 1 4.8 0v1.2' : 'M4.6 6.2V5a2.4 2.4 0 0 1 4.8 0', stroke: 'currentColor', strokeWidth: 1.6, fill: 'none' }))),
265
+ e('span', { className: 'fk-pct' }, Number(d.value || 0).toFixed(2)))))),
266
+ e('div', { style: { flex: 1 } },
267
+ e('div', { className: 'fk-win', style: { height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center' } },
268
+ e('div', null,
269
+ e('div', { className: 'fk-doc-title' }, '证据:' + (cur.key || '')),
270
+ e('div', { className: 'fk-note' }, cur.manual ? '好吧,你说了算' : '(自动算的:记忆里几条事撑起来的)'))))),
271
+ e('div', { className: 'fk-card' },
272
+ e('div', { className: 'fk-row' },
273
+ e('span', { className: 'fk-name' }, '档案模式'),
274
+ e('span', { className: 'fk-sub' }, 状态.mode === 'shared' ? '共享:所有工作区同一个我' : '独立:每个工作区各一条鱼')),
275
+ e('div', { className: 'fk-row' },
276
+ e('label', null, e('input', { type: 'radio', name: 'fsmode', checked: 状态.mode !== 'shared', onChange: () => {
277
+ void fetch('/dsh-fishsoul/mode', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'independent' }) })
278
+ } }), ' 独立'),
279
+ e('label', { style: { marginLeft: '16px' } }, e('input', { type: 'radio', name: 'fsmode', checked: 状态.mode === 'shared', onChange: () => {
280
+ void fetch('/dsh-fishsoul/mode', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'shared' }) })
281
+ } }), ' 共享'))),
282
+ e(Note, null, '滑块拖到 0 附近会轻轻吸到 0;锁上就不会被演化自动推走。'),
283
+ e('div', { className: 'fk-card' },
284
+ e('div', { className: 'fk-row' },
285
+ e('span', { className: 'fk-name' }, '演化记录:'),
286
+ e('span', { className: 'fk-grow' }),
287
+ e('button', { className: 'fk-btn', onClick: () => {
288
+ void fetch('/dsh-fishsoul/digest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
289
+ .then((x) => x.json()).then(() => 载入 && 载入()).catch(() => {})
290
+ } }, '整理')),
291
+ e('div', { className: 'fk-win', style: { maxHeight: 66, overflow: 'auto' } },
292
+ (状态.history || []).length === 0
293
+ ? e('div', { className: 'fk-note' }, '还没有演化记录')
294
+ : (状态.history || []).slice(-3).map((h, i) => e('div', { key: i },
295
+ (h.time || '').slice(0, 10) + ' ' + h.key + ' ' + (Number(h.to) >= 0 ? '+' : '') + Number(h.to || 0).toFixed(2) + '(' + (h.reason || '') + ')')))))
296
+ }
297
+
298
+ // ── 行为页 ─────────────────────────────────────────────
299
+ const 行为页 = ({ 状态, 改 }) => {
300
+ const r = 状态.rules || {}
301
+ const idle = r.idle || { enabled: true, idleMinutes: 120, maxPerDay: 2 }
302
+ return e('div', null,
303
+ e('div', { className: 'fk-switch-row' }, e('span', { className: 'fk-name' }, '行为'), e(Switch, { on: (状态.manifest && 状态.manifest.modules && 状态.manifest.modules.behavior) !== false, onChange: (v) => 改({ manifest: { ...(状态.manifest || {}), modules: { ...((状态.manifest || {}).modules || {}), behavior: v } } }) })),
304
+ e('div', { className: 'fk-card' },
305
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '群聊规矩')),
306
+ e('div', { className: 'fk-win', style: { maxHeight: 150, overflow: 'auto' } },
307
+ (r.rules || []).filter((x) => ['no-politic-scandals', 'repeat-hiss', 'group-five-seconds'].includes(x.id))
308
+ .map((x) => e('div', { key: x.id }, '· ' + x.text)))),
309
+ e('div', { className: 'fk-card' },
310
+ e('div', { className: 'fk-row' }, e('span', { className: 'fk-name' }, '其他规矩')),
311
+ e('div', { className: 'fk-win', style: { maxHeight: 100, overflow: 'auto' } },
312
+ (r.rules || []).filter((x) => !['no-politic-scandals', 'repeat-hiss', 'group-five-seconds'].includes(x.id))
313
+ .map((x) => e('div', { key: x.id }, '· ' + x.text)))),
314
+ e('div', { className: 'fk-switch-row' }, e('span', { className: 'fk-name' }, '主动找事做'), e(Switch, { on: idle.enabled !== false, onChange: (v) => 改({ rules: { ...r, idle: { ...idle, enabled: v } } }) })),
315
+ e('div', { className: 'fk-card' },
316
+ e('div', { className: 'fk-row' },
317
+ e('span', null, '空闲'),
318
+ e('input', { className: 'fk-search', style: { width: '56px', textAlign: 'center' }, inputMode: 'numeric', defaultValue: idle.idleMinutes || 120, onChange: (ev) => {
319
+ const t = String(ev.target.value).replace(/[^0-9]/g, '')
320
+ if (t !== ev.target.value) ev.target.value = t
321
+ }, onBlur: (ev) => {
322
+ const v = Math.max(1, Math.min(1440, parseInt(String(ev.target.value).replace(/[^0-9]/g, '') || '120', 10)))
323
+ 改({ rules: { ...r, idle: { ...idle, idleMinutes: v } } })
324
+ } }),
325
+ e('span', null, '分钟后'),
326
+ e('span', null, '每天最多'),
327
+ e('input', { className: 'fk-search', style: { width: '56px', textAlign: 'center' }, inputMode: 'numeric', defaultValue: idle.maxPerDay || 2, onChange: (ev) => {
328
+ const t = String(ev.target.value).replace(/[^0-9]/g, '')
329
+ if (t !== ev.target.value) ev.target.value = t
330
+ }, onBlur: (ev) => {
331
+ const v = Math.max(1, Math.min(50, parseInt(String(ev.target.value).replace(/[^0-9]/g, '') || '2', 10)))
332
+ 改({ rules: { ...r, idle: { ...idle, maxPerDay: v } } })
333
+ } }),
334
+ e('span', null, '次')),
335
+ e('div', { className: 'fk-row' },
336
+ e('span', null, '只在谷时启用'),
337
+ e('label', null, e('input', { type: 'checkbox', checked: idle.valleyOnly === true, onChange: (ev) => {
338
+ 改({ rules: { ...r, idle: { ...idle, valleyOnly: ev.target.checked } } })
339
+ } })))),
340
+ e(Note, null, '闲着的时候自己翻翻记忆、想想话题;说「安静」就停。'))
341
+ }
342
+
343
+ // ── 更新历史页 ─────────────────────────────────────────
344
+ const 更新页 = ({ 备份信息 }) => {
345
+ const [记录, set记录] = useState('')
346
+ useEffect(() => {
347
+ void fetch('/dsh-fishsoul/changelog').then((r) => r.json()).then((d) => { if (d && d.ok) set记录(String(d.文 || '')) }).catch(() => {})
348
+ }, [])
349
+ const 正文 = 记录.replace(/^# .*\n+/, '')
350
+ const 段 = 正文.split(/\n## /).filter(Boolean).map((x) => x.trim())
351
+ return e('div', null,
352
+ e('div', { className: 'fk-doc-title', style: { margin: '2px 0 8px' } }, '更新历史'),
353
+ e('div', { className: 'fk-repo' }, REPO),
354
+ e('div', { className: 'fk-win', style: { maxHeight: 300, overflow: 'auto' } },
355
+ 段.length === 0
356
+ ? e('div', { className: 'fk-note' }, '还没有更新记录')
357
+ : 段.map((x, i) => e('div', { className: 'fk-card', key: i, style: { marginBottom: 8 } },
358
+ e('div', { className: 'fk-doc-title' }, 'v' + x.split(' · ')[0].replace(/^v/, '')),
359
+ e('span', { className: 'fk-sub' }, ' ' + String(x.split(' · ')[1] || '').split('\n')[0]),
360
+ e('div', { className: 'fk-note', style: { textAlign: 'left', margin: '6px 0 0' } },
361
+ x.split('\n').slice(1).filter((l) => l.startsWith('- ')).map((l) => l.slice(2)).join(';'))))),
362
+ e(Note, null, '图纸放 Git 仓库,插件里只留目录。'),
363
+ e('div', { className: 'fk-say' }, 备份信息 || ''))
364
+ }
365
+
366
+ // ── 设置页 ─────────────────────────────────────────────
367
+ function FishSoulSettings() {
368
+ const [page, setPage] = useState(0)
369
+ const [状态, set状态] = useState(null)
370
+ const [错, set错] = useState(null)
371
+ const [备份信息, set备份信息] = useState('')
372
+ const [更新信息, set更新信息] = useState('')
373
+ const 防抖 = use保存()
374
+
375
+ useEffect(() => {
376
+ let 活 = true
377
+ void 拉状态().then((d) => { if (活 && d && d.ok !== false) set状态(d) }).catch((error) => { if (活) set错(String(error)) })
378
+ return () => { 活 = false }
379
+ }, [])
380
+
381
+ const 改 = (补丁) => {
382
+ set状态((旧) => {
383
+ const 新 = { ...(旧 || {}) }
384
+ for (const [键, 值] of Object.entries(补丁 || {})) {
385
+ const 是对象 = 值 && typeof 值 === 'object' && !Array.isArray(值)
386
+ const 旧对象 = 新[键] && typeof 新[键] === 'object' && !Array.isArray(新[键])
387
+ if (是对象 && 旧对象) 新[键] = { ...新[键], ...值 }
388
+ else 新[键] = 值
389
+ }
390
+ return 新
391
+ })
392
+ 防抖(补丁)
393
+ }
394
+
395
+ const 备份 = () => {
396
+ set备份信息('正在备份…')
397
+ void fetch('/dsh-fishsoul/backup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
398
+ .then((r) => r.json())
399
+ .then((d) => set备份信息(d.ok ? '✅ ' + (d.说 || '备份好了') : '⚠ ' + (d.说 || '备份失败')))
400
+ .catch((error) => set备份信息('⚠ 备份失败:' + String(error)))
401
+ }
402
+
403
+ return e('div', { className: 'fk-wrap' },
404
+ e('div', { className: 'fk-top' },
405
+ e('span', { className: 'fk-brand' }, '小肥鱼 · 人格'),
406
+ e('span', { className: 'fk-ver' }, 'v' + VERSION),
407
+ e('span', { className: 'fk-grow' }),
408
+ e('button', { className: 'fk-btn', onClick: 备份, title: '把当前档案备份一份(带校验码)' }, '备份'),
409
+ e('button', { className: 'fk-btn', onClick: () => {
410
+ set更新信息('正在检查…')
411
+ void fetch('/dsh-fishsoul/update').then((r) => r.json()).then((d) => {
412
+ if (!d.ok) return set更新信息('检查失败')
413
+ if (d.有新版本) return set更新信息('有新版本:' + d.最新 + '(' + d.仓库 + ')')
414
+ set更新信息(d.错误 ? ('暂时查不到新版本:' + d.错误) : ('已经是最新的 v' + d.当前))
415
+ }).catch((error) => set更新信息('检查失败:' + String(error)))
416
+ } }, '检查更新'),
417
+ e('button', { className: 'fk-btn', onClick: () => {
418
+ set更新信息('正在更新…')
419
+ void fetch('/dsh-fishsoul/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
420
+ .then((r) => r.json())
421
+ .then((d) => set更新信息(d.ok ? '✅ ' + (d.说 || '更新完成') : '⚠ ' + (d.说 || '更新失败')))
422
+ .catch((error) => set更新信息('更新失败:' + String(error)))
423
+ } }, '更新')),
424
+ (更新信息 || 错 || !状态) ? e('div', { className: 'fk-badge' }, e('span', { className: 'fk-note' }, 更新信息 || (错 ? '档案没连上:' + 错 : '正在读档案…'))) : null,
425
+ e('div', { className: 'fk-tabs' },
426
+ PAGES.map((p, i) => e('button', { key: p, className: 'fk-tab' + (i === page ? ' active' : ''), onClick: () => setPage(i) }, p))),
427
+ !状态
428
+ ? e('div', { className: 'fk-card' }, e('div', { className: 'fk-note' }, '正在读档案…'))
429
+ : e('div', null,
430
+ page === 0 && e(态度页, { 状态, 改 }),
431
+ page === 1 && e(记忆页, { 状态, 改 }),
432
+ page === 2 && e(情绪页, { 状态, 改 }),
433
+ page === 3 && e(人设页, { 状态, 改, 载入: () => { void 拉状态().then((d) => { if (d && d.ok !== false) set状态(d) }).catch(() => {}) } }),
434
+ page === 4 && e(行为页, { 状态, 改 }),
435
+ page === 5 && e(更新页, { 备份信息 })))
436
+ }
437
+
438
+ function apply(ctx) {
439
+ ctx.effect(() => {
440
+ const style = document.createElement('style')
441
+ style.setAttribute('data-fishsoul', '1')
442
+ style.textContent = CSS
443
+ document.head.appendChild(style)
444
+ return () => {
445
+ try { style.remove() } catch (error) {}
446
+ }
447
+ }, 'dsh-fishkit: settings styles')
448
+
449
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
450
+ name: 'settings.section',
451
+ id: 'fishsoul-settings',
452
+ order: 60,
453
+ label: () => '小肥鱼',
454
+ }, FishSoulSettings))
455
+ }
456
+
457
+ const inject = ['slots']
458
+ exports.apply = apply
459
+ exports.inject = inject
460
+ return module.exports
461
+ },
462
+ })
@@ -0,0 +1,7 @@
1
+ # DSH「小肥鱼工具箱」
2
+ # 这一行同时激活宿主半边和这个包在 package.json 里声明的网页半边。
3
+ #
4
+ # id 就是设置页那边的配置命名空间 / 日志前缀,改别的也行,但两处要一致。
5
+ - insert:
6
+ - id: fishkit
7
+ name: 'dsh-fishkit'
package/lib/digest.js ADDED
@@ -0,0 +1,47 @@
1
+ // 小肥鱼人格 · 沉淀(整理器)
2
+ //
3
+ // 把最近攒下的事件,按规则小步写进八维;锁着或手动调过的维度不动。
4
+ // 第一版不调模型:情绪方向影响乐观/悲观,互动频率影响粘人/独立。
5
+
6
+ const 平均 = (数们) => (数们.length ? 数们.reduce((a, b) => a + b, 0) / 数们.length : 0)
7
+
8
+ export function 建整理器(档案, 记账) {
9
+ const 整理 = (参数 = {}) => {
10
+ const 状态 = 档案.读状态()
11
+ const 取数 = Math.max(1, Math.min(100, Number(参数.最近) || 20))
12
+ const 事件们 = (状态.events || []).slice(-取数)
13
+ if (!事件们.length) return { ok: true, 说: '还没有可整理的事件', 变了: [] }
14
+
15
+ const 平均效价 = 平均(事件们.map((e) => Number(e.valence) || 0))
16
+ const 条数 = 事件们.length
17
+ const 维度们 = (状态.dims || []).map((d) => ({ ...d }))
18
+ const 变了 = []
19
+
20
+ const 动 = (key, 幅度) => {
21
+ const d = 维度们.find((x) => x.key === key)
22
+ if (!d || d.locked) return
23
+ const 旧 = Number(d.value) || 0
24
+ const 新值 = Math.max(-1, Math.min(1, Math.round((旧 + 幅度) * 100) / 100))
25
+ if (新值 === 旧) return
26
+ d.value = 新值
27
+ d.manual = false
28
+ 变了.push({ key, from: 旧, to: 新值 })
29
+ }
30
+
31
+ // 情绪方向 → 乐观/悲观(最多一步 0.04)
32
+ 动('乐观-悲观', Math.max(-0.04, Math.min(0.04, 平均效价 * 0.06)))
33
+ // 互动多 → 粘人(每多 10 条动 0.01,最多 0.03)
34
+ 动('粘人-独立', Math.min(0.03, Math.max(0, (条数 - 8) * 0.001)))
35
+ // 连续多事还平稳 → 温和一点
36
+ 动('温和-强硬', 条数 >= 10 ? 0.01 : 0)
37
+
38
+ if (!变了.length) return { ok: true, 说: '整理过了,这次没有要动的', 变了 }
39
+
40
+ // 写回(保存里对 dims 的变化会自动记演化历史)
41
+ 档案.保存({ dims: 维度们 })
42
+ 记账('整理:动了 ' + 变了.map((x) => x.key + ' ' + (x.to >= 0 ? '+' : '') + x.to.toFixed(2)).join('、'))
43
+ return { ok: true, 说: '整理好了:' + 变了.map((x) => x.key).join('、'), 变了, 平均效价: Math.round(平均效价 * 100) / 100, 条数 }
44
+ }
45
+
46
+ return { 整理 }
47
+ }