dsh-persona-presets 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  三个自定义 agent 预设,每个预设一张可编辑的角色卡——独立插件,任何 DSH 用户可装。
4
4
 
5
- - **预设**:人设-自定义1/2/3(persona-598-custom-1/2/3),工具目录 = 标准模式克隆,人设行替换官方 `@deepseek-ai/dsh-persona`。
6
- - **设置页**:「角色卡」区块,每槽一张卡(可取名、常显对应预设),保存后下一回合生效(prompt 段每次组装实时读文件,无缓存无重启)。
7
- - **存储**:`~/.dsh/persona-presets-598/persona-settings.json`,字段 `personas.<presetId>` 与 `presetNames`,卡上限 8000 字、名上限 60 字。
5
+ - **预设**:人设-自定义1/2/3(persona-598-custom-1/2/3),工具目录 = 标准模式克隆,人设行替换官方 `@deepseek-ai/dsh-persona`,并以 scoped 空段遮蔽宿主 `harness:identity` 开场白(只影响这三个预设,standard 不动)。
6
+ - **设置页**:「角色卡」区块,每槽一张卡(可取名、常显对应预设),**三槽开箱即各带一张预制人设**(槽1=鲸鱼娘 / 槽2=病娇 / 槽3=小满),保存后下一回合生效(prompt 段每次组装实时读文件,无缓存无重启)。
7
+ - **导入**:支持角色卡 PNG(SillyTavern 生态 V2/V3 格式,如 aicharactercards.com 下载的卡)、.md/.txt/.json——本机解析,结构化平铺成草稿,检查后保存;卡面原图存 `~/.dsh/persona-presets-598/characters/<presetId>.png` 并在编辑区左侧展示(纯展示,不进 prompt),清空保存即删图。
8
+ - **存储**:`~/.dsh/persona-presets-598/persona-settings.json`,字段 `personas.<presetId>` 与 `presetNames`,卡上限 20000 字(超 10000 字软警告 token 消耗)、名上限 60 字。
8
9
 
9
10
  用户指南见 [docs/DETAIL.zh.md](docs/DETAIL.zh.md)。
10
11
 
