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 CHANGED
@@ -1,38 +1,44 @@
1
- # Changelog
2
-
3
- ## v0.2.8 (2026-09-20)
4
-
5
- ### 🐛 修复:Zod「变量结构」整类读不到(覆盖面最大的一个)
6
-
7
- parseMuvCard() 取 Zod schema 时用的是**精确等值** `s.name === 'zod'`,而真机上的卡
8
- 几乎不这么命名(「变量结构」「星辉zod」…)——**一个都对不上**,zodSource 恒为空。
9
- Zod(`z.object({...})` / `registerMvuSchema`)是 MUV 最主流的 schema 载体,
10
- 读不到等于整张卡的变量表建不出来,而且是静默失败。
11
-
12
- 现在按两条线索找:**名字含 zod**,或**内容像 Zod schema**(`z.object(` /
13
- `registerMvuSchema` / `prefault(` 等)。
14
- 实测:`_足控天堂2` 0 → **148106** 字符;`异世界农场` 0 → **953** 字符。
15
-
16
- ### 🐛 修复:面板把 `@deepseek-ai/dsh-persona` 当卡名显示
17
-
18
- `extractMuvFromYml()` 的 `^\s*name:` 兜底会吃到 YAML 里的**服务声明**
19
- (`name: '@deepseek-ai/dsh-persona'`)。隔壁 `extractCardNameFromYml()` 有
20
- `@`/`/` 守卫,这里漏了。面板会把它显示成卡名并写进 localStorage 粘住。已补上同一守卫。
21
-
22
- ### 🐛 修复:`<VariableInsert>` 形态的卡建不出变量表
23
-
24
- (由上一版引入的 `extractVariableInsert()` 修复,此处一并记录)
25
- `_足控天堂2` 的变量树在 `first_mes` 的 `<VariableInsert>{JSON}</VariableInsert>` 里,
26
- 卡中**没有 `<initvar>`**,而 PNG 卡主路径走的 `parseMuvCard()` 只扫
27
- `alternate_greetings` 的 `<initvar>` → `schemas: 0`。现在 `parseMuvCard()` 在
28
- `initvarBlocks` 为空时回退到 `<VariableInsert>`,`<initvar>` 卡行为完全不变。
29
- 实测 `schemas` 0 → **8**(世界信息/主角信息/公司/道具系统/剧情事件/因特网/剧情选项/主播档案)。
30
-
31
- > 回归:`node test-png-card.mjs` 12 → **28** 项;
32
- > 复现脚本 `repro-variableinsert.mjs` 留在仓库里可直接跑(进程内挂真实路由,不需要重启 DSH)。
33
-
34
- ## Unreleased
1
+ # Changelog
35
2
 
3
+ ## v0.2.11 (2026-09-20)
4
+ ### 🐛 修复:世界书里「没有 `<initvar>` 标签、只有 `[initvar]` 注释前缀」的裸 YAML 变量树读不到
5
+
6
+ **现象**:`异世界农场` 的整棵变量树**不在卡文本里**,而在世界书条目
7
+ `[initvar]变量初始化勿开` 的 `content` 里,是裸 YAML:
8
+
9
+ ```
10
+ 时间:
11
+ 日期: '05-20'
12
+ 星期: 周日
13
+ 种族好感度:
14
+ 凛原族: 0
15
+ …
16
+ ```
17
+
18
+ 它**没有 `<initvar>` 标签**,所以按标签扫描的候选集
19
+ (`first_mes` / `description` / `scenario` / `alternate_greetings` / 世界书 / helper)
20
+ 永远扫不到 → 该卡 `schemas` 恒为 `0`,面板一片空白。
21
+
22
+ **修法**(`lib/muv-parser.js`):新增 `worldbookInitvarEntries()`(找 `comment` 以
23
+ `[initvar]` 开头的世界书条目)与 `looksLikeInitvarMapping()`(content 必须**读起来像
24
+ 变量映射**:`键:` / `键: 值` 行算键、`- item` 算列表内容、空行与 `#` 注释跳过,
25
+ **只要有一行不属于这三类就否决**)。四道闸:① 只在上面所有来源都落空时启用;
26
+ ② content 必须过形状判定;③ 解析出的映射非空;④ 响应里标
27
+ `schemaSource: 'worldbook-initvar'`,来源可见(面板上显示在卡名后面)。
28
+
29
+ **★ 为什么它会读一条 `enabled: false` 的条目**(这条请照原样保留,否则会被当 bug 报回来):
30
+ 要读的那条条目**本身就是被禁用的**,而且 `comment` 里就写着「变量初始化**勿开**」。
31
+ 这是卡作者的**约定**:把变量树放进一条禁用条目 → 它不会被注入上下文,但工具链仍然
32
+ 可以读到(「勿开」正是这个意思的字面声明)。所以判定只认 `[initvar]` 这个**显式标记**,
33
+ `enabled` 不参与;没有该标记的禁用条目**永远不读**。挡住垃圾的是「标记 + 形状判定」,
34
+ 不是 `enabled`。若要求条目必须启用,这条兜底在唯一需要它的卡上就是空转。
35
+
36
+ **实测**:`异世界农场` schemas **0 → 3**(时间 / 种族好感度 / 个人好感度),
37
+ `schemaSource = worldbook-initvar`;`苍玄界`(对应条目叫 `[mvu_update]变量输出格式`,
38
+ 是**文档**不是数据)、`_足控天堂2`、`食人世界`、`涩涩提瓦特`、`default_Seraphina`
39
+ **逐字节无回归**;`test-muv-parser.mjs` 56 → 76 项。
40
+
41
+ ## v0.2.10 (2026-09-20)
36
42
  ### 🐛 修复:变量面板读不到「足控天堂2」这种卡的变量表
