@zaofan/dsh-qqbot 1.5.6 → 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/README.md +78 -0
- package/client/qqbot-settings.js +8 -1
- package/client/qqbot-settings.js.bak-ga-041943 +4775 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +27 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/settings-host.js +645 -23
package/settings-host.js
CHANGED
|
@@ -97,10 +97,180 @@ async function readJsonBody(req) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/** 读命名空间当前解析值 + revision(redact 防密钥外泄) */
|
|
100
|
+
/**
|
|
101
|
+
* 兜底:从 profile 的 cordis.patch.yml 里读某个 entry 的 config(2026-09-23 适配 dsh 0.1.7)。
|
|
102
|
+
*
|
|
103
|
+
* 背景:0.1.7 把设置机制换成了「从 profile 里插件条目的 config schema 推导」,
|
|
104
|
+
* 注册入口 `installSection` 已移除 → 插件静默降级后 `describe()` 里不再有本实例的 ns,
|
|
105
|
+
* 于是 /read 返回 {value:undefined,revision:undefined}(JSON 序列化后正好是 {}),
|
|
106
|
+
* 前端就显示"读取失败: {}"。
|
|
107
|
+
*
|
|
108
|
+
* 这里只做「块级 + 简单标量」提取(不整文件 YAML 解析,因为含 !!js 标签):
|
|
109
|
+
* 找 `- id: <entryId>` 块的 `config:` 子树,逐行取标量,跳过 !!js 与嵌套子块;
|
|
110
|
+
* secret 类字段按宿主 describe(redactSecrets) 的口径打码。
|
|
111
|
+
*/
|
|
112
|
+
function readEntryConfigFromPatch(entryId) {
|
|
113
|
+
try {
|
|
114
|
+
if (!existsSync(PATCH_FILE)) return null;
|
|
115
|
+
const lines = readFileSync(PATCH_FILE, 'utf8').split(/\r\n|\n/);
|
|
116
|
+
const esc = String(entryId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
117
|
+
const headRe = new RegExp('^\\s*-\\s*id:\\s*[\'"]?' + esc + '[\'"]?\\s*$');
|
|
118
|
+
const start = lines.findIndex((l) => headRe.test(l));
|
|
119
|
+
if (start < 0) return null;
|
|
120
|
+
const headIndent = (lines[start].match(/^\s*/) || [''])[0].length;
|
|
121
|
+
let end = lines.length;
|
|
122
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
123
|
+
const ind = (lines[i].match(/^\s*/) || [''])[0].length;
|
|
124
|
+
if (/- /.test(lines[i]) && ind <= headIndent) { end = i; break; }
|
|
125
|
+
}
|
|
126
|
+
const block = lines.slice(start + 1, end);
|
|
127
|
+
const ci = block.findIndex((l) => /^\s*config:\s*$/.test(l));
|
|
128
|
+
if (ci < 0) return null;
|
|
129
|
+
const cfgIndent = (block[ci].match(/^\s*/) || [''])[0].length;
|
|
130
|
+
const out = {};
|
|
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;
|
|
191
|
+
return Object.keys(out).length ? out : null;
|
|
192
|
+
} catch { return null; }
|
|
193
|
+
}
|
|
194
|
+
/** 读一个实例的配置视图;宿主 describe 拿不到时回落到 profile patch。 */
|
|
100
195
|
function viewOf(settings, ns) {
|
|
101
196
|
const target = ns || NS;
|
|
102
|
-
|
|
103
|
-
|
|
197
|
+
let list = [];
|
|
198
|
+
try { list = settings.describe({ redactSecrets: true }) || []; } catch { list = []; }
|
|
199
|
+
const d = list.find((x) => x.ns === target);
|
|
200
|
+
if (d) return { value: d.value, revision: d.revision };
|
|
201
|
+
const fb = readEntryConfigFromPatch(target);
|
|
202
|
+
// revision 用 -1 标记"兜底视图、没有乐观锁基线"(/update 见负数就跳过 expectedRevision)
|
|
203
|
+
if (fb) return { value: fb, revision: -1 };
|
|
204
|
+
return { value: undefined, revision: undefined };
|
|
205
|
+
}
|
|
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');
|
|
104
274
|
}
|
|
105
275
|
|
|
106
276
|
/** 请求里的目录参数(dataDir 显式传; 空=primary 主账号) */
|
|
@@ -172,19 +342,37 @@ export function apply(ctx) {
|
|
|
172
342
|
// ── 设置面板读写桥(多账号: ?ns= 选实例命名空间; 缺省 im-qqbot 主账号) ──
|
|
173
343
|
route(ctx, 'GET', '/api/qqbot-settings/read', async (req, res) => {
|
|
174
344
|
const u = new URL(req.url ?? '/', 'http://x');
|
|
175
|
-
|
|
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' });
|
|
176
351
|
});
|
|
177
352
|
route(ctx, 'POST', '/api/qqbot-settings/update', async (req, res) => {
|
|
178
353
|
const body = await readJsonBody(req);
|
|
179
354
|
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be JSON object' });
|
|
180
355
|
const ns = typeof body.ns === 'string' && body.ns ? body.ns : NS;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
});
|
|
184
374
|
} catch (e) {
|
|
185
|
-
|
|
186
|
-
if (code === 'SETTINGS_CONFLICT' || code === 'SettingsConflictError') return writeJson(res, 409, { error: e.message });
|
|
187
|
-
writeJson(res, 400, { error: String(e?.message ?? e) });
|
|
375
|
+
writeJson(res, 500, { error: '保存失败:' + String(e?.message ?? e) });
|
|
188
376
|
}
|
|
189
377
|
});
|
|
190
378
|
|
|
@@ -514,11 +702,22 @@ export function apply(ctx) {
|
|
|
514
702
|
|
|
515
703
|
if (orig) {
|
|
516
704
|
const kv = {};
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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) */ }
|
|
521
719
|
if (inst.disabled !== undefined) kv.disabled = inst.disabled ? 'true' : 'false';
|
|
720
|
+
if (Object.keys(kv).length) updates.push({ id: String(inst.id), kv });
|
|
522
721
|
updates.push({ id: String(inst.id), kv });
|
|
523
722
|
} else {
|
|
524
723
|
appends.push(renderBotBlock({ ...inst, appSecret: secret, dataRoot }));
|
|
@@ -540,6 +739,27 @@ export function apply(ctx) {
|
|
|
540
739
|
let out = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
541
740
|
if (appends.length) out += (out.trimEnd() ? '\n' : '') + appends.join('\n') + '\n';
|
|
542
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 写入 */ }
|
|
543
763
|
writeFileSync(PATCH_FILE, out, 'utf8');
|
|
544
764
|
return { ok: true, file: PATCH_FILE, needRestart: true };
|
|
545
765
|
}
|
|
@@ -751,9 +971,30 @@ export function apply(ctx) {
|
|
|
751
971
|
});
|
|
752
972
|
// 复制并双名: {sourceId, newId, newName}
|
|
753
973
|
route(ctx, 'POST', '/api/qqbot-settings/presets/copy', async (req, res) => {
|
|
754
|
-
const blocked = presetWriteBlocked();
|
|
755
|
-
if (blocked) return writeJson(res, 501, { error: blocked });
|
|
756
974
|
const body = await readJsonBody(req);
|
|
975
|
+
// dsh 0.1.7+:预设是 profile patch 里的声明行 → 复制 = 整段搬运 + 改写 id/name
|
|
976
|
+
if (getAgentPresets()) {
|
|
977
|
+
if (!body || typeof body.sourceId !== 'string' || typeof body.newId !== 'string') {
|
|
978
|
+
return writeJson(res, 400, { error: 'sourceId/newId 必填' });
|
|
979
|
+
}
|
|
980
|
+
const nId = body.newId.trim();
|
|
981
|
+
if (!ID_RE.test(nId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _' });
|
|
982
|
+
if (nId === String(body.sourceId).trim()) return writeJson(res, 400, { error: '新预设 id 不能与源相同' });
|
|
983
|
+
const nName = String(body.newName ?? nId).trim() || nId;
|
|
984
|
+
try {
|
|
985
|
+
const arr = await getAgentPresets().list();
|
|
986
|
+
if (Array.isArray(arr) && arr.some((x) => x && x.id === nId)) {
|
|
987
|
+
return writeJson(res, 409, { error: '预设 ' + nId + ' 已存在' });
|
|
988
|
+
}
|
|
989
|
+
} catch { /* 列表不可用时靠 patch 自身判断 */ }
|
|
990
|
+
const srcId = String(body.sourceId).trim();
|
|
991
|
+
// 先按 patch 里的原块整段复制(原汁原味、字节保真);
|
|
992
|
+
// patch 里没有(如宿主内置预设 standard)→ 回退用宿主文档读出组成再生成
|
|
993
|
+
let r = copyPresetDecl(srcId, nId, nName);
|
|
994
|
+
if (!r.ok) r = await copyPresetViaDocument(srcId, nId, nName);
|
|
995
|
+
if (!r.ok) return writeJson(res, 404, { error: r.error });
|
|
996
|
+
return writeJson(res, 200, { ok: true, newId: nId, backup: r.backup, note: '已写入 profile patch,热生效(新会话生效)' });
|
|
997
|
+
}
|
|
757
998
|
if (!body || typeof body.sourceId !== 'string' || typeof body.newId !== 'string') {
|
|
758
999
|
return writeJson(res, 400, { error: 'sourceId/newId 必填' });
|
|
759
1000
|
}
|
|
@@ -781,9 +1022,22 @@ export function apply(ctx) {
|
|
|
781
1022
|
// 从内置"标准模式(standard)"新建一个预设(空机器起步用): 复制宿主 standard + 自动补 QQ 通道工具行
|
|
782
1023
|
// 复用宿主原生 agentPresets.copy(from,id,name) —— 复制出的正是宿主当前标准版, 保证能跑; 无需自己拼组合。
|
|
783
1024
|
route(ctx, 'POST', '/api/qqbot-settings/presets/new', async (req, res) => {
|
|
784
|
-
const blocked = presetWriteBlocked();
|
|
785
|
-
if (blocked) return writeJson(res, 501, { error: blocked });
|
|
786
1025
|
const body = await readJsonBody(req);
|
|
1026
|
+
// dsh 0.1.7+:从 profile patch 里的 preset-standard 声明整段复制出一个新预设
|
|
1027
|
+
if (getAgentPresets()) {
|
|
1028
|
+
const nId = String(body?.id ?? '').trim();
|
|
1029
|
+
const nName = String(body?.name ?? '').trim() || nId;
|
|
1030
|
+
if (!ID_RE.test(nId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _' });
|
|
1031
|
+
// standard 是**宿主内置**预设(不在用户 patch 里)→ 用宿主的预设文档读它的组成
|
|
1032
|
+
let r = await copyPresetViaDocument('standard', nId, nName);
|
|
1033
|
+
if (!r.ok) {
|
|
1034
|
+
// 兜底:万一 patch 里正好也有一份 preset-standard,就直接整段复制
|
|
1035
|
+
const local = copyPresetDecl('standard', nId, nName);
|
|
1036
|
+
if (local.ok) r = local;
|
|
1037
|
+
}
|
|
1038
|
+
if (!r.ok) return writeJson(res, 500, { error: r.error });
|
|
1039
|
+
return writeJson(res, 200, { ok: true, newId: nId, backup: r.backup, note: '已从 standard 生成新预设并写入 profile patch,热生效' });
|
|
1040
|
+
}
|
|
787
1041
|
const newId = String(body?.id ?? '').trim();
|
|
788
1042
|
const newName = String(body?.name ?? '').trim() || undefined;
|
|
789
1043
|
if (!ID_RE.test(newId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _(也是文件夹名)' });
|
|
@@ -824,8 +1078,343 @@ export function apply(ctx) {
|
|
|
824
1078
|
|
|
825
1079
|
// ── 预设人格文件浏览/编辑(2026-09-07): "展开改写人格"。安全: 仅 PRESET_ROOT/{id} 内白名单文件;
|
|
826
1080
|
// 写权限仅限"复制出来带QQ工具标记"的副本(防误改内置/半成品); 大小上限 200KB。
|
|
1081
|
+
//
|
|
1082
|
+
// ── 2026-09-24 适配 dsh 0.1.7:预设改为 profile patch 里的声明行后,目录没了,
|
|
1083
|
+
// 但「人设正文」仍在 patch 内(persona 插件 config.prefix 的块标量)。
|
|
1084
|
+
// 这里为它提供"虚拟文件"读写:只动那一段块标量,其余内容一个字节都不碰。
|
|
827
1085
|
const PRESET_EDIT_EXT = /.(yml|yaml|json|mjs|js|md|txt)$/i;
|
|
828
1086
|
const PRESET_EDIT_MAX = 200 * 1024;
|
|
1087
|
+
|
|
1088
|
+
/** 定位 profile patch 里某个预设声明块 `- id: preset-<id>`。 */
|
|
1089
|
+
function locatePresetBlock(lines, presetId) {
|
|
1090
|
+
const esc = String(presetId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1091
|
+
const re = new RegExp('^\\s*-\\s*id:\\s*[\'"]?preset-' + esc + '[\'"]?\\s*$');
|
|
1092
|
+
const head = lines.findIndex((l) => re.test(l));
|
|
1093
|
+
if (head < 0) return null;
|
|
1094
|
+
const headIndent = (lines[head].match(/^\s*/) || [''])[0].length;
|
|
1095
|
+
let end = lines.length;
|
|
1096
|
+
for (let i = head + 1; i < lines.length; i++) {
|
|
1097
|
+
const ind = (lines[i].match(/^\s*/) || [''])[0].length;
|
|
1098
|
+
if (/- /.test(lines[i]) && ind <= headIndent) { end = i; break; }
|
|
1099
|
+
}
|
|
1100
|
+
return { head, headIndent, end };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* 读预设的人设正文(0.1.7 路径)。
|
|
1105
|
+
* 结构: preset 块 → `- id: persona` → `prefix|text|prompt: |-` → 块标量正文
|
|
1106
|
+
* @returns {{text:string, indent:number, bodyStart:number, bodyEnd:number}|null}
|
|
1107
|
+
*/
|
|
1108
|
+
function readPresetPersona(presetId, linesOverride) {
|
|
1109
|
+
try {
|
|
1110
|
+
if (!existsSync(PATCH_FILE)) return null;
|
|
1111
|
+
const lines = linesOverride || readFileSync(PATCH_FILE, 'utf8').split(/\r\n|\n/);
|
|
1112
|
+
const blk = locatePresetBlock(lines, presetId);
|
|
1113
|
+
if (!blk) return null;
|
|
1114
|
+
let pi = -1;
|
|
1115
|
+
for (let i = blk.head + 1; i < blk.end; i++) {
|
|
1116
|
+
if (/^\s*-\s*id:\s*['"]?persona['"]?\s*$/.test(lines[i])) { pi = i; break; }
|
|
1117
|
+
}
|
|
1118
|
+
if (pi < 0) return null;
|
|
1119
|
+
const personaIndent = (lines[pi].match(/^\s*/) || [''])[0].length;
|
|
1120
|
+
let ki = -1;
|
|
1121
|
+
let indent = 0;
|
|
1122
|
+
let inline = false;
|
|
1123
|
+
let inlineVal = '';
|
|
1124
|
+
for (let i = pi + 1; i < blk.end; i++) {
|
|
1125
|
+
const l = lines[i];
|
|
1126
|
+
if (/^\s*#/.test(l)) continue; // 注释行跳过
|
|
1127
|
+
const mB = /^(\s*)(?:prefix|text|prompt):\s*\|-?\s*$/.exec(l);
|
|
1128
|
+
if (mB) { ki = i; indent = mB[1].length; inline = false; break; }
|
|
1129
|
+
const mI = /^(\s*)(?:prefix|text|prompt):\s+(\S.*)$/.exec(l); // 行内标量写法
|
|
1130
|
+
if (mI) { ki = i; indent = mI[1].length; inline = true; inlineVal = mI[2].trim(); break; }
|
|
1131
|
+
const ind = (l.match(/^\s*/) || [''])[0].length;
|
|
1132
|
+
if (/- /.test(l) && ind <= personaIndent) break;
|
|
1133
|
+
}
|
|
1134
|
+
if (ki < 0) return null;
|
|
1135
|
+
if (inline) {
|
|
1136
|
+
// 行内写法(如 `prefix: 你是一个 xx 助手`)→ 直接返回那一句
|
|
1137
|
+
let v = inlineVal;
|
|
1138
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
|
|
1139
|
+
return { text: v, indent: indent + 2, bodyStart: ki + 1, bodyEnd: ki + 1, inline: true, lineIndex: ki };
|
|
1140
|
+
}
|
|
1141
|
+
let bEnd = ki + 1;
|
|
1142
|
+
while (bEnd < blk.end) {
|
|
1143
|
+
const raw = lines[bEnd];
|
|
1144
|
+
if (raw.trim() === '') { bEnd++; continue; }
|
|
1145
|
+
if (((raw.match(/^\s*/) || [''])[0]).length <= indent) break;
|
|
1146
|
+
bEnd++;
|
|
1147
|
+
}
|
|
1148
|
+
let last = bEnd;
|
|
1149
|
+
while (last > ki + 1 && lines[last - 1].trim() === '') last--;
|
|
1150
|
+
const body = lines.slice(ki + 1, last);
|
|
1151
|
+
const cuts = body.filter((l) => l.trim()).map((l) => (l.match(/^\s*/) || [''])[0].length);
|
|
1152
|
+
const cut = cuts.length ? Math.min(...cuts) : indent + 2;
|
|
1153
|
+
// 纯空白行原样保留(既不切片也不补缩进),保证「读出 → 原样写回」字节级一致
|
|
1154
|
+
const text = body.map((l) => (l.trim() === '' ? l : l.slice(cut))).join('\n');
|
|
1155
|
+
return { text, indent: cut, bodyStart: ki + 1, bodyEnd: last };
|
|
1156
|
+
} catch { return null; }
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* 复制一个预设声明(dsh 0.1.7 路径):在 profile patch 里把 `- id: preset-<src>` 整段
|
|
1161
|
+
* 复制一份,只改写三处后追加到顶层数组 —— `- id:` / `config.id` / `config.name`,其余一个字节不动。
|
|
1162
|
+
* 行内换行按原文保真,缩进原样搬运。
|
|
1163
|
+
*/
|
|
1164
|
+
function copyPresetDecl(srcId, newId, newName) {
|
|
1165
|
+
try {
|
|
1166
|
+
if (!existsSync(PATCH_FILE)) return { ok: false, error: 'profile patch 不存在' };
|
|
1167
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1168
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1169
|
+
const lineTexts = [];
|
|
1170
|
+
const eols = [];
|
|
1171
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1172
|
+
if (i % 2 === 0) lineTexts.push(parts[i]);
|
|
1173
|
+
else eols.push(parts[i]);
|
|
1174
|
+
}
|
|
1175
|
+
const blk = locatePresetBlock(lineTexts, srcId);
|
|
1176
|
+
if (!blk) return { ok: false, error: '在 profile patch 里找不到源预设 preset-' + srcId };
|
|
1177
|
+
const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1178
|
+
const headRe = new RegExp('id:\\s*[\'"]?preset-' + escRe(srcId) + '[\'"]?');
|
|
1179
|
+
const seg = lineTexts.slice(blk.head, blk.end);
|
|
1180
|
+
const out = [];
|
|
1181
|
+
let cfgIndent = -1;
|
|
1182
|
+
for (let i = 0; i < seg.length; i++) {
|
|
1183
|
+
let l = seg[i];
|
|
1184
|
+
const ind = (l.match(/^\s*/) || [''])[0].length;
|
|
1185
|
+
if (i === 0) {
|
|
1186
|
+
l = l.replace(headRe, 'id: preset-' + newId);
|
|
1187
|
+
} else {
|
|
1188
|
+
const mCfg = /^(\s*)config:\s*$/.exec(l);
|
|
1189
|
+
if (mCfg) { cfgIndent = mCfg[1].length; }
|
|
1190
|
+
else if (cfgIndent >= 0 && ind === cfgIndent + 2) {
|
|
1191
|
+
if (/^\s*id:\s/.test(l)) l = l.replace(/id:\s*.*$/, 'id: ' + newId);
|
|
1192
|
+
else if (/^\s*name:\s/.test(l)) l = l.replace(/name:\s*.*$/, 'name: ' + newName);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
out.push(l);
|
|
1196
|
+
}
|
|
1197
|
+
const eol = eols[blk.end - 1] || eols[blk.head] || '\n';
|
|
1198
|
+
let add = '';
|
|
1199
|
+
for (const l of out) add += l + eol;
|
|
1200
|
+
let text = raw;
|
|
1201
|
+
if (!/[\r\n]$/.test(text)) text += eol;
|
|
1202
|
+
text += add;
|
|
1203
|
+
const bak = PATCH_FILE + '.bak-copy-' + Date.now();
|
|
1204
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1205
|
+
writeFileSync(PATCH_FILE, text, 'utf8');
|
|
1206
|
+
return { ok: true, backup: bak, newId };
|
|
1207
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* 用宿主的 `agentPresets.readDocument(id)` 读来源预设的组成,生成一条新的 preset 声明写进 patch。
|
|
1212
|
+
* 用途:**内置预设**(如 `standard`,它不在用户 patch 里)或任何 patch 中不存在的预设。
|
|
1213
|
+
* readDocument 官方标注"仅用于查看"——我们只把它当模板读出来,写进**自己的** profile patch。
|
|
1214
|
+
*/
|
|
1215
|
+
async function copyPresetViaDocument(srcId, newId, newName) {
|
|
1216
|
+
const ap = getAgentPresets();
|
|
1217
|
+
if (!ap || typeof ap.readDocument !== 'function') {
|
|
1218
|
+
return { ok: false, error: '宿主未提供 agentPresets.readDocument(读不了内置预设的组成)' };
|
|
1219
|
+
}
|
|
1220
|
+
let doc;
|
|
1221
|
+
try { doc = await ap.readDocument(srcId); }
|
|
1222
|
+
catch (e) { return { ok: false, error: '读取源预设 ' + srcId + ' 失败:' + String((e && e.message) || e) }; }
|
|
1223
|
+
const content = String((doc && doc.content) || '');
|
|
1224
|
+
if (!content.trim()) return { ok: false, error: '源预设 ' + srcId + ' 的组成为空' };
|
|
1225
|
+
const pad = ' ';
|
|
1226
|
+
const head = [
|
|
1227
|
+
'# ── 复制自 ' + srcId + '(组成由宿主的预设文档读出)──',
|
|
1228
|
+
'- insert:',
|
|
1229
|
+
' - id: preset-' + newId,
|
|
1230
|
+
" name: '@deepseek-ai/dsh-agent-preset'",
|
|
1231
|
+
' config:',
|
|
1232
|
+
' id: ' + newId,
|
|
1233
|
+
' name: ' + newName,
|
|
1234
|
+
' plugins:',
|
|
1235
|
+
];
|
|
1236
|
+
const bodyLines = content.split(/\r\n|\n/).map((l) => (l.trim() === '' ? '' : pad + l));
|
|
1237
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1238
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1239
|
+
const eol = parts[1] ?? '\n';
|
|
1240
|
+
let text = raw;
|
|
1241
|
+
if (!/[\r\n]$/.test(text)) text += eol;
|
|
1242
|
+
text += head.join(eol) + eol + bodyLines.join(eol) + eol;
|
|
1243
|
+
const bak = PATCH_FILE + '.bak-newpreset-' + Date.now();
|
|
1244
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1245
|
+
writeFileSync(PATCH_FILE, text, 'utf8');
|
|
1246
|
+
return { ok: true, backup: bak, newId, from: 'document' };
|
|
1247
|
+
}
|
|
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
|
+
|
|
1367
|
+
/** 写回人设正文(只替换那段块标量,写前自动备份整个 patch)。
|
|
1368
|
+
* ⚠️ 换行符**逐行保真**:patch 可能 LF/CRLF 混用(实测踩过),统一 eol 会改掉别处字节。 */
|
|
1369
|
+
function writePresetPersona(presetId, text) {
|
|
1370
|
+
try {
|
|
1371
|
+
if (!existsSync(PATCH_FILE)) return { ok: false, error: 'profile patch 不存在' };
|
|
1372
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1373
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1374
|
+
const lineTexts = [];
|
|
1375
|
+
const eols = [];
|
|
1376
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1377
|
+
if (i % 2 === 0) lineTexts.push(parts[i]);
|
|
1378
|
+
else eols.push(parts[i]);
|
|
1379
|
+
}
|
|
1380
|
+
const found = readPresetPersona(presetId, lineTexts);
|
|
1381
|
+
if (!found) return { ok: false, error: '没找到该预设的人设段落(persona / prefix)' };
|
|
1382
|
+
let pad;
|
|
1383
|
+
let insertAt;
|
|
1384
|
+
let endAt;
|
|
1385
|
+
if (found.inline) {
|
|
1386
|
+
// 行内标量 → 升级成块标量(`prefix: |-` + 缩进正文),这样多行人设也存得下
|
|
1387
|
+
const keyRaw = lineTexts[found.lineIndex];
|
|
1388
|
+
const key = (/(prefix|text|prompt):/.exec(keyRaw) || [])[1] || 'prefix';
|
|
1389
|
+
const keyIndent = (keyRaw.match(/^\s*/) || [''])[0];
|
|
1390
|
+
lineTexts[found.lineIndex] = keyIndent + key + ': |-';
|
|
1391
|
+
pad = keyIndent + ' ';
|
|
1392
|
+
insertAt = found.lineIndex + 1;
|
|
1393
|
+
endAt = found.lineIndex + 1;
|
|
1394
|
+
} else {
|
|
1395
|
+
pad = ' '.repeat(found.indent);
|
|
1396
|
+
insertAt = found.bodyStart;
|
|
1397
|
+
endAt = found.bodyEnd;
|
|
1398
|
+
}
|
|
1399
|
+
const nl = eols[insertAt - 1] || '\n'; // 沿用原段落的换行风格
|
|
1400
|
+
const body = String(text).split(/\r\n|\n|\r/).map((l) => (l.trim() === '' ? l : pad + l));
|
|
1401
|
+
const head = lineTexts.slice(0, insertAt);
|
|
1402
|
+
const tail = lineTexts.slice(endAt);
|
|
1403
|
+
let out = '';
|
|
1404
|
+
for (let i = 0; i < head.length; i++) out += head[i] + (eols[i] ?? '\n');
|
|
1405
|
+
for (const l of body) out += l + nl;
|
|
1406
|
+
for (let i = 0; i < tail.length; i++) {
|
|
1407
|
+
out += tail[i];
|
|
1408
|
+
const ei = endAt + i;
|
|
1409
|
+
if (ei < eols.length) out += eols[ei];
|
|
1410
|
+
}
|
|
1411
|
+
const bak = PATCH_FILE + '.bak-persona-' + Date.now();
|
|
1412
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1413
|
+
writeFileSync(PATCH_FILE, out, 'utf8');
|
|
1414
|
+
return { ok: true, backup: bak };
|
|
1415
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
829
1418
|
function presetDirSafe(id) {
|
|
830
1419
|
if (!ID_RE.test(String(id || ''))) return null;
|
|
831
1420
|
const dir = resolve(PRESET_ROOT, id);
|
|
@@ -839,11 +1428,21 @@ export function apply(ctx) {
|
|
|
839
1428
|
return !!(p && p.hasChannelTools);
|
|
840
1429
|
}
|
|
841
1430
|
route(ctx, 'GET', '/api/qqbot-settings/presets/files', async (req, res) => {
|
|
842
|
-
// 新版(0.1.7+)没有预设目录了 → 给明确指引, 免得前端只看到"预设不存在"
|
|
843
|
-
const blockedFiles = presetWriteBlocked();
|
|
844
|
-
if (blockedFiles) return writeJson(res, 501, { error: blockedFiles });
|
|
845
1431
|
const q = new URL(req.url, 'http://x').searchParams;
|
|
846
|
-
const
|
|
1432
|
+
const rid = String(q.get('id') || '').trim();
|
|
1433
|
+
// dsh 0.1.7+:预设声明在 profile patch 里 → 对外虚拟成「人设正文」一个可编辑文件
|
|
1434
|
+
if (getAgentPresets()) {
|
|
1435
|
+
if (!ID_RE.test(rid)) return writeJson(res, 400, { error: '非法的预设 id' });
|
|
1436
|
+
const persona = readPresetPersona(rid);
|
|
1437
|
+
if (!persona) return writeJson(res, 404, { error: '该预设没有内联人设段落(persona / prefix);人格可能在外部 .mjs 文件里' });
|
|
1438
|
+
return writeJson(res, 200, {
|
|
1439
|
+
id: rid,
|
|
1440
|
+
dir: '(profile patch)',
|
|
1441
|
+
writable: true,
|
|
1442
|
+
files: [{ name: 'persona(人设正文)', size: Buffer.byteLength(persona.text, 'utf8'), mtime: 0 }],
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
const dir = presetDirSafe(rid);
|
|
847
1446
|
if (!dir) return writeJson(res, 404, { error: '预设不存在' });
|
|
848
1447
|
try {
|
|
849
1448
|
const files = readdirSync(dir, { withFileTypes: true })
|
|
@@ -854,8 +1453,31 @@ export function apply(ctx) {
|
|
|
854
1453
|
} catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
|
|
855
1454
|
});
|
|
856
1455
|
route(ctx, ['GET', 'PUT'], '/api/qqbot-settings/presets/file', async (req, res) => {
|
|
857
|
-
|
|
858
|
-
if (
|
|
1456
|
+
// dsh 0.1.7+:只支持「人设正文」这一个虚拟文件的读写(落在 profile patch 的 persona 块标量里)
|
|
1457
|
+
if (getAgentPresets()) {
|
|
1458
|
+
if (req.method === 'PUT') {
|
|
1459
|
+
const body = await readJsonBody(req);
|
|
1460
|
+
const id = String(body?.id ?? '').trim();
|
|
1461
|
+
if (!ID_RE.test(id)) return writeJson(res, 400, { error: '非法的预设 id' });
|
|
1462
|
+
const content = String(body?.content ?? '');
|
|
1463
|
+
if (Buffer.byteLength(content, 'utf8') > PRESET_EDIT_MAX) return writeJson(res, 400, { error: '内容过大(上限 200KB)' });
|
|
1464
|
+
const w = writePresetPersona(id, content);
|
|
1465
|
+
if (!w.ok) return writeJson(res, 500, { error: w.error });
|
|
1466
|
+
return writeJson(res, 200, {
|
|
1467
|
+
ok: true,
|
|
1468
|
+
name: 'persona(人设正文)',
|
|
1469
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
1470
|
+
backup: w.backup,
|
|
1471
|
+
note: '已写入 profile patch,patchReload 热生效(新会话生效)',
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
const q = new URL(req.url, 'http://x').searchParams;
|
|
1475
|
+
const id = String(q.get('id') || '').trim();
|
|
1476
|
+
if (!ID_RE.test(id)) return writeJson(res, 400, { error: '非法的预设 id' });
|
|
1477
|
+
const persona = readPresetPersona(id);
|
|
1478
|
+
if (!persona) return writeJson(res, 404, { error: '该预设没有内联人设段落(persona / prefix)' });
|
|
1479
|
+
return writeJson(res, 200, { id, name: 'persona(人设正文)', content: persona.text, writable: true });
|
|
1480
|
+
}
|
|
859
1481
|
if (req.method === 'PUT') {
|
|
860
1482
|
const body = await readJsonBody(req);
|
|
861
1483
|
const id = String(body?.id ?? '');
|