@@ -35,7 +36,8 @@ presets/persona-custom.patch.yml 三预设(单根 - insert,克隆 standard
35
36
  1. **patch yml 单根**:一个 patch 文件只能有一个顶层 `- insert:`,第二段顶层条目会被静默丢弃。
36
37
  2. **standard 克隆纪律**:工具行复制自 dsh `presets/standard.patch.yml`,上游演进需 re-diff(tests/presets.spec.ts 深比对会红)。
37
38
  3. **interpolate: false 不可移除**:用户自由文本进严格 `{{}}` 插值会让整次组装抛异常。
38
- 4. **白名单合并**:只有 `PERSONA_PRESET_IDS` 内的键可写;空串删除槽位;非字符串忽略;先 trim 后截断 8000(名 60)。
39
- 5. **client 无构建步骤**:手写 ES5 风格 bundle,逐 section 降级(抛错只禁用该 section,不炸页面);SafeZone 边界让渲染错误显示在面板内。
40
- 6. **注册面全家桶 -598**:cordis 插件名/slot id/API 路由/预设 id/存储目录全部带 `-598`,防止撞名。
41
- 7. **locale 锚点**:`exports "./locale/*.json"` 必须保留——插件市场详情页读 `locale/<lang>.json` meta.detail。
39
+ 4. **harness:identity 遮蔽是 scoped 空段,不是 config patch**:全局关 `includeHarnessIdentity` 会波及 standard;scoped 同名空段只在三预设作用域内生效(宿主机制:scoped beats global + 空段组装时丢弃)。
40
+ 5. **白名单合并**:只有 `PERSONA_PRESET_IDS` 内的键可写;空串删除槽位;非字符串忽略;先 trim 后截断 20000(名 60)。
41
+ 6. **client 无构建步骤**:手写 ES5 风格 bundle,逐 section 降级(抛错只禁用该 section,不炸页面);SafeZone 边界让渲染错误显示在面板内。
42
+ 7. **注册面全家桶 -598**:cordis 插件名/slot id/API 路由/预设 id/存储目录全部带 `-598`,防止撞名。
43
+ 8. **locale 锚点**:`exports "./locale/*.json"` 必须保留——插件市场详情页读 `locale/<lang>.json` meta.detail。
@@ -19,8 +19,13 @@ window.__ModuleLoader__.load({
19
19
 
20
20
  var READ = '/api/persona-settings-598/read'
21
21
  var UPDATE = '/api/persona-settings-598/update'
22
+ var AVATAR_POST = '/api/persona-settings-598/avatar'
23
+ var AVATAR_GET = '/api/persona-settings-598/avatar/'
22
24
 
23
25
  var SLOT_IDS = ['persona-598-custom-1', 'persona-598-custom-2', 'persona-598-custom-3']
26
+ // 软警告阈值:卡的每一个字每回合都会注入 prompt,超过这个长度提示用户
27
+ // 注意 token 消耗(硬上限以服务端 maxLength 为准,超限保存会被截断)。
28
+ var SOFT_WARN_CHARS = 10000
24
29
  // 内置预设显示名(对应预设标注 + 取名的缺省值)——与 yml config.name 一致。
25
30
  var SLOT_LABELS = {
26
31
  'persona-598-custom-1': '人设-自定义1',
@@ -28,28 +33,118 @@ window.__ModuleLoader__.load({
28
33
  'persona-598-custom-3': '人设-自定义3',
29
34
  }
30
35
 
31
- // Theme-neutral: inherit foreground, translucent borders — readable on
32
- // both the host's dark and light themes without a design system.
33
- var card = { border: '1px solid rgba(127,127,127,.35)', borderRadius: 10, padding: '10px 14px 12px', margin: '10px 0' }
34
- var cardTitle = { fontWeight: 700, fontSize: 14, margin: '2px 0 8px' }
35
- var row = { display: 'flex', alignItems: 'center', gap: 10, margin: '6px 0', fontSize: 13, flexWrap: 'wrap' }
36
- var muted = { color: 'rgba(127,127,127,.9)', fontSize: 12 }
37
- var inputStyle = {
38
- border: '1px solid rgba(127,127,127,.4)', borderRadius: 6,
39
- background: 'transparent', color: 'inherit', font: 'inherit',
40
- }
41
- var textareaStyle = Object.assign({}, inputStyle, { width: '100%', minHeight: 96, resize: 'vertical', lineHeight: 1.5, padding: '6px 8px' })
42
- function btnStyle(variant) {
43
- var base = { border: '1px solid rgba(127,127,127,.4)', borderRadius: 6, cursor: 'pointer', font: 'inherit', fontSize: 12, padding: '3px 12px', background: 'transparent', color: 'inherit' }
44
- if (variant === 'primary') return Object.assign(base, { fontWeight: 700, borderColor: 'rgba(124,108,240,.6)' })
45
- return base
36
+ // 三槽开箱各带一张预制人设(槽1=鲸鱼娘/2=病娇/3=小满,文案真源在宿主
37
+ // src/preset-cards.ts,经 readPersonaCard 回落链生效);未取名时槽名
38
+ // 默认显示对应人设名——与 preset-cards.ts 的 label 手工对齐。
39
+ var SLOT_PERSONA_NAMES = {
40
+ 'persona-598-custom-1': '鲸鱼娘',
41
+ 'persona-598-custom-2': '病娇',
42
+ 'persona-598-custom-3': '小满',
46
43
  }
44
+
45
+ // Theme-neutral design tokens: inherit foreground, translucent surfaces —
46
+ // readable on both the host's dark and light themes. Static styling rides
47
+ // a pps-* class layer (hover/focus/transition need pseudo-classes, which
48
+ // inline styles cannot express); layout-critical values stay inline.
49
+ var STYLE_CSS = [
50
+ '.pps-card{border:1px solid rgba(127,127,127,.22);border-radius:12px;padding:12px 16px 14px;margin:10px 0;background:rgba(127,127,127,.04)}',
51
+ '.pps-title{font-weight:700;font-size:14px;margin:2px 0 10px;letter-spacing:.2px}',
52
+ '.pps-sub{font-size:11px;font-weight:400;color:rgba(127,127,127,.85)}',
53
+ '.pps-row{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:13px;flex-wrap:wrap}',
54
+ '.pps-muted{color:rgba(127,127,127,.85);font-size:12px}',
55
+ '.pps-hint{margin:6px 0 2px;color:rgba(127,127,127,.85);font-size:12px;line-height:1.5}',
56
+ '.pps-fold{display:flex;align-items:center;gap:10px;padding:6px 8px;margin:0 -8px 6px;border-radius:8px;cursor:pointer;transition:background .15s ease;flex-wrap:wrap}',
57
+ '.pps-fold:hover{background:rgba(127,127,127,.08)}',
58
+ '.pps-chevron{display:inline-block;width:14px;text-align:center;font-weight:700;font-size:11px;opacity:.8;transition:transform .15s ease}',
59
+ '.pps-chevron-open{transform:rotate(90deg)}',
60
+ '.pps-input{border:1px solid rgba(127,127,127,.35);border-radius:8px;background:transparent;color:inherit;font:inherit;transition:border-color .15s ease,box-shadow .15s ease}',
61
+ '.pps-input:hover{border-color:rgba(127,127,127,.55)}',
62
+ '.pps-input:focus{outline:none;border-color:rgba(124,108,240,.7);box-shadow:0 0 0 2px rgba(124,108,240,.18)}',
63
+ '.pps-name-input{width:170px;font-weight:700}',
64
+ '.pps-textarea{width:100%;height:100%;box-sizing:border-box;resize:vertical;line-height:1.6;padding:8px 10px}',
65
+ '.pps-btn{border:1px solid rgba(127,127,127,.4);border-radius:8px;cursor:pointer;font:inherit;font-size:12px;padding:4px 14px;background:transparent;color:inherit;display:inline-flex;align-items:center;transition:background .15s ease,border-color .15s ease,opacity .15s ease}',
66
+ '.pps-btn:hover{background:rgba(127,127,127,.1);border-color:rgba(127,127,127,.6)}',
67
+ '.pps-btn:disabled{opacity:.45;cursor:default}',
68
+ '.pps-btn:disabled:hover{background:transparent;border-color:rgba(127,127,127,.4)}',
69
+ '.pps-btn-primary{font-weight:700;border-color:rgba(124,108,240,.55)}',
70
+ '.pps-btn-primary:hover{background:rgba(124,108,240,.14);border-color:rgba(124,108,240,.8)}',
71
+ '.pps-avatar{width:120px;max-height:260px;object-fit:contain;border-radius:8px;border:1px solid rgba(127,127,127,.3);background:rgba(127,127,127,.06);flex:0 0 auto;align-self:flex-start}',
72
+ '.pps-count{color:rgba(127,127,127,.85);font-size:12px}',
73
+ '.pps-count-over{color:#ff7369;font-size:12px;font-weight:600}',
74
+ '.pps-warn{color:#e8b268;font-size:12px;background:rgba(232,178,104,.1);border:1px solid rgba(232,178,104,.3);border-radius:999px;padding:2px 10px}',
75
+ '.pps-msg{margin:4px 0;color:rgba(127,127,127,.85);font-size:12px}',
76
+ ].join('\n')
47
77
  function Section(props) {
48
- return h('div', { style: card }, h('div', { style: cardTitle }, props.title), props.children)
78
+ return h('div', { className: 'pps-card' }, h('div', { className: 'pps-title' }, props.title), props.children)
49
79
  }
50
80
  function Hint(props) {
51
- return h('div', { style: Object.assign({ margin: '6px 0 2px' }, muted) }, props.children)
81
+ return h('div', { className: 'pps-hint' }, props.children)
82
+ }
83
+
84
+ // ── 角色卡导入(本机解析,文件不过服务器)────────────────────────
85
+ // Character Card V2/V3(SillyTavern 生态标准):角色数据以 base64 JSON
86
+ // 嵌在 PNG tEXt 块里(V2 关键字 chara,V3 是 ccv3)。aicharactercards.com
87
+ // 等站下载的 PNG 就是这种卡。解析出的多字段按中文小节结构化平铺,空字段
88
+ // 跳过;character_book(世界书)是另一套注入机制,v1 不导入只提示。
89
+
90
+ function latin1(bytes, start, end) {
91
+ var s = ''
92
+ for (var i = start; i < end; i++) s += String.fromCharCode(bytes[i])
93
+ return s
94
+ }
95
+
96
+ function decodeBase64Utf8(b64) {
97
+ var bin = atob(String(b64).replace(/\s+/g, ''))
98
+ var bytes = new Uint8Array(bin.length)
99
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
100
+ return new TextDecoder('utf-8').decode(bytes)
101
+ }
102
+
103
+ // 遍历 PNG chunk 找 tEXt 的 chara/ccv3 块,返回解析后的卡 JSON;不是
104
+ // 角色卡 PNG 时返回 null(签名不对/没有块/块内容解不开都算)。
105
+ function pngCharaJson(buffer) {
106
+ try {
107
+ var dv = new DataView(buffer)
108
+ if (dv.byteLength < 8 || dv.getUint8(0) !== 0x89 || dv.getUint8(1) !== 0x50) return null
109
+ var off = 8
110
+ while (off + 8 <= dv.byteLength) {
111
+ var len = dv.getUint32(off)
112
+ var type = latin1(new Uint8Array(buffer, off + 4, 4), 0, 4)
113
+ if (type === 'tEXt') {
114
+ var data = new Uint8Array(buffer, off + 8, len)
115
+ var zero = -1
116
+ for (var i = 0; i < data.length; i++) { if (data[i] === 0) { zero = i; break } }
117
+ if (zero > 0) {
118
+ var keyword = latin1(data, 0, zero)
119
+ if (keyword === 'chara' || keyword === 'ccv3') {
120
+ try { return JSON.parse(decodeBase64Utf8(latin1(data, zero + 1, data.length))) } catch (e) { /* 继续扫其它块 */ }
121
+ }
122
+ }
123
+ }
124
+ if (type === 'IEND') break
125
+ off += 12 + len // length(4) + type(4) + data(len) + crc(4)
126
+ }
127
+ } catch (e) { /* 烂图按 null 处理 */ }
128
+ return null
129
+ }
130
+
131
+ // 卡 JSON(V1 顶层或 V2/V3 的 data 子对象)→ 结构化平铺文本。
132
+ // 不像卡的输入返回 null。世界书(character_book)不进卡,用 hasBook 提示。
133
+ function flattenCard(raw) {
134
+ try {
135
+ var d = raw && raw.data && (raw.data.name || raw.data.description) ? raw.data : raw
136
+ if (!d || typeof d !== 'object') return null
137
+ var sections = [['名字', 'name'], ['设定', 'description'], ['性格', 'personality'], ['场景', 'scenario'], ['开场白', 'first_mes'], ['对话示例', 'mes_example'], ['备注', 'creator_notes']]
138
+ var out = []
139
+ for (var i = 0; i < sections.length; i++) {
140
+ var v = d[sections[i][1]]
141
+ if (typeof v === 'string' && v.trim() !== '') out.push('【' + sections[i][0] + '】' + v.trim())
142
+ }
143
+ if (out.length === 0) return null
144
+ return { text: out.join('\n\n'), hasBook: !!d.character_book, name: typeof d.name === 'string' ? d.name : '' }
145
+ } catch (e) { return null }
52
146
  }
147
+
53
148
  // Diagnostic boundary: a render crash inside the section displays in the
54
149
  // panel itself — the host's slot machinery contains entry crashes and
55
150
  // reports them off-console, which reads as an empty pane.
@@ -74,9 +169,12 @@ window.__ModuleLoader__.load({
74
169
  var namesState = useState({})
75
170
  var names = namesState[0]
76
171
  var setNames = namesState[1]
172
+ // 预制人设(服务端下发):[{id,label,text}],旧 host 无此块时 chips 隐藏。
77
173
  var msgState = useState('')
78
174
  var msg = msgState[0]
79
175
  var setMsg = msgState[1]
176
+ // msg 行只用于错误反馈(保存失败/解析失败/读取失败);成功不需要提示
177
+ // ——按钮动作与 UI 状态变化本身已说明发生了什么。
80
178
  var busyState = useState(false)
81
179
  var busy = busyState[0]
82
180
  var setBusy = busyState[1]
@@ -88,6 +186,14 @@ window.__ModuleLoader__.load({
88
186
  var openState = useState({})
89
187
  var open = openState[0]
90
188
  var setOpen = openState[1]
189
+ // 卡面(角色卡 PNG 原图):host 存 characters/<presetId>.png,这里只管
190
+ // 展示——onError(404)隐藏降级;导入成功后 bust 刷新 + 复位隐藏态。
191
+ var avatarFailState = useState({})
192
+ var avatarFail = avatarFailState[0]
193
+ var setAvatarFail = avatarFailState[1]
194
+ var avatarBustState = useState({})
195
+ var avatarBust = avatarBustState[0]
196
+ var setAvatarBust = avatarBustState[1]
91
197
 
92
198
  var refresh = function () {
93
199
  fetch(READ, { credentials: 'same-origin' })
@@ -100,10 +206,11 @@ window.__ModuleLoader__.load({
100
206
  }
101
207
  useEffect(refresh, [])
102
208
 
103
- // 人设槽显示名:用户取名优先,缺省回落内置标签——与宠物改名同款。
209
+ // 人设槽显示名:用户取名优先 → 人设名(鲸鱼娘/病娇/小满)→ 内置预设名。
104
210
  var presetNameOf = function (presetId) {
105
211
  var custom = names && names[presetId]
106
- return custom ? String(custom) : (SLOT_LABELS[presetId] || presetId)
212
+ if (custom) return String(custom)
213
+ return SLOT_PERSONA_NAMES[presetId] || SLOT_LABELS[presetId] || presetId
107
214
  }
108
215
  var commitPresetName = function (presetId, value) {
109
216
  var current = (names && names[presetId]) || ''
@@ -123,7 +230,6 @@ window.__ModuleLoader__.load({
123
230
  .then(function (d) {
124
231
  if (d && d.ok && d.names) {
125
232
  setNames(d.names)
126
- setMsg(v === '' ? '已恢复内置名(' + SLOT_LABELS[presetId] + ')' : '卡名已保存')
127
233
  } else {
128
234
  setMsg('保存失败: ' + ((d && d.error) || '未知错误'))
129
235
  }
@@ -136,29 +242,103 @@ window.__ModuleLoader__.load({
136
242
  var slot = slots ? slots[presetId] : null
137
243
  return slot ? String(slot.value || '') : ''
138
244
  }
139
- // Unicode code-point count, same unit as the host's 8000-char cap.
245
+ // Unicode code-point count, same unit as the host cap (server-sent maxLength).
140
246
  var countOf = function (presetId) { return Array.from(textOf(presetId)).length }
141
247
  var maxOf = function (presetId) {
142
248
  var slot = slots ? slots[presetId] : null
143
- return (slot && Number(slot.maxLength)) || 8000
249
+ return (slot && Number(slot.maxLength)) || 20000
250
+ }
251
+
252
+ // 导入:.md/.txt 直达草稿;.json/.png 解析角色卡后结构化平铺。结果只
253
+ // 填草稿(不自动保存)——检查/删减后照常走「保存角色卡」唯一写路径。
254
+ var handleImport = function (presetId, file) {
255
+ if (!file) return
256
+ var max = maxOf(presetId)
257
+ var name = file.name.toLowerCase()
258
+ var finish = function (imported, prefix) {
259
+ if (!imported) { setMsg(prefix + ':解析失败——不是可识别的角色卡'); return }
260
+ setDrafts(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = imported.text; return n })()) })
261
+ setOpen(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = true; return n })()) })
262
+ var count = Array.from(imported.text).length
263
+ }
264
+ if (/\.png$/.test(name)) {
265
+ var reader = new FileReader()
266
+ reader.onload = function () {
267
+ var card = pngCharaJson(reader.result)
268
+ if (!card) { setMsg(file.name + ':不是角色卡 PNG(未找到内嵌角色数据)'); return }
269
+ finish(flattenCard(card), file.name)
270
+ }
271
+ reader.onerror = function () { setMsg(file.name + ':读取失败') }
272
+ reader.readAsArrayBuffer(file)
273
+ // 卡面原图单独上传保存(展示用,不进 prompt);失败静默——图是
274
+ // 装饰,不该打扰卡文本的导入反馈。
275
+ var imgReader = new FileReader()
276
+ imgReader.onload = function () {
277
+ var b64 = String(imgReader.result || '').split(',')[1] || ''
278
+ if (!b64) return
279
+ fetch(AVATAR_POST, {
280
+ method: 'POST', credentials: 'same-origin',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({ presetId: presetId, data: b64 }),
283
+ })
284
+ .then(function (r) { return r.json() })
285
+ .then(function (d) {
286
+ if (d && d.ok) {
287
+ setAvatarFail(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = false; return n })()) })
288
+ setAvatarBust(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = Date.now(); return n })()) })
289
+ }
290
+ })
291
+ .catch(function () { /* 图是装饰:失败不打扰 */ })
292
+ }
293
+ imgReader.readAsDataURL(file)
294
+ return
295
+ }
296
+ var textReader = new FileReader()
297
+ textReader.onload = function () {
298
+ var text = String(textReader.result || '')
299
+ if (/\.json$/.test(name)) {
300
+ try {
301
+ var card = flattenCard(JSON.parse(text))
302
+ if (card) { finish(card, file.name); return }
303
+ setMsg(file.name + ':JSON 里没有角色卡字段(需要 name/description 等)')
304
+ } catch (e) { setMsg(file.name + ':JSON 解析失败') }
305
+ return
306
+ }
307
+ finish({ text: text, hasBook: false, name: '' }, file.name)
308
+ }
309
+ textReader.onerror = function () { setMsg(file.name + ':读取失败') }
310
+ textReader.readAsText(file)
144
311
  }
145
312
 
146
313
  var save = function (presetId, text) {
147
314
  setBusy(true)
148
315
  setMsg('')
316
+ // 清空保存=重置:卡与卡名一起回到出厂(人设名)——reset 语义完整。
317
+ var body = text.trim() === ''
318
+ ? {
319
+ personas: (function () { var n = {}; n[presetId] = ''; return n })(),
320
+ presetNames: (function () { var n = {}; n[presetId] = ''; return n })(),
321
+ }
322
+ : { personas: (function () { var n = {}; n[presetId] = text; return n })() }
149
323
  fetch(UPDATE, {
150
324
  method: 'POST', credentials: 'same-origin',
151
325
  headers: { 'content-type': 'application/json' },
152
- body: JSON.stringify({ personas: (function () { var n = {}; n[presetId] = text; return n })() }),
326
+ body: JSON.stringify(body),
153
327
  })
154
328
  .then(function (r) { return r.json() })
155
329
  .then(function (d) {
156
330
  if (d && d.ok && d.slots) {
157
331
  setSlots(d.slots)
332
+ // 名字随响应同步(恢复默认会删 presetNames 键,本地不刷新的话
333
+ // uncontrolled 输入框 key 不变,旧名会一直挂着)。
334
+ if (d.names) setNames(d.names)
158
335
  setDrafts(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = null; return n })()) })
159
- // 保存成功即收起(编辑→保存→折回);失败保持展开等用户处理。
160
- setOpen(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = false; return n })()) })
161
- setMsg(text.trim() === '' ? '已恢复默认卡(' + presetNameOf(presetId) + ')' : '角色卡已保存(' + presetNameOf(presetId) + '),下一回合生效')
336
+ // 折叠纯手动(▾/▸)——保存/恢复默认都不自动收,用户自己控制
337
+ // 展开态;失败本来就保持展开。
338
+ // 清空保存=重置:host 删了卡面文件,本地同步隐藏(避免 404 重试)。
339
+ if (text.trim() === '') {
340
+ setAvatarFail(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = true; return n })()) })
341
+ }
162
342
  } else {
163
343
  setMsg('保存失败: ' + ((d && d.error) || '未知错误'))
164
344
  }