37
43
 
38
44
  **现象**(已复现):`GET /api/muv-table/tavern-card?presetId=…` 对这张卡返回
@@ -90,6 +96,234 @@ initvarBlocks: 0
90
96
  > 而是放在世界书条目 `[initvar]变量初始化勿开` 的 `content` 里,因此它的
91
97
  > `schemas` 依然是 0。那是另一套投递方式(世界书),不是本次的
92
98
  > `<VariableInsert>` 问题,需要单独决定是否要让面板读世界书。
99
+ >
100
+ > **更新(下一节「变量块来源」)**:世界书/脚本里**带 `<initvar>` / `<VariableInsert>`
101
+ > 标签**的块现在能读了。但 `异世界农场` 那条世界书条目是**没有标签的裸 YAML**
102
+ > (content 里只有 `时间:`、`种族好感度:` 这样的裸文本),按标签扫描依然扫不到,
103
+ > 它的 `schemas` 仍是 0 —— 这一条需要一条**新规则**才能接住,见下一节末尾
104
+ > 「仍未修 / 待决定」。
105
+
106
+ ---
107
+
108
+ ### 🔴 修复:initvar 列表值被吞成空对象(内容静默丢失)
109
+
110
+ **现象**(`苍玄界` 实测,已复现):卡里写的是
111
+
112
+ ```
113
+ 最近互动记录:
114
+ - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。
115
+ ```
116
+
117
+ 解析结果却是 `最近互动记录 = {}` —— **内容消失,且不进 schema**。该卡 initvar 共 65 个
118
+ 叶子行,`人际交往.结识道友录.沈慕微.最近互动记录` 与 `人际交往.结识道友录.江念.最近互动记录`
119
+ 两条路径**都不在 schema 里**(含这两条路径的叶子 = 0)。
120
+
121
+ **根因**:`lib/initvar-parser.js` 两处配合出事——
122
+
123
+ - 空值建空对象:`键: ` 后面为空就被当成「嵌套对象的开始」;
124
+ - 无冒号的行直接丢弃:紧随其后的 `- item` 是列表项、没有冒号,被整行 `continue` 掉。
125
+
126
+ 于是「空值 + 下一行 `- `」这种写法(`苍玄界`、以及 MUV 原生卡的常规形态)必然退化成空对象。
127
+
128
+ **修法**(`lib/initvar-parser.js` 为主):
129
+
130
+ - 栈帧改为记录「开启它的键 + 持有它的父对象」。`- item` 行归属**最内层待定键**:
131
+ 该键若仍是空对象,就说明它其实是列表 → 原地替换成数组再追加。判定放在出栈**之前**
132
+ (列表允许与它的键同缩进)。
133
+ - 列表项按标量解析(`- 3` → `3`);列表下再出现的 `键: 值` 保留成条目文本,不静默丢行。
134
+ - `serializeInitvar()` 把数组写回 `键:` + ` - item`(空数组写 `[]`),`parseValue('[]')` → `[]`,
135
+ 列表**往返一致**。
136
+ - `inferSchemaFromData()` / `index.js: inferSchemas()` / `client.js: renderField`:
137
+ 列表行按换行拼接显示并带 `isList: true`;`applyEditsAndGenerate()` 把「编辑成多行文本」的列表行
138
+ 再切回数组,生成块仍是 `- ` 列表,不会被拍平成 `a,b`。
139
+
140
+ **实测数据**:
141
+
142
+ - `repro-initvar-list.mjs`:改前 **7 项不通过** → 改后全通过;叶子行 **65 → 67**。
143
+ - 真实卡往返一致性:`苍玄界`(16 个 initvar 块)+ 其 `.json` 共 **34 块**,
144
+ 解析 → 序列化 → 解析 **逐字节一致**(改前 34 块里 32 块不一致)。
145
+ - 引擎端到端(不改 `dsh-muv-engine`,只调用它):`parseLatestInitvar → mergeState → generateBlock`
146
+ 用 `苍玄界` 真实问候语跑通,合并后列表仍在,生成行是
147
+ ` - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。`,回读一致。
148
+
149
+ ### 🔴 附带发现:`键: ""` 往返变成 `{}`(类型静默翻转)
150
+
151
+ **现象**:`苍玄界` 的 `世界系统.在场角色: ""`,生成块时被写成裸的 `在场角色: `,
152
+ 下次解析回来变成 `{}`(空对象)—— 同一个字段的形状在往返中变了。
153
+ **根因**:`parseValue('""') → ''`,而 `formatValue('') → ''`;写出去的那一行看起来正是「空值 = 嵌套对象开始」。
154
+ **修法**:`formatValue('')` 输出 `""`(卡片自己就是这么写的,等于沿用卡的惯例)。
155
+ **实测**:上面那条 34 块往返一致性用例就是它的哨兵 —— 改前所有不一致都出自这一处,改后为 0。
156
+
157
+ ### 🔴 修复:`presetId` 不存在时静默返回另一张卡
158
+
159
+ **现象**(已复现):`GET /api/muv-table/tavern-card?presetId=does-not-exist`
160
+
161
+ ```
162
+ HTTP 200 { ok:true, found:true, cardName:'川上富江', presetDir:'tavern-lite' }
163
+ ```
164
+
165
+ 用户以为在看自己的卡,其实拿到的是 tavern-lite 的卡。预设改名(历史上「深渊区」→「精简酒馆」)
166
+ 或重名,一样中招。
167
+
168
+ **根因**:`lib/index.js` 里 `let presetDir = …; if (!fs.existsSync(presetDir)) presetDir = TAVERN_PRESET_DIR`
169
+ —— 任何一步定位失败都**无条件**换成硬编码的 tavern-lite,而且响应里没有任何字段说明「这不是你要的那张」。
170
+
171
+ **修法**:定位结果分来源,且**失败就明说**:
172
+
173
+ | 请求 | 行为 |
174
+ | --- | --- |
175
+ | `presetId` 存在 | 用它,`presetSource: 'explicit'` |
176
+ | `presetId` 不存在 | `found:false`,`error: preset not found: <id>`,附 `availablePresets` 清单 |
177
+ | `sessionId` 绑定到真实预设 | 用它,`presetSource: 'session'` |
178
+ | `sessionId` 绑定**已消失**(改名) | `found:false`,`error: preset bound to session … not found: <旧名>` |
179
+ | `sessionId` 绑定为 `default` / 无绑定 | 用酒馆自己的默认(tavern-lite),`presetSource: 'session-default'` |
180
+ | 两个定位参数都没给 | 按「最近写入的会话」猜,`presetSource: 'active'`;猜不到才用默认,`presetSource: 'default'` |
181
+
182
+ - `default` 这条**是照抄酒馆的规则**,不是猜:`dsh-tavern` 的
183
+ `lib/index.js:2404` 就是 `if (currentPresetId === 'default') currentPresetId = 'tavern-lite'`,
184
+ `GET /api/tavern/presets` 也返回 `defaultPresetId: 'tavern-lite'`。
185
+ 区别在于现在响应里**标明了**来源,而不是混在 `found:true` 里让人看不出来。
186
+ - `presetId` / `sessionId` 一律当**目录名**解析,带 `\` `/` 或 `..` 的直接判不存在(顺带收紧路径穿越)。
187
+ - 成功响应统一新增 `presetSource`;`/api/muv-table/active-preset` 不再返回
188
+ `fallback: 'tavern-lite'`(那个字段暗示仍会回退到固定预设,已不成立)。
189
+
190
+ **实测数据**:`repro-preset-not-found.mjs` 改前 **4 项不通过**(复现「静默换卡」)→ 改后全通过;
191
+ 新增 `test-preset-resolve.mjs` 用**临时 DSH_HOME** 造 6 个假预设/假卡,**30 项全通过**
192
+ (含 `?sessionId=` 的四种绑定形态,以及畸形卡不再 500)。
193
+
194
+ ### 🟠 修复:变量块的来源只扫 4 个字段,世界书 / helper 脚本读不到
195
+
196
+ **现象**:`<initvar>` / `<VariableInsert>` 只可能在 `first_mes` / `description` / `scenario` /
197
+ `alternate_greetings` 里被找到。写在 `character_book.entries[].content` 或
198
+ `tavern_helper` 脚本里的变量块,整张卡的变量表就是空的。
199
+
200
+ **修法**(`lib/muv-parser.js`):新增 `variableTextCandidates(data)`,把四个字段、世界书条目
201
+ (`character_book.entries[].content`,`where` 带上 `comment` 便于排查)、
202
+ `extensions.tavern_helper.scripts[].content` 一起纳入候选集,并给每个候选打**分组**,
203
+ 让调用方能按「问候语级来源优先」取用。优先级(**先到先得**):
204
+
205
+ 1. `alternate_greetings` 里的 `<initvar>`(16 个都要,行为不变)
206
+ 2. `first_mes` → `description` → `scenario` 里的 `<initvar>`(顺带把 `scenario` 补上 ——
207
+ `<VariableInsert>` 一直认它,`<initvar>` 不认,两条路本来就不对称)
208
+ 3. 任意来源(含世界书/脚本)里的 `<VariableInsert>`,第一个能 `JSON.parse` 成对象的块
209
+ 4. **最后**才看世界书/脚本里的 `<initvar>`
210
+
211
+ 第 4 步排在 `<VariableInsert>` 之后是刻意的:世界书和脚本里塞满了**引用标签的示例**
212
+ (`苍玄界` 的 `[mvu_update]变量输出格式`、`_足控天堂2` 的 `ERA 变量操作规则/意图说明`),
213
+ 示例更可能是「能解析成垃圾、但不会抛异常」的裸 `<initvar>` 骨架,所以把它压到最后。
214
+ **非法 JSON 继续往后找、全程不抛异常**这条行为有专门的用例守着。
215
+
216
+ **实测数据**:`repro-parser-robustness.mjs` 改前 9 项不通过 → 改后全通过。
217
+ `_足控天堂2` 的 8 组 schema、`公司.总现金 = 40000` 与 `主播档案.$template` 剔除全部不变
218
+ (世界书里那两份文档块**没有**被误命中)。
219
+
220
+ ### 🟠 修复:畸形卡让端点 500
221
+
222
+ **现象**:`alternate_greetings` 是**对象**(不是数组)时,`data.alternate_greetings || []`
223
+ 之后的 `for...of` 抛 `TypeError: greetings is not iterable` →
224
+ `/api/muv-table/tavern-card` 直接 500。同理还有两处:`tavern_helper.scripts` 不是数组时
225
+ `scripts.find is not a function`;世界书 `entries` 不是数组时同样炸。
226
+
227
+ **修法**:`asArray()` 统一归一化 `alternate_greetings` / `character_book.entries` /
228
+ `tavern_helper.scripts`;条目为 `null` 也跳过。
229
+
230
+ **实测数据**:改前抛 3 个 TypeError(脚本里的 `scripts.find` 是复现时才发现的第三处),
231
+ 改后 `parseMuvCard(null)`、对象版 `alternate_greetings`、`entries: [null, 7, …]` 全部不抛异常;
232
+ 端点用例里那张畸形卡从 **500 → 200 + `schemas: []`**(同张卡别的内容照旧返回)。
233
+
234
+ ### 🟠 修复:扁平 V1 JSON 卡读成 0 字段
235
+
236
+ **现象**:`findJsonMatch` 能按卡名找到**没有 `data` 包装**的扁平 V1 卡(`{name, description,
237
+ first_mes, …}`),但 `parseMuvCard` 只认 `cardJson.data` → 卡名对了、变量表 0 字段。
238
+ `regexScriptsOf()` 同样只认 `cardJson.data.extensions.regex_scripts`,扁平卡的正则脚本也一起丢。
239
+
240
+ **修法**:新增 `cardData(cardJson)`:有对象型 `data` 就用它,否则**把卡本身当 data**
241
+ (规格 v1 就是这样)。`regexScriptsOf()` 用同一套判定。
242
+
243
+ **实测数据**:扁平卡的 `<initvar>` / `<VariableInsert>` 从 0 → 正常建表;
244
+ 有 `data` 包装的卡仍然以 `data` 为准(回归用例守住)。
245
+
246
+ ### 🧪 本轮新增的哨兵 / 回归
247
+
248
+ | 文件 | 作用 |
249
+ | --- | --- |
250
+ | `repro-initvar-list.mjs` | ① 最小复现(`苍玄界` 真实卡)。改前 7 失败 → 改后 0 |
251
+ | `repro-preset-not-found.mjs` | ② 最小复现。改前 4 失败 → 改后 0 |
252
+ | `repro-parser-robustness.mjs` | ③④⑤ 最小复现。改前 9 失败 → 改后 0 |
253
+ | `test-muv-parser.mjs` | 解析器回归 **56 项**(含真实卡往返一致性、列表 schema 形状、编辑回写、畸形卡) |
254
+ | `test-preset-resolve.mjs` | 端点回归 **30 项**(临时 DSH_HOME,不碰真实环境) |
255
+ | `test-cards.mjs` | 测试用的真实卡定位辅助(找不到就 SKIP,不失败) |
256
+
257
+ 原有基线不变:`node test-png-card.mjs` **28 通过**;
258
+ `node C:\dsh-muv-engine\test-status-cascade.mjs` **84 通过**。
259
+
260
+ ### ⏭ 仍未修 / 待决定:世界书里**没有标签**的 `[initvar]` 条目
261
+
262
+ `异世界农场.png` 的初始值放在世界书条目 `[initvar]变量初始化勿开` 的 content 里,
263
+ 但那份 content 是**裸 YAML、没有 `<initvar>` 标签**:
264
+
265
+ ```
266
+ 时间:
267
+ 日期: '05-20'
268
+ …
269
+ 种族好感度: …
270
+ 个人好感度: {}
271
+ ```
272
+
273
+ 所以按标签扫描(本节这套)扫不到它,该卡 `schemas` 仍是 0(它的变量表实际由
274
+ `tavern_helper` 里的 Zod 结构定义,`zodSource` 953 字符,已能读到,但面板是用
275
+ `initvarData` 建行的)。要接住它需要一条**新**规则,例如:
276
+
277
+ > 彻底找不到变量块时,再找 `comment` 以 `[initvar]` 开头的世界书条目,把 `content`
278
+ > 当 initvar 块解析(预计 `异世界农场` 会从 0 变成 `时间` / `种族好感度` / `个人好感度` 三组)。
279
+
280
+ **本轮没做**:它超出「扫这两个来源里的标签」的范围,而且会引入「注释前缀」这一层新判定
281
+ (`苍玄界` 的对应条目叫 `[mvu_update]变量输出格式`,是文档不是数据 —— 两个前缀是有区别的,
282
+ 但 `[initvar]` 万一也是文档就会解析出垃圾表)。留待决定。现状已用
283
+ `test-muv-parser.mjs` 的第 [10] 节**钉住**(含「没有标签」这条断言),改动必然是有意的。
284
+
285
+ ## v0.2.9 (2026-09-20)
286
+ ### 🐛 修复:面板「不管点哪个会话都显示同一张卡」
287
+
288
+ 服务端无问题(按 `presetId` 请求时每个预设都返回各自的卡)。根因在客户端:它一直发同一个
289
+ 预设——因为它依赖酒馆 DOM,而 `#tavern-session-preset-label` 的 `dataset.presetId`
290
+ 在切换会话时不一定更新(酒馆自己 `bundle.js:1655` 用 `savedPid || 旧值`,`savedPid`
291
+ 为空时**保留旧值**),`localStorage` 那份更是切换预设时才写。**切换会话根本不会更新它们。**
292
+
293
+ 改为**优先用当前 DSH 会话 id 请求**:服务端按 `session-bindings.json` 查出该会话绑定的预设,
294
+ 切换会话必然跟着变,不会粘。实测 4/5 会话正确解析(第 5 个 `minimal-gitbash` 本身无卡)。
295
+
296
+ 注意只传一个定位参数且会话 id 优先——服务端判定顺序是 `if (presetId) … else if (sessionId) …`,
297
+ 两个都传时**可能粘住的那个会盖掉可靠的**。
298
+ ## v0.2.8 (2026-09-20)
299
+ ### 🐛 修复:Zod「变量结构」整类读不到(覆盖面最大的一个)
300
+
301
+ parseMuvCard() 取 Zod schema 时用的是**精确等值** `s.name === 'zod'`,而真机上的卡
302
+ 几乎不这么命名(「变量结构」「星辉zod」…)——**一个都对不上**,zodSource 恒为空。
303
+ Zod(`z.object({...})` / `registerMvuSchema`)是 MUV 最主流的 schema 载体,
304
+ 读不到等于整张卡的变量表建不出来,而且是静默失败。
305
+
306
+ 现在按两条线索找:**名字含 zod**,或**内容像 Zod schema**(`z.object(` /
307
+ `registerMvuSchema` / `prefault(` 等)。
308
+ 实测:`_足控天堂2` 0 → **148106** 字符;`异世界农场` 0 → **953** 字符。
309
+
310
+ ### 🐛 修复:面板把 `@deepseek-ai/dsh-persona` 当卡名显示
311
+
312
+ `extractMuvFromYml()` 的 `^\s*name:` 兜底会吃到 YAML 里的**服务声明**
313
+ (`name: '@deepseek-ai/dsh-persona'`)。隔壁 `extractCardNameFromYml()` 有
314
+ `@`/`/` 守卫,这里漏了。面板会把它显示成卡名并写进 localStorage 粘住。已补上同一守卫。
315
+
316
+ ### 🐛 修复:`<VariableInsert>` 形态的卡建不出变量表
317
+
318
+ (由上一版引入的 `extractVariableInsert()` 修复,此处一并记录)
319
+ `_足控天堂2` 的变量树在 `first_mes` 的 `<VariableInsert>{JSON}</VariableInsert>` 里,
320
+ 卡中**没有 `<initvar>`**,而 PNG 卡主路径走的 `parseMuvCard()` 只扫
321
+ `alternate_greetings` 的 `<initvar>` → `schemas: 0`。现在 `parseMuvCard()` 在
322
+ `initvarBlocks` 为空时回退到 `<VariableInsert>`,`<initvar>` 卡行为完全不变。
323
+ 实测 `schemas` 0 → **8**(世界信息/主角信息/公司/道具系统/剧情事件/因特网/剧情选项/主播档案)。
324
+
325
+ > 回归:`node test-png-card.mjs` 12 → **28** 项;
326
+ > 复现脚本 `repro-variableinsert.mjs` 留在仓库里可直接跑(进程内挂真实路由,不需要重启 DSH)。
93
327
 
