dsh-muv-table 0.2.3 → 0.2.6
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 +74 -0
- package/README.md +41 -0
- package/lib/client.js +41 -0
- package/lib/index.js +345 -36
- package/lib/png-card.js +126 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.6 (2026-09-20)
|
|
4
|
+
|
|
5
|
+
### 🐛 修复:变量面板显示错误的卡(显示成苍玄界)
|
|
6
|
+
|
|
7
|
+
两个独立原因,都会让面板拿到**别的预设**的卡:
|
|
8
|
+
|
|
9
|
+
- **无 `presetId` 时回退到硬编码预设**:面板只在收到 `tavern-preset-changed`
|
|
10
|
+
事件后才设置 `lastPresetId`,而页面首次加载时该事件不会触发,请求因此不带
|
|
11
|
+
参数;服务端于是回退到写死的 `tavern-lite`,把那个预设的卡显示出来。
|
|
12
|
+
现在新增 `/api/muv-table/active-preset`,面板在首次加载前先问服务端
|
|
13
|
+
「当前是哪个预设」,服务端按**最近写入的会话**所绑定的预设来回答。
|
|
14
|
+
- **过期 managed copy**:`tavern-lite/muv-tables/card.json` 里是 8 月留下的
|
|
15
|
+
苍玄界,而该预设的 `characters.json` 早已改成川上富江。现在 `card.json`
|
|
16
|
+
的卡名与预设记录不一致时不再采用,转而走卡库匹配,避免张冠李戴。
|
|
17
|
+
|
|
18
|
+
### ✨ 支持读取 SillyTavern 的 PNG 角色卡
|
|
19
|
+
|
|
20
|
+
- **新增 `lib/png-card.js`**:从 PNG 的 `tEXt`/`iTXt` 元数据里解出 `chara` 块
|
|
21
|
+
(base64 编码的卡 JSON)。这是 TavernAI 系生态的标准做法——图片本身就是卡,
|
|
22
|
+
角色数据搭车存在文件里,所以一张 3.5 MB 的「卡」实际是封面图 + 约 1.5 MB 的
|
|
23
|
+
base64 卡数据。
|
|
24
|
+
- **为什么需要**:DSH 自己的导入器只把 `{name, desc, first, enabled}` 写进
|
|
25
|
+
`characters.json`,**正则脚本、世界书、tavern_helper 全部丢失**。所以一张拖进
|
|
26
|
+
SillyTavern 的卡,在 DSH 这边只能拿到 3 KB 文本残影。现在直接读原始 PNG 文件。
|
|
27
|
+
- **卡库搜索路径接入 SillyTavern**:自动发现
|
|
28
|
+
`<SillyTavern>/data/<user>/characters`(含 `C:/MySpecialFolder/SillyTavern`、
|
|
29
|
+
`~/SillyTavern`、`~/Documents/SillyTavern`),可用 `DSH_SILLYTAVERN_DIR`
|
|
30
|
+
覆盖。预设目录里直接放 `.png` 也会被识别。
|
|
31
|
+
- `findJsonMatch` 同时接受 `.json` 与 `.png`,按卡名匹配。
|
|
32
|
+
|
|
33
|
+
实测:SillyTavern 卡库 5 张 PNG 卡全部读出,其中 4 张带回完整正则脚本
|
|
34
|
+
(足控天堂 10 个、异世界农场 3 个、涩涩提瓦特 2 个、食人世界 2 个)。
|
|
35
|
+
|
|
36
|
+
### 🧪 测试
|
|
37
|
+
|
|
38
|
+
- 新增 `test-png-card.mjs`:12 项,覆盖合成 PNG 解析、无 `chara` 块拒绝、
|
|
39
|
+
垃圾数据不抛异常,以及真实卡库验证(卡库不存在时自动跳过)。
|
|
40
|
+
|
|
41
|
+
## v0.2.5 (2026-09-20)
|
|
42
|
+
|
|
43
|
+
### 🐛 修复:变量面板读不到已导入的卡
|
|
44
|
+
|
|
45
|
+
- **新增 `extractMuvFromCharactersJson`**:酒馆把导入的卡存在
|
|
46
|
+
`<预设目录>/characters.json`。此前面板只认两条路——预设里的
|
|
47
|
+
`muv-tables/card.json`,或按卡名在外部卡库目录里找原始 JSON 文件。
|
|
48
|
+
导入的卡往往两条都不满足(原始文件根本没下载到本机),于是接口返回
|
|
49
|
+
`found:false`、变量面板空白,**而数据其实就在 characters.json 里**。
|
|
50
|
+
现在第三条路直接从那里提取变量表。
|
|
51
|
+
- 同时支持两种编码:`<VariableInsert>{…纯 JSON…}`(社区卡常见)与
|
|
52
|
+
MUV 原生的 `<UpdateVariable><initvar>…</initvar></UpdateVariable>`。
|
|
53
|
+
- 过滤掉 `主播档案.$template` 占位条目,它不是真实角色。
|
|
54
|
+
|
|
55
|
+
### 🐛 修复:角色卡读取路径过窄
|
|
56
|
+
|
|
57
|
+
- `MUV_CARD_DIRS` 之前硬编码了某一台机器的下载目录,换台机器就找不到卡。
|
|
58
|
+
现在按顺序尝试:预设目录 `muv-tables/card.json`、`DSH_MUV_CARD_DIRS`
|
|
59
|
+
环境变量、系统下载目录、桌面。
|
|
60
|
+
- 角色卡名可从酒馆的 `characters.json` 读取,不再依赖单一 yml 字段。
|
|
61
|
+
- `tavern-card` 接口支持按 `presetId` / `sessionId` 取卡,并返回
|
|
62
|
+
`regexScripts` 与 `data.extensions.regex_scripts` 两种正则脚本形态。
|
|
63
|
+
|
|
64
|
+
## v0.2.4 (2026-09-13)
|
|
65
|
+
|
|
66
|
+
### 📝 文档
|
|
67
|
+
|
|
68
|
+
- `package.json` 的 `description` 补上「是 `dsh-muv-engine` 的伴生插件」。
|
|
69
|
+
插件市场与 npm 的条目会带上这句,避免用户只装了引擎却奇怪变量表格为什么不显示。
|
|
70
|
+
- 同步收录到 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
71
|
+
精选列表(`category: ui`),与另外两个插件一起。
|
|
72
|
+
|
|
73
|
+
> 无代码改动。未改动许可证内容。
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
3
77
|
## v0.2.3 (2026-09-13)
|
|
4
78
|
|
|
5
79
|
### 🐛 修复
|
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
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
322
|
-
|
|
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
|
-
|
|
604
|
+
} else if (sessionId) {
|
|
605
|
+
presetDir = presetDirForSession(sessionId)
|
|
326
606
|
} else {
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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,
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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 })
|
package/lib/png-card.js
ADDED
|
@@ -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
|
-
"description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick
|
|
3
|
+
"version": "0.2.6",
|
|
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": {
|