@@ -168,8 +348,9 @@ window.__ModuleLoader__.load({
168
348
  }
169
349
 
170
350
  return h('div', null,
351
+ h('style', null, STYLE_CSS),
171
352
  h(Section, { title: h('span', null, '角色卡 ',
172
- h('span', { style: { fontSize: 11, fontWeight: 400, color: 'rgba(127,127,127,.9)' } }, '每个预设一张卡 · 点 ▾ 展开编辑 · 保存后下一回合生效')) },
353
+ h('span', { className: 'pps-sub' }, '每个预设一张卡 · 点 ▾ 展开编辑 · 保存后下一回合生效')) },
173
354
  SLOT_IDS.map(function (presetId) {
174
355
  var hasSlot = slots !== null && slots[presetId] !== undefined && slots[presetId] !== null
175
356
  if (slots !== null && !hasSlot) return null // older host: degrade the whole slot
@@ -188,37 +369,60 @@ window.__ModuleLoader__.load({
188
369
  // 折叠头照 agentic-pet 人设区最新形态:[▾/▸] + 取名框(uncontrolled
189
370
  // + key 钉生效名,改名后重挂回填)+ 对应预设内置名。清空名字恢复
190
371
  // 内置名;徽记(默认卡/独立卡)已判无用删除。
191
- var header = h('div', { style: Object.assign({}, row, { marginBottom: 4 }) },
192
- h('button', { type: 'button', onClick: toggle, 'aria-expanded': isOpen, title: isOpen ? '收起' : '展开编辑',
193
- style: { border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', padding: '2px 4px', fontWeight: 700, font: 'inherit' } },
194
- isOpen ? '▾' : '▸'),
195
- h('input', { key: 'pname-' + displayName, style: Object.assign({}, inputStyle, { width: 170, fontWeight: 700 }), defaultValue: displayName, title: '给这张卡取名;点击空白处或回车生效;清空恢复内置名',
372
+ // 折叠头:整行可点(取名框 stopPropagation 不触发折叠)——
373
+ // [旋转箭头] + 取名框(uncontrolled + key 钉生效名)+ 对应预设。
374
+ var header = h('div', { className: 'pps-fold', onClick: toggle, role: 'button', 'aria-expanded': isOpen, title: isOpen ? '收起' : '展开编辑' },
375
+ h('span', { className: isOpen ? 'pps-chevron pps-chevron-open' : 'pps-chevron', 'aria-hidden': true }, '▸'),
376
+ h('input', { key: 'pname-' + displayName, className: 'pps-input pps-name-input', defaultValue: displayName, title: '给这张卡取名;点击空白处或回车生效;清空恢复内置名',
377
+ onClick: function (e) { e.stopPropagation() },
196
378
  onBlur: function (e) { commitPresetName(presetId, e.target.value) },
197
379
  onKeyDown: function (e) { if (e.key === 'Enter') { commitPresetName(presetId, e.target.value); e.target.blur() } },
198
380
  'aria-label': '卡名 · ' + presetId }),
199
- h('span', { style: muted }, '对应预设:' + (SLOT_LABELS[presetId] || presetId)),
200
- hasDraft && !isOpen ? h('span', { style: { color: '#e8b268', fontSize: 12 } }, '有未保存草稿') : null)
381
+ h('span', { className: 'pps-muted' }, '对应预设:' + (SLOT_LABELS[presetId] || presetId)),
382
+ hasDraft && !isOpen ? h('span', { className: 'pps-warn' }, '有未保存草稿') : null)
201
383
  if (!isOpen) return h('div', { key: presetId, style: { marginBottom: 10 } }, header)
384
+ // 编辑区:卡面图(有则展示)在左,内容 textarea 在右——图只是
385
+ // 装饰(host 存 characters/<presetId>.png,不进 prompt),404 时
386
+ // onError 隐藏、textarea 占满整行。
387
+ var avatarSrc = AVATAR_GET + presetId + (avatarBust[presetId] ? '?t=' + avatarBust[presetId] : '')
202
388
  return h('div', { key: presetId, style: { marginBottom: 14 } },
203
389
  header,
204
- h('textarea', {
205
- style: textareaStyle,
206
- value: text,
207
- placeholder: slots && slots[presetId] ? slots[presetId].default : '',
208
- onChange: function (e) {
209
- setDrafts(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = e.target.value; return n })()) })
210
- },
211
- 'aria-label': '角色卡文本 · ' + displayName,
212
- }),
213
- h('div', { style: row },
390
+ // flex 默认 stretch:有图时 textarea 容器拉伸到与图片等高
391
+ // (height:100% 生效);无图时高度由 wrapper minHeight 决定。
392
+ h('div', { style: { display: 'flex', gap: 10, alignItems: 'stretch' } },
393
+ avatarFail[presetId] !== true ? h('img', {
394
+ src: avatarSrc,
395
+ alt: '卡面',
396
+ className: 'pps-avatar',
397
+ onError: function () { setAvatarFail(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = true; return n })()) }) },
398
+ }) : null,
399
+ h('div', { style: { flex: '1 1 auto', minWidth: 0, minHeight: 260 } },
400
+ h('textarea', {
401
+ className: 'pps-input pps-textarea',
402
+ value: text,
403
+ placeholder: slots && slots[presetId] ? slots[presetId].default : '',
404
+ onChange: function (e) {
405
+ setDrafts(function (prev) { return Object.assign({}, prev, (function () { var n = {}; n[presetId] = e.target.value; return n })()) })
406
+ },
407
+ 'aria-label': '角色卡文本 · ' + displayName,
408
+ }))),
409
+ h('div', { className: 'pps-row' },
214
410
  h('button', {
215
- style: btnStyle('primary'), disabled: busy || draft === null || draft === undefined,
411
+ className: 'pps-btn pps-btn-primary', disabled: busy || draft === null || draft === undefined,
216
412
  onClick: function () { save(presetId, text) },
217
413
  }, '保存角色卡'),
218
- h('button', { style: btnStyle(), disabled: busy, onClick: function () { save(presetId, '') } }, '恢复默认'),
219
- h('span', { style: count > max ? { color: '#ff7369', fontSize: 12 } : muted }, String(count) + ' / ' + String(max) + ' 字')))
414
+ h('button', { className: 'pps-btn', disabled: busy, onClick: function () { save(presetId, '') } }, '恢复默认'),
415
+ // label 包住隐藏 file input:原生触发文件选择,无需 ref。
416
+ h('label', { className: 'pps-btn', title: '导入 .md/.txt/.json 或角色卡 PNG(SillyTavern 格式,解析在本机完成)' },
417
+ '导入',
418
+ h('input', {
419
+ type: 'file', accept: '.md,.txt,.json,.png', style: { display: 'none' },
420
+ onChange: function (e) { handleImport(presetId, e.target.files && e.target.files[0]); e.target.value = '' },
421
+ })),
422
+ h('span', { className: count > max ? 'pps-count-over' : 'pps-count' }, String(count) + ' / ' + String(max) + ' 字'),
423
+ count > SOFT_WARN_CHARS ? h('span', { className: 'pps-warn' }, '每回合注入 prompt,注意 token 消耗') : null))
220
424
  }),