94
328
  ## v0.2.7 (2026-09-20)
95
329
 
@@ -47,6 +47,14 @@ function setNestedValue(obj, path, value) {
47
47
  }
48
48
  const lastKey = keys[keys.length - 1]
49
49
 
50
+ // A list row is edited as newline-joined text; splitting it back keeps the
51
+ // field a list so the generated block still writes `- item` lines instead of
52
+ // collapsing the list into one comma-joined string.
53
+ if (Array.isArray(current[lastKey]) && typeof value === 'string') {
54
+ current[lastKey] = value.split('\n').map(s => s.trim()).filter(s => s !== '')
55
+ return
56
+ }
57
+
50
58
  // Coerce types
51
59
  if (typeof current[lastKey] === 'number' || (!isNaN(current[lastKey]) && current[lastKey] !== '' && current[lastKey] !== null && current[lastKey] !== undefined)) {
52
60
  const num = Number(value)
package/lib/client.js CHANGED
@@ -318,7 +318,10 @@ window.__ModuleLoader__.load({
318
318
  // ============ render a field node recursively ============
319
319
  const renderField = (field, data, prefix, depth) => {
320
320
  const path = prefix?prefix+'.'+field.name:field.name
321
- const val = data?.[field.name]
321
+ const raw = data?.[field.name]
322
+ // A `- item` list arrives as an array; this row is one input, so join the
323
+ // items with newlines instead of letting React render "a,b".
324
+ const val = Array.isArray(raw)?raw.join('\n'):raw
322
325
  const isContainer = field.type==='object' && field.children && field.children.length>0
323
326
  const isCollapsed = collapsed.has(path)
324
327
  const changed = state.edits[path]!==undefined
package/lib/index.js CHANGED
@@ -33,7 +33,6 @@ function resolveDshHome() {
33
33
  // apply() 时按 DSH 实际 home 重新绑定
34
34
  let DSH_HOME = resolveDshHome()
35
35
  let PRESETS_ROOT = path.join(DSH_HOME, '.agent-presets')
36
- let TAVERN_PRESET_DIR = path.join(PRESETS_ROOT, 'tavern-lite')
37
36
  let SESSION_BINDINGS_FILE = path.join(PRESETS_ROOT, 'session-bindings.json')
38
37
 
39
38
  /**
@@ -49,7 +48,6 @@ function bindDshPaths(ctx) {
49
48
  if (!home) home = resolveDshHome()
50
49
  DSH_HOME = home
51
50
  PRESETS_ROOT = path.join(home, '.agent-presets')
52
- TAVERN_PRESET_DIR = path.join(PRESETS_ROOT, 'tavern-lite')
53
51
  SESSION_BINDINGS_FILE = path.join(PRESETS_ROOT, 'session-bindings.json')
54
52
  }
55
53
 
@@ -164,16 +162,68 @@ function scanAllPresetCards() {
164
162
  }
165
163
 
166
164
  /** Resolve preset dir for a session id via session-bindings.json */
167
- function presetDirForSession(sessionId) {
165
+ function presetIdForSession(sessionId) {
168
166
  try {
169
167
  const bindPath = SESSION_BINDINGS_FILE
170
168
  if (sessionId && fs.existsSync(bindPath)) {
171
169
  const bind = JSON.parse(fs.readFileSync(bindPath, 'utf8'))
172
170
  const preset = bind[sessionId]
173
- if (preset && preset !== 'default') return path.join(PRESETS_ROOT, preset)
171
+ if (preset && preset !== 'default') return String(preset)
174
172
  }
175
173
  } catch (_) {}
176
- return TAVERN_PRESET_DIR
174
+ return ''
175
+ }
176
+
177
+ /** Names of the presets that actually exist — for diagnostics when one misses. */
178
+ function availablePresetIds() {
179
+ try {
180
+ return fs.readdirSync(PRESETS_ROOT, { withFileTypes: true })
181
+ .filter(d => d.isDirectory())
182
+ .map(d => d.name)
183
+ .sort()
184
+ } catch (_) {
185
+ return []
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The preset the tavern itself uses when a session has none.
191
+ *
192
+ * `GET /api/tavern/presets` answers `defaultPresetId: 'tavern-lite'`, and the
193
+ * tavern maps an explicit `default` binding onto it the same way
194
+ * (`if (currentPresetId === 'default') currentPresetId = 'tavern-lite'`). So
195
+ * falling back here is *not* a guess — it is the tavern's own rule, and the only
196
+ * reason this plugin is allowed to use it is that the response now says so via
197
+ * `presetSource`.
198
+ * @returns {string} absolute directory, or '' when it does not exist
199
+ */
200
+ function defaultPresetDir() {
201
+ return resolvePresetDirById('tavern-lite')
202
+ }
203
+
204
+ /**
205
+ * Resolve one preset id to an existing directory *inside* PRESETS_ROOT.
206
+ *
207
+ * Returns '' when the preset does not exist, and the caller must report that as
208
+ * `found:false`. It used to fall back to `TAVERN_PRESET_DIR`, which meant a
209
+ * mistyped or renamed preset id silently served a *different* preset's card
210
+ * (`?presetId=does-not-exist` → tavern-lite's 川上富江) while the panel said
211
+ * nothing — the user had no way to tell they were looking at someone else's card.
212
+ *
213
+ * Ids are directory names, never paths: separators and `..` are rejected so a
214
+ * request cannot walk out of the preset root.
215
+ * @param {string} id
216
+ * @returns {string} absolute directory, or ''
217
+ */
218
+ function resolvePresetDirById(id) {
219
+ const name = String(id || '').trim()
220
+ if (!name || name === '.' || name === '..' || /[\\/]/.test(name)) return ''
221
+ const dir = path.join(PRESETS_ROOT, name)
222
+ try {
223
+ return fs.statSync(dir).isDirectory() ? dir : ''
224
+ } catch (_) {
225
+ return ''
226
+ }
177
227
  }
178
228
 
179
229
  /**
@@ -337,7 +387,11 @@ function loadRawCardForPreset(presetDir, cardName) {
337
387
  * @returns {object[]}
338
388
  */
339
389
  function regexScriptsOf(cardJson) {
340
- const scripts = cardJson?.data?.extensions?.regex_scripts
390
+ // Spec-v2 wraps fields in `data`, spec-v1 (flat) keeps them at the top level —
391
+ // reading only `data` dropped every regex script of a flat card, which is what
392
+ // makes the rendering engine work at all.
393
+ const data = (cardJson?.data && typeof cardJson.data === 'object') ? cardJson.data : cardJson
394
+ const scripts = data?.extensions?.regex_scripts
341
395
  return Array.isArray(scripts) ? scripts : []
342
396
  }
343
397
 
@@ -386,7 +440,8 @@ function extractMuvFromCharactersJson(presetDir) {
386
440
  schemas: inferSchemas(clean),
387
441
  initvarData: clean,
388
442
  initvarBlocks: [body],
389
- zodSource: ''
443
+ zodSource: '',
444
+ schemaSource: 'variable-insert'
390
445
  }
391
446
  } catch (_) {
392
447
  // Fall through: the block may still be YAML-ish.
@@ -402,7 +457,8 @@ function extractMuvFromCharactersJson(presetDir) {
402
457
  schemas: inferSchemas(parsed),
403
458
  initvarData: parsed,
404
459
  initvarBlocks: [iv[1]],
405
- zodSource: ''
460
+ zodSource: '',
461
+ schemaSource: 'initvar'
406
462
  }
407
463
  }
408
464
  return null
@@ -430,10 +486,10 @@ function extractMuvFromYml(yml) {
430
486
  const blockText = m[1]
431
487
  const parsed = parseInitvar(blockText)
432
488
  const schemas = inferSchemas(parsed)
433
- return { name, schemas, initvarData: parsed, initvarBlocks: [blockText], zodSource: '' }
489
+ return { name, schemas, initvarData: parsed, initvarBlocks: [blockText], zodSource: '', schemaSource: 'initvar' }
434
490
  }
435
491
  // No initvar default — serve empty-but-valid so the panel opens empty
436
- return { name, schemas: [], initvarData: {}, initvarBlocks: [], zodSource: '' }
492
+ return { name, schemas: [], initvarData: {}, initvarBlocks: [], zodSource: '', schemaSource: 'none' }
437
493
  } catch (_) {
438
494
  return null
439
495
  }
@@ -449,6 +505,16 @@ function inferSchemas(data) {
449
505
  if (children.length > 0) fields.push({ name: key, type: 'object', defaultValue: '{}', description: '', children })
450
506
  } else if (typeof value === 'number') {
451
507
  fields.push({ name: key, type: 'number', defaultValue: String(value), description: '', children: [] })
508
+ } else if (Array.isArray(value)) {
509
+ // `- item` list: shown as newline-joined text, kept as an array in the data.
510
+ fields.push({
511
+ name: key,
512
+ type: 'string',
513
+ defaultValue: value.map(v => (v === null || v === undefined) ? '' : String(v)).join('\n'),
514
+ description: '',
515
+ children: [],
516
+ isList: true
517
+ })
452
518
  } else {
453
519
  fields.push({ name: key, type: 'string', defaultValue: String(value ?? ''), description: '', children: [] })
454
520
  }
@@ -581,7 +647,10 @@ export function apply(ctx) {
581
647
  handler: (req, res) => {
582
648
  try {
583
649
  const presetId = activePresetId()
584
- json(res, 200, { ok: true, presetId, fallback: presetId ? '' : path.basename(TAVERN_PRESET_DIR) })
650
+ // No `fallback` name any more: falling back to a fixed preset is what
651
+ // made the panel show an unrelated card. An empty presetId now means
652
+ // "cannot tell", and tavern-card says so instead of guessing.
653
+ json(res, 200, { ok: true, presetId, fallback: '' })
585
654
  } catch (e) {
586
655
  json(res, 500, { ok: false, error: e.message })
587
656
  }
@@ -597,21 +666,64 @@ export function apply(ctx) {
597
666
  const sessionId = url.searchParams.get('sessionId') || ''
598
667
 
599
668
  // Resolve the preset: explicit id > the session's bound preset >
600
- // whichever preset the user is actually in. The last resort used to be
601
- // a hardcoded `tavern-lite`, which made the panel show that preset's
602
- // card (a stale 苍玄界 copy) whenever the client had no preset id yet
603
- // — which is the common case, since the id only arrives via a
604
- // `tavern-preset-changed` event that never fires on a fresh page.
605
- let presetDir
669
+ // whichever preset the user is actually in > the tavern's own default.
670
+ //
671
+ // Every step that missed used to end in `presetDir = TAVERN_PRESET_DIR`
672
+ // *silently*, so an unknown id (a renamed preset, a stale id cached in
673
+ // the client, a typo) served that preset's card while the panel said
674
+ // nothing — the user had no way to tell they were looking at someone
675
+ // else's card. The default is still the right answer for a session the
676
+ // tavern itself considers unbound (`default` ⇒ `tavern-lite`), but it is
677
+ // no longer the answer for an id that was asked for by name; those
678
+ // report `found:false` with the reason, and every hit reports how it was
679
+ // located in `presetSource`.
680
+ let presetDir = ''
681
+ let presetSource = ''
682
+ let missing = ''
683
+ let requested = ''
606
684
  if (presetId) {
607
- presetDir = path.join(PRESETS_ROOT, presetId)
685
+ requested = String(presetId)
686
+ presetDir = resolvePresetDirById(presetId)
687
+ if (presetDir) presetSource = 'explicit'
688
+ else missing = 'preset not found: ' + requested
608
689
  } else if (sessionId) {
609
- presetDir = presetDirForSession(sessionId)
610
- } else {
690
+ const bound = presetIdForSession(sessionId)
691
+ requested = bound
692
+ if (!bound || bound === 'default') {
693
+ // The session has no preset of its own — the tavern uses its
694
+ // default for it, so that is the honest answer.
695
+ presetDir = defaultPresetDir()
696
+ if (presetDir) presetSource = 'session-default'
697
+ } else {
698
+ presetDir = resolvePresetDirById(bound)
699
+ if (presetDir) presetSource = 'session'
700
+ else missing = 'preset bound to session ' + sessionId + ' not found: ' + bound
701
+ }
702
+ }
703
+ if (!presetDir && !missing) {
611
704
  const active = activePresetId()
612
- presetDir = active ? path.join(PRESETS_ROOT, active) : TAVERN_PRESET_DIR
705
+ if (active) {
706
+ requested = active
707
+ presetDir = resolvePresetDirById(active)
708
+ if (presetDir) presetSource = 'active'
709
+ else missing = 'active preset not found: ' + active
710
+ }
711
+ }
712
+ if (!presetDir && !missing) {
713
+ presetDir = defaultPresetDir()
714
+ if (presetDir) presetSource = 'default'
715
+ }
716
+ if (!presetDir) {
717
+ json(res, 200, {
718
+ ok: true, found: false,
719
+ error: missing || 'no preset could be resolved',
720
+ reason: missing ? 'preset-not-found' : 'no-preset-resolved',
721
+ requestedPresetId: requested || undefined,
722
+ availablePresets: availablePresetIds(),
723
+ regexScripts: []
724
+ })
725
+ return
613
726
  }
614
- if (!fs.existsSync(presetDir)) presetDir = TAVERN_PRESET_DIR
615
727
  const ymlPath = path.join(presetDir, 'agent.cordis.yml')
616
728
 
617
729
  let yml = fs.existsSync(ymlPath) ? readYmlSafe(ymlPath) : ''
@@ -629,6 +741,7 @@ export function apply(ctx) {
629
741
  cardSource: raw.source,
630
742
  cardName,
631
743
  presetDir: path.basename(presetDir),
744
+ presetSource,
632
745
  ...parseMuvCard(raw.json),
633
746
  // Consumers read the scripts from either shape; keep both so an
634
747
  // existing reader keeps working while the engine migrates.
@@ -651,6 +764,7 @@ export function apply(ctx) {
651
764
  cardSource: 'characters',
652
765
  cardName: fromStore.cardName || cardName,
653
766
  presetDir: path.basename(presetDir),
767
+ presetSource,
654
768
  name: fromStore.name,
655
769
  schemas: fromStore.schemas,
656
770
  initvarData: fromStore.initvarData,
@@ -666,12 +780,16 @@ export function apply(ctx) {
666
780
  ok: true, found: true,
667
781
  fileName: path.basename(presetDir) + '/agent.cordis.yml',
668
782
  cardName, presetDir: path.basename(presetDir),
783
+ presetSource,
669
784
  ...parsed,
670
785
  regexScripts: [],
671
786
  data: { extensions: { regex_scripts: [] } }
672
787
  })
673
788
  } else {
674
- json(res, 200, { ok: true, found: false, error: 'no card in preset', regexScripts: [] })
789
+ json(res, 200, {
790
+ ok: true, found: false, error: 'no card in preset',
791
+ presetDir: path.basename(presetDir), presetSource, regexScripts: []
792
+ })
675
793
  }
676
794
  } catch (e) {
677
795
  json(res, 500, { ok: false, error: e.message })