dsh-muv-table 0.2.4 → 0.2.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.7 (2026-09-20)
4
+
5
+ ### 📦 发布内容修正
6
+
7
+ - **把 `test-png-card.mjs` 纳入 npm 包**。0.2.6 的 README 让用户跑
8
+ `node test-png-card.mjs`,但 `files` 字段没列它,从 npm 安装的人拿不到。
9
+ 现在一并发布,装完即可自测 PNG 卡解析是否正常(含真实卡库验证,卡库不存在时自动跳过)。
10
+ - `README.md` 显式列入 `files`。
11
+
12
+ > 0.2.6 的功能改动见下。
13
+
14
+ ## v0.2.6 (2026-09-20)
15
+
16
+ ### 🐛 修复:变量面板显示错误的卡(显示成苍玄界)
17
+
18
+ 两个独立原因,都会让面板拿到**别的预设**的卡:
19
+
20
+ - **无 `presetId` 时回退到硬编码预设**:面板只在收到 `tavern-preset-changed`
21
+ 事件后才设置 `lastPresetId`,而页面首次加载时该事件不会触发,请求因此不带
22
+ 参数;服务端于是回退到写死的 `tavern-lite`,把那个预设的卡显示出来。
23
+ 现在新增 `/api/muv-table/active-preset`,面板在首次加载前先问服务端
24
+ 「当前是哪个预设」,服务端按**最近写入的会话**所绑定的预设来回答。
25
+ - **过期 managed copy**:`tavern-lite/muv-tables/card.json` 里是 8 月留下的
26
+ 苍玄界,而该预设的 `characters.json` 早已改成川上富江。现在 `card.json`
27
+ 的卡名与预设记录不一致时不再采用,转而走卡库匹配,避免张冠李戴。
28
+
29
+ ### ✨ 支持读取 SillyTavern 的 PNG 角色卡
30
+
31
+ - **新增 `lib/png-card.js`**:从 PNG 的 `tEXt`/`iTXt` 元数据里解出 `chara` 块
32
+ (base64 编码的卡 JSON)。这是 TavernAI 系生态的标准做法——图片本身就是卡,
33
+ 角色数据搭车存在文件里,所以一张 3.5 MB 的「卡」实际是封面图 + 约 1.5 MB 的
34
+ base64 卡数据。
35
+ - **为什么需要**:DSH 自己的导入器只把 `{name, desc, first, enabled}` 写进
36
+ `characters.json`,**正则脚本、世界书、tavern_helper 全部丢失**。所以一张拖进
37
+ SillyTavern 的卡,在 DSH 这边只能拿到 3 KB 文本残影。现在直接读原始 PNG 文件。
38
+ - **卡库搜索路径接入 SillyTavern**:自动发现
39
+ `<SillyTavern>/data/<user>/characters`(含 `C:/MySpecialFolder/SillyTavern`、
40
+ `~/SillyTavern`、`~/Documents/SillyTavern`),可用 `DSH_SILLYTAVERN_DIR`
41
+ 覆盖。预设目录里直接放 `.png` 也会被识别。
42
+ - `findJsonMatch` 同时接受 `.json` 与 `.png`,按卡名匹配。
43
+
44
+ 实测:SillyTavern 卡库 5 张 PNG 卡全部读出,其中 4 张带回完整正则脚本
45
+ (足控天堂 10 个、异世界农场 3 个、涩涩提瓦特 2 个、食人世界 2 个)。
46
+
47
+ ### 🧪 测试
48
+
49
+ - 新增 `test-png-card.mjs`:12 项,覆盖合成 PNG 解析、无 `chara` 块拒绝、
50
+ 垃圾数据不抛异常,以及真实卡库验证(卡库不存在时自动跳过)。
51
+
52
+ ## v0.2.5 (2026-09-20)
53
+
54
+ ### 🐛 修复:变量面板读不到已导入的卡
55
+
56
+ - **新增 `extractMuvFromCharactersJson`**:酒馆把导入的卡存在
57
+ `<预设目录>/characters.json`。此前面板只认两条路——预设里的
58
+ `muv-tables/card.json`,或按卡名在外部卡库目录里找原始 JSON 文件。
59
+ 导入的卡往往两条都不满足(原始文件根本没下载到本机),于是接口返回
60
+ `found:false`、变量面板空白,**而数据其实就在 characters.json 里**。
61
+ 现在第三条路直接从那里提取变量表。
62
+ - 同时支持两种编码:`<VariableInsert>{…纯 JSON…}`(社区卡常见)与
63
+ MUV 原生的 `<UpdateVariable><initvar>…</initvar></UpdateVariable>`。
64
+ - 过滤掉 `主播档案.$template` 占位条目,它不是真实角色。
65
+
66
+ ### 🐛 修复:角色卡读取路径过窄
67
+
68
+ - `MUV_CARD_DIRS` 之前硬编码了某一台机器的下载目录,换台机器就找不到卡。
69
+ 现在按顺序尝试:预设目录 `muv-tables/card.json`、`DSH_MUV_CARD_DIRS`
70
+ 环境变量、系统下载目录、桌面。
71
+ - 角色卡名可从酒馆的 `characters.json` 读取,不再依赖单一 yml 字段。
72
+ - `tavern-card` 接口支持按 `presetId` / `sessionId` 取卡,并返回
73
+ `regexScripts` 与 `data.extensions.regex_scripts` 两种正则脚本形态。
74
+
3
75
  ## v0.2.4 (2026-09-13)
4
76
 
5
77
  ### 📝 文档
package/README.md CHANGED
@@ -4,6 +4,47 @@ MUV 变量表格编辑器 —— 酒馆伴生插件。
4
4
 
