@zaofan/dsh-qqbot 1.5.6 → 1.5.7
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 +40 -0
- package/client/qqbot-settings.js +3 -1
- package/package.json +1 -1
- package/settings-host.js +360 -13
package/README.md
CHANGED
|
@@ -194,6 +194,46 @@ pwsh -File scripts/fix-dsh-016-presets.ps1 -Apply
|
|
|
194
194
|
|
|
195
195
|
dsh 0.1.6 起默认模型为 `deepseek-official/deepseek-flash`(旧的 `deepseek-v4-flash` 已移出默认模型列表)。插件在「什么都没配」时的兜底也已同步为该值;你在设置里显式指定的 provider/model 优先级更高。
|
|
196
196
|
|
|
197
|
+
### 升级 dsh 0.1.7 后,恢复会话报「预设缺失」/ 机器人不理人?
|
|
198
|
+
|
|
199
|
+
dsh **0.1.7** 把 Agent 预设从「目录里的 yml 文件」换成了「**profile 配置里的声明行**」:
|
|
200
|
+
由 `@deepseek-ai/dsh-agent-preset-registry` + 每个预设一行 `@deepseek-ai/dsh-agent-preset` 组成。
|
|
201
|
+
官方明确:**注册表不扫描目录、不接受 preset 路径、没有任何接口接受 YAML 写回** —— 所以旧目录预设**必须迁移**,否则会话日志里记着的 preset ID 找不到定义,**恢复会话会被拒绝**。
|
|
202
|
+
|
|
203
|
+
**一键迁移**(仓库里 `scripts/migrate-presets-017.ps1`):
|
|
204
|
+
|
|
205
|
+
```powershell
|
|
206
|
+
# ① 先看看会迁移哪些预设(不写任何文件)
|
|
207
|
+
pwsh -File scripts/migrate-presets-017.ps1
|
|
208
|
+
|
|
209
|
+
# ② 生成「原 patch + 迁移片段」的合并版,核对一眼
|
|
210
|
+
pwsh -File scripts/migrate-presets-017.ps1 -Merge -Out "$env:TEMP\cordis.patch.yml.0.1.7-ready"
|
|
211
|
+
|
|
212
|
+
# ③ 升级 dsh 到 ≥0.1.7 之后启用(⚠️ 顺序不可反!0.1.6 读不了新声明会加载失败)
|
|
213
|
+
cd ~/.dsh/profiles/web
|
|
214
|
+
Copy-Item cordis.patch.yml cordis.patch.yml.bak-0.1.6
|
|
215
|
+
Move-Item "$env:TEMP\cordis.patch.yml.0.1.7-ready" cordis.patch.yml -Force
|
|
216
|
+
# 然后重启 dsh
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
> ⚠️ 两个坑(脚本已自动处理):patch 顶层若是空数组 `[]` 要**先删掉**再粘;新增条目必须用 `- insert:`(写成 `- id:` 会报 `entry not found`)。
|
|
220
|
+
> ⚠️ 0.1.7 会把**会话日志升级为 V4(不可回退)**、`settings.yaml` 也只导入一次 —— 升级前记得备份整个 `~/.dsh`。
|
|
221
|
+
|
|
222
|
+
### 升级 dsh 0.1.7 后,某些插件被「禁用 / 跳过」?
|
|
223
|
+
|
|
224
|
+
0.1.7 新增了**插件版本兼容检查**(比对各插件的 `peerDependencies`)。钉死在旧次版本的包会被跳过,或整行被禁用:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
dsh: disabling profile plugin row "mcp-chrome": Plugin ... is incompatible with dsh 0.1.7-rc.1
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
常见两例:
|
|
231
|
+
|
|
232
|
+
- `@deepseek-ai/dsh-mcp-client@0.0.1-rc.1` → 让所有 `mcp-*` 行失效。**注意官方这个包的 `latest` 标签还停在旧版,`next` 才是新版** → 要显式装 `@deepseek-ai/dsh-mcp-client@0.1.7-rc.1`
|
|
233
|
+
- `@dhicoc/dsh-reverse-skill@1.0.5` → 跳过(作者尚未适配 0.1.7)
|
|
234
|
+
|
|
235
|
+
想强行运行,可用官方提供的**确切版本例外**(`dsh plugin allow-version`,或插件管理页里授予),但官方警告"可能崩溃或数据丢失",自行权衡。
|
|
236
|
+
|
|
197
237
|
## 支持这个项目
|
|
198
238
|
|
|
199
239
|
如果这个插件帮你省了 token、或者让你家的鲸鱼更活蹦乱跳 —— **给个 ⭐ Star** 就是最实在的支持;有 bug / 想要的功能,欢迎开 [Issue](https://github.com/gcry13067381632-jpg/dsh-qqbot/issues)。
|
package/client/qqbot-settings.js
CHANGED
|
@@ -1392,9 +1392,11 @@ window.__ModuleLoader__.load({
|
|
|
1392
1392
|
}).then(function (r) { return r.json() }).then(function (d) {
|
|
1393
1393
|
setBusy('')
|
|
1394
1394
|
if (d && d.error) { setMsg('复制失败: ' + d.error); return }
|
|
1395
|
-
setMsg('✅ 新预设已生成: ' + d.newId + (copy.fromStandard ? ' (
|
|
1395
|
+
setMsg('✅ 新预设已生成: ' + d.newId + (copy.fromStandard ? ' (复制自标准模式)' : '') + '。稍等片刻即可在列表看到;要改人设点「人格」。')
|
|
1396
1396
|
setCopy(null); setCopyId(''); setCopyName('')
|
|
1397
1397
|
load(true)
|
|
1398
|
+
// dsh 0.1.7:预设写在 profile patch 里,由宿主 HMR 重载后才出现在列表中 → 稍后再拉一次
|
|
1399
|
+
setTimeout(function () { load(true) }, 1200)
|
|
1398
1400
|
}).catch(function (e) { setBusy(''); setMsg('复制异常: ' + e.message) })
|
|
1399
1401
|
}
|
|
1400
1402
|
function openPreset(id) {
|
package/package.json
CHANGED
package/settings-host.js
CHANGED
|
@@ -97,10 +97,70 @@ 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
|
+
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
|
+
}
|
|
150
|
+
return Object.keys(out).length ? out : null;
|
|
151
|
+
} catch { return null; }
|
|
152
|
+
}
|
|
153
|
+
/** 读一个实例的配置视图;宿主 describe 拿不到时回落到 profile patch。 */
|
|
100
154
|
function viewOf(settings, ns) {
|
|
101
155
|
const target = ns || NS;
|
|
102
|
-
|
|
103
|
-
|
|
156
|
+
let list = [];
|
|
157
|
+
try { list = settings.describe({ redactSecrets: true }) || []; } catch { list = []; }
|
|
158
|
+
const d = list.find((x) => x.ns === target);
|
|
159
|
+
if (d) return { value: d.value, revision: d.revision };
|
|
160
|
+
const fb = readEntryConfigFromPatch(target);
|
|
161
|
+
// revision 用 -1 标记"兜底视图、没有乐观锁基线"(/update 见负数就跳过 expectedRevision)
|
|
162
|
+
if (fb) return { value: fb, revision: -1 };
|
|
163
|
+
return { value: undefined, revision: undefined };
|
|
104
164
|
}
|
|
105
165
|
|
|
106
166
|
/** 请求里的目录参数(dataDir 显式传; 空=primary 主账号) */
|
|
@@ -179,7 +239,10 @@ export function apply(ctx) {
|
|
|
179
239
|
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be JSON object' });
|
|
180
240
|
const ns = typeof body.ns === 'string' && body.ns ? body.ns : NS;
|
|
181
241
|
try {
|
|
182
|
-
|
|
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);
|
|
183
246
|
writeJson(res, 200, viewOf(ctx.settings, ns));
|
|
184
247
|
} catch (e) {
|
|
185
248
|
const code = e?.code ?? '';
|
|
@@ -751,9 +814,30 @@ export function apply(ctx) {
|
|
|
751
814
|
});
|
|
752
815
|
// 复制并双名: {sourceId, newId, newName}
|
|
753
816
|
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
817
|
const body = await readJsonBody(req);
|
|
818
|
+
// dsh 0.1.7+:预设是 profile patch 里的声明行 → 复制 = 整段搬运 + 改写 id/name
|
|
819
|
+
if (getAgentPresets()) {
|
|
820
|
+
if (!body || typeof body.sourceId !== 'string' || typeof body.newId !== 'string') {
|
|
821
|
+
return writeJson(res, 400, { error: 'sourceId/newId 必填' });
|
|
822
|
+
}
|
|
823
|
+
const nId = body.newId.trim();
|
|
824
|
+
if (!ID_RE.test(nId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _' });
|
|
825
|
+
if (nId === String(body.sourceId).trim()) return writeJson(res, 400, { error: '新预设 id 不能与源相同' });
|
|
826
|
+
const nName = String(body.newName ?? nId).trim() || nId;
|
|
827
|
+
try {
|
|
828
|
+
const arr = await getAgentPresets().list();
|
|
829
|
+
if (Array.isArray(arr) && arr.some((x) => x && x.id === nId)) {
|
|
830
|
+
return writeJson(res, 409, { error: '预设 ' + nId + ' 已存在' });
|
|
831
|
+
}
|
|
832
|
+
} catch { /* 列表不可用时靠 patch 自身判断 */ }
|
|
833
|
+
const srcId = String(body.sourceId).trim();
|
|
834
|
+
// 先按 patch 里的原块整段复制(原汁原味、字节保真);
|
|
835
|
+
// patch 里没有(如宿主内置预设 standard)→ 回退用宿主文档读出组成再生成
|
|
836
|
+
let r = copyPresetDecl(srcId, nId, nName);
|
|
837
|
+
if (!r.ok) r = await copyPresetViaDocument(srcId, nId, nName);
|
|
838
|
+
if (!r.ok) return writeJson(res, 404, { error: r.error });
|
|
839
|
+
return writeJson(res, 200, { ok: true, newId: nId, backup: r.backup, note: '已写入 profile patch,热生效(新会话生效)' });
|
|
840
|
+
}
|
|
757
841
|
if (!body || typeof body.sourceId !== 'string' || typeof body.newId !== 'string') {
|
|
758
842
|
return writeJson(res, 400, { error: 'sourceId/newId 必填' });
|
|
759
843
|
}
|
|
@@ -781,9 +865,22 @@ export function apply(ctx) {
|
|
|
781
865
|
// 从内置"标准模式(standard)"新建一个预设(空机器起步用): 复制宿主 standard + 自动补 QQ 通道工具行
|
|
782
866
|
// 复用宿主原生 agentPresets.copy(from,id,name) —— 复制出的正是宿主当前标准版, 保证能跑; 无需自己拼组合。
|
|
783
867
|
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
868
|
const body = await readJsonBody(req);
|
|
869
|
+
// dsh 0.1.7+:从 profile patch 里的 preset-standard 声明整段复制出一个新预设
|
|
870
|
+
if (getAgentPresets()) {
|
|
871
|
+
const nId = String(body?.id ?? '').trim();
|
|
872
|
+
const nName = String(body?.name ?? '').trim() || nId;
|
|
873
|
+
if (!ID_RE.test(nId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _' });
|
|
874
|
+
// standard 是**宿主内置**预设(不在用户 patch 里)→ 用宿主的预设文档读它的组成
|
|
875
|
+
let r = await copyPresetViaDocument('standard', nId, nName);
|
|
876
|
+
if (!r.ok) {
|
|
877
|
+
// 兜底:万一 patch 里正好也有一份 preset-standard,就直接整段复制
|
|
878
|
+
const local = copyPresetDecl('standard', nId, nName);
|
|
879
|
+
if (local.ok) r = local;
|
|
880
|
+
}
|
|
881
|
+
if (!r.ok) return writeJson(res, 500, { error: r.error });
|
|
882
|
+
return writeJson(res, 200, { ok: true, newId: nId, backup: r.backup, note: '已从 standard 生成新预设并写入 profile patch,热生效' });
|
|
883
|
+
}
|
|
787
884
|
const newId = String(body?.id ?? '').trim();
|
|
788
885
|
const newName = String(body?.name ?? '').trim() || undefined;
|
|
789
886
|
if (!ID_RE.test(newId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _(也是文件夹名)' });
|
|
@@ -824,8 +921,225 @@ export function apply(ctx) {
|
|
|
824
921
|
|
|
825
922
|
// ── 预设人格文件浏览/编辑(2026-09-07): "展开改写人格"。安全: 仅 PRESET_ROOT/{id} 内白名单文件;
|
|
826
923
|
// 写权限仅限"复制出来带QQ工具标记"的副本(防误改内置/半成品); 大小上限 200KB。
|
|
924
|
+
//
|
|
925
|
+
// ── 2026-09-24 适配 dsh 0.1.7:预设改为 profile patch 里的声明行后,目录没了,
|
|
926
|
+
// 但「人设正文」仍在 patch 内(persona 插件 config.prefix 的块标量)。
|
|
927
|
+
// 这里为它提供"虚拟文件"读写:只动那一段块标量,其余内容一个字节都不碰。
|
|
827
928
|
const PRESET_EDIT_EXT = /.(yml|yaml|json|mjs|js|md|txt)$/i;
|
|
828
929
|
const PRESET_EDIT_MAX = 200 * 1024;
|
|
930
|
+
|
|
931
|
+
/** 定位 profile patch 里某个预设声明块 `- id: preset-<id>`。 */
|
|
932
|
+
function locatePresetBlock(lines, presetId) {
|
|
933
|
+
const esc = String(presetId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
934
|
+
const re = new RegExp('^\\s*-\\s*id:\\s*[\'"]?preset-' + esc + '[\'"]?\\s*$');
|
|
935
|
+
const head = lines.findIndex((l) => re.test(l));
|
|
936
|
+
if (head < 0) return null;
|
|
937
|
+
const headIndent = (lines[head].match(/^\s*/) || [''])[0].length;
|
|
938
|
+
let end = lines.length;
|
|
939
|
+
for (let i = head + 1; i < lines.length; i++) {
|
|
940
|
+
const ind = (lines[i].match(/^\s*/) || [''])[0].length;
|
|
941
|
+
if (/- /.test(lines[i]) && ind <= headIndent) { end = i; break; }
|
|
942
|
+
}
|
|
943
|
+
return { head, headIndent, end };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 读预设的人设正文(0.1.7 路径)。
|
|
948
|
+
* 结构: preset 块 → `- id: persona` → `prefix|text|prompt: |-` → 块标量正文
|
|
949
|
+
* @returns {{text:string, indent:number, bodyStart:number, bodyEnd:number}|null}
|
|
950
|
+
*/
|
|
951
|
+
function readPresetPersona(presetId, linesOverride) {
|
|
952
|
+
try {
|
|
953
|
+
if (!existsSync(PATCH_FILE)) return null;
|
|
954
|
+
const lines = linesOverride || readFileSync(PATCH_FILE, 'utf8').split(/\r\n|\n/);
|
|
955
|
+
const blk = locatePresetBlock(lines, presetId);
|
|
956
|
+
if (!blk) return null;
|
|
957
|
+
let pi = -1;
|
|
958
|
+
for (let i = blk.head + 1; i < blk.end; i++) {
|
|
959
|
+
if (/^\s*-\s*id:\s*['"]?persona['"]?\s*$/.test(lines[i])) { pi = i; break; }
|
|
960
|
+
}
|
|
961
|
+
if (pi < 0) return null;
|
|
962
|
+
const personaIndent = (lines[pi].match(/^\s*/) || [''])[0].length;
|
|
963
|
+
let ki = -1;
|
|
964
|
+
let indent = 0;
|
|
965
|
+
let inline = false;
|
|
966
|
+
let inlineVal = '';
|
|
967
|
+
for (let i = pi + 1; i < blk.end; i++) {
|
|
968
|
+
const l = lines[i];
|
|
969
|
+
if (/^\s*#/.test(l)) continue; // 注释行跳过
|
|
970
|
+
const mB = /^(\s*)(?:prefix|text|prompt):\s*\|-?\s*$/.exec(l);
|
|
971
|
+
if (mB) { ki = i; indent = mB[1].length; inline = false; break; }
|
|
972
|
+
const mI = /^(\s*)(?:prefix|text|prompt):\s+(\S.*)$/.exec(l); // 行内标量写法
|
|
973
|
+
if (mI) { ki = i; indent = mI[1].length; inline = true; inlineVal = mI[2].trim(); break; }
|
|
974
|
+
const ind = (l.match(/^\s*/) || [''])[0].length;
|
|
975
|
+
if (/- /.test(l) && ind <= personaIndent) break;
|
|
976
|
+
}
|
|
977
|
+
if (ki < 0) return null;
|
|
978
|
+
if (inline) {
|
|
979
|
+
// 行内写法(如 `prefix: 你是一个 xx 助手`)→ 直接返回那一句
|
|
980
|
+
let v = inlineVal;
|
|
981
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
|
|
982
|
+
return { text: v, indent: indent + 2, bodyStart: ki + 1, bodyEnd: ki + 1, inline: true, lineIndex: ki };
|
|
983
|
+
}
|
|
984
|
+
let bEnd = ki + 1;
|
|
985
|
+
while (bEnd < blk.end) {
|
|
986
|
+
const raw = lines[bEnd];
|
|
987
|
+
if (raw.trim() === '') { bEnd++; continue; }
|
|
988
|
+
if (((raw.match(/^\s*/) || [''])[0]).length <= indent) break;
|
|
989
|
+
bEnd++;
|
|
990
|
+
}
|
|
991
|
+
let last = bEnd;
|
|
992
|
+
while (last > ki + 1 && lines[last - 1].trim() === '') last--;
|
|
993
|
+
const body = lines.slice(ki + 1, last);
|
|
994
|
+
const cuts = body.filter((l) => l.trim()).map((l) => (l.match(/^\s*/) || [''])[0].length);
|
|
995
|
+
const cut = cuts.length ? Math.min(...cuts) : indent + 2;
|
|
996
|
+
// 纯空白行原样保留(既不切片也不补缩进),保证「读出 → 原样写回」字节级一致
|
|
997
|
+
const text = body.map((l) => (l.trim() === '' ? l : l.slice(cut))).join('\n');
|
|
998
|
+
return { text, indent: cut, bodyStart: ki + 1, bodyEnd: last };
|
|
999
|
+
} catch { return null; }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 复制一个预设声明(dsh 0.1.7 路径):在 profile patch 里把 `- id: preset-<src>` 整段
|
|
1004
|
+
* 复制一份,只改写三处后追加到顶层数组 —— `- id:` / `config.id` / `config.name`,其余一个字节不动。
|
|
1005
|
+
* 行内换行按原文保真,缩进原样搬运。
|
|
1006
|
+
*/
|
|
1007
|
+
function copyPresetDecl(srcId, newId, newName) {
|
|
1008
|
+
try {
|
|
1009
|
+
if (!existsSync(PATCH_FILE)) return { ok: false, error: 'profile patch 不存在' };
|
|
1010
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1011
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1012
|
+
const lineTexts = [];
|
|
1013
|
+
const eols = [];
|
|
1014
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1015
|
+
if (i % 2 === 0) lineTexts.push(parts[i]);
|
|
1016
|
+
else eols.push(parts[i]);
|
|
1017
|
+
}
|
|
1018
|
+
const blk = locatePresetBlock(lineTexts, srcId);
|
|
1019
|
+
if (!blk) return { ok: false, error: '在 profile patch 里找不到源预设 preset-' + srcId };
|
|
1020
|
+
const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1021
|
+
const headRe = new RegExp('id:\\s*[\'"]?preset-' + escRe(srcId) + '[\'"]?');
|
|
1022
|
+
const seg = lineTexts.slice(blk.head, blk.end);
|
|
1023
|
+
const out = [];
|
|
1024
|
+
let cfgIndent = -1;
|
|
1025
|
+
for (let i = 0; i < seg.length; i++) {
|
|
1026
|
+
let l = seg[i];
|
|
1027
|
+
const ind = (l.match(/^\s*/) || [''])[0].length;
|
|
1028
|
+
if (i === 0) {
|
|
1029
|
+
l = l.replace(headRe, 'id: preset-' + newId);
|
|
1030
|
+
} else {
|
|
1031
|
+
const mCfg = /^(\s*)config:\s*$/.exec(l);
|
|
1032
|
+
if (mCfg) { cfgIndent = mCfg[1].length; }
|
|
1033
|
+
else if (cfgIndent >= 0 && ind === cfgIndent + 2) {
|
|
1034
|
+
if (/^\s*id:\s/.test(l)) l = l.replace(/id:\s*.*$/, 'id: ' + newId);
|
|
1035
|
+
else if (/^\s*name:\s/.test(l)) l = l.replace(/name:\s*.*$/, 'name: ' + newName);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
out.push(l);
|
|
1039
|
+
}
|
|
1040
|
+
const eol = eols[blk.end - 1] || eols[blk.head] || '\n';
|
|
1041
|
+
let add = '';
|
|
1042
|
+
for (const l of out) add += l + eol;
|
|
1043
|
+
let text = raw;
|
|
1044
|
+
if (!/[\r\n]$/.test(text)) text += eol;
|
|
1045
|
+
text += add;
|
|
1046
|
+
const bak = PATCH_FILE + '.bak-copy-' + Date.now();
|
|
1047
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1048
|
+
writeFileSync(PATCH_FILE, text, 'utf8');
|
|
1049
|
+
return { ok: true, backup: bak, newId };
|
|
1050
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* 用宿主的 `agentPresets.readDocument(id)` 读来源预设的组成,生成一条新的 preset 声明写进 patch。
|
|
1055
|
+
* 用途:**内置预设**(如 `standard`,它不在用户 patch 里)或任何 patch 中不存在的预设。
|
|
1056
|
+
* readDocument 官方标注"仅用于查看"——我们只把它当模板读出来,写进**自己的** profile patch。
|
|
1057
|
+
*/
|
|
1058
|
+
async function copyPresetViaDocument(srcId, newId, newName) {
|
|
1059
|
+
const ap = getAgentPresets();
|
|
1060
|
+
if (!ap || typeof ap.readDocument !== 'function') {
|
|
1061
|
+
return { ok: false, error: '宿主未提供 agentPresets.readDocument(读不了内置预设的组成)' };
|
|
1062
|
+
}
|
|
1063
|
+
let doc;
|
|
1064
|
+
try { doc = await ap.readDocument(srcId); }
|
|
1065
|
+
catch (e) { return { ok: false, error: '读取源预设 ' + srcId + ' 失败:' + String((e && e.message) || e) }; }
|
|
1066
|
+
const content = String((doc && doc.content) || '');
|
|
1067
|
+
if (!content.trim()) return { ok: false, error: '源预设 ' + srcId + ' 的组成为空' };
|
|
1068
|
+
const pad = ' ';
|
|
1069
|
+
const head = [
|
|
1070
|
+
'# ── 复制自 ' + srcId + '(组成由宿主的预设文档读出)──',
|
|
1071
|
+
'- insert:',
|
|
1072
|
+
' - id: preset-' + newId,
|
|
1073
|
+
" name: '@deepseek-ai/dsh-agent-preset'",
|
|
1074
|
+
' config:',
|
|
1075
|
+
' id: ' + newId,
|
|
1076
|
+
' name: ' + newName,
|
|
1077
|
+
' plugins:',
|
|
1078
|
+
];
|
|
1079
|
+
const bodyLines = content.split(/\r\n|\n/).map((l) => (l.trim() === '' ? '' : pad + l));
|
|
1080
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1081
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1082
|
+
const eol = parts[1] ?? '\n';
|
|
1083
|
+
let text = raw;
|
|
1084
|
+
if (!/[\r\n]$/.test(text)) text += eol;
|
|
1085
|
+
text += head.join(eol) + eol + bodyLines.join(eol) + eol;
|
|
1086
|
+
const bak = PATCH_FILE + '.bak-newpreset-' + Date.now();
|
|
1087
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1088
|
+
writeFileSync(PATCH_FILE, text, 'utf8');
|
|
1089
|
+
return { ok: true, backup: bak, newId, from: 'document' };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** 写回人设正文(只替换那段块标量,写前自动备份整个 patch)。
|
|
1093
|
+
* ⚠️ 换行符**逐行保真**:patch 可能 LF/CRLF 混用(实测踩过),统一 eol 会改掉别处字节。 */
|
|
1094
|
+
function writePresetPersona(presetId, text) {
|
|
1095
|
+
try {
|
|
1096
|
+
if (!existsSync(PATCH_FILE)) return { ok: false, error: 'profile patch 不存在' };
|
|
1097
|
+
const raw = readFileSync(PATCH_FILE, 'utf8');
|
|
1098
|
+
const parts = raw.split(/(\r\n|\n)/);
|
|
1099
|
+
const lineTexts = [];
|
|
1100
|
+
const eols = [];
|
|
1101
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1102
|
+
if (i % 2 === 0) lineTexts.push(parts[i]);
|
|
1103
|
+
else eols.push(parts[i]);
|
|
1104
|
+
}
|
|
1105
|
+
const found = readPresetPersona(presetId, lineTexts);
|
|
1106
|
+
if (!found) return { ok: false, error: '没找到该预设的人设段落(persona / prefix)' };
|
|
1107
|
+
let pad;
|
|
1108
|
+
let insertAt;
|
|
1109
|
+
let endAt;
|
|
1110
|
+
if (found.inline) {
|
|
1111
|
+
// 行内标量 → 升级成块标量(`prefix: |-` + 缩进正文),这样多行人设也存得下
|
|
1112
|
+
const keyRaw = lineTexts[found.lineIndex];
|
|
1113
|
+
const key = (/(prefix|text|prompt):/.exec(keyRaw) || [])[1] || 'prefix';
|
|
1114
|
+
const keyIndent = (keyRaw.match(/^\s*/) || [''])[0];
|
|
1115
|
+
lineTexts[found.lineIndex] = keyIndent + key + ': |-';
|
|
1116
|
+
pad = keyIndent + ' ';
|
|
1117
|
+
insertAt = found.lineIndex + 1;
|
|
1118
|
+
endAt = found.lineIndex + 1;
|
|
1119
|
+
} else {
|
|
1120
|
+
pad = ' '.repeat(found.indent);
|
|
1121
|
+
insertAt = found.bodyStart;
|
|
1122
|
+
endAt = found.bodyEnd;
|
|
1123
|
+
}
|
|
1124
|
+
const nl = eols[insertAt - 1] || '\n'; // 沿用原段落的换行风格
|
|
1125
|
+
const body = String(text).split(/\r\n|\n|\r/).map((l) => (l.trim() === '' ? l : pad + l));
|
|
1126
|
+
const head = lineTexts.slice(0, insertAt);
|
|
1127
|
+
const tail = lineTexts.slice(endAt);
|
|
1128
|
+
let out = '';
|
|
1129
|
+
for (let i = 0; i < head.length; i++) out += head[i] + (eols[i] ?? '\n');
|
|
1130
|
+
for (const l of body) out += l + nl;
|
|
1131
|
+
for (let i = 0; i < tail.length; i++) {
|
|
1132
|
+
out += tail[i];
|
|
1133
|
+
const ei = endAt + i;
|
|
1134
|
+
if (ei < eols.length) out += eols[ei];
|
|
1135
|
+
}
|
|
1136
|
+
const bak = PATCH_FILE + '.bak-persona-' + Date.now();
|
|
1137
|
+
try { writeFileSync(bak, raw, 'utf8'); } catch { /* 备份失败不阻塞 */ }
|
|
1138
|
+
writeFileSync(PATCH_FILE, out, 'utf8');
|
|
1139
|
+
return { ok: true, backup: bak };
|
|
1140
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
829
1143
|
function presetDirSafe(id) {
|
|
830
1144
|
if (!ID_RE.test(String(id || ''))) return null;
|
|
831
1145
|
const dir = resolve(PRESET_ROOT, id);
|
|
@@ -839,11 +1153,21 @@ export function apply(ctx) {
|
|
|
839
1153
|
return !!(p && p.hasChannelTools);
|
|
840
1154
|
}
|
|
841
1155
|
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
1156
|
const q = new URL(req.url, 'http://x').searchParams;
|
|
846
|
-
const
|
|
1157
|
+
const rid = String(q.get('id') || '').trim();
|
|
1158
|
+
// dsh 0.1.7+:预设声明在 profile patch 里 → 对外虚拟成「人设正文」一个可编辑文件
|
|
1159
|
+
if (getAgentPresets()) {
|
|
1160
|
+
if (!ID_RE.test(rid)) return writeJson(res, 400, { error: '非法的预设 id' });
|
|
1161
|
+
const persona = readPresetPersona(rid);
|
|
1162
|
+
if (!persona) return writeJson(res, 404, { error: '该预设没有内联人设段落(persona / prefix);人格可能在外部 .mjs 文件里' });
|
|
1163
|
+
return writeJson(res, 200, {
|
|
1164
|
+
id: rid,
|
|
1165
|
+
dir: '(profile patch)',
|
|
1166
|
+
writable: true,
|
|
1167
|
+
files: [{ name: 'persona(人设正文)', size: Buffer.byteLength(persona.text, 'utf8'), mtime: 0 }],
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
const dir = presetDirSafe(rid);
|
|
847
1171
|
if (!dir) return writeJson(res, 404, { error: '预设不存在' });
|
|
848
1172
|
try {
|
|
849
1173
|
const files = readdirSync(dir, { withFileTypes: true })
|
|
@@ -854,8 +1178,31 @@ export function apply(ctx) {
|
|
|
854
1178
|
} catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
|
|
855
1179
|
});
|
|
856
1180
|
route(ctx, ['GET', 'PUT'], '/api/qqbot-settings/presets/file', async (req, res) => {
|
|
857
|
-
|
|
858
|
-
if (
|
|
1181
|
+
// dsh 0.1.7+:只支持「人设正文」这一个虚拟文件的读写(落在 profile patch 的 persona 块标量里)
|
|
1182
|
+
if (getAgentPresets()) {
|
|
1183
|
+
if (req.method === 'PUT') {
|
|
1184
|
+
const body = await readJsonBody(req);
|
|
1185
|
+
const id = String(body?.id ?? '').trim();
|
|
1186
|
+
if (!ID_RE.test(id)) return writeJson(res, 400, { error: '非法的预设 id' });
|
|
1187
|
+
const content = String(body?.content ?? '');
|
|
1188
|
+
if (Buffer.byteLength(content, 'utf8') > PRESET_EDIT_MAX) return writeJson(res, 400, { error: '内容过大(上限 200KB)' });
|
|
1189
|
+
const w = writePresetPersona(id, content);
|
|
1190
|
+
if (!w.ok) return writeJson(res, 500, { error: w.error });
|
|
1191
|
+
return writeJson(res, 200, {
|
|
1192
|
+
ok: true,
|
|
1193
|
+
name: 'persona(人设正文)',
|
|
1194
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
1195
|
+
backup: w.backup,
|
|
1196
|
+
note: '已写入 profile patch,patchReload 热生效(新会话生效)',
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
const q = new URL(req.url, 'http://x').searchParams;
|
|
1200
|
+
const id = String(q.get('id') || '').trim();
|
|
1201
|
+
if (!ID_RE.test(id)) return writeJson(res, 400, { error: '非法的预设 id' });
|
|
1202
|
+
const persona = readPresetPersona(id);
|
|
1203
|
+
if (!persona) return writeJson(res, 404, { error: '该预设没有内联人设段落(persona / prefix)' });
|
|
1204
|
+
return writeJson(res, 200, { id, name: 'persona(人设正文)', content: persona.text, writable: true });
|
|
1205
|
+
}
|
|
859
1206
|
if (req.method === 'PUT') {
|
|
860
1207
|
const body = await readJsonBody(req);
|
|
861
1208
|
const id = String(body?.id ?? '');
|