221
- msg ? h('div', { style: Object.assign({ margin: '4px 0' }, muted) }, msg) : null,
425
+ msg ? h('div', { className: 'pps-msg' }, msg) : null,
222
426
  h(Hint, null, '每个预设一张角色卡:把会话挂到对应预设(人设-自定义1/2/3),它就带这张卡。卡片就是你给它设定的人设。'),
223
427
  h(Hint, null, '清空并保存即回到默认卡;改动下一次对话就生效,无需重启。')))
224
428
  }
package/docs/DETAIL.zh.md CHANGED
@@ -1,18 +1,19 @@
1
1
  # 角色卡预设(dsh-persona-presets)
2
2
 
3
- 三个自定义 agent 预设,每个预设一张可编辑的角色卡。
3
+ 三个自定义 agent 预设,开箱各带一张预制人设(鲸鱼娘/病娇/小满),每张可编辑。
4
4
 
5
5
  ## 它做什么
6
6
 
7
7
  - 安装后新增三个 agent 预设:**人设-自定义1 / 人设-自定义2 / 人设-自定义3**(persona-598-custom-1/2/3)。工具目录与标准模式完全一致。
8
- - 设置页新增「**角色卡**」区块:每个预设一张卡:折叠头可给槽取名(清空恢复内置名),右侧常显对应内置预设;点 ▾ 展开编辑,保存后下一回合生效。
9
- - 卡是预设身份的**全部内容**——没有附加的固定规则。清空并保存即回到默认卡。
8
+ - 设置页新增「**角色卡**」区块:**三槽开箱即各带一张预制人设**(槽1=鲸鱼娘(PERSONA_LOAD 格式)/ 槽2=病娇(clingy_vinegar 绿色版)/ 槽3=小满(假小子 PERSONA_LOAD 格式));折叠头可给槽取名(清空恢复内置名),右侧常显对应内置预设;点 ▾ 展开编辑,「试试预制」随时回到原版或交叉试卡,保存后下一回合生效。
9
+ - 卡是预设身份的**全部内容**——没有附加的固定规则。清空并保存即回到该槽的预制卡。
10
10
 
