@xia-sc/dsh-cc-studio 0.4.0 → 0.7.1
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 +112 -274
- package/lib/agent.js +898 -159
- package/lib/client.js +83 -35
- package/lib/index.js +131 -24
- package/package.json +2 -2
- package/presets/cc/agent.cordis.yml +6 -4
- package/presets/cc.patch.yml +6 -4
package/lib/agent.js
CHANGED
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
* 强制工作流:cc_get_card → cc_patch_character → cc_patch_world(≥3维,autoLorebook) → cc_add_lorebook_entries(≥5条) → cc_patch_greetings → cc_validate
|
|
5
5
|
*/
|
|
6
6
|
import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
7
|
-
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
const name = "dsh-cc-studio-agent";
|
|
11
|
+
// 版本标识(0.7.1):每次工具返回都带 `build`。
|
|
12
|
+
// 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
|
|
13
|
+
// 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
|
|
14
|
+
// 发版时与 package.json 的 version 同步。
|
|
15
|
+
const BUILD = "0.7.1";
|
|
11
16
|
const inject = ["tools", "agents"];
|
|
12
17
|
|
|
13
18
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
@@ -64,21 +69,111 @@ function loadPersistedDraftSync(key){
|
|
|
64
69
|
function persistDraftSync(key, draft){
|
|
65
70
|
try{
|
|
66
71
|
mkdirSync(draftDir(), { recursive: true });
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
// revision(0.6.0,P0-6):工具半与宿主半各持各的内存、各写各的文件,**只有文件里的 rev 是共同真相**。
|
|
73
|
+
// 每次落盘 +1;工具侧据此发现「草稿被外部(工坊界面操作 / host 恢复)换过了」——
|
|
74
|
+
// 第二轮实测正是栽在这里:LLM 基于过期草稿 patch,把用户刚改好的 personality 静默清空了。
|
|
75
|
+
let rev = 1;
|
|
76
|
+
try{
|
|
77
|
+
const prev = JSON.parse(readFileSync(draftPath(key), "utf8"));
|
|
78
|
+
if(prev && typeof prev.rev === "number" && prev.rev > 0) rev = prev.rev + 1;
|
|
79
|
+
}catch{}
|
|
80
|
+
writeFileSync(draftPath(key), JSON.stringify({ key: String(key), updatedAt: nowSec(), rev, draft }, null, 2), "utf8");
|
|
81
|
+
return rev;
|
|
82
|
+
}catch(e){ try{ console.error("[dsh-cc-studio] persist draft failed:", (e && e.message) || e); }catch{} return null; }
|
|
83
|
+
}
|
|
84
|
+
/** 盘上这份草稿的 rev(没有文件 / 0.5.x 及以前的旧格式 → null)。 */
|
|
85
|
+
function draftRevOnDisk(key){
|
|
86
|
+
try{
|
|
87
|
+
const p = findDraftPath(key);
|
|
88
|
+
if(!p) return null;
|
|
89
|
+
const obj = JSON.parse(readFileSync(p, "utf8"));
|
|
90
|
+
return (obj && typeof obj.rev === "number") ? obj.rev : null;
|
|
91
|
+
}catch{ return null; }
|
|
92
|
+
}
|
|
93
|
+
// —— 草稿的「来源 id」sidecar(0.5.0,修 P0-4)——
|
|
94
|
+
// `cc_load_from_library` 的工具文档承诺「载入后再次保存将按此 ID 更新」,但草稿里此前**没有任何地方
|
|
95
|
+
// 记住这个 id**,于是不传 id 保存就新建了一张同名重复卡 —— 用户载入自己的卡、改两笔、保存,侧边栏
|
|
96
|
+
// 就多一张重复,反复几次一地垃圾而用户毫无察觉(LLM 侧回环测试实测)。
|
|
97
|
+
// 存 sidecar 而不是塞进 draft.extensions:后者会跟着导出进用户的卡里。
|
|
98
|
+
function originPath(key){ return join(draftDir(), safeDraftFile(key).replace(/\.json$/, ".origin.json")); }
|
|
99
|
+
function readOriginSync(key){
|
|
100
|
+
try{
|
|
101
|
+
const p = originPath(key);
|
|
102
|
+
if(!existsSync(p)) return null;
|
|
103
|
+
const obj = JSON.parse(readFileSync(p, "utf8"));
|
|
104
|
+
if(obj && typeof obj.libraryId === "string" && obj.libraryId) return obj;
|
|
105
|
+
}catch{}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function writeOriginSync(key, libraryId, name){
|
|
109
|
+
try{
|
|
110
|
+
if(!libraryId){ try{ unlinkSync(originPath(key)); }catch{} return; }
|
|
111
|
+
mkdirSync(draftDir(), { recursive: true });
|
|
112
|
+
writeFileSync(originPath(key), JSON.stringify({ key: String(key), libraryId: String(libraryId), name: String(name||""), savedAt: nowSec() }, null, 2), "utf8");
|
|
113
|
+
}catch(e){ try{ console.error("[dsh-cc-studio] persist draft origin failed:", (e && e.message) || e); }catch{} }
|
|
69
114
|
}
|
|
70
115
|
function draftMeta(key){
|
|
71
116
|
const k = String(key || "default");
|
|
72
|
-
if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false });
|
|
117
|
+
if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false, rev: null, pendingFresh: null });
|
|
73
118
|
return globalThis.__CC_DRAFT_META__.get(k);
|
|
74
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* 草稿状态(0.6.0,P0-6):**把「身份」和「会话槽」分开说清楚**。
|
|
122
|
+
* 旧版只有 `key`,而它是会话槽标识(同一会话里恒定不变)+ `isNew`,两个看起来都能标识草稿的字段
|
|
123
|
+
* 其实都不承载卡片身份 —— 第二轮实测中草稿被外部换成另一张卡后,LLM 完全无法察觉。
|
|
124
|
+
* 现在:`sessionKey`(会话槽,明说它不是身份)、`cardId` / `cardName`(真正的身份)、`rev`(版本号)。
|
|
125
|
+
*/
|
|
75
126
|
function draftStatusFor(key, draft){
|
|
76
127
|
const m = draftMeta(key);
|
|
77
|
-
|
|
128
|
+
const k = String(key);
|
|
129
|
+
const origin = readOriginSync(k);
|
|
130
|
+
return {
|
|
131
|
+
isNew: !!m.isNew,
|
|
132
|
+
recovered: !!m.recovered,
|
|
133
|
+
// B1(0.7.1):`creation_date` 在外部导入的卡上可能整个缺失 —— 缺了就显式给 null,
|
|
134
|
+
// 绝不让 `undefined` 漏进返回值(DSH 的 lossless-JSON 校验会因为一个 undefined 判整次调用失败)。
|
|
135
|
+
creation_date: (draft && draft.data && draft.data.creation_date !== undefined) ? draft.data.creation_date : null,
|
|
136
|
+
sessionKey: k,
|
|
137
|
+
cardId: (origin && origin.libraryId) || null,
|
|
138
|
+
cardName: (draft && draft.data && String(draft.data.name||"").trim()) || "",
|
|
139
|
+
rev: (typeof m.rev === "number") ? m.rev : null,
|
|
140
|
+
// watchedRev 由 tool() 包装在调用前捕获后注入:它 = 「本次调用开始前工具半记着的盘上版本」。
|
|
141
|
+
watchedRev: null,
|
|
142
|
+
};
|
|
78
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* 取一次「草稿被外部改过」的提示(0.7.0:改由 `tool()` 包装按 rev 漂移驱动,不再靠 getDraft 的 flag)。
|
|
146
|
+
*
|
|
147
|
+
* 判据统一成一句话:**盘上的 rev 与本次调用开始前我记着的 rev 不一致** —— 也就是「在我上次动手之后、
|
|
148
|
+
* 这次动手之前,有别人(工坊界面 / host 恢复 / 另一个会话)写过这份草稿」。自己写的那一次不算。
|
|
149
|
+
* 这条同时兑现了 LLM 侧第四轮的 S1:**不传 expectedRev 的调用也会被提醒**(弱模型不会主动传乐观锁)。
|
|
150
|
+
*/
|
|
151
|
+
function externalChangeNotice(key, draft, fromRev, toRev){
|
|
152
|
+
const name = (draft && draft.data && String(draft.data.name||"").trim()) || "(未命名)";
|
|
153
|
+
const entries = (draft && draft.data && draft.data.character_book && draft.data.character_book.entries) ? draft.data.character_book.entries.length : 0;
|
|
154
|
+
return "⚠️ 草稿已被外部修改(工坊界面操作或 host 恢复),已自动同步到最新版本(rev " + fromRev + " → " + toRev + ")—— 当前卡片「" + name + "」共 " + entries + " 条世界书。动手改字段前先确认卡片名对不对。";
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 新草稿提示(0.7.0 修 S2)。
|
|
158
|
+
*
|
|
159
|
+
* 原始警告说的是「此前的内容可能已丢失」,而它对**主动 cc_new_card 重置**是确凿的误报
|
|
160
|
+
* (第四轮实测:刚重置完再写入,仍被吓一句)—— 误报会训练用户和模型忽略 warning。
|
|
161
|
+
* 所以改成**待告知标记** `pendingFresh`:只有「内存与磁盘都没有草稿、这次调用才现建了一张空卡」
|
|
162
|
+
* 时才是 `"welcome"`(真·host 重启后的新会话,提示有意义);`cc_new_card` 主动重置则是 `null`。
|
|
163
|
+
* 用标记而不是当场判断,是因为提示要由 `tool()` 包装在**调用之后**统一发出,那时 `setDraft`
|
|
164
|
+
* 已经把 `isNew` 清掉了。
|
|
165
|
+
*/
|
|
79
166
|
function freshNotice(meta){
|
|
80
|
-
if(meta
|
|
81
|
-
return "";
|
|
167
|
+
if(!meta || !meta.pendingFresh) return "";
|
|
168
|
+
return "⚠️ 当前会话此前无草稿(可能为 host 重启后的新会话,或全新会话 id),已新建空白草稿。若此前有未 ★ 保存到侧边栏的内容可能已丢失,请先确认;如需继续旧卡请调 cc_list_library/cc_load_from_library。";
|
|
169
|
+
}
|
|
170
|
+
/** 卡片内容指纹(忽略 modification_date):用来判断「这次写入到底有没有真的改变内容」(N4)。 */
|
|
171
|
+
function cardFingerprint(draft){
|
|
172
|
+
try{
|
|
173
|
+
const d = JSON.parse(JSON.stringify((draft && draft.data) ? draft.data : {}));
|
|
174
|
+
delete d.modification_date;
|
|
175
|
+
return JSON.stringify(d);
|
|
176
|
+
}catch{ return ""; }
|
|
82
177
|
}
|
|
83
178
|
function recoveredNotice(key, draft){
|
|
84
179
|
const m = draftMeta(key);
|
|
@@ -107,28 +202,100 @@ function keyFor(ctx, exec){
|
|
|
107
202
|
}catch{}
|
|
108
203
|
return "default";
|
|
109
204
|
}
|
|
205
|
+
/** 空白卡(新草稿与 cc_new_card 共用一份定义,避免两处漂移) */
|
|
206
|
+
function makeEmptyCard(){
|
|
207
|
+
return {
|
|
208
|
+
spec:"chara_card_v3", spec_version:"3.0",
|
|
209
|
+
data:{
|
|
210
|
+
name:"", nickname:"", tags:[], creator:"", character_version:"0.1",
|
|
211
|
+
description:"", personality:"", scenario:"", system_prompt:"", post_history_instructions:"",
|
|
212
|
+
first_mes:"", alternate_greetings:[], group_only_greetings:[], mes_example:"",
|
|
213
|
+
creator_notes:"", creator_notes_multilingual:{}, source:[], assets:[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}],
|
|
214
|
+
creation_date: nowSec(), modification_date: nowSec(),
|
|
215
|
+
character_book:{ name:"", description:"", scan_depth:4, token_budget:1200, recursive_scanning:false, extensions:{}, entries:[] }
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 草稿形状规范化(0.7.1,B1)。
|
|
221
|
+
*
|
|
222
|
+
* 背景:DSH 对工具返回值做 lossless-JSON 校验 —— **任何嵌套 `undefined` / `NaN` / `-0` 都会让整次
|
|
223
|
+
* 工具调用直接报错**(用户看到的是「工具坏了」)。第五轮实测:重启后第一次
|
|
224
|
+
* `cc_get_card({fields:[...]})` 就炸在这里,报 `value is not lossless JSON`。
|
|
225
|
+
*
|
|
226
|
+
* 根因是**外部来源的卡结构不完整**:PNG/CHARX 导入的卡、或早期版本写下的草稿可能没有
|
|
227
|
+
* `spec_version` / `creation_date` 这类字段,而代码直接读它、原样输出 → `undefined` 泄漏。
|
|
228
|
+
* 所以加载草稿时**按空卡模板补齐缺失键**:只补 `undefined`,不覆盖已有值、不改类型
|
|
229
|
+
* (类型不对属于内容问题,交给 cc_validate 报),下游就永远拿到完整结构。
|
|
230
|
+
*/
|
|
231
|
+
function normalizeCardShape(draft){
|
|
232
|
+
const tpl = makeEmptyCard();
|
|
233
|
+
if(!draft || typeof draft !== "object") return tpl;
|
|
234
|
+
const out = {
|
|
235
|
+
spec: (draft.spec !== undefined && draft.spec !== null) ? draft.spec : tpl.spec,
|
|
236
|
+
spec_version: (draft.spec_version !== undefined && draft.spec_version !== null) ? draft.spec_version : tpl.spec_version,
|
|
237
|
+
data: {},
|
|
238
|
+
};
|
|
239
|
+
const src = (draft.data && typeof draft.data === "object") ? draft.data : {};
|
|
240
|
+
for(const k of Object.keys(tpl.data)){
|
|
241
|
+
out.data[k] = (src[k] !== undefined) ? src[k] : tpl.data[k];
|
|
242
|
+
}
|
|
243
|
+
// 模板之外的字段(用户扩展 / 未来版本新增)原样保留,别吃掉
|
|
244
|
+
for(const k of Object.keys(src)){ if(!(k in out.data)) out.data[k] = src[k]; }
|
|
245
|
+
const book = (out.data.character_book && typeof out.data.character_book === "object") ? out.data.character_book : {};
|
|
246
|
+
const bTpl = tpl.data.character_book;
|
|
247
|
+
const nb = {};
|
|
248
|
+
for(const k of Object.keys(bTpl)){ nb[k] = (book[k] !== undefined) ? book[k] : bTpl[k]; }
|
|
249
|
+
for(const k of Object.keys(book)){ if(!(k in nb)) nb[k] = book[k]; }
|
|
250
|
+
if(!Array.isArray(nb.entries)) nb.entries = [];
|
|
251
|
+
if(!nb.extensions || typeof nb.extensions !== "object") nb.extensions = {};
|
|
252
|
+
out.data.character_book = nb;
|
|
253
|
+
if(!out.data.extensions || typeof out.data.extensions !== "object") out.data.extensions = {};
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
/** 输出卫生(0.7.1,B1 的第二道防线):JSON 往返一趟,丢掉任何 `undefined` 属性。 */
|
|
257
|
+
function sanitizeJson(value){
|
|
258
|
+
try{ return JSON.parse(JSON.stringify(value)); }catch{ return value; }
|
|
259
|
+
}
|
|
260
|
+
/** 值是否真的变了(B2:`changed` 与 `rev` 必须用同一套判据,别一个说改了、一个纹丝不动)。 */
|
|
261
|
+
function sameJsonValue(a, b){
|
|
262
|
+
if(a === b) return true;
|
|
263
|
+
try{ return JSON.stringify(a) === JSON.stringify(b); }catch{ return false; }
|
|
264
|
+
}
|
|
265
|
+
/** 赋值并只在「值真的变了」时记进 `changed`(B2,见上)。 */
|
|
266
|
+
function assignIfChanged(target, key, value, changed){
|
|
267
|
+
const before = target[key];
|
|
268
|
+
target[key] = value;
|
|
269
|
+
if(sameJsonValue(before, value)) return false;
|
|
270
|
+
if(changed) changed.push(key);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
110
273
|
function getDraft(ctx, exec){
|
|
111
274
|
const m = draftsMap();
|
|
112
275
|
const k = keyFor(ctx, exec);
|
|
276
|
+
const meta = draftMeta(k);
|
|
277
|
+
// —— P0-6:工坊界面 / host 恢复可能在两次工具调用之间把草稿整份换掉 ——
|
|
278
|
+
// 盘上的 rev 一旦与本半上次见到的不一致,就**以盘为准重载**,绝不拿过期前提去写。
|
|
279
|
+
// (第二轮实测:LLM 基于旧草稿 patch,把用户刚在工坊里改好的 personality 静默清空了。)
|
|
280
|
+
const diskRev = draftRevOnDisk(k);
|
|
281
|
+
if(m.has(k) && diskRev !== null && meta.rev !== null && diskRev !== meta.rev){
|
|
282
|
+
const latest = loadPersistedDraftSync(k);
|
|
283
|
+
if(latest){
|
|
284
|
+
m.set(k, normalizeCardShape(latest));
|
|
285
|
+
meta.isNew = false; meta.recovered = false; meta.pendingFresh = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if(diskRev !== null) meta.rev = diskRev;
|
|
113
289
|
if(!m.has(k)){
|
|
114
290
|
// fix #2:内存 miss 时先尝试从磁盘恢复,而非静默建空
|
|
115
291
|
const restored = loadPersistedDraftSync(k);
|
|
116
292
|
if(restored){
|
|
117
|
-
m.set(k, restored);
|
|
118
|
-
|
|
293
|
+
m.set(k, normalizeCardShape(restored)); // B1:外部来源的卡可能缺字段,恢复时就补齐
|
|
294
|
+
meta.isNew = false; meta.recovered = true; meta.pendingFresh = null;
|
|
119
295
|
} else {
|
|
120
|
-
m.set(k,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
name:"", nickname:"", tags:[], creator:"", character_version:"0.1",
|
|
124
|
-
description:"", personality:"", scenario:"", system_prompt:"", post_history_instructions:"",
|
|
125
|
-
first_mes:"", alternate_greetings:[], group_only_greetings:[], mes_example:"",
|
|
126
|
-
creator_notes:"", creator_notes_multilingual:{}, source:[], assets:[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}],
|
|
127
|
-
creation_date: nowSec(), modification_date: nowSec(),
|
|
128
|
-
character_book:{ name:"", description:"", scan_depth:4, token_budget:1200, recursive_scanning:false, extensions:{}, entries:[] }
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
const meta = draftMeta(k); meta.isNew = true; meta.recovered = false;
|
|
296
|
+
m.set(k, makeEmptyCard());
|
|
297
|
+
// 内存与磁盘都没有草稿 → 真·新会话:这条提示值得发(可能真丢过内容)
|
|
298
|
+
meta.isNew = true; meta.recovered = false; meta.pendingFresh = "welcome";
|
|
132
299
|
}
|
|
133
300
|
}
|
|
134
301
|
return m.get(k);
|
|
@@ -136,10 +303,17 @@ function getDraft(ctx, exec){
|
|
|
136
303
|
function setDraft(ctx, draft, exec){
|
|
137
304
|
const m = draftsMap();
|
|
138
305
|
const k = keyFor(ctx, exec);
|
|
306
|
+
const prev = m.get(k);
|
|
307
|
+
// N4(0.7.0):**内容一字未变时不落盘、不递增 rev**。空改动(传了不生效的参数、同值覆盖、
|
|
308
|
+
// 删 0 条条目…)原来也会把 rev 推进一格,于是持有旧 rev 的调用方会无辜失败一次
|
|
309
|
+
// (第四轮实测:`changed: []` 让 rev 7 → 8)。modification_date 也要跟着不动,否则指纹永远不等。
|
|
310
|
+
if(prev && cardFingerprint(prev) === cardFingerprint(draft)) return draft;
|
|
139
311
|
draft.data.modification_date = nowSec();
|
|
140
312
|
m.set(k, draft);
|
|
313
|
+
// pendingFresh 刻意不在这里清:新草稿提示由 tool() 包装在**调用之后**统一发,这里清掉就发不出来了。
|
|
141
314
|
const meta = draftMeta(k); meta.isNew = false; meta.recovered = false;
|
|
142
|
-
persistDraftSync(k, draft);
|
|
315
|
+
const rev = persistDraftSync(k, draft);
|
|
316
|
+
if(typeof rev === "number") meta.rev = rev; // 自己写的这一次不算「外部修改」
|
|
143
317
|
return draft;
|
|
144
318
|
}
|
|
145
319
|
|
|
@@ -165,11 +339,12 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
|
165
339
|
const data=draft&&draft.data?draft.data:{};
|
|
166
340
|
const now=nowSec();
|
|
167
341
|
const loreCount=Array.isArray(data.character_book?.entries)?data.character_book.entries.length:0;
|
|
168
|
-
const
|
|
342
|
+
const allTags=Array.isArray(data.tags)?data.tags.map(String):[];
|
|
343
|
+
const tags=allTags.slice(0,6);
|
|
169
344
|
return {
|
|
170
345
|
id:String(id),
|
|
171
346
|
name:(data.name && String(data.name).trim()) || (existingMeta && existingMeta.name) || "未命名角色",
|
|
172
|
-
loreCount, tags,
|
|
347
|
+
loreCount, tags, tagsTotal: allTags.length,
|
|
173
348
|
descriptionSnippet:String(data.description||"").slice(0,80),
|
|
174
349
|
createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
|
|
175
350
|
updatedAt: now,
|
|
@@ -207,10 +382,15 @@ async function saveLibraryEntry(draft, opts){
|
|
|
207
382
|
}
|
|
208
383
|
if(!id) id=String(Date.now())+"-"+Math.random().toString(36).slice(2,6);
|
|
209
384
|
const meta=makeLibraryEntryMeta(draft, existing?existing.meta:null, id);
|
|
210
|
-
|
|
385
|
+
// 改名 = 单一事实来源:meta.name 与 draft.data.name 一起改(否则「列表名 / 导出名」两套,用户改名看不出效果)
|
|
386
|
+
if(opts && typeof opts.name==="string" && opts.name.trim()){
|
|
387
|
+
const n=String(opts.name).trim().slice(0,60);
|
|
388
|
+
meta.name=n;
|
|
389
|
+
draft.data.name=n;
|
|
390
|
+
}
|
|
211
391
|
const payload={id,meta,draft};
|
|
212
392
|
await writeFile(join(dir,id+".json"), JSON.stringify(payload,null,2), "utf8");
|
|
213
|
-
return {id,meta,draft};
|
|
393
|
+
return {id,meta,draft,action:existing?"updated":"created"};
|
|
214
394
|
}
|
|
215
395
|
async function loadLibraryEntry(id){
|
|
216
396
|
await ensureLibDir();
|
|
@@ -238,60 +418,250 @@ async function renameLibraryEntry(id,newName){
|
|
|
238
418
|
if(!name) throw new Error("name 不能为空");
|
|
239
419
|
obj.meta.name=name;
|
|
240
420
|
obj.meta.updatedAt=nowSec();
|
|
421
|
+
// 单一事实来源(0.5.0):列表名与卡内 data.name 一起改 —— 此前只改 meta,导出后仍是旧名
|
|
422
|
+
if(obj.draft && obj.draft.data) obj.draft.data.name=name;
|
|
241
423
|
await writeFile(found.path, JSON.stringify(obj,null,2), "utf8");
|
|
242
424
|
return obj.meta;
|
|
243
425
|
}
|
|
244
426
|
|
|
245
427
|
function out(schema){ return { schema, render: (args, result) => [{ type:"text", text: JSON.stringify(result, null, 2) }] }; }
|
|
246
428
|
|
|
247
|
-
// ——
|
|
429
|
+
// —— 强制工作流状态计算(0.5.0 重写)——
|
|
430
|
+
// 两处结构性修正,都来自 LLM 侧实测反馈:
|
|
431
|
+
// ① **每一项都是三态**(missing / partial / ok),不再有 `characterOk` 与 `characterPartial` 同时为 true
|
|
432
|
+
// 的自相矛盾组合(模型无法据此决策,只能忽略);
|
|
433
|
+
// ② **lore 的「完成」要求至少 1 条非自动条目**:否则 autoLorebook 一次生成 5 条就顶替了第 4 步的共创,
|
|
434
|
+
// 而 `cc_add_lorebook_entries` 的契约写着「必调」—— 两条相反指令同时下发正是实测到的卡壳点。
|
|
435
|
+
const WORLD_DIMS = ["timeline","factions","geo","power","daily"];
|
|
436
|
+
const DIM_NAMES = { timeline:"年表", factions:"势力", geo:"地理", power:"力量体系", daily:"日常" };
|
|
437
|
+
// 英文 key 与 0.4.x 生成过的完全一致(老卡里就是这个写法),避免升级后触发词看起来「悄悄变了」
|
|
438
|
+
const DIM_EN = { timeline:"timeline", factions:"faction", geo:"geography", power:"power", daily:"daily" };
|
|
439
|
+
// 旧数据没有 cc_auto 标记,靠「名字是维度名」兜底;但**必须同时看内容**,
|
|
440
|
+
// 否则用户手工建的、恰好叫「日常」的条目会被下一次 cc_patch_world 静默删掉(0.5.0 修)。
|
|
441
|
+
const AUTO_NAMES = new Set(["年表","势力","地理","力量体系","日常"]);
|
|
442
|
+
function isAutoEntry(e){
|
|
443
|
+
return !!(e && (e.cc_auto === true || (e.extensions && e.extensions.cc_auto)));
|
|
444
|
+
}
|
|
445
|
+
function looksLikeLegacyAuto(e){
|
|
446
|
+
if(!e || !AUTO_NAMES.has(String(e.name||""))) return false;
|
|
447
|
+
return /^\s*@@/.test(String(e.content||"")) || Array.isArray(e.keys) && e.keys.some(k => ["timeline","faction","geography","power","daily"].includes(String(k)));
|
|
448
|
+
}
|
|
248
449
|
function workflowStatus(draft){
|
|
249
450
|
const d = draft && draft.data ? draft.data : {};
|
|
250
|
-
const w = d.extensions && d.extensions.cc_world ? d.extensions.cc_world : {};
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
451
|
+
const w = (d.extensions && d.extensions.cc_world) ? d.extensions.cc_world : {};
|
|
452
|
+
const entries = (d.character_book && Array.isArray(d.character_book.entries)) ? d.character_book.entries : [];
|
|
453
|
+
const loreCount = entries.length;
|
|
454
|
+
const manualLoreCount = entries.filter(e => !isAutoEntry(e)).length;
|
|
455
|
+
const worldFilled = WORLD_DIMS.filter(k => typeof w[k]==="string" && String(w[k]).trim().length>=6).length;
|
|
456
|
+
|
|
457
|
+
const nameOk = !!String(d.name||"").trim();
|
|
458
|
+
const descOk = !!String(d.description||"").trim();
|
|
459
|
+
const characterState = (nameOk && descOk && String(d.personality||"").trim() && String(d.scenario||"").trim())
|
|
460
|
+
? "ok" : ((nameOk && descOk) ? "partial" : "missing");
|
|
461
|
+
const worldState = worldFilled >= 3 ? "ok" : (worldFilled > 0 ? "partial" : "missing");
|
|
462
|
+
const loreState = (loreCount >= 5 && manualLoreCount >= 1) ? "ok" : (loreCount > 0 ? "partial" : "missing");
|
|
463
|
+
const gFirst = !!String(d.first_mes||"").trim();
|
|
464
|
+
const gAlt = Array.isArray(d.alternate_greetings) ? d.alternate_greetings.length : 0;
|
|
465
|
+
const gGroup = Array.isArray(d.group_only_greetings) ? d.group_only_greetings.length : 0;
|
|
466
|
+
const greetingsState = (gFirst && gAlt>=2 && gGroup>=1) ? "ok" : ((gFirst || gGroup>=1) ? "partial" : "missing");
|
|
467
|
+
|
|
468
|
+
let step = 6; let next = "";
|
|
469
|
+
if(characterState !== "ok"){
|
|
470
|
+
step = 2; next = "下一步必须 cc_patch_character:先与用户讨论角色定位(气质/关系张力等,1-2 问)再补齐 name/description/personality/scenario,严禁直接推断填满";
|
|
471
|
+
} else if(worldState !== "ok"){
|
|
472
|
+
step = 3; next = "角色基础已齐,下一步必须 cc_patch_world:先与用户讨论世界观侧重(哪几维最想展开/有无私设)再补 5 维(≥3 项),**并为每一维自报触发词 loreKeys**(真实对话里用户会说的实体名,如 {geo:[\"旧校舍\",\"广播室\"]})——不给的话自动条目只能用维度名当 key,永远命中不了";
|
|
473
|
+
} else if(loreState !== "ok"){
|
|
474
|
+
step = 4;
|
|
475
|
+
next = manualLoreCount === 0
|
|
476
|
+
? "五维已齐,autoLorebook 已自动生成 "+loreCount+" 条基础条目,但它们以维度名当触发词、且不满足共创要求:下一步必须 cc_add_lorebook_entries,先与用户讨论触发词/常驻偏好,至少再补 1 条(总≥5 条、至少 1 constant、content 头部写 @@decorator)"
|
|
477
|
+
: "五维已齐,下一步必须 cc_add_lorebook_entries:与用户讨论触发词/常驻偏好后补到≥5 条(年表/势力/地理/力量/日常各 1,至少 1 constant,@@decorator),当前 "+loreCount+" 条(其中共创 "+manualLoreCount+" 条)";
|
|
478
|
+
} else if(greetingsState !== "ok"){
|
|
479
|
+
step = 5; next = "世界书已齐("+loreCount+" 条,其中共创 "+manualLoreCount+" 条),下一步必须 cc_patch_greetings:先问用户偏好的开场场景/关系阶段/群聊与私聊区分,再补问候语(first_mes + alternate≥2 + group≥1)";
|
|
269
480
|
} else {
|
|
270
|
-
step = 6; next = "
|
|
481
|
+
step = 6; next = "结构已齐,下一步必须 cc_validate 校验,valid=true 才可收尾;通过后提醒用户在工坊左下角「校验导出」入口打开导出页(JSON / PNG / CHARX),或点「★ 保存当前」落盘";
|
|
271
482
|
}
|
|
272
|
-
const
|
|
273
|
-
return {
|
|
483
|
+
const state = { character: characterState, world: worldState, lore: loreState, greetings: greetingsState };
|
|
484
|
+
return {
|
|
485
|
+
step,
|
|
486
|
+
readyToValidate: step === 6,
|
|
487
|
+
state,
|
|
488
|
+
worldFilled, loreCount, manualLoreCount,
|
|
489
|
+
next,
|
|
490
|
+
// 0.6.0(R1/R2):`hint` 只报**进度**,不再内嵌 `next` 全文 ——
|
|
491
|
+
// 旧版 `hint = 进度 + " → " + next` 让同一句话在一份返回里出现三遍(hint 内嵌 next、message 又等于 hint)。
|
|
492
|
+
// 终态(校验通过)也走同一个函数,所以 `hint` 不会再说「下一步必须 cc_validate」(那是第一轮 P0-3 的半修残留)。
|
|
493
|
+
hint: progressHint(step, state, worldFilled, loreCount, manualLoreCount, false),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/** 一行进度摘要(**不含 next** —— 下一步是 workflow.next 的职责,别再复制一份)。 */
|
|
497
|
+
function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done){
|
|
498
|
+
const mark = s => s==="ok" ? "✓" : (s==="partial" ? "◐" : "✗");
|
|
499
|
+
return "工作流 " + step + "/" + (done ? 7 : 6)
|
|
500
|
+
+ " " + mark(state.character) + "角色"
|
|
501
|
+
+ " " + mark(state.world) + "五维(" + worldFilled + "/5)"
|
|
502
|
+
+ " " + mark(state.lore) + "世界书(" + loreCount + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
|
|
503
|
+
+ " " + mark(state.greetings) + "问候语"
|
|
504
|
+
+ (done ? " · 已完成,可导出/保存" : "");
|
|
505
|
+
}
|
|
506
|
+
// 粗略 token 估算:CJK 一字≈1 token,其余按 4 字符≈1 token(用于 validate 的预算告警与 lint/preview 报告)
|
|
507
|
+
function estimateTokens(text){
|
|
508
|
+
const s = String(text||"");
|
|
509
|
+
let cjk = 0;
|
|
510
|
+
for(const ch of s){ if(ch.charCodeAt(0) > 0x2E7F) cjk++; }
|
|
511
|
+
const rest = s.length - cjk;
|
|
512
|
+
return Math.ceil(cjk + rest/4);
|
|
513
|
+
}
|
|
514
|
+
function lorebookTokens(entries){
|
|
515
|
+
return (Array.isArray(entries)? entries : []).reduce((n,e) => n + estimateTokens(e && e.content), 0);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* 最坏情况单次触发量(0.7.0,N2)。
|
|
519
|
+
*
|
|
520
|
+
* 为什么不能只看总量:`token_budget` 管的是**一轮对话**里世界书能塞多少,而真正的坑是
|
|
521
|
+
* 「用户随便一句话被好几个条目同时命中」—— 第四轮实测:总量 2272 / 预算 1200 看着只是超一点,
|
|
522
|
+
* 但一句「剑冢到底是什么地方」单独就吃掉 **1139 token**(势力 + 地理 + 力量体系(constant) + 剑冢(constant))。
|
|
523
|
+
* 更糟的是它太好「修好」了:把 token_budget 调大,lint 立刻只剩一条 warning,可单次爆炸一点没变。
|
|
524
|
+
*
|
|
525
|
+
* 算法:把**共享同一个触发词的条目**并成一组(传递闭包 —— A/B 共享 x、B/C 共享 y,那么一句话
|
|
526
|
+
* 可以把三条全带出来),再叠加**每轮必插的 constant**,取最大的那组作为最坏情况。
|
|
527
|
+
* 单字符 key 不参与共现("a" 这种会把整本书并成一组,报告全是噪音)。
|
|
528
|
+
*/
|
|
529
|
+
function worstCaseTrigger(entries){
|
|
530
|
+
const list = (Array.isArray(entries) ? entries : []).filter(e => e && e.enabled !== false);
|
|
531
|
+
const constantEntries = list.filter(e => e.constant);
|
|
532
|
+
const constantTokens = constantEntries.reduce((n, e) => n + estimateTokens(e && e.content), 0);
|
|
533
|
+
const varEntries = list.filter(e => !e.constant);
|
|
534
|
+
const parent = new Map();
|
|
535
|
+
const find = x => { let r = x; while(parent.get(r) !== r) r = parent.get(r); while(parent.get(x) !== r){ const nx = parent.get(x); parent.set(x, r); x = nx; } return r; };
|
|
536
|
+
varEntries.forEach((e, i) => parent.set(i, i));
|
|
537
|
+
const byKey = new Map();
|
|
538
|
+
varEntries.forEach((e, i) => {
|
|
539
|
+
(Array.isArray(e.keys) ? e.keys : []).forEach(k => {
|
|
540
|
+
const kk = String(k).trim().toLowerCase();
|
|
541
|
+
if(kk.length < 2) return;
|
|
542
|
+
if(!byKey.has(kk)) byKey.set(kk, []);
|
|
543
|
+
byKey.get(kk).push(i);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
byKey.forEach(idxList => { for(let i = 1; i < idxList.length; i++) parent.set(find(idxList[i]), find(idxList[0])); });
|
|
547
|
+
const groups = new Map();
|
|
548
|
+
varEntries.forEach((e, i) => {
|
|
549
|
+
const root = find(i);
|
|
550
|
+
if(!groups.has(root)) groups.set(root, { tokens: 0, names: [] });
|
|
551
|
+
const g = groups.get(root);
|
|
552
|
+
g.tokens += estimateTokens(e && e.content);
|
|
553
|
+
g.names.push(String((e && e.name) || ""));
|
|
554
|
+
});
|
|
555
|
+
let best = { tokens: constantTokens, names: [] };
|
|
556
|
+
groups.forEach(g => {
|
|
557
|
+
if(g.tokens + constantTokens > best.tokens) best = { tokens: g.tokens + constantTokens, names: g.names.slice() };
|
|
558
|
+
});
|
|
559
|
+
// 单条自己就最重的情况已含在上面(没有共享 key 的条目自成一组),这里只把常驻条目的名字补进名单。
|
|
560
|
+
const names = best.names.concat(constantEntries.map(e => String((e && e.name) || "")));
|
|
561
|
+
return { tokens: best.tokens, constantTokens, entries: names };
|
|
274
562
|
}
|
|
275
563
|
|
|
564
|
+
|
|
276
565
|
function apply(ctx){
|
|
277
|
-
|
|
566
|
+
// —— 统一收口(0.6.0,P0-6)——
|
|
567
|
+
// 每个工具的返回都补上「我在操作哪张卡」:`cardName` / `cardId`(未关联已存卡时为 null)+
|
|
568
|
+
// `draftStatus`(含 sessionKey / cardId / cardName / rev)。19 个工具各写一遍没有意义,也容易漏。
|
|
569
|
+
// 另外做两件防「静默数据损坏」的事:
|
|
570
|
+
// ① 盘上 rev 与期望不符(模型传了 expectedRev)→ 直接拒绝,别拿过期前提去写;
|
|
571
|
+
// ② **上次调用之后有别人写过这份草稿**(0.7.0 起按 rev 漂移判定,见 S1)→ 返回里给一条明确提示。
|
|
572
|
+
// 新草稿提示(S2)也在这里出:调用前捕获成因,`reset` 不报「内容可能已丢失」。
|
|
573
|
+
const tool = (def) => {
|
|
574
|
+
const raw = def.execute;
|
|
575
|
+
def.execute = async function(args, exec){
|
|
576
|
+
const k = keyFor(ctx, exec);
|
|
577
|
+
const meta = draftMeta(k);
|
|
578
|
+
const expected = (args && typeof args.expectedRev === "number") ? args.expectedRev : null;
|
|
579
|
+
const beforeRev = draftRevOnDisk(k);
|
|
580
|
+
// S1 补充(0.7.1):把「本次调用开始前工具半记着的盘上版本」捕下来,返回里一并报出 ——
|
|
581
|
+
// 模型可以随时对账:`watchedRev ≠ rev` 就说明这两次调用之间有别人写过。第五轮实测里,
|
|
582
|
+
// 测试者想验证「不传 expectedRev 也能发现漂移」,却在工具序列内造不出漂移(自己写的 rev
|
|
583
|
+
// 会被记成自己的),有了这个字段它就能自己看懂机制,不必靠猜。
|
|
584
|
+
const watchedRevAtStart = (typeof meta.rev === "number") ? meta.rev : null;
|
|
585
|
+
// —— S1(0.7.0):不传 expectedRev 时的「被动提醒」——
|
|
586
|
+
// 上次调用结束时我记着的 rev(meta.rev)与盘上现在的不一致,说明**在我两次动手之间别人写过**:
|
|
587
|
+
// 工坊界面点了保存、host 恢复、或另一个会话在动同一把槽。弱模型不会主动传 expectedRev,
|
|
588
|
+
// 但它至少该知道自己手上的前提过期了 —— 第四轮实测里 LLM 就是在这里完全无感知的。
|
|
589
|
+
// 自己不传 expectedRev 的写调用不算漂移(rev 由本次自己推进,后置会同步回 meta.rev)。
|
|
590
|
+
const driftFrom = (expected === null && meta.rev !== null && beforeRev !== null && beforeRev !== meta.rev) ? meta.rev : null;
|
|
591
|
+
if(expected !== null){
|
|
592
|
+
const actual = beforeRev;
|
|
593
|
+
if(actual !== null && actual !== expected){
|
|
594
|
+
const d = getDraft(ctx, exec);
|
|
595
|
+
const st = draftStatusFor(k, d);
|
|
596
|
+
return sanitizeJson({ ok:false, staleRev:true, expectedRev:expected, actualRev:actual, build: BUILD,
|
|
597
|
+
cardName: st.cardName, cardId: st.cardId, draftStatus: st,
|
|
598
|
+
error:"草稿已被外部修改(期望 rev=" + expected + ",实际 rev=" + actual + ")—— 你手上的前提过期了。请先 cc_get_card 确认当前卡片(当前卡名:「" + (st.cardName || "未命名") + "」)再决定要不要重试。" });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const res = await raw(args, exec);
|
|
602
|
+
try{
|
|
603
|
+
const d = getDraft(ctx, exec);
|
|
604
|
+
const st = draftStatusFor(k, d);
|
|
605
|
+
const afterRev = draftRevOnDisk(k);
|
|
606
|
+
if(afterRev !== null) meta.rev = afterRev; // 记下「本次调用结束时我见到的版本」,供下一次比对
|
|
607
|
+
if(res && typeof res === "object"){
|
|
608
|
+
if(!res.draftStatus) res.draftStatus = st;
|
|
609
|
+
res.draftStatus.watchedRev = watchedRevAtStart;
|
|
610
|
+
res.cardName = st.cardName;
|
|
611
|
+
res.cardId = st.cardId;
|
|
612
|
+
res.build = BUILD; // 版本标识:测试者能一眼确认「我测的是不是最新代码」(第五轮流程建议)
|
|
613
|
+
if(driftFrom !== null){
|
|
614
|
+
// 显式字段 + 一句话:报告里 S1 要的「不传 rev 也能看出过期」就是这两个东西。
|
|
615
|
+
res.revDrift = { from: driftFrom, to: beforeRev };
|
|
616
|
+
res.warnings = (Array.isArray(res.warnings) ? res.warnings : []).concat([externalChangeNotice(k, d, driftFrom, beforeRev)]);
|
|
617
|
+
}
|
|
618
|
+
// S2(0.7.0):新草稿提示只在这里出一次 —— `pendingFresh` 由 getDraft 在「内存与磁盘都
|
|
619
|
+
// 没有草稿、现建了一张空卡」时打上,cc_new_card 主动重置则清掉它(有意重置 ≠ 内容丢失)。
|
|
620
|
+
const freshReason = meta.pendingFresh;
|
|
621
|
+
if(freshReason){
|
|
622
|
+
meta.pendingFresh = null;
|
|
623
|
+
const note = freshNotice({ pendingFresh: freshReason });
|
|
624
|
+
if(note && !(Array.isArray(res.warnings) && res.warnings.indexOf(note) >= 0)){
|
|
625
|
+
res.warnings = (Array.isArray(res.warnings) ? res.warnings : []).concat([note]);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}catch(e){}
|
|
630
|
+
// B1 的第二道防线:无论各工具返回什么,出门前统一过一遍 JSON 往返,绝不把 `undefined`
|
|
631
|
+
// 留给 DSH 的 lossless-JSON 校验(一个 undefined 就会让整次工具调用报错)。
|
|
632
|
+
return sanitizeJson(res);
|
|
633
|
+
};
|
|
634
|
+
return ctx.tools.register(def);
|
|
635
|
+
};
|
|
636
|
+
tool({
|
|
278
637
|
name: "cc_get_card",
|
|
279
|
-
description: "【工作流第1步·必调·先问再填】查看当前 CC 角色卡草稿与工作流进度。CC 模式下必须先调此工具,然后用自然语言向用户总结当前进度(角色/五维/世界书/问候语各缺什么),并用 1-2
|
|
280
|
-
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
281
|
-
output: out({ type:"object", additionalProperties:true, properties:{ draft:{type:"object"},
|
|
638
|
+
description: "【工作流第1步·必调·先问再填】查看当前 CC 角色卡草稿与工作流进度。CC 模式下必须先调此工具,然后用自然语言向用户总结当前进度(角色/五维/世界书/问候语各缺什么),并用 1-2 个开放问题邀请用户表达偏好,再按步推进。严禁未与用户讨论就直接推断填满。卡较大时可用 fields 只取需要的字段,省上下文。",
|
|
639
|
+
parameters: { type:"object", properties:{ fields:{ type:"array", items:{ type:"string" }, description:"可选:只取 data 下的这些字段(如 [\"name\",\"tags\"]);省略则返回整份 draft" } }, additionalProperties:false },
|
|
640
|
+
output: out({ type:"object", additionalProperties:true, properties:{ draft:{type:"object"}, workflow:{type:"object"} } }),
|
|
282
641
|
async execute(args, exec){
|
|
283
642
|
const draft = getDraft(ctx, exec);
|
|
284
643
|
const k = keyFor(ctx, exec);
|
|
285
644
|
const st = draftStatusFor(k, draft);
|
|
286
645
|
const wf = workflowStatus(draft);
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
646
|
+
const warnings = [recoveredNotice(k, draft)].filter(Boolean); // 新草稿提示由 tool() 包装统一注入(S2,只出一份)
|
|
647
|
+
const fields = Array.isArray(args.fields) ? args.fields.map(String).filter(Boolean) : [];
|
|
648
|
+
let outDraft = draft;
|
|
649
|
+
if(fields.length){
|
|
650
|
+
const data = {};
|
|
651
|
+
for(const f of fields){ if(Object.prototype.hasOwnProperty.call(draft.data, f)) data[f] = draft.data[f]; }
|
|
652
|
+
outDraft = { spec: draft.spec, spec_version: draft.spec_version, data };
|
|
653
|
+
}
|
|
654
|
+
const res = { ok:true, draft: outDraft, draftStatus: st, workflow: wf,
|
|
655
|
+
// R2:message 不再与 hint 一字不差 —— 它是**给用户看**的一句复述(进度 + 下一步),
|
|
656
|
+
// 而 workflow.hint 只报进度、workflow.next 只报下一步(各司其职,不再互相复制)。
|
|
657
|
+
message: "当前进度:" + wf.hint + (wf.done ? "。" : "。下一步:" + wf.next) };
|
|
658
|
+
if(fields.length) res.fieldsReturned = Object.keys(outDraft.data);
|
|
659
|
+
if(warnings.length) res.warnings = warnings;
|
|
660
|
+
return res;
|
|
291
661
|
}
|
|
292
662
|
});
|
|
293
663
|
|
|
294
|
-
|
|
664
|
+
tool({
|
|
295
665
|
name: "cc_patch_character",
|
|
296
666
|
description: "【工作流第2步·共创】写入角色基础(name/nickname/description/personality/scenario/system_prompt/tags)。调用前必须已与用户讨论:至少问 1 个开放问题(如想要的气质/性别/年龄/与你的关系张力/禁忌),拿到用户偏好后再填;禁止仅凭世界观自行推断一次性填满。必须在 cc_get_card 之后、五维之前调用,之后下一步是 cc_patch_world。",
|
|
297
667
|
parameters: {
|
|
@@ -305,7 +675,12 @@ function apply(ctx){
|
|
|
305
675
|
system_prompt:{type:"string", description:"system_prompt"},
|
|
306
676
|
post_history_instructions:{type:"string", description:"post_history_instructions"},
|
|
307
677
|
tags:{type:"array", items:{type:"string"}, description:"标签数组,会整体覆盖"},
|
|
308
|
-
creator_notes:{type:"string", description:"creator_notes"}
|
|
678
|
+
creator_notes:{type:"string", description:"creator_notes"},
|
|
679
|
+
// N1(0.7.0):`data.creator` 此前**没有任何写入路径** —— cc_validate 每次都警告「creator 为空」,
|
|
680
|
+
// 但工具侧和工坊界面都改不了它,等于反复催一个没有解法的问题(第四轮实测:传进来被静默忽略)。
|
|
681
|
+
creator:{type:"string", description:"作者名(data.creator)。cc_validate 会警告它为空时导出后看不到作者,这里就是它的写入路径。"},
|
|
682
|
+
verbose:{type:"boolean", description:"可选:true 时额外回吐整份 draft;默认只回 changed 字段名列表(省上下文)"},
|
|
683
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev,避免基于过期草稿覆盖别人的修改"}
|
|
309
684
|
},
|
|
310
685
|
additionalProperties:false
|
|
311
686
|
},
|
|
@@ -313,26 +688,37 @@ function apply(ctx){
|
|
|
313
688
|
async execute(args, exec){
|
|
314
689
|
const k = keyFor(ctx, exec);
|
|
315
690
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
316
|
-
const wasFresh = draftMeta(k).isNew;
|
|
317
691
|
const recNote = recoveredNotice(k, cur);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if(args.
|
|
324
|
-
if(args.
|
|
325
|
-
if(
|
|
326
|
-
if(args.
|
|
692
|
+
// 合并式更新:只有显式传了的字段才动。**传空字符串 = 清空该字段**(P2-10 之前文档没写清),
|
|
693
|
+
// 不传 = 保持原值。
|
|
694
|
+
const changed = [];
|
|
695
|
+
// B2(0.7.1):`changed` 与 `rev` 用同一套判据 —— 只有值真的变了才记改动,否则会出现
|
|
696
|
+
// 「返回说改了 creator、rev 却纹丝不动」这种自相矛盾(第五轮实测 B2)。
|
|
697
|
+
if(args.name!==undefined) assignIfChanged(cur.data, "name", String(args.name).slice(0,120), changed);
|
|
698
|
+
if(args.nickname!==undefined) assignIfChanged(cur.data, "nickname", String(args.nickname).slice(0,120), changed);
|
|
699
|
+
if(args.description!==undefined) assignIfChanged(cur.data, "description", String(args.description).slice(0,5000), changed);
|
|
700
|
+
if(args.personality!==undefined) assignIfChanged(cur.data, "personality", String(args.personality).slice(0,5000), changed);
|
|
701
|
+
if(args.scenario!==undefined) assignIfChanged(cur.data, "scenario", String(args.scenario).slice(0,5000), changed);
|
|
702
|
+
if(args.system_prompt!==undefined) assignIfChanged(cur.data, "system_prompt", String(args.system_prompt).slice(0,3000), changed);
|
|
703
|
+
if(args.post_history_instructions!==undefined) assignIfChanged(cur.data, "post_history_instructions", String(args.post_history_instructions).slice(0,3000), changed);
|
|
704
|
+
if(Array.isArray(args.tags)) assignIfChanged(cur.data, "tags", args.tags.map(String).slice(0,12), changed);
|
|
705
|
+
if(args.creator_notes!==undefined) assignIfChanged(cur.data, "creator_notes", String(args.creator_notes).slice(0,3000), changed);
|
|
706
|
+
if(args.creator!==undefined) assignIfChanged(cur.data, "creator", String(args.creator).slice(0,120), changed);
|
|
327
707
|
setDraft(ctx, cur, exec);
|
|
328
708
|
const wf = workflowStatus(cur);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
709
|
+
const warnings = [];
|
|
710
|
+
if(recNote) warnings.push(recNote);
|
|
711
|
+
// P1-4:patch 类默认不再回吐整份 draft —— 长卡下每改一个字段都要模型重读全卡,纯烧上下文。
|
|
712
|
+
// 需要全量时显式传 verbose:true(cc_get_card 也能按 fields 取)。
|
|
713
|
+
const res = { ok:true, changed, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
714
|
+
message: "已写入胶囊(角色基础:" + (changed.length ? changed.join("/") : "无改动") + ")。" + wf.next };
|
|
715
|
+
if(args.verbose === true) res.draft = cur;
|
|
716
|
+
if(warnings.length) res.warnings = warnings;
|
|
717
|
+
return res;
|
|
332
718
|
}
|
|
333
719
|
});
|
|
334
720
|
|
|
335
|
-
|
|
721
|
+
tool({
|
|
336
722
|
name: "cc_patch_world",
|
|
337
723
|
description: "【工作流第3步·共创·必调】写入 5 维世界观(timeline/factions/geo/power/daily 至少 3 项)。若用户已提供世界观文档,先用 2-3 句归纳你理解的 5 维,再问用户“哪几维最想展开?有无私设要加?”;若世界观薄弱,则与用户讨论 1-2 个薄弱点后再填。严禁未与用户讨论就直接照搬文档填满 5 维。调后下一步是 cc_add_lorebook_entries。",
|
|
338
724
|
parameters: {
|
|
@@ -343,7 +729,14 @@ function apply(ctx){
|
|
|
343
729
|
geo:{type:"string", description:"地理"},
|
|
344
730
|
power:{type:"string", description:"力量体系"},
|
|
345
731
|
daily:{type:"string", description:"日常"},
|
|
346
|
-
autoLorebook:{type:"boolean", description:"是否基于 5 维自动生成 3-6 条带 @@decorator 的 lorebook 条目,强烈建议 true"}
|
|
732
|
+
autoLorebook:{type:"boolean", description:"是否基于 5 维自动生成 3-6 条带 @@decorator 的 lorebook 条目,强烈建议 true"},
|
|
733
|
+
loreKeys:{
|
|
734
|
+
type:"object",
|
|
735
|
+
description:"**强烈建议提供**:为每一维自报触发词(真实对话里用户会说的实体名),如 {\"geo\":[\"旧校舍\",\"广播室\",\"天台\"],\"factions\":[\"夜巡\",\"学生会\"]}。不给的话自动条目只能用维度名当 key —— 用户永远不会说「地理」,那几条等于死条目。",
|
|
736
|
+
additionalProperties:{ type:"array", items:{type:"string"} }
|
|
737
|
+
},
|
|
738
|
+
verbose:{type:"boolean", description:"可选:true 时额外回吐整份 draft;默认只回状态与条目摘要"},
|
|
739
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev,避免基于过期草稿覆盖别人的修改"}
|
|
347
740
|
},
|
|
348
741
|
additionalProperties:false
|
|
349
742
|
},
|
|
@@ -351,7 +744,6 @@ function apply(ctx){
|
|
|
351
744
|
async execute(args, exec){
|
|
352
745
|
const k = keyFor(ctx, exec);
|
|
353
746
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
354
|
-
const wasFresh = draftMeta(k).isNew;
|
|
355
747
|
const recNote = recoveredNotice(k, cur);
|
|
356
748
|
cur.data.extensions = cur.data.extensions || {};
|
|
357
749
|
const w = cur.data.extensions.cc_world || {};
|
|
@@ -362,36 +754,97 @@ function apply(ctx){
|
|
|
362
754
|
if(args.daily!==undefined) w.daily = String(args.daily).slice(0,1000);
|
|
363
755
|
cur.data.extensions.cc_world = w;
|
|
364
756
|
const shouldAuto = args.autoLorebook !== false; // 默认 true,防偷懒
|
|
757
|
+
const autoDropped = [];
|
|
758
|
+
const autoEdited = [];
|
|
365
759
|
if(shouldAuto){
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
760
|
+
// 移除先前由 autoLorebook 生成的旧条目(保留手动条目)。
|
|
761
|
+
// ⚠️ 0.5.0 修:旧数据没有 cc_auto 标记时**不能只按名字删** —— 用户手工建的、恰好叫「日常」的条目
|
|
762
|
+
// 会被静默吃掉(LLM 侧实测之外的源码复核发现)。现在额外要求内容确实是自动格式(@@ 开头或英文维度 key)。
|
|
763
|
+
// ⚠️ 0.6.0 补(R4):被删的条目里如果有**手工改过 keys** 的,要报出来 —— 第二轮实测中用户手工把 5 条
|
|
764
|
+
// keys 改成实体词,重新生成后全被覆盖回维度名,而返回只说「替换了 5 条」,用户毫无察觉。
|
|
369
765
|
cur.data.character_book.entries = cur.data.character_book.entries.filter(e => {
|
|
370
|
-
if(e
|
|
371
|
-
|
|
372
|
-
|
|
766
|
+
if(isAutoEntry(e) || looksLikeLegacyAuto(e)){
|
|
767
|
+
autoDropped.push(String(e.name||""));
|
|
768
|
+
const ref = (e.extensions && e.extensions.cc_world_ref) || null;
|
|
769
|
+
const keys = Array.isArray(e.keys) ? e.keys.map(String) : [];
|
|
770
|
+
const defaults = ref ? [DIM_NAMES[ref], DIM_EN[ref]] : null;
|
|
771
|
+
const edited = defaults
|
|
772
|
+
? (keys.length !== defaults.length || keys.some((x, i) => x !== defaults[i]))
|
|
773
|
+
: (keys.length > 2 || (keys.length === 1 && !AUTO_NAMES.has(keys[0])));
|
|
774
|
+
if(edited) autoEdited.push(String(e.name||""));
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
373
777
|
return true;
|
|
374
778
|
});
|
|
375
779
|
// 重新计算 insertion_order 保持连续(手动条目保留相对顺序)
|
|
376
780
|
cur.data.character_book.entries.forEach((e, i) => e.insertion_order = i);
|
|
377
|
-
|
|
378
|
-
|
|
781
|
+
// 触发词:模型自报的 loreKeys 优先,维度名固定作为最后一条兜底(至少能命中「年表」这种直球提问)
|
|
782
|
+
const given = (dim, fallback) => {
|
|
783
|
+
const raw = (args.loreKeys && Array.isArray(args.loreKeys[dim])) ? args.loreKeys[dim].map(String).map(s=>s.trim()).filter(Boolean) : [];
|
|
784
|
+
return raw.concat(fallback.filter(k => raw.indexOf(k) < 0)).slice(0, 12);
|
|
379
785
|
};
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
786
|
+
const push=(dim, content, priority, constant=false)=>{
|
|
787
|
+
const name = DIM_NAMES[dim];
|
|
788
|
+
cur.data.character_book.entries.push({
|
|
789
|
+
keys: given(dim, [name, DIM_EN[dim]]),
|
|
790
|
+
content, enabled:true, insertion_order: cur.data.character_book.entries.length,
|
|
791
|
+
case_sensitive:false, use_regex:false, constant, name, priority,
|
|
792
|
+
id: Date.now()+Math.floor(Math.random()*10000), cc_auto:true,
|
|
793
|
+
extensions:{ cc_auto:true, cc_world_ref: dim },
|
|
794
|
+
});
|
|
795
|
+
};
|
|
796
|
+
if(w.timeline) push("timeline", "@@position after_desc\n"+w.timeline, 10);
|
|
797
|
+
if(w.factions) push("factions", "@@depth 4\n"+w.factions, 9);
|
|
798
|
+
if(w.geo) push("geo", "@@position scenario\n"+w.geo, 8);
|
|
799
|
+
if(w.power) push("power", "@@activate\n"+w.power, 9, true);
|
|
800
|
+
if(w.daily) push("daily", "@@depth 5\n"+w.daily, 7);
|
|
385
801
|
}
|
|
386
802
|
setDraft(ctx, cur, exec);
|
|
387
803
|
const wf = workflowStatus(cur);
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
804
|
+
const autoEntries = cur.data.character_book.entries.filter(isAutoEntry);
|
|
805
|
+
const warnings = [];
|
|
806
|
+
if(recNote) warnings.push(recNote);
|
|
807
|
+
const gaveKeys = !!(args.loreKeys && Object.keys(args.loreKeys).length);
|
|
808
|
+
if(autoEntries.length && !gaveKeys){
|
|
809
|
+
warnings.push("自动生成的 " + autoEntries.length + " 条世界书条目只用了维度名当触发词(年表/timeline 这类),真实对话几乎不会命中。建议用 cc_update_lorebook_entry 改成用户会说的实体名(如「旧校舍」「广播室」),或下次调用 cc_patch_world 时带上 loreKeys。");
|
|
810
|
+
}
|
|
811
|
+
// N2 附带(0.7.0):同一个实体被分给多个维度时**当场说破** —— 第四轮实测里「剑冢」同时落在
|
|
812
|
+
// factions / geo / power,一句「剑冢到底是什么地方」就触发了 4 条、1139 token(预算 1200)。
|
|
813
|
+
// 此前只有写完之后跑 cc_lint_lorebook 才会报「触发词被多条条目共用」,反馈太晚。
|
|
814
|
+
const sharedLoreKeys = (() => {
|
|
815
|
+
if(!args.loreKeys || typeof args.loreKeys !== "object") return [];
|
|
816
|
+
const seen = new Map();
|
|
817
|
+
Object.keys(args.loreKeys).forEach(dim => {
|
|
818
|
+
(Array.isArray(args.loreKeys[dim]) ? args.loreKeys[dim] : []).forEach(raw => {
|
|
819
|
+
const key = String(raw).trim();
|
|
820
|
+
if(!key) return;
|
|
821
|
+
if(!seen.has(key)) seen.set(key, []);
|
|
822
|
+
if(seen.get(key).indexOf(dim) < 0) seen.get(key).push(dim);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
return [...seen.entries()].filter(pair => pair[1].length > 1).map(pair => pair[0] + "(" + pair[1].join("/") + ")");
|
|
826
|
+
})();
|
|
827
|
+
if(shouldAuto && sharedLoreKeys.length){
|
|
828
|
+
warnings.push("这些触发词被同时分给了多个维度:" + sharedLoreKeys.join("、") + " —— 用户一句话提到它们会**同时触发多条**,token 消耗成倍叠加。建议一个实体只留给最贴切的那个维度。");
|
|
829
|
+
}
|
|
830
|
+
// N2(0.7.0):自动条目的 content 是五维**原文照搬**,维度写得越细,单次命中越贵。
|
|
831
|
+
const heavyAuto = autoEntries.filter(e => estimateTokens(e.content) > 400);
|
|
832
|
+
if(heavyAuto.length){
|
|
833
|
+
warnings.push("有 " + heavyAuto.length + " 条自动条目的内容超过 400 token(" + heavyAuto.map(e => String(e.name||"")).join("、") + "):auto 条目的 content 是五维原文照搬,写多细就多重,每次命中都会吃这么多上下文。可用 cc_update_lorebook_entry 精炼成摘要,或把对应维度缩短。");
|
|
834
|
+
}
|
|
835
|
+
const res = { ok:true, worldFilled: wf.worldFilled, loreCount: wf.loreCount,
|
|
836
|
+
autoLoreAdded: autoEntries.length, autoLoreReplaced: autoDropped.length, autoLoreEdited: autoEdited.length,
|
|
837
|
+
sharedLoreKeys, heavyAutoEntries: heavyAuto.length,
|
|
838
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
839
|
+
message: "已写入五维(" + wf.worldFilled + "/5),自动生成 " + autoEntries.length + " 条基础条目。" + wf.next };
|
|
840
|
+
if(autoEdited.length) warnings.push("被替换的 " + autoEdited.length + " 条旧自动条目(" + autoEdited.join("、") + ")的 keys 曾被手工改过,这次已重建回默认维度名,手工触发词会丢。想保住它们:先用 cc_update_lorebook_entry 写回,或以后调本工具时带上 loreKeys。");
|
|
841
|
+
if(args.verbose === true) res.draft = cur;
|
|
842
|
+
if(warnings.length) res.warnings = warnings;
|
|
843
|
+
return res;
|
|
391
844
|
}
|
|
392
845
|
});
|
|
393
846
|
|
|
394
|
-
|
|
847
|
+
tool({
|
|
395
848
|
name: "cc_add_lorebook_entries",
|
|
396
849
|
description: "【工作流第4步·共创·必调】添加 1-5 条 Lorebook,补到至少 5 条(年表/势力/地理/力量/日常各 1,至少 1 条 constant,@@decorator)。调用前应与用户讨论:问用户想要哪些触发词/常驻条目/要藏什么彩蛋,拿到偏好后再生成。必须在五维之后、问候语之前调用,禁止一次性无讨论批量生成。",
|
|
397
850
|
parameters: {
|
|
@@ -412,7 +865,8 @@ function apply(ctx){
|
|
|
412
865
|
required:["content"],
|
|
413
866
|
additionalProperties:false
|
|
414
867
|
}
|
|
415
|
-
}
|
|
868
|
+
},
|
|
869
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
416
870
|
},
|
|
417
871
|
required:["entries"],
|
|
418
872
|
additionalProperties:false
|
|
@@ -421,7 +875,6 @@ function apply(ctx){
|
|
|
421
875
|
async execute(args, exec){
|
|
422
876
|
const k = keyFor(ctx, exec);
|
|
423
877
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
424
|
-
const wasFresh = draftMeta(k).isNew;
|
|
425
878
|
const recNote = recoveredNotice(k, cur);
|
|
426
879
|
args.entries.forEach(function(e){
|
|
427
880
|
cur.data.character_book.entries.push({
|
|
@@ -439,20 +892,25 @@ function apply(ctx){
|
|
|
439
892
|
});
|
|
440
893
|
setDraft(ctx, cur, exec);
|
|
441
894
|
const wf = workflowStatus(cur);
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
895
|
+
const warnings = [];
|
|
896
|
+
if(recNote) warnings.push(recNote);
|
|
897
|
+
const res = { ok:true, added: args.entries.length, loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount,
|
|
898
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
899
|
+
message: "已新增 " + args.entries.length + " 条世界书条目(共 " + wf.loreCount + " 条,其中共创 " + wf.manualLoreCount + " 条)。" + wf.next };
|
|
900
|
+
if(warnings.length) res.warnings = warnings;
|
|
901
|
+
return res;
|
|
445
902
|
}
|
|
446
903
|
});
|
|
447
904
|
|
|
448
|
-
|
|
905
|
+
tool({
|
|
449
906
|
name: "cc_delete_lorebook_entries",
|
|
450
907
|
description: "【Lorebook 管理】按 id 或 name 删除条目(用于清理过时条目,autoLorebook 已自动覆盖旧自动条目,手动条目需显式删除)。至少提供 ids 或 names 之一。",
|
|
451
908
|
parameters: {
|
|
452
909
|
type:"object",
|
|
453
910
|
properties:{
|
|
454
911
|
ids:{type:"array", items:{type:"number"}, description:"要删除的条目 id 列表"},
|
|
455
|
-
names:{type:"array", items:{type:"string"}, description:"要删除的条目 name 列表(精确匹配)"}
|
|
912
|
+
names:{type:"array", items:{type:"string"}, description:"要删除的条目 name 列表(精确匹配)"},
|
|
913
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
456
914
|
},
|
|
457
915
|
additionalProperties:false
|
|
458
916
|
},
|
|
@@ -462,7 +920,6 @@ function apply(ctx){
|
|
|
462
920
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
463
921
|
const before = draftStatusFor(k, cur);
|
|
464
922
|
const beforeRec = recoveredNotice(k, cur);
|
|
465
|
-
const beforeFresh = before.isNew;
|
|
466
923
|
const beforeCount = cur.data.character_book.entries.length;
|
|
467
924
|
const idSet = new Set(Array.isArray(args.ids)? args.ids.map(Number): []);
|
|
468
925
|
const nameSet = new Set(Array.isArray(args.names)? args.names.map(String): []);
|
|
@@ -472,13 +929,18 @@ function apply(ctx){
|
|
|
472
929
|
cur.data.character_book.entries.forEach((e,i)=> e.insertion_order=i);
|
|
473
930
|
setDraft(ctx, cur, exec);
|
|
474
931
|
const wf = workflowStatus(cur);
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
932
|
+
const deleted = beforeCount - wf.loreCount;
|
|
933
|
+
const warnings = [];
|
|
934
|
+
if(beforeRec) warnings.push(beforeRec);
|
|
935
|
+
const res = { ok:true, deleted, loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount,
|
|
936
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
937
|
+
message: "已删除 " + deleted + " 条世界书条目(剩 " + wf.loreCount + " 条,其中共创 " + wf.manualLoreCount + " 条)。" + wf.next };
|
|
938
|
+
if(warnings.length) res.warnings = warnings;
|
|
939
|
+
return res;
|
|
478
940
|
}
|
|
479
941
|
});
|
|
480
942
|
|
|
481
|
-
|
|
943
|
+
tool({
|
|
482
944
|
name: "cc_update_lorebook_entry",
|
|
483
945
|
description: "【Lorebook 管理】按 id 更新单条条目(content/keys/name 等),用于修正过时内容而非整批重建。",
|
|
484
946
|
parameters: {
|
|
@@ -491,7 +953,8 @@ function apply(ctx){
|
|
|
491
953
|
enabled:{type:"boolean"},
|
|
492
954
|
constant:{type:"boolean"},
|
|
493
955
|
use_regex:{type:"boolean"},
|
|
494
|
-
priority:{type:"number"}
|
|
956
|
+
priority:{type:"number"},
|
|
957
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
495
958
|
},
|
|
496
959
|
required:["id"],
|
|
497
960
|
additionalProperties:false
|
|
@@ -500,26 +963,30 @@ function apply(ctx){
|
|
|
500
963
|
async execute(args, exec){
|
|
501
964
|
const k = keyFor(ctx, exec);
|
|
502
965
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
503
|
-
const wasFresh = draftMeta(k).isNew;
|
|
504
966
|
const recNote = recoveredNotice(k, cur);
|
|
505
967
|
const e = cur.data.character_book.entries.find(x=> x.id===args.id);
|
|
506
968
|
if(!e) return { ok:false, error:"未找到 id="+args.id };
|
|
507
|
-
|
|
508
|
-
if(
|
|
509
|
-
if(args.
|
|
510
|
-
if(args.
|
|
511
|
-
if(args.
|
|
512
|
-
if(args.
|
|
513
|
-
if(
|
|
969
|
+
const changed = [];
|
|
970
|
+
if(args.name!==undefined) assignIfChanged(e, "name", String(args.name).slice(0,60), changed);
|
|
971
|
+
if(Array.isArray(args.keys)) assignIfChanged(e, "keys", args.keys.map(String), changed);
|
|
972
|
+
if(args.content!==undefined) assignIfChanged(e, "content", String(args.content).slice(0,5000), changed);
|
|
973
|
+
if(args.enabled!==undefined) assignIfChanged(e, "enabled", !!args.enabled, changed);
|
|
974
|
+
if(args.constant!==undefined) assignIfChanged(e, "constant", !!args.constant, changed);
|
|
975
|
+
if(args.use_regex!==undefined) assignIfChanged(e, "use_regex", !!args.use_regex, changed);
|
|
976
|
+
if(typeof args.priority==="number") assignIfChanged(e, "priority", args.priority, changed);
|
|
514
977
|
setDraft(ctx, cur, exec);
|
|
515
978
|
const wf = workflowStatus(cur);
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
979
|
+
const warnings = [];
|
|
980
|
+
if(recNote) warnings.push(recNote);
|
|
981
|
+
const res = { ok:true, entry:e, changed, loreCount: wf.loreCount,
|
|
982
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
983
|
+
message: "已更新条目「" + String(e.name||"") + "」(" + changed.join("/") + ")。" + wf.next };
|
|
984
|
+
if(warnings.length) res.warnings = warnings;
|
|
985
|
+
return res;
|
|
519
986
|
}
|
|
520
987
|
});
|
|
521
988
|
|
|
522
|
-
|
|
989
|
+
tool({
|
|
523
990
|
name: "cc_patch_greetings",
|
|
524
991
|
description: "【工作流第5步·共创】写入开场与示例(first_mes / alternate_greetings≥2 / group_only_greetings≥1 / mes_example)。调用前必须问用户偏好的开场场景/关系阶段/群聊与私聊区分,拿到偏好后再写;禁止未与用户讨论就自行编造全部问候语。必须在角色+五维+世界书(≥5)后调用。",
|
|
525
992
|
parameters: {
|
|
@@ -528,7 +995,9 @@ function apply(ctx){
|
|
|
528
995
|
first_mes:{type:"string", description:"首条开场白"},
|
|
529
996
|
alternate_greetings:{type:"array", items:{type:"string"}, description:"备选开场白数组,至少 2 条"},
|
|
530
997
|
group_only_greetings:{type:"array", items:{type:"string"}, description:"群聊限定开场白(必填至少1条)"},
|
|
531
|
-
mes_example:{type:"string", description:"对话示例(可用 {{user}}/{{char}})"}
|
|
998
|
+
mes_example:{type:"string", description:"对话示例(可用 {{user}}/{{char}})"},
|
|
999
|
+
verbose:{type:"boolean", description:"可选:true 时额外回吐整份 draft;默认只回问候语计数与状态"},
|
|
1000
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev,避免基于过期草稿覆盖别人的修改"}
|
|
532
1001
|
},
|
|
533
1002
|
additionalProperties:false
|
|
534
1003
|
},
|
|
@@ -536,7 +1005,6 @@ function apply(ctx){
|
|
|
536
1005
|
async execute(args, exec){
|
|
537
1006
|
const k = keyFor(ctx, exec);
|
|
538
1007
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
539
|
-
const wasFresh = draftMeta(k).isNew;
|
|
540
1008
|
const recNote = recoveredNotice(k, cur);
|
|
541
1009
|
if(args.first_mes!==undefined) cur.data.first_mes = String(args.first_mes).slice(0,5000);
|
|
542
1010
|
if(Array.isArray(args.alternate_greetings)) cur.data.alternate_greetings = args.alternate_greetings.map(String).slice(0,10);
|
|
@@ -545,17 +1013,23 @@ function apply(ctx){
|
|
|
545
1013
|
setDraft(ctx, cur, exec);
|
|
546
1014
|
const wf = workflowStatus(cur);
|
|
547
1015
|
// 若世界书/五维未齐,警告但仍写入,让 validate 卡住
|
|
548
|
-
|
|
549
|
-
if(
|
|
550
|
-
if(
|
|
551
|
-
if(
|
|
552
|
-
const
|
|
553
|
-
|
|
1016
|
+
const warnings = [];
|
|
1017
|
+
if(recNote) warnings.push(recNote);
|
|
1018
|
+
if(wf.state.world !== "ok") warnings.push("五维仅 " + wf.worldFilled + "/5 项(未达 3 项),validate 会失败,请先补 cc_patch_world。");
|
|
1019
|
+
if(wf.state.lore !== "ok") warnings.push("世界书未达「≥5 条且至少 1 条共创」(当前 " + wf.loreCount + " 条,其中共创 " + wf.manualLoreCount + " 条),validate 会失败,请先补 cc_add_lorebook_entries。");
|
|
1020
|
+
const warnText = warnings.length ? "(" + warnings.join(" ") + ")" : "";
|
|
1021
|
+
const res = { ok:true,
|
|
1022
|
+
greetings: { first_mes: !!String(cur.data.first_mes||"").trim(), alternate: (cur.data.alternate_greetings||[]).length, group: (cur.data.group_only_greetings||[]).length },
|
|
1023
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1024
|
+
message: "已写入问候语" + warnText + "。" + wf.next };
|
|
1025
|
+
if(args.verbose === true) res.draft = cur;
|
|
1026
|
+
if(warnings.length) res.warnings = warnings;
|
|
1027
|
+
return res;
|
|
554
1028
|
}
|
|
555
1029
|
});
|
|
556
1030
|
|
|
557
1031
|
// —— 已存角色库 CRUD(ID 化,模型可直接管理,与工坊侧栏共享 ~/.dsh/cc-library) ——
|
|
558
|
-
|
|
1032
|
+
tool({
|
|
559
1033
|
name: "cc_list_library",
|
|
560
1034
|
description: "【库·查】列出已存角色库所有条目(id/name/loreCount/tags/updatedAt),用于让用户挑选载入或让模型决定更新哪一张。",
|
|
561
1035
|
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
@@ -565,64 +1039,290 @@ function apply(ctx){
|
|
|
565
1039
|
return { ok:true, entries, count:entries.length };
|
|
566
1040
|
}
|
|
567
1041
|
});
|
|
568
|
-
|
|
1042
|
+
tool({
|
|
569
1043
|
name: "cc_save_to_library",
|
|
570
|
-
description: "
|
|
571
|
-
parameters: { type:"object", properties:{ id:{type:"string", description:"要覆盖的已存 ID
|
|
1044
|
+
description: "【库·增/改】把当前会话草稿存入已存库(ID 化 CRUD)。**不传 id 时优先沿用草稿的来源 id**(最近一次 cc_load_from_library 载入的那张),所以「载入 → 修改 → 保存」是更新原卡,不会再多出一张同名重复卡;只有从未载入过(或已 cc_new_card 重置)时才新建。传 id 则按该 ID 原地覆盖。返回值 action 说明本次是 created 还是 updated。传 name 会同时写入 meta.name 与 draft.data.name(避免列表名与卡内名两套)。",
|
|
1045
|
+
parameters: { type:"object", properties:{ id:{type:"string", description:"要覆盖的已存 ID(不传则沿用草稿来源 id;没有来源才新建)"}, name:{type:"string", description:"新名称(可选;同时写入 meta.name 与 draft.data.name)"} }, additionalProperties:false },
|
|
572
1046
|
output: out({ type:"object", additionalProperties:true }),
|
|
573
1047
|
async execute(args, exec){
|
|
1048
|
+
const k = keyFor(ctx, exec);
|
|
574
1049
|
const draft=JSON.parse(JSON.stringify(getDraft(ctx,exec)));
|
|
575
1050
|
// 至少需要 name
|
|
576
1051
|
if(!draft.data.name || !String(draft.data.name).trim()) return { ok:false, error:"当前草稿缺少 data.name,请先 cc_patch_character 补齐" };
|
|
577
|
-
const
|
|
578
|
-
|
|
1052
|
+
const explicit = args.id ? String(args.id) : "";
|
|
1053
|
+
const origin = explicit ? null : readOriginSync(k);
|
|
1054
|
+
const targetId = explicit || (origin ? origin.libraryId : "");
|
|
1055
|
+
const res=await saveLibraryEntry(draft, { id: targetId || undefined, name: args.name });
|
|
1056
|
+
// 记来源:下次不传 id 时沿用同一张(cc_new_card / 工坊清空草稿会清掉它)
|
|
1057
|
+
writeOriginSync(k, res.id, res.meta.name);
|
|
1058
|
+
return { ok:true, action: targetId ? "updated" : "created", id:res.id, meta:res.meta, loreCount:res.meta.loreCount,
|
|
1059
|
+
message: (targetId ? "已更新已存卡「" : "已新建已存卡「") + res.meta.name + "」(id " + res.id + ")。" + (targetId ? "" : "下次保存(不传 id)会更新这一张。") };
|
|
579
1060
|
}
|
|
580
1061
|
});
|
|
581
|
-
|
|
1062
|
+
tool({
|
|
582
1063
|
name: "cc_load_from_library",
|
|
583
|
-
description: "【库·载入】按 ID
|
|
584
|
-
parameters: { type:"object", properties:{ id:{type:"string", description:"已存条目 ID"} }, required:["id"], additionalProperties:false },
|
|
1064
|
+
description: "【库·载入】按 ID 把已存卡载入当前会话草稿(**会覆盖当前 draft**)。载入后会记住来源 id:之后 cc_save_to_library 不传 id 也按这张更新,不会再新建重复卡。",
|
|
1065
|
+
parameters: { type:"object", properties:{ id:{type:"string", description:"已存条目 ID"}, includeDraft:{type:"boolean", description:"默认 true(载入后通常要基于内容继续工作);false 时只回 meta 与状态,省上下文"} }, required:["id"], additionalProperties:false },
|
|
585
1066
|
output: out({ type:"object", additionalProperties:true }),
|
|
586
1067
|
async execute(args, exec){
|
|
587
1068
|
const obj=await loadLibraryEntry(args.id);
|
|
588
1069
|
const cur=JSON.parse(JSON.stringify(obj.draft));
|
|
589
1070
|
setDraft(ctx,cur,exec);
|
|
590
1071
|
const k = keyFor(ctx, exec);
|
|
591
|
-
|
|
1072
|
+
writeOriginSync(k, obj.id, obj.meta.name);
|
|
1073
|
+
const wf = workflowStatus(cur);
|
|
1074
|
+
const res = { ok:true, id:obj.id, meta:obj.meta, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1075
|
+
loreCount: wf.loreCount,
|
|
1076
|
+
message: "已载入「" + obj.meta.name + "」(id " + obj.id + ",世界书 " + wf.loreCount + " 条)。之后保存(不传 id)会更新这一张。" };
|
|
1077
|
+
if(args.includeDraft !== false) res.draft = cur;
|
|
1078
|
+
return res;
|
|
592
1079
|
}
|
|
593
1080
|
});
|
|
594
|
-
|
|
1081
|
+
tool({
|
|
595
1082
|
name: "cc_delete_from_library",
|
|
596
|
-
description: "【库·删】按 ID
|
|
1083
|
+
description: "【库·删】按 ID 删除已存条目,模型可在用户确认后调用。返回值带 remaining(删完后库里还剩几条)。",
|
|
597
1084
|
parameters: { type:"object", properties:{ id:{type:"string", description:"要删除的 ID"} }, required:["id"], additionalProperties:false },
|
|
598
1085
|
output: out({ type:"object", additionalProperties:true }),
|
|
599
1086
|
async execute(args, exec){
|
|
1087
|
+
const k = keyFor(ctx, exec);
|
|
1088
|
+
const origin = readOriginSync(k);
|
|
1089
|
+
const wasOrigin = !!(origin && origin.libraryId === String(args.id));
|
|
600
1090
|
await deleteLibraryEntry(args.id);
|
|
601
|
-
|
|
1091
|
+
// 删掉的正好是当前草稿的来源卡 → 清掉来源,否则下次保存会「更新」一张已不存在的卡
|
|
1092
|
+
if(wasOrigin) writeOriginSync(k, null);
|
|
1093
|
+
const entries = await listLibraryEntries();
|
|
1094
|
+
return { ok:true, id:args.id, remaining: entries.length, originCleared: wasOrigin,
|
|
1095
|
+
message: "已删除(id " + args.id + "),库里还剩 " + entries.length + " 条。" + (wasOrigin ? "当前草稿的来源关联也一并解除。" : "") };
|
|
602
1096
|
}
|
|
603
1097
|
});
|
|
604
|
-
|
|
1098
|
+
tool({
|
|
605
1099
|
name: "cc_rename_in_library",
|
|
606
|
-
description: "【库·改名】按 ID
|
|
607
|
-
parameters: { type:"object", properties:{ id:{type:"string"}, name:{type:"string", description:"
|
|
1100
|
+
description: "【库·改名】按 ID 重命名已存条目。**同时写入 meta.name 与 draft.data.name**(单一事实来源)—— 此前只改 meta,于是侧边栏显示新名、导出的卡里还是旧名,用户反复改名却不明白为什么不生效。",
|
|
1101
|
+
parameters: { type:"object", properties:{ id:{type:"string"}, name:{type:"string", description:"新名称(列表名与卡内 data.name 一起改)"} }, required:["id","name"], additionalProperties:false },
|
|
608
1102
|
output: out({ type:"object", additionalProperties:true }),
|
|
609
1103
|
async execute(args, exec){
|
|
1104
|
+
const k = keyFor(ctx, exec);
|
|
610
1105
|
const meta=await renameLibraryEntry(args.id,args.name);
|
|
611
|
-
|
|
1106
|
+
const origin = readOriginSync(k);
|
|
1107
|
+
if(origin && origin.libraryId === String(args.id)) writeOriginSync(k, String(args.id), meta.name);
|
|
1108
|
+
return { ok:true, meta, syncedDraftName: true,
|
|
1109
|
+
message: "已改名为「" + meta.name + "」(meta.name 与 draft.data.name 同步更新,导出后就是这个名字)。" };
|
|
612
1110
|
}
|
|
613
1111
|
});
|
|
614
|
-
|
|
1112
|
+
tool({
|
|
615
1113
|
name: "cc_get_library_entry",
|
|
616
|
-
description: "【库·查单条】按 ID 获取单条完整草稿与 meta
|
|
617
|
-
parameters: { type:"object", properties:{ id:{type:"string"} }, required:["id"], additionalProperties:false },
|
|
1114
|
+
description: "【库·查单条】按 ID 获取单条完整草稿与 meta,用于模型在重命名/更新前确认内容。只想确认 meta 或某个字段时传 includeDraft:false,省上下文。",
|
|
1115
|
+
parameters: { type:"object", properties:{ id:{type:"string"}, includeDraft:{type:"boolean", description:"默认 true;只想确认 meta/字段时传 false"} }, required:["id"], additionalProperties:false },
|
|
618
1116
|
output: out({ type:"object", additionalProperties:true }),
|
|
619
1117
|
async execute(args, exec){
|
|
620
1118
|
const obj=await loadLibraryEntry(args.id);
|
|
621
|
-
|
|
1119
|
+
const res = { ok:true, id:obj.id, meta:obj.meta };
|
|
1120
|
+
if(args.includeDraft !== false) res.draft = obj.draft;
|
|
1121
|
+
return res;
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// ——————————————————————————————————————————————————————————————
|
|
1126
|
+
// 0.5.0 新增工具:都来自 LLM 侧实测反馈的「无工具可调」清单
|
|
1127
|
+
// ——————————————————————————————————————————————————————————————
|
|
1128
|
+
|
|
1129
|
+
// 【重置】空白起点。破坏性 → 必须显式 confirm,杜绝模型顺手清空用户的卡。
|
|
1130
|
+
tool({
|
|
1131
|
+
name: "cc_new_card",
|
|
1132
|
+
description: "【卡·重置】把当前会话草稿换成一张全新的空白卡(清空所有字段与世界书,并解除与已存卡的来源关联)。这是**破坏性操作**:必须先向用户确认(说明现状 + 影响),拿到明确同意后再带 confirm:true 调用;不带 confirm 时本工具只返回提示、不改任何数据。若用户只是想换一张已存卡继续,用 cc_list_library + cc_load_from_library。",
|
|
1133
|
+
parameters: { type:"object", properties:{ confirm:{type:"boolean", description:"必须在用户明确同意重置后传 true"} }, additionalProperties:false },
|
|
1134
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1135
|
+
async execute(args, exec){
|
|
1136
|
+
const k = keyFor(ctx, exec);
|
|
1137
|
+
if(args.confirm !== true){
|
|
1138
|
+
const cur = getDraft(ctx, exec);
|
|
1139
|
+
const wf = workflowStatus(cur);
|
|
1140
|
+
return { ok:false, needsConfirm:true, workflow: wf,
|
|
1141
|
+
message:"重置会清空当前草稿的全部内容(角色/五维/世界书/问候语)且无法撤销。当前草稿:" + (String(cur.data.name||"").trim() || "(未命名)") + ",世界书 " + wf.loreCount + " 条。请先向用户说明并征求同意;同意后再以 confirm:true 重新调用。" };
|
|
1142
|
+
}
|
|
1143
|
+
const fresh = makeEmptyCard();
|
|
1144
|
+
setDraft(ctx, fresh, exec);
|
|
1145
|
+
writeOriginSync(k, null);
|
|
1146
|
+
// P0-6 附带 bug 修:setDraft 会把 isNew 置 false,但重置出来的确实是一张全新草稿 ——
|
|
1147
|
+
// 第二轮实测里 `isNew` 两轮从未出现过 true,正是漏了这一处(它本该是「这张卡还没跟已存卡建立关联」的信号)。
|
|
1148
|
+
draftMeta(k).isNew = true;
|
|
1149
|
+
// S2(0.7.0):成因是「有意重置」,不是「会话里突然没有草稿了」—— 清掉待告知标记,
|
|
1150
|
+
// 之后写入不再报「此前有未保存的内容可能已丢失」(第四轮实测:刚重置完就被吓一句,纯误报)。
|
|
1151
|
+
draftMeta(k).pendingFresh = null;
|
|
1152
|
+
const wf = workflowStatus(fresh);
|
|
1153
|
+
return { ok:true, reset:true, workflow: wf,
|
|
1154
|
+
message:"已重置为空白草稿(并解除与已存卡的来源关联,之后保存会新建一张)。" + wf.next };
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// 【世界书元信息】此前 name/description/scan_depth/token_budget/recursive_scanning 一个都改不了
|
|
1159
|
+
tool({
|
|
1160
|
+
name: "cc_patch_book_meta",
|
|
1161
|
+
description: "【世界书·元信息】改 character_book 的顶层参数:name / description / scan_depth / token_budget / recursive_scanning。这些字段此前没有任何工具能改,导致导出的世界书永远是无名书、预算也调不了。合并式更新,只改传入的字段。",
|
|
1162
|
+
parameters: {
|
|
1163
|
+
type:"object",
|
|
1164
|
+
properties:{
|
|
1165
|
+
name:{type:"string", description:"世界书名称(character_book.name)"},
|
|
1166
|
+
description:{type:"string", description:"世界书说明(character_book.description)"},
|
|
1167
|
+
scan_depth:{type:"number", description:"扫描深度:在最近 N 条消息里找触发词,常用 2-8"},
|
|
1168
|
+
token_budget:{type:"number", description:"世界书 token 预算,超出会被前端截断"},
|
|
1169
|
+
recursive_scanning:{type:"boolean", description:"是否递归扫描(被触发条目里的内容也能再触发其他条目)"},
|
|
1170
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
1171
|
+
},
|
|
1172
|
+
additionalProperties:false
|
|
1173
|
+
},
|
|
1174
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1175
|
+
async execute(args, exec){
|
|
1176
|
+
const k = keyFor(ctx, exec);
|
|
1177
|
+
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
1178
|
+
cur.data.character_book = cur.data.character_book || {};
|
|
1179
|
+
const book = cur.data.character_book;
|
|
1180
|
+
const changed = [];
|
|
1181
|
+
if(args.name!==undefined) assignIfChanged(book, "name", String(args.name).slice(0,120), changed);
|
|
1182
|
+
if(args.description!==undefined) assignIfChanged(book, "description", String(args.description).slice(0,500), changed);
|
|
1183
|
+
if(typeof args.scan_depth==="number") assignIfChanged(book, "scan_depth", Math.max(1, Math.min(50, Math.round(args.scan_depth))), changed);
|
|
1184
|
+
if(typeof args.token_budget==="number") assignIfChanged(book, "token_budget", Math.max(0, Math.round(args.token_budget)), changed);
|
|
1185
|
+
if(args.recursive_scanning!==undefined) assignIfChanged(book, "recursive_scanning", !!args.recursive_scanning, changed);
|
|
1186
|
+
setDraft(ctx, cur, exec);
|
|
1187
|
+
const wf = workflowStatus(cur);
|
|
1188
|
+
const used = lorebookTokens(book.entries);
|
|
1189
|
+
const res = { ok:true, changed, book: { name:book.name, description:book.description, scan_depth:book.scan_depth, token_budget:book.token_budget, recursive_scanning:book.recursive_scanning },
|
|
1190
|
+
lorebookTokens: used, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1191
|
+
message: "已更新世界书元信息(" + (changed.length ? changed.join("/") : "无改动") + ")。当前条目内容估算 " + used + " token / 预算 " + book.token_budget + "。" + wf.next };
|
|
1192
|
+
return res;
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// 【触发模拟】给一句话,看哪些条目会被触发 —— 写入时就能发现死条目,不用等用户聊两句
|
|
1197
|
+
tool({
|
|
1198
|
+
name: "cc_simulate_trigger",
|
|
1199
|
+
description: "【世界书·验】给一句用户真的会说的话,返回哪些条目会被触发(constant 条目永远算命中)+ 估算 token。用来在写入时就发现「keys 是维度名、真实对话永远不会命中」这类死条目。用 text 传一句,或用 texts 传多句批量验。",
|
|
1200
|
+
parameters: {
|
|
1201
|
+
type:"object",
|
|
1202
|
+
properties:{
|
|
1203
|
+
text:{type:"string", description:"一句测试文本,如「我们去旧校舍看看吧」"},
|
|
1204
|
+
texts:{type:"array", items:{type:"string"}, description:"多句批量测试(可与 text 并用)"}
|
|
1205
|
+
},
|
|
1206
|
+
additionalProperties:false
|
|
1207
|
+
},
|
|
1208
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1209
|
+
async execute(args, exec){
|
|
1210
|
+
const cur = getDraft(ctx, exec);
|
|
1211
|
+
const book = cur.data.character_book || {};
|
|
1212
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1213
|
+
const texts = [];
|
|
1214
|
+
if(typeof args.text === "string" && args.text.trim()) texts.push(args.text);
|
|
1215
|
+
if(Array.isArray(args.texts)) args.texts.forEach(x => { if(typeof x === "string" && x.trim()) texts.push(x); });
|
|
1216
|
+
if(!texts.length) return { ok:false, error:"请用 text 或 texts 提供至少一句测试文本" };
|
|
1217
|
+
const hitsOf = (line) => {
|
|
1218
|
+
const lower = line.toLowerCase();
|
|
1219
|
+
return entries.filter(e => {
|
|
1220
|
+
if(!e || e.enabled === false) return false;
|
|
1221
|
+
if(e.constant) return true; // 常驻条目永远命中
|
|
1222
|
+
const keys = Array.isArray(e.keys) ? e.keys : [];
|
|
1223
|
+
return keys.some(k => {
|
|
1224
|
+
const key = String(k);
|
|
1225
|
+
if(!key) return false;
|
|
1226
|
+
if(e.use_regex){ try{ return new RegExp(key, "i").test(line); }catch{ return false; } }
|
|
1227
|
+
return lower.indexOf(key.toLowerCase()) >= 0;
|
|
1228
|
+
});
|
|
1229
|
+
});
|
|
1230
|
+
};
|
|
1231
|
+
const results = texts.map(line => {
|
|
1232
|
+
const hits = hitsOf(line);
|
|
1233
|
+
return {
|
|
1234
|
+
text: line, hitCount: hits.length, tokens: lorebookTokens(hits),
|
|
1235
|
+
hits: hits.map(e => ({ name: String(e.name||""), constant: !!e.constant, keys: Array.isArray(e.keys)?e.keys:[], priority: e.priority })),
|
|
1236
|
+
missed: entries.filter(e => e.enabled !== false && hits.indexOf(e) < 0).map(e => String(e.name||"")),
|
|
1237
|
+
};
|
|
1238
|
+
});
|
|
1239
|
+
const warnings = [];
|
|
1240
|
+
if(entries.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
|
|
1241
|
+
const never = entries.filter(e => e.enabled !== false && !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
|
|
1242
|
+
if(never.length) warnings.push(never.length + " 条非 constant 条目没有 keys,永远触发不了:" + never.map(e=>String(e.name||"")).join("、"));
|
|
1243
|
+
const res = { ok:true, simulated: results.length, results,
|
|
1244
|
+
lorebookTokens: lorebookTokens(entries), scanDepth: book.scan_depth,
|
|
1245
|
+
message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token)").join(";") };
|
|
1246
|
+
if(warnings.length) res.warnings = warnings;
|
|
1247
|
+
return res;
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
// 【静态体检】不需要测试句的那一半检查
|
|
1252
|
+
tool({
|
|
1253
|
+
name: "cc_lint_lorebook",
|
|
1254
|
+
description: "【世界书·静态检查】体检世界书:空 keys、只有维度名的 keys、keys 与 content 无交集、触发词重复、constant 数量、@@decorator 缺失、token 预算占用、**最坏情况单次触发量**(一句话命中多个条目时的 token 爆炸 —— 这一项调大 token_budget 盖不住)。写完世界书后调一次,比肉眼推理 keys 命中率靠谱。",
|
|
1255
|
+
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
1256
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1257
|
+
async execute(args, exec){
|
|
1258
|
+
const cur = getDraft(ctx, exec);
|
|
1259
|
+
const book = cur.data.character_book || {};
|
|
1260
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1261
|
+
const issues = [];
|
|
1262
|
+
const add = (level, message, names) => issues.push({ level, message, entries: names || [] });
|
|
1263
|
+
const isDimKey = k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k));
|
|
1264
|
+
const noKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
|
|
1265
|
+
if(noKeys.length) add("error", noKeys.length + " 条非 constant 条目没有 keys(永远触发不了)", noKeys.map(e=>String(e.name||"")));
|
|
1266
|
+
const dimOnly = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && e.keys.every(isDimKey));
|
|
1267
|
+
if(dimOnly.length) add("error", dimOnly.length + " 条条目的 keys 只有维度名,真实对话不会命中(可用 cc_simulate_trigger 验证)", dimOnly.map(e=>String(e.name||"")));
|
|
1268
|
+
const notInContent = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && !e.keys.some(k => String(e.content||"").indexOf(String(k))>=0));
|
|
1269
|
+
if(notInContent.length) add("warn", notInContent.length + " 条条目的 keys 在自身 content 里找不到(可能误填或内容已改)", notInContent.map(e=>String(e.name||"")));
|
|
1270
|
+
const seen = new Map();
|
|
1271
|
+
entries.forEach(e => (Array.isArray(e.keys)? e.keys : []).forEach(k => { const kk=String(k).toLowerCase(); seen.set(kk, (seen.get(kk)||[]).concat(String(e.name||""))); }));
|
|
1272
|
+
const dup = [...seen.entries()].filter(pair => pair[1].length > 1);
|
|
1273
|
+
if(dup.length) add("warn", dup.length + " 个触发词被多条条目共用(会同时触发,可能互相打架)", dup.map(pair => pair[0]));
|
|
1274
|
+
if(entries.length && !entries.some(e=>e.constant)) add("warn", "没有 constant 常驻条目:世界观基调只在命中触发词时才出现");
|
|
1275
|
+
if(entries.length && entries.every(e=> !String(e.content||"").includes("@@"))) add("warn", "所有条目都没有 @@decorator(@@position/@@depth/@@activate),插入位置全用默认值");
|
|
1276
|
+
const used = lorebookTokens(entries);
|
|
1277
|
+
const budget = Number(book.token_budget||0) || 0;
|
|
1278
|
+
if(budget>0 && used>budget) add("warn", "估算 " + used + " token,已超出 token_budget=" + budget);
|
|
1279
|
+
// N2(0.7.0):总量与「单次触发」是两个独立风险,只报总量会让「调大预算」看起来像修好了。
|
|
1280
|
+
const worst = worstCaseTrigger(entries);
|
|
1281
|
+
const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries, overBudget: budget > 0 && worst.tokens > budget };
|
|
1282
|
+
if(worstCase.overBudget){
|
|
1283
|
+
add("warn", "最坏情况单次触发约 " + worst.tokens + " token(含常驻 " + worst.constantTokens + ",预算 " + budget + "):「" + worst.entries.join(" + ") + "」一句话就吃光整份预算(调大 token_budget 盖不住这条)。", worst.entries);
|
|
1284
|
+
}
|
|
1285
|
+
if(!String(book.name||"").trim()) add("warn", "世界书没有名字(character_book.name 为空,导出后是一本无名的书)");
|
|
1286
|
+
const errorCount = issues.filter(i => i.level === "error").length;
|
|
1287
|
+
return { ok:true,
|
|
1288
|
+
entries: entries.length, manualEntries: entries.filter(e => !isAutoEntry(e)).length,
|
|
1289
|
+
constantEntries: entries.filter(e => e.constant).length,
|
|
1290
|
+
lorebookTokens: used, tokenBudget: budget, worstCase,
|
|
1291
|
+
issues, errorCount, warningCount: issues.length - errorCount,
|
|
1292
|
+
message: issues.length
|
|
1293
|
+
? "体检发现 " + issues.length + " 个问题(" + errorCount + " 个会真的不工作):" + issues.map(i=>i.message).join(";")
|
|
1294
|
+
: "世界书体检通过:没有发现结构性问题。" };
|
|
622
1295
|
}
|
|
623
1296
|
});
|
|
624
1297
|
|
|
625
|
-
|
|
1298
|
+
// 【导出干跑】补上「五维到底导不导出」这个此前没人能回答的问题
|
|
1299
|
+
tool({
|
|
1300
|
+
name: "cc_preview_export",
|
|
1301
|
+
description: "【导出·干跑】不落盘地预估导出结果:JSON 体积、各字段估算 token、最终会导出的字段清单(并回答「五维会不会一起导出」)、五维与世界书条目的重复内容量。导出前调一次,心里有数。",
|
|
1302
|
+
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
1303
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1304
|
+
async execute(args, exec){
|
|
1305
|
+
const cur = getDraft(ctx, exec);
|
|
1306
|
+
const d = cur.data;
|
|
1307
|
+
const book = d.character_book || {};
|
|
1308
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1309
|
+
const json = JSON.stringify(cur, null, 2);
|
|
1310
|
+
const w = (d.extensions && d.extensions.cc_world) || {};
|
|
1311
|
+
// 五维(extensions.cc_world)会被一起导出,且与 autoLorebook 生成的条目内容逐字重复 —— 量化给出
|
|
1312
|
+
let dupChars = 0;
|
|
1313
|
+
entries.forEach(e => { const c = String(e.content||""); WORLD_DIMS.forEach(k => { const t = String(w[k]||""); if(t && c.indexOf(t) >= 0) dupChars += t.length; }); });
|
|
1314
|
+
const exportedFields = ["name","nickname","tags","creator","character_version","description","personality","scenario","system_prompt","post_history_instructions","first_mes","alternate_greetings","group_only_greetings","mes_example","creator_notes","character_book","assets","creation_date","modification_date"];
|
|
1315
|
+
const bytes = Buffer.byteLength(json, "utf8");
|
|
1316
|
+
return { ok:true,
|
|
1317
|
+
bytes, tokens: estimateTokens(json), lorebookTokens: lorebookTokens(entries),
|
|
1318
|
+
exportedFields, extensionsExported: Object.keys(d.extensions || {}),
|
|
1319
|
+
worldDuplicatedChars: dupChars,
|
|
1320
|
+
loreCount: entries.length, manualLoreCount: entries.filter(e => !isAutoEntry(e)).length,
|
|
1321
|
+
message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中世界书 " + lorebookTokens(entries) + " token;五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
tool({
|
|
626
1326
|
name: "cc_validate",
|
|
627
1327
|
description: "【工作流第6步·收口必调】校验 CC 角色卡(SPEC_V3 + 角色四件套/五维≥3/世界书≥5/问候语齐)。valid=false 按 errors 与用户讨论继续补;valid=true 时总结成果并邀请用户在工坊微调后“导出 JSON”或“★ 保存到侧边栏”。全程强调共创:多问用户、多给草案供挑选。",
|
|
628
1328
|
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
@@ -631,27 +1331,66 @@ function apply(ctx){
|
|
|
631
1331
|
const d = getDraft(ctx, exec);
|
|
632
1332
|
const k = keyFor(ctx, exec);
|
|
633
1333
|
const st = draftStatusFor(k, d);
|
|
634
|
-
const fresh = freshNotice(draftMeta(k));
|
|
635
|
-
const rec = recoveredNotice(k, d);
|
|
636
|
-
const notice = [rec, fresh].filter(Boolean).join(" ");
|
|
637
1334
|
const wf = workflowStatus(d);
|
|
1335
|
+
const book = d.data.character_book || {};
|
|
1336
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
638
1337
|
const errors=[], warnings=[];
|
|
1338
|
+
// —— 结构(不齐 = 失败)——
|
|
639
1339
|
if(!String(d.data.name||"").trim()) errors.push("缺 data.name(角色名)");
|
|
640
1340
|
if(!String(d.data.description||"").trim()) errors.push("缺 data.description");
|
|
641
1341
|
if(!String(d.data.personality||"").trim()) errors.push("缺 data.personality");
|
|
642
|
-
if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充");
|
|
643
1342
|
if(!Array.isArray(d.data.group_only_greetings) || d.data.group_only_greetings.length<1) errors.push("缺 group_only_greetings(至少 1 条必填)");
|
|
644
|
-
if(
|
|
1343
|
+
if(wf.state.world !== "ok") errors.push("五维世界观未齐:需 timeline/factions/geo/power/daily 至少 3 项(当前 "+wf.worldFilled+"/5),请调 cc_patch_world");
|
|
1344
|
+
if(wf.state.lore !== "ok"){
|
|
1345
|
+
errors.push(wf.manualLoreCount === 0
|
|
1346
|
+
? "世界书只有自动条目("+wf.loreCount+" 条,全部由 autoLorebook 生成),未完成共创:请调 cc_add_lorebook_entries 至少补 1 条(总≥5 条)"
|
|
1347
|
+
: "世界书未齐:需至少 5 条(当前 "+wf.loreCount+" 条),请调 cc_add_lorebook_entries 补到 5 条");
|
|
1348
|
+
}
|
|
1349
|
+
// —— 质量(P1-5:此前 valid=true 只代表「四件套非空」,给用户「卡没问题了」的错觉)——
|
|
1350
|
+
if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充(缺失时模型只能靠 description 猜场景)");
|
|
1351
|
+
if(!Array.isArray(d.data.alternate_greetings) || d.data.alternate_greetings.length<2) warnings.push("alternate_greetings 建议至少 2 条(当前 "+((d.data.alternate_greetings||[]).length)+" 条)");
|
|
645
1352
|
if(!String(d.data.first_mes||"").trim()) warnings.push("first_mes 为空");
|
|
646
|
-
if(!
|
|
647
|
-
if(!
|
|
648
|
-
if(
|
|
649
|
-
if(
|
|
1353
|
+
if(!String(d.data.mes_example||"").trim()) warnings.push("mes_example 为空,建议写几段示例对话");
|
|
1354
|
+
if(!String(d.data.creator||"").trim()) warnings.push("data.creator 为空(导出后别人看不到作者)—— 用 cc_patch_character 的 creator 参数写入,或让用户在工坊「角色基础」里手动填。");
|
|
1355
|
+
if(!String(book.name||"").trim()) warnings.push("世界书没有名字(character_book.name 为空),导出后是一本无名的书;可用 cc_patch_book_meta 命名");
|
|
1356
|
+
if(!String(book.description||"").trim()) warnings.push("character_book.description 为空,建议一句话说明这本世界书的作用");
|
|
1357
|
+
if(entries.length>0 && !entries.some(e=>e.constant)) warnings.push("世界书建议至少 1 条 constant 常驻条目(否则全靠触发词命中)");
|
|
1358
|
+
if(entries.length>0 && entries.every(e=> !String(e.content||"").includes("@@"))) warnings.push("世界书条目建议带 @@decorator(@@position/@@depth/@@activate)");
|
|
1359
|
+
const deadKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length===0));
|
|
1360
|
+
if(deadKeys.length) warnings.push(deadKeys.length + " 条非 constant 条目没有 keys(永远触发不了):" + deadKeys.map(e=>String(e.name||"未命名")).join("、"));
|
|
1361
|
+
const dimOnly = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && e.keys.every(k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k))));
|
|
1362
|
+
if(dimOnly.length) warnings.push(dimOnly.length + " 条条目的 keys 只有维度名(" + dimOnly.map(e=>String(e.name||"未命名")).join("、") + "),真实对话不会命中;可用 cc_simulate_trigger 拿一句用户会说的话验证,建议改成实体名");
|
|
1363
|
+
const keyless = entries.filter(e => { const keys=Array.isArray(e.keys)?e.keys:[]; return keys.length>0 && !e.constant && !keys.some(k => String(e.content||"").indexOf(String(k))>=0); });
|
|
1364
|
+
if(keyless.length) warnings.push(keyless.length + " 条条目的 keys 在自身 content 里找不到(可能是误填):" + keyless.map(e=>String(e.name||"未命名")).join("、"));
|
|
1365
|
+
const used = lorebookTokens(entries);
|
|
1366
|
+
const budget = Number(book.token_budget||0) || 0;
|
|
1367
|
+
if(budget>0 && used > budget) warnings.push("世界书内容估算约 "+used+" token,已超出 token_budget="+budget+",建议调大预算或精简条目(cc_patch_book_meta)");
|
|
1368
|
+
// —— 终态:校验通过后 next 不再指向 cc_validate(P0-3:那正是弱模型死循环的诱因)——
|
|
650
1369
|
const valid = errors.length===0;
|
|
651
|
-
const
|
|
652
|
-
|
|
1370
|
+
const next = valid
|
|
1371
|
+
? "校验通过 ✓ 工作流已完成(step 7/7)。请在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘到 ~/.dsh/cc-library;还想打磨就直接说改哪里。"
|
|
1372
|
+
: "校验未通过,按 errors 逐项补齐(严禁跳步),第一项:" + errors[0];
|
|
1373
|
+
// R1(0.6.0):终态的 `hint` 必须与 `next` 同源 —— 否则会出现「next 说工作流已完成、hint 说下一步必须去校验」
|
|
1374
|
+
// 这种自相矛盾(弱模型会读信息最全的 hint,于是反复调 cc_validate)。半修的状态机比没修更危险。
|
|
1375
|
+
const wfDone = Object.assign({}, wf, {
|
|
1376
|
+
step: valid ? 7 : wf.step,
|
|
1377
|
+
done: valid,
|
|
1378
|
+
readyToValidate: !valid && wf.readyToValidate,
|
|
1379
|
+
next,
|
|
1380
|
+
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid),
|
|
1381
|
+
});
|
|
1382
|
+
const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
|
|
1383
|
+
return { ok:true, valid, done: valid, errors, warnings: warningsAll,
|
|
1384
|
+
loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
|
|
1385
|
+
lorebookTokens: used, tokenBudget: budget,
|
|
1386
|
+
draftStatus: st, workflow: wfDone,
|
|
1387
|
+
// R3(0.6.0):message 不再拼接 next —— 那会让「校验通过 ✓」连说两遍(两个来源拼接未去重)。
|
|
1388
|
+
// 下一步只保留 workflow.next 一处。
|
|
1389
|
+
message: valid
|
|
1390
|
+
? "校验通过 ✓ 工作流完成(世界书 " + wf.loreCount + " 条 / 估算 " + used + " token)。"
|
|
1391
|
+
: "校验未通过:" + errors.length + " 项待补。" };
|
|
653
1392
|
}
|
|
654
1393
|
});
|
|
655
1394
|
}
|
|
656
1395
|
|
|
657
|
-
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots };
|
|
1396
|
+
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync };
|