dsh-muv-table 0.2.9 → 0.2.11
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 +268 -34
- package/lib/block-generator.js +8 -0
- package/lib/client.js +4 -1
- package/lib/index.js +141 -23
- package/lib/initvar-parser.js +68 -10
- package/lib/muv-parser.js +219 -30
- package/lib/panel.html +10 -1
- package/package.json +2 -2
- package/test-muv-parser.mjs +488 -0
- package/test-preset-resolve.mjs +210 -0
package/lib/initvar-parser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// MUV Initvar parser: converts YAML-like indented initvar text to nested objects.
|
|
2
|
-
// Format: 2-space indentation, `键: 值` pairs. Supports nested objects
|
|
3
|
-
// lists (comma-separated values wrapped in [ ]).
|
|
2
|
+
// Format: 2-space indentation, `键: 值` pairs. Supports nested objects, `- item`
|
|
3
|
+
// lists, and inline lists (comma-separated values wrapped in [ ]).
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Parse an initvar text block into a nested object.
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* @returns {object} Parsed nested object
|
|
9
9
|
*/
|
|
10
10
|
export function parseInitvar(text) {
|
|
11
|
-
const lines = text.split(/\r?\n/)
|
|
11
|
+
const lines = String(text ?? '').split(/\r?\n/)
|
|
12
12
|
const root = {}
|
|
13
|
-
|
|
13
|
+
// Each frame remembers the key that opened it and the object that holds it, so
|
|
14
|
+
// a frame created as an assumed-empty object can still be turned into an array
|
|
15
|
+
// once its contents turn out to be `- item` lines.
|
|
16
|
+
const stack = [{ obj: root, indent: -1, key: null, parent: null, list: false }]
|
|
14
17
|
|
|
15
18
|
for (const rawLine of lines) {
|
|
16
19
|
const line = rawLine.trimEnd()
|
|
@@ -19,16 +22,55 @@ export function parseInitvar(text) {
|
|
|
19
22
|
const indent = line.length - line.trimStart().length
|
|
20
23
|
const trimmed = line.trim()
|
|
21
24
|
|
|
25
|
+
// A `- item` line is a list entry, not a `键: 值` pair. It used to be dropped
|
|
26
|
+
// outright (`colonIdx === -1` → continue), which silently emptied every list
|
|
27
|
+
// a card declares the way 苍玄界 does:
|
|
28
|
+
//
|
|
29
|
+
// 最近互动记录:
|
|
30
|
+
// - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。
|
|
31
|
+
//
|
|
32
|
+
// The key before it looks like an empty value, so the entry belongs to the
|
|
33
|
+
// innermost *pending* key: that key was optimistically created as `{}` and is
|
|
34
|
+
// now revealed to be an array. Checked before the stack pop because a list is
|
|
35
|
+
// allowed to sit at the same indent as its own key.
|
|
36
|
+
const itemMatch = /^-(?:\s+(.*))?$/.exec(trimmed)
|
|
37
|
+
if (itemMatch) {
|
|
38
|
+
const frame = stack[stack.length - 1]
|
|
39
|
+
if (frame.key !== null && !frame.list && Object.keys(frame.obj).length === 0) {
|
|
40
|
+
const arr = []
|
|
41
|
+
frame.parent[frame.key] = arr
|
|
42
|
+
frame.obj = arr
|
|
43
|
+
frame.list = true
|
|
44
|
+
}
|
|
45
|
+
// No pending key above us means a stray item with nowhere to go; dropping
|
|
46
|
+
// it is the same behaviour this line always had.
|
|
47
|
+
if (frame.list) frame.obj.push(parseValue((itemMatch[1] ?? '').trim()))
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
// Pop stack until we find the right parent
|
|
23
52
|
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
24
53
|
stack.pop()
|
|
25
54
|
}
|
|
26
55
|
|
|
27
|
-
const
|
|
56
|
+
const parentFrame = stack[stack.length - 1]
|
|
57
|
+
const parent = parentFrame.obj
|
|
28
58
|
const colonIdx = trimmed.indexOf(':')
|
|
29
59
|
|
|
30
60
|
if (colonIdx === -1) {
|
|
31
|
-
// Value-only line (continuation
|
|
61
|
+
// Value-only line (continuation) — not representable in this format, and
|
|
62
|
+
// dropped exactly as before. Inside a list there are no keys at all, so the
|
|
63
|
+
// raw line is kept as an item rather than vanishing.
|
|
64
|
+
if (parentFrame.list) parent.push(trimmed)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A `键: 值` line under a list is not a key either (`- a` followed by an
|
|
69
|
+
// indented `x: 1` is a list of objects, which this format cannot express).
|
|
70
|
+
// Keeping the text as an item preserves the content; writing it as a property
|
|
71
|
+
// of an array would be silently lost by JSON.stringify.
|
|
72
|
+
if (parentFrame.list) {
|
|
73
|
+
parent.push(trimmed)
|
|
32
74
|
continue
|
|
33
75
|
}
|
|
34
76
|
|
|
@@ -36,10 +78,10 @@ export function parseInitvar(text) {
|
|
|
36
78
|
const value = trimmed.slice(colonIdx + 1).trim()
|
|
37
79
|
|
|
38
80
|
if (value === '') {
|
|
39
|
-
// Nested object start
|
|
81
|
+
// Nested object start — may still turn out to be a list (see above).
|
|
40
82
|
const newObj = {}
|
|
41
83
|
parent[key] = newObj
|
|
42
|
-
stack.push({ obj: newObj, indent })
|
|
84
|
+
stack.push({ obj: newObj, indent, key, parent, list: false })
|
|
43
85
|
} else {
|
|
44
86
|
// Leaf value
|
|
45
87
|
parent[key] = parseValue(value)
|
|
@@ -52,12 +94,14 @@ export function parseInitvar(text) {
|
|
|
52
94
|
/**
|
|
53
95
|
* Parse a leaf value string to appropriate type.
|
|
54
96
|
* @param {string} val
|
|
55
|
-
* @returns {string|number|boolean}
|
|
97
|
+
* @returns {string|number|boolean|Array}
|
|
56
98
|
*/
|
|
57
99
|
function parseValue(val) {
|
|
58
100
|
// Empty string
|
|
59
101
|
if (val === '""' || val === "''") return ''
|
|
60
102
|
if (val === '{}') return '{}'
|
|
103
|
+
// Empty list — serialized that way so it does not come back as `{}`.
|
|
104
|
+
if (val === '[]') return []
|
|
61
105
|
|
|
62
106
|
// Number
|
|
63
107
|
if (/^-?\d+(\.\d+)?$/.test(val)) {
|
|
@@ -82,7 +126,16 @@ export function serializeInitvar(obj, depth = 0) {
|
|
|
82
126
|
let result = ''
|
|
83
127
|
|
|
84
128
|
for (const [key, value] of Object.entries(obj)) {
|
|
85
|
-
if (
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
// Lists are written the way the cards write them (`键:` then ` - item`), so
|
|
131
|
+
// a parsed list survives a round trip instead of collapsing into "a,b".
|
|
132
|
+
if (value.length === 0) {
|
|
133
|
+
result += `${indent}${key}: []\n`
|
|
134
|
+
} else {
|
|
135
|
+
result += `${indent}${key}:\n`
|
|
136
|
+
for (const item of value) result += `${indent} - ${formatValue(item)}\n`
|
|
137
|
+
}
|
|
138
|
+
} else if (value !== null && typeof value === 'object') {
|
|
86
139
|
result += `${indent}${key}:\n`
|
|
87
140
|
result += serializeInitvar(value, depth + 1)
|
|
88
141
|
} else {
|
|
@@ -97,5 +150,10 @@ function formatValue(val) {
|
|
|
97
150
|
if (val === null || val === undefined) return ''
|
|
98
151
|
if (typeof val === 'boolean') return val ? 'true' : 'false'
|
|
99
152
|
if (typeof val === 'number') return String(val)
|
|
153
|
+
// An empty string must stay quoted. Written bare (`键: `) the line looks like
|
|
154
|
+
// the start of a nested object, so 苍玄界's `在场角色: ""` came back as `{}`
|
|
155
|
+
// the next time the block was read — a silent type flip. The cards quote it
|
|
156
|
+
// themselves; this just keeps that convention on the way out.
|
|
157
|
+
if (val === '') return '""'
|
|
100
158
|
return String(val)
|
|
101
159
|
}
|
package/lib/muv-parser.js
CHANGED
|
@@ -17,6 +17,142 @@ import { parseInitvar } from './initvar-parser.js'
|
|
|
17
17
|
// as well park it in description/scenario, so all three are scanned.
|
|
18
18
|
const VARIABLE_TEXT_FIELDS = ['first_mes', 'description', 'scenario']
|
|
19
19
|
|
|
20
|
+
/** Arrays only — a malformed card may carry an object where a list belongs. */
|
|
21
|
+
function asArray(value) {
|
|
22
|
+
return Array.isArray(value) ? value : []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every text blob of a card that might hold `<initvar>` / `<VariableInsert>`.
|
|
27
|
+
*
|
|
28
|
+
* Besides the obvious greeting fields, two more sources carry these blocks in
|
|
29
|
+
* the field and used to be skipped entirely:
|
|
30
|
+
*
|
|
31
|
+
* - world book entries (`character_book.entries[].content`) — `[initvar]变量初始化勿开`
|
|
32
|
+
* is a world-book entry, not a greeting, in the cards that use this shape;
|
|
33
|
+
* - `tavern_helper` scripts, whose source often contains the same block.
|
|
34
|
+
*
|
|
35
|
+
* The list is ordered by preference and grouped, so a caller can ask for "the
|
|
36
|
+
* greeting-level sources first" without hardcoding field names again.
|
|
37
|
+
* @param {object} data - the card's `data` object
|
|
38
|
+
* @returns {Array<{group: string, where: string, text: string}>}
|
|
39
|
+
*/
|
|
40
|
+
function variableTextCandidates(data) {
|
|
41
|
+
const out = []
|
|
42
|
+
if (!data || typeof data !== 'object') return out
|
|
43
|
+
|
|
44
|
+
const push = (group, where, text) => {
|
|
45
|
+
if (typeof text === 'string') out.push({ group, where, text })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const field of VARIABLE_TEXT_FIELDS) push('field', field, data[field])
|
|
49
|
+
for (const greeting of asArray(data.alternate_greetings)) {
|
|
50
|
+
push('greeting', 'alternate_greetings', greeting)
|
|
51
|
+
}
|
|
52
|
+
for (const entry of asArray(data.character_book?.entries)) {
|
|
53
|
+
push('worldbook', 'character_book:' + String(entry?.comment || ''), entry?.content)
|
|
54
|
+
}
|
|
55
|
+
for (const script of asArray(data.extensions?.tavern_helper?.scripts)) {
|
|
56
|
+
push('script', 'tavern_helper:' + String(script?.name || ''), script?.content)
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** All texts belonging to the given candidate groups, in candidate order. */
|
|
62
|
+
function textsInGroups(candidates, ...groups) {
|
|
63
|
+
return candidates.filter(c => groups.includes(c.group)).map(c => c.text)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* World-book entries that hold a **bare** initvar block — no `<initvar>` tag at all.
|
|
68
|
+
*
|
|
69
|
+
* `异世界农场` keeps its entire variable tree in an entry whose comment is
|
|
70
|
+
* `[initvar]变量初始化勿开` and whose content is plain YAML:
|
|
71
|
+
*
|
|
72
|
+
* 时间:
|
|
73
|
+
* 日期: '05-20'
|
|
74
|
+
* 星期: 周日
|
|
75
|
+
* …
|
|
76
|
+
*
|
|
77
|
+
* Because there is no tag, the tag-based scan can never reach it and the card
|
|
78
|
+
* reported `schemas: 0` even though the whole tree was sitting in the world book.
|
|
79
|
+
*
|
|
80
|
+
* The prefix must be exactly `[initvar]`. The sibling conventions are **documents,
|
|
81
|
+
* not data** and reading them as YAML would build a junk table:
|
|
82
|
+
*
|
|
83
|
+
* - `[mvu_update]变量输出格式` / `[mvu_update]变量更新规则` — how to emit updates
|
|
84
|
+
* (苍玄界 and 异世界农场 both carry these);
|
|
85
|
+
* - `[mvu_plot]…` — illustration prompts.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} data - the card's `data` object
|
|
88
|
+
* @returns {Array<{comment: string, content: string}>}
|
|
89
|
+
*/
|
|
90
|
+
function worldbookInitvarEntries(data) {
|
|
91
|
+
const out = []
|
|
92
|
+
for (const entry of asArray(data?.character_book?.entries)) {
|
|
93
|
+
if (!/^\s*\[initvar\]/i.test(String(entry?.comment || ''))) continue
|
|
94
|
+
if (typeof entry?.content === 'string') out.push({ comment: String(entry.comment), content: entry.content })
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Does this text read as a YAML **mapping of variables**, rather than prose or a
|
|
101
|
+
* document that happens to mention keys?
|
|
102
|
+
*
|
|
103
|
+
* `parseInitvar` never fails — it drops what it cannot use and returns whatever is
|
|
104
|
+
* left, so "it parsed" is not evidence. This is the actual shape test used to decide
|
|
105
|
+
* whether a `[initvar]`-marked entry is data:
|
|
106
|
+
*
|
|
107
|
+
* - blanks and `# comment` lines are ignored;
|
|
108
|
+
* - `键:` / `键: 值` lines count as keys;
|
|
109
|
+
* - `- item` lines count as list content belonging to the nearest key;
|
|
110
|
+
* - **any other line means this is not a variable mapping** → reject.
|
|
111
|
+
*
|
|
112
|
+
* That last rule is what keeps a prose document out: Chinese prose uses the full-width
|
|
113
|
+
* `:`, so a sentence like `变量初始化格式说明:…` produces no key line at all, and a
|
|
114
|
+
* paragraph next to a stray `键:` line is still rejected (one foreign line is enough).
|
|
115
|
+
* @param {string} text
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function looksLikeInitvarMapping(text) {
|
|
119
|
+
let keys = 0
|
|
120
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
121
|
+
const line = rawLine.trim()
|
|
122
|
+
if (!line || line.startsWith('#')) continue
|
|
123
|
+
if (/^-(?:\s|$)/.test(line)) continue
|
|
124
|
+
if (/^[^:\s#][^:]*:(\s|$)/.test(line)) { keys++; continue }
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
return keys > 0
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The first `<initvar>` block in a list of texts, or null. */
|
|
131
|
+
function firstInitvarBlock(texts) {
|
|
132
|
+
for (const text of texts) {
|
|
133
|
+
const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
|
|
134
|
+
if (match) return { raw: match[1].trim(), parsed: parseInitvar(match[1]) }
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A card's variable data, whichever spec shape it uses.
|
|
141
|
+
*
|
|
142
|
+
* Spec-v2 cards wrap every field in `data`; spec-v1 (flat) cards put the same
|
|
143
|
+
* fields at the top level. Reading only `data` made a flat card — one
|
|
144
|
+
* `findJsonMatch` happily found by name — parse as `{name:'Unnamed'}` with 0
|
|
145
|
+
* fields, i.e. the library file was located and then thrown away.
|
|
146
|
+
* @param {object} cardJson
|
|
147
|
+
* @returns {object}
|
|
148
|
+
*/
|
|
149
|
+
function cardData(cardJson) {
|
|
150
|
+
if (!cardJson || typeof cardJson !== 'object') return {}
|
|
151
|
+
const inner = cardJson.data
|
|
152
|
+
if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
|
|
153
|
+
return cardJson
|
|
154
|
+
}
|
|
155
|
+
|
|
20
156
|
/**
|
|
21
157
|
* Drop a collection's `$template` placeholder.
|
|
22
158
|
*
|
|
@@ -46,15 +182,7 @@ export function stripPlaceholderEntries(data) {
|
|
|
46
182
|
export function extractVariableInsert(data) {
|
|
47
183
|
if (!data || typeof data !== 'object') return null
|
|
48
184
|
|
|
49
|
-
const
|
|
50
|
-
for (const field of VARIABLE_TEXT_FIELDS) {
|
|
51
|
-
if (typeof data[field] === 'string') candidates.push(data[field])
|
|
52
|
-
}
|
|
53
|
-
for (const greeting of data.alternate_greetings || []) {
|
|
54
|
-
if (typeof greeting === 'string') candidates.push(greeting)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
for (const text of candidates) {
|
|
185
|
+
for (const { text } of variableTextCandidates(data)) {
|
|
58
186
|
const match = text.match(/<VariableInsert>([\s\S]*?)<\/VariableInsert>/i)
|
|
59
187
|
if (!match) continue
|
|
60
188
|
const raw = match[1].trim()
|
|
@@ -65,6 +193,9 @@ export function extractVariableInsert(data) {
|
|
|
65
193
|
}
|
|
66
194
|
} catch (_) {
|
|
67
195
|
// Not JSON — keep looking; another block might be the real one.
|
|
196
|
+
// World-book entries and helper scripts routinely *quote* the tags in
|
|
197
|
+
// prose (「必须使用 `<VariableInsert>`」) or inside a fenced code block, and
|
|
198
|
+
// those examples are never valid JSON, so this is the common case there.
|
|
68
199
|
}
|
|
69
200
|
}
|
|
70
201
|
return null
|
|
@@ -73,11 +204,15 @@ export function extractVariableInsert(data) {
|
|
|
73
204
|
/**
|
|
74
205
|
* Parse a MUV character card JSON and extract schema + variable data.
|
|
75
206
|
* @param {object} cardJson - The full character card JSON
|
|
76
|
-
* @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string
|
|
207
|
+
* @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string,
|
|
208
|
+
* schemaSource: 'initvar'|'variable-insert'|'worldbook-initvar'|'none' }}
|
|
209
|
+
* `schemaSource` names the source the variable tree was read from ('none' when the
|
|
210
|
+
* card carries none), so a reader can tell a real tree from the heuristic fallback.
|
|
77
211
|
*/
|
|
78
212
|
export function parseMuvCard(cardJson) {
|
|
79
|
-
const data = cardJson
|
|
80
|
-
const name = data.name || cardJson?.name || 'Unnamed'
|
|
213
|
+
const data = cardData(cardJson)
|
|
214
|
+
const name = data.name || cardJson?.data?.name || cardJson?.name || 'Unnamed'
|
|
215
|
+
const candidates = variableTextCandidates(data)
|
|
81
216
|
const tavernHelper = data.extensions?.tavern_helper
|
|
82
217
|
|
|
83
218
|
// Extract Zod schema source (for reference)
|
|
@@ -87,18 +222,17 @@ export function parseMuvCard(cardJson) {
|
|
|
87
222
|
// (`z.object({...})` / `registerMvuSchema`)其实是 MUV 最主流的 schema 载体,
|
|
88
223
|
// 读不到就等于整张卡的变量表建不出来。
|
|
89
224
|
// 现在按「名字含 zod」或「内容像 Zod schema」两条线索找,兼容各种命名。
|
|
90
|
-
|
|
225
|
+
// `scripts` 不一定是数组(畸形卡),先归一化再找,否则这里会先抛 TypeError。
|
|
226
|
+
const scripts = asArray(tavernHelper?.scripts)
|
|
91
227
|
const zodScript = scripts.find(s => /zod/i.test(String(s?.name || '')))
|
|
92
228
|
|| scripts.find(s => /z\.object\s*\(|registerMvuSchema|Mvu\.|z\.string\s*\(|prefault\s*\(/.test(String(s?.content || '')))
|
|
93
229
|
const zodSource = zodScript?.content || ''
|
|
94
230
|
|
|
95
231
|
// Extract initvar blocks from alternate_greetings
|
|
96
|
-
const greetings = data.alternate_greetings || []
|
|
97
232
|
const initvarBlocks = []
|
|
98
233
|
|
|
99
|
-
for (const
|
|
100
|
-
|
|
101
|
-
const match = greeting.match(/<initvar>([\s\S]*?)<\/initvar>/i)
|
|
234
|
+
for (const text of textsInGroups(candidates, 'greeting')) {
|
|
235
|
+
const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
|
|
102
236
|
if (match) {
|
|
103
237
|
initvarBlocks.push({
|
|
104
238
|
raw: match[1].trim(),
|
|
@@ -109,24 +243,19 @@ export function parseMuvCard(cardJson) {
|
|
|
109
243
|
|
|
110
244
|
// An <initvar> may live in the opening greeting instead. Only consulted when
|
|
111
245
|
// alternate_greetings had none, so a card that already worked keeps reading
|
|
112
|
-
// from exactly the same place it used to.
|
|
246
|
+
// from exactly the same place it used to. (`scenario` is included here for the
|
|
247
|
+
// same reason `<VariableInsert>` already scans it — the two paths were
|
|
248
|
+
// asymmetric.)
|
|
113
249
|
if (initvarBlocks.length === 0) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
|
|
117
|
-
if (match) {
|
|
118
|
-
initvarBlocks.push({
|
|
119
|
-
raw: match[1].trim(),
|
|
120
|
-
parsed: parseInitvar(match[1])
|
|
121
|
-
})
|
|
122
|
-
break
|
|
123
|
-
}
|
|
124
|
-
}
|
|
250
|
+
const hit = firstInitvarBlock(textsInGroups(candidates, 'field'))
|
|
251
|
+
if (hit) initvarBlocks.push(hit)
|
|
125
252
|
}
|
|
126
253
|
|
|
127
254
|
// Use the first initvar block as default data
|
|
128
255
|
let initvarData = initvarBlocks.length > 0 ? initvarBlocks[0].parsed : {}
|
|
129
256
|
let initvarRaw = initvarBlocks.map(b => b.raw)
|
|
257
|
+
/** Where the variable tree came from — see `schemaSource` in the return value. */
|
|
258
|
+
let schemaSource = initvarRaw.length > 0 ? 'initvar' : 'none'
|
|
130
259
|
|
|
131
260
|
// No <initvar> anywhere → fall back to the community <VariableInsert> shape.
|
|
132
261
|
if (initvarRaw.length === 0) {
|
|
@@ -134,6 +263,52 @@ export function parseMuvCard(cardJson) {
|
|
|
134
263
|
if (inserted) {
|
|
135
264
|
initvarData = inserted.data
|
|
136
265
|
initvarRaw = [inserted.raw]
|
|
266
|
+
schemaSource = 'variable-insert'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Still nothing → the block may only exist in the world book or in a
|
|
271
|
+
// tavern_helper script. This is deliberately the last resort, after
|
|
272
|
+
// <VariableInsert>: those two sources are full of *quoted examples* of the
|
|
273
|
+
// syntax, and an example is far more likely to be a bare `<initvar>` skeleton
|
|
274
|
+
// (which parses into junk but never throws) than a usable JSON tree.
|
|
275
|
+
if (initvarRaw.length === 0) {
|
|
276
|
+
const hit = firstInitvarBlock(textsInGroups(candidates, 'worldbook', 'script'))
|
|
277
|
+
if (hit) {
|
|
278
|
+
initvarData = hit.parsed
|
|
279
|
+
initvarRaw = [hit.raw]
|
|
280
|
+
schemaSource = 'initvar'
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Last of the last resorts: a world-book entry marked `[initvar]…` whose content is
|
|
285
|
+
// bare YAML with no tag at all (see worldbookInitvarEntries). Four gates:
|
|
286
|
+
//
|
|
287
|
+
// ① only after every source above came up empty — a card that already parses from
|
|
288
|
+
// a greeting must not change where it reads from (native `<initvar>` wins);
|
|
289
|
+
// ② the content must actually read as a YAML mapping (looksLikeInitvarMapping),
|
|
290
|
+
// not as prose/documentation;
|
|
291
|
+
// ③ the caller is told where the tree came from via `schemaSource`, so this
|
|
292
|
+
// heuristic is visible in the API response instead of being silent;
|
|
293
|
+
// ④ the parsed mapping must be non-empty.
|
|
294
|
+
//
|
|
295
|
+
// **`enabled` is deliberately not part of the filter.** The entry this fix exists for
|
|
296
|
+
// — 异世界农场's `[initvar]变量初始化勿开` — is itself `enabled: false`, and its
|
|
297
|
+
// comment literally says 「勿开」(do not enable). That is the convention, not an
|
|
298
|
+
// accident: authors keep the variable tree in a *disabled* entry so it is never
|
|
299
|
+
// injected into the prompt, while tooling can still read it. Skipping disabled
|
|
300
|
+
// entries would make this whole fallback a no-op on the only card that needs it
|
|
301
|
+
// (measured: repro-wb-initvar.mjs). What keeps junk out is the exact `[initvar]`
|
|
302
|
+
// marker plus gate ② — a disabled entry *without* that marker is never read.
|
|
303
|
+
if (initvarRaw.length === 0) {
|
|
304
|
+
for (const entry of worldbookInitvarEntries(data)) {
|
|
305
|
+
if (!looksLikeInitvarMapping(entry.content)) continue
|
|
306
|
+
const parsed = parseInitvar(entry.content)
|
|
307
|
+
if (!parsed || typeof parsed !== 'object' || Object.keys(parsed).length === 0) continue
|
|
308
|
+
initvarData = parsed
|
|
309
|
+
initvarRaw = [String(entry.content).trim()]
|
|
310
|
+
schemaSource = 'worldbook-initvar'
|
|
311
|
+
break
|
|
137
312
|
}
|
|
138
313
|
}
|
|
139
314
|
|
|
@@ -145,7 +320,8 @@ export function parseMuvCard(cardJson) {
|
|
|
145
320
|
schemas,
|
|
146
321
|
initvarData,
|
|
147
322
|
initvarBlocks: initvarRaw,
|
|
148
|
-
zodSource
|
|
323
|
+
zodSource,
|
|
324
|
+
schemaSource
|
|
149
325
|
}
|
|
150
326
|
}
|
|
151
327
|
|
|
@@ -180,6 +356,19 @@ function inferSchemaFromData(data, name = '') {
|
|
|
180
356
|
description: '',
|
|
181
357
|
children: []
|
|
182
358
|
})
|
|
359
|
+
} else if (Array.isArray(value)) {
|
|
360
|
+
// A `- item` list. The panel edits one row per field, so the items are
|
|
361
|
+
// joined with newlines for display; `initvarData` keeps the real array and
|
|
362
|
+
// serializeInitvar writes it back as `- item` lines. Without this branch a
|
|
363
|
+
// list fell into the `String(value)` case and showed up as "a,b".
|
|
364
|
+
fields.push({
|
|
365
|
+
name: key,
|
|
366
|
+
type: 'string',
|
|
367
|
+
defaultValue: value.map(v => (v === null || v === undefined) ? '' : String(v)).join('\n'),
|
|
368
|
+
description: '',
|
|
369
|
+
children: [],
|
|
370
|
+
isList: true
|
|
371
|
+
})
|
|
183
372
|
} else {
|
|
184
373
|
fields.push({
|
|
185
374
|
name: key,
|
package/lib/panel.html
CHANGED
|
@@ -46,6 +46,15 @@ body { font-family: 'Segoe UI', 'Noto Sans SC', system-ui, sans-serif; backgroun
|
|
|
46
46
|
</div>
|
|
47
47
|
<script>
|
|
48
48
|
let state = { cardName: '', schemas: [], initvarData: {}, edits: {}, generatedBlock: '' }
|
|
49
|
+
// Where the variable tree was read from, shown next to the card name. `worldbook-initvar`
|
|
50
|
+
// is the only *heuristic* source (a world-book entry marked `[initvar]` with bare YAML
|
|
51
|
+
// and no tag), so it has to be visible — otherwise a wrong guess looks exactly like a
|
|
52
|
+
// card that genuinely declares its variables.
|
|
53
|
+
const SOURCE_LABEL = { 'initvar': '<initvar> 块', 'variable-insert': '<VariableInsert>', 'worldbook-initvar': '世界书 [initvar] 条目', 'none': '' }
|
|
54
|
+
function badgeText(d) {
|
|
55
|
+
const label = SOURCE_LABEL[d.schemaSource]
|
|
56
|
+
return d.name + (label ? ' · ' + label : '')
|
|
57
|
+
}
|
|
49
58
|
function handleDrop(e) { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadFile(f) }
|
|
50
59
|
function handleFile(e) { const f = e.target.files[0]; if (f) loadFile(f) }
|
|
51
60
|
function loadFile(f) { const r = new FileReader(); r.onload = () => { try { parseCard(JSON.parse(r.result)) } catch(err) { alert('JSON error: '+err.message) } }; r.readAsText(f) }
|
|
@@ -55,7 +64,7 @@ async function parseCard(json) {
|
|
|
55
64
|
const r = await fetch('/api/muv-table/parse-card', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify(json) })
|
|
56
65
|
const d = await r.json(); if (!d.ok) throw new Error(d.error)
|
|
57
66
|
state = { cardName: d.name, schemas: d.schemas, initvarData: d.initvarData, edits: {}, generatedBlock: '' }
|
|
58
|
-
document.getElementById('badge').style.display = 'inline'; document.getElementById('badge').textContent = d
|
|
67
|
+
document.getElementById('badge').style.display = 'inline'; document.getElementById('badge').textContent = badgeText(d)
|
|
59
68
|
document.getElementById('btnGenerate').style.display = 'inline-block'
|
|
60
69
|
renderTable()
|
|
61
70
|
} catch(e) { document.getElementById('main').innerHTML = '<div style="padding:12px;color:var(--danger)">⚠ '+e.message+'</div>' }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-muv-table",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理;支持直接读取 SillyTavern PNG 角色卡(含正则脚本、世界书、变量表)。dsh-muv-engine 的伴生插件",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"./lib/*": "./lib/*",
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
|
-
"files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-png-card.mjs"],
|
|
13
|
+
"files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-png-card.mjs", "test-muv-parser.mjs", "test-preset-resolve.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": {
|