@zaofan/dsh-qqbot 1.5.7 → 1.5.8

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/settings-host.js CHANGED
@@ -128,25 +128,66 @@ function readEntryConfigFromPatch(entryId) {
128
128
  if (ci < 0) return null;
129
129
  const cfgIndent = (block[ci].match(/^\s*/) || [''])[0].length;
130
130
  const out = {};
131
- for (let i = ci + 1; i < block.length; i++) {
132
- const raw = block[i];
133
- if (!raw.trim()) continue;
134
- const ind = (raw.match(/^\s*/) || [''])[0].length;
135
- if (ind <= cfgIndent) break; // config 子树结束
136
- if (ind > cfgIndent + 2) continue; // 嵌套子块/数组项 → 跳过
137
- const m = /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
138
- if (!m) continue;
139
- let v = m[2].trim();
140
- if (v === '' || v.startsWith('!!js')) continue; // 空值 / 表达式 → 跳过
141
- v = v.replace(/^['"]|['"]$/g, '');
142
- if (v === 'true') v = true;
143
- else if (v === 'false') v = false;
144
- else if (/^-?\d+$/.test(v)) v = Number(v);
145
- out[m[1]] = v;
146
- }
147
- for (const k of Object.keys(out)) {
148
- if (/secret|token|password|_key$/i.test(k)) out[k] = '__REDACTED__';
149
- }
131
+ const parseScalar = (v) => {
132
+ const s = String(v).trim().replace(/^['"]|['"]$/g, '');
133
+ if (s === 'true') return true;
134
+ if (s === 'false') return false;
135
+ if (s === 'null' || s === '~') return null;
136
+ if (/^-?\d+$/.test(s)) return Number(s);
137
+ return s;
138
+ };
139
+ // 递归解析(2026-09-24:之前只取一层,groupAdmin 这类嵌套对象的子字段全被跳过 →
140
+ // 前端读回来是 undefined,勾选后"保存了却跳回去")
141
+ const parseBlock = (from, to, indent) => {
142
+ const obj = {};
143
+ for (let i = from; i < to; i++) {
144
+ const raw = block[i];
145
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
146
+ if (((raw.match(/^\s*/) || [''])[0]).length !== indent) continue;
147
+ const m = /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
148
+ if (!m) continue;
149
+ const key = m[1];
150
+ const rest = m[2].trim();
151
+ let subEnd = i + 1;
152
+ while (subEnd < to) {
153
+ const l = block[subEnd];
154
+ if (l.trim() && ((l.match(/^\s*/) || [''])[0]).length <= indent) break;
155
+ subEnd++;
156
+ }
157
+ if (rest === '') {
158
+ const inner = block.slice(i + 1, subEnd);
159
+ if (inner.some((l) => /^\s*-\s/.test(l))) {
160
+ const arr = [];
161
+ for (const l of inner) {
162
+ const am = /^\s*-\s*(.*)$/.exec(l);
163
+ if (am && am[1].trim()) arr.push(parseScalar(am[1]));
164
+ }
165
+ obj[key] = arr;
166
+ } else {
167
+ obj[key] = parseBlock(i + 1, subEnd, indent + 2);
168
+ }
169
+ } else if (rest === '[]') {
170
+ obj[key] = [];
171
+ } else if (rest.startsWith('!!js')) {
172
+ obj[key] = rest; // 表达式:原样返回(前端只读展示)
173
+ } else {
174
+ obj[key] = parseScalar(rest);
175
+ }
176
+ i = subEnd - 1;
177
+ }
178
+ return obj;
179
+ };
180
+ const parsed = parseBlock(ci + 1, block.length, cfgIndent + 2);
181
+ const redact = (o) => {
182
+ if (!o || typeof o !== 'object') return o;
183
+ for (const k of Object.keys(o)) {
184
+ if (/secret|token|password|_key$/i.test(k)) o[k] = '__REDACTED__';
185
+ else if (o[k] && typeof o[k] === 'object') redact(o[k]);
186
+ }
187
+ return o;
188
+ };
189
+ redact(parsed);
190
+ for (const [k, v] of Object.entries(parsed)) out[k] = v;
150
191
  return Object.keys(out).length ? out : null;
151
192
  } catch { return null; }
152
193
  }
@@ -163,6 +204,75 @@ function viewOf(settings, ns) {
163
204
  return { value: undefined, revision: undefined };
164
205
  }
165
206
 
207
+ /**
208
+ * ── 插件自有设置存储(2026-09-24)──
209
+ *
210
+ * 为什么不再写 profile 的 `cordis.patch.yml`:
211
+ * ① 0.1.7 下写 patch 会被宿主当**热更新**提交 → 插件重新 apply → 本插件启动时若凭据无效会再写
212
+ * → 自反馈闭环(实测把 dsh 启动刷死、无限弹二维码);
213
+ * ② 手拼 YAML 保不住字段类型(实测 appId 写成裸数字、owners 空数组写成空串 → 宿主 schema 校验失败);
214
+ * ③ 不支持"新增键"(groupAdmin.pollJoinRequests 这类写不进去)。
215
+ *
216
+ * 改用插件自己的 JSON 存储:不触发任何热更新、JSON 天生有类型、随便加键,
217
+ * 而且**用户不需要手改任何配置文件**(可视化的初衷)。
218
+ * 位置:{DSH_HOME}/qqbot-settings/<instanceNs>.json
219
+ */
220
+ const OWN_SETTINGS_DIR = join(DSH_HOME_DIR, 'qqbot-settings');
221
+ function ownSettingsPath(ns) {
222
+ const safe = String(ns || NS).replace(/[^A-Za-z0-9_-]/g, '_');
223
+ return join(OWN_SETTINGS_DIR, safe + '.json');
224
+ }
225
+ function readOwnSettings(ns) {
226
+ try {
227
+ const p = ownSettingsPath(ns);
228
+ if (!existsSync(p)) return {};
229
+ const o = JSON.parse(readFileSync(p, 'utf8'));
230
+ return o && typeof o === 'object' ? o : {};
231
+ } catch { return {}; }
232
+ }
233
+ function writeOwnSettings(ns, obj) {
234
+ const p = ownSettingsPath(ns);
235
+ try { mkdirSync(OWN_SETTINGS_DIR, { recursive: true }); } catch { /* ignore */ }
236
+ try { if (existsSync(p)) writeFileSync(p + '.bak-' + Date.now(), readFileSync(p)); } catch { /* 备份失败不阻塞 */ }
237
+ writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8');
238
+ return p;
239
+ }
240
+ /** 深合并一层(够用:本插件嵌套只到 groupAdmin / sticker.gates 这一层)。 */
241
+ function mergeDeepOne(cur, patch) {
242
+ const out = Object.assign({}, cur);
243
+ for (const [k, v] of Object.entries(patch || {})) {
244
+ if (v === undefined) continue;
245
+ if (v === '__REDACTED__') continue; // 脱敏占位符绝不写回
246
+ if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) {
247
+ out[k] = Object.assign({}, out[k], v);
248
+ } else {
249
+ out[k] = v;
250
+ }
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /** 按 (条目, 缩进层级, 键名) 去掉重复键,保留**最后一次**出现(YAML 语义)。
256
+ * 2026-09-24 兜底:写 patch 前必跑。重复键会让宿主的 YAML 严格解析直接抛错
257
+ * (YAMLException: duplicated mapping key)→ dsh 完全启动不了。 */
258
+ function dedupeYamlKeys(text) {
259
+ const lines = String(text).split(/\r?\n/);
260
+ const scope = ['']; const seen = new Map(); const keep = new Array(lines.length).fill(true);
261
+ let cur = '';
262
+ for (let i = 0; i < lines.length; i++) {
263
+ const l = lines[i];
264
+ if (!l.trim() || l.trimStart().startsWith('#')) continue;
265
+ const mId = /^(\s*)-\s*id:\s*(\S+)\s*$/.exec(l);
266
+ if (mId) { cur = mId[2]; seen.clear(); continue; }
267
+ const mK = /^(\s*)([A-Za-z0-9_-]+):/.exec(l);
268
+ if (!mK) continue;
269
+ const sig = cur + '|' + mK[1].length + '|' + mK[2];
270
+ if (seen.has(sig)) keep[seen.get(sig)] = false;
271
+ seen.set(sig, i);
272
+ }
273
+ return lines.filter((_, i) => keep[i]).join('\n');
274
+ }
275
+
166
276
  /** 请求里的目录参数(dataDir 显式传; 空=primary 主账号) */
167
277
  function dirOf(u, key) {
168
278
  return (u?.searchParams?.get(key) || '').trim() || undefined;
@@ -232,22 +342,37 @@ export function apply(ctx) {
232
342
  // ── 设置面板读写桥(多账号: ?ns= 选实例命名空间; 缺省 im-qqbot 主账号) ──
233
343
  route(ctx, 'GET', '/api/qqbot-settings/read', async (req, res) => {
234
344
  const u = new URL(req.url ?? '/', 'http://x');
235
- writeJson(res, 200, viewOf(ctx.settings, u.searchParams.get('ns') || undefined));
345
+ const ns = u.searchParams.get('ns') || NS;
346
+ const base = viewOf(ctx.settings, ns) || {};
347
+ const own = readOwnSettings(ns);
348
+ // 自有存储优先:它就是用户在设置页保存的那份
349
+ const value = Object.assign({}, base.value || {}, own);
350
+ writeJson(res, 200, { value, revision: base.revision, source: Object.keys(own).length ? 'own' : 'patch' });
236
351
  });
237
352
  route(ctx, 'POST', '/api/qqbot-settings/update', async (req, res) => {
238
353
  const body = await readJsonBody(req);
239
354
  if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be JSON object' });
240
355
  const ns = typeof body.ns === 'string' && body.ns ? body.ns : NS;
241
- try {
242
- // revision = -1 是"兜底视图"标记(宿主 describe 里没有本 ns,如 dsh 0.1.7)
243
- // → 没有可信基线,跳过乐观锁,否则保存必报 SETTINGS_CONFLICT
244
- const exp = typeof body.expectedRevision === 'number' && body.expectedRevision >= 0 ? body.expectedRevision : undefined;
245
- await ctx.settings.update(ns, body.patch ?? {}, exp);
246
- writeJson(res, 200, viewOf(ctx.settings, ns));
356
+ // ⚠️ 2026-09-24 起:保存一律写**插件自有存储**({DSH_HOME}/qqbot-settings/<ns>.json),
357
+ // 绝不写 profile 的 cordis.patch.yml —— 写 patch 会被宿主当热更新提交,本插件启动时
358
+ // 若凭据无效会再写配置,形成自反馈闭环(实测把 dsh 启动刷死、无限弹二维码)。
359
+ // 自有存储还让「用户不必手改任何配置文件」(可视化的初衷)。
360
+ try {
361
+ const cur = readOwnSettings(ns);
362
+ const merged = mergeDeepOne(cur, body.patch ?? {});
363
+ const file = writeOwnSettings(ns, merged);
364
+ // 通知插件实例重新读取(桥与插件同进程;不写宿主配置 → 不触发任何热更新)
365
+ try { ctx.emit('qqbot/settings-changed', ns); } catch { /* 无监听者也无妨 */ }
366
+ const base = viewOf(ctx.settings, ns) || {};
367
+ writeJson(res, 200, {
368
+ value: Object.assign({}, base.value || {}, merged),
369
+ revision: base.revision,
370
+ mode: 'own',
371
+ file,
372
+ note: '已保存(插件自有存储,热生效)',
373
+ });
247
374
  } catch (e) {
248
- const code = e?.code ?? '';
249
- if (code === 'SETTINGS_CONFLICT' || code === 'SettingsConflictError') return writeJson(res, 409, { error: e.message });
250
- writeJson(res, 400, { error: String(e?.message ?? e) });
375
+ writeJson(res, 500, { error: '保存失败:' + String(e?.message ?? e) });
251
376
  }
252
377
  });
253
378
 
@@ -577,11 +702,22 @@ export function apply(ctx) {
577
702
 
578
703
  if (orig) {
579
704
  const kv = {};
580
- if (inst.appId !== undefined) kv.appId = yq(String(inst.appId));
581
- if (secret) kv.appSecret = ysec(secret);
582
- if (inst.preset !== undefined && String(inst.preset) !== '') kv.preset = yq(String(inst.preset));
583
- if (inst.cwd !== undefined && String(inst.cwd) !== '') kv.cwd = yq(String(inst.cwd));
705
+ // ✅ 2026-09-24:已有账号的 appId/appSecret/preset/cwd 只写**自有存储**,不再写 patch。
706
+ // 原因:patch 里 - insert: 下挂多条目时,updateKeysInBlock 的块边界会跨到隔壁条目,
707
+ // 实测把 im-qqbot-2 的凭据写进了 mcp-chrome、并给 im-qqbot-3 追加重复键。
708
+ // 插件启动时自有存储会覆盖 patch 值,所以只写自有存储完全等效,且绝不会写歪。
709
+ // disabled 例外:它是宿主的 entry 属性、不在 config 里,必须继续写 patch。
710
+ const ownKv = {};
711
+ if (inst.appId !== undefined) ownKv.appId = String(inst.appId);
712
+ if (secret) ownKv.appSecret = String(secret);
713
+ if (inst.preset !== undefined && String(inst.preset) !== '') ownKv.preset = String(inst.preset);
714
+ if (inst.cwd !== undefined && String(inst.cwd) !== '') ownKv.cwd = String(inst.cwd);
715
+ try {
716
+ const nsKey = String(inst.settingsNs || inst.id);
717
+ writeOwnSettings(nsKey, Object.assign(readOwnSettings(nsKey), ownKv));
718
+ } catch { /* 自有存储失败 → 退化到写 patch(下面仍会处理 disabled) */ }
584
719
  if (inst.disabled !== undefined) kv.disabled = inst.disabled ? 'true' : 'false';
720
+ if (Object.keys(kv).length) updates.push({ id: String(inst.id), kv });
585
721
  updates.push({ id: String(inst.id), kv });
586
722
  } else {
587
723
  appends.push(renderBotBlock({ ...inst, appSecret: secret, dataRoot }));
@@ -603,6 +739,27 @@ export function apply(ctx) {
603
739
  let out = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
604
740
  if (appends.length) out += (out.trimEnd() ? '\n' : '') + appends.join('\n') + '\n';
605
741
  writeFileSync(`${PATCH_FILE}.bak`, raw, 'utf8');
742
+ // ✅ 2026-09-24 兜底①:写前按 (条目,缩进,键) 去重。
743
+ // 起因:`- insert:` 下挂多条目时块边界会跨到隔壁条目,曾把凭据写进 mcp-chrome、
744
+ // 并给 im-qqbot-3 追加重复键;重复键会让宿主 YAML 解析抛错、dsh 完全起不来。
745
+ out = dedupeYamlKeys(out);
746
+ // ✅ 2026-09-24 兜底②:把凭据同步写进插件自有存储。
747
+ // 插件启动时自有存储**覆盖** patch 的值,所以即便 patch 被写歪,机器人照样用正确凭据连上。
748
+ try {
749
+ for (const inst of instances) {
750
+ if (inst.remove) continue;
751
+ const nsKey = String(inst.settingsNs || inst.id);
752
+ const cur = readOwnSettings(nsKey);
753
+ let sec = String(inst.appSecret ?? '');
754
+ if (!sec || sec === SECRET_MASK) sec = cur.appSecret ? String(cur.appSecret) : '';
755
+ if (inst.appId !== undefined) cur.appId = inst.appId;
756
+ if (sec) cur.appSecret = sec;
757
+ if (inst.preset) cur.preset = String(inst.preset);
758
+ if (inst.cwd) cur.cwd = String(inst.cwd);
759
+ if (inst.disabled !== undefined) cur.disabled = !!inst.disabled;
760
+ writeOwnSettings(nsKey, cur);
761
+ }
762
+ } catch { /* 自有存储失败不阻塞 patch 写入 */ }
606
763
  writeFileSync(PATCH_FILE, out, 'utf8');
607
764
  return { ok: true, file: PATCH_FILE, needRestart: true };
608
765
  }
@@ -1089,6 +1246,124 @@ export function apply(ctx) {
1089
1246
  return { ok: true, backup: bak, newId, from: 'document' };
1090
1247
  }
1091
1248
 
1249
+ /** YAML 标量序列化。forceQuote=true 时强制加引号(保留原字段的字符串类型 —— 例如 appId
1250
+ * 的 schema 是 string,裸写数字会被 YAML 解析成 number → 宿主 schema 校验失败)。 */
1251
+ function yScalar(v, forceQuote) {
1252
+ if (v === null || v === undefined) return "''";
1253
+ if (typeof v === 'boolean') return String(v);
1254
+ const s = String(v);
1255
+ if (typeof v === 'number' && !forceQuote) return s; // 真正的数字字段(historyLimit 等)保持数字
1256
+ if (forceQuote) return "'" + s.replace(/'/g, "''") + "'";
1257
+ if (typeof v === 'string') {
1258
+ if (s === '' || /[:#'"\[\]{}&*!|>%@`,]/.test(s) || /^\s|\s$/.test(s) || /^(true|false|null|~|-?\d)/i.test(s)) {
1259
+ return "'" + s.replace(/'/g, "''") + "'";
1260
+ }
1261
+ }
1262
+ return s;
1263
+ }
1264
+ /** 把值序列化成"一行 YAML"(数组走流式,避免多行插入)。keepType 提示按原字段类型保真。 */
1265
+ function yValue(v, keepQuoted, origWasArray) {
1266
+ if (Array.isArray(v)) {
1267
+ if (!v.length) return '[]';
1268
+ return '[' + v.map((it) => yScalar(it, typeof it === 'string')).join(', ') + ']';
1269
+ }
1270
+ if (v === '' || v === null || v === undefined) {
1271
+ // ⚠️ 空值必须看原字段类型:原本是数组的(owners)写成 `''` 会让宿主 schema 校验失败
1272
+ return origWasArray ? '[]' : "''";
1273
+ }
1274
+ return yScalar(v, keepQuoted || typeof v === 'string');
1275
+ }
1276
+
1277
+ /**
1278
+ * 把一批字段合并进 profile patch 里某个 entry 的 `config`(dsh 0.1.7 路径,2026-09-24)。
1279
+ *
1280
+ * 为什么需要:0.1.7 的 `settings.update()` 只接受 **schema 里声明为 volatile** 的字段
1281
+ * (源码:`const form = volatileForm(schema); if (!form) throw new Error('Plugin entry "x" has no volatile fields')`),
1282
+ * 本插件用自定义 config、没有 schemastery schema → 保存必被拒。这里改为直接改 profile patch:
1283
+ * **已存在的字段就地替换值**;patch 里没有的字段记入 `missing` 返回给前端(不擅自新增结构)。
1284
+ * 支持一层嵌套对象。写前自动备份,换行逐行保真。
1285
+ */
1286
+ function mergeEntryConfigInPatch(entryId, patch) {
1287
+ try {
1288
+ if (!existsSync(PATCH_FILE)) return { ok: false, error: 'profile patch 不存在' };
1289
+ if (!patch || typeof patch !== 'object') return { ok: false, error: 'patch 必须是对象' };
1290
+ const raw = readFileSync(PATCH_FILE, 'utf8');
1291
+ const parts = raw.split(/(\r\n|\n)/);
1292
+ const lineTexts = [];
1293
+ const eols = [];
1294
+ for (let i = 0; i < parts.length; i++) {
1295
+ if (i % 2 === 0) lineTexts.push(parts[i]);
1296
+ else eols.push(parts[i]);
1297
+ }
1298
+ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1299
+ const headRe = new RegExp('^\\s*-\\s*id:\\s*[\'"]?' + esc(entryId) + '[\'"]?\\s*$');
1300
+ const start = lineTexts.findIndex((l) => headRe.test(l));
1301
+ if (start < 0) return { ok: false, error: '在 profile patch 里找不到 entry ' + entryId };
1302
+ const headIndent = (lineTexts[start].match(/^\s*/) || [''])[0].length;
1303
+ let end = lineTexts.length;
1304
+ for (let i = start + 1; i < lineTexts.length; i++) {
1305
+ const ind = (lineTexts[i].match(/^\s*/) || [''])[0].length;
1306
+ if (/- /.test(lineTexts[i]) && ind <= headIndent) { end = i; break; }
1307
+ }
1308
+ let ci = -1;
1309
+ for (let i = start + 1; i < end; i++) {
1310
+ if (/^\s*config:\s*$/.test(lineTexts[i])) { ci = i; break; }
1311
+ }
1312
+ if (ci < 0) return { ok: false, error: 'entry ' + entryId + ' 在 profile patch 里没有 config: 段' };
1313
+ const cfgIndent = (lineTexts[ci].match(/^\s*/) || [''])[0].length;
1314
+ const keyIndent = cfgIndent + 2;
1315
+ let cEnd = ci + 1;
1316
+ for (let i = ci + 1; i < end; i++) {
1317
+ if (lineTexts[i].trim() && ((lineTexts[i].match(/^\s*/) || [''])[0]).length <= cfgIndent) break;
1318
+ cEnd = i + 1;
1319
+ }
1320
+ const missing = [];
1321
+ const setKey = (key, value, from, to, indent) => {
1322
+ // ⚠️ 脱敏占位符绝不写回(2026-09-24 实测事故):设置页读到的是 viewOf 打码过的
1323
+ // `__REDACTED__`,若原样写入就会把真密钥覆盖掉。这个值一律视为"未修改"。
1324
+ if (value === '__REDACTED__') return true;
1325
+ const re = new RegExp('^\\s{' + indent + '}' + esc(key) + ':\\s*(.*)$');
1326
+ for (let i = from; i < to; i++) {
1327
+ if (!re.test(lineTexts[i])) continue;
1328
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
1329
+ let subEnd = i + 1;
1330
+ for (let j = i + 1; j < to; j++) {
1331
+ if (lineTexts[j].trim() && ((lineTexts[j].match(/^\s*/) || [''])[0]).length <= indent) break;
1332
+ subEnd = j + 1;
1333
+ }
1334
+ for (const [k, v] of Object.entries(value)) {
1335
+ if (v === undefined) continue;
1336
+ if (!setKey(k, v, i + 1, subEnd, indent + 2)) missing.push(key + '.' + k);
1337
+ }
1338
+ return true;
1339
+ }
1340
+ // 按**原字段类型**保真写入(引号风格 / 数组 / 空值),避免写坏宿主 schema:
1341
+ // 实测踩坑:appId 裸写数字 → "expected string";owners 空值写成 '' → "expected array"
1342
+ const origRaw = String(lineTexts[i] || '');
1343
+ const origVal = ((origRaw.match(/:\s*(.*)$/) || [])[1] || '').trim();
1344
+ const origWasArray = origVal === '[]' || origVal.startsWith('- ');
1345
+ const keepQuoted = /^['"]/.test(origVal);
1346
+ lineTexts[i] = ' '.repeat(indent) + key + ': ' + yValue(value, keepQuoted, origWasArray);
1347
+ return true;
1348
+ }
1349
+ return false;
1350
+ };
1351
+ for (const [k, v] of Object.entries(patch)) {
1352
+ if (v === undefined) continue;
1353
+ if (!setKey(k, v, ci + 1, cEnd, keyIndent)) missing.push(k);
1354
+ }
1355
+ let out = '';
1356
+ for (let i = 0; i < lineTexts.length; i++) {
1357
+ out += lineTexts[i];
1358
+ if (i < eols.length) out += eols[i];
1359
+ }
1360
+ const bak = PATCH_FILE + '.bak-cfg-' + Date.now();
1361
+ try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
1362
+ writeFileSync(PATCH_FILE, out, 'utf8');
1363
+ return { ok: true, backup: bak, missing };
1364
+ } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
1365
+ }
1366
+
1092
1367
  /** 写回人设正文(只替换那段块标量,写前自动备份整个 patch)。
1093
1368
  * ⚠️ 换行符**逐行保真**:patch 可能 LF/CRLF 混用(实测踩过),统一 eol 会改掉别处字节。 */
1094
1369
  function writePresetPersona(presetId, text) {