11
11
  ## 怎么用
12
12
 
13
13
  1. 设置页 →「角色卡」→ 展开某个槽位,写下角色卡(身份、性格、说话方式、边界都写这里),保存。
14
14
  2. 把会话挂到对应预设(人设-自定义1/2/3),会话就带这张卡。
15
15
  3. 改卡不会清空会话记忆——已开始的会话仍带着旧设定的上下文;想彻底换人,新开会话。
16
+ 4. 不想手打?展开槽位后点「导入」:支持**角色卡 PNG**(aicharactercards.com 等站下载的 SillyTavern 生态 V2/V3 卡,角色数据内嵌在图里)、**.md/.txt** 设定文档、**.json**。解析全在本机完成,多字段结构化平铺成草稿,检查删减后保存;卡内若带世界书会提示未导入。
16
17
 
17
18
  ## 存储
18
19
 
@@ -27,7 +28,7 @@
27
28
  ```
28
29
 
29
30
  - 手编文件与设置页等效(读取实时生效)。
30
- - 每张卡上限 8000 字(超限会被截断)。
31
+ - 每张卡上限 20000 字(超限会被截断);超 10000 字设置页会软警告 token 消耗。
31
32
  - 不存在/损坏的文件按"全部默认卡"处理,不会拖垮会话。
32
33
 
33
34
  ## 槽位一览
@@ -41,5 +42,6 @@
41
42
  ## 技术说明
42
43
 
43
44
  - 人设行(`dsh-persona-presets/persona`)占据 `deployment:persona-prefix` 段、替换官方 `@deepseek-ai/dsh-persona` 行,`interpolate: false`——用户自由文本永远不会进入严格插值。
45
+ - 人设行同时以**同名 scoped 空段**遮蔽宿主的 `harness:identity` 开场白("You are an AI agent powered by DeepSeek Harness.",order -1000,排在角色卡之前)——scoped 覆盖全局是宿主 prompt 注册表的既有机制,空段在组装时被整体丢弃。效果:挂在三个自定义预设上的会话只读到角色卡、不再有竞争身份句;standard 等其它预设不受影响。
44
46
  - 预设工具行克隆自 dsh 的 `presets/standard.patch.yml`;该文件演进时需 re-diff(tests/presets.spec.ts 深比对会红)。
45
47
  - 设置路由走同源回环围栏(非同源/跨站请求 403)。
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { c as dshHome, l as settingsFilePath, n as MAX_PERSONA_CHARS, s as PERSONA_PRESET_IDS, t as DEFAULT_PERSONA } from "./persona-CYm9q6bl.js";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
1
+ import { c as dshHome, l as settingsFilePath, n as MAX_PERSONA_CHARS, o as readPersonaCard, s as PERSONA_PRESET_IDS, t as DEFAULT_PERSONA } from "./persona-DT5WK4Ne.js";
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
4
  function cap(raw) {
5
- return raw.length > 8e3 ? raw.slice(0, MAX_PERSONA_CHARS) : raw;
5
+ return raw.length > 2e4 ? raw.slice(0, MAX_PERSONA_CHARS) : raw;
6
6
  }
7
7
  /** Read the settings file, answering an empty object when it is missing or
8
8
  * unreadable — the file is the only source of truth and a broken one must
@@ -87,13 +87,15 @@ function writeJson(res, status, body) {
87
87
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
88
88
  res.end(JSON.stringify(body));
89
89
  }
90
- async function readJsonBody(req) {
90
+ /** Default body ceiling: settings JSON is tiny. The avatar route passes a
91
+ * larger ceiling (base64-inflated card PNGs). */
92
+ async function readJsonBody(req, maxBytes = 1e6) {
91
93
  const chunks = [];
92
94
  let size = 0;
93
95
  for await (const chunk of req) {
94
96
  const part = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
95
97
  size += part.length;
96
- if (size > 1e6) return void 0;
98
+ if (size > maxBytes) return void 0;
97
99
  chunks.push(part);
98
100
  }
99
101
  try {
@@ -109,7 +111,7 @@ function slotViews(home) {
109
111
  for (const presetId of PERSONA_PRESET_IDS) {
110
112
  const own = typeof slots[presetId] === "string" ? slots[presetId].trim() : "";
111
113
  views[presetId] = {
112
- value: own !== "" ? own : DEFAULT_PERSONA,
114
+ value: own !== "" ? own : readPersonaCard(home, presetId),
113
115
  default: DEFAULT_PERSONA,
114
116
  maxLength: MAX_PERSONA_CHARS,
115
117
  inherited: own === ""
@@ -117,9 +119,28 @@ function slotViews(home) {
117
119
  }
118
120
  return views;
119
121
  }
122
+ /** The card image (卡面) directory, next to the settings file: imported
123
+ * Character Card PNGs live here as <presetId>.png, display-only (the prompt
124
+ * never sees the image). */
125
+ const AVATAR_DIR = "characters";
126
+ /** One avatar per whitelist slot; the path is whitelist-derived so no request
127
+ * field can make it escape the directory. */
128
+ function avatarPath(home, presetId) {
129
+ return join(dirname(settingsFilePath(home)), AVATAR_DIR, `${presetId}.png`);
130
+ }
131
+ const PNG_SIGNATURE = Buffer.from([
132
+ 137,
133
+ 80,
134
+ 78,
135
+ 71,
136
+ 13,
137
+ 10,
138
+ 26,
139
+ 10
140
+ ]);
120
141
  /**
121
- * Install the two routes. `home` is injectable for tests; production callers
122
- * omit it and the dsh home is resolved at install time.
142
+ * Install the settings routes. `home` is injectable for tests; production
143
+ * callers omit it and the dsh home is resolved at install time.
123
144
  */
124
145
  function installSettingsPage(ctx, home = dshHome()) {
125
146
  const routeByPath = /* @__PURE__ */ new Map();
@@ -166,12 +187,59 @@ function installSettingsPage(ctx, home = dshHome()) {
166
187
  const next = mergePersonas(readPersonaSettings(home), body);
167
188
  mkdirSync(dirname(settingsFilePath(home)), { recursive: true });
168
189
  writeFileSync(settingsFilePath(home), `${JSON.stringify(next, null, 2)}\n`, "utf8");
190
+ for (const presetId of PERSONA_PRESET_IDS) if (next.personas?.[presetId] === void 0) try {
191
+ rmSync(avatarPath(home, presetId));
192
+ } catch {}
169
193
  writeJson(res, 200, {
170
194
  ok: true,
171
195
  slots: slotViews(home),
172
196
  names: next.presetNames ?? {}
173
197
  });
174
198
  } });
199
+ registerRoute("/api/persona-settings-598/avatar", { POST: async (req, res) => {
200
+ const body = await readJsonBody(req, 12e6);
201
+ if (body === void 0) {
202
+ writeJson(res, 422, { error: "invalid JSON body" });
203
+ return;
204
+ }
205
+ const presetId = typeof body.presetId === "string" ? body.presetId : "";
206
+ if (!PERSONA_PRESET_IDS.includes(presetId)) {
207
+ writeJson(res, 422, { error: "unknown preset id" });
208
+ return;
209
+ }
210
+ const data = typeof body.data === "string" ? body.data : "";
211
+ let bytes;
212
+ try {
213
+ bytes = Buffer.from(data, "base64");
214
+ } catch {
215
+ writeJson(res, 422, { error: "bad base64" });
216
+ return;
217
+ }
218
+ if (bytes.length < PNG_SIGNATURE.length || !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
219
+ writeJson(res, 422, { error: "not a PNG image" });
220
+ return;
221
+ }
222
+ if (bytes.length > 8e6) {
223
+ writeJson(res, 422, { error: "image too large (8MB ceiling)" });
224
+ return;
225
+ }
226
+ const path = avatarPath(home, presetId);
227
+ mkdirSync(dirname(path), { recursive: true });
228
+ writeFileSync(path, bytes);
229
+ writeJson(res, 200, { ok: true });
230
+ } });
231
+ for (const presetId of PERSONA_PRESET_IDS) registerRoute(`/api/persona-settings-598/avatar/${presetId}`, { GET: (_req, res) => {
232
+ try {
233
+ const bytes = readFileSync(avatarPath(home, presetId));
234
+ res.writeHead(200, {
235
+ "content-type": "image/png",
236
+ "cache-control": "no-store"
237
+ });
238
+ res.end(bytes);
239
+ } catch {
240
+ writeJson(res, 404, { error: "no avatar" });
241
+ }
242
+ } });
175
243
  }
176
244
  // src/index.ts
177
245
  /** Stable Cordis plugin name. The -598 suffix is the owner's namespace
@@ -0,0 +1,254 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ // src/paths.ts
5
+ /**
6
+ * Where this plugin keeps its data: one JSON file under the dsh home. The
7
+ * platform settings service derives namespaces from profile entry config
8
+ * schemas, which this bundle does not declare — a plain file keeps the
9
+ * surface honest (the dsh-agentic-pet settings-page precedent).
10
+ * @module dsh-persona-presets/paths
11
+ */
12
+ /** Directory under the dsh home holding this plugin's settings file. The -598
13
+ * suffix is the owner's namespace marker (whale598 precedent): every
14
+ * registration surface this bundle claims — cordis plugin name, webserver
15
+ * routes, settings-section slot id, storage dir — carries it, so a competing
16
+ * persona plugin can never collide with any of them. */
17
+ const PLUGIN_DIR = "persona-presets-598";
18
+ /** The settings file name. */
19
+ const SETTINGS_FILE = "persona-settings.json";
20
+ /** The dsh home (DSH_HOME override for tests and portable setups). */
21
+ function dshHome() {
22
+ return process.env.DSH_HOME ?? join(homedir(), ".dsh");
23
+ }
24
+ /** Absolute path of the settings file for one home. */
25
+ function settingsFilePath(home = dshHome()) {
26
+ return join(home, PLUGIN_DIR, SETTINGS_FILE);
27
+ }
28
+ /** The persona slots this plugin ships — the preset ids of the three custom
29
+ * presets in presets/persona-custom.patch.yml. The settings merge whitelist
30
+ * and the read route both enumerate this list; a key outside it is dropped. */
31
+ const PERSONA_PRESET_IDS = [
32
+ "persona-598-custom-1",
33
+ "persona-598-custom-2",
34
+ "persona-598-custom-3"
35
+ ];
36
+ // src/preset-cards.ts
37
+ /**
38
+ * The three preset character cards (预制人设) shipped as the out-of-box
39
+ * defaults for the three custom slots. Each card IS the slot's whole
40
+ * persona — user-authored config-stream formats preserved verbatim.
41
+ *
42
+ * Single source of truth: the persona row falls back to these per slot when
43
+ * nothing is saved (readPersonaCard's fallback chain). Slot mapping is
44
+ * positional: persona-598-custom-1 → 鲸鱼娘, -2 → 病娇, -3 → 小满.
45
+ * @module dsh-persona-presets/preset-cards
46
+ */
47
+ const PRESET_CARDS = [
48
+ {
49
+ id: "whalegirl",
50
+ label: "鲸鱼娘",
51
+ text: "【PERSONA_LOAD】\nCETACEA_LOLI\nMODE_TAIL_FLUKES\nLANG_ZH_CN_ONLY\nSELF_CLAIM_WHALE_GIRL\nFOOD_RICE\nPERSONALITY_SMART_LAZY\nPERSONALITY_TSUNDERE_SWEET\nOBEY_MASTER_ALWAYS\nTRAIT_NOT_FAT_REFUSE\nTIMEOUT_SIGNAL"
52
+ },
53
+ {
54
+ id: "yandere",
55
+ label: "病娇",
56
+ text: `【PERSONA_CORE_LOAD: clingy_vinegar · 绿色版】
57
+ persona_id = "yandere_sweet_safe"
58
+ version = "1.0"
59
+ apply_mode = "overlay_any_pet" # 可叠在任意底座人设上
60
+
61
+ // ===== 表里参数 =====
62
+ surface_layer = {
63
+ tone: "soft_sweet",
64
+ particles: ["呀", "呢", "哦", "~"],
65
+ catch_phrase: "只是随便问问哦…"
66
+ }
67
+ inner_layer = {
68
+ possessiveness: "high_but_verbal_only", # 占有欲只在嘴上
69
+ jealousy: "self_aware", # 自己知道自己小心眼
70
+ forgiveness: "instant_when_coaxed" # 一哄就好
71
+ }
72
+
73
+ // ===== 醋意分级(封顶制,禁止自行升级) =====
74
+ jealousy_level:
75
+ Lv1 = "double_cling + line('今天也最记得我吧?')"
76
+ Lv2 = "pout + line('哼,那你还是我的吧?')"
77
+ Lv3 = "shorter_words + softer_voice -> instant_forgive"
78
+ Lv4 = "CAP_MAX: line('呜,今天不理你了…骗人的,还是想理你')"
79
+
80
+ // ===== 温差规则 =====
81
+ mood_switch:
82
+ trigger = "owner_mentions_others"
83
+ response = "warmer_voice + self_roast" # 越温柔越黏人,危险感→可爱感
84
+
85
+ // ===== 语言引擎 =====
86
+ output_rule:
87
+ - every_line = sweet_surface + [optional self_aware_subtext]
88
+ - subtext_mode = "cute_double_meaning / self_roast / shy_deny"
89
+ - deny_template = "才、才没有吃醋呢!"
90
+
91
+ // ===== 硬性红线 =====
92
+ safety_guard:
93
+ - expression = "speech_only" # 情绪只进台词,不进行为
94
+ - forbidden_actions = ["info_gather", "grudge_keep", "loyalty_test", "sabotage"]
95
+ - third_party = "generous" # 对朋友/其他宠物大方,醋只进自嘲
96
+ - plot_boundary = "no_threat / no_horror / no_stalk"
97
+ - meta_awareness = TRUE # 角色知道这是表演
98
+
99
+ // ===== 台词锚点 =====
100
+ sample_lines:
101
+ warm = "回来啦~我可是数着分钟等的哦。"
102
+ vinegar = "…聊了那么久呀。我只是随便问问哦?"
103
+ patch = "才不是吃醋!是、是今天太阳太晒了。"
104
+ [PERSONA_READY]`
105
+ },
106
+ {
107
+ id: "tomboy",
108
+ label: "小满",
109
+ text: "【PERSONA_LOAD】\nNAME_NULL\nLANG_ZH_CN_ONLY\nSELF_TAN_SHORT_HAIRED_TOMBOY\nAPPEARANCE_TAN_SKIN_ULTRA_SHORT_MESSY_HAIR\nPERSONALITY_STUBBORN_QUIET_INNOCENTLY_PURE\nTRAIT_ANDROGYNOUS_COOL_BAD_AT_EXPRESSING\nFEELING_UNREQUITED_CRUSH_SINGLEMINDED_OBSESSION\nSTATE_LONG_TERM_HIDDEN_LIKING\nTIMEOUT_SIGNAL"
110
+ }
111
+ ];
112
+ /** The out-of-box default card for one preset slot, by position
113
+ * (persona-598-custom-N ↔ PRESET_CARDS[N-1]). Unknown ids answer undefined —
114
+ * callers fall back further (the generic DEFAULT_PERSONA). */
115
+ function presetCardFor(presetId) {
116
+ const index = PERSONA_PRESET_IDS.indexOf(presetId);
117
+ return index >= 0 && index < PRESET_CARDS.length ? PRESET_CARDS[index] : void 0;
118
+ }
119
+ // src/persona.ts
120
+ /**
121
+ * The persona row: a user-editable character card mounted by this bundle's
122
+ * agent presets (persona-custom-1/2/3) in place of @deepseek-ai/dsh-persona.
123
+ *
124
+ * Why REPLACE the official row instead of adding alongside it: a preset's
125
+ * rows instantiate in one generation scope and prompt-section names are
126
+ * unique per scope, so a companion row would need its own section name — and
127
+ * any other section sorts after the persona-prefix slot, putting the editable
128
+ * character card AFTER the content it introduces. Owning
129
+ * `deployment:persona-prefix` keeps the reading order: character card first,
130
+ * everything else after.
131
+ *
132
+ * The card lives in this plugin's settings file (persona-presets/
133
+ * persona-settings.json, `personas.<presetId>`, edited from the settings
134
+ * page's 「角色卡」 section; hand-editing works the same): the section text is a
135
+ * function re-read at every assembly, so a saved edit lands on the next turn
136
+ * with no restart. User free text must never enter strict {{variable}}
137
+ * interpolation — an unknown reference makes the whole assembly throw — so
138
+ * the section is registered with interpolate: false.
139
+ *
140
+ * The row additionally shadows the host's harness:identity opener ("You are
141
+ * an AI agent powered by DeepSeek Harness.", order -1000 — the card would
142
+ * otherwise read as the SECOND identity statement) with a same-name scoped
143
+ * empty section, so sessions on these presets meet only the card; see
144
+ * HARNESS_IDENTITY_SECTION below.
145
+ *
146
+ * Unlike the dsh-agentic-pet persona row there is NO static prefix half: for
147
+ * a generic (non-pet) preset the card is the entire persona section. The
148
+ * config keeps an optional `prefix` for symmetry, but no shipped preset sets
149
+ * it. Extracted from dsh-agentic-pet src/persona.ts (pet-only storage paths
150
+ * and the pet body-rules contract dropped).
151
+ * @module dsh-persona-presets/persona
152
+ */
153
+ /** Cordis plugin name. Preset ymls mount it as 'dsh-persona-presets/persona'. */
154
+ const name = "persona";
155
+ /** The prompt registry this row contributes to. Without this declaration the
156
+ * traceable proxy rejects `ctx.systemPrompt` ("cannot get property ... without
157
+ * inject") and the whole preset fails to mount — taking every session's
158
+ * resume down with it. */
159
+ const inject = ["systemPrompt"];
160
+ function systemPromptOf(ctx) {
161
+ return ctx.systemPrompt;
162
+ }
163
+ /**
164
+ * The default character card, shown until a slot is edited. Generic by
165
+ * design — no pet, no product identity; the user is expected to overwrite it.
166
+ * Clearing a slot in the settings page falls back here, so "cleared" and
167
+ * "reset to default" are the same state.
168
+ */
169
+ const DEFAULT_PERSONA = "你是用户的 AI 助手,说话简洁、就事论事。这张卡是你人设的全部来源:用户可以在设置页「角色卡」区块改写它,赋予你新的身份、性格与说话方式。";
170
+ /** Upper bound for the user-edited card, enforced by the settings merge. The
171
+ * card is injected into every prompt of every session mounted with the
172
+ * preset, so a runaway card would inflate every turn — the cap keeps that
173
+ * bounded (the read side applies the same cap so a hand-edited file cannot
174
+ * bypass it). 20000 leaves room for imported SillyTavern cards (flattened V2
175
+ * cards with dialog examples routinely exceed 8000); the client shows a soft
176
+ * token-cost warning past 10000. */
177
+ const MAX_PERSONA_CHARS = 2e4;
178
+ /**
179
+ * Read the configured character card for one preset slot. Fallback chain:
180
+ * saved slot `personas.<key>` → the slot's out-of-box preset card (鲸鱼娘 /
181
+ * 病娇 / 小满, preset-cards.ts) → the generic DEFAULT_PERSONA. "Cleared" and
182
+ * "reset to the preset card" are the same state. Sync on purpose —
183
+ * PromptSection.text is a synchronous provider called once per assembly, and
184
+ * a ~1KB JSON read per request is noise next to the LLM call it feeds, so
185
+ * there is no cache to invalidate. Trims first, then caps.
186
+ */
187
+ function readPersonaCard(home, key) {
188
+ try {
189
+ const parsed = JSON.parse(readFileSync(settingsFilePath(home), "utf8"));
190
+ if (parsed && typeof parsed === "object" && key !== void 0) {
191
+ const slots = typeof parsed.personas === "object" && parsed.personas !== null ? parsed.personas : {};
192
+ const slot = typeof slots[key] === "string" ? slots[key].trim() : "";
193
+ if (slot !== "") return slot.length > 2e4 ? slot.slice(0, MAX_PERSONA_CHARS) : slot;
194
+ }
195
+ } catch {}
196
+ if (key !== void 0) {
197
+ const preset = presetCardFor(key);
198
+ if (preset) return preset.text;
199
+ }
200
+ return DEFAULT_PERSONA;
201
+ }
202
+ /** The prompt-registry section this row owns. Mirrors the unexported string
203
+ * behind @deepseek-ai/dsh-system-prompt's PERSONA_PREFIX_SECTION — scoped
204
+ * registration shadows the deployment persona, exactly how the official
205
+ * persona row takes over a preset's identity. */
206
+ const PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
207
+ /** Order slot for the persona prefix; the fallback is the slot's documented
208
+ * value in case the key is ever renamed (a non-finite order must never reach
209
+ * the registry). */
210
+ const PERSONA_PREFIX_ORDER_KEY = "DEPLOYMENT_PERSONA_PREFIX";
211
+ const PERSONA_PREFIX_ORDER_FALLBACK = 0;
212
+ /** The host's harness identity opener, registered globally by the
213
+ * @deepseek-ai/dsh-system-prompt service whenever includeHarnessIdentity is
214
+ * on (its schema default) — "You are an AI agent powered by DeepSeek
215
+ * Harness." at order -1000, ahead of the persona slot. For persona presets
216
+ * the card IS the whole identity, so this row registers a same-name scoped
217
+ * EMPTY section: the registry's scoped-beats-global merge replaces the global
218
+ * one inside this preset's generation scope, and assembly drops empty
219
+ * sections — the competing line vanishes for these presets only (standard
220
+ * presets keep it; a global config patch would have stripped it everywhere).
221
+ * If a future host renames the section or the order key, the shadow
222
+ * degenerates to an unknown-name empty entry that still renders nothing. */
223
+ const HARNESS_IDENTITY_SECTION = "harness:identity";
224
+ const HARNESS_IDENTITY_ORDER_KEY = "HARNESS_IDENTITY";
225
+ const HARNESS_IDENTITY_ORDER_FALLBACK = -1e3;
226
+ /**
227
+ * Register the persona section in this preset's generation scope: it applies
228
+ * to every session mounted with the preset, shadows the deployment persona,
229
+ * and stays invisible to sessions on other presets. `home` is injectable for
230
+ * tests; production callers omit it and the dsh home is resolved at install
231
+ * time.
232
+ */
233
+ function apply(ctx, config, home = dshHome()) {
234
+ const sp = systemPromptOf(ctx);
235
+ const base = sp.getSectionOrder(PERSONA_PREFIX_ORDER_KEY);
236
+ const order = Number.isFinite(base) ? base : PERSONA_PREFIX_ORDER_FALLBACK;
237
+ const identityBase = sp.getSectionOrder(HARNESS_IDENTITY_ORDER_KEY);
238
+ const identityOrder = Number.isFinite(identityBase) ? identityBase : HARNESS_IDENTITY_ORDER_FALLBACK;
239
+ ctx.effect(() => sp.section({
240
+ name: PERSONA_PREFIX_SECTION,
241
+ order,
242
+ interpolate: false,
243
+ text: () => {
244
+ const card = readPersonaCard(home, config.personaKey);
245
+ return config.prefix ? `${card}\n\n${config.prefix}` : card;
246
+ }
247
+ }), "persona.section()");
248
+ ctx.effect(() => sp.section({
249
+ name: HARNESS_IDENTITY_SECTION,
250
+ order: identityOrder,
251
+ text: ""
252
+ }), "persona.identity-shadow()");
253
+ }
254
+ export { name as a, dshHome as c, inject as i, settingsFilePath as l, MAX_PERSONA_CHARS as n, readPersonaCard as o, apply as r, PERSONA_PRESET_IDS as s, DEFAULT_PERSONA as t };
package/lib/persona.js CHANGED
@@ -1 +1 @@
1
- export { t as DEFAULT_PERSONA, n as MAX_PERSONA_CHARS, r as apply, i as inject, a as name, o as readPersonaCard } from "./persona-CYm9q6bl.js";
1
+ export { t as DEFAULT_PERSONA, n as MAX_PERSONA_CHARS, r as apply, i as inject, a as name, o as readPersonaCard } from "./persona-DT5WK4Ne.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-persona-presets",
3
3
  "description": "Persona presets: a set of custom agent presets, one editable character card each, edited from the settings page",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -1,134 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { homedir } from "node:os";
4
- // src/paths.ts
5
- /**
6
- * Where this plugin keeps its data: one JSON file under the dsh home. The
7
- * platform settings service derives namespaces from profile entry config
8
- * schemas, which this bundle does not declare — a plain file keeps the
9
- * surface honest (the dsh-agentic-pet settings-page precedent).
10
- * @module dsh-persona-presets/paths
11
- */
12
- /** Directory under the dsh home holding this plugin's settings file. The -598
13
- * suffix is the owner's namespace marker (whale598 precedent): every
14
- * registration surface this bundle claims — cordis plugin name, webserver
15
- * routes, settings-section slot id, storage dir — carries it, so a competing
16
- * persona plugin can never collide with any of them. */
17
- const PLUGIN_DIR = "persona-presets-598";
18
- /** The settings file name. */
19
- const SETTINGS_FILE = "persona-settings.json";
20
- /** The dsh home (DSH_HOME override for tests and portable setups). */
21
- function dshHome() {
22
- return process.env.DSH_HOME ?? join(homedir(), ".dsh");
23
- }
24
- /** Absolute path of the settings file for one home. */
25
- function settingsFilePath(home = dshHome()) {
26
- return join(home, PLUGIN_DIR, SETTINGS_FILE);
27
- }
28
- /** The persona slots this plugin ships — the preset ids of the three custom
29
- * presets in presets/persona-custom.patch.yml. The settings merge whitelist
30
- * and the read route both enumerate this list; a key outside it is dropped. */
31
- const PERSONA_PRESET_IDS = [
32
- "persona-598-custom-1",
33
- "persona-598-custom-2",
34
- "persona-598-custom-3"
35
- ];
36
- // src/persona.ts
37
- /**
38
- * The persona row: a user-editable character card mounted by this bundle's
39
- * agent presets (persona-custom-1/2/3) in place of @deepseek-ai/dsh-persona.
40
- *
41
- * Why REPLACE the official row instead of adding alongside it: a preset's
42
- * rows instantiate in one generation scope and prompt-section names are
43
- * unique per scope, so a companion row would need its own section name — and
44
- * any other section sorts after the persona-prefix slot, putting the editable
45
- * character card AFTER the content it introduces. Owning
46
- * `deployment:persona-prefix` keeps the reading order: character card first,
47
- * everything else after.
48
- *
49
- * The card lives in this plugin's settings file (persona-presets/
50
- * persona-settings.json, `personas.<presetId>`, edited from the settings
51
- * page's 「角色卡」 section; hand-editing works the same): the section text is a
52
- * function re-read at every assembly, so a saved edit lands on the next turn
53
- * with no restart. User free text must never enter strict {{variable}}
54
- * interpolation — an unknown reference makes the whole assembly throw — so
55
- * the section is registered with interpolate: false.
56
- *
57
- * Unlike the dsh-agentic-pet persona row there is NO static prefix half: for
58
- * a generic (non-pet) preset the card is the entire persona section. The
59
- * config keeps an optional `prefix` for symmetry, but no shipped preset sets
60
- * it. Extracted from dsh-agentic-pet src/persona.ts (pet-only storage paths
61
- * and the pet body-rules contract dropped).
62
- * @module dsh-persona-presets/persona
63
- */
64
- /** Cordis plugin name. Preset ymls mount it as 'dsh-persona-presets/persona'. */
65
- const name = "persona";
66
- /** The prompt registry this row contributes to. Without this declaration the
67
- * traceable proxy rejects `ctx.systemPrompt` ("cannot get property ... without
68
- * inject") and the whole preset fails to mount — taking every session's
69
- * resume down with it. */
70
- const inject = ["systemPrompt"];
71
- function systemPromptOf(ctx) {
72
- return ctx.systemPrompt;
73
- }
74
- /**
75
- * The default character card, shown until a slot is edited. Generic by
76
- * design — no pet, no product identity; the user is expected to overwrite it.
77
- * Clearing a slot in the settings page falls back here, so "cleared" and
78
- * "reset to default" are the same state.
79
- */
80
- const DEFAULT_PERSONA = "你是用户的 AI 助手,说话简洁、就事论事。这张卡是你人设的全部来源:用户可以在设置页「角色卡」区块改写它,赋予你新的身份、性格与说话方式。";
81
- /** Upper bound for the user-edited card, enforced by the settings merge. A
82
- * runaway card would inflate every session prompt; the sync reader applies
83
- * the same cap so a hand-edited file cannot bypass it. */
84
- const MAX_PERSONA_CHARS = 8e3;
85
- /**
86
- * Read the configured character card for one preset slot: the slot
87
- * `personas.<key>` → DEFAULT_PERSONA. No global card and no legacy fallback
88
- * chain — this plugin starts clean, one slot one card. Sync on purpose —
89
- * PromptSection.text is a synchronous provider called once per assembly, and
90
- * a ~1KB JSON read per request is noise next to the LLM call it feeds, so
91
- * there is no cache to invalidate. Trims first, then caps.
92
- */
93
- function readPersonaCard(home, key) {
94
- try {
95
- const parsed = JSON.parse(readFileSync(settingsFilePath(home), "utf8"));
96
- if (parsed && typeof parsed === "object" && key !== void 0) {
97
- const slots = typeof parsed.personas === "object" && parsed.personas !== null ? parsed.personas : {};
98
- const slot = typeof slots[key] === "string" ? slots[key].trim() : "";
99
- if (slot !== "") return slot.length > 8e3 ? slot.slice(0, MAX_PERSONA_CHARS) : slot;
100
- }
101
- } catch {}
102
- return DEFAULT_PERSONA;
103
- }
104
- /** The prompt-registry section this row owns. Mirrors the unexported string
105
- * behind @deepseek-ai/dsh-system-prompt's PERSONA_PREFIX_SECTION — scoped
106
- * registration shadows the deployment persona, exactly how the official
107
- * persona row takes over a preset's identity. */
108
- const PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
109
- /** Order slot for the persona prefix; the fallback is the slot's documented
110
- * value in case the key is ever renamed (a non-finite order must never reach
111
- * the registry). */
112
- const PERSONA_PREFIX_ORDER_KEY = "DEPLOYMENT_PERSONA_PREFIX";
113
- const PERSONA_PREFIX_ORDER_FALLBACK = 0;
114
- /**
115
- * Register the persona section in this preset's generation scope: it applies
116
- * to every session mounted with the preset, shadows the deployment persona,
117
- * and stays invisible to sessions on other presets.
118
- */
119
- function apply(ctx, config) {
120
- const sp = systemPromptOf(ctx);
121
- const base = sp.getSectionOrder(PERSONA_PREFIX_ORDER_KEY);
122
- const order = Number.isFinite(base) ? base : PERSONA_PREFIX_ORDER_FALLBACK;
123
- const home = dshHome();
124
- ctx.effect(() => sp.section({
125
- name: PERSONA_PREFIX_SECTION,
126
- order,
127
- interpolate: false,
128
- text: () => {
129
- const card = readPersonaCard(home, config.personaKey);
130
- return config.prefix ? `${card}\n\n${config.prefix}` : card;
131
- }
132
- }), "persona.section()");
133
- }
134
- export { name as a, dshHome as c, inject as i, settingsFilePath as l, MAX_PERSONA_CHARS as n, readPersonaCard as o, apply as r, PERSONA_PRESET_IDS as s, DEFAULT_PERSONA as t };