5
5
  > 🍺 本插件是 [dsh-tavern-v2](https://github.com/chen731215-dev/dsh-tavern-v2) 的扩展,需配合酒馆使用。
6
6
 
7
+ ## 🆕 v0.2.6 更新内容
8
+
9
+ ### 新增:读取 SillyTavern 的 PNG 角色卡
10
+
11
+ 这是本次最重要的能力。SillyTavern(及整个 TavernAI 系生态)的角色卡就是**一张 PNG**——
12
+ 卡数据以 base64 存在图片的 `tEXt` 元数据里。一张 3.5 MB 的「卡」实际是封面图 +
13
+ 约 1.5 MB 的卡数据。
14
+
15
+ **为什么必须读它**:DSH 自己的导入器只把 `{name, desc, first}` 写进 `characters.json`,
16
+ **正则脚本、世界书、tavern_helper 全部丢失**。于是一张拖进酒馆的卡,在 DSH 这边只剩
17
+ 3 KB 文本残影,卡自带的插画、视频、状态栏渲染全都不工作。现在直接读原始 PNG 文件。
18
+
19
+ - 新增 `lib/png-card.js`:解析 PNG 的 `tEXt`/`iTXt` 元数据,取出 `chara` 块
20
+ - 卡库搜索路径自动接入 `<SillyTavern>/data/<user>/characters`
21
+ (含 `~/SillyTavern`、`~/Documents/SillyTavern`、`C:/MySpecialFolder/SillyTavern`),
22
+ 可用环境变量 `DSH_SILLYTAVERN_DIR` 覆盖
23
+ - 预设目录里直接放 `.png` 也会被识别
24
+
25
+ 实测:SillyTavern 卡库 5 张 PNG 卡全部读出,带回了完整的正则脚本与上百条世界书。
26
+
27
+ ### 修复:变量面板显示错误的卡
28
+
29
+ 两个独立原因都会让面板拿到**别的预设**的卡:
30
+
31
+ - 面板在首次加载时不知道自己在哪个预设(预设切换事件不会在页面加载时触发),
32
+ 请求不带参数,服务端回退到**硬编码的** `tavern-lite`,把那个预设的卡显示出来。
33
+ 现在新增 `/api/muv-table/active-preset`,面板主动询问当前预设。
34
+ - 面板会把首次解析出的预设**缓存住永不更新**,切换预设后卡死在旧卡上。现在每次
35
+ 轮询都重读。
36
+ - 预设里的 `muv-tables/card.json` 可能是过期残留(卡名与 `characters.json` 不符),
37
+ 现在会被识别并忽略。
38
+
39
+ ### 新增:从 `characters.json` 提取变量表
40
+
41
+ 酒馆把导入的卡存在 `<预设目录>/characters.json`。当原始卡文件不在本机时,
42
+ **这是变量数据唯一还存在的地方**。现在会从这里提取,支持
43
+ `<VariableInsert>{…JSON…}` 与 MUV 原生的 `<UpdateVariable><initvar>…</initvar>` 两种编码。
44
+
45
+ > 回归测试:`node test-png-card.mjs`(12 项,含真实卡库验证)
46
+
47
+
7
48
  ## 是什么
8
49
 
9
50
  把酒馆角色卡的 MUV 变量渲染成结构化表格,Agent 直接编辑表格即可生成格式100%正确的 `<UpdateVariable>` 块,彻底避免 DeepSeek 漏变量、缩进错、标签不闭合的问题。
package/lib/client.js CHANGED
@@ -155,7 +155,48 @@ window.__ModuleLoader__.load({
155
155
  const hasAutoLoaded = React.useRef(false)
156
156
 
157
157
  // Get the tavern's active card
158
+ //
159
+ // Which preset is active has exactly one source of truth: the tavern
160
+ // plugin's own selection, which it publishes on these DOM nodes (and
161
+ // mirrors to localStorage). `lastPresetId` was previously only set by a
162
+ // `tavern-preset-changed` event, which does not fire on page load — so the
163
+ // first request carried no preset and the server fell back to a hardcoded
164
+ // one, showing an unrelated card.
165
+ //
166
+ // Reading the DOM here rather than caching an event value matters: the
167
+ // panel polls, and the user can switch presets at any time. An earlier
168
+ // revision cached the first answer and never re-checked, which pinned the
169
+ // panel to whatever preset happened to be active when it opened.
170
+ const readActivePresetFromDom = () => {
171
+ try {
172
+ for (const id of ['tavern-session-preset-label', 'tavern-session-preset-btn']) {
173
+ const el = document.getElementById(id)
174
+ const pid = el && el.dataset && el.dataset.presetId
175
+ if (pid) return pid
176
+ }
177
+ const item = document.querySelector('#tavern-session-preset-panel [data-preset-id]')
178
+ const aid = item && item.getAttribute && item.getAttribute('data-preset-id')
179
+ if (aid) return aid
180
+ } catch (_) {}
181
+ try { return localStorage.getItem('dsh-tavern-active-preset') || '' } catch (_) { return '' }
182
+ }
183
+
184
+ const resolveActivePreset = async () => {
185
+ // Tavern's DOM is authoritative and updates the moment the user switches.
186
+ const fromDom = readActivePresetFromDom()
187
+ if (fromDom) { lastPresetId = fromDom; return lastPresetId }
188
+ // The DOM nodes are absent outside the tavern UI; ask the server, which
189
+ // resolves the preset from the most recently active session.
190
+ try {
191
+ const r = await fetch('/api/muv-table/active-preset')
192
+ const d = await r.json()
193
+ if (d && d.ok && d.presetId) lastPresetId = d.presetId
194
+ } catch (_) {}
195
+ return lastPresetId
196
+ }
197
+
158
198
  const fetchTavernCard = async () => {
199
+ await resolveActivePreset()
159
200
  let url = '/api/muv-table/tavern-card'
160
201
  if (lastPresetId) url += '?presetId=' + encodeURIComponent(lastPresetId)
161
202
  const r = await fetch(url)
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { parseMuvCard } from './muv-parser.js'
5
5
  import { generateMuvBlock, applyEditsAndGenerate } from './block-generator.js'
6
6
  import { parseInitvar } from './initvar-parser.js'
7
+ import { readPngCard } from './png-card.js'
7
8
  import fs from 'node:fs'
8
9
  import path from 'node:path'
9
10
  import os from 'node:os'
@@ -52,7 +53,46 @@ function bindDshPaths(ctx) {
52
53
  SESSION_BINDINGS_FILE = path.join(PRESETS_ROOT, 'session-bindings.json')
53
54
  }
54
55
 
55
- const MUV_CARD_DIRS = ['E:/BaiduNetdiskDownload/EdgeDownload']
56
+ // External character-card library searched when a preset has no managed copy.
57
+ // Historically this was a single hardcoded download folder, which silently
58
+ // limited every lookup to whatever happened to sit there. Keep that folder
59
+ // first (existing installs rely on it), then widen to the other places cards
60
+ // actually get downloaded to, and allow an explicit override.
61
+ //
62
+ // The SillyTavern folders matter because that is where a user's *original* PNG
63
+ // cards live. DSH's own `characters.json` keeps only `{name, desc, first}`, so
64
+ // for a card dragged into SillyTavern the original file is the only place its
65
+ // regex scripts, world book and helper scripts still exist.
66
+ function sillyTavernCharacterDirs() {
67
+ const roots = []
68
+ const explicit = process.env.DSH_SILLYTAVERN_DIR
69
+ if (explicit) roots.push(explicit)
70
+ // Common install locations; each may hold data/<user>/characters.
71
+ roots.push('C:/MySpecialFolder/SillyTavern')
72
+ roots.push(path.join(os.homedir(), 'SillyTavern'))
73
+ roots.push(path.join(os.homedir(), 'Documents', 'SillyTavern'))
74
+
75
+ const out = []
76
+ for (const root of roots) {
77
+ try {
78
+ const dataDir = path.join(root, 'data')
79
+ if (!fs.existsSync(dataDir)) continue
80
+ for (const user of fs.readdirSync(dataDir)) {
81
+ const chars = path.join(dataDir, user, 'characters')
82
+ if (fs.existsSync(chars)) out.push(chars)
83
+ }
84
+ } catch (_) {}
85
+ }
86
+ return out
87
+ }
88
+
89
+ const MUV_CARD_DIRS = [
90
+ 'E:/BaiduNetdiskDownload/EdgeDownload',
91
+ ...(process.env.DSH_MUV_CARD_DIRS || '').split(path.delimiter).map(s => s.trim()).filter(Boolean),
92
+ ...sillyTavernCharacterDirs(),
93
+ path.join(os.homedir(), 'Downloads'),
94
+ path.join(os.homedir(), 'Desktop')
95
+ ].filter((dir, i, all) => all.indexOf(dir) === i)
56
96
 
57
97
  /** Read a YAML/yml file with encoding detection (GBK → UTF-8 fallback). */
58
98
  function readYmlSafe(filePath) {
@@ -73,15 +113,22 @@ function readYmlSafe(filePath) {
73
113
  */
74
114
  function extractCardNameFromYml(yml) {
75
115
  if (!yml) return ''
76
- // 1) Find the persona text: |- block first (the actual character card content)
77
- const textMatch = yml.match(/text:\s*\|-?\s*\n([\s\S]*?)(?=\n\s*[-a-zA-Z@_]+:|\n\s*$)/)
78
- const body = textMatch ? textMatch[1] : yml
116
+ // 1) Find the persona body. Tavern writes `prefix: |-`; other generators use
117
+ // `text:`. Matching only `text:` made everything below scan raw YAML,
118
+ // where the first non-comment line is the persona *service* name.
119
+ const textMatch = yml.match(/(?:prefix|text):\s*\|-?\s*\n([\s\S]*?)(?=\n\s*[-a-zA-Z@_]+:|\n\s*$)/)
120
+ const body = textMatch ? textMatch[1] : ''
79
121
  // 2) 角色名:xxx pattern (used by tavern cards)
80
122
  const personaMatch = body.match(/角色名[::]\s*([^\s#\n]+)/)
81
123
  if (personaMatch) return personaMatch[1].trim()
82
- // 3) First meaningful line of the persona block (card title)
83
- const firstLine = body.split(/\r?\n/).map(l => l.trim()).find(l => l && !l.startsWith('#') && !l.startsWith('-') && !l.startsWith('['))
84
- if (firstLine) return firstLine.replace(/[#*_【】「」]/g, '').trim()
124
+ // 3) Card title: the first meaningful line of the persona body only — never
125
+ // fall back to the raw YAML, whose lines are configuration keys.
126
+ const firstLine = body.split(/\r?\n/).map(l => l.trim())
127
+ .find(l => l && !l.startsWith('#') && !l.startsWith('-') && !l.startsWith('[') && !/^[a-zA-Z@_][\w@./-]*:/.test(l))
128
+ if (firstLine) {
129
+ const cleaned = firstLine.replace(/[#*_【】「」]/g, '').replace(/[🔒🔓]\s*/g, '').trim()
130
+ if (cleaned) return cleaned
131
+ }
85
132
  // 4) Upstream structured name: — but skip service names like '@deepseek-ai/dsh-persona'
86
133
  const nameMatch = yml.match(/^\s*name:\s*(.+)$/m)
87
134
  if (nameMatch) {
@@ -130,15 +177,71 @@ function presetDirForSession(sessionId) {
130
177
  }
131
178
 
132
179
  /**
133
- * Search a person text for a matching card JSON in MUV dirs.
134
- * Returns parsed card or null.
180
+ * Which preset is the user actually in?
181
+ *
182
+ * The panel opens without knowing the session, and `tavern-lite` was the
183
+ * hardcoded fallback — so a user working in another preset saw *that* preset's
184
+ * card in the variable panel (a stale 苍玄界 copy, in the reported case).
185
+ *
186
+ * The most reliable signal available server-side is the session store: the
187
+ * binding belonging to the most recently written session is the one on screen.
188
+ * @returns {string} a preset directory name, or '' when nothing can be resolved
189
+ */
190
+ function activePresetId() {
191
+ try {
192
+ if (!fs.existsSync(SESSION_BINDINGS_FILE)) return ''
193
+ const bind = JSON.parse(fs.readFileSync(SESSION_BINDINGS_FILE, 'utf8'))
194
+
195
+ // Session transcripts live under <dshHome>/storages/session_projcache/sessions
196
+ const sessDir = path.join(DSH_HOME, 'storages', 'session_projcache', 'sessions')
197
+ if (fs.existsSync(sessDir)) {
198
+ const files = fs.readdirSync(sessDir)
199
+ .filter(f => f.startsWith('session-') && f.endsWith('.json'))
200
+ .map(f => {
201
+ let mtime = 0
202
+ try { mtime = fs.statSync(path.join(sessDir, f)).mtimeMs } catch (_) {}
203
+ return { id: f.replace(/\.json$/, ''), mtime }
204
+ })
205
+ .sort((a, b) => b.mtime - a.mtime)
206
+
207
+ for (const s of files) {
208
+ const preset = bind[s.id]
209
+ if (preset && preset !== 'default') {
210
+ if (fs.existsSync(path.join(PRESETS_ROOT, preset))) return preset
211
+ }
212
+ }
213
+ }
214
+ } catch (_) {}
215
+ return ''
216
+ }
217
+
218
+ /**
219
+ * Search the MUV card dirs for a card matching a name.
220
+ *
221
+ * Reads both `.json` cards and SillyTavern `.png` cards — the latter carries
222
+ * the full card (regex scripts, world book, helper scripts) in a `tEXt` chunk,
223
+ * which is the only place that data survives when a card was dragged into
224
+ * SillyTavern rather than imported through DSH.
225
+ * @param {string} cardNameLower
226
+ * @param {string[]} dirs
227
+ * @returns {{json:object, fileName:string}|null}
135
228
  */
136
229
  function findJsonMatch(cardNameLower, dirs) {
137
230
  for (const dir of dirs) {
138
231
  if (!fs.existsSync(dir)) continue
139
- for (const f of fs.readdirSync(dir).filter(f2 => f2.endsWith('.json'))) {
232
+ let files
233
+ try { files = fs.readdirSync(dir) } catch (_) { continue }
234
+ for (const f of files) {
235
+ const isJson = f.endsWith('.json')
236
+ const isPng = f.endsWith('.png')
237
+ if (!isJson && !isPng) continue
140
238
  try {
141
- const json = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
239
+ let json
240
+ if (isPng) {
241
+ json = readPngCard(path.join(dir, f))
242
+ } else {
243
+ json = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
244
+ }
142
245
  const name = json?.data?.name || json?.name || ''
143
246
  if (!name) continue
144
247
  if (cardNameLower && (name.toLowerCase().includes(cardNameLower) || cardNameLower.includes(name.toLowerCase()))) {
@@ -150,6 +253,164 @@ function findJsonMatch(cardNameLower, dirs) {
150
253
  return null // No fallback — if no exact match, use yml extraction
151
254
  }
152
255
 
256
+ /**
257
+ * Read the active character name from a preset's tavern-managed store.
258
+ *
259
+ * `characters.json` is where tavern actually records the imported card
260
+ * (`[{ name, desc, first, enabled }]`), so it is the authoritative card name.
261
+ * Parsing the persona YAML only yields the preset module's title, which is a
262
+ * different thing entirely.
263
+ * @param {string} presetDir - absolute preset directory
264
+ * @returns {string} the enabled character's name, or ''
265
+ */
266
+ function cardNameFromCharactersJson(presetDir) {
267
+ const file = path.join(presetDir, 'characters.json')
268
+ if (!fs.existsSync(file)) return ''
269
+ try {
270
+ const list = JSON.parse(fs.readFileSync(file, 'utf8'))
271
+ if (!Array.isArray(list)) return ''
272
+ const enabled = list.find(c => c && c.enabled && c.name)
273
+ if (enabled) return String(enabled.name).trim()
274
+ const first = list.find(c => c && c.name)
275
+ return first ? String(first.name).trim() : ''
276
+ } catch (_) {
277
+ return ''
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Load the full raw character card for a preset, so its `regex_scripts` are
283
+ * available to the rendering engine.
284
+ *
285
+ * The tavern/MUV-managed copy inside the preset (`muv-tables/card.json`) is the
286
+ * authoritative one: it is the card the user actually imported and edited, and
287
+ * it is the only location guaranteed to sit next to the preset that uses it.
288
+ * Only when it is absent do we fall back to matching by name against the
289
+ * external card library.
290
+ * @param {string} presetDir - absolute preset directory
291
+ * @param {string} cardName - card name resolved from the preset's persona
292
+ * @returns {{ json: object, fileName: string, source: string }|null}
293
+ */
294
+ function loadRawCardForPreset(presetDir, cardName) {
295
+ const managed = path.join(presetDir, 'muv-tables', 'card.json')
296
+ if (fs.existsSync(managed)) {
297
+ try {
298
+ const json = JSON.parse(fs.readFileSync(managed, 'utf8'))
299
+ const managedName = String(json?.data?.name || json?.name || '')
300
+ // The managed copy goes stale: `tavern-lite` held a 苍玄界 card from
301
+ // August while the preset's characters.json had moved on to 川上富江.
302
+ // Serving it anyway showed the wrong card in the variable panel, so only
303
+ // trust it when the names actually agree. (An empty name means the card
304
+ // carries none — then there is nothing to contradict, so keep it.)
305
+ const wanted = String(cardName || '')
306
+ const agrees = !managedName || !wanted
307
+ || managedName.toLowerCase().includes(wanted.toLowerCase())
308
+ || wanted.toLowerCase().includes(managedName.toLowerCase())
309
+ if (agrees) {
310
+ return { json, fileName: path.basename(presetDir) + '/muv-tables/card.json', source: 'preset' }
311
+ }
312
+ } catch (_) {}
313
+ }
314
+ // A PNG card sitting next to the preset — the user may have dropped the
315
+ // original file there instead of letting tavern import it.
316
+ try {
317
+ for (const f of fs.readdirSync(presetDir)) {
318
+ if (!f.endsWith('.png')) continue
319
+ const json = readPngCard(path.join(presetDir, f))
320
+ if (!json) continue
321
+ const name = json?.data?.name || json?.name || ''
322
+ if (!cardName || !name) continue
323
+ const a = name.toLowerCase(), b = String(cardName).toLowerCase()
324
+ if (a.includes(b) || b.includes(a)) {
325
+ return { json, fileName: path.basename(presetDir) + '/' + f, source: 'preset-png' }
326
+ }
327
+ }
328
+ } catch (_) {}
329
+ const match = findJsonMatch(String(cardName || '').toLowerCase(), MUV_CARD_DIRS)
330
+ if (match) return { ...match, source: 'library' }
331
+ return null
332
+ }
333
+
334
+ /**
335
+ * Pull the card's regex scripts in whatever shape the card carries them.
336
+ * @param {object|null} cardJson
337
+ * @returns {object[]}
338
+ */
339
+ function regexScriptsOf(cardJson) {
340
+ const scripts = cardJson?.data?.extensions?.regex_scripts
341
+ return Array.isArray(scripts) ? scripts : []
342
+ }
343
+
344
+ /**
345
+ * Extract the card's variable schema from tavern's own character store.
346
+ *
347
+ * Tavern keeps what it imported in `<presetDir>/characters.json`, so for a card
348
+ * whose original JSON file is not in any searched library folder this is the
349
+ * only place the variables still exist. Two encodings appear in the field:
350
+ *
351
+ * - `<VariableInsert>{…JSON…}</VariableInsert>` — community cards that ship a
352
+ * plain JSON schema (this is what the 足控-style cards use).
353
+ * - `<UpdateVariable><initvar>…</initvar></UpdateVariable>` — the MUV-native
354
+ * YAML-ish form.
355
+ *
356
+ * Without this, a preset imported like that reported `found:false` and the
357
+ * variable panel stayed empty even though the data was sitting right there.
358
+ * @param {string} presetDir
359
+ * @returns {{name:string, schemas:object[], initvarData:object, initvarBlocks:string[], zodSource:string, cardName:string}|null}
360
+ */
361
+ function extractMuvFromCharactersJson(presetDir) {
362
+ try {
363
+ const file = path.join(presetDir, 'characters.json')
364
+ if (!fs.existsSync(file)) return null
365
+ const list = JSON.parse(fs.readFileSync(file, 'utf8'))
366
+ if (!Array.isArray(list) || !list.length) return null
367
+ const card = list.find(c => c && c.enabled) || list[0]
368
+ if (!card) return null
369
+ const name = String(card.name || '').trim() || 'MUV Card'
370
+ const text = String(card.desc || '') + '\n' + String(card.first || '')
371
+
372
+ // 1) Plain-JSON schema.
373
+ const vi = text.match(/<VariableInsert>([\s\S]*?)<\/VariableInsert>/i)
374
+ if (vi) {
375
+ const body = vi[1].trim()
376
+ try {
377
+ const data = JSON.parse(body)
378
+ // `$template` is the card's own placeholder entry, not a real character.
379
+ const clean = { ...data }
380
+ if (clean['主播档案'] && clean['主播档案'].$template) {
381
+ clean['主播档案'] = { ...clean['主播档案'] }
382
+ delete clean['主播档案'].$template
383
+ }
384
+ return {
385
+ name, cardName: name,
386
+ schemas: inferSchemas(clean),
387
+ initvarData: clean,
388
+ initvarBlocks: [body],
389
+ zodSource: ''
390
+ }
391
+ } catch (_) {
392
+ // Fall through: the block may still be YAML-ish.
393
+ }
394
+ }
395
+
396
+ // 2) MUV-native initvar.
397
+ const iv = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
398
+ if (iv) {
399
+ const parsed = parseInitvar(iv[1])
400
+ return {
401
+ name, cardName: name,
402
+ schemas: inferSchemas(parsed),
403
+ initvarData: parsed,
404
+ initvarBlocks: [iv[1]],
405
+ zodSource: ''
406
+ }
407
+ }
408
+ return null
409
+ } catch (_) {
410
+ return null
411
+ }
412
+ }
413
+
153
414
  /**
154
415
  * Extract MUV card info from a preset's persona YAML/yml text.
155
416
  * Looks for <initvar> blocks and builds a fake card object for parseMuvCard.
@@ -310,6 +571,18 @@ export function apply(ctx) {
310
571
  }
311
572
  }
312
573
  },
574
+ {
575
+ kind: 'exact',
576
+ path: '/api/muv-table/active-preset',
577
+ handler: (req, res) => {
578
+ try {
579
+ const presetId = activePresetId()
580
+ json(res, 200, { ok: true, presetId, fallback: presetId ? '' : path.basename(TAVERN_PRESET_DIR) })
581
+ } catch (e) {
582
+ json(res, 500, { ok: false, error: e.message })
583
+ }
584
+ }
585
+ },
313
586
  {
314
587
  kind: 'exact',
315
588
  path: '/api/muv-table/tavern-card',
@@ -317,48 +590,84 @@ export function apply(ctx) {
317
590
  try {
318
591
  const url = new URL(req.url, 'http://localhost')
319
592
  const presetId = url.searchParams.get('presetId') || ''
593
+ const sessionId = url.searchParams.get('sessionId') || ''
320
594
 
321
- // If a specific preset is requested, read from that preset's directory
322
- let ymlPath, presetDir
595
+ // Resolve the preset: explicit id > the session's bound preset >
596
+ // whichever preset the user is actually in. The last resort used to be
597
+ // a hardcoded `tavern-lite`, which made the panel show that preset's
598
+ // card (a stale 苍玄界 copy) whenever the client had no preset id yet
599
+ // — which is the common case, since the id only arrives via a
600
+ // `tavern-preset-changed` event that never fires on a fresh page.
601
+ let presetDir
323
602
  if (presetId) {
324
603
  presetDir = path.join(PRESETS_ROOT, presetId)
325
- ymlPath = path.join(presetDir, 'agent.cordis.yml')
604
+ } else if (sessionId) {
605
+ presetDir = presetDirForSession(sessionId)
326
606
  } else {
327
- presetDir = TAVERN_PRESET_DIR
328
- ymlPath = path.join(TAVERN_PRESET_DIR, 'agent.cordis.yml')
607
+ const active = activePresetId()
608
+ presetDir = active ? path.join(PRESETS_ROOT, active) : TAVERN_PRESET_DIR
329
609
  }
610
+ if (!fs.existsSync(presetDir)) presetDir = TAVERN_PRESET_DIR
611
+ const ymlPath = path.join(presetDir, 'agent.cordis.yml')
330
612
 
331
613
  let yml = fs.existsSync(ymlPath) ? readYmlSafe(ymlPath) : ''
332
- let cardName = extractCardNameFromYml(yml)
333
-
334
- if (!cardName) {
335
- const allPresets = scanAllPresetCards()
336
- const nonEmpty = allPresets.filter(p => p.cardName).sort((a, b) => b.mtime - a.mtime)
337
- if (nonEmpty.length > 0) {
338
- cardName = nonEmpty[0].cardName
339
- yml = nonEmpty[0].yml
340
- }
341
- }
614
+ // Prefer tavern's own character store; fall back to the persona YAML.
615
+ let cardName = cardNameFromCharactersJson(presetDir) || extractCardNameFromYml(yml)
342
616
 
343
- const match = findJsonMatch(String(cardName || '').toLowerCase(), MUV_CARD_DIRS)
344
-
345
- if (match) {
346
- const result = parseMuvCard(match.json)
617
+ // The raw card is the source of regex_scripts. Prefer the preset's own
618
+ // card; fall back to the external library matched by name.
619
+ const raw = loadRawCardForPreset(presetDir, cardName)
620
+ if (raw) {
621
+ const regexScripts = regexScriptsOf(raw.json)
347
622
  json(res, 200, {
348
- ok: true, found: true, fileName: match.fileName,
349
- cardName, presetDir: path.basename(presetDir),
350
- ...result
623
+ ok: true, found: true,
624
+ fileName: raw.fileName,
625
+ cardSource: raw.source,
626
+ cardName,
627
+ presetDir: path.basename(presetDir),
628
+ ...parseMuvCard(raw.json),
629
+ // Consumers read the scripts from either shape; keep both so an
630
+ // existing reader keeps working while the engine migrates.
631
+ regexScripts,
632
+ data: { extensions: { regex_scripts: regexScripts } }
351
633
  })
352
- } else if (cardName && yml) {
634
+ return
635
+ }
636
+
637
+ if (cardName && yml) {
638
+ // Neither a managed copy nor a library file: read the schema out of
639
+ // tavern's own character store. Imported cards routinely live only
640
+ // here, and returning found:false left the variable panel empty
641
+ // while the data sat in characters.json.
642
+ const fromStore = extractMuvFromCharactersJson(presetDir)
643
+ if (fromStore) {
644
+ json(res, 200, {
645
+ ok: true, found: true,
646
+ fileName: path.basename(presetDir) + '/characters.json',
647
+ cardSource: 'characters',
648
+ cardName: fromStore.cardName || cardName,
649
+ presetDir: path.basename(presetDir),
650
+ name: fromStore.name,
651
+ schemas: fromStore.schemas,
652
+ initvarData: fromStore.initvarData,
653
+ initvarBlocks: fromStore.initvarBlocks,
654
+ zodSource: fromStore.zodSource,
655
+ regexScripts: [],
656
+ data: { extensions: { regex_scripts: [] } }
657
+ })
658
+ return
659
+ }
353
660
  const parsed = extractMuvFromYml(yml)
354
661
  json(res, 200, {
355
662
  ok: true, found: true,
356
663
  fileName: path.basename(presetDir) + '/agent.cordis.yml',
357
664
  cardName, presetDir: path.basename(presetDir),
358
- ...parsed
665
+ ...parsed,
666
+ regexScripts: [],
667
+ data: { extensions: { regex_scripts: [] } }
359
668
  })
360
669
  } else {
361
- json(res, 200, { ok: true, found: false, error: 'no card in preset' })
670
+ json(res, 200, { ok: true, found: false, error: 'no card in preset', regexScripts: [] })
362
671
  }
363
672
  } catch (e) {
364
673
  json(res, 500, { ok: false, error: e.message })
@@ -0,0 +1,126 @@
1
+ // PNG character-card reader.
2
+ //
3
+ // SillyTavern (and the whole TavernAI-derived ecosystem) stores a card inside
4
+ // the PNG's metadata: a `tEXt` chunk whose keyword is `chara`, holding the card
5
+ // JSON as base64. The image itself is just the avatar — the card data rides
6
+ // along in the file, which is why a 3.5 MB "card" is really a picture plus
7
+ // ~1.5 MB of base64 payload.
8
+ //
9
+ // DSH's own importer never looked at these files: it only ever read its
10
+ // `characters.json` store, which keeps just `{name, desc, first, enabled}`. So a
11
+ // card dragged in as PNG arrived stripped of its regex scripts, world book and
12
+ // helper scripts. This module reads the original file instead.
13
+
14
+ import fs from 'node:fs'
15
+
16
+ /**
17
+ * Walk a PNG's chunks.
18
+ *
19
+ * PNG layout: 8-byte signature, then chunks of
20
+ * `length(4) type(4) data(length) crc(4)`, ending at IEND.
21
+ * @param {Buffer} buf
22
+ * @returns {{type:string, len:number, off:number}[]}
23
+ */
24
+ export function readPngChunks(buf) {
25
+ const chunks = []
26
+ if (!buf || buf.length < 8) return chunks
27
+ let off = 8
28
+ // Guard against a malformed length field running us off the end.
29
+ while (off + 8 <= buf.length) {
30
+ const len = buf.readUInt32BE(off)
31
+ const type = buf.toString('ascii', off + 4, off + 8)
32
+ if (!/^[A-Za-z]{4}$/.test(type)) break
33
+ chunks.push({ type, len, off })
34
+ if (type === 'IEND') break
35
+ off += 12 + len
36
+ if (len < 0) break
37
+ }
38
+ return chunks
39
+ }
40
+
41
+ /**
42
+ * Pull the `chara` payload out of a PNG's text chunks.
43
+ *
44
+ * Both `tEXt` and `iTXt` appear in the wild. `tEXt` is `keyword\0text`, while
45
+ * `iTXt` is `keyword\0 compressionFlag(1) compressionMethod(1) langTag\0
46
+ * translatedKeyword\0 text` — the extra header bytes have to be skipped or the
47
+ * base64 decodes to garbage.
48
+ * @param {Buffer} buf
49
+ * @returns {string|null} the raw (still base64) payload
50
+ */
51
+ export function extractCharaPayload(buf) {
52
+ for (const c of readPngChunks(buf)) {
53
+ if (c.type !== 'tEXt' && c.type !== 'iTXt') continue
54
+ const body = buf.subarray(c.off + 8, c.off + 8 + c.len)
55
+ const nul = body.indexOf(0)
56
+ if (nul < 0) continue
57
+ const keyword = body.toString('latin1', 0, nul)
58
+ if (keyword !== 'chara' && keyword !== 'ccv3') continue
59
+
60
+ let rest = body.subarray(nul + 1)
61
+ if (c.type === 'iTXt') {
62
+ const compressed = rest[0] === 1
63
+ if (compressed) continue // would need zlib; these cards do not use it
64
+ rest = rest.subarray(2)
65
+ const langEnd = rest.indexOf(0)
66
+ if (langEnd < 0) continue
67
+ rest = rest.subarray(langEnd + 1)
68
+ const transEnd = rest.indexOf(0)
69
+ if (transEnd < 0) continue
70
+ rest = rest.subarray(transEnd + 1)
71
+ }
72
+ return rest.toString('latin1')
73
+ }
74
+ return null
75
+ }
76
+
77
+ /**
78
+ * Read a PNG character card into the card-JSON shape the rest of the plugin
79
+ * expects — the same `{name, description, first_mes, data:{extensions}}` object
80
+ * a `.json` card has.
81
+ * @param {string} filePath
82
+ * @returns {object|null}
83
+ */
84
+ export function readPngCard(filePath) {
85
+ let buf
86
+ try {
87
+ buf = fs.readFileSync(filePath)
88
+ } catch (_) {
89
+ return null
90
+ }
91
+ const payload = extractCharaPayload(buf)
92
+ if (!payload) return null
93
+
94
+ const decoded = [
95
+ () => Buffer.from(payload, 'base64').toString('utf8'),
96
+ () => Buffer.from(payload.replace(/\s+/g, ''), 'base64').toString('utf8'),
97
+ ]
98
+ for (const make of decoded) {
99
+ try {
100
+ const json = JSON.parse(make())
101
+ // Some exports wrap the card as `{spec, data}`, others are flat.
102
+ const data = json.data && json.data.name ? json.data : json
103
+ if (!data || (!data.name && !data.first_mes && !data.description)) continue
104
+ return {
105
+ ...json,
106
+ data: {
107
+ ...(json.data || {}),
108
+ name: data.name || '',
109
+ description: data.description || '',
110
+ personality: data.personality || '',
111
+ scenario: data.scenario || '',
112
+ first_mes: data.first_mes || '',
113
+ mes_example: data.mes_example || '',
114
+ extensions: data.extensions || (json.data && json.data.extensions) || {},
115
+ character_book: data.character_book || (json.data && json.data.character_book) || null,
116
+ },
117
+ }
118
+ } catch (_) {}
119
+ }
120
+ return null
121
+ }
122
+
123
+ /** Does the file look like a PNG card (by extension)? */
124
+ export function isPngCard(filePath) {
125
+ return /\.png$/i.test(String(filePath || ''))
126
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-muv-table",
3
- "version": "0.2.4",
4
- "description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理。dsh-muv-engine 的伴生插件",
3
+ "version": "0.2.7",
4
+ "description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理;支持直接读取 SillyTavern PNG 角色卡(含正则脚本、世界书、变量表)。dsh-muv-engine 的伴生插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -10,7 +10,7 @@
10
10
  "./lib/*": "./lib/*",
11
11
  "./package.json": "./package.json"
12
12
  },
13
- "files": ["lib", "cordis.patch.yml", "CHANGELOG.md"],
13
+ "files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-png-card.mjs"],
14
14
  "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "muv", "magvarupdate", "tavern", "variable-table"],
15
15
  "license": "PolyForm-Noncommercial-Copyleft-1.0.0",
16
16
  "dsh": {
@@ -0,0 +1,116 @@
1
+ // Regression for the PNG character-card reader.
2
+ //
3
+ // Run: node test-png-card.mjs
4
+ //
5
+ // The reader is what lets DSH see a SillyTavern card's *original* data —
6
+ // regex scripts, world book, helper scripts. DSH's own `characters.json`
7
+ // keeps only {name, desc, first}, so if this breaks, a card dragged into
8
+ // SillyTavern silently arrives with none of its rendering machinery.
9
+ import { readPngCard, readPngChunks, extractCharaPayload, isPngCard } from './lib/png-card.js'
10
+ import { readdirSync, existsSync } from 'node:fs'
11
+ import path from 'node:path'
12
+ import os from 'node:os'
13
+
14
+ let pass = 0, fail = 0
15
+ function check(name, cond, detail) {
16
+ if (cond) { pass++; console.log(' OK ' + name) }
17
+ else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
18
+ }
19
+
20
+ console.log('=== PNG 卡解析回归 ===\n')
21
+
22
+ // 1) Synthesise a PNG so the test does not depend on any particular machine's
23
+ // card library. Build the smallest valid chunk set with a `chara` tEXt.
24
+ function buildPng(cardObj) {
25
+ const chunks = []
26
+ const chunk = (type, data) => {
27
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
28
+ const t = Buffer.from(type, 'ascii')
29
+ const crc = Buffer.alloc(4) // CRC is not validated by the reader
30
+ return Buffer.concat([len, t, data, crc])
31
+ }
32
+ const ihdr = Buffer.alloc(13)
33
+ ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4)
34
+ chunks.push(chunk('IHDR', ihdr))
35
+ const b64 = Buffer.from(JSON.stringify(cardObj), 'utf8').toString('base64')
36
+ chunks.push(chunk('tEXt', Buffer.concat([Buffer.from('chara', 'latin1'), Buffer.from([0]), Buffer.from(b64, 'latin1')])))
37
+ chunks.push(chunk('IEND', Buffer.alloc(0)))
38
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ...chunks])
39
+ }
40
+
41
+ const sampleCard = {
42
+ name: 'sample',
43
+ description: 'desc',
44
+ first_mes: 'hello',
45
+ data: {
46
+ name: 'sample',
47
+ description: 'desc text',
48
+ first_mes: 'hello there',
49
+ extensions: {
50
+ regex_scripts: [{ scriptName: 'a' }, { scriptName: 'b' }],
51
+ tavern_helper: { scripts: [{ name: 'x' }] },
52
+ },
53
+ character_book: { name: 'wb', entries: [{ comment: 'e1' }] },
54
+ },
55
+ }
56
+
57
+ console.log('[1] 合成的 PNG 能被解析')
58
+ const buf = buildPng(sampleCard)
59
+ const chunks = readPngChunks(buf)
60
+ check('chunk 遍历出 IHDR/tEXt/IEND', chunks.map(c => c.type).join(',') === 'IHDR,tEXt,IEND', chunks.map(c => c.type).join(','))
61
+ const payload = extractCharaPayload(buf)
62
+ check('chara payload 提取成功', typeof payload === 'string' && payload.length > 10)
63
+ check('payload 是 base64', /^[A-Za-z0-9+/=]+$/.test(payload || ''))
64
+ const decoded = JSON.parse(Buffer.from(payload, 'base64').toString('utf8'))
65
+ check('解出正则数量=2', (decoded.data.extensions.regex_scripts || []).length === 2)
66
+ check('解出世界书', decoded.data.character_book?.entries?.length === 1)
67
+ check('解出 helper 脚本', (decoded.data.extensions.tavern_helper.scripts || []).length === 1)
68
+
69
+ console.log('\n[2] 不是卡的 PNG 返回 null(不能瞎认)')
70
+ const plainPng = Buffer.concat([
71
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
72
+ (() => { const l = Buffer.alloc(4); l.writeUInt32BE(13); const t = Buffer.from('IHDR'); const d = Buffer.alloc(13); return Buffer.concat([l, t, d, Buffer.alloc(4)]) })(),
73
+ (() => { const l = Buffer.alloc(4); l.writeUInt32BE(0); const t = Buffer.from('IEND'); return Buffer.concat([l, t, Buffer.alloc(4)]) })(),
74
+ ])
75
+ check('无 chara 块 -> null', extractCharaPayload(plainPng) === null)
76
+ check('垃圾数据不抛异常', (() => { try { extractCharaPayload(Buffer.from('not a png')); return true } catch { return false } })())
77
+
78
+ console.log('\n[3] isPngCard')
79
+ check('.png 判定', isPngCard('a/b/c.png') === true)
80
+ check('.json 不判定为 png', isPngCard('a.json') === false)
81
+
82
+ console.log('\n[4] 真实卡库(存在才测,缺失则跳过)')
83
+ function sillyTavernDirs() {
84
+ const roots = [process.env.DSH_SILLYTAVERN_DIR, 'C:/MySpecialFolder/SillyTavern',
85
+ path.join(os.homedir(), 'SillyTavern'), path.join(os.homedir(), 'Documents', 'SillyTavern')].filter(Boolean)
86
+ const out = []
87
+ for (const root of roots) {
88
+ const dataDir = path.join(root, 'data')
89
+ if (!existsSync(dataDir)) continue
90
+ for (const user of readdirSync(dataDir)) {
91
+ const chars = path.join(dataDir, user, 'characters')
92
+ if (existsSync(chars)) out.push(chars)
93
+ }
94
+ }
95
+ return out
96
+ }
97
+ const dirs = sillyTavernDirs()
98
+ let realCards = 0, realWithRegex = 0
99
+ for (const d of dirs) {
100
+ for (const f of readdirSync(d)) {
101
+ if (!f.endsWith('.png')) continue
102
+ const card = readPngCard(path.join(d, f))
103
+ if (!card) continue
104
+ realCards++
105
+ if ((card.data.extensions?.regex_scripts || []).length > 0) realWithRegex++
106
+ }
107
+ }
108
+ if (realCards === 0) {
109
+ console.log(' SKIP 未找到 SillyTavern 卡库')
110
+ } else {
111
+ check('读到真实 PNG 卡 (' + realCards + ' 张)', realCards > 0)
112
+ check('其中带正则脚本的 (' + realWithRegex + ' 张)', realWithRegex > 0)
113
+ }
114
+
115
+ console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
116
+ process.exit(fail ? 1